Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
277 changes: 271 additions & 6 deletions quickthumb/_diagnostics.py
Original file line number Diff line number Diff line change
@@ -1,6 +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,
Expand All @@ -11,15 +13,20 @@
from quickthumb._effects import EffectsEngine
from quickthumb._fonts import FontEngine
from quickthumb._groups import GroupEngine
from quickthumb._measurements import LayerMeasurement, measure_layers
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
MIN_PARTIAL_OVERLAP_RATIO = 0.2
BACKDROP_COVERAGE_RATIO = 0.95
OVERLAP_CLEARANCE_PX = 8


def _relative_luminance(rgb: tuple[float, ...]) -> float:
Expand All @@ -38,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."""

Expand All @@ -47,28 +70,36 @@ def __init__(
canvas: "Canvas",
effects: EffectsEngine,
fonts: FontEngine,
images: ImageEngine,
shapes: ShapeEngine,
text: TextEngine,
groups: GroupEngine,
):
self._ctx = ctx
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.

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._alpha_cache.clear()
self._canvas._validate_image_paths()
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):
Expand All @@ -85,8 +116,242 @@ 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]:
findings: list[Diagnostic] = []
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 _iter_overlap_candidates(
self, measurements: Iterable[LayerMeasurement]
) -> Iterable[LayerMeasurement]:
for measured in measurements:
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,
)

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 = self._bbox(lower)
upper_box = self._bbox(upper)
overlap = lower_box.intersection(upper_box)
if overlap is None:
return None

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

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}); 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: _OverlapMeasurement
) -> bool:
if lower.layer_type == "text" and upper.layer_type == "text":
return True

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

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

def _is_text_on_backdrop(
self, lower: LayerMeasurement, upper: LayerMeasurement, overlap: _OverlapMeasurement
) -> bool:
if lower.layer_type == "text" or upper.layer_type != "text":
return False
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)
lower_box = self._bbox(lower)

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}"

@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:
Expand Down
7 changes: 7 additions & 0 deletions quickthumb/_measurements.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 8 additions & 1 deletion quickthumb/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion quickthumb/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading