diff --git a/.github/workflows/enscripten.yaml b/.github/workflows/enscripten.yaml deleted file mode 100644 index bc06451..0000000 --- a/.github/workflows/enscripten.yaml +++ /dev/null @@ -1,63 +0,0 @@ -name: WASM - -on: - workflow_dispatch: - push: - branches: - - master - pull_request: - branches: - - master - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-wasm-emscripten: - name: Pyodide - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v4 - with: - submodules: true - fetch-depth: 0 - - - uses: actions/setup-python@v4 - with: - python-version: "3.11" - - - name: Install pyodide-build - run: pip install pyodide-build==0.23.4 - - - name: Compute emsdk version - id: compute-emsdk-version - run: | - pyodide xbuildenv install --download - EMSCRIPTEN_VERSION=$(pyodide config get emscripten_version) - echo "emsdk-version=$EMSCRIPTEN_VERSION" >> $GITHUB_OUTPUT - - - uses: mymindstorm/setup-emsdk@v13 - with: - version: ${{ steps.compute-emsdk-version.outputs.emsdk-version }} - actions-cache-folder: emsdk-cache - - # A future version of pyodide may switch to -fwasm-exceptions - - name: Build - run: CFLAGS=-fexceptions LDFLAGS=-fexceptions pyodide build - - - uses: actions/upload-artifact@v3 - with: - path: dist/*.whl - - - uses: actions/setup-node@v4 - with: - node-version: 18 - - - name: Set up Pyodide virtual environment - run: | - pyodide venv .venv-pyodide - .venv-pyodide/bin/pip install $(echo -n dist/*.whl)[test] - - - name: Test - run: .venv-pyodide/bin/pytest diff --git a/Makefile b/Makefile index cba2950..b0bc8f1 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ force_clean: docker run --rm -v `pwd`:`pwd` -w `pwd` -it alpine/make make clean pytest: - python3 -m pip install pytest numpy + python3 -m pip install pytest numpy pillow pytest tests # --capture=tee-sys .PHONY: test pytest diff --git a/pyproject.toml b/pyproject.toml index 34fd67f..f37cc42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,33 +5,31 @@ build-backend = "scikit_build_core.build" [project] name = "pybind11_pixelmatch" -version = "0.1.5" +version = "0.1.6" description="A C++17 port of the JavaScript pixelmatch library (with python binding), providing a small pixel-level image comparison library." readme = "README.md" authors = [ { name = "district10", email = "dvorak4tzx@gmail.com" }, ] -requires-python = ">=3.7" +requires-python = ">=3.9" classifiers = [ "Development Status :: 4 - Beta", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.7", - "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", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] -dependencies = [ - "numpy", - "opencv-python", -] +dependencies = [] [project.urls] Homepage = "https://github.com/cubao/pybind11_pixelmatch" [project.optional-dependencies] -test = ["pytest"] +test = ["pytest", "Pillow", "numpy", "opencv-python-headless"] # docs: https://scikit-build-core.readthedocs.io/en/latest/configuration.html diff --git a/src/pybind11_pixelmatch/__init__.py b/src/pybind11_pixelmatch/__init__.py index 44c01a0..c944ce8 100644 --- a/src/pybind11_pixelmatch/__init__.py +++ b/src/pybind11_pixelmatch/__init__.py @@ -1,6 +1,10 @@ from __future__ import annotations from pathlib import Path +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + import numpy as np from ._core import ( Color, @@ -11,31 +15,74 @@ rgb2yiq, ) +Backend = Literal["cv2", "pillow"] -def read_image(path): - import cv2 - import numpy as np +def read_image( + path: str, + *, + backend: Backend | None = None, +) -> np.ndarray: assert Path(path).is_file(), f"{path} does not exist" - img = cv2.imread(path, cv2.IMREAD_UNCHANGED) - if img.shape[2] == 3: - B, G, R = cv2.split(img) - A = np.ones(B.shape, dtype=B.dtype) * 255 - img = cv2.merge((R, G, B, A)) - elif img.shape[2] == 4: - img = cv2.cvtColor(img, cv2.COLOR_BGRA2RGBA) - return img - - -def write_image(path, img): - import cv2 - - if img.shape[2] == 3: - img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) - else: - img = cv2.cvtColor(img, cv2.COLOR_RGBA2BGRA) + + if backend is None: + try: + import PIL # noqa: F401 + + backend = "pillow" + except ImportError: + backend = "cv2" + + if backend == "cv2": + import cv2 + import numpy as np + + img = cv2.imread(str(path), cv2.IMREAD_UNCHANGED) + if img.shape[2] == 3: + B, G, R = cv2.split(img) + A = np.ones(B.shape, dtype=B.dtype) * 255 + img = cv2.merge((R, G, B, A)) + elif img.shape[2] == 4: + img = cv2.cvtColor(img, cv2.COLOR_BGRA2RGBA) + return img + + # pillow backend + import numpy as np + from PIL import Image + + return np.array(Image.open(path).convert("RGBA")) + + +def write_image( + path: str, + img: np.ndarray, + *, + backend: Backend | None = None, +) -> None: Path(path).resolve().parent.mkdir(parents=True, exist_ok=True) - cv2.imwrite(path, img) + + if backend is None: + try: + import PIL # noqa: F401 + + backend = "pillow" + except ImportError: + backend = "cv2" + + if backend == "cv2": + import cv2 + + if img.shape[2] == 3: + img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) + else: + img = cv2.cvtColor(img, cv2.COLOR_RGBA2BGRA) + cv2.imwrite(str(path), img) + return + + # pillow backend + from PIL import Image + + Image.fromarray(img).save(path) def normalize_color(rgba): diff --git a/src/pybind11_pixelmatch/viewer.py b/src/pybind11_pixelmatch/viewer.py index 4cb4b7b..25635d9 100644 --- a/src/pybind11_pixelmatch/viewer.py +++ b/src/pybind11_pixelmatch/viewer.py @@ -3,7 +3,7 @@ import json import sys import tkinter as tk -from functools import lru_cache +from functools import cache from pathlib import Path import cv2 @@ -571,7 +571,7 @@ def align_image( return warp_matrix, aligned -@lru_cache(maxsize=None) +@cache def diff_image_options( threshold: float = 0.1, include_aa: bool = False, diff --git a/tests/test_binding.py b/tests/test_binding.py index 04e691d..46bccb1 100644 --- a/tests/test_binding.py +++ b/tests/test_binding.py @@ -68,3 +68,26 @@ def test_pixelmatch(): num = pixelmatch(img1, img2, output=diff) assert num == 163889 write_image("diff.png", diff) + + +def test_read_image_backends_equivalent(): + project_source_dir = str(Path(__file__).resolve().parent.parent) + path = f"{project_source_dir}/data/pic1.png" + + img_cv2 = read_image(path, backend="cv2") + img_pil = read_image(path, backend="pillow") + + assert img_cv2.shape == img_pil.shape + assert img_cv2.dtype == img_pil.dtype + assert np.array_equal(img_cv2, img_pil) + + +def test_write_image_round_trip(tmp_path): + project_source_dir = str(Path(__file__).resolve().parent.parent) + original = read_image(f"{project_source_dir}/data/pic1.png") + + for backend in ("cv2", "pillow"): + out = str(tmp_path / f"out_{backend}.png") + write_image(out, original, backend=backend) + reloaded = read_image(out, backend=backend) + assert np.array_equal(original, reloaded), f"round-trip failed for {backend}"