diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 813f0af..68ae2fd 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -97,6 +97,53 @@ With `--format json`, lint prints a machine-readable payload: } ``` +### `quickthumb diff` + +Compare a rendered image with a golden image before accepting a visual change: + +```bash +quickthumb diff tests/snapshots/solid_background.png output.png +quickthumb diff golden.png output.png --format json --output diff.png +``` + +The comparison combines an average perceptual hash with pixel-level metrics. +By default, per-channel differences of up to 2 are ignored, but any pixel that +exceeds that tolerance fails the comparison. The default perceptual-hash +similarity threshold is `0.95`. Tighten or relax those values for a particular +renderer: + +```bash +quickthumb diff golden.png output.png \ + --threshold 0.98 \ + --pixel-tolerance 1 \ + --max-diff-ratio 0.005 +``` + +Use `--format json` for CI or agent pipelines. `--max-diff-ratio` can allow a +small fraction of pixels to differ, and `--output` writes a pixel-difference +image for visual inspection. Exit codes are: + +| Exit code | Meaning | +| --- | --- | +| `0` | Images match within the configured tolerances | +| `1` | Images differ, inputs are invalid, or output cannot be written | + +The same comparison is available as a Python assertion: + +```python +from quickthumb import assert_image_similar + +assert_image_similar( + "tests/snapshots/solid_background.png", + "output.png", + threshold=0.95, +) +``` + +The assertion returns an `ImageDiff` with image sizes, changed-pixel counts, +normalized mean error, perceptual hashes, and similarity metrics when callers +need to report more detail. + Exit codes make it easy to gate CI or agent pipelines: | Exit code | Meaning | diff --git a/quickthumb/__init__.py b/quickthumb/__init__.py index 4ef7a2a..a4fc251 100644 --- a/quickthumb/__init__.py +++ b/quickthumb/__init__.py @@ -1,4 +1,11 @@ from quickthumb import transitions +from quickthumb._diff import ( + ImageDiff, + assert_image_similar, + compare_images, + create_diff_image, + perceptual_hash, +) from quickthumb.canvas import Canvas from quickthumb.deck import Deck, DeckDiagnostic from quickthumb.errors import QuickthumbError, RenderingError, ValidationError @@ -58,6 +65,11 @@ __all__ = [ "Canvas", + "ImageDiff", + "assert_image_similar", + "compare_images", + "create_diff_image", + "perceptual_hash", "Deck", "DeckDiagnostic", "QuickthumbError", diff --git a/quickthumb/_diff.py b/quickthumb/_diff.py new file mode 100644 index 0000000..576c2ac --- /dev/null +++ b/quickthumb/_diff.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +from dataclasses import dataclass +from math import isfinite +from pathlib import Path +from typing import TypeAlias + +from PIL import Image, ImageChops, UnidentifiedImageError + +ImageSource: TypeAlias = str | Path | Image.Image + +DEFAULT_HASH_SIZE = 8 +DEFAULT_HASH_THRESHOLD = 0.95 +DEFAULT_PIXEL_TOLERANCE = 2 +DEFAULT_MAX_DIFFERENT_PIXEL_RATIO = 0.0 + + +@dataclass(frozen=True) +class ImageDiff: + """Structured comparison result for two raster images.""" + + matches: bool + expected_size: tuple[int, int] + actual_size: tuple[int, int] + exact: bool + pixel_count: int | None + different_pixels: int | None + different_pixel_ratio: float | None + mean_absolute_error: float | None + max_channel_delta: int | None + expected_hash: str + actual_hash: str + hash_distance: int + hash_similarity: float + threshold: float + pixel_tolerance: int + max_different_pixel_ratio: float + reason: str | None = None + + def to_dict(self) -> dict[str, object]: + """Return a JSON-serializable representation of the comparison.""" + return { + "matches": self.matches, + "expected_size": list(self.expected_size), + "actual_size": list(self.actual_size), + "exact": self.exact, + "pixel_count": self.pixel_count, + "different_pixels": self.different_pixels, + "different_pixel_ratio": self.different_pixel_ratio, + "mean_absolute_error": self.mean_absolute_error, + "max_channel_delta": self.max_channel_delta, + "expected_hash": self.expected_hash, + "actual_hash": self.actual_hash, + "hash_distance": self.hash_distance, + "hash_similarity": self.hash_similarity, + "threshold": self.threshold, + "pixel_tolerance": self.pixel_tolerance, + "max_different_pixel_ratio": self.max_different_pixel_ratio, + "reason": self.reason, + } + + def format_text(self) -> str: + """Return a concise human-readable comparison summary.""" + status = "MATCH" if self.matches else "MISMATCH" + lines = [ + status, + f"size: expected={self.expected_size[0]}x{self.expected_size[1]} " + f"actual={self.actual_size[0]}x{self.actual_size[1]}", + f"exact: {'yes' if self.exact else 'no'}", + f"hash similarity: {self.hash_similarity:.4f} (distance {self.hash_distance})", + ] + if self.different_pixel_ratio is not None: + lines.extend( + [ + f"different pixels: {self.different_pixels}/{self.pixel_count} " + f"({self.different_pixel_ratio:.2%})", + f"mean absolute error: {self.mean_absolute_error:.6f}", + f"max channel delta: {self.max_channel_delta}", + ] + ) + if self.reason is not None: + lines.append(f"reason: {self.reason}") + return "\n".join(lines) + + +@dataclass(frozen=True) +class _PixelMetrics: + """Internal pixel-level metrics for same-sized images.""" + + pixel_count: int + different_pixels: int + different_pixel_ratio: float + mean_absolute_error: float + max_channel_delta: int + + +def assert_image_similar( + expected: ImageSource, + actual: ImageSource, + *, + threshold: float = DEFAULT_HASH_THRESHOLD, + pixel_tolerance: int = DEFAULT_PIXEL_TOLERANCE, + max_different_pixel_ratio: float = DEFAULT_MAX_DIFFERENT_PIXEL_RATIO, + hash_size: int = DEFAULT_HASH_SIZE, +) -> ImageDiff: + """Assert that an actual image is close enough to a golden image for CI.""" + comparison = compare_images( + expected, + actual, + threshold=threshold, + pixel_tolerance=pixel_tolerance, + max_different_pixel_ratio=max_different_pixel_ratio, + hash_size=hash_size, + ) + if not comparison.matches: + raise AssertionError(comparison.format_text()) + return comparison + + +def compare_images( + expected: ImageSource, + actual: ImageSource, + *, + threshold: float = DEFAULT_HASH_THRESHOLD, + pixel_tolerance: int = DEFAULT_PIXEL_TOLERANCE, + max_different_pixel_ratio: float = DEFAULT_MAX_DIFFERENT_PIXEL_RATIO, + hash_size: int = DEFAULT_HASH_SIZE, +) -> ImageDiff: + """Compare two raster images with pixel metrics and a perceptual hash. + + ``threshold`` is the minimum average-hash similarity. ``pixel_tolerance`` + ignores per-channel differences up to that value when counting changed + pixels. ``max_different_pixel_ratio`` limits the fraction of pixels that + may exceed that tolerance. + """ + _validate_options(threshold, pixel_tolerance, max_different_pixel_ratio, hash_size) + expected_image = _load_image(expected) + actual_image = _load_image(actual) + expected_hash = _perceptual_hash_image(expected_image, hash_size) + actual_hash = _perceptual_hash_image(actual_image, hash_size) + hash_distance = _hamming_distance(expected_hash, actual_hash) + hash_similarity = 1.0 - hash_distance / (hash_size * hash_size) + expected_size = expected_image.size + actual_size = actual_image.size + + if expected_size != actual_size: + return ImageDiff( + matches=False, + expected_size=expected_size, + actual_size=actual_size, + exact=False, + pixel_count=None, + different_pixels=None, + different_pixel_ratio=None, + mean_absolute_error=None, + max_channel_delta=None, + expected_hash=expected_hash, + actual_hash=actual_hash, + hash_distance=hash_distance, + hash_similarity=hash_similarity, + threshold=threshold, + pixel_tolerance=pixel_tolerance, + max_different_pixel_ratio=max_different_pixel_ratio, + reason="image dimensions differ", + ) + + expected_composited = _composite_for_comparison(expected_image) + actual_composited = _composite_for_comparison(actual_image) + metrics = _pixel_metrics(expected_composited, actual_composited, pixel_tolerance) + exact = metrics.max_channel_delta == 0 + matches = ( + hash_similarity >= threshold and metrics.different_pixel_ratio <= max_different_pixel_ratio + ) + return ImageDiff( + matches=matches, + expected_size=expected_size, + actual_size=actual_size, + exact=exact, + pixel_count=metrics.pixel_count, + different_pixels=metrics.different_pixels, + different_pixel_ratio=metrics.different_pixel_ratio, + mean_absolute_error=metrics.mean_absolute_error, + max_channel_delta=metrics.max_channel_delta, + expected_hash=expected_hash, + actual_hash=actual_hash, + hash_distance=hash_distance, + hash_similarity=hash_similarity, + threshold=threshold, + pixel_tolerance=pixel_tolerance, + max_different_pixel_ratio=max_different_pixel_ratio, + reason=None if matches else "comparison is outside the configured tolerances", + ) + + +def _validate_options( + threshold: float, + pixel_tolerance: int, + max_different_pixel_ratio: float, + hash_size: int, +) -> None: + if not isfinite(threshold) or not 0.0 <= threshold <= 1.0: + raise ValueError("threshold must be between 0 and 1") + if not isinstance(pixel_tolerance, int) or not 0 <= pixel_tolerance <= 255: + raise ValueError("pixel_tolerance must be between 0 and 255") + if not isfinite(max_different_pixel_ratio) or not 0.0 <= max_different_pixel_ratio <= 1.0: + raise ValueError("max_different_pixel_ratio must be between 0 and 1") + _validate_hash_size(hash_size) + + +def _validate_hash_size(hash_size: int) -> None: + if not isinstance(hash_size, int) or not 2 <= hash_size <= 32: + raise ValueError("hash_size must be between 2 and 32") + + +def _load_image(source: ImageSource) -> Image.Image: + if isinstance(source, Image.Image): + return source.convert("RGBA") + + try: + with Image.open(source) as image: + return image.convert("RGBA") + except (OSError, UnidentifiedImageError) as error: + raise ValueError(f"unable to read image '{source}': {error}") from error + + +def _composite_for_comparison(image: Image.Image) -> Image.Image: + white_background = Image.new("RGBA", image.size, (255, 255, 255, 255)) + return Image.alpha_composite(white_background, image).convert("RGB") + + +def _pixel_metrics( + expected: Image.Image, + actual: Image.Image, + pixel_tolerance: int, +) -> _PixelMetrics: + total_pixels = expected.width * expected.height + different_pixels = 0 + total_delta = 0 + max_channel_delta = 0 + for expected_pixel, actual_pixel in zip( + expected.get_flattened_data(), actual.get_flattened_data(), strict=True + ): + deltas = [ + abs(expected_channel - actual_channel) + for expected_channel, actual_channel in zip(expected_pixel, actual_pixel, strict=True) + ] + max_channel_delta = max(max_channel_delta, *deltas) + total_delta += sum(deltas) + if any(delta > pixel_tolerance for delta in deltas): + different_pixels += 1 + + channel_count = total_pixels * 3 + return _PixelMetrics( + pixel_count=total_pixels, + different_pixels=different_pixels, + different_pixel_ratio=different_pixels / total_pixels, + mean_absolute_error=total_delta / (channel_count * 255), + max_channel_delta=max_channel_delta, + ) + + +def _perceptual_hash_image(image: Image.Image, hash_size: int) -> str: + composited = _composite_for_comparison(image).convert("L") + resized = composited.resize((hash_size, hash_size), Image.Resampling.LANCZOS) + pixels = list(resized.get_flattened_data()) + average = sum(pixels) / len(pixels) + value = 0 + for pixel in pixels: + value = (value << 1) | int(pixel >= average) + hex_width = (hash_size * hash_size + 3) // 4 + return f"{value:0{hex_width}x}" + + +def _hamming_distance(first: str, second: str) -> int: + return (int(first, 16) ^ int(second, 16)).bit_count() + + +def create_diff_image(expected: ImageSource, actual: ImageSource) -> Image.Image: + """Create a raw RGBA difference image for two same-sized raster images.""" + expected_image = _load_image(expected) + actual_image = _load_image(actual) + if expected_image.size != actual_image.size: + raise ValueError("cannot create an image diff for different dimensions") + return ImageChops.difference(expected_image, actual_image) + + +def perceptual_hash(source: ImageSource, *, hash_size: int = DEFAULT_HASH_SIZE) -> str: + """Return a stable average perceptual hash for an image or image path.""" + _validate_hash_size(hash_size) + return _perceptual_hash_image(_load_image(source), hash_size) diff --git a/quickthumb/cli.py b/quickthumb/cli.py index 674e2d1..142cbe9 100644 --- a/quickthumb/cli.py +++ b/quickthumb/cli.py @@ -6,7 +6,17 @@ from typing import Annotated import typer - +from PIL import Image + +from quickthumb._diff import ( + DEFAULT_HASH_SIZE, + DEFAULT_HASH_THRESHOLD, + DEFAULT_MAX_DIFFERENT_PIXEL_RATIO, + DEFAULT_PIXEL_TOLERANCE, + _load_image, + compare_images, + create_diff_image, +) from quickthumb.canvas import _VAR_RE, Canvas, _is_theme_reference from quickthumb.errors import RenderingError, ValidationError from quickthumb.schema import canvas_json_schema @@ -135,6 +145,92 @@ def render( typer.echo(str(output)) +@app.command() +def diff( + expected: Annotated[Path, typer.Argument(help="Path to the golden image")], + actual: Annotated[Path, typer.Argument(help="Path to the image under test")], + output: Annotated[ + Path | None, + typer.Option("-o", "--output", help="Write a raw pixel-difference image"), + ] = None, + threshold: Annotated[ + float, + typer.Option("--threshold", min=0.0, max=1.0, help="Minimum perceptual-hash similarity"), + ] = DEFAULT_HASH_THRESHOLD, + pixel_tolerance: Annotated[ + int, + typer.Option( + "--pixel-tolerance", + min=0, + max=255, + help="Ignore per-channel deltas up to this value", + ), + ] = DEFAULT_PIXEL_TOLERANCE, + max_diff_ratio: Annotated[ + float, + typer.Option( + "--max-diff-ratio", + min=0.0, + max=1.0, + help="Maximum fraction of pixels allowed to differ", + ), + ] = DEFAULT_MAX_DIFFERENT_PIXEL_RATIO, + hash_size: Annotated[ + int, + typer.Option("--hash-size", min=2, max=32, help="Perceptual hash width and height"), + ] = DEFAULT_HASH_SIZE, + output_format: Annotated[ + str, + typer.Option("--format", help="Output format: text or json"), + ] = "text", +) -> None: + """Compare a golden image with an image under test for CI or review.""" + diff_format = output_format.lower() + if diff_format not in ("text", "json"): + typer.echo( + f"Invalid diff format '{output_format}'. Must be one of: text, json", + err=True, + ) + raise typer.Exit(1) + + try: + expected_image = _load_image(expected) + actual_image = _load_image(actual) + comparison = compare_images( + expected_image, + actual_image, + threshold=threshold, + pixel_tolerance=pixel_tolerance, + max_different_pixel_ratio=max_diff_ratio, + hash_size=hash_size, + ) + if output is not None: + _save_diff_image(expected_image, actual_image, output) + except (OSError, ValueError) as error: + typer.echo(str(error), err=True) + raise typer.Exit(1) from error + + if diff_format == "json": + payload = comparison.to_dict() + if output is not None: + payload["diff_output"] = str(output) + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + else: + typer.echo(comparison.format_text()) + if output is not None: + typer.echo(f"diff image: {output}") + + if not comparison.matches: + raise typer.Exit(1) + + +def _save_diff_image(expected: Image.Image, actual: Image.Image, output: Path) -> None: + diff_image = create_diff_image(expected, actual) + if output.suffix.lower() in {".jpg", ".jpeg"}: + diff_image = diff_image.convert("RGB") + diff_image.save(output) + + @app.command() def lint( spec: Annotated[Path, typer.Argument(help="Path to a JSON spec file")], diff --git a/tests/test_diff.py b/tests/test_diff.py new file mode 100644 index 0000000..166d1de --- /dev/null +++ b/tests/test_diff.py @@ -0,0 +1,254 @@ +import json + +import pytest +from PIL import Image +from typer.testing import CliRunner + + +def _write_image(path, color, *, size=(32, 32), pixel=None): + image = Image.new("RGBA", size, color) + if pixel is not None: + position, value = pixel + image.putpixel(position, value) + image.save(path) + + +class TestImageDiffCLI: + """Black-box tests for the quickthumb diff command.""" + + def test_should_report_exact_matches_as_json(self, tmp_path): + """diff emits a successful structured result for identical images""" + from quickthumb.cli import app + + # given: two identical golden-image files + expected_path = tmp_path / "expected.png" + actual_path = tmp_path / "actual.png" + _write_image(expected_path, (32, 64, 128, 255)) + _write_image(actual_path, (32, 64, 128, 255)) + + # when: the images are compared in JSON mode + result = CliRunner().invoke( + app, + ["diff", str(expected_path), str(actual_path), "--format", "json"], + ) + + # then: the result is an exact match with stable hash metrics + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["matches"] is True + assert payload["exact"] is True + assert payload["different_pixels"] == 0 + assert payload["hash_distance"] == 0 + assert payload["hash_similarity"] == 1.0 + + def test_should_tolerate_small_raster_noise(self, tmp_path): + """diff ignores small anti-aliasing noise within the default tolerance""" + from quickthumb.cli import app + + # given: an image whose actual render differs by one low-amplitude pixel + expected_path = tmp_path / "expected.png" + actual_path = tmp_path / "actual.png" + _write_image(expected_path, (255, 255, 255, 255)) + _write_image( + actual_path, + (255, 255, 255, 255), + pixel=((12, 12), (255, 254, 255, 255)), + ) + + # when: the images are compared with the default golden-image settings + result = CliRunner().invoke(app, ["diff", str(expected_path), str(actual_path)]) + + # then: the comparison passes while retaining the raw error measurement + assert result.exit_code == 0 + assert "MATCH" in result.output + assert "different pixels: 0/1024" in result.output + assert "max channel delta: 1" in result.output + + def test_should_fail_and_write_a_visual_diff_for_significant_changes(self, tmp_path): + """diff exits one and writes a difference image for a failed comparison""" + from quickthumb.cli import app + + # given: a white golden image and a visibly different black render + expected_path = tmp_path / "expected.png" + actual_path = tmp_path / "actual.png" + diff_path = tmp_path / "diff.png" + _write_image(expected_path, (255, 255, 255, 255)) + _write_image(actual_path, (0, 0, 0, 255)) + + # when: the images are compared and a visual diff is requested + result = CliRunner().invoke( + app, + [ + "diff", + str(expected_path), + str(actual_path), + "--format", + "json", + "--output", + str(diff_path), + ], + ) + + # then: CI receives a mismatch and the diff contains changed pixels + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["matches"] is False + assert payload["different_pixels"] == 1024 + assert payload["diff_output"] == str(diff_path) + with Image.open(diff_path) as diff_image: + assert diff_image.getpixel((0, 0)) == (255, 255, 255, 0) + + def test_should_write_rgb_visual_diffs_as_jpeg(self, tmp_path): + """diff writes a valid RGB image when the requested output is JPEG""" + from quickthumb.cli import app + + # given: two image files and a JPEG diff destination + expected_path = tmp_path / "expected.png" + actual_path = tmp_path / "actual.png" + diff_path = tmp_path / "diff.jpg" + _write_image(expected_path, (255, 255, 255, 255)) + _write_image(actual_path, (0, 0, 0, 255)) + + # when: the images are compared with a JPEG visual diff requested + result = CliRunner().invoke( + app, + ["diff", str(expected_path), str(actual_path), "--output", str(diff_path)], + ) + + # then: the command reports the mismatch and leaves a JPEG-readable diff + assert result.exit_code == 1 + with Image.open(diff_path) as diff_image: + assert diff_image.mode == "RGB" + assert diff_image.format == "JPEG" + + def test_should_report_dimension_mismatches(self, tmp_path): + """diff reports incompatible image dimensions as a failed comparison""" + from quickthumb.cli import app + + # given: images with different canvas dimensions + expected_path = tmp_path / "expected.png" + actual_path = tmp_path / "actual.png" + _write_image(expected_path, (255, 255, 255, 255), size=(32, 32)) + _write_image(actual_path, (255, 255, 255, 255), size=(16, 16)) + + # when: the images are compared in JSON mode + result = CliRunner().invoke( + app, + ["diff", str(expected_path), str(actual_path), "--format", "json"], + ) + + # then: the mismatch includes both sizes and a useful reason + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["matches"] is False + assert payload["expected_size"] == [32, 32] + assert payload["actual_size"] == [16, 16] + assert payload["reason"] == "image dimensions differ" + + def test_should_reject_a_small_high_contrast_patch_by_default(self, tmp_path): + """diff does not hide a visible localized change behind the hash threshold""" + from quickthumb.cli import app + + # given: a white image with a small black patch in the actual render + expected_path = tmp_path / "expected.png" + actual_path = tmp_path / "actual.png" + _write_image(expected_path, (255, 255, 255, 255), size=(32, 32)) + actual_image = Image.new("RGBA", (32, 32), (255, 255, 255, 255)) + for x in range(14, 17): + for y in range(14, 17): + actual_image.putpixel((x, y), (0, 0, 0, 255)) + actual_image.save(actual_path) + + # when: the images are compared with the default strict diff ratio + result = CliRunner().invoke( + app, + ["diff", str(expected_path), str(actual_path), "--format", "json"], + ) + + # then: the visible patch is reported as a mismatch + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["matches"] is False + assert payload["different_pixels"] == 9 + assert payload["max_different_pixel_ratio"] == 0.0 + + def test_should_reject_unknown_output_formats(self, tmp_path): + """diff exits one before reading images when its output format is invalid""" + from quickthumb.cli import app + + # given: paths that would otherwise be valid image inputs + expected_path = tmp_path / "expected.png" + actual_path = tmp_path / "actual.png" + _write_image(expected_path, (255, 255, 255, 255)) + _write_image(actual_path, (255, 255, 255, 255)) + + # when: an unsupported structured-output format is requested + result = CliRunner().invoke( + app, + ["diff", str(expected_path), str(actual_path), "--format", "yaml"], + ) + + # then: the CLI explains the accepted formats + assert result.exit_code == 1 + assert "Must be one of: text, json" in result.output + + +class TestImageDiffAPI: + """Black-box tests for the public golden-image assertion helper.""" + + def test_should_assert_similar_images(self, tmp_path): + """assert_image_similar returns metrics for an accepted comparison""" + from quickthumb import assert_image_similar + + # given: two image files with identical pixels + expected_path = tmp_path / "expected.png" + actual_path = tmp_path / "actual.png" + _write_image(expected_path, (12, 34, 56, 255)) + _write_image(actual_path, (12, 34, 56, 255)) + + # when: the public CI assertion is called + comparison = assert_image_similar(expected_path, actual_path) + + # then: it returns the structured comparison for further reporting + assert comparison.matches is True + assert comparison.exact is True + + def test_should_ignore_hidden_rgb_changes_in_transparent_pixels(self): + """compare_images measures visible output instead of hidden transparent RGB""" + from quickthumb import compare_images + + # given: fully transparent pixels with different hidden RGB values + expected = Image.new("RGBA", (2, 2), (0, 0, 0, 0)) + actual = Image.new("RGBA", (2, 2), (255, 0, 0, 0)) + + # when: the public image comparison uses zero tolerance + comparison = compare_images( + expected, + actual, + pixel_tolerance=0, + max_different_pixel_ratio=0.0, + ) + + # then: invisible channel differences do not count as changed pixels + assert comparison.matches is True + assert comparison.exact is True + assert comparison.different_pixels == 0 + assert comparison.different_pixel_ratio == 0.0 + + def test_should_raise_a_readable_assertion_for_failed_comparison(self, tmp_path): + """assert_image_similar raises a readable failure for changed images""" + from quickthumb import assert_image_similar + + # given: two materially different images + expected_path = tmp_path / "expected.png" + actual_path = tmp_path / "actual.png" + _write_image(expected_path, (255, 255, 255, 255)) + _write_image(actual_path, (0, 0, 0, 255)) + + # when: the public CI assertion is called + with pytest.raises(AssertionError) as error: + assert_image_similar(expected_path, actual_path) + + # then: the exception identifies the failed comparison + assert "MISMATCH" in str(error.value) + assert "different pixels: 1024/1024" in str(error.value)