From ee356f20234f198497e7583380a9ea1e145dc8f1 Mon Sep 17 00:00:00 2001 From: sjquant Date: Sun, 5 Jul 2026 17:02:44 +0900 Subject: [PATCH 1/7] feat: add tiled contrast diagnostics --- quickthumb/_diagnostic_rules.py | 53 +++++++++++++++++++++++++++++++++ quickthumb/_diagnostics.py | 40 +++++++++++++++++-------- tests/test_cli.py | 52 ++++++++++++++++++++++++++++++++ tests/test_diagnostics.py | 33 ++++++++++++++++++++ 4 files changed, 166 insertions(+), 12 deletions(-) diff --git a/quickthumb/_diagnostic_rules.py b/quickthumb/_diagnostic_rules.py index 3e426c2..c407631 100644 --- a/quickthumb/_diagnostic_rules.py +++ b/quickthumb/_diagnostic_rules.py @@ -37,6 +37,17 @@ class OverlapMeasurement: upper_visible_pct: float +@dataclass(frozen=True) +class TiledContrastMeasurement: + """Worst contrast found by sampling a region in tiles.""" + + contrast: float + foreground: tuple[float, float, float] + background: tuple[float, float, float] + tile: BBox + tile_count: int + + @dataclass(frozen=True) class SafeMarginOverlay: """A platform UI region that should stay clear of important layer pixels.""" @@ -415,6 +426,48 @@ def average_visible_background(image: Image.Image, region: BBox) -> tuple[float, return tuple(channel * alpha + 255 * (1 - alpha) for channel in (mean_r, mean_g, mean_b)) +def worst_tile_contrast( + image: Image.Image, + region: BBox, + foregrounds: Iterable[tuple[float, float, float]], + *, + tile_size: int, +) -> TiledContrastMeasurement | None: + """Measure the lowest foreground/background contrast across tiled samples.""" + tiles = list(_tiled_regions(region, tile_size)) + if not tiles: + return None + + worst: TiledContrastMeasurement | None = None + for tile in tiles: + background = average_visible_background(image, tile) + for foreground in foregrounds: + contrast = contrast_ratio(foreground, background) + if worst is None or contrast < worst.contrast: + worst = TiledContrastMeasurement( + contrast=contrast, + foreground=foreground, + background=background, + tile=tile, + tile_count=len(tiles), + ) + return worst + + +def _tiled_regions(region: BBox, tile_size: int) -> Iterable[BBox]: + if tile_size <= 0: + raise ValueError("tile_size must be positive") + + for y in range(region.y, region.bottom, tile_size): + for x in range(region.x, region.right, tile_size): + yield BBox.from_points( + x, + y, + min(x + tile_size, region.right), + min(y + tile_size, region.bottom), + ) + + def contrast_ratio(rgb_a: tuple[float, ...], rgb_b: tuple[float, ...]) -> float: lum_a = _relative_luminance(rgb_a) lum_b = _relative_luminance(rgb_b) diff --git a/quickthumb/_diagnostics.py b/quickthumb/_diagnostics.py index 4554b24..e3103a3 100644 --- a/quickthumb/_diagnostics.py +++ b/quickthumb/_diagnostics.py @@ -17,10 +17,10 @@ LayerAlphaCache, OverlapMeasurement, SafeMarginPreset, + TiledContrastMeasurement, average_visible_background, bbox_payload, clear_overlap_suggestion, - contrast_ratio, diagnostic_context, edge_distances, layer_label, @@ -33,6 +33,7 @@ resolve_safe_margins, safe_area_bbox, visible_leaf_layers, + worst_tile_contrast, ) from quickthumb._effects import EffectsEngine from quickthumb._groups import GroupEngine @@ -56,6 +57,7 @@ TINY_TEXT_RATIO = 0.025 LOW_CONTRAST_THRESHOLD = 2.0 +CONTRAST_TILE_SIZE = 32 MIN_PARTIAL_OVERLAP_RATIO = 0.2 BACKDROP_COVERAGE_RATIO = 0.95 OVERLAP_CLEARANCE_PX = 8 @@ -671,19 +673,26 @@ def _diagnose_text_layer( ) contrast = self._text_background_contrast(running, measured) - if contrast is not None and contrast < LOW_CONTRAST_THRESHOLD: + if contrast is not None and contrast.contrast < LOW_CONTRAST_THRESHOLD: findings.append( Diagnostic( code="low-contrast", severity="warning", layer_index=measured.index, message=( - f"text contrast ratio {contrast:.2f} against the layers below it " + f"text worst-tile contrast ratio {contrast.contrast:.2f} " + "against the layers below it " f"is under {LOW_CONTRAST_THRESHOLD}; the text may be hard to read" ), measured={ - "contrast": contrast, + "contrast": contrast.contrast, "threshold": LOW_CONTRAST_THRESHOLD, + "method": "worst-tile", + "tile_bbox": bbox_payload(contrast.tile), + "tile_count": contrast.tile_count, + "tile_size": CONTRAST_TILE_SIZE, + "foreground_rgb": contrast.foreground, + "background_rgb": contrast.background, }, suggestion=( f"increase foreground/background contrast to at least " @@ -818,18 +827,20 @@ def _glyph_signature(self, font, char: str): def _text_background_contrast( self, running: Image.Image, measured: LayerMeasurement - ) -> float | None: + ) -> TiledContrastMeasurement | None: """Worst contrast ratio between the layer's text colors and the area below it.""" layer = measured.effective_text_layer if layer is None: return None if isinstance(layer.content, list): - text_colors = {self._text.resolve_color(part, layer) for part in layer.content} + text_colors = { + _rgb_tuple(self._text.resolve_color(part, layer)) for part in layer.content + } elif layer.color: - text_colors = {self._effects.parse_color(layer.color)} + text_colors = {_rgb_tuple(self._effects.parse_color(layer.color))} else: - text_colors = {DEFAULT_TEXT_COLOR} + text_colors = {_rgb_tuple(DEFAULT_TEXT_COLOR)} box = measured.bbox if box is None: @@ -838,8 +849,13 @@ def _text_background_contrast( if clamped is None: return None - background = average_visible_background(running, clamped) - - return min( - contrast_ratio(tuple(float(c) for c in color[:3]), background) for color in text_colors + return worst_tile_contrast( + running, + clamped, + text_colors, + tile_size=CONTRAST_TILE_SIZE, ) + + +def _rgb_tuple(color: tuple[int, ...]) -> tuple[float, float, float]: + return float(color[0]), float(color[1]), float(color[2]) diff --git a/tests/test_cli.py b/tests/test_cli.py index 93ea0fa..3432ee6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1059,6 +1059,58 @@ def test_should_exit_1_when_platform_spec_has_only_one_dimension(self): assert result.exit_code == 1 assert "'width' and 'height' must be integers" in result.output + def test_should_emit_worst_tile_contrast_json_for_busy_background(self): + """lint --format json reports the tile that drives low-contrast text""" + from PIL import Image + from quickthumb.cli import app + + # given: a raster background with white text crossing black and white regions + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as image_file: + image_path = image_file.name + image = Image.new("RGBA", (240, 120), (0, 0, 0, 255)) + image.paste((255, 255, 255, 255), (116, 0, 240, 120)) + image.save(image_path) + spec_path = self._write_spec( + { + "width": 240, + "height": 120, + "layers": [ + {"type": "background", "image": image_path}, + { + "type": "text", + "content": "BUSY TITLE", + "size": 36, + "color": "#FFFFFF", + "position": [20, 30], + }, + ], + } + ) + + # when + try: + result = CliRunner().invoke(app, ["lint", spec_path, "--format", "json"]) + finally: + os.unlink(spec_path) + os.unlink(image_path) + + # then + assert result.exit_code == 3 + payload = json.loads(result.output) + assert payload["summary"] == { + "diagnostic_count": 1, + "error_count": 0, + "warning_count": 1, + } + finding = payload["diagnostics"][0] + assert finding["code"] == "low-contrast" + assert finding["measured"]["contrast"] < finding["measured"]["threshold"] + assert finding["measured"]["method"] == "worst-tile" + assert finding["measured"]["tile_size"] == 32 + assert finding["measured"]["tile_count"] > 1 + assert finding["measured"]["tile_bbox"]["x"] >= 116 + assert finding["measured"]["background_rgb"][0] > 240 + def test_should_exit_1_for_invalid_lint_format(self, spec_file): """lint exits 1 when --format is neither text nor json""" from quickthumb.cli import app diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 62469d4..675bee6 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -741,8 +741,41 @@ def test_should_include_structured_values_for_low_contrast_text(self): assert finding.bbox.height > 0 assert finding.related_layers == ["layer:1"] assert finding.measured["contrast"] < finding.measured["threshold"] + assert finding.measured["method"] == "worst-tile" + assert finding.measured["tile_count"] >= 1 + assert finding.measured["tile_size"] == 32 + assert finding.measured["tile_bbox"]["width"] > 0 + assert finding.measured["tile_bbox"]["height"] > 0 + assert len(finding.measured["foreground_rgb"]) == 3 + assert len(finding.measured["background_rgb"]) == 3 assert finding.suggestion == "increase foreground/background contrast to at least 2.0:1" + def test_should_warn_for_worst_tile_contrast_on_busy_background(self): + """Mixed backgrounds fail when any tile under the text has low contrast""" + from quickthumb import Canvas + + def paint_split_background(image: Image.Image) -> None: + image.paste((0, 0, 0, 255), (0, 0, 240, 120)) + image.paste((255, 255, 255, 255), (116, 0, 240, 120)) + + # given: white text spans a black/white split background whose average is readable + canvas = ( + Canvas(240, 120) + .custom(paint_split_background) + .text("BUSY TITLE", size=36, color="#FFFFFF", position=(20, 30)) + ) + + # when + diagnostics = canvas.diagnose() + + # then + assert [d.code for d in diagnostics] == ["low-contrast"] + finding = diagnostics[0] + assert finding.measured["method"] == "worst-tile" + assert finding.measured["contrast"] < finding.measured["threshold"] + assert finding.measured["tile_bbox"]["x"] >= 116 + assert finding.measured["background_rgb"][0] > 240 + @pytest.mark.parametrize( "background,color", [ From 3a50db17ba856e5337a50c6e4f3f05cff1fd89df Mon Sep 17 00:00:00 2001 From: sjquant Date: Sun, 5 Jul 2026 17:23:07 +0900 Subject: [PATCH 2/7] fix: sample contrast over text pixels --- quickthumb/_diagnostic_rules.py | 65 +++++++++++++++++++++++---------- quickthumb/_diagnostics.py | 31 ++++++---------- tests/test_diagnostics.py | 57 +++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 39 deletions(-) diff --git a/quickthumb/_diagnostic_rules.py b/quickthumb/_diagnostic_rules.py index c407631..793fc14 100644 --- a/quickthumb/_diagnostic_rules.py +++ b/quickthumb/_diagnostic_rules.py @@ -417,9 +417,14 @@ def _alpha_region( return mask.point(lambda value: 255 if value else 0) -def average_visible_background(image: Image.Image, region: BBox) -> tuple[float, float, float]: +def average_visible_background( + image: Image.Image, region: BBox, mask: Image.Image | None = None +) -> tuple[float, float, float]: crop = image.crop((region.x, region.y, region.right, region.bottom)) - mean_r, mean_g, mean_b, mean_a = ImageStat.Stat(crop).mean + mask_crop = ( + None if mask is None else mask.crop((region.x, region.y, region.right, region.bottom)) + ) + mean_r, mean_g, mean_b, mean_a = ImageStat.Stat(crop, mask=mask_crop).mean # Transparent areas read as white, matching JPEG export and typical viewers. alpha = mean_a / 255 @@ -427,33 +432,53 @@ def average_visible_background(image: Image.Image, region: BBox) -> tuple[float, def worst_tile_contrast( - image: Image.Image, + background_image: Image.Image, + foreground_image: Image.Image, region: BBox, - foregrounds: Iterable[tuple[float, float, float]], *, tile_size: int, ) -> TiledContrastMeasurement | None: - """Measure the lowest foreground/background contrast across tiled samples.""" - tiles = list(_tiled_regions(region, tile_size)) - if not tiles: - return None + """Measure the lowest text-pixel contrast across tiled samples.""" + foreground_alpha = foreground_image.getchannel("A").point(lambda value: 255 if value else 0) + tile_count = _tile_count(region, tile_size) worst: TiledContrastMeasurement | None = None - for tile in tiles: - background = average_visible_background(image, tile) - for foreground in foregrounds: - contrast = contrast_ratio(foreground, background) - if worst is None or contrast < worst.contrast: - worst = TiledContrastMeasurement( - contrast=contrast, - foreground=foreground, - background=background, - tile=tile, - tile_count=len(tiles), - ) + for tile in _tiled_regions(region, tile_size): + alpha_tile = foreground_alpha.crop((tile.x, tile.y, tile.right, tile.bottom)) + if alpha_tile.getbbox() is None: + continue + + background = average_visible_background(background_image, tile, mask=foreground_alpha) + foreground = _average_visible_foreground(foreground_image, tile, foreground_alpha) + contrast = contrast_ratio(foreground, background) + if worst is None or contrast < worst.contrast: + worst = TiledContrastMeasurement( + contrast=contrast, + foreground=foreground, + background=background, + tile=tile, + tile_count=tile_count, + ) return worst +def _average_visible_foreground( + image: Image.Image, region: BBox, mask: Image.Image +) -> tuple[float, float, float]: + crop = image.crop((region.x, region.y, region.right, region.bottom)) + mask_crop = mask.crop((region.x, region.y, region.right, region.bottom)) + mean_r, mean_g, mean_b, *_ = ImageStat.Stat(crop, mask=mask_crop).mean + return mean_r, mean_g, mean_b + + +def _tile_count(region: BBox, tile_size: int) -> int: + if tile_size <= 0: + raise ValueError("tile_size must be positive") + cols = (region.width + tile_size - 1) // tile_size + rows = (region.height + tile_size - 1) // tile_size + return cols * rows + + def _tiled_regions(region: BBox, tile_size: int) -> Iterable[BBox]: if tile_size <= 0: raise ValueError("tile_size must be positive") diff --git a/quickthumb/_diagnostics.py b/quickthumb/_diagnostics.py index e3103a3..56d1379 100644 --- a/quickthumb/_diagnostics.py +++ b/quickthumb/_diagnostics.py @@ -6,12 +6,7 @@ from PIL import Image, ImageChops -from quickthumb._base import ( - DEFAULT_TEXT_COLOR, - DEFAULT_TEXT_SIZE, - RenderContext, - parse_coordinate, -) +from quickthumb._base import DEFAULT_TEXT_SIZE, RenderContext, parse_coordinate from quickthumb._diagnostic_rules import ( PLATFORM_SAFE_MARGIN_PRESETS, LayerAlphaCache, @@ -828,20 +823,11 @@ def _glyph_signature(self, font, char: str): def _text_background_contrast( self, running: Image.Image, measured: LayerMeasurement ) -> TiledContrastMeasurement | None: - """Worst contrast ratio between the layer's text colors and the area below it.""" + """Worst contrast ratio between the layer's visible text pixels and the area below.""" layer = measured.effective_text_layer if layer is None: return None - if isinstance(layer.content, list): - text_colors = { - _rgb_tuple(self._text.resolve_color(part, layer)) for part in layer.content - } - elif layer.color: - text_colors = {_rgb_tuple(self._effects.parse_color(layer.color))} - else: - text_colors = {_rgb_tuple(DEFAULT_TEXT_COLOR)} - box = measured.bbox if box is None: return None @@ -849,13 +835,20 @@ def _text_background_contrast( if clamped is None: return None + foreground = self._render_text_foreground(layer) return worst_tile_contrast( running, + foreground, clamped, - text_colors, tile_size=CONTRAST_TILE_SIZE, ) + def _render_text_foreground(self, layer: TextLayer) -> Image.Image: + content = layer.content + if isinstance(content, list): + content = [part.model_copy(update={"effects": []}) for part in content] + foreground_layer = layer.model_copy(update={"content": content, "effects": []}) -def _rgb_tuple(color: tuple[int, ...]) -> tuple[float, float, float]: - return float(color[0]), float(color[1]), float(color[2]) + foreground = Image.new("RGBA", (self._ctx.width, self._ctx.height), (0, 0, 0, 0)) + self._text.render_text_layer(foreground, foreground_layer) + return foreground diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 675bee6..08c98ad 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -776,6 +776,63 @@ def paint_split_background(image: Image.Image) -> None: assert finding.measured["tile_bbox"]["x"] >= 116 assert finding.measured["background_rgb"][0] > 240 + def test_should_ignore_low_contrast_tiles_without_text_pixels(self): + """Whitespace inside the text bbox does not drive low-contrast findings""" + from quickthumb import Canvas + + def paint_background_with_light_gap(image: Image.Image) -> None: + image.paste((0, 0, 0, 255), (0, 0, 300, 120)) + image.paste((255, 255, 255, 255), (70, 0, 120, 120)) + + # given: white glyphs sit on black while only the empty space crosses white + canvas = ( + Canvas(300, 120) + .custom(paint_background_with_light_gap) + .text("A A", size=36, color="#FFFFFF", position=(20, 30)) + ) + + # when / then + assert canvas.diagnose() == [] + + def test_should_not_warn_for_readable_rich_text_on_split_background(self): + """Rich text colors are compared only where each colored run renders""" + from quickthumb import Canvas + + def paint_split_background(image: Image.Image) -> None: + image.paste((0, 0, 0, 255), (0, 0, 300, 120)) + image.paste((255, 255, 255, 255), (70, 0, 300, 120)) + + # given: white rich text renders on black and black rich text renders on white + canvas = ( + Canvas(300, 120) + .custom(paint_split_background) + .text( + [ + {"text": "L", "color": "#FFFFFF"}, + {"text": " R", "color": "#000000"}, + ], + size=36, + position=(20, 30), + ) + ) + + # when / then + assert canvas.diagnose() == [] + + def test_should_use_default_text_color_for_worst_tile_contrast(self): + """Text without a color still uses the public default black foreground""" + from quickthumb import Canvas + + # given: default black text over a black background + canvas = Canvas(200, 120).background(color="#000000").text("default", size=36) + + # when + diagnostics = canvas.diagnose() + + # then + assert [finding.code for finding in diagnostics] == ["low-contrast"] + assert diagnostics[0].measured["foreground_rgb"] == (0.0, 0.0, 0.0) + @pytest.mark.parametrize( "background,color", [ From 59d0e60db83080fcd88d8f811b621273d19c6344 Mon Sep 17 00:00:00 2001 From: sjquant Date: Sun, 5 Jul 2026 17:28:45 +0900 Subject: [PATCH 3/7] refactor: simplify contrast sampling --- quickthumb/_diagnostic_rules.py | 31 +++++++++---------------------- quickthumb/_diagnostics.py | 17 ++++++----------- 2 files changed, 15 insertions(+), 33 deletions(-) diff --git a/quickthumb/_diagnostic_rules.py b/quickthumb/_diagnostic_rules.py index 793fc14..e256df4 100644 --- a/quickthumb/_diagnostic_rules.py +++ b/quickthumb/_diagnostic_rules.py @@ -439,8 +439,13 @@ def worst_tile_contrast( tile_size: int, ) -> TiledContrastMeasurement | None: """Measure the lowest text-pixel contrast across tiled samples.""" + if tile_size <= 0: + raise ValueError("tile_size must be positive") + foreground_alpha = foreground_image.getchannel("A").point(lambda value: 255 if value else 0) - tile_count = _tile_count(region, tile_size) + tile_count = ((region.width + tile_size - 1) // tile_size) * ( + (region.height + tile_size - 1) // tile_size + ) worst: TiledContrastMeasurement | None = None for tile in _tiled_regions(region, tile_size): @@ -449,7 +454,9 @@ def worst_tile_contrast( continue background = average_visible_background(background_image, tile, mask=foreground_alpha) - foreground = _average_visible_foreground(foreground_image, tile, foreground_alpha) + foreground_crop = foreground_image.crop((tile.x, tile.y, tile.right, tile.bottom)) + foreground_mask = foreground_alpha.crop((tile.x, tile.y, tile.right, tile.bottom)) + foreground = tuple(ImageStat.Stat(foreground_crop, mask=foreground_mask).mean[:3]) contrast = contrast_ratio(foreground, background) if worst is None or contrast < worst.contrast: worst = TiledContrastMeasurement( @@ -462,27 +469,7 @@ def worst_tile_contrast( return worst -def _average_visible_foreground( - image: Image.Image, region: BBox, mask: Image.Image -) -> tuple[float, float, float]: - crop = image.crop((region.x, region.y, region.right, region.bottom)) - mask_crop = mask.crop((region.x, region.y, region.right, region.bottom)) - mean_r, mean_g, mean_b, *_ = ImageStat.Stat(crop, mask=mask_crop).mean - return mean_r, mean_g, mean_b - - -def _tile_count(region: BBox, tile_size: int) -> int: - if tile_size <= 0: - raise ValueError("tile_size must be positive") - cols = (region.width + tile_size - 1) // tile_size - rows = (region.height + tile_size - 1) // tile_size - return cols * rows - - def _tiled_regions(region: BBox, tile_size: int) -> Iterable[BBox]: - if tile_size <= 0: - raise ValueError("tile_size must be positive") - for y in range(region.y, region.bottom, tile_size): for x in range(region.x, region.right, tile_size): yield BBox.from_points( diff --git a/quickthumb/_diagnostics.py b/quickthumb/_diagnostics.py index 56d1379..4098fd6 100644 --- a/quickthumb/_diagnostics.py +++ b/quickthumb/_diagnostics.py @@ -835,20 +835,15 @@ def _text_background_contrast( if clamped is None: return None - foreground = self._render_text_foreground(layer) - return worst_tile_contrast( - running, - foreground, - clamped, - tile_size=CONTRAST_TILE_SIZE, - ) - - def _render_text_foreground(self, layer: TextLayer) -> Image.Image: content = layer.content if isinstance(content, list): content = [part.model_copy(update={"effects": []}) for part in content] foreground_layer = layer.model_copy(update={"content": content, "effects": []}) - foreground = Image.new("RGBA", (self._ctx.width, self._ctx.height), (0, 0, 0, 0)) self._text.render_text_layer(foreground, foreground_layer) - return foreground + return worst_tile_contrast( + running, + foreground, + clamped, + tile_size=CONTRAST_TILE_SIZE, + ) From a0be48d5fa866d9ce732701b4c1d4844d85c3ac1 Mon Sep 17 00:00:00 2001 From: sjquant Date: Sun, 5 Jul 2026 17:34:40 +0900 Subject: [PATCH 4/7] test: assert contrast payloads directly --- tests/test_cli.py | 16 ++++++++++------ tests/test_diagnostics.py | 32 ++++++++++++++++++++------------ 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 3432ee6..9d1f2c8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1104,12 +1104,16 @@ def test_should_emit_worst_tile_contrast_json_for_busy_background(self): } finding = payload["diagnostics"][0] assert finding["code"] == "low-contrast" - assert finding["measured"]["contrast"] < finding["measured"]["threshold"] - assert finding["measured"]["method"] == "worst-tile" - assert finding["measured"]["tile_size"] == 32 - assert finding["measured"]["tile_count"] > 1 - assert finding["measured"]["tile_bbox"]["x"] >= 116 - assert finding["measured"]["background_rgb"][0] > 240 + assert finding["measured"] == { + "contrast": 1.0, + "threshold": 2.0, + "method": "worst-tile", + "tile_bbox": {"x": 116, "y": 30, "width": 32, "height": 26}, + "tile_count": 6, + "tile_size": 32, + "foreground_rgb": [255.0, 255.0, 255.0], + "background_rgb": [255.0, 255.0, 255.0], + } def test_should_exit_1_for_invalid_lint_format(self, spec_file): """lint exits 1 when --format is neither text nor json""" diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 08c98ad..aa569d8 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -740,14 +740,16 @@ def test_should_include_structured_values_for_low_contrast_text(self): assert finding.bbox.width > 0 assert finding.bbox.height > 0 assert finding.related_layers == ["layer:1"] - assert finding.measured["contrast"] < finding.measured["threshold"] - assert finding.measured["method"] == "worst-tile" - assert finding.measured["tile_count"] >= 1 - assert finding.measured["tile_size"] == 32 - assert finding.measured["tile_bbox"]["width"] > 0 - assert finding.measured["tile_bbox"]["height"] > 0 - assert len(finding.measured["foreground_rgb"]) == 3 - assert len(finding.measured["background_rgb"]) == 3 + assert finding.measured == { + "contrast": 1.0620159366897584, + "threshold": 2.0, + "method": "worst-tile", + "tile_bbox": {"x": 10, "y": 10, "width": 32, "height": 32}, + "tile_count": 12, + "tile_size": 32, + "foreground_rgb": (248.0, 248.0, 248.0), + "background_rgb": (255.0, 255.0, 255.0), + } assert finding.suggestion == "increase foreground/background contrast to at least 2.0:1" def test_should_warn_for_worst_tile_contrast_on_busy_background(self): @@ -771,10 +773,16 @@ def paint_split_background(image: Image.Image) -> None: # then assert [d.code for d in diagnostics] == ["low-contrast"] finding = diagnostics[0] - assert finding.measured["method"] == "worst-tile" - assert finding.measured["contrast"] < finding.measured["threshold"] - assert finding.measured["tile_bbox"]["x"] >= 116 - assert finding.measured["background_rgb"][0] > 240 + assert finding.measured == { + "contrast": 1.0, + "threshold": 2.0, + "method": "worst-tile", + "tile_bbox": {"x": 116, "y": 30, "width": 32, "height": 26}, + "tile_count": 6, + "tile_size": 32, + "foreground_rgb": (255.0, 255.0, 255.0), + "background_rgb": (255.0, 255.0, 255.0), + } def test_should_ignore_low_contrast_tiles_without_text_pixels(self): """Whitespace inside the text bbox does not drive low-contrast findings""" From 8158047824bfb5fb26ea31ed0761c5d03352518a Mon Sep 17 00:00:00 2001 From: sjquant Date: Sun, 5 Jul 2026 17:43:43 +0900 Subject: [PATCH 5/7] fix: align contrast tests with visibility diagnostics --- quickthumb/_diagnostics.py | 1 - tests/test_diagnostics.py | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/quickthumb/_diagnostics.py b/quickthumb/_diagnostics.py index 4098fd6..8b788e8 100644 --- a/quickthumb/_diagnostics.py +++ b/quickthumb/_diagnostics.py @@ -13,7 +13,6 @@ OverlapMeasurement, SafeMarginPreset, TiledContrastMeasurement, - average_visible_background, bbox_payload, clear_overlap_suggestion, diagnostic_context, diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index aa569d8..39aca6f 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -832,7 +832,9 @@ def test_should_use_default_text_color_for_worst_tile_contrast(self): from quickthumb import Canvas # given: default black text over a black background - canvas = Canvas(200, 120).background(color="#000000").text("default", size=36) + canvas = ( + Canvas(200, 120).background(color="#000000").text("default", size=36, position=(20, 20)) + ) # when diagnostics = canvas.diagnose() From 8c876e43a1cbb70a86604ff74a1aa48afd4f10ff Mon Sep 17 00:00:00 2001 From: sjquant Date: Sun, 5 Jul 2026 17:58:55 +0900 Subject: [PATCH 6/7] fix: preserve rendered text contrast --- quickthumb/_diagnostic_rules.py | 77 ++++++++++++++++++++++++++++++--- tests/test_diagnostics.py | 61 ++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 5 deletions(-) diff --git a/quickthumb/_diagnostic_rules.py b/quickthumb/_diagnostic_rules.py index e256df4..12f27d1 100644 --- a/quickthumb/_diagnostic_rules.py +++ b/quickthumb/_diagnostic_rules.py @@ -1,5 +1,6 @@ from collections.abc import Callable, Iterable from dataclasses import dataclass +from typing import cast from PIL import Image, ImageChops, ImageStat from typing_extensions import TypedDict @@ -446,17 +447,83 @@ def worst_tile_contrast( tile_count = ((region.width + tile_size - 1) // tile_size) * ( (region.height + tile_size - 1) // tile_size ) + background_region = background_image.crop( + (region.x, region.y, region.right, region.bottom) + ).convert("RGBA") + foreground_region = foreground_image.crop( + (region.x, region.y, region.right, region.bottom) + ).convert("RGBA") + foreground_alpha_region = foreground_alpha.crop( + (region.x, region.y, region.right, region.bottom) + ) worst: TiledContrastMeasurement | None = None for tile in _tiled_regions(region, tile_size): - alpha_tile = foreground_alpha.crop((tile.x, tile.y, tile.right, tile.bottom)) + local_tile = BBox(tile.x - region.x, tile.y - region.y, tile.width, tile.height) + alpha_tile = foreground_alpha_region.crop( + (local_tile.x, local_tile.y, local_tile.right, local_tile.bottom) + ) if alpha_tile.getbbox() is None: continue - background = average_visible_background(background_image, tile, mask=foreground_alpha) - foreground_crop = foreground_image.crop((tile.x, tile.y, tile.right, tile.bottom)) - foreground_mask = foreground_alpha.crop((tile.x, tile.y, tile.right, tile.bottom)) - foreground = tuple(ImageStat.Stat(foreground_crop, mask=foreground_mask).mean[:3]) + measurement = _tile_contrast( + background_region, + foreground_region, + local_tile, + tile=tile, + tile_count=tile_count, + ) + if measurement is not None and (worst is None or measurement.contrast < worst.contrast): + worst = measurement + return worst + + +def _tile_contrast( + background_region: Image.Image, + foreground_region: Image.Image, + region: BBox, + *, + tile: BBox, + tile_count: int, +) -> TiledContrastMeasurement | None: + background_crop = background_region.crop((region.x, region.y, region.right, region.bottom)) + foreground_crop = foreground_region.crop((region.x, region.y, region.right, region.bottom)) + background_pixels = background_crop.load() + foreground_pixels = foreground_crop.load() + assert background_pixels is not None and foreground_pixels is not None + groups: dict[tuple[int, int, int], list[float]] = {} + + for y in range(region.height): + for x in range(region.width): + background_pixel = cast(tuple[int, int, int, int], background_pixels[x, y]) + foreground_pixel = cast(tuple[int, int, int, int], foreground_pixels[x, y]) + foreground_r, foreground_g, foreground_b, foreground_a = foreground_pixel + if foreground_a == 0: + continue + + background_alpha = background_pixel[3] / 255 + visible_background = ( + background_pixel[0] * background_alpha + 255 * (1 - background_alpha), + background_pixel[1] * background_alpha + 255 * (1 - background_alpha), + background_pixel[2] * background_alpha + 255 * (1 - background_alpha), + ) + key = (foreground_r, foreground_g, foreground_b) + group = groups.setdefault(key, [0.0, 0.0, 0.0, 0.0, 0.0]) + group[0] += visible_background[0] + group[1] += visible_background[1] + group[2] += visible_background[2] + group[3] += 1 + group[4] = max(group[4], foreground_a) + + worst: TiledContrastMeasurement | None = None + for foreground_raw, group in groups.items(): + background = (group[0] / group[3], group[1] / group[3], group[2] / group[3]) + opacity = group[4] / 255 + foreground = ( + foreground_raw[0] * opacity + background[0] * (1 - opacity), + foreground_raw[1] * opacity + background[1] * (1 - opacity), + foreground_raw[2] * opacity + background[2] * (1 - opacity), + ) contrast = contrast_ratio(foreground, background) if worst is None or contrast < worst.contrast: worst = TiledContrastMeasurement( diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 39aca6f..13f2126 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -843,6 +843,67 @@ def test_should_use_default_text_color_for_worst_tile_contrast(self): assert [finding.code for finding in diagnostics] == ["low-contrast"] assert diagnostics[0].measured["foreground_rgb"] == (0.0, 0.0, 0.0) + def test_should_warn_for_low_opacity_text(self): + """Semi-transparent text is checked by its effective rendered contrast""" + from quickthumb import Canvas + + # given: faint black text renders close to white over a white background + canvas = ( + Canvas(240, 120) + .background(color="#FFFFFF") + .text("faint", size=36, color="#000000", opacity=0.1, position=(20, 20)) + ) + + # when + diagnostics = canvas.diagnose() + + # then + assert [finding.code for finding in diagnostics] == ["low-contrast"] + assert diagnostics[0].measured == { + "contrast": 1.2480715209939224, + "threshold": 2.0, + "method": "worst-tile", + "tile_bbox": {"x": 20, "y": 20, "width": 32, "height": 28}, + "tile_count": 3, + "tile_size": 32, + "foreground_rgb": (230.0, 230.0, 230.0), + "background_rgb": (255.0, 255.0, 255.0), + } + + def test_should_warn_for_low_contrast_rich_text_run_inside_tile(self): + """A high-contrast run cannot hide a low-contrast run in the same tile""" + from quickthumb import Canvas + + # given: white rich text is readable but the following black run is invisible + canvas = ( + Canvas(260, 120) + .background(color="#000000") + .text( + [ + {"text": "HELLO", "color": "#FFFFFF"}, + {"text": "I", "color": "#000000"}, + ], + size=36, + position=(20, 20), + ) + ) + + # when + diagnostics = canvas.diagnose() + + # then + assert [finding.code for finding in diagnostics] == ["low-contrast"] + assert diagnostics[0].measured == { + "contrast": 1.0, + "threshold": 2.0, + "method": "worst-tile", + "tile_bbox": {"x": 116, "y": 20, "width": 26, "height": 32}, + "tile_count": 8, + "tile_size": 32, + "foreground_rgb": (0.0, 0.0, 0.0), + "background_rgb": (0.0, 0.0, 0.0), + } + @pytest.mark.parametrize( "background,color", [ From b26ff78fabbb405fab57fe671f7d5607f4eba7c8 Mon Sep 17 00:00:00 2001 From: sjquant Date: Sun, 5 Jul 2026 18:08:15 +0900 Subject: [PATCH 7/7] test: stabilize contrast tile assertion --- tests/test_diagnostics.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 13f2126..1395b17 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -872,6 +872,8 @@ def test_should_warn_for_low_opacity_text(self): def test_should_warn_for_low_contrast_rich_text_run_inside_tile(self): """A high-contrast run cannot hide a low-contrast run in the same tile""" + from unittest.mock import ANY + from quickthumb import Canvas # given: white rich text is readable but the following black run is invisible @@ -897,7 +899,7 @@ def test_should_warn_for_low_contrast_rich_text_run_inside_tile(self): "contrast": 1.0, "threshold": 2.0, "method": "worst-tile", - "tile_bbox": {"x": 116, "y": 20, "width": 26, "height": 32}, + "tile_bbox": {"x": 116, "y": 20, "width": ANY, "height": 32}, "tile_count": 8, "tile_size": 32, "foreground_rgb": (0.0, 0.0, 0.0),