Skip to content

Commit de802d7

Browse files
committed
integrate imagehash to pytest
1 parent de774bd commit de802d7

6 files changed

Lines changed: 141 additions & 3 deletions

File tree

docs/configuration.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,20 @@ If its directory does not exist, it will be created along with any missing paren
207207
Configuring this option disables baseline image comparison.
208208
If you want to enable both hash and baseline image comparison, which we call :doc:`"hybrid mode" <hybrid_mode>`, you must explicitly set the :ref:`baseline directory configuration option <baseline-dir>`.
209209

210+
Hash method used for hash comparison
211+
------------------------------------
212+
| **kwarg**: ``hash_method=<name>``
213+
| **CLI**: ``--mpl-hash-method=<name>``
214+
| **INI**: ``mpl-hash-method = <name>``
215+
| Default: ``sha256``
216+
217+
The hash method to use when generating and comparing hashes. Supported methods are
218+
``sha256``, ``ahash``, ``phash``, ``phash_simple``, ``dhash``, ``dhash_vertical``,
219+
``whash``, ``colorhash``, and ``crop_resistant_hash``.
220+
221+
Non-``sha256`` methods require raster formats (``png``) and depend on the
222+
``ImageHash`` package. ``phash`` may require extra optional dependencies (e.g. SciPy).
223+
210224
.. _controlling-sensitivity:
211225

212226
Controlling the sensitivity of the comparison

docs/hash_mode.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ Hash Comparison Mode
77
This how-to guide will show you how to use the hash comparison mode of ``pytest-mpl``.
88

99
In this mode, the hash of the image is compared to the hash of the baseline image.
10+
By default, ``pytest-mpl`` uses ``sha256``, but you can configure alternative hash
11+
methods (e.g. perceptual hashes) via ``hash_method`` or ``--mpl-hash-method``.
1012
Only the hash value of the baseline image, rather than the full image, needs to be stored in the repository.
1113
This means that the repository size is reduced, and the images can be regenerated if necessary.
1214
This approach does however make it more difficult to visually inspect any changes to the images.

docs/usage.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,8 @@ Also see the :doc:`configuration guide <configuration>` for more information on
142142
Hash comparison mode
143143
^^^^^^^^^^^^^^^^^^^^
144144

145-
Instead of comparing to baseline images, you can instead compare against a JSON library of SHA-256 hashes of the baseline image files.
145+
Instead of comparing to baseline images, you can instead compare against a JSON library of hashes of the baseline image files.
146+
By default these are SHA-256 hashes, but you can configure alternative hash methods.
146147
Pros and cons of this mode are:
147148

148149
- :octicon:`diff-added;1em;sd-text-success` Easy to configure

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ pytest_mpl = "pytest_mpl.plugin"
5151
test = [
5252
"pytest-cov>=6.0.0",
5353
]
54+
hashes = [
55+
"ImageHash>=4.3.1",
56+
"scipy>=1.8.0",
57+
]
5458
docs = [
5559
"sphinx>=7.0.0",
5660
"mpl_sphinx_theme>=3.9.0",

pytest_mpl/plugin.py

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@
5151
DEFAULT_BACKEND = "agg"
5252

5353
SUPPORTED_FORMATS = {"html", "json", "basic-html"}
54+
SUPPORTED_HASH_METHODS = {
55+
"sha256",
56+
"ahash",
57+
"phash",
58+
"phash_simple",
59+
"dhash",
60+
"dhash_vertical",
61+
"whash",
62+
"colorhash",
63+
"crop_resistant_hash",
64+
}
5465

5566
SHAPE_MISMATCH_ERROR = """Error: Image dimensions did not match.
5667
Expected shape: {expected_shape}
@@ -83,6 +94,58 @@ def _hash_file(in_stream):
8394
return hasher.hexdigest()
8495

8596

97+
def _normalize_hash_method(hash_method):
98+
if hash_method is None:
99+
return "sha256"
100+
method = str(hash_method).lower()
101+
if method not in SUPPORTED_HASH_METHODS:
102+
raise ValueError(
103+
f"Unsupported hash method '{hash_method}'. "
104+
f"Supported methods are: {sorted(SUPPORTED_HASH_METHODS)}."
105+
)
106+
return method
107+
108+
109+
def _compute_imagehash(hash_method, in_stream):
110+
try:
111+
import imagehash
112+
except ImportError as exc:
113+
raise ImportError(
114+
"Hash method requires the 'ImageHash' package. "
115+
"Install pytest-mpl with the 'hashes' extra or add ImageHash to your dependencies."
116+
) from exc
117+
118+
in_stream.seek(0)
119+
try:
120+
from PIL import Image
121+
except ImportError as exc:
122+
raise ImportError(
123+
"Hash method requires Pillow to load image data."
124+
) from exc
125+
126+
image = Image.open(in_stream)
127+
128+
methods = {
129+
"ahash": imagehash.average_hash,
130+
"phash": imagehash.phash,
131+
"phash_simple": imagehash.phash_simple,
132+
"dhash": imagehash.dhash,
133+
"dhash_vertical": imagehash.dhash_vertical,
134+
"whash": imagehash.whash,
135+
"colorhash": imagehash.colorhash,
136+
"crop_resistant_hash": imagehash.crop_resistant_hash,
137+
}
138+
if hash_method not in methods:
139+
raise ValueError(f"Unsupported imagehash method '{hash_method}'.")
140+
141+
try:
142+
return str(methods[hash_method](image))
143+
except ImportError as exc:
144+
raise ImportError(
145+
f"Hash method '{hash_method}' requires extra optional dependencies."
146+
) from exc
147+
148+
86149
def pathify(path):
87150
"""
88151
Remove non-path safe characters.
@@ -166,6 +229,11 @@ def pytest_addoption(parser):
166229
group.addoption(f"--{option}", help=msg, action="store")
167230
parser.addini(option, help=msg)
168231

232+
msg = "hash method to use for hash comparison and generation"
233+
option = "mpl-hash-method"
234+
group.addoption(f"--{option}", help=msg, action="store")
235+
parser.addini(option, help=msg)
236+
169237
msg = (
170238
"Generate a summary report of any failed tests"
171239
", in --mpl-results-path. The type of the report should be "
@@ -251,6 +319,7 @@ def get_cli_or_ini(name, default=None):
251319

252320
hash_library = get_cli_or_ini("mpl-hash-library")
253321
_hash_library_from_cli = bool(config.getoption("--mpl-hash-library")) # for backwards compatibility
322+
hash_method = _normalize_hash_method(get_cli_or_ini("mpl-hash-method", "sha256"))
254323

255324
default_tolerance = get_cli_or_ini("mpl-default-tolerance", DEFAULT_TOLERANCE)
256325
if isinstance(default_tolerance, str):
@@ -310,6 +379,7 @@ def get_cli_or_ini(name, default=None):
310379
baseline_relative_dir=baseline_relative_dir,
311380
generate_dir=generate_dir,
312381
hash_library=hash_library,
382+
hash_method=hash_method,
313383
generate_hash_library=generate_hash_lib,
314384
generate_summary=generate_summary,
315385
results_always=results_always,
@@ -372,6 +442,7 @@ def __init__(
372442
baseline_relative_dir=None,
373443
generate_dir=None,
374444
hash_library=None,
445+
hash_method="sha256",
375446
generate_hash_library=None,
376447
generate_summary=None,
377448
results_always=False,
@@ -388,6 +459,7 @@ def __init__(
388459
self.generate_dir = path_is_not_none(generate_dir)
389460
self.results_dir = None
390461
self.hash_library = path_is_not_none(hash_library)
462+
self.hash_method = _normalize_hash_method(hash_method)
391463
self._hash_library_from_cli = _hash_library_from_cli # for backwards compatibility
392464
self.generate_hash_library = path_is_not_none(generate_hash_library)
393465
if generate_summary:
@@ -569,13 +641,24 @@ def generate_baseline_image(self, item, fig):
569641

570642
def generate_image_hash(self, item, fig):
571643
"""
572-
For a `matplotlib.figure.Figure`, returns the SHA256 hash as a hexadecimal
644+
For a `matplotlib.figure.Figure`, returns the hash as a hexadecimal
573645
string.
574646
"""
647+
compare = get_compare(item)
648+
hash_method = _normalize_hash_method(compare.kwargs.get('hash_method', self.hash_method))
649+
ext = self._file_extension(item)
650+
if hash_method != "sha256" and ext not in RASTER_IMAGE_FORMATS:
651+
raise ValueError(
652+
f"Hash method '{hash_method}' only supports raster formats {RASTER_IMAGE_FORMATS}. "
653+
f"Got format '{ext}'."
654+
)
575655

576656
imgdata = io.BytesIO()
577657
self.save_figure(item, fig, imgdata)
578-
out = _hash_file(imgdata)
658+
if hash_method == "sha256":
659+
out = _hash_file(imgdata)
660+
else:
661+
out = _compute_imagehash(hash_method, imgdata)
579662
imgdata.close()
580663

581664
close_mpl_figure(fig)

tests/test_pytest_mpl.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,3 +472,37 @@ def test_format_{file_format}():
472472
result.assert_outcomes(passed=1)
473473
else:
474474
result.assert_outcomes(failed=1)
475+
476+
477+
def test_hash_method_phash(pytester, tmp_path):
478+
imagehash = pytest.importorskip("imagehash")
479+
try:
480+
from PIL import Image
481+
imagehash.phash(Image.new("L", (8, 8)))
482+
except Exception as exc:
483+
pytest.skip(f"imagehash.phash not available: {exc}")
484+
485+
tmp_hash_library = tmp_path / "hash_library_phash.json"
486+
tmp_hash_library.write_text("{}")
487+
488+
pytester.makepyfile(
489+
f"""
490+
import pytest
491+
import matplotlib.pyplot as plt
492+
493+
@pytest.mark.mpl_image_compare(baseline_dir=r"{baseline_dir_abs}",
494+
hash_library=r"{tmp_hash_library}",
495+
hash_method="phash",
496+
deterministic=True,
497+
savefig_kwargs={{'format': 'png'}})
498+
def test_format_phash():
499+
fig = plt.figure()
500+
ax = fig.add_subplot(1, 1, 1)
501+
ax.plot([1, 2, 3])
502+
return fig
503+
"""
504+
)
505+
506+
pytester.runpytest(f'--mpl-generate-hash-library={tmp_hash_library.as_posix()}', '-rs')
507+
hash_data = json.loads(tmp_hash_library.read_text())
508+
assert len(hash_data["test_hash_method_phash.test_format_phash"]) == 16

0 commit comments

Comments
 (0)