diff --git a/quickthumb/_diagnostic_rules.py b/quickthumb/_diagnostic_rules.py index 3e426c2..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 @@ -37,6 +38,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.""" @@ -406,15 +418,135 @@ 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 return tuple(channel * alpha + 255 * (1 - alpha) for channel in (mean_r, mean_g, mean_b)) +def worst_tile_contrast( + background_image: Image.Image, + foreground_image: Image.Image, + region: BBox, + *, + 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 = ((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): + 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 + + 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( + contrast=contrast, + foreground=foreground, + background=background, + tile=tile, + tile_count=tile_count, + ) + return worst + + +def _tiled_regions(region: BBox, tile_size: int) -> Iterable[BBox]: + 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..8b788e8 100644 --- a/quickthumb/_diagnostics.py +++ b/quickthumb/_diagnostics.py @@ -6,21 +6,15 @@ 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, OverlapMeasurement, SafeMarginPreset, - average_visible_background, + TiledContrastMeasurement, bbox_payload, clear_overlap_suggestion, - contrast_ratio, diagnostic_context, edge_distances, layer_label, @@ -33,6 +27,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 +51,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 +667,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,19 +821,12 @@ def _glyph_signature(self, font, char: str): def _text_background_contrast( self, running: Image.Image, measured: LayerMeasurement - ) -> float | None: - """Worst contrast ratio between the layer's text colors and the area below it.""" + ) -> TiledContrastMeasurement | None: + """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 = {self._text.resolve_color(part, layer) for part in layer.content} - elif layer.color: - text_colors = {self._effects.parse_color(layer.color)} - else: - text_colors = {DEFAULT_TEXT_COLOR} - box = measured.bbox if box is None: return None @@ -838,8 +834,15 @@ 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 + 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 worst_tile_contrast( + running, + foreground, + clamped, + tile_size=CONTRAST_TILE_SIZE, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 93ea0fa..9d1f2c8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1059,6 +1059,62 @@ 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": 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""" from quickthumb.cli import app diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 62469d4..1395b17 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -740,9 +740,172 @@ 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 == { + "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): + """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 == { + "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 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, position=(20, 20)) + ) + + # 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) + + 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 unittest.mock import ANY + + 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": ANY, "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", [