Skip to content

Commit acec5b4

Browse files
committed
fix: sample contrast over text pixels
1 parent ba3d04e commit acec5b4

3 files changed

Lines changed: 114 additions & 39 deletions

File tree

quickthumb/_diagnostic_rules.py

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -256,43 +256,68 @@ def _alpha_region(
256256
return mask.point(lambda value: 255 if value else 0)
257257

258258

259-
def average_visible_background(image: Image.Image, region: BBox) -> tuple[float, float, float]:
259+
def average_visible_background(
260+
image: Image.Image, region: BBox, mask: Image.Image | None = None
261+
) -> tuple[float, float, float]:
260262
crop = image.crop((region.x, region.y, region.right, region.bottom))
261-
mean_r, mean_g, mean_b, mean_a = ImageStat.Stat(crop).mean
263+
mask_crop = (
264+
None if mask is None else mask.crop((region.x, region.y, region.right, region.bottom))
265+
)
266+
mean_r, mean_g, mean_b, mean_a = ImageStat.Stat(crop, mask=mask_crop).mean
262267

263268
# Transparent areas read as white, matching JPEG export and typical viewers.
264269
alpha = mean_a / 255
265270
return tuple(channel * alpha + 255 * (1 - alpha) for channel in (mean_r, mean_g, mean_b))
266271

267272

268273
def worst_tile_contrast(
269-
image: Image.Image,
274+
background_image: Image.Image,
275+
foreground_image: Image.Image,
270276
region: BBox,
271-
foregrounds: Iterable[tuple[float, float, float]],
272277
*,
273278
tile_size: int,
274279
) -> TiledContrastMeasurement | None:
275-
"""Measure the lowest foreground/background contrast across tiled samples."""
276-
tiles = list(_tiled_regions(region, tile_size))
277-
if not tiles:
278-
return None
280+
"""Measure the lowest text-pixel contrast across tiled samples."""
281+
foreground_alpha = foreground_image.getchannel("A").point(lambda value: 255 if value else 0)
282+
tile_count = _tile_count(region, tile_size)
279283

280284
worst: TiledContrastMeasurement | None = None
281-
for tile in tiles:
282-
background = average_visible_background(image, tile)
283-
for foreground in foregrounds:
284-
contrast = contrast_ratio(foreground, background)
285-
if worst is None or contrast < worst.contrast:
286-
worst = TiledContrastMeasurement(
287-
contrast=contrast,
288-
foreground=foreground,
289-
background=background,
290-
tile=tile,
291-
tile_count=len(tiles),
292-
)
285+
for tile in _tiled_regions(region, tile_size):
286+
alpha_tile = foreground_alpha.crop((tile.x, tile.y, tile.right, tile.bottom))
287+
if alpha_tile.getbbox() is None:
288+
continue
289+
290+
background = average_visible_background(background_image, tile, mask=foreground_alpha)
291+
foreground = _average_visible_foreground(foreground_image, tile, foreground_alpha)
292+
contrast = contrast_ratio(foreground, background)
293+
if worst is None or contrast < worst.contrast:
294+
worst = TiledContrastMeasurement(
295+
contrast=contrast,
296+
foreground=foreground,
297+
background=background,
298+
tile=tile,
299+
tile_count=tile_count,
300+
)
293301
return worst
294302

295303

304+
def _average_visible_foreground(
305+
image: Image.Image, region: BBox, mask: Image.Image
306+
) -> tuple[float, float, float]:
307+
crop = image.crop((region.x, region.y, region.right, region.bottom))
308+
mask_crop = mask.crop((region.x, region.y, region.right, region.bottom))
309+
mean_r, mean_g, mean_b, *_ = ImageStat.Stat(crop, mask=mask_crop).mean
310+
return mean_r, mean_g, mean_b
311+
312+
313+
def _tile_count(region: BBox, tile_size: int) -> int:
314+
if tile_size <= 0:
315+
raise ValueError("tile_size must be positive")
316+
cols = (region.width + tile_size - 1) // tile_size
317+
rows = (region.height + tile_size - 1) // tile_size
318+
return cols * rows
319+
320+
296321
def _tiled_regions(region: BBox, tile_size: int) -> Iterable[BBox]:
297322
if tile_size <= 0:
298323
raise ValueError("tile_size must be positive")

quickthumb/_diagnostics.py

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,7 @@
33

44
from PIL import Image
55

6-
from quickthumb._base import (
7-
DEFAULT_TEXT_COLOR,
8-
DEFAULT_TEXT_SIZE,
9-
RenderContext,
10-
parse_coordinate,
11-
)
6+
from quickthumb._base import DEFAULT_TEXT_SIZE, RenderContext, parse_coordinate
127
from quickthumb._diagnostic_rules import (
138
LayerAlphaCache,
149
OverlapMeasurement,
@@ -419,34 +414,32 @@ def _first_word_wider_than(
419414
def _text_background_contrast(
420415
self, running: Image.Image, measured: LayerMeasurement
421416
) -> TiledContrastMeasurement | None:
422-
"""Worst contrast ratio between the layer's text colors and the area below it."""
417+
"""Worst contrast ratio between the layer's visible text pixels and the area below."""
423418
layer = measured.effective_text_layer
424419
if layer is None:
425420
return None
426421

427-
if isinstance(layer.content, list):
428-
text_colors = {
429-
_rgb_tuple(self._text.resolve_color(part, layer)) for part in layer.content
430-
}
431-
elif layer.color:
432-
text_colors = {_rgb_tuple(self._effects.parse_color(layer.color))}
433-
else:
434-
text_colors = {_rgb_tuple(DEFAULT_TEXT_COLOR)}
435-
436422
box = measured.bbox
437423
if box is None:
438424
return None
439425
clamped = box.clamped_to(self._ctx.width, self._ctx.height)
440426
if clamped is None:
441427
return None
442428

429+
foreground = self._render_text_foreground(layer)
443430
return worst_tile_contrast(
444431
running,
432+
foreground,
445433
clamped,
446-
text_colors,
447434
tile_size=CONTRAST_TILE_SIZE,
448435
)
449436

437+
def _render_text_foreground(self, layer: TextLayer) -> Image.Image:
438+
content = layer.content
439+
if isinstance(content, list):
440+
content = [part.model_copy(update={"effects": []}) for part in content]
441+
foreground_layer = layer.model_copy(update={"content": content, "effects": []})
450442

451-
def _rgb_tuple(color: tuple[int, ...]) -> tuple[float, float, float]:
452-
return float(color[0]), float(color[1]), float(color[2])
443+
foreground = Image.new("RGBA", (self._ctx.width, self._ctx.height), (0, 0, 0, 0))
444+
self._text.render_text_layer(foreground, foreground_layer)
445+
return foreground

tests/test_diagnostics.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,63 @@ def paint_split_background(image: Image.Image) -> None:
516516
assert finding.measured["tile_bbox"]["x"] >= 116
517517
assert finding.measured["background_rgb"][0] > 240
518518

519+
def test_should_ignore_low_contrast_tiles_without_text_pixels(self):
520+
"""Whitespace inside the text bbox does not drive low-contrast findings"""
521+
from quickthumb import Canvas
522+
523+
def paint_background_with_light_gap(image: Image.Image) -> None:
524+
image.paste((0, 0, 0, 255), (0, 0, 300, 120))
525+
image.paste((255, 255, 255, 255), (70, 0, 120, 120))
526+
527+
# given: white glyphs sit on black while only the empty space crosses white
528+
canvas = (
529+
Canvas(300, 120)
530+
.custom(paint_background_with_light_gap)
531+
.text("A A", size=36, color="#FFFFFF", position=(20, 30))
532+
)
533+
534+
# when / then
535+
assert canvas.diagnose() == []
536+
537+
def test_should_not_warn_for_readable_rich_text_on_split_background(self):
538+
"""Rich text colors are compared only where each colored run renders"""
539+
from quickthumb import Canvas
540+
541+
def paint_split_background(image: Image.Image) -> None:
542+
image.paste((0, 0, 0, 255), (0, 0, 300, 120))
543+
image.paste((255, 255, 255, 255), (70, 0, 300, 120))
544+
545+
# given: white rich text renders on black and black rich text renders on white
546+
canvas = (
547+
Canvas(300, 120)
548+
.custom(paint_split_background)
549+
.text(
550+
[
551+
{"text": "L", "color": "#FFFFFF"},
552+
{"text": " R", "color": "#000000"},
553+
],
554+
size=36,
555+
position=(20, 30),
556+
)
557+
)
558+
559+
# when / then
560+
assert canvas.diagnose() == []
561+
562+
def test_should_use_default_text_color_for_worst_tile_contrast(self):
563+
"""Text without a color still uses the public default black foreground"""
564+
from quickthumb import Canvas
565+
566+
# given: default black text over a black background
567+
canvas = Canvas(200, 120).background(color="#000000").text("default", size=36)
568+
569+
# when
570+
diagnostics = canvas.diagnose()
571+
572+
# then
573+
assert [finding.code for finding in diagnostics] == ["low-contrast"]
574+
assert diagnostics[0].measured["foreground_rgb"] == (0.0, 0.0, 0.0)
575+
519576
@pytest.mark.parametrize(
520577
"background,color",
521578
[

0 commit comments

Comments
 (0)