diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55538e5..8938835 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,9 +4,9 @@ name: CI on: push: branches: - - main + - main tags: - - '*' + - "*" pull_request: jobs: @@ -17,20 +17,25 @@ jobs: fail-fast: false matrix: include: - - {name: Windows, python: '3.11', os: windows-2019} - - {name: Mac, python: '3.11', os: macos-latest} - - {name: 'Ubuntu', python: '3.11', os: ubuntu-latest} - - {name: '3.12', python: '3.12', os: ubuntu-latest} - - {name: '3.11', python: '3.11', os: ubuntu-latest} - - {name: '3.9', python: '3.9', os: ubuntu-latest} - - {name: '3.8', python: '3.8', os: ubuntu-latest} + - { name: Windows, python: "3.11", os: windows-2019 } + - { name: Mac, python: "3.11", os: macos-latest } + - { name: "Ubuntu", python: "3.11", os: ubuntu-latest } + - { name: "3.12", python: "3.12", os: ubuntu-latest } + - { name: "3.11", python: "3.11", os: ubuntu-latest } + - { name: "3.9", python: "3.9", os: ubuntu-latest } + - { name: "3.8", python: "3.8", os: ubuntu-latest } steps: - uses: actions/checkout@v3 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} + - name: Install dependencies run: | python -m pip install --upgrade pip @@ -55,74 +60,3 @@ jobs: flags: unittests name: ${{ matrix.python }} fail_ci_if_error: false - - build_wheels: - needs: [tests] - if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' - name: ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-2019, macos-latest] - - steps: - - uses: actions/checkout@v3 - - - name: Set up QEMU - if: runner.os == 'Linux' - uses: docker/setup-qemu-action@v2 - with: - platforms: arm64 - - - uses: pypa/cibuildwheel@v2.16.5 - env: - # https://github.com/mapbox/rio-color/blob/0ab59ad8e2db99ad1d0c8bd8c2e4cf8d0c3114cf/appveyor.yml#L3 - CIBW_SKIP: 'cp27* cp35* pp* *-win32 *-manylinux_i686 *musllinux*' - CIBW_ARCHS_MACOS: x86_64 arm64 universal2 - CIBW_ARCHS_LINUX: auto aarch64 - # CIBW_TEST_REQUIRES: pytest colormath==2.0.2 - # CIBW_TEST_COMMAND: python -m pytest {project}/tests - - - uses: actions/upload-artifact@v3 - with: - path: ./wheelhouse - - build_sdist: - needs: [tests] - if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' - name: Build source distribution - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - uses: actions/setup-python@v4 - name: Install Python - with: - python-version: '3.10' - - - name: Install dependencies - run: | - python -m pip install numpy Cython - - - name: Build sdist - run: python setup.py sdist - - - uses: actions/upload-artifact@v3 - with: - path: dist/*.tar.gz - - upload_pypi: - needs: [build_wheels, build_sdist] - runs-on: ubuntu-latest - steps: - - uses: actions/download-artifact@v2 - with: - name: artifact - path: dist - - - uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: ${{ secrets.PYPI_USERNAME }} - password: ${{ secrets.PYPI_PASSWORD }} - # To test: repository_url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/python-wheels.yml b/.github/workflows/python-wheels.yml new file mode 100644 index 0000000..d67b9b4 --- /dev/null +++ b/.github/workflows/python-wheels.yml @@ -0,0 +1,171 @@ +# This file is autogenerated by maturin v1.6.0 +# To update, run +# +# maturin generate-ci github +# +name: Build Wheels + +on: + push: + branches: + - main + - master + tags: + - "*" + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + linux: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: ubuntu-latest + target: x86_64 + - runner: ubuntu-latest + target: x86 + - runner: ubuntu-latest + target: aarch64 + - runner: ubuntu-latest + target: armv7 + - runner: ubuntu-latest + target: s390x + - runner: ubuntu-latest + target: ppc64le + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist + sccache: "true" + manylinux: auto + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-linux-${{ matrix.platform.target }} + path: dist + + musllinux: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: ubuntu-latest + target: x86_64 + - runner: ubuntu-latest + target: x86 + - runner: ubuntu-latest + target: aarch64 + - runner: ubuntu-latest + target: armv7 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist + sccache: "true" + manylinux: musllinux_1_2 + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-musllinux-${{ matrix.platform.target }} + path: dist + + windows: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: windows-latest + target: x64 + - runner: windows-latest + target: x86 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + architecture: ${{ matrix.platform.target }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist + sccache: "true" + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.platform.target }} + path: dist + + macos: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: macos-12 + target: x86_64 + - runner: macos-14 + target: aarch64 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist + sccache: "true" + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.platform.target }} + path: dist + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: dist + + upload_pypi: + name: Release + needs: [linux, musllinux, windows, macos, sdist] + runs-on: ubuntu-latest + if: "startsWith(github.ref, 'refs/tags/')" + steps: + - uses: actions/download-artifact@v4 + with: + name: artifact + path: dist + + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: ${{ secrets.PYPI_USERNAME }} + password: ${{ secrets.PYPI_PASSWORD }} + # To test: repository_url: https://test.pypi.org/legacy/ diff --git a/.gitignore b/.gitignore index 6c62113..ec5c93e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +/target + # Generated C files from cython *.c diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..06552f7 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,384 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "color-operations" +version = "0.2.0" +dependencies = [ + "approx", + "lazy_static", + "numpy", + "pyo3", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" + +[[package]] +name = "lock_api" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "matrixmultiply" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "090126dc04f95dc0d1c1c91f61bdd474b3930ca064c1edc8a849da2c6cbe1e77" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "memoffset" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" +dependencies = [ + "autocfg", +] + +[[package]] +name = "ndarray" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "rawpointer", +] + +[[package]] +name = "num-complex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e0d21255c828d6f128a1e41534206671e8c3ea0c62f32291e808dc82cff17d" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + +[[package]] +name = "numpy" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec170733ca37175f5d75a5bea5911d6ff45d2cd52849ce98b685394e4f2f37f4" +dependencies = [ + "libc", + "ndarray", + "num-complex", + "num-integer", + "num-traits", + "pyo3", + "rustc-hash", +] + +[[package]] +name = "once_cell" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "portable-atomic" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7170ef9988bc169ba16dd36a7fa041e5c4cbeb6a35b76d4c03daded371eae7c0" + +[[package]] +name = "proc-macro2" +version = "1.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e00b96a521718e08e03b1a622f01c8a8deb50719335de3f60b3b3950f069d8" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "parking_lot", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883df5835fafdad87c0d888b266c8ec0f4c9ca48a5bed6bbb592e8dedee1b50" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01be5843dc60b916ab4dad1dca6d20b9b4e6ddc8e15f50c47fe6d85f1fb97403" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77b34069fc0682e11b31dbd10321cbf94808394c56fd996796ce45217dfac53c" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08260721f32db5e1a5beae69a55553f56b99bd0e1c3e6e0a5e8851a9d0f5a85c" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fe8a65d69dd0808184ebb5f836ab526bb259db23c657efa38711b1072ee47f0" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "smallvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" + +[[package]] +name = "syn" +version = "2.0.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239814284fd6f1a4ffe4ca893952cdd93c224b6a1571c9a9eadd670295c0c9e2" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.12.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8e77cb757a61f51b947ec4a7e3646efd825b73561db1c232a8ccb639e611a0" + +[[package]] +name = "unicode-ident" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" + +[[package]] +name = "unindent" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" + +[[package]] +name = "windows-targets" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..82f0d27 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "color-operations" +version = "0.2.0" +edition = "2021" +description = "Color-oriented image operations. A port of rio-color." +readme = "README.md" +repository = "https://github.com/vincentsarago/color-operations" +license = "MIT" +keywords = ["python"] +categories = [] +rust-version = "1.75" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +name = "color_operations" +crate-type = ["cdylib"] + +[dependencies] +numpy = "0.21.0" +pyo3 = { version = "0.21.0", features = ["abi3-py38"] } +lazy_static = "1.4" + +[dev-dependencies] +approx = "0.5.1" diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 30ef378..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,10 +0,0 @@ -include CHANGES.md LICENSE README.md pyproject.toml setup.cfg setup.py -include color_operations/*.pyx -include color_operations/*.pxd -exclude color_operations/*.c -exclude color_operations/*.cpp - -include color_operations/*.py -recursive-include tests *.py - -exclude MANIFEST.in diff --git a/color_operations/colorspace.pyx b/color_operations_bak/colorspace.pyx similarity index 100% rename from color_operations/colorspace.pyx rename to color_operations_bak/colorspace.pyx diff --git a/pyproject.toml b/pyproject.toml index 1d0b20d..bcf2691 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,31 +1,51 @@ [build-system] -requires = [ - "setuptools", - "wheel", - "cython>=0.29.32", - "numpy==2.0.0; python_version >= '3.9'", - "oldest-supported-numpy; python_version < '3.9'" +requires = ["maturin>=1.6,<2.0"] +build-backend = "maturin" + +[project] +name = "color-operations" +requires-python = ">=3.8" +dependencies = ["numpy"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Rust", + "Topic :: Multimedia :: Graphics :: Graphics Conversion", + "Topic :: Scientific/Engineering :: GIS", ] +authors = [{ name = "Vincent Sarago", email = "vincent@developmentseed.com" }] +# This version overwrites the version in Cargo.toml, so ideally we wouldn't have +# a version identifier here, but pre-commit fails without it. +version = "0.2.0" + +[project.optional-dependencies] +test = ["pytest", "colormath==2.0.2", "pytest-cov"] +dev = ["pre-commit"] + +[tool.maturin] +features = ["pyo3/extension-module"] +module-name = "color_operations._rust" +python-source = "python" [tool.coverage.run] branch = true parallel = true [tool.coverage.report] -exclude_lines = [ - "no cov", - "if __name__ == .__main__.:", - "if TYPE_CHECKING:", -] +exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"] [tool.isort] profile = "black" -known_first_party = ["tipg"] +known_first_party = ["tipg", "color_operations"] known_third_party = ["geojson_pydantic", "buildpg", "pydantic"] -forced_separate = [ - "fastapi", - "starlette", -] +forced_separate = ["fastapi", "starlette"] default_section = "THIRDPARTY" [tool.mypy] @@ -36,17 +56,17 @@ line-length = 90 [tool.ruff.lint] select = [ - "D1", # pydocstyle errors - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # flake8 - "C", # flake8-comprehensions - "B", # flake8-bugbear + "D1", # pydocstyle errors + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # flake8 + "C", # flake8-comprehensions + "B", # flake8-bugbear ] ignore = [ - "E501", # line too long, handled by black - "B008", # do not perform function calls in argument defaults - "B905", # ignore zip() without an explicit strict= parameter, only support with python >3.10 + "E501", # line too long, handled by black + "B008", # do not perform function calls in argument defaults + "B905", # ignore zip() without an explicit strict= parameter, only support with python >3.10 ] [tool.ruff.lint.mccabe] diff --git a/color_operations/__init__.py b/python/color_operations/__init__.py similarity index 100% rename from color_operations/__init__.py rename to python/color_operations/__init__.py diff --git a/python/color_operations/_enums.py b/python/color_operations/_enums.py new file mode 100644 index 0000000..eff37e3 --- /dev/null +++ b/python/color_operations/_enums.py @@ -0,0 +1,9 @@ +from enum import IntEnum + + +class ColorSpace(IntEnum): + rgb = 0 + xyz = 1 + lab = 2 + lch = 3 + luv = 4 diff --git a/python/color_operations/_rust.pyi b/python/color_operations/_rust.pyi new file mode 100644 index 0000000..885cc20 --- /dev/null +++ b/python/color_operations/_rust.pyi @@ -0,0 +1,16 @@ +from __future__ import annotations + +from typing import Tuple + +import numpy as np +from numpy.typing import NDArray + +from ._enums import ColorSpace + +def convert_arr( + arr: NDArray[np.float64], src: ColorSpace, dst: ColorSpace +) -> NDArray[np.float64]: ... +def convert( + one: float, two: float, three: float, src: ColorSpace, dst: ColorSpace +) -> Tuple[float, float, float]: ... +def saturate_rgb(arr: NDArray[np.float64], satmult: float) -> NDArray[np.float64]: ... diff --git a/python/color_operations/colorspace.py b/python/color_operations/colorspace.py new file mode 100644 index 0000000..09bfb59 --- /dev/null +++ b/python/color_operations/colorspace.py @@ -0,0 +1,4 @@ +"""Maintained for backwards compatibility""" + +from ._enums import ColorSpace # noqa +from ._rust import convert, convert_arr, saturate_rgb # noqa diff --git a/color_operations/operations.py b/python/color_operations/operations.py similarity index 100% rename from color_operations/operations.py rename to python/color_operations/operations.py diff --git a/python/color_operations/py.typed b/python/color_operations/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/color_operations/utils.py b/python/color_operations/utils.py similarity index 100% rename from color_operations/utils.py rename to python/color_operations/utils.py diff --git a/rust/.github/workflows/CI.yml b/rust/.github/workflows/CI.yml new file mode 100644 index 0000000..8268eaf --- /dev/null +++ b/rust/.github/workflows/CI.yml @@ -0,0 +1,120 @@ +# This file is autogenerated by maturin v1.1.0 +# To update, run +# +# maturin generate-ci github +# +name: CI + +on: + push: + branches: + - main + - master + tags: + - '*' + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + linux: + runs-on: ubuntu-latest + strategy: + matrix: + target: [x86_64, x86, aarch64, armv7, s390x, ppc64le] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + manylinux: auto + - name: Upload wheels + uses: actions/upload-artifact@v3 + with: + name: wheels + path: dist + + windows: + runs-on: windows-latest + strategy: + matrix: + target: [x64, x86] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.10' + architecture: ${{ matrix.target }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v3 + with: + name: wheels + path: dist + + macos: + runs-on: macos-latest + strategy: + matrix: + target: [x86_64, aarch64] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v3 + with: + name: wheels + path: dist + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + - name: Upload sdist + uses: actions/upload-artifact@v3 + with: + name: wheels + path: dist + + release: + name: Release + runs-on: ubuntu-latest + if: "startsWith(github.ref, 'refs/tags/')" + needs: [linux, windows, macos, sdist] + steps: + - uses: actions/download-artifact@v3 + with: + name: wheels + - name: Publish to PyPI + uses: PyO3/maturin-action@v1 + env: + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + with: + command: upload + args: --skip-existing * diff --git a/setup.py b/setup.py deleted file mode 100644 index 65cc93b..0000000 --- a/setup.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Setup script.""" - -# The MIT License (MIT) - -# Copyright (c) 2015 Mapbox - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -import sys - -from setuptools import find_packages, setup -from setuptools.extension import Extension - -# Use Cython if available. -try: - from Cython.Build import cythonize -except ImportError: - cythonize = None - -include_dirs = [] -try: - import numpy - - include_dirs.append(numpy.get_include()) -except ImportError: - print("Numpy and its headers are required to run setup(). Exiting.") - sys.exit(1) - - -with open("README.md") as f: - readme = f.read() - - -if cythonize and "clean" not in sys.argv: - ext_modules = cythonize( - [ - Extension( - "color_operations.colorspace", - ["color_operations/colorspace.pyx"], - extra_compile_args=["-O2"], - ) - ] - ) -else: - ext_modules = [ - Extension("color_operations.colorspace", ["color_operations/colorspace.c"]) - ] - - -setup( - name="color-operations", - description="Apply basic color-oriented image operations.", - long_description=readme, - long_description_content_type="text/markdown", - python_requires=">=3.8", - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "Intended Audience :: Science/Research", - "License :: OSI Approved :: MIT License", - "Programming Language :: Cython", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Topic :: Multimedia :: Graphics :: Graphics Conversion", - "Topic :: Scientific/Engineering :: GIS", - ], - keywords="", - author="Vincent Sarago", - author_email="vincent@developmentseed.com", - url="https://github.com/vincentsarago/color-operations", - license="MIT", - packages=find_packages(exclude=["tests"]), - include_package_data=True, - zip_safe=False, - install_requires=["numpy"], - ext_modules=ext_modules, - include_dirs=include_dirs, - extras_require={ - "test": [ - "pytest", - "colormath==2.0.2", - "pytest-cov", - ], - "dev": [ - "pre-commit", - ], - }, -) diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..6e352f9 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,112 @@ +use numpy::ndarray::Array3; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::colorspace::{ColorSpace, LCHColor, RGBColor}; +use numpy::{IntoPyArray, PyArray3, PyReadonlyArray3}; + +#[pyfunction] +pub fn convert_arr<'py>( + py: Python<'py>, + arr: PyReadonlyArray3, + src: ColorSpace, + dst: ColorSpace, +) -> PyResult>> { + let arr = arr.as_array(); + let shape = arr.shape(); + if shape[0] != 3 { + return Err(PyValueError::new_err( + "The 0th dimension must contain 3 bands", + )); + } + + let dim_i = shape[1]; + let dim_j = shape[2]; + + let out = py.allow_threads(|| { + let mut out = Array3::::zeros((3, dim_i, dim_j)); + for i in 0..dim_i { + for j in 0..dim_j { + let c1 = arr[(0, i, j)]; + let c2 = arr[(1, i, j)]; + let c3 = arr[(2, i, j)]; + + let converted = crate::colorspace::convert((c1, c2, c3), src, dst); + + out[(0, i, j)] = converted.0; + out[(1, i, j)] = converted.1; + out[(2, i, j)] = converted.2; + } + } + + out + }); + + Ok(out.into_pyarray_bound(py)) +} + +#[pyfunction] +pub fn convert( + one: f64, + two: f64, + three: f64, + src: ColorSpace, + dst: ColorSpace, +) -> (f64, f64, f64) { + crate::colorspace::convert((one, two, three), src, dst) +} + +/// Convert array of RGB -> LCH, adjust saturation, back to RGB +/// +/// A special case of convert_arr with hardcoded color spaces and a bit of data manipulation inside +/// the loop. +#[pyfunction] +pub fn saturate_rgb<'py>( + py: Python<'py>, + arr: PyReadonlyArray3, + satmult: f64, +) -> PyResult>> { + let arr = arr.as_array(); + let shape = arr.shape(); + if shape[0] != 3 { + return Err(PyValueError::new_err( + "The 0th dimension must contain 3 bands", + )); + } + + let dim_i = shape[1]; + let dim_j = shape[2]; + + let out = py.allow_threads(|| { + let mut out = Array3::::zeros((3, dim_i, dim_j)); + for i in 0..dim_i { + for j in 0..dim_j { + let r = arr[(0, i, j)]; + let g = arr[(1, i, j)]; + let b = arr[(2, i, j)]; + + let rgb = RGBColor { r, g, b }; + let mut c_lch: LCHColor = rgb.into(); + c_lch.c *= satmult; + let c_rgb: RGBColor = c_lch.into(); + + out[(0, i, j)] = c_rgb.r; + out[(1, i, j)] = c_rgb.g; + out[(2, i, j)] = c_rgb.b; + } + } + + out + }); + + Ok(out.into_pyarray_bound(py)) +} + +/// A Python module implemented in Rust. +#[pymodule] +pub fn _rust(_py: Python, m: &Bound) -> PyResult<()> { + m.add_function(wrap_pyfunction!(saturate_rgb, m)?)?; + m.add_function(wrap_pyfunction!(convert, m)?)?; + m.add_function(wrap_pyfunction!(convert_arr, m)?)?; + Ok(()) +} diff --git a/src/colorspace.rs b/src/colorspace.rs new file mode 100644 index 0000000..0f23209 --- /dev/null +++ b/src/colorspace.rs @@ -0,0 +1,573 @@ +use lazy_static::lazy_static; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +// Constants +const BINTERCEPT: f64 = 4.0 / 29.; // 0.137931 +const DELTA: f64 = 6.0 / 29.; // 0.206896 + +// Can't use powf directly in const +// https://users.rust-lang.org/t/why-is-f64-powf-not-a-const-fn/31093/5 +lazy_static! { + pub static ref T0: f64 = DELTA.powi(3) ; // 0.008856 + pub static ref ALPHA: f64 = (DELTA.powi(-2)) / 3.; // 7.787037 + pub static ref KAPPA: f64 = (29.0f64 / 3.).powi(3); // 903.3 +} + +const THIRD: f64 = 1.0 / 3.; +const GAMMA: f64 = 2.2; +const XN: f64 = 0.95047; +const YN: f64 = 1.0; +const ZN: f64 = 1.08883; +const DENOM_N: f64 = XN + (15. * YN) + (3. * ZN); +const UPRIME_N: f64 = (4. * XN) / DENOM_N; +const VPRIME_N: f64 = (9. * YN) / DENOM_N; + +/// Compile time option to use +/// sRGB companding (default, True) or simplified gamma (False) +/// sRGB companding is slightly slower but is more accurate at +/// the extreme ends of scale +/// Unit tests tuned to sRGB companding, change with caution +const SRGB_COMPAND: bool = true; + +/// A color with three values +#[derive(Clone, Copy, Debug)] +#[allow(clippy::upper_case_acronyms)] +pub enum Color { + RGB(RGBColor), + XYZ(XYZColor), + LAB(LABColor), + LCH(LCHColor), + LUV(LUVColor), +} + +impl Color { + pub fn new_rgb(r: f64, g: f64, b: f64) -> Self { + Self::RGB((r, g, b).into()) + } + + pub fn new_xyz(x: f64, y: f64, z: f64) -> Self { + Self::XYZ((x, y, z).into()) + } + + pub fn new_lab(l: f64, a: f64, b: f64) -> Self { + Self::LAB((l, a, b).into()) + } + + pub fn new_lch(l: f64, c: f64, h: f64) -> Self { + Self::LCH((l, c, h).into()) + } + + pub fn new_luv(l: f64, u: f64, v: f64) -> Self { + Self::LUV((l, u, v).into()) + } +} + +impl From for (f64, f64, f64) { + fn from(value: Color) -> Self { + match value { + Color::RGB(c) => c.into(), + Color::XYZ(c) => c.into(), + Color::LAB(c) => c.into(), + Color::LCH(c) => c.into(), + Color::LUV(c) => c.into(), + } + } +} + +/// A Color defined by Red, Green, Blue +#[derive(Clone, Copy, Debug)] +pub struct RGBColor { + pub r: f64, + pub g: f64, + pub b: f64, +} + +impl From<(f64, f64, f64)> for RGBColor { + fn from((r, g, b): (f64, f64, f64)) -> Self { + RGBColor { r, g, b } + } +} + +impl From for (f64, f64, f64) { + fn from(value: RGBColor) -> Self { + (value.r, value.g, value.b) + } +} + +/// A Color defined by X, Y, Z +#[derive(Clone, Copy, Debug)] +pub struct XYZColor { + pub x: f64, + pub y: f64, + pub z: f64, +} + +impl From<(f64, f64, f64)> for XYZColor { + fn from((x, y, z): (f64, f64, f64)) -> Self { + XYZColor { x, y, z } + } +} + +impl From for (f64, f64, f64) { + fn from(value: XYZColor) -> Self { + (value.x, value.y, value.z) + } +} + +/// A Color defined by LAB +#[derive(Clone, Copy, Debug)] +pub struct LABColor { + pub l: f64, + pub a: f64, + pub b: f64, +} + +impl From<(f64, f64, f64)> for LABColor { + fn from((l, a, b): (f64, f64, f64)) -> Self { + LABColor { l, a, b } + } +} + +impl From for (f64, f64, f64) { + fn from(value: LABColor) -> Self { + (value.l, value.a, value.b) + } +} + +/// A Color defined by LCH +#[derive(Clone, Copy, Debug)] +pub struct LCHColor { + pub l: f64, + pub c: f64, + pub h: f64, +} + +impl From<(f64, f64, f64)> for LCHColor { + fn from((l, c, h): (f64, f64, f64)) -> Self { + LCHColor { l, c, h } + } +} + +impl From for (f64, f64, f64) { + fn from(value: LCHColor) -> Self { + (value.l, value.c, value.h) + } +} + +/// A Color defined by LUV +#[derive(Clone, Copy, Debug)] +pub struct LUVColor { + pub l: f64, + pub u: f64, + pub v: f64, +} + +impl From<(f64, f64, f64)> for LUVColor { + fn from((l, u, v): (f64, f64, f64)) -> Self { + LUVColor { l, u, v } + } +} + +impl From for (f64, f64, f64) { + fn from(value: LUVColor) -> Self { + (value.l, value.u, value.v) + } +} + +// Color space transformations + +impl From for LABColor { + fn from(value: RGBColor) -> Self { + let c: XYZColor = value.into(); + c.into() + } +} + +impl From for LCHColor { + fn from(value: RGBColor) -> Self { + let c1: XYZColor = value.into(); + let c2: LABColor = c1.into(); + c2.into() + } +} + +impl From for XYZColor { + #[inline(always)] + fn from(value: RGBColor) -> Self { + let r = value.r; + let g = value.g; + let b = value.b; + + // Convert RGB to linear scale + let (rl, gl, bl) = if SRGB_COMPAND { + let rl = if r <= 0.04045 { + r / 12.92 + } else { + ((r + 0.055) / 1.055).powf(2.4) + }; + + let gl = if g <= 0.04045 { + g / 12.92 + } else { + ((g + 0.055) / 1.055).powf(2.4) + }; + + let bl = if b <= 0.04045 { + b / 12.92 + } else { + ((b + 0.055) / 1.055).powf(2.4) + }; + + (rl, gl, bl) + } else { + // Use "simplified sRGB" + (r.powf(GAMMA), g.powf(GAMMA), b.powf(GAMMA)) + }; + + // matrix mult for srgb->xyz, + // includes adjustment for reference white + let x = ((rl * 0.4124564) + (gl * 0.3575761) + (bl * 0.1804375)) / XN; + let y = (rl * 0.2126729) + (gl * 0.7151522) + (bl * 0.0721750); + let z = ((rl * 0.0193339) + (gl * 0.1191920) + (bl * 0.9503041)) / ZN; + + Self { x, y, z } + } +} + +impl From for LUVColor { + fn from(value: RGBColor) -> Self { + let c1: XYZColor = value.into(); + c1.into() + } +} + +impl From for LABColor { + #[inline(always)] + fn from(value: XYZColor) -> Self { + let x = value.x; + let y = value.y; + let z = value.z; + + // convert XYZ to LAB colorspace + + let fx = if x > *T0 { + x.powf(THIRD) + } else { + (*ALPHA * x) + BINTERCEPT + }; + + let fy = if y > *T0 { + y.powf(THIRD) + } else { + (*ALPHA * y) + BINTERCEPT + }; + + let fz = if z > *T0 { + z.powf(THIRD) + } else { + (*ALPHA * z) + BINTERCEPT + }; + + let l = (116. * fy) - 16.; + let a = 500. * (fx - fy); + let b = 200. * (fy - fz); + + Self { l, a, b } + } +} + +impl From for LCHColor { + fn from(value: XYZColor) -> Self { + let c1: LABColor = value.into(); + c1.into() + } +} + +impl From for RGBColor { + #[inline(always)] + fn from(value: XYZColor) -> Self { + let x = value.x; + let y = value.y; + let z = value.z; + + // uses reference white d65 + let x = x * XN; + let z = z * ZN; + + // XYZ to sRGB + // expanded matrix multiplication + let rlin = (x * 3.2404542) + (y * -1.5371385) + (z * -0.4985314); + let glin = (x * -0.9692660) + (y * 1.8760108) + (z * 0.0415560); + let blin = (x * 0.0556434) + (y * -0.2040259) + (z * 1.0572252); + + let (r, g, b) = if SRGB_COMPAND { + let r = if rlin <= 0.0031308 { + 12.92 * rlin + } else { + (1.055 * (rlin.powf(1. / 2.4))) - 0.055 + }; + let g = if glin <= 0.0031308 { + 12.92 * glin + } else { + (1.055 * (glin.powf(1. / 2.4))) - 0.055 + }; + let b = if blin <= 0.0031308 { + 12.92 * blin + } else { + (1.055 * (blin.powf(1. / 2.4))) - 0.055 + }; + (r, g, b) + } else { + // Use simplified sRGB + let r = rlin.powf(1. / GAMMA); + let g = glin.powf(1. / GAMMA); + let b = blin.powf(1. / GAMMA); + (r, g, b) + }; + + // constrain to 0..1 to deal with any float drift + let r = r.clamp(0.0, 1.0); + let g = g.clamp(0.0, 1.0); + let b = b.clamp(0.0, 1.0); + + Self { r, g, b } + } +} + +impl From for LUVColor { + fn from(value: XYZColor) -> Self { + let x = value.x; + let y = value.y; + let z = value.z; + + let denom = x + (15. * y) + (3. * z); + let uprime = (4. * x) / denom; + let vprime = (9. * y) / denom; + + let y = y / YN; + + let l = if y <= *T0 { + *KAPPA * y + } else { + (116. * (y.powf(THIRD))) - 16. + }; + + let u = 13. * l * (uprime - UPRIME_N); + let v = 13. * l * (vprime - VPRIME_N); + Self { l, u, v } + } +} + +impl From for XYZColor { + #[inline(always)] + fn from(value: LABColor) -> Self { + let l = value.l; + let a = value.a; + let b = value.b; + + let tx = ((l + 16.) / 116.0) + (a / 500.0); + let x = if tx > DELTA { + tx.powi(3) + } else { + 3. * DELTA * DELTA * (tx - BINTERCEPT) + }; + + let ty = (l + 16.) / 116.0; + let y = if ty > DELTA { + ty.powi(3) + } else { + 3. * DELTA * DELTA * (ty - BINTERCEPT) + }; + + let tz = ((l + 16.) / 116.0) - (b / 200.0); + let z = if tz > DELTA { + tz.powi(3) + } else { + 3. * DELTA * DELTA * (tz - BINTERCEPT) + }; + + // Reference illuminant + Self { x, y, z } + } +} + +impl From for LCHColor { + #[inline(always)] + fn from(value: LABColor) -> Self { + let l = value.l; + let a = value.a; + let b = value.b; + + let c = ((a * a) + (b * b)).powf(0.5); + let h = b.atan2(a); + Self { l, c, h } + } +} + +impl From for RGBColor { + fn from(value: LABColor) -> Self { + let c1: XYZColor = value.into(); + c1.into() + } +} + +impl From for LUVColor { + fn from(value: LABColor) -> Self { + let c1: XYZColor = value.into(); + c1.into() + } +} + +impl From for LABColor { + #[inline(always)] + fn from(value: LCHColor) -> Self { + let l = value.l; + let c = value.c; + let h = value.h; + + let a = c * h.cos(); + let b = c * h.sin(); + + Self { l, a, b } + } +} + +impl From for XYZColor { + fn from(value: LCHColor) -> Self { + let c1: LABColor = value.into(); + c1.into() + } +} + +impl From for RGBColor { + #[inline(always)] + fn from(value: LCHColor) -> Self { + let c1: LABColor = value.into(); + let c2: XYZColor = c1.into(); + c2.into() + } +} + +impl From for LUVColor { + fn from(value: LCHColor) -> Self { + let c1: LABColor = value.into(); + let c2: XYZColor = c1.into(); + c2.into() + } +} + +impl From for LABColor { + fn from(value: LUVColor) -> Self { + let c1: XYZColor = value.into(); + c1.into() + } +} + +impl From for XYZColor { + fn from(value: LUVColor) -> Self { + let l = value.l; + let u = value.u; + let v = value.v; + + if l == 0.0 { + return Self { + x: 0., + y: 0., + z: 0., + }; + } + + let uprime = (u / (13. * l)) + UPRIME_N; + let vprime = (v / (13. * l)) + VPRIME_N; + + let y = if l <= 8.0 { + l / *KAPPA + } else { + ((l + 16.) / 116.0).powf(3.) + }; + + let x = y * ((9. * uprime) / (4. * vprime)); + let z = y * ((12. - (3. * uprime) - (20. * vprime)) / (4. * vprime)); + + Self { x, y, z } + } +} + +impl From for RGBColor { + fn from(value: LUVColor) -> Self { + let c1: XYZColor = value.into(); + c1.into() + } +} + +impl From for LCHColor { + fn from(value: LUVColor) -> Self { + let c1: XYZColor = value.into(); + let c2: LABColor = c1.into(); + c2.into() + } +} + +#[allow(non_camel_case_types)] +#[derive(Copy, Clone, Debug)] +pub enum ColorSpace { + rgb = 0, + xyz = 1, + lab = 2, + lch = 3, + luv = 4, +} + +impl<'py> FromPyObject<'py> for ColorSpace { + fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { + let val: i64 = ob.extract()?; + match val { + 0 => Ok(ColorSpace::rgb), + 1 => Ok(ColorSpace::xyz), + 2 => Ok(ColorSpace::lab), + 3 => Ok(ColorSpace::lch), + 4 => Ok(ColorSpace::luv), + _ => Err(PyValueError::new_err(format!( + "Unknown color enum value {}", + val + ))), + } + } +} + +pub fn convert(c: (f64, f64, f64), src: ColorSpace, dst: ColorSpace) -> (f64, f64, f64) { + use ColorSpace::*; + match (src, dst) { + (rgb, rgb) => c, + (xyz, xyz) => c, + (lab, lab) => c, + (lch, lch) => c, + (luv, luv) => c, + + (rgb, lab) => LABColor::from(RGBColor::from(c)).into(), + (rgb, lch) => LCHColor::from(RGBColor::from(c)).into(), + (rgb, xyz) => XYZColor::from(RGBColor::from(c)).into(), + (rgb, luv) => LUVColor::from(RGBColor::from(c)).into(), + + (xyz, lab) => LABColor::from(XYZColor::from(c)).into(), + (xyz, lch) => LCHColor::from(XYZColor::from(c)).into(), + (xyz, rgb) => RGBColor::from(XYZColor::from(c)).into(), + (xyz, luv) => LUVColor::from(XYZColor::from(c)).into(), + + (lab, xyz) => XYZColor::from(LABColor::from(c)).into(), + (lab, lch) => LCHColor::from(LABColor::from(c)).into(), + (lab, rgb) => RGBColor::from(LABColor::from(c)).into(), + (lab, luv) => LUVColor::from(LABColor::from(c)).into(), + + (lch, lab) => LABColor::from(LCHColor::from(c)).into(), + (lch, xyz) => XYZColor::from(LCHColor::from(c)).into(), + (lch, rgb) => RGBColor::from(LCHColor::from(c)).into(), + (lch, luv) => LUVColor::from(LCHColor::from(c)).into(), + + (luv, lab) => LABColor::from(LUVColor::from(c)).into(), + (luv, xyz) => XYZColor::from(LUVColor::from(c)).into(), + (luv, rgb) => RGBColor::from(LUVColor::from(c)).into(), + (luv, lch) => LCHColor::from(LUVColor::from(c)).into(), + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c5e3ef4 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +mod api; +mod colorspace; +pub use api::_rust; diff --git a/tests/test_colorspace.py b/tests/test_colorspace.py index 8d8de97..627f193 100644 --- a/tests/test_colorspace.py +++ b/tests/test_colorspace.py @@ -171,45 +171,49 @@ def test_bad_array_bands(): def test_bad_array_dims(): bad = np.random.random((3, 3)) - with pytest.raises(ValueError) as exc: + with pytest.raises(TypeError) as exc: saturate_rgb(bad, 1.1) - assert "wrong number of dimensions" in str(exc.value) + assert "cannot be converted" in str(exc.value) - with pytest.raises(ValueError) as exc: + with pytest.raises(TypeError) as exc: convert_arr(bad, cs.rgb, cs.lch) - assert "wrong number of dimensions" in str(exc.value) + assert "cannot be converted" in str(exc.value) def test_bad_array_type(): bad = np.random.random((3, 3, 3)).astype("uint8") - with pytest.raises(ValueError) as exc: + with pytest.raises(TypeError) as exc: saturate_rgb(bad, 1.1) - assert "dtype mismatch" in str(exc.value) + assert "cannot be converted" in str(exc.value) - with pytest.raises(ValueError) as exc: + with pytest.raises(TypeError) as exc: convert_arr(bad, cs.rgb, cs.lch) - assert "dtype mismatch" in str(exc.value) + assert "cannot be converted" in str(exc.value) def test_array_bad_colorspace(): - arr = np.random.random((3, 3)) - with pytest.raises(ValueError): - convert_arr(arr, src="FOO", dst="RGB") + rgb = _make_array(0.392156, 0.776470, 0.164705) + with pytest.raises(TypeError) as exc: + convert_arr(rgb, src="FOO", dst="RGB") + assert "'str' object cannot be interpreted as an integer" in str(exc.value) - with pytest.raises(ValueError): - convert_arr(arr, src=999, dst=999) + with pytest.raises(ValueError) as exc: + convert_arr(rgb, src=999, dst=999) + assert "Unknown color enum value 999" in str(exc.value) def test_bad_colorspace_string(): """String colorspaces raise ValueError""" - with pytest.raises(ValueError): + with pytest.raises(TypeError) as exc: convert(0.1, 0.1, 0.1, src="FOO", dst="RGB") + assert "'str' object cannot be interpreted as an integer" in str(exc.value) def test_bad_colorspace_invalid_int(): """Invalid colorspace integers raise ValueError""" - with pytest.raises(ValueError): + with pytest.raises(ValueError) as exc: convert(0.1, 0.1, 0.1, src=999, dst=999) + assert "Unknown color enum value 999" in str(exc.value) def test_bad_colorspace_invalid_enum():