diff --git a/docs/configuration.rst b/docs/configuration.rst index 1afce3af..59a3cd2f 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -207,6 +207,20 @@ If its directory does not exist, it will be created along with any missing paren Configuring this option disables baseline image comparison. If you want to enable both hash and baseline image comparison, which we call :doc:`"hybrid mode" `, you must explicitly set the :ref:`baseline directory configuration option `. +Hash method used for hash comparison +------------------------------------ +| **kwarg**: ``hash_method=`` +| **CLI**: ``--mpl-hash-method=`` +| **INI**: ``mpl-hash-method = `` +| Default: ``sha256`` + +The hash method to use when generating and comparing hashes. Supported methods are +``sha256``, ``ahash``, ``phash``, ``phash_simple``, ``dhash``, ``dhash_vertical``, +``whash``, ``colorhash``, and ``crop_resistant_hash``. + +Non-``sha256`` methods require raster formats (``png``) and depend on the +``ImageHash`` package. ``phash`` may require extra optional dependencies (e.g. SciPy). + .. _controlling-sensitivity: Controlling the sensitivity of the comparison diff --git a/docs/hash_mode.rst b/docs/hash_mode.rst index 13fa7de8..6ae6eb9d 100644 --- a/docs/hash_mode.rst +++ b/docs/hash_mode.rst @@ -7,6 +7,8 @@ Hash Comparison Mode This how-to guide will show you how to use the hash comparison mode of ``pytest-mpl``. In this mode, the hash of the image is compared to the hash of the baseline image. +By default, ``pytest-mpl`` uses ``sha256``, but you can configure alternative hash +methods (e.g. perceptual hashes) via ``hash_method`` or ``--mpl-hash-method``. Only the hash value of the baseline image, rather than the full image, needs to be stored in the repository. This means that the repository size is reduced, and the images can be regenerated if necessary. This approach does however make it more difficult to visually inspect any changes to the images. diff --git a/docs/usage.rst b/docs/usage.rst index 0c944171..ea24aebe 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -142,7 +142,8 @@ Also see the :doc:`configuration guide ` for more information on Hash comparison mode ^^^^^^^^^^^^^^^^^^^^ -Instead of comparing to baseline images, you can instead compare against a JSON library of SHA-256 hashes of the baseline image files. +Instead of comparing to baseline images, you can instead compare against a JSON library of hashes of the baseline image files. +By default these are SHA-256 hashes, but you can configure alternative hash methods. Pros and cons of this mode are: - :octicon:`diff-added;1em;sd-text-success` Easy to configure diff --git a/pyproject.toml b/pyproject.toml index bd116e12..71556063 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,10 @@ pytest_mpl = "pytest_mpl.plugin" test = [ "pytest-cov>=6.0.0", ] +hashes = [ + "ImageHash>=4.3.1", + "scipy>=1.8.0", +] docs = [ "sphinx>=7.0.0", "mpl_sphinx_theme>=3.9.0", diff --git a/pytest_mpl/plugin.py b/pytest_mpl/plugin.py index 4367dd89..38acb817 100644 --- a/pytest_mpl/plugin.py +++ b/pytest_mpl/plugin.py @@ -51,6 +51,17 @@ DEFAULT_BACKEND = "agg" SUPPORTED_FORMATS = {"html", "json", "basic-html"} +SUPPORTED_HASH_METHODS = { + "sha256", + "ahash", + "phash", + "phash_simple", + "dhash", + "dhash_vertical", + "whash", + "colorhash", + "crop_resistant_hash", +} SHAPE_MISMATCH_ERROR = """Error: Image dimensions did not match. Expected shape: {expected_shape} @@ -83,6 +94,58 @@ def _hash_file(in_stream): return hasher.hexdigest() +def _normalize_hash_method(hash_method): + if hash_method is None: + return "sha256" + method = str(hash_method).lower() + if method not in SUPPORTED_HASH_METHODS: + raise ValueError( + f"Unsupported hash method '{hash_method}'. " + f"Supported methods are: {sorted(SUPPORTED_HASH_METHODS)}." + ) + return method + + +def _compute_imagehash(hash_method, in_stream): + try: + import imagehash + except ImportError as exc: + raise ImportError( + "Hash method requires the 'ImageHash' package. " + "Install pytest-mpl with the 'hashes' extra or add ImageHash to your dependencies." + ) from exc + + in_stream.seek(0) + try: + from PIL import Image + except ImportError as exc: + raise ImportError( + "Hash method requires Pillow to load image data." + ) from exc + + image = Image.open(in_stream) + + methods = { + "ahash": imagehash.average_hash, + "phash": imagehash.phash, + "phash_simple": imagehash.phash_simple, + "dhash": imagehash.dhash, + "dhash_vertical": imagehash.dhash_vertical, + "whash": imagehash.whash, + "colorhash": imagehash.colorhash, + "crop_resistant_hash": imagehash.crop_resistant_hash, + } + if hash_method not in methods: + raise ValueError(f"Unsupported imagehash method '{hash_method}'.") + + try: + return str(methods[hash_method](image)) + except ImportError as exc: + raise ImportError( + f"Hash method '{hash_method}' requires extra optional dependencies." + ) from exc + + def pathify(path): """ Remove non-path safe characters. @@ -166,6 +229,11 @@ def pytest_addoption(parser): group.addoption(f"--{option}", help=msg, action="store") parser.addini(option, help=msg) + msg = "hash method to use for hash comparison and generation" + option = "mpl-hash-method" + group.addoption(f"--{option}", help=msg, action="store") + parser.addini(option, help=msg) + msg = ( "Generate a summary report of any failed tests" ", in --mpl-results-path. The type of the report should be " @@ -251,6 +319,7 @@ def get_cli_or_ini(name, default=None): hash_library = get_cli_or_ini("mpl-hash-library") _hash_library_from_cli = bool(config.getoption("--mpl-hash-library")) # for backwards compatibility + hash_method = _normalize_hash_method(get_cli_or_ini("mpl-hash-method", "sha256")) default_tolerance = get_cli_or_ini("mpl-default-tolerance", DEFAULT_TOLERANCE) if isinstance(default_tolerance, str): @@ -310,6 +379,7 @@ def get_cli_or_ini(name, default=None): baseline_relative_dir=baseline_relative_dir, generate_dir=generate_dir, hash_library=hash_library, + hash_method=hash_method, generate_hash_library=generate_hash_lib, generate_summary=generate_summary, results_always=results_always, @@ -372,6 +442,7 @@ def __init__( baseline_relative_dir=None, generate_dir=None, hash_library=None, + hash_method="sha256", generate_hash_library=None, generate_summary=None, results_always=False, @@ -388,6 +459,7 @@ def __init__( self.generate_dir = path_is_not_none(generate_dir) self.results_dir = None self.hash_library = path_is_not_none(hash_library) + self.hash_method = _normalize_hash_method(hash_method) self._hash_library_from_cli = _hash_library_from_cli # for backwards compatibility self.generate_hash_library = path_is_not_none(generate_hash_library) if generate_summary: @@ -569,13 +641,24 @@ def generate_baseline_image(self, item, fig): def generate_image_hash(self, item, fig): """ - For a `matplotlib.figure.Figure`, returns the SHA256 hash as a hexadecimal + For a `matplotlib.figure.Figure`, returns the hash as a hexadecimal string. """ + compare = get_compare(item) + hash_method = _normalize_hash_method(compare.kwargs.get('hash_method', self.hash_method)) + ext = self._file_extension(item) + if hash_method != "sha256" and ext not in RASTER_IMAGE_FORMATS: + raise ValueError( + f"Hash method '{hash_method}' only supports raster formats {RASTER_IMAGE_FORMATS}. " + f"Got format '{ext}'." + ) imgdata = io.BytesIO() self.save_figure(item, fig, imgdata) - out = _hash_file(imgdata) + if hash_method == "sha256": + out = _hash_file(imgdata) + else: + out = _compute_imagehash(hash_method, imgdata) imgdata.close() close_mpl_figure(fig) diff --git a/tests/test_pytest_mpl.py b/tests/test_pytest_mpl.py index 62e6d794..4638294e 100644 --- a/tests/test_pytest_mpl.py +++ b/tests/test_pytest_mpl.py @@ -472,3 +472,37 @@ def test_format_{file_format}(): result.assert_outcomes(passed=1) else: result.assert_outcomes(failed=1) + + +def test_hash_method_phash(pytester, tmp_path): + imagehash = pytest.importorskip("imagehash") + try: + from PIL import Image + imagehash.phash(Image.new("L", (8, 8))) + except Exception as exc: + pytest.skip(f"imagehash.phash not available: {exc}") + + tmp_hash_library = tmp_path / "hash_library_phash.json" + tmp_hash_library.write_text("{}") + + pytester.makepyfile( + f""" + import pytest + import matplotlib.pyplot as plt + + @pytest.mark.mpl_image_compare(baseline_dir=r"{baseline_dir_abs}", + hash_library=r"{tmp_hash_library}", + hash_method="phash", + deterministic=True, + savefig_kwargs={{'format': 'png'}}) + def test_format_phash(): + fig = plt.figure() + ax = fig.add_subplot(1, 1, 1) + ax.plot([1, 2, 3]) + return fig + """ + ) + + pytester.runpytest(f'--mpl-generate-hash-library={tmp_hash_library.as_posix()}', '-rs') + hash_data = json.loads(tmp_hash_library.read_text()) + assert len(hash_data["test_hash_method_phash.test_format_phash"]) == 16