diff --git a/backend/util/cl2k/logo_extract.py b/backend/util/cl2k/logo_extract.py index f57d8517..f0e9fcdc 100644 --- a/backend/util/cl2k/logo_extract.py +++ b/backend/util/cl2k/logo_extract.py @@ -25,7 +25,7 @@ from typing import Optional import numpy as np -from PIL import Image, ImageFilter +from PIL import Image def _despeckle(alpha: np.ndarray, min_area: int = 12) -> np.ndarray: @@ -204,6 +204,44 @@ def _kmeans(pts: np.ndarray, k: int, iters: int = 12): return cent, np.bincount(lab, minlength=k) +# A near-ring cluster with no colour within this ΔE in the FAR ring is title +# bleed (grunge spray / glow hugging the glyphs), not backdrop — see _drop_bleed. +_BLEED_FAR_SUPPORT = 14.0 + + +def _far_ring_palette( + space: np.ndarray, mask: np.ndarray, near_px: int +) -> Optional[np.ndarray]: + """k-means palette of the 40-80px far ring, in ``space``'s own colour space. + + None when the ring is too small to trust — absolutely, or relative to the + near ring (a brush close to the frame edge leaves a one-SIDED far ring that + would wrongly condemn colours legitimately present near the other sides). + """ + d40 = _dilate(mask, 40) + far = _dilate(d40, 40) & ~d40 # square dilations compose: 40+40 = 80 + fpts = space[far] + if len(fpts) < max(200, near_px // 2): + return None + fsub = fpts.astype(np.float32)[:: max(1, len(fpts) // 4000)] + fcent, fcounts = _kmeans(fsub, 5) + return fcent[fcounts > 0] + + +def _drop_bleed( + arr: np.ndarray, mask: np.ndarray, cent: np.ndarray, near_px: int +) -> np.ndarray: + """Drop near-ring clusters with no far-ring colour support — title bleed + (spray/glow), not backdrop. Keeps all clusters when validation can't run.""" + fcent = _far_ring_palette(arr, mask, near_px) + if fcent is None: + return cent + diff = _srgb_to_lab(cent[None])[0][:, None] - _srgb_to_lab(fcent[None])[0][None] + de = np.sqrt((diff * diff).sum(axis=2)).min(axis=1) + keep = de <= _BLEED_FAR_SUPPORT + return cent[keep] if keep.any() else cent + + def _background_colors(arr: np.ndarray, mask: Optional[np.ndarray]) -> np.ndarray: """Backdrop palette (n, 3): the colours the title sits ON. @@ -213,29 +251,27 @@ def _background_colors(arr: np.ndarray, mask: Optional[np.ndarray]) -> np.ndarra as several colours rather than one muddy average. Sampling outside, not inside, sidesteps having to guess which inside-brush colour is the title — so a title that spans several tones (highlight + shadow) is never mistaken for - backdrop and erased. Falls back to a single border colour with no brush. + backdrop and erased. Clusters that are title bleed rather than backdrop are + dropped (see :func:`_drop_bleed`). Falls back to a single border colour with + no brush. """ if mask is not None and mask.any(): - m = Image.fromarray((mask.astype(np.uint8)) * 255) - ring = ( - np.asarray(m.filter(ImageFilter.MaxFilter(45))) > 127 - ) & ~mask # ~22px out + ring = _dilate(mask, 22) & ~mask pts = arr[ring] if len(pts) >= 50: sub = pts.astype(np.float32)[ :: max(1, len(pts) // 4000) ] # subsample, cheap cent, counts = _kmeans(sub, 5) - return cent[counts > 0] + return _drop_bleed(arr, mask, cent[counts > 0], int(ring.sum())) return _local_background(arr, mask)[None, :] -def _background_distance(arr: np.ndarray, mask: Optional[np.ndarray]) -> np.ndarray: +def _background_distance(arr: np.ndarray, bg: np.ndarray) -> np.ndarray: """Per-pixel ΔE76 (Lab) to the *nearest* backdrop colour (see :func:`_background_colors`) — small where a pixel matches the backdrop, large on the title. Lab, not raw RGB: a dark-red title on a dark backdrop is a hue flip RGB distance barely scores, while ΔE tracks what the eye separates.""" - bg = _background_colors(arr, mask) diff = _srgb_to_lab(arr)[..., None, :] - _srgb_to_lab(bg)[None, None] return np.sqrt((diff * diff).sum(axis=-1)).min(axis=-1) @@ -244,6 +280,122 @@ def _background_distance(arr: np.ndarray, mask: Optional[np.ndarray]) -> np.ndar # oversized/decompression-bomb image. Posters are well under this. _MAX_SIDE = 3000 +# White-union guard: reject when the union would cover more of the brush than a +# title plausibly does (a pale FIELD keyed white). +_UNION_MAX_COVER = 0.60 + + +def _white_union_alpha( + arr: np.ndarray, mask: np.ndarray, color_alpha: np.ndarray +) -> np.ndarray: + """Brightness-key alpha for the WHITE part of a mixed title, or zeros. + Guards: border-spill flood (bright content crossing the brush edge is + backdrop) and the ``_UNION_MAX_COVER`` cap (a pale field, not letters).""" + mn = np.minimum(np.minimum(arr[..., 0], arr[..., 1]), arr[..., 2]) + split = _otsu(mn[mask]) + lo = float(np.clip(split, 120.0, 200.0)) if split is not None else 165.0 + hi = lo + 50.0 + t = np.clip((mn - lo) / (hi - lo), 0.0, 1.0) + walpha = ((t * t * (3.0 - 2.0 * t)) * 255.0).astype(np.uint8) + walpha = (walpha * mask).astype(np.uint8) + + add = (walpha > 128) & (color_alpha <= 128) + if not add.any(): + return np.zeros_like(walpha) + + bright_out = (_dilate(mask, 2) & ~mask) & (mn >= lo) + spill = _geodesic_flood(add, bright_out) + if spill.any(): + walpha[_dilate(spill, 2)] = 0 + + union = (color_alpha > 128) | (walpha > 128) + if int(union.sum()) / max(int(mask.sum()), 1) > _UNION_MAX_COVER: + return np.zeros_like(walpha) + return walpha + + +def _geodesic_flood(add: np.ndarray, seed: np.ndarray) -> np.ndarray: + """Geodesic flood of ``seed`` through ``add`` (connected reachability at + 4px/pass). Bound = max possible travel; the loop exits early on stability.""" + spill = add & _dilate(seed, 3) + for _ in range((add.shape[0] + add.shape[1]) // 4 + 2): + grown = add & _dilate(spill, 4) + if int(grown.sum()) == int(spill.sum()): + break + spill = grown + return spill + + +def _anchor_rescue_alpha( + arr: np.ndarray, mask: np.ndarray, base_alpha: np.ndarray, bg: np.ndarray +) -> np.ndarray: + """Per-anchor key bands for pale non-white words the fitted band drops + (band clamped to ``0.6 x`` backdrop distance, as in :func:`_detect_anchors`). + Same border-spill and coverage guards as the white union; zeros if unsafe.""" + zeros = np.zeros(arr.shape[:2], dtype=np.uint8) + pts = arr[mask] + if len(pts) < 200: + return zeros + sub = pts.astype(np.float32)[:: max(1, len(pts) // 4000)] + cent, counts = _kmeans(sub, 5) + frac = counts / max(1, int(counts.sum())) + lab = _srgb_to_lab(arr) + cent_lab = _srgb_to_lab(cent[None])[0] + bg_lab = _srgb_to_lab(bg[None])[0] + + rim_out = _dilate(mask, 2) & ~mask + rescue = zeros.copy() + seed = np.zeros(arr.shape[:2], dtype=bool) + for c, f in zip(cent_lab, frac): + if f < _ANCHOR_MIN_FRAC: + continue + d = float(np.sqrt(((c - bg_lab) ** 2).sum(axis=1)).min()) + if d < _BG_NEAR: + continue # backdrop-like, or too close to separate safely + tol = min(_COLOR_TOL_MAX, max(_BG_SAME, 0.6 * d)) + de = np.sqrt(((lab - c) ** 2).sum(axis=-1)) + t = np.clip((tol - de) / max(0.3 * tol, 1.0), 0.0, 1.0) + rescue = np.maximum(rescue, (t * t * (3.0 - 2.0 * t) * 255.0).astype(np.uint8)) + seed |= rim_out & (de < tol) + rescue = (rescue * mask).astype(np.uint8) + + add = (rescue > 128) & (base_alpha <= 128) + if not add.any(): + return zeros + spill = _geodesic_flood(add, seed) + if spill.any(): + rescue[_dilate(spill, 2)] = 0 + + union = (base_alpha > 128) | (rescue > 128) + if int(union.sum()) / max(int(mask.sum()), 1) > _UNION_MAX_COVER: + return zeros + return rescue + + +# The detector must account for at least this share of the keyed area before the +# zone filter may remove anything — below it, it likely missed the wordmark. +_ZONE_MIN_KEEP = 0.5 + + +def _text_zone_filter( + image_bytes: bytes, mask: np.ndarray, alpha: np.ndarray +) -> np.ndarray: + """Drop keyed content with no connection to a detected text line — scene + junk the colour key can't tell from title. Fail-safe: no detector, no boxes, + or a keep under ``_ZONE_MIN_KEEP`` of the keyed area leaves alpha unchanged.""" + prob = _sized_probmap(image_bytes, alpha.shape) + if prob is None: + return alpha + width = alpha.shape[1] + zone = _dilate((prob > 0.3) & _dilate(mask, 8), max(12, round(0.025 * width))) + keyed = alpha > 40 + if not (zone.any() and keyed.any()): + return alpha + keep = _geodesic_flood(keyed, keyed & zone) + if int(keep.sum()) < _ZONE_MIN_KEEP * int(keyed.sum()): + return alpha + return (alpha * _dilate(keep, 2)).astype(np.uint8) + def _open_rgb_bounded(image_bytes: bytes) -> Image.Image: img = Image.open(io.BytesIO(image_bytes)).convert("RGB") @@ -266,9 +418,11 @@ def extract_subject_logo( *distance from the local background* (see :func:`_background_distance`, which models a multi-toned backdrop as a colour palette), so a red or green title separates from a cityscape or a wood-grain plate while keeping its own - colours. The CL2K whiten pass downstream then turns that colour into the - two-tone look, exactly as it does for a fetched TMDB/fanart logo — so this - must NOT pre-whiten the way the white key does. + colours. A MIXED title (white words + coloured words) additionally unions in + the brightness key (see :func:`_white_union_alpha`). The CL2K whiten pass + downstream then turns that colour into the two-tone look, exactly as it does + for a fetched TMDB/fanart logo — so this must NOT pre-whiten the way the + white key does. mask_bytes: brush PNG, white = the title region; brush close around the title so the backdrop palette is sampled from real backdrop, not other artwork. @@ -282,7 +436,8 @@ def extract_subject_logo( arr = np.asarray(img).astype(np.float32) mask = _load_mask(mask_bytes, img.size) - dist = _background_distance(arr, mask) + bg = _background_colors(arr, mask) + dist = _background_distance(arr, bg) if (lo, hi) == (40.0, 90.0): # untouched defaults -> fit the band per poster split = _otsu(dist[mask] if mask is not None else dist) if split is not None and 8.0 <= split <= 80.0: @@ -292,8 +447,15 @@ def extract_subject_logo( if mask is not None: alpha = (alpha * mask).astype(np.uint8) + # Mixed titles: union in the white part (see _white_union_alpha) — the + # colour key alone drops it when the backdrop palette has a white-ish + # tone — then rescue pale non-white words the band-fit dropped. + alpha = np.maximum(alpha, _white_union_alpha(arr, mask, alpha)) + alpha = np.maximum(alpha, _anchor_rescue_alpha(arr, mask, alpha, bg)) alpha = _despeckle(alpha) + if mask is not None: + alpha = _text_zone_filter(image_bytes, mask, alpha) out = np.zeros((img.height, img.width, 4), dtype=np.uint8) out[..., 0:3] = arr.astype(np.uint8) # keep ORIGINAL colours; whiten happens later @@ -438,6 +600,7 @@ def _sized_probmap(image_bytes, shape): # Anchor/background separation tiers (ΔE76 in Lab, against the DOMINANT outside # clusters only — ``_BG_DOMINANT_FRAC`` mirrors _matches_background's min_frac). _BG_SAME = 8.0 # closer than this = the background itself, not ink +_COLOR_TOL_MAX = 33.0 # cap on any per-anchor key band (tighten + rescue paths) _BG_NEAR = 20.0 # closer than this = "suspect" ink (white title on a pale field) _BG_DOMINANT_FRAC = 0.15 _ANCHOR_MIN_FRAC = 0.08 # smaller clusters are anti-aliasing blends, not ink @@ -506,6 +669,9 @@ def _outside_background(lab, block, width): brush tightness. Returns ``None`` when there's no usable outside (a block that fills the frame). Used to key the title INK against — and to reject an anchor (detector or colour-key) that merely IS the background, i.e. an inversion. + Title bleed clusters (spray hugging the glyphs, no far-ring support) are + dropped, as in :func:`_drop_bleed`; kept fractions stay un-renormalised so + the dominance gates still measure share of the full outside area. """ outside = _dilate(block, max(8, round(0.02 * width))) & ~block if int(outside.sum()) < 50: @@ -516,7 +682,15 @@ def _outside_background(lab, block, width): obg = obg.astype(np.float32)[:: max(1, len(obg) // 4000)] bg_cent, bg_counts = _kmeans(obg, 5) keep = bg_counts > 0 - return bg_cent[keep], bg_counts[keep] / int(bg_counts.sum()) + cent, frac = bg_cent[keep], bg_counts[keep] / int(bg_counts.sum()) + fcent = _far_ring_palette(lab, block, int(outside.sum())) + if fcent is not None: + diff = cent[:, None] - fcent[None] + de = np.sqrt((diff * diff).sum(axis=2)).min(axis=1) + keeps = de <= _BLEED_FAR_SUPPORT + if keeps.any(): + cent, frac = cent[keeps], frac[keeps] + return cent, frac def _matches_background(title_lab, bg, tol=20.0, min_frac=0.15): @@ -538,7 +712,7 @@ def tighten_text_mask( mask_bytes: Optional[bytes], *, grow: Optional[int] = None, - color_tol: float = 33.0, + color_tol: float = _COLOR_TOL_MAX, ) -> Optional[bytes]: """Shrink a brushed *block* erase-mask down to the title's glyph strokes. diff --git a/frontend/src/pages/poster/Cl2kMakerPage.jsx b/frontend/src/pages/poster/Cl2kMakerPage.jsx index a4e2a093..fecd8073 100644 --- a/frontend/src/pages/poster/Cl2kMakerPage.jsx +++ b/frontend/src/pages/poster/Cl2kMakerPage.jsx @@ -6195,7 +6195,8 @@ const LogoAssetPanel = ({ item, artBySource, loadingArt, saveTargets, toast }) = // ─── Extract a logo from a poster (no AI) ────────────────────────────────── // Brush over a title on a poster; the key lifts it into a transparent logo, // which becomes the custom logo and flows through the same whiten/save. White - // mode keys a white title by brightness; coloured mode keys it by colour. + // mode keys a white title by brightness; coloured mode keys by colour and + // also carries the white words of a mixed title. const [extractOpen, setExtractOpen] = useState(false); const [posterSource, setPosterSource] = useState('tmdb'); const [posterPath, setPosterPath] = useState(null); @@ -6820,12 +6821,12 @@ const LogoAssetPanel = ({ item, artBySource, loadingArt, saveTargets, toast }) = className={seg(extractMode === 'subject')} onClick={() => setExtractMode('subject')} > - Coloured title + Coloured / mixed title

{extractMode === 'subject' - ? 'Brush over the coloured title (keep close to it):' + ? 'Brush over the whole title — mixed white + coloured titles are keyed together:' : 'Brush over the white title:'}

diff --git a/tests/test_cl2k_logo_extract.py b/tests/test_cl2k_logo_extract.py index 484570c6..1c5f9f9a 100644 --- a/tests/test_cl2k_logo_extract.py +++ b/tests/test_cl2k_logo_extract.py @@ -105,6 +105,207 @@ def test_white_key_misses_the_coloured_title(): assert res.split()[-1].getextrema()[1] == 0 # nothing keyed -> fully transparent +def _brush(size, rect) -> bytes: + m = Image.new("L", size, 0) + ImageDraw.Draw(m).rectangle(rect, fill=255) + return _png(m) + + +def test_subject_keeps_both_halves_of_a_mixed_title(): + # white word + red word on a dark field; the colour key alone drops the white + # word when a white-ish tone sits in the backdrop palette, so subject mode + # unions in the brightness key + img = Image.new("RGB", (400, 300), (40, 35, 30)) + d = ImageDraw.Draw(img) + d.rectangle((60, 100, 340, 130), fill=(250, 248, 245)) # white word + d.rectangle((60, 160, 340, 190), fill=(210, 170, 30)) # yellow word + d.rectangle((0, 270, 400, 300), fill=(245, 243, 240)) # white-ish art far away + + out = extract_subject_logo(_jpeg(img), _brush(img.size, (40, 80, 360, 210))) + res = Image.open(io.BytesIO(out)) + # both words in the crop: white bar at the top, yellow at the bottom (a + # yellow-only extraction would crop to ~30px tall) + assert res.height >= 85 + a = res.split()[-1].load() + px = res.load() + wx, wy = res.width // 2, res.height // 6 # mid white bar + yx, yy = res.width // 2, res.height * 5 // 6 # mid yellow bar + assert a[wx, wy] > 200, "white word must survive subject mode" + assert a[yx, yy] > 200, "coloured word must survive subject mode" + r, g, b, _ = px[yx, yy] + assert r > 150 and b < 100, "coloured word keeps its original colour" + + +def test_subject_keys_every_colour_of_a_multicolour_title(): + # the colour key is distance-from-backdrop, not anchored to one hue: white, + # red, yellow and green words must ALL extract with their original colours + img = Image.new("RGB", (400, 420), (38, 34, 30)) + d = ImageDraw.Draw(img) + bars = [ + ((60, 80, 340, 110), (250, 248, 245)), + ((60, 150, 340, 180), (200, 35, 35)), + ((60, 220, 340, 250), (215, 180, 45)), + ((60, 290, 340, 320), (50, 160, 60)), + ] + for r, c in bars: + d.rectangle(r, fill=c) + + out = extract_subject_logo(_jpeg(img), _brush(img.size, (40, 60, 360, 340))) + res = Image.open(io.BytesIO(out)) + px = res.load() + ox, oy = 58, 78 # crop origin = content bbox (bars start at (60, 80)) + for (x0, y0, x1, y1), (er, eg, eb) in bars: + r, g, b, a = px[(x0 + x1) // 2 - ox, (y0 + y1) // 2 - oy] + assert a > 200, f"word at y={y0} must extract" + assert abs(r - er) < 30 and abs(g - eg) < 30 and abs(b - eb) < 30 + + +def test_subject_ring_bleed_does_not_eat_the_title(): + # grunge 'spray' of title colour just OUTSIDE the brush poisons the backdrop + # palette unless bleed clusters are dropped (no far-ring support) + img = Image.new("RGB", (500, 360), (30, 28, 25)) + d = ImageDraw.Draw(img) + d.rectangle((120, 140, 380, 220), fill=(215, 180, 45)) # fat yellow title + # dense speckle hugging the title: inside the near ring, outside the brush + for x in range(100, 400, 9): + d.ellipse((x, 118, x + 5, 123), fill=(215, 180, 45)) + d.ellipse((x, 238, x + 5, 243), fill=(215, 180, 45)) + + out = extract_subject_logo(_jpeg(img), _brush(img.size, (110, 132, 390, 228))) + res = Image.open(io.BytesIO(out)) + arr = res.split()[-1] + # the glyph body must stay SOLID: sample a grid inside the bar + solid = sum( + arr.load()[x, y] > 200 + for x in range(30, res.width - 30, 20) + for y in range(20, res.height - 20, 12) + ) + total = len(range(30, res.width - 30, 20)) * len(range(20, res.height - 20, 12)) + assert solid / total > 0.9, "title body must not be eaten by its own bleed" + + +def test_subject_union_rejected_on_a_pale_field(): + # coloured title on a cream field: the white key would grab the WHOLE field — + # the coverage guard must reject the union and keep pure colour-key output + img = Image.new("RGB", (400, 240), (235, 228, 210)) + ImageDraw.Draw(img).rectangle((60, 100, 340, 140), fill=(200, 30, 30)) + + out = extract_subject_logo(_jpeg(img), _brush(img.size, (40, 80, 360, 160))) + res = Image.open(io.BytesIO(out)) + assert 250 <= res.width <= 320 and res.height <= 80 # red bar only, no field + r, g, b, a = res.getpixel((res.width // 2, res.height // 2)) + assert r > 150 and g < 100 and a > 200 + + +def test_subject_union_drops_backdrop_spilling_across_the_brush(): + # a pale sky band crossing the brush border is backdrop, not title — the + # spill guard must drop it even though it brightness-keys + img = Image.new("RGB", (400, 300), (40, 45, 60)) + d = ImageDraw.Draw(img) + d.rectangle((0, 0, 400, 90), fill=(200, 215, 235)) # sky, continues past brush + d.rectangle((60, 140, 340, 190), fill=(200, 30, 30)) # red title + + out = extract_subject_logo(_jpeg(img), _brush(img.size, (30, 60, 370, 220))) + res = Image.open(io.BytesIO(out)) + assert res.height <= 80, "sky band must not widen the crop" + r, g, b, a = res.getpixel((res.width // 2, res.height // 2)) + assert r > 150 and g < 100 and a > 200 # the title itself survives + + +def test_subject_rescues_a_pale_word_next_to_a_vivid_one(): + # a big vivid word pulls the Otsu band-fit high enough that a pale muted + # word lands under `lo` and vanishes; per-anchor rescue must keep it (its + # min channel is under the white-key floor, so the union can't) + import numpy as np + + img = Image.new("RGB", (400, 340), (128, 128, 128)) + d = ImageDraw.Draw(img) + d.rectangle((100, 90, 300, 120), fill=(110, 120, 160)) # pale muted-blue word + d.rectangle((50, 160, 350, 280), fill=(205, 35, 35)) # fat vivid red word + + out = extract_subject_logo(_jpeg(img), _brush(img.size, (40, 70, 360, 300))) + res = Image.open(io.BytesIO(out)) + assert res.height >= 180, "crop must span BOTH words, not just the vivid one" + arr = np.asarray(res) + top = arr[: res.height // 3] + op = top[..., 3] > 128 + assert int(op.sum()) > 2000, "pale word must survive" + mean = top[..., :3][op].mean(axis=0) + assert mean[2] > mean[0] + 20, "pale word keeps its blue tint" + + +def _junk_poster(): + """Red title bar plus an off-title junk blob, both far from the backdrop.""" + img = Image.new("RGB", (400, 300), (40, 42, 46)) + d = ImageDraw.Draw(img) + d.rectangle((60, 90, 340, 130), fill=(200, 30, 30)) # the title + d.ellipse((150, 190, 250, 250), fill=(60, 130, 200)) # scene junk in-brush + return img + + +def test_subject_zone_filter_drops_junk_off_the_text_line(monkeypatch): + import numpy as np + + from backend.util.cl2k import text_detect + + prob = np.zeros((300, 400), dtype=np.float32) + prob[92:128, 65:335] = 1.0 # detector box over the title only + monkeypatch.setattr(text_detect, "detect_text_probmap", lambda _b: prob) + + img = _junk_poster() + out = extract_subject_logo(_jpeg(img), _brush(img.size, (40, 70, 360, 270))) + res = Image.open(io.BytesIO(out)) + assert res.height <= 80, "junk blob must be dropped, crop is the title alone" + r, g, b, a = res.getpixel((res.width // 2, res.height // 2)) + assert r > 150 and a > 200 + + +def test_subject_zone_filter_fails_safe_without_detector(monkeypatch): + from backend.util.cl2k import text_detect + + monkeypatch.setattr(text_detect, "detect_text_probmap", lambda _b: None) + img = _junk_poster() + out = extract_subject_logo(_jpeg(img), _brush(img.size, (40, 70, 360, 270))) + res = Image.open(io.BytesIO(out)) + assert res.height > 140, "no detector -> keep everything the key found" + + +def test_subject_zone_filter_distrusts_a_partial_detection(monkeypatch): + # the box covers only a minority DISCONNECTED piece; the flood then keeps + # under half the keyed area -> the detector likely missed the wordmark, so + # the filter must leave alpha alone rather than shave the bulk + import numpy as np + + from backend.util.cl2k import text_detect + + prob = np.zeros((300, 400), dtype=np.float32) + prob[92:112, 65:110] = 1.0 # box over the small bar only + monkeypatch.setattr(text_detect, "detect_text_probmap", lambda _b: prob) + + img = Image.new("RGB", (400, 300), (40, 42, 46)) + d = ImageDraw.Draw(img) + d.rectangle((60, 90, 110, 112), fill=(200, 30, 30)) # small detected bar + d.rectangle((60, 150, 340, 250), fill=(200, 30, 30)) # bulk, disconnected + out = extract_subject_logo(_jpeg(img), _brush(img.size, (40, 70, 360, 270))) + res = Image.open(io.BytesIO(out)) + assert res.height > 140, "partial detection must not shave the wordmark" + + +def test_subject_spill_guard_reaches_deep_into_a_large_brush(): + # a bright column entering a TALL brush and running deep inside it must be + # fully flood-killed — a fixed iteration cap left everything past ~256px + img = Image.new("RGB", (300, 1400), (35, 38, 44)) + d = ImageDraw.Draw(img) + d.rectangle((120, 0, 180, 1200), fill=(205, 212, 228)) # crosses the brush top + d.rectangle((60, 1250, 240, 1300), fill=(200, 30, 30)) # red title near the foot + + out = extract_subject_logo(_jpeg(img), _brush(img.size, (20, 40, 280, 1350))) + res = Image.open(io.BytesIO(out)) + assert res.height <= 80, "deep backdrop column must not survive the flood" + r, g, b, a = res.getpixel((res.width // 2, res.height // 2)) + assert r > 150 and g < 100 and a > 200 + + def _coloured_title_strokes(): """Thin red vertical strokes (stroke-shaped 'letters') on a grey plate.""" img = Image.new("RGB", (400, 240), (120, 122, 124)) @@ -114,6 +315,34 @@ def _coloured_title_strokes(): return img +def test_tighten_survives_title_bleed_in_the_outside_ring(monkeypatch): + # spray of the TITLE's colour just outside the block used to poison + # _outside_background: the fallback anchor then "matched the background" and + # tighten gave up. Bleed clusters have no far-ring support and are dropped. + import numpy as np + + from backend.util.cl2k import text_detect + + monkeypatch.setattr(text_detect, "detect_text_probmap", lambda _b: None) + img = Image.new("RGB", (500, 300), (120, 122, 124)) + d = ImageDraw.Draw(img) + for x in range(90, 420, 45): # stroke-shaped red title + d.rectangle((x, 120, x + 12, 180), fill=(210, 40, 40)) + for x in range(60, 450, 8): # dense red spray hugging the block + d.rectangle((x, 100, x + 3, 103), fill=(210, 40, 40)) + d.rectangle((x, 197, x + 3, 200), fill=(210, 40, 40)) + + block = Image.new("L", img.size, 0) + ImageDraw.Draw(block).rectangle((70, 108, 440, 192), fill=255) + + out = tighten_text_mask(_jpeg(img), _png(block)) + assert out is not None, "bleed in the ring must not abort tightening" + m = Image.open(io.BytesIO(out)).convert("L") + assert m.getpixel((96, 150)) > 200 # on a red stroke -> remove + assert m.getpixel((118, 150)) < 60 # centre of the gap between strokes -> keep + assert (np.asarray(m) > 127).sum() < (np.asarray(block) > 127).sum() + + def test_tighten_shrinks_block_to_coloured_glyphs(monkeypatch): # Colour-key fallback (detector off): a generous block over a stroke-shaped # coloured title keys down to the strokes — white (remove) on a stroke, black