Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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" <hybrid_mode>`, you must explicitly set the :ref:`baseline directory configuration option <baseline-dir>`.

Hash method used for hash comparison
------------------------------------
| **kwarg**: ``hash_method=<name>``
| **CLI**: ``--mpl-hash-method=<name>``
| **INI**: ``mpl-hash-method = <name>``
| 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
Expand Down
2 changes: 2 additions & 0 deletions docs/hash_mode.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@ Also see the :doc:`configuration guide <configuration>` 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
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
87 changes: 85 additions & 2 deletions pytest_mpl/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions tests/test_pytest_mpl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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