From 88214746897e1b83cb2509bd5695d669bd8a60b5 Mon Sep 17 00:00:00 2001 From: sjquant Date: Tue, 21 Jul 2026 00:31:05 +0900 Subject: [PATCH 1/3] feat: add golden image diff helpers --- docs/diagnostics.md | 45 +++++++ quickthumb/__init__.py | 12 ++ quickthumb/_diff.py | 285 +++++++++++++++++++++++++++++++++++++++++ quickthumb/cli.py | 85 ++++++++++++ tests/test_diff.py | 182 ++++++++++++++++++++++++++ 5 files changed, 609 insertions(+) create mode 100644 quickthumb/_diff.py create mode 100644 tests/test_diff.py diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 813f0af..7029c9c 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -97,6 +97,51 @@ 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 and at most 1% of +pixels may exceed that tolerance. 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. `--output` writes a raw RGBA +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..52e0da3 --- /dev/null +++ b/quickthumb/_diff.py @@ -0,0 +1,285 @@ +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.01 + + +@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", + ) + + metrics = _pixel_metrics(expected_image, actual_image, 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") + 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 _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 * 4 + 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: + white_background = Image.new("RGBA", image.size, (255, 255, 255, 255)) + composited = Image.alpha_composite(white_background, 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_options( + DEFAULT_HASH_THRESHOLD, + DEFAULT_PIXEL_TOLERANCE, + DEFAULT_MAX_DIFFERENT_PIXEL_RATIO, + hash_size, + ) + return _perceptual_hash_image(_load_image(source), hash_size) diff --git a/quickthumb/cli.py b/quickthumb/cli.py index 674e2d1..619684c 100644 --- a/quickthumb/cli.py +++ b/quickthumb/cli.py @@ -7,6 +7,14 @@ import typer +from quickthumb._diff import ( + DEFAULT_HASH_SIZE, + DEFAULT_HASH_THRESHOLD, + DEFAULT_MAX_DIFFERENT_PIXEL_RATIO, + DEFAULT_PIXEL_TOLERANCE, + 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 +143,83 @@ 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: + comparison = compare_images( + expected, + actual, + threshold=threshold, + pixel_tolerance=pixel_tolerance, + max_different_pixel_ratio=max_diff_ratio, + hash_size=hash_size, + ) + if output is not None: + create_diff_image(expected, actual).save(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) + + @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..05ab6f3 --- /dev/null +++ b/tests/test_diff.py @@ -0,0 +1,182 @@ +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))[:3] == (255, 255, 255) + + 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_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_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) From c251a26a393ed2ce0c0f123b3abdf231dc8f267d Mon Sep 17 00:00:00 2001 From: sjquant Date: Tue, 21 Jul 2026 00:32:53 +0900 Subject: [PATCH 2/3] test: satisfy golden image type checks --- tests/test_diff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_diff.py b/tests/test_diff.py index 05ab6f3..9b94237 100644 --- a/tests/test_diff.py +++ b/tests/test_diff.py @@ -96,7 +96,7 @@ def test_should_fail_and_write_a_visual_diff_for_significant_changes(self, tmp_p 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))[:3] == (255, 255, 255) + assert diff_image.getpixel((0, 0)) == (255, 255, 255, 0) def test_should_report_dimension_mismatches(self, tmp_path): """diff reports incompatible image dimensions as a failed comparison""" From 5921a7b37bbda19b0f67277d806f28fe4d9e5c66 Mon Sep 17 00:00:00 2001 From: sjquant Date: Tue, 21 Jul 2026 22:29:04 +0900 Subject: [PATCH 3/3] fix: harden golden image comparisons --- docs/diagnostics.md | 12 ++++---- quickthumb/_diff.py | 27 ++++++++++------- quickthumb/cli.py | 17 +++++++++-- tests/test_diff.py | 72 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 19 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 7029c9c..68ae2fd 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -107,9 +107,10 @@ 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 and at most 1% of -pixels may exceed that tolerance. The default perceptual-hash similarity -threshold is `0.95`. Tighten or relax those values for a particular renderer: +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 \ @@ -118,8 +119,9 @@ quickthumb diff golden.png output.png \ --max-diff-ratio 0.005 ``` -Use `--format json` for CI or agent pipelines. `--output` writes a raw RGBA -pixel-difference image for visual inspection. Exit codes are: +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 | | --- | --- | diff --git a/quickthumb/_diff.py b/quickthumb/_diff.py index 52e0da3..576c2ac 100644 --- a/quickthumb/_diff.py +++ b/quickthumb/_diff.py @@ -12,7 +12,7 @@ DEFAULT_HASH_SIZE = 8 DEFAULT_HASH_THRESHOLD = 0.95 DEFAULT_PIXEL_TOLERANCE = 2 -DEFAULT_MAX_DIFFERENT_PIXEL_RATIO = 0.01 +DEFAULT_MAX_DIFFERENT_PIXEL_RATIO = 0.0 @dataclass(frozen=True) @@ -164,7 +164,9 @@ def compare_images( reason="image dimensions differ", ) - metrics = _pixel_metrics(expected_image, actual_image, pixel_tolerance) + 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 @@ -202,6 +204,10 @@ def _validate_options( 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") @@ -217,6 +223,11 @@ def _load_image(source: ImageSource) -> Image.Image: 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, @@ -238,7 +249,7 @@ def _pixel_metrics( if any(delta > pixel_tolerance for delta in deltas): different_pixels += 1 - channel_count = total_pixels * 4 + channel_count = total_pixels * 3 return _PixelMetrics( pixel_count=total_pixels, different_pixels=different_pixels, @@ -249,8 +260,7 @@ def _pixel_metrics( def _perceptual_hash_image(image: Image.Image, hash_size: int) -> str: - white_background = Image.new("RGBA", image.size, (255, 255, 255, 255)) - composited = Image.alpha_composite(white_background, image).convert("L") + 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) @@ -276,10 +286,5 @@ def create_diff_image(expected: ImageSource, actual: ImageSource) -> Image.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_options( - DEFAULT_HASH_THRESHOLD, - DEFAULT_PIXEL_TOLERANCE, - DEFAULT_MAX_DIFFERENT_PIXEL_RATIO, - hash_size, - ) + _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 619684c..142cbe9 100644 --- a/quickthumb/cli.py +++ b/quickthumb/cli.py @@ -6,12 +6,14 @@ 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, ) @@ -192,16 +194,18 @@ def diff( raise typer.Exit(1) try: + expected_image = _load_image(expected) + actual_image = _load_image(actual) comparison = compare_images( - expected, - actual, + 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: - create_diff_image(expected, actual).save(output) + _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 @@ -220,6 +224,13 @@ def diff( 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 index 9b94237..166d1de 100644 --- a/tests/test_diff.py +++ b/tests/test_diff.py @@ -98,6 +98,29 @@ def test_should_fail_and_write_a_visual_diff_for_significant_changes(self, tmp_p 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 @@ -122,6 +145,33 @@ def test_should_report_dimension_mismatches(self, tmp_path): 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 @@ -163,6 +213,28 @@ def test_should_assert_similar_images(self, tmp_path): 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