From 88d2062b6a097ab9ff066ddedc1064eee5d683fd Mon Sep 17 00:00:00 2001 From: sjquant Date: Thu, 2 Jul 2026 21:28:27 +0900 Subject: [PATCH 1/5] Add layer overlap diagnostics --- quickthumb/_diagnostics.py | 121 ++++++++++++++++++++++- quickthumb/_measurements.py | 7 ++ quickthumb/models.py | 2 +- tests/test_diagnostics.py | 189 ++++++++++++++++++++++++++++++++++++ 4 files changed, 317 insertions(+), 2 deletions(-) diff --git a/quickthumb/_diagnostics.py b/quickthumb/_diagnostics.py index 521a2ee..e6769e7 100644 --- a/quickthumb/_diagnostics.py +++ b/quickthumb/_diagnostics.py @@ -20,6 +20,9 @@ TINY_TEXT_RATIO = 0.025 LOW_CONTRAST_THRESHOLD = 2.0 +MIN_PARTIAL_OVERLAP_RATIO = 0.2 +BACKDROP_COVERAGE_RATIO = 0.95 +OVERLAP_CLEARANCE_PX = 8 def _relative_luminance(rgb: tuple[float, ...]) -> float: @@ -67,8 +70,9 @@ def diagnose(self) -> list[Diagnostic]: self._ctx.begin_render_pass() diagnostics: list[Diagnostic] = [] + measurements = measure_layers(self._canvas) running = self._canvas._create_canvas() - for measured in measure_layers(self._canvas): + for measured in measurements: if measured.visible: layer = measured.raw_layer if isinstance(layer, TextLayer): @@ -85,8 +89,123 @@ def diagnose(self) -> list[Diagnostic]: self._canvas._render_layer(running, measured.raw_layer) + diagnostics.extend(self._diagnose_layer_overlaps(measurements)) + return diagnostics + def _diagnose_layer_overlaps(self, measurements: list[LayerMeasurement]) -> list[Diagnostic]: + candidates = self._overlap_candidates(measurements) + findings: list[Diagnostic] = [] + for lower_index, lower in enumerate(candidates): + for upper in candidates[lower_index + 1 :]: + finding = self._diagnose_candidate_overlap(lower, upper) + if finding is not None: + findings.append(finding) + return findings + + def _overlap_candidates(self, measurements: list[LayerMeasurement]) -> list[LayerMeasurement]: + candidates: list[LayerMeasurement] = [] + for measured in measurements: + self._append_overlap_candidates(candidates, measured) + return candidates + + def _append_overlap_candidates( + self, candidates: list[LayerMeasurement], measured: LayerMeasurement + ) -> None: + if measured.children: + for child in measured.children: + self._append_overlap_candidates(candidates, child) + return + + if measured.visible and measured.bbox is not None and not measured.bbox.is_empty: + candidates.append(measured) + + def _diagnose_candidate_overlap( + self, lower: LayerMeasurement, upper: LayerMeasurement + ) -> Diagnostic | None: + lower_box = lower.bbox + upper_box = upper.bbox + if lower_box is None or upper_box is None: + return None + + overlap = lower_box.intersection(upper_box) + if overlap is None: + return None + + if not self._is_suspicious_overlap(lower, upper, overlap.area): + return None + + lower_pct = overlap.area / lower_box.area + upper_pct = overlap.area / upper_box.area + suggestion = self._overlap_suggestion(upper, lower) + return Diagnostic( + code="layer-overlap", + severity="warning", + layer_index=upper.index, + message=( + f"{self._layer_label(upper)} (order {upper.order}) " + f"overlaps {self._layer_label(lower)} " + f"(order {lower.order}) by {overlap.area}px " + f"({upper_pct:.0%} of upper, {lower_pct:.0%} of lower); {suggestion}" + ), + ) + + def _is_suspicious_overlap( + self, lower: LayerMeasurement, upper: LayerMeasurement, overlap_area: int + ) -> bool: + if lower.layer_type == "text" and upper.layer_type == "text": + return True + + if self._is_text_on_backdrop(lower, upper, overlap_area): + return False + + lower_area = lower.bbox.area if lower.bbox is not None else 0 + upper_area = upper.bbox.area if upper.bbox is not None else 0 + smaller_area = min(lower_area, upper_area) + if smaller_area == 0: + return False + + overlap_ratio = overlap_area / smaller_area + return ( + 0 < overlap_ratio < BACKDROP_COVERAGE_RATIO + and overlap_ratio >= MIN_PARTIAL_OVERLAP_RATIO + ) + + def _is_text_on_backdrop( + self, lower: LayerMeasurement, upper: LayerMeasurement, overlap_area: int + ) -> bool: + if lower.layer_type == "text" or upper.layer_type != "text" or upper.bbox is None: + return False + return overlap_area / upper.bbox.area >= BACKDROP_COVERAGE_RATIO + + def _overlap_suggestion(self, upper: LayerMeasurement, lower: LayerMeasurement) -> str: + upper_box = upper.bbox + lower_box = lower.bbox + if upper_box is None or lower_box is None: + return f"move layer {upper.index} to clear the overlap" + + below_y = lower_box.bottom + OVERLAP_CLEARANCE_PX + if below_y + upper_box.height <= self._ctx.height: + return f"move layer {upper.index} to y={below_y} to clear the overlap" + + above_y = lower_box.y - upper_box.height - OVERLAP_CLEARANCE_PX + if above_y >= 0: + return f"move layer {upper.index} to y={above_y} to clear the overlap" + + right_x = lower_box.right + OVERLAP_CLEARANCE_PX + if right_x + upper_box.width <= self._ctx.width: + return f"move layer {upper.index} to x={right_x} to clear the overlap" + + left_x = lower_box.x - upper_box.width - OVERLAP_CLEARANCE_PX + if left_x >= 0: + return f"move layer {upper.index} to x={left_x} to clear the overlap" + + return f"move or resize layer {upper.index} to clear the overlap" + + @staticmethod + def _layer_label(measured: LayerMeasurement) -> str: + return f"{measured.layer_type} layer {measured.layer_id}" + def _diagnose_off_canvas(self, measured: LayerMeasurement) -> Diagnostic | None: box = measured.bbox if box is None or box.is_empty: diff --git a/quickthumb/_measurements.py b/quickthumb/_measurements.py index 940a04a..018f632 100644 --- a/quickthumb/_measurements.py +++ b/quickthumb/_measurements.py @@ -81,6 +81,13 @@ def clamped_to(self, canvas_width: int, canvas_height: int) -> "BBox | None": return None return BBox.from_points(left, top, right, bottom) + def intersection(self, other: "BBox") -> "BBox | None": + left, top = max(self.x, other.x), max(self.y, other.y) + right, bottom = min(self.right, other.right), min(self.bottom, other.bottom) + if right <= left or bottom <= top: + return None + return BBox.from_points(left, top, right, bottom) + @dataclass(frozen=True) class LayerMeasurement: diff --git a/quickthumb/models.py b/quickthumb/models.py index 3953453..052f1fd 100644 --- a/quickthumb/models.py +++ b/quickthumb/models.py @@ -806,7 +806,7 @@ def serialize_align(self, align: Align | None) -> str | None: class Diagnostic(quickthumbModel): - code: Literal["off-canvas", "tiny-text", "text-overflow", "low-contrast"] + code: Literal["off-canvas", "tiny-text", "text-overflow", "low-contrast", "layer-overlap"] severity: Literal["warning", "error"] layer_index: int message: str diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 01f416b..43ea293 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -385,6 +385,194 @@ def test_should_evaluate_contrast_against_layers_below(self): assert canvas.diagnose() == [] +class TestDiagnoseLayerOverlap: + """Test suite for suspicious layer overlap findings""" + + def test_should_warn_when_text_layers_overlap(self): + """Text over text is flagged because it is almost always unreadable""" + from quickthumb import Canvas + + # given: two readable text layers whose measured boxes intersect + canvas = ( + Canvas(360, 220) + .background(color="#FFFFFF") + .text("Alpha", size=48, color="#000000", position=(20, 20)) + .text("Beta", size=48, color="#000000", position=(50, 32)) + ) + + # when + diagnostics = canvas.diagnose() + + # then: the overlap points at the upper text layer and includes measured values + overlap_findings = [finding for finding in diagnostics if finding.code == "layer-overlap"] + assert len(overlap_findings) == 1 + finding = overlap_findings[0] + assert finding.severity == "warning" + assert finding.layer_index == 2 + assert "text layer layer:2 (order 2) overlaps text layer layer:1 (order 1)" in ( + finding.message + ) + assert "by " in finding.message + assert "% of upper" in finding.message + assert "move layer 2" in finding.message + + def test_should_allow_text_over_backdrop(self): + """Text fully contained by a lower non-text backdrop is intentional layout""" + from quickthumb import Canvas + + # given: a large backdrop shape behind a text label + canvas = ( + Canvas(300, 180) + .background(color="#FFFFFF") + .shape( + shape="rectangle", + position=(20, 20), + width=220, + height=90, + color="#EEEEEE", + ) + .text("Label", size=40, color="#000000", position=(40, 38)) + ) + + # when + diagnostics = canvas.diagnose() + + # then + assert [finding.code for finding in diagnostics] == [] + + def test_should_warn_for_partial_overlap(self): + """Substantial partial overlap between measured boxes is suspicious""" + from quickthumb import Canvas + from quickthumb.models import Diagnostic + + # given: two visible shapes with a 40x40 intersection + canvas = ( + Canvas(240, 180) + .background(color="#FFFFFF") + .shape( + shape="rectangle", + position=(20, 20), + width=100, + height=60, + color="#FF0000", + ) + .shape( + shape="rectangle", + position=(80, 40), + width=80, + height=50, + color="#00FF00", + ) + ) + + # when + diagnostics = canvas.diagnose() + + # then + assert diagnostics == snapshot( + [ + Diagnostic( + code="layer-overlap", + severity="warning", + layer_index=2, + message=( + "shape layer layer:2 (order 2) overlaps shape layer layer:1 " + "(order 1) by 1600px (40% of upper, 27% of lower); " + "move layer 2 to y=88 to clear the overlap" + ), + ) + ] + ) + + def test_should_not_warn_for_non_overlapping_layers(self): + """Separated measured boxes do not produce overlap findings""" + from quickthumb import Canvas + + # given: two visible shapes with a gap between their boxes + canvas = ( + Canvas(240, 180) + .background(color="#FFFFFF") + .shape( + shape="rectangle", + position=(20, 20), + width=60, + height=60, + color="#FF0000", + ) + .shape( + shape="rectangle", + position=(100, 20), + width=60, + height=60, + color="#00FF00", + ) + ) + + # when / then + assert canvas.diagnose() == [] + + def test_should_ignore_invisible_overlapping_layers(self): + """Transparent layers do not participate in overlap diagnostics""" + from quickthumb import Canvas + + # given: an invisible top shape whose box intersects a visible lower shape + canvas = ( + Canvas(200, 160) + .background(color="#FFFFFF") + .shape( + shape="rectangle", + position=(20, 20), + width=80, + height=60, + color="#FF0000", + ) + .shape( + shape="rectangle", + position=(40, 30), + width=80, + height=60, + color="#00FF00", + opacity=0, + ) + ) + + # when / then + assert canvas.diagnose() == [] + + def test_should_warn_when_grouped_text_overlaps_outer_text(self): + """Grouped child measurements participate in z-order-aware overlap checks""" + from quickthumb import Canvas + + # given: text inside a group and a later top-level text layer overlap + canvas = ( + Canvas(360, 220) + .background(color="#FFFFFF") + .group( + children=[ + { + "type": "text", + "content": "Alpha", + "size": 48, + "color": "#000000", + } + ], + position=(20, 20), + ) + .text("Beta", size=48, color="#000000", position=(50, 32)) + ) + + # when + diagnostics = canvas.diagnose() + + # then + overlap_findings = [finding for finding in diagnostics if finding.code == "layer-overlap"] + assert len(overlap_findings) == 1 + assert overlap_findings[0].layer_index == 2 + assert "text layer layer:2 (order 2) overlaps text layer layer:1:0 (order 1)" in ( + overlap_findings[0].message + ) + + class TestDiagnoseMeasuredLayers: """Test suite for layer measurement paths used by diagnostics""" @@ -404,6 +592,7 @@ def test_should_expose_bbox_geometry_helpers(self): assert first.bottom == 60 assert first.area == 1200 assert not first.is_empty + assert first.intersection(second) == BBox(x=35, y=45, width=5, height=15) assert union == BBox(x=10, y=20, width=55, height=60) def test_should_measure_layers_with_stable_internal_contract(self): From cf9613af3018e94faa457f0eb6250367c92f6a4b Mon Sep 17 00:00:00 2001 From: sjquant Date: Thu, 2 Jul 2026 21:35:34 +0900 Subject: [PATCH 2/5] Fix layer overlap diagnostics --- quickthumb/_diagnostics.py | 104 ++++++++++-------- tests/test_diagnostics.py | 212 ++++++++++++++++++++++++++++++++++++- 2 files changed, 271 insertions(+), 45 deletions(-) diff --git a/quickthumb/_diagnostics.py b/quickthumb/_diagnostics.py index e6769e7..d21118a 100644 --- a/quickthumb/_diagnostics.py +++ b/quickthumb/_diagnostics.py @@ -1,3 +1,4 @@ +from collections.abc import Iterable from typing import TYPE_CHECKING from PIL import Image, ImageStat @@ -11,7 +12,7 @@ from quickthumb._effects import EffectsEngine from quickthumb._fonts import FontEngine from quickthumb._groups import GroupEngine -from quickthumb._measurements import LayerMeasurement, measure_layers +from quickthumb._measurements import BBox, LayerMeasurement, measure_layers from quickthumb._text import TextEngine if TYPE_CHECKING: @@ -63,8 +64,9 @@ def __init__( def diagnose(self) -> list[Diagnostic]: """Check layers for layout and legibility issues without producing an output file. - Returns structured findings (off-canvas, tiny-text, text-overflow, low-contrast) - that an agent or human can act on before rendering. + Returns structured findings (off-canvas, tiny-text, text-overflow, + low-contrast, layer-overlap) that an agent or human can act on before + rendering. """ self._canvas._validate_image_paths() self._ctx.begin_render_pass() @@ -94,40 +96,58 @@ def diagnose(self) -> list[Diagnostic]: return diagnostics def _diagnose_layer_overlaps(self, measurements: list[LayerMeasurement]) -> list[Diagnostic]: - candidates = self._overlap_candidates(measurements) findings: list[Diagnostic] = [] - for lower_index, lower in enumerate(candidates): - for upper in candidates[lower_index + 1 :]: - finding = self._diagnose_candidate_overlap(lower, upper) - if finding is not None: - findings.append(finding) + candidates = list(self._iter_overlap_candidates(measurements)) + for lower, upper in self._candidate_overlap_pairs(candidates): + finding = self._diagnose_candidate_overlap(lower, upper) + if finding is not None: + findings.append(finding) return findings - def _overlap_candidates(self, measurements: list[LayerMeasurement]) -> list[LayerMeasurement]: - candidates: list[LayerMeasurement] = [] + def _iter_overlap_candidates( + self, measurements: Iterable[LayerMeasurement] + ) -> Iterable[LayerMeasurement]: for measured in measurements: - self._append_overlap_candidates(candidates, measured) - return candidates - - def _append_overlap_candidates( - self, candidates: list[LayerMeasurement], measured: LayerMeasurement - ) -> None: - if measured.children: - for child in measured.children: - self._append_overlap_candidates(candidates, child) - return + if measured.children: + yield from self._iter_overlap_candidates(measured.children) + elif measured.visible and measured.bbox is not None and not measured.bbox.is_empty: + yield measured + + def _candidate_overlap_pairs( + self, candidates: list[LayerMeasurement] + ) -> Iterable[tuple[LayerMeasurement, LayerMeasurement]]: + active: list[tuple[int, LayerMeasurement]] = [] + pairs: list[tuple[int, int, LayerMeasurement, LayerMeasurement]] = [] + by_left_edge = sorted( + enumerate(candidates), + key=lambda item: self._bbox(item[1]).x, + ) - if measured.visible and measured.bbox is not None and not measured.bbox.is_empty: - candidates.append(measured) + for candidate_order, candidate in by_left_edge: + candidate_box = self._bbox(candidate) + active = [ + (other_order, other) + for other_order, other in active + if self._bbox(other).right > candidate_box.x + ] + for other_order, other in active: + if other_order < candidate_order: + lower_order, upper_order = other_order, candidate_order + lower, upper = other, candidate + else: + lower_order, upper_order = candidate_order, other_order + lower, upper = candidate, other + pairs.append((lower_order, upper_order, lower, upper)) + active.append((candidate_order, candidate)) + + for _, _, lower, upper in sorted(pairs, key=lambda item: (item[0], item[1])): + yield lower, upper def _diagnose_candidate_overlap( self, lower: LayerMeasurement, upper: LayerMeasurement ) -> Diagnostic | None: - lower_box = lower.bbox - upper_box = upper.bbox - if lower_box is None or upper_box is None: - return None - + lower_box = self._bbox(lower) + upper_box = self._bbox(upper) overlap = lower_box.intersection(upper_box) if overlap is None: return None @@ -159,30 +179,20 @@ def _is_suspicious_overlap( if self._is_text_on_backdrop(lower, upper, overlap_area): return False - lower_area = lower.bbox.area if lower.bbox is not None else 0 - upper_area = upper.bbox.area if upper.bbox is not None else 0 - smaller_area = min(lower_area, upper_area) - if smaller_area == 0: - return False - + smaller_area = min(self._bbox(lower).area, self._bbox(upper).area) overlap_ratio = overlap_area / smaller_area - return ( - 0 < overlap_ratio < BACKDROP_COVERAGE_RATIO - and overlap_ratio >= MIN_PARTIAL_OVERLAP_RATIO - ) + return overlap_ratio >= MIN_PARTIAL_OVERLAP_RATIO def _is_text_on_backdrop( self, lower: LayerMeasurement, upper: LayerMeasurement, overlap_area: int ) -> bool: - if lower.layer_type == "text" or upper.layer_type != "text" or upper.bbox is None: + if lower.layer_type == "text" or upper.layer_type != "text": return False - return overlap_area / upper.bbox.area >= BACKDROP_COVERAGE_RATIO + return overlap_area / self._bbox(upper).area >= BACKDROP_COVERAGE_RATIO def _overlap_suggestion(self, upper: LayerMeasurement, lower: LayerMeasurement) -> str: - upper_box = upper.bbox - lower_box = lower.bbox - if upper_box is None or lower_box is None: - return f"move layer {upper.index} to clear the overlap" + upper_box = self._bbox(upper) + lower_box = self._bbox(lower) below_y = lower_box.bottom + OVERLAP_CLEARANCE_PX if below_y + upper_box.height <= self._ctx.height: @@ -206,6 +216,12 @@ def _overlap_suggestion(self, upper: LayerMeasurement, lower: LayerMeasurement) def _layer_label(measured: LayerMeasurement) -> str: return f"{measured.layer_type} layer {measured.layer_id}" + @staticmethod + def _bbox(measured: LayerMeasurement) -> BBox: + box = measured.bbox + assert box is not None and not box.is_empty + return box + def _diagnose_off_canvas(self, measured: LayerMeasurement) -> Diagnostic | None: box = measured.bbox if box is None or box.is_empty: diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 43ea293..b8fd3b1 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -484,6 +484,163 @@ def test_should_warn_for_partial_overlap(self): ] ) + @pytest.mark.parametrize( + "canvas_size,lower_position,lower_size,upper_position,upper_size,expected_message", + [ + ( + (240, 180), + (20, 100), + (100, 60), + (80, 110), + (80, 50), + ( + "shape layer layer:2 (order 2) overlaps shape layer layer:1 " + "(order 1) by 2000px (50% of upper, 33% of lower); " + "move layer 2 to y=42 to clear the overlap" + ), + ), + ( + (180, 140), + (20, 20), + (80, 110), + (60, 40), + (40, 80), + ( + "shape layer layer:2 (order 2) overlaps shape layer layer:1 " + "(order 1) by 3200px (100% of upper, 36% of lower); " + "move layer 2 to x=108 to clear the overlap" + ), + ), + ( + (180, 140), + (80, 20), + (80, 110), + (90, 40), + (40, 80), + ( + "shape layer layer:2 (order 2) overlaps shape layer layer:1 " + "(order 1) by 3200px (100% of upper, 36% of lower); " + "move layer 2 to x=32 to clear the overlap" + ), + ), + ( + (120, 120), + (20, 20), + (40, 40), + (10, 10), + (80, 80), + ( + "shape layer layer:2 (order 2) overlaps shape layer layer:1 " + "(order 1) by 1600px (25% of upper, 100% of lower); " + "move or resize layer 2 to clear the overlap" + ), + ), + ], + ) + def test_should_suggest_available_moves_for_overlaps( + self, + canvas_size, + lower_position, + lower_size, + upper_position, + upper_size, + expected_message, + ): + """Overlap suggestions use an available clear direction or fall back to resize""" + from quickthumb import Canvas + from quickthumb.models import Diagnostic + + # given: overlapping shapes arranged to force a specific repair suggestion + canvas = ( + Canvas(*canvas_size) + .background(color="#FFFFFF") + .shape( + shape="rectangle", + position=lower_position, + width=lower_size[0], + height=lower_size[1], + color="#FF0000", + ) + .shape( + shape="rectangle", + position=upper_position, + width=upper_size[0], + height=upper_size[1], + color="#00FF00", + ) + ) + + # when + diagnostics = canvas.diagnose() + + # then + assert diagnostics == [ + Diagnostic( + code="layer-overlap", + severity="warning", + layer_index=2, + message=expected_message, + ) + ] + + def test_should_warn_when_shape_fully_covers_shape(self): + """A complete non-backdrop coverage overlap is still suspicious""" + from quickthumb import Canvas + + # given: the upper shape fully covers the lower shape + canvas = ( + Canvas(120, 120) + .background(color="#FFFFFF") + .shape( + shape="rectangle", + position=(20, 20), + width=40, + height=40, + color="#FF0000", + ) + .shape( + shape="rectangle", + position=(10, 10), + width=80, + height=80, + color="#00FF00", + ) + ) + + # when + diagnostics = canvas.diagnose() + + # then + assert [finding.code for finding in diagnostics] == ["layer-overlap"] + assert "100% of lower" in diagnostics[0].message + + def test_should_warn_when_shape_covers_text(self): + """Non-text layers covering text are suspicious, unlike text on a backdrop""" + from quickthumb import Canvas + + # given: a shape later in render order covers an earlier text layer + canvas = ( + Canvas(240, 180) + .background(color="#FFFFFF") + .text("Label", size=40, color="#000000", position=(20, 20)) + .shape( + shape="rectangle", + position=(15, 15), + width=180, + height=70, + color="#00FF00", + ) + ) + + # when + diagnostics = canvas.diagnose() + + # then + assert [finding.code for finding in diagnostics] == ["layer-overlap"] + assert diagnostics[0].layer_index == 2 + assert "shape layer layer:2" in diagnostics[0].message + assert "text layer layer:1" in diagnostics[0].message + def test_should_not_warn_for_non_overlapping_layers(self): """Separated measured boxes do not produce overlap findings""" from quickthumb import Canvas @@ -511,6 +668,60 @@ def test_should_not_warn_for_non_overlapping_layers(self): # when / then assert canvas.diagnose() == [] + def test_should_not_warn_for_touching_layer_edges(self): + """Boxes whose edges touch without intersecting do not overlap""" + from quickthumb import Canvas + + # given: two visible shapes with adjacent edges + canvas = ( + Canvas(240, 180) + .background(color="#FFFFFF") + .shape( + shape="rectangle", + position=(20, 20), + width=60, + height=60, + color="#FF0000", + ) + .shape( + shape="rectangle", + position=(80, 20), + width=60, + height=60, + color="#00FF00", + ) + ) + + # when / then + assert canvas.diagnose() == [] + + def test_should_not_warn_for_layers_separated_vertically(self): + """Boxes with overlapping x-ranges but separated y-ranges do not overlap""" + from quickthumb import Canvas + + # given: two visible shapes stacked with a gap between their boxes + canvas = ( + Canvas(240, 180) + .background(color="#FFFFFF") + .shape( + shape="rectangle", + position=(20, 20), + width=80, + height=40, + color="#FF0000", + ) + .shape( + shape="rectangle", + position=(30, 80), + width=80, + height=40, + color="#00FF00", + ) + ) + + # when / then + assert canvas.diagnose() == [] + def test_should_ignore_invisible_overlapping_layers(self): """Transparent layers do not participate in overlap diagnostics""" from quickthumb import Canvas @@ -592,7 +803,6 @@ def test_should_expose_bbox_geometry_helpers(self): assert first.bottom == 60 assert first.area == 1200 assert not first.is_empty - assert first.intersection(second) == BBox(x=35, y=45, width=5, height=15) assert union == BBox(x=10, y=20, width=55, height=60) def test_should_measure_layers_with_stable_internal_contract(self): From 3506077aac9000521fa1a1cea111007a115721d5 Mon Sep 17 00:00:00 2001 From: sjquant Date: Thu, 2 Jul 2026 21:37:26 +0900 Subject: [PATCH 3/5] Simplify overlap suggestion tests --- tests/test_diagnostics.py | 41 ++++++++++----------------------------- 1 file changed, 10 insertions(+), 31 deletions(-) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index b8fd3b1..a0a2053 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -485,7 +485,7 @@ def test_should_warn_for_partial_overlap(self): ) @pytest.mark.parametrize( - "canvas_size,lower_position,lower_size,upper_position,upper_size,expected_message", + "canvas_size,lower_position,lower_size,upper_position,upper_size,expected_suggestion", [ ( (240, 180), @@ -493,11 +493,7 @@ def test_should_warn_for_partial_overlap(self): (100, 60), (80, 110), (80, 50), - ( - "shape layer layer:2 (order 2) overlaps shape layer layer:1 " - "(order 1) by 2000px (50% of upper, 33% of lower); " - "move layer 2 to y=42 to clear the overlap" - ), + "move layer 2 to y=42 to clear the overlap", ), ( (180, 140), @@ -505,11 +501,7 @@ def test_should_warn_for_partial_overlap(self): (80, 110), (60, 40), (40, 80), - ( - "shape layer layer:2 (order 2) overlaps shape layer layer:1 " - "(order 1) by 3200px (100% of upper, 36% of lower); " - "move layer 2 to x=108 to clear the overlap" - ), + "move layer 2 to x=108 to clear the overlap", ), ( (180, 140), @@ -517,11 +509,7 @@ def test_should_warn_for_partial_overlap(self): (80, 110), (90, 40), (40, 80), - ( - "shape layer layer:2 (order 2) overlaps shape layer layer:1 " - "(order 1) by 3200px (100% of upper, 36% of lower); " - "move layer 2 to x=32 to clear the overlap" - ), + "move layer 2 to x=32 to clear the overlap", ), ( (120, 120), @@ -529,11 +517,7 @@ def test_should_warn_for_partial_overlap(self): (40, 40), (10, 10), (80, 80), - ( - "shape layer layer:2 (order 2) overlaps shape layer layer:1 " - "(order 1) by 1600px (25% of upper, 100% of lower); " - "move or resize layer 2 to clear the overlap" - ), + "move or resize layer 2 to clear the overlap", ), ], ) @@ -544,11 +528,10 @@ def test_should_suggest_available_moves_for_overlaps( lower_size, upper_position, upper_size, - expected_message, + expected_suggestion, ): """Overlap suggestions use an available clear direction or fall back to resize""" from quickthumb import Canvas - from quickthumb.models import Diagnostic # given: overlapping shapes arranged to force a specific repair suggestion canvas = ( @@ -574,14 +557,10 @@ def test_should_suggest_available_moves_for_overlaps( diagnostics = canvas.diagnose() # then - assert diagnostics == [ - Diagnostic( - code="layer-overlap", - severity="warning", - layer_index=2, - message=expected_message, - ) - ] + assert [finding.code for finding in diagnostics] == ["layer-overlap"] + assert diagnostics[0].severity == "warning" + assert diagnostics[0].layer_index == 2 + assert diagnostics[0].message.endswith(expected_suggestion) def test_should_warn_when_shape_fully_covers_shape(self): """A complete non-backdrop coverage overlap is still suspicious""" From c7be23426c0913d8ee33438d9ebb6370a3a56f63 Mon Sep 17 00:00:00 2001 From: sjquant Date: Sat, 4 Jul 2026 16:35:37 +0900 Subject: [PATCH 4/5] Refine layer overlap precision --- quickthumb/_diagnostics.py | 152 +++++++++++++++++++++++++++++++++---- quickthumb/canvas.py | 9 ++- tests/test_diagnostics.py | 99 ++++++++++++++++++++++-- 3 files changed, 241 insertions(+), 19 deletions(-) diff --git a/quickthumb/_diagnostics.py b/quickthumb/_diagnostics.py index d21118a..eac8fcb 100644 --- a/quickthumb/_diagnostics.py +++ b/quickthumb/_diagnostics.py @@ -1,7 +1,8 @@ from collections.abc import Iterable +from dataclasses import dataclass from typing import TYPE_CHECKING -from PIL import Image, ImageStat +from PIL import Image, ImageChops, ImageStat from quickthumb._base import ( DEFAULT_TEXT_COLOR, @@ -12,12 +13,14 @@ from quickthumb._effects import EffectsEngine from quickthumb._fonts import FontEngine from quickthumb._groups import GroupEngine +from quickthumb._images import ImageEngine from quickthumb._measurements import BBox, LayerMeasurement, measure_layers +from quickthumb._shapes import ShapeEngine from quickthumb._text import TextEngine if TYPE_CHECKING: from quickthumb.canvas import Canvas -from quickthumb.models import Diagnostic, GroupLayer, TextLayer +from quickthumb.models import Diagnostic, GroupLayer, ImageLayer, ShapeLayer, SvgLayer, TextLayer TINY_TEXT_RATIO = 0.025 LOW_CONTRAST_THRESHOLD = 2.0 @@ -42,6 +45,22 @@ def _contrast_ratio(rgb_a: tuple[float, ...], rgb_b: tuple[float, ...]) -> float return (lighter + 0.05) / (darker + 0.05) +@dataclass(frozen=True) +class _LayerAlpha: + visible_area: int + mask: Image.Image | None = None + + +@dataclass(frozen=True) +class _OverlapMeasurement: + bbox_area: int + visible_area: int + lower_bbox_pct: float + upper_bbox_pct: float + lower_visible_pct: float + upper_visible_pct: float + + class DiagnosticsEngine: """Pre-render legibility and layout checks over a canvas's layers.""" @@ -51,6 +70,8 @@ def __init__( canvas: "Canvas", effects: EffectsEngine, fonts: FontEngine, + images: ImageEngine, + shapes: ShapeEngine, text: TextEngine, groups: GroupEngine, ): @@ -58,8 +79,11 @@ def __init__( self._canvas = canvas self._effects = effects self._fonts = fonts + self._images = images + self._shapes = shapes self._text = text self._groups = groups + self._alpha_cache: dict[str, _LayerAlpha] = {} def diagnose(self) -> list[Diagnostic]: """Check layers for layout and legibility issues without producing an output file. @@ -152,11 +176,13 @@ def _diagnose_candidate_overlap( if overlap is None: return None - if not self._is_suspicious_overlap(lower, upper, overlap.area): + measured_overlap = self._measure_visible_overlap(lower, upper, overlap) + if measured_overlap is None: + return None + + if not self._is_suspicious_overlap(lower, upper, measured_overlap): return None - lower_pct = overlap.area / lower_box.area - upper_pct = overlap.area / upper_box.area suggestion = self._overlap_suggestion(upper, lower) return Diagnostic( code="layer-overlap", @@ -165,30 +191,130 @@ def _diagnose_candidate_overlap( message=( f"{self._layer_label(upper)} (order {upper.order}) " f"overlaps {self._layer_label(lower)} " - f"(order {lower.order}) by {overlap.area}px " - f"({upper_pct:.0%} of upper, {lower_pct:.0%} of lower); {suggestion}" + f"(order {lower.order}); bbox_overlap={measured_overlap.bbox_area}px " + f"(bbox_overlap_pct={measured_overlap.upper_bbox_pct:.0%} of upper, " + f"{measured_overlap.lower_bbox_pct:.0%} of lower), " + f"visible_overlap={measured_overlap.visible_area}px " + f"(visible_overlap_pct={measured_overlap.upper_visible_pct:.0%} of upper, " + f"{measured_overlap.lower_visible_pct:.0%} of lower); {suggestion}" ), ) def _is_suspicious_overlap( - self, lower: LayerMeasurement, upper: LayerMeasurement, overlap_area: int + self, lower: LayerMeasurement, upper: LayerMeasurement, overlap: _OverlapMeasurement ) -> bool: if lower.layer_type == "text" and upper.layer_type == "text": return True - if self._is_text_on_backdrop(lower, upper, overlap_area): + if lower.layer_type == "text": + return overlap.lower_visible_pct >= MIN_PARTIAL_OVERLAP_RATIO + + if self._is_text_on_backdrop(lower, upper, overlap): return False - smaller_area = min(self._bbox(lower).area, self._bbox(upper).area) - overlap_ratio = overlap_area / smaller_area + overlap_ratio = min(overlap.lower_visible_pct, overlap.upper_visible_pct) return overlap_ratio >= MIN_PARTIAL_OVERLAP_RATIO def _is_text_on_backdrop( - self, lower: LayerMeasurement, upper: LayerMeasurement, overlap_area: int + self, lower: LayerMeasurement, upper: LayerMeasurement, overlap: _OverlapMeasurement ) -> bool: if lower.layer_type == "text" or upper.layer_type != "text": return False - return overlap_area / self._bbox(upper).area >= BACKDROP_COVERAGE_RATIO + return overlap.upper_visible_pct >= BACKDROP_COVERAGE_RATIO + + def _measure_visible_overlap( + self, lower: LayerMeasurement, upper: LayerMeasurement, overlap: BBox + ) -> _OverlapMeasurement | None: + lower_alpha = self._layer_alpha(lower) + upper_alpha = self._layer_alpha(upper) + visible_area = self._visible_intersection_area( + lower, upper, overlap, lower_alpha, upper_alpha + ) + if visible_area == 0: + return None + + return _OverlapMeasurement( + bbox_area=overlap.area, + visible_area=visible_area, + lower_bbox_pct=overlap.area / self._bbox(lower).area, + upper_bbox_pct=overlap.area / self._bbox(upper).area, + lower_visible_pct=visible_area / lower_alpha.visible_area, + upper_visible_pct=visible_area / upper_alpha.visible_area, + ) + + def _visible_intersection_area( + self, + lower: LayerMeasurement, + upper: LayerMeasurement, + overlap: BBox, + lower_alpha: _LayerAlpha, + upper_alpha: _LayerAlpha, + ) -> int: + if lower_alpha.mask is None and upper_alpha.mask is None: + return overlap.area + + lower_mask = self._alpha_region(lower, lower_alpha, overlap) + upper_mask = self._alpha_region(upper, upper_alpha, overlap) + combined = ImageChops.multiply(lower_mask, upper_mask) + return int(ImageStat.Stat(combined).sum[0] / 255) + + def _alpha_region( + self, measured: LayerMeasurement, alpha: _LayerAlpha, region: BBox + ) -> Image.Image: + if alpha.mask is None: + return Image.new("L", (region.width, region.height), 255) + box = self._bbox(measured) + left, top = region.x - box.x, region.y - box.y + mask = alpha.mask.crop((left, top, left + region.width, top + region.height)) + return mask.point(lambda value: 255 if value else 0) + + def _layer_alpha(self, measured: LayerMeasurement) -> _LayerAlpha: + cached = self._alpha_cache.get(measured.layer_id) + if cached is not None: + return cached + + if self._has_opaque_rectangle_mask(measured): + alpha = _LayerAlpha(visible_area=self._bbox(measured).area) + else: + mask = self._render_layer_alpha_mask(measured) + alpha = _LayerAlpha(visible_area=self._mask_area(mask), mask=mask) + self._alpha_cache[measured.layer_id] = alpha + return alpha + + def _has_opaque_rectangle_mask(self, measured: LayerMeasurement) -> bool: + layer = measured.raw_layer + return ( + isinstance(layer, ShapeLayer) + and layer.shape == "rectangle" + and layer.border_radius == 0 + and layer.rotation == 0 + and layer.opacity == 1.0 + and not layer.effects + ) + + def _render_layer_alpha_mask(self, measured: LayerMeasurement) -> Image.Image: + box = self._bbox(measured) + image = Image.new("RGBA", (self._ctx.width, self._ctx.height), (0, 0, 0, 0)) + layer = measured.raw_layer + if isinstance(layer, TextLayer): + self._text.render_text_layer(image, layer) + elif isinstance(layer, ImageLayer): + self._images.render_image_layer(image, layer) + elif isinstance(layer, SvgLayer): + self._images.render_svg_layer(image, layer) + elif isinstance(layer, ShapeLayer): + self._shapes.render_shape_layer(image, layer) + else: + return Image.new("L", (box.width, box.height), 0) + return ( + image.getchannel("A") + .crop((box.x, box.y, box.right, box.bottom)) + .point(lambda value: 255 if value else 0) + ) + + @staticmethod + def _mask_area(mask: Image.Image) -> int: + return int(ImageStat.Stat(mask).sum[0] / 255) def _overlap_suggestion(self, upper: LayerMeasurement, lower: LayerMeasurement) -> str: upper_box = self._bbox(upper) diff --git a/quickthumb/canvas.py b/quickthumb/canvas.py index d244d1a..37679d1 100644 --- a/quickthumb/canvas.py +++ b/quickthumb/canvas.py @@ -158,7 +158,14 @@ def __init__( self._shapes = ShapeEngine(self._ctx, self._effects, self._images) self._groups = GroupEngine(self._ctx, self._fonts, self._images, self._shapes, self._text) self._diagnostics = DiagnosticsEngine( - self._ctx, self, self._effects, self._fonts, self._text, self._groups + self._ctx, + self, + self._effects, + self._fonts, + self._images, + self._shapes, + self._text, + self._groups, ) @property diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index a0a2053..535f4f1 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -194,8 +194,11 @@ def test_should_not_warn_for_rich_text_parts_inheriting_a_readable_layer_size(se ) ) - # when / then - assert canvas.diagnose() == [] + # when + diagnostics = canvas.diagnose() + + # then + assert diagnostics == [] def test_should_warn_when_a_rich_text_word_exceeds_max_width(self): """An unbreakable word inside a rich-text part is flagged like plain-text overflow""" @@ -412,7 +415,8 @@ def test_should_warn_when_text_layers_overlap(self): assert "text layer layer:2 (order 2) overlaps text layer layer:1 (order 1)" in ( finding.message ) - assert "by " in finding.message + assert "bbox_overlap_pct=" in finding.message + assert "visible_overlap_pct=" in finding.message assert "% of upper" in finding.message assert "move layer 2" in finding.message @@ -477,13 +481,98 @@ def test_should_warn_for_partial_overlap(self): layer_index=2, message=( "shape layer layer:2 (order 2) overlaps shape layer layer:1 " - "(order 1) by 1600px (40% of upper, 27% of lower); " + "(order 1); bbox_overlap=1600px " + "(bbox_overlap_pct=40% of upper, 27% of lower), " + "visible_overlap=1600px " + "(visible_overlap_pct=40% of upper, 27% of lower); " "move layer 2 to y=88 to clear the overlap" ), ) ] ) + def test_should_not_warn_for_ellipse_corner_bbox_overlap(self): + """Ellipse masks prevent corner-only bounding-box intersections from warning""" + from quickthumb import Canvas + + # given: a rectangle fully inside an ellipse bbox corner but outside the ellipse pixels + canvas = ( + Canvas(200, 200) + .background(color="#FFFFFF") + .shape( + shape="ellipse", + position=(20, 20), + width=80, + height=80, + color="#FF0000", + ) + .shape( + shape="rectangle", + position=(20, 20), + width=10, + height=10, + color="#00FF00", + ) + ) + + # when / then + assert canvas.diagnose() == [] + + def test_should_not_warn_for_transparent_png_bbox_overlap(self, tmp_path): + """Transparent image pixels do not count as visible overlap""" + from quickthumb import Canvas + + # given: two same-size PNG layers whose opaque pixels occupy opposite sides + lower = tmp_path / "lower.png" + upper = tmp_path / "upper.png" + lower_image = Image.new("RGBA", (80, 80), (0, 0, 0, 0)) + upper_image = Image.new("RGBA", (80, 80), (0, 0, 0, 0)) + for x in range(0, 30): + for y in range(80): + lower_image.putpixel((x, y), (255, 0, 0, 255)) + for x in range(50, 80): + for y in range(80): + upper_image.putpixel((x, y), (0, 255, 0, 255)) + lower_image.save(lower) + upper_image.save(upper) + + canvas = ( + Canvas(160, 120) + .background(color="#FFFFFF") + .image(path=str(lower), position=(20, 20)) + .image(path=str(upper), position=(20, 20)) + ) + + # when + diagnostics = canvas.diagnose() + + # then + assert diagnostics == [] + + def test_should_not_warn_for_glyph_hole_bbox_overlap(self): + """Text masks use rendered glyph pixels, not just text bounding boxes""" + from quickthumb import Canvas + + # given: a shape placed inside the hollow center of a large glyph + canvas = ( + Canvas(180, 160) + .background(color="#FFFFFF") + .text("O", size=110, color="#000000", position=(20, 10)) + .shape( + shape="rectangle", + position=(58, 50), + width=24, + height=24, + color="#00FF00", + ) + ) + + # when + diagnostics = canvas.diagnose() + + # then + assert diagnostics == [] + @pytest.mark.parametrize( "canvas_size,lower_position,lower_size,upper_position,upper_size,expected_suggestion", [ @@ -591,7 +680,7 @@ def test_should_warn_when_shape_fully_covers_shape(self): # then assert [finding.code for finding in diagnostics] == ["layer-overlap"] - assert "100% of lower" in diagnostics[0].message + assert "visible_overlap_pct=25% of upper, 100% of lower" in diagnostics[0].message def test_should_warn_when_shape_covers_text(self): """Non-text layers covering text are suspicious, unlike text on a backdrop""" From abbe6ad62e095c13b5303c7452abb5434e959293 Mon Sep 17 00:00:00 2001 From: sjquant Date: Sat, 4 Jul 2026 17:01:36 +0900 Subject: [PATCH 5/5] Fix layer overlap edge cases --- quickthumb/_diagnostics.py | 4 ++ tests/test_diagnostics.py | 78 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/quickthumb/_diagnostics.py b/quickthumb/_diagnostics.py index eac8fcb..bae2f18 100644 --- a/quickthumb/_diagnostics.py +++ b/quickthumb/_diagnostics.py @@ -92,6 +92,7 @@ def diagnose(self) -> list[Diagnostic]: low-contrast, layer-overlap) that an agent or human can act on before rendering. """ + self._alpha_cache.clear() self._canvas._validate_image_paths() self._ctx.begin_render_pass() @@ -212,6 +213,9 @@ def _is_suspicious_overlap( if self._is_text_on_backdrop(lower, upper, overlap): return False + if max(overlap.lower_visible_pct, overlap.upper_visible_pct) >= BACKDROP_COVERAGE_RATIO: + return True + overlap_ratio = min(overlap.lower_visible_pct, overlap.upper_visible_pct) return overlap_ratio >= MIN_PARTIAL_OVERLAP_RATIO diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 535f4f1..a5cbf45 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -682,6 +682,84 @@ def test_should_warn_when_shape_fully_covers_shape(self): assert [finding.code for finding in diagnostics] == ["layer-overlap"] assert "visible_overlap_pct=25% of upper, 100% of lower" in diagnostics[0].message + def test_should_warn_when_large_shape_fully_covers_small_shape(self): + """Complete coverage is suspicious even when the covering layer is much larger""" + from quickthumb import Canvas + + # given: a large upper shape fully covers a tiny lower shape + canvas = ( + Canvas(300, 300) + .background(color="#FFFFFF") + .shape( + shape="rectangle", + position=(100, 100), + width=10, + height=10, + color="#FF0000", + ) + .shape( + shape="rectangle", + position=(0, 0), + width=250, + height=250, + color="#00FF00", + ) + ) + + # when + diagnostics = canvas.diagnose() + + # then + assert [finding.code for finding in diagnostics] == ["layer-overlap"] + assert "visible_overlap_pct=0% of upper, 100% of lower" in diagnostics[0].message + + def test_should_remeasure_alpha_masks_after_canvas_layers_change(self): + """Repeated diagnoses do not reuse stale alpha masks for changed layers""" + from quickthumb import Canvas + + # given: a canvas whose first pass caches a hollow ellipse corner mask + canvas = ( + Canvas(200, 200) + .background(color="#FFFFFF") + .shape( + shape="ellipse", + position=(20, 20), + width=80, + height=80, + color="#FF0000", + ) + .shape( + shape="rectangle", + position=(20, 20), + width=10, + height=10, + color="#00FF00", + ) + ) + first_diagnostics = canvas.diagnose() + + # when: the same implicit layer IDs now point to overlapping opaque rectangles + canvas.layers = [canvas.layers[0]] + canvas.shape( + shape="rectangle", + position=(20, 20), + width=80, + height=80, + color="#FF0000", + ) + canvas.shape( + shape="rectangle", + position=(20, 20), + width=10, + height=10, + color="#00FF00", + ) + second_diagnostics = canvas.diagnose() + + # then + assert first_diagnostics == [] + assert [finding.code for finding in second_diagnostics] == ["layer-overlap"] + def test_should_warn_when_shape_covers_text(self): """Non-text layers covering text are suspicious, unlike text on a backdrop""" from quickthumb import Canvas