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
136 changes: 134 additions & 2 deletions quickthumb/_diagnostic_rules.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand Down
51 changes: 27 additions & 24 deletions quickthumb/_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -818,28 +821,28 @@ 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
clamped = box.clamped_to(self._ctx.width, self._ctx.height)
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,
)
56 changes: 56 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading