Kapow

View the Project on GitHub sanchezpaco/kapow

Enlarged speech bubbles

A readability toggle for small lettering that keeps the page at its normal zoom: each detected speech bubble or caption is drawn again, scaled up in place, over the page. Toggle: the ChatBubble button in the HUD (ReaderUiState.bubblesEnlarged). Nothing moves and nothing has to be tapped through — it is a render enhancement, not a navigation mode.

Pipeline

page bitmap ──▶ PanelDetector.bubbles (data) ──▶ OnnxBoxDetector (ML boxes) ──▶ SpeechBubbles.outlined (domain) ──▶ List<SpeechBubble> ──▶ cache
                                                                                                                              │
                                          BubbleLayout.enlarge (domain, pure) ──▶ List<EnlargedBubble> ──▶ ZoomablePage overlay ◀─┘

PanelDetector.bubbles asks the ML model where the bubbles are and the pixel heuristic what shape they have: OnnxBoxDetector returns normalized boxes, SpeechBubbles.outlined turns each box into a SpeechBubble with outlines. Pixels are classified at the analysis size shared with panel detection (the page is scaled so its shorter side is ~1000 px, so a double-page spread keeps the same cell size as a single page and its small bubbles survive). SpeechBubbles and BubbleLayout are pure Kotlin in feature/reader/domain, unit-tested on synthetic pages. Detection runs off the main thread and is cached per page in PageLoader (only when the toggle is used), first in memory and then in Room (see below).

Persisted detections (page_detections)

Detections are never recomputed for a page already seen. PageLoader.panels and bubbles check their LruCache, then PageDetectionStore, and only then run PanelDetector, writing the result through to Room. The page_detections table (core/storage, DB version 3) is keyed by document URI and page index — the reader opens by URI, and a library rescan that recreates the comics row does not invalidate detections — with nullable panels and bubbles columns so Guided View and bubble mode fill their own half independently. Rects and outlines are normalised page coordinates serialised by PageDetectionCodec (pure Kotlin, round-trip tested): panels l,t,r,b;…, bubbles l,t,r,b|x,y,x,y,…/…;….

Every row carries modelVersion = DETECTIONS_VERSION (PanelDetector.kt, e.g. panels-v1+bubbles-v4). Bump it whenever a bundled ONNX model, the outliner or the panel heuristic changes; rows with another version are ignored and overwritten lazily on the next visit, so there is no migration to write when a model ships. PageLoader logs bubbles on page N in M ms at debug level on every real model run (decode, panels and bubble plan share the line, see performance.md); a second open of a comic in bubble mode must log none. Measured on the Z Fold (Doctor Doom #1 pp. 5-12, bubble mode, 4 page turns): first open 8 runs of 60–645 ms (page 5, the one the toggle waits for: 645 ms); second open after a process kill 0 runs, the same enlarged layout, frames 3.8 % → 3.2 % janky.

ML boxes (OnnxBoxDetector)

The model is a YOLO26n distilled from ogkalu/comic-speech-bubble-detector-yolov8m. The YOLOv8m teacher (F1 0.96, but 99 MB fp32 / 26 MB weight-only int8 and ~1.4 s per page on the Fold emulator) was run over ~2,500 pages of the 16-comic corpus (capped at 250 pages per series, the 98 ground-truth pages excluded) and its boxes used as labels to train YOLO26n for 60 epochs at 640 px (tools/training/build.py, label.py; the whole pipeline is in docs/training.md). The current student was fine-tuned from the first one (11 comics, 300 pages each, Marvel ×3) after the corpus grew with 1970s halftone (Defensores), 1990s scans (Spiderman 2099, Gambito) and modern digital Marvel (4F, Capitana Marvel). Exported with ultralytics (format=onnx imgsz=640 nms=True, so the graph ends in NonMaxSuppression and outputs [1, 300, 6] like the panel model) it is 9.8 MB in fp32; the bundled assets/models/bubbles.ort is the ORT-format version with fused operators and fp16 Conv weights, 5.2 MB, run by a minimal ONNX Runtime build (docs/ml-runtime.md, 2026-08-28). On the 147-page ground truth the fp16 weights give the same boxes on every page (F1 0.97 = fp32) and bit-identical outlines on Doom #1 pp. 5-12 on the Fold; weight-only int8 (2.8 MB) kept the global F1 but lost up to 0.04 on single series (Ben Reilly, Vengadores 174, Venomverse) and was rejected. Boxes with score ≥ 0.25 are kept (the threshold baked into the exported NMS).

On the 98-page ground truth (14 series × 7 pages) the student scores bubble F1 0.96 — the same as the teacher, up from 0.94 for the first student, 0.78 for the pixel heuristic alone — at ~35 ms per page on a laptop CPU and ~230 ms including outline extraction on the software-rendered Fold emulator (the teacher needed ~1.4 s; a 2953 × 4528 scan took 650–900 ms before the page was scaled to the analysis size first). While bubbles are enlarged, PageLoader.preload also detects the neighbouring pages, nearest first, so a page turn normally finds its bubbles already cached. New styles get 4F 0.99, Spiderman 2099 0.99, Defensores 0.90 (up from 0.88; see below — the teacher itself only reaches 0.90 there). On new styles the teacher is the reference to re-distil from; re-distilling is a fine-tune from the previous student. The only comics under 0.9 are manga where the ground truth itself under-counts.

Defensores was the one series where the student inherited a teacher mistake: 1970s reprints carry a page-wide yellow recap caption (“EPISODIO ANTERIOR…”) that the teacher’s pseudo-labels routinely missed, capping the student at the teacher’s own 0.90. Beyond-the-teacher correction: 120 of Defensores’ ~230 training pages (stratified across its four source volumes) were rendered with their existing pseudo-label boxes overlaid and reviewed by vision subagents told to report only missed bubbles/captions — 7 boxes added across 6 pages, mostly that recap caption. Fine-tuning from the previous student for 60 epochs on the corrected set moved Defensores 0.88 → 0.90–0.91 depending on seed, with no consistent cost elsewhere (a second run with a different seed served as a check against training noise, since a handful of corrected pages barely shifts 60 epochs over 2,500). A parallel YOLO26s fine-tune on the same corrected data reached bubble F1 0.97 overall but did not fix Defensores itself (0.898, unchanged) and cost 4× the model size (36 MB vs 9.4 MB) and ~2.75× the inference time — rejected, since detection speed was already not the bottleneck and the series that motivated the exercise didn’t benefit. A page where the model finds nothing yields no bubbles: falling back to the heuristic there was tried and only added false positives (manga pages without bubbles).

Student v4 (2026-08-28) is a fine-tune of v3 on the whole comics/ folder (the corpus is private and not distributed; only the annotations under tools/training/gt/ ship with the repository): 4,723 pages of 31 series (250 per series, 8 % validation), 73 of the issues new — Doctor Doom, Absolute Carnage, Escuadrón Suicida, Loki, Marvel Zombies, Marvels: Ruinas (painted), Black Cat Strikes, Vader Down, two Panini tomo scans and the first European albums, Blacksad and Rapaces. The teacher labelled the pages (29,546 boxes); 500 training pages of the seven new styles were then reviewed by vision subagents for missed balloons and captions, which added 57 boxes on 33 pages (tools/training/gt/v4_bubble_corrections.json: “?” / “?!” mini-balloons, borderless narration captions, the Marvel Zombies recap captions on credits pages). 60 epochs, 4.6 h on MPS, val mAP50 0.992. The box ground truth grew to 147 pages of 21 series (gt_{doom,ruinas, blacksad,rapaces,escuadron,vengadores174,carnage}.json, 7 pages each). On it, bubble F1 v3 0.968 → v4 0.971 (teacher 0.967; precision 0.964 → 0.965, recall 0.971 → 0.977), 29 ms per page on the laptop CPU. Per series v4 gains Titanes 0.93 → 0.96, Ben Reilly #03 0.96 → 0.98, Vengadores tomo 0.97 → 0.99, Rapaces 0.99 → 1.00, loses Escuadrón and Ruinas 0.99 → 0.97; Defensores 0.98, Doom 0.97, Carnage 0.92. Blacksad reads 0.88 for v3, v4 and the teacher alike with every balloon found on all seven pages — the subagent-drawn ground-truth boxes are offset on a few tall balloons and miss the IoU 0.3 match, a ground-truth artefact rather than a detector miss. Silhouette IoU 0.879 → 0.881 (76 → 78 of 115 ≥ 0.9, 0 fallbacks), 98-page corpus uncovered area 0.2269 → 0.2212. Doom #7/#9 uncovered 0.0036 → 0.0072 because v4 now boxes six borderless character-introduction captions (white lettering straight over art, so they fall back to the box) and two false positives on a “LIVE STREAM” screen graphic. The Spiderman 2099 Vol1 04 p.7 split caption (“POR CULPA DE ESE SPIDERMAN”, whose second box over the last line hid “A POR MÍ. DE HECHO” once enlarged) still shows the nested box on the 2000 px offline page, but on the Fold emulator — the analysis runs at 1000 px there — v4 yields one box and the enlarged caption reads complete; verified on the emulator, not yet on the physical Fold.

Student v5 (2026-08-31) extends the corpus with eleven series new to the folder — Superman (ECC), Arkham Asylum (painted, hand-lettered), Outlast, Ultimate Wolverine, Do Androids Dream of Electric Sheep (Norma), Crossed, Sonic (cartoon), Zombillenium (European BD), Batman: Terrores Nocturnos, and the first PDF sources (LOK: Soul Reaver, Fallen Brothers, rasterised with pdftoppm -r 200) — 1,791 pages added (5,977 train / 536 val). Three process changes over v4: pages are pseudo-labelled by the union of the teacher and v4 (IoU ≥ 0.5 match) and only the 342 pages where they disagree went to vision subagents for review (19 % of the new pages, against 500 blind pages for v4); the review now also removes false positives (boxes indexed on the rendered page; 67 boxes added, 283 removed — chat-avatar icons, credits pages, loose lettering over art; tools/training/gt/v5_bubble_corrections .json); and training stops early (patience=15). A first run with the v3-era schedule (lr0 0.01) never recovered from warmup — the best checkpoint was epoch 1, i.e. v4 perturbed, and it scored worse than v4. The kept run fine-tunes from v4 at lr0 0.001, warmup 1 epoch, 30 epochs (2.9 h MPS, best epoch 28). The box ground truth grew to 193 pages of 28 series (7 new series incl. both PDFs; Blacksad re-annotated after its 0.88 turned out to be offset ground-truth boxes, not detector misses — v4 scores 1.00 on the fixed file). On it, bubble F1 v4 0.970 → v5 0.973 (precision 0.963 → 0.969, recall 0.978 → 0.976); Fallen Brothers 0.92 → 0.96, Ruinas 0.97 → 1.00, Zombillenium 0.90 → 0.92, Sonic and Androides 1.00. Cost: Inmortalidad 0.92 → 0.86 and Ben Reilly #03 0.98 → 0.96 — three shout balloons without a white body (black “TUYA!” oval, spiky scream balloons over art), a family adjacent to the removed false positives. Silhouette IoU 0.881 → 0.875 on the old corpus, 0.881 → 0.887 on the Doom set, uncovered area unchanged on all three corpora. The fp16/ORT conversion keeps GT F1 at 0.973 bit-for-bit on 190 of 193 pages (the other three trade one sub-threshold box). Shipping v5 also surfaced a render bug unrelated to the detector — LOK’s tinted yellow captions cut their last line — fixed in PaperTone the same day (see the outlining section; repro kept in the training kit under lok7_tinted_caption/).

Compression of the teacher, for the record: weight-only int8 (per-channel int8 Conv weights + DequantizeLinear, fp32 activations, a one-off script not kept in the repository) kept identical detections at 26 MB and loads on ORT mobile; ConvInteger dynamic quantization does not load there and QDQ static quantization destroyed accuracy; XNNPACK made no speed difference.

Occasionally the exported NMS lets two near-identical boxes for the same physical bubble through (observed IoU 0.99 on a real page). Each got outlined and enlarged independently, and BubbleLayout.enlarge — seeing two distinct bubbles that collide once grown — pushed them apart instead of recognising them as one, rendering the same balloon twice, side by side. detect() now merges boxes with IoU ≥ 0.5 (their union) before returning, for both the panel and bubble models. Diagnosed by reproducing on-device (Muerte by ALHma[CRG], Marvel Saga 14 “Swing Shift”) and dumping raw box coordinates.

Copy overlap (occlusion) — metric and layout rules

Detection F1, silhouette IoU, uncovered area and the constrained/stuck counts all validate their own layer; none of them saw the final rendered result hiding text: an enlarged copy drawn over a neighbouring balloon’s text (reported by a tester on Venomverse, 2026-08-31; 15 of 157 corpus pages had serious cases, Shangri-La and Spiderman 2099 worst). The visualizer now measures it per page: collisions / collisionArea (pairs of enlarged silhouettes that overlap, and the page fraction they share — rim-dilation makes tiny kisses unavoidable, so judge by area; ≥ 0.005 of the page is the “serious” bar) and intrusions / intrusionArea (an enlarged copy over another bubble’s original silhouette where that bubble’s own copy no longer covers it).

Area alone still hid the worst cases: a small balloon can be buried whole and still cover a negligible share of the page (Venomverse 001-001, the page the tester reported, scored 0.0059 — under the serious bar). The metric that reveals it is per victim: worstOccluded is the largest share of any enlarged copy hidden by the copies drawn after it (draw order is the enlarged list order, in both the visualizer and BubbleOverlay), and occluded counts the copies past OCCLUDED_SHARE (0.10). It is a strict superset of the area bar — 27 of 156 corpus pages against 11 — and the 16 it adds are small balloons with their text cut. KAPOW_BUBBLE_SCALE sweeps the metric across the user-facing 1.1–2.0 range.

worstOccluded still measures the share of the whole silhouette hidden, which over- and under-counts the thing that matters: a copy can bury a neighbour’s paper margin (harmless) or its last text line (unreadable) for the same score. The text-aware metric measures the letters. Each bubble now carries its text: the word boxes inside it, extracted by reusing the same word machinery the heuristic uses (interiorHoles of the body → segment → the MIN_WORD_HEIGHT_FRACTION/MAX_TEXT_BLOCK_HEIGHT_FRACTION/ink-share filter) but run inside the already-known ML box and its already-computed paper/dark body instead of over the whole page, so it never fires on art — the model has already found the balloon. It is a separately versioned unit (TEXT_VERSION, text-v1) so the text finder can iterate as its own “model”; it is off by default (SpeechBubbles.outlined(..., extractText)), computed only in the visualizer for now, and adds no device cost until the layout consumes it (at which point it joins DETECTIONS_VERSION and is persisted). The visualizer draws the word boxes on -bubbles.png and reports worstTextHidden (the largest share of any copy’s own text, drawn at its enlarged position, hidden by the copies drawn after it) and textOccluded (copies past TEXT_HIDDEN_SHARE = 0.10) alongside worstOccluded. It is strictly sharper: on the acceptance pages LOK p.20 reads worstOccluded 0.247 but worstTextHidden 0.000 (the overlap there is paper margin, not text — the ghost-line bug below, not copy-over-copy), Shangri-La 01(30) reads 0.243 / 0.210 (a real burial — “UNA OBRA MAESTRA” over “ES LA ANTÍTESIS…”), and Venomverse 001-001, the tester’s page, reads 0.077 / 0.013 (the committed fix already cleared it).

Three layout rules changed with the metric as gate: intrusion depth is measured relative to the host’s original size, not its target; reanchorPair picks positions by total intrusion depth instead of counting colliding pairs, which used to trade a harmless margin graze for a deep overlap over text; and buriedGrowth charges a pair for the growth of its absolute overlap beyond what the artist drew. The last one is what the first two could not reach: the exemption for an art-overlapping pair was expressed in normalized depth, which is nearly invariant under joint concentric scaling, so such a pair stayed exempt forever while its absolute overlap grew up to 3.8× (26 of the 27 bad pages were this family, 0 were detection errors). Charging the growth in page area, normalized by the smaller bubble, took bad pages 27 → 18 and the worst case 0.879 → 0.247, costing constrained +46 % and delivered scale −1 %.

Scaling art-overlapping pairs as rigid groups (preserving the artist’s stacking, scaled) was tried and REJECTED: it forfeits the separations the push/reanchor machinery does win — corpus bad pages 6 → 18.

Known cost: separating more exposes more of each original, so intrusionArea rose 24 % and uncovered 32 %; on LOK p.20 and Shangri-La 01(30) that reads as a ghost line of the original’s text behind the moved copy. Same failure shape as the bug above — a page-normalized metric hiding a legibility defect — and the next thing to attack.

buriedGrowth was then replaced by buriedShare: instead of only charging the growth of an art-overlapping pair’s overlap, a pair is charged the larger of that growth and its absolute overlap beyond a ceiling (BURIED_SHARE_TOLERANCE = 10 % of the smaller bubble), whatever the artist drew — no pair may bury more than a tenth of its neighbour. The final coverage-relaxation shrink also floors at CONTAINED_SCALE_FLOOR (1.15×) instead of 1×, so no bubble is ever left un-enlarged (the feature loses its point otherwise). Judged against the text-aware metric on the 215-page general sample, this took pages with any text hidden past TEXT_HIDDEN_SHARE from 1 to 0 and the summed worstTextHidden 0.199 → 0.035 (−82 %), pages with silhouette overlap past the bar 18 → 2, stuck-at-1× 1 → 0, at a modest uncovered cost (+32 %, an eighth of what the rejected two-region round below cost). Verified on the Fold emulator: Venomverse 001-001/001-019 read complete, Shangri-La 01(30)’s buried “UNA OBRA MAESTRA” pair now reads (both copies separated). That left one defect: the ghost behind a separated pair. On linked balloons the artist drew as one continuous speech (two lobes that overlap heavily), separating them to satisfy the ceiling opened a gap the width of their original overlap, and the CrescentFill there showed a faint double of the original text. The uncovered metric is area-based and under-weights it (0.0033 of the page reads as a visible ghost), the same blind spot pattern again, so it was judged by eye too.

The fix removes the gap at its source, in SpeechBubbles.outlined (mergeLinked): the model emits one box per balloon, so a linked pair arrives as two boxes the layout then has to separate. After outlining every box, pairs whose dilated silhouettes overlap strongly — shared cells ≥ LINKED_SILHOUETTE_OVERLAP (20 %) of the smaller silhouette — are unioned into a single SpeechBubble (their blobs merged, then re-traced by toBubble, so a fused pair yields one clean outline), exactly as the heuristic path unions mergeOverlapping blobs. BubbleLayout then grows the unit about its joint centre and never opens a gap between the lobes; CrescentFill fills the union footprint as one. The threshold is deliberately high (a genuine linked waist shares ~30–50 % of the smaller balloon; an incidental tail-cross a few per cent) so it is a hybrid: only truly fused pairs merge, and the push/reanchor machinery still wins the separations it can when there is room — merging all art-overlapping pairs was rejected (corpus bad pages 6 → 18). The union is purely geometric (silhouette cells, not text), so the device path is unchanged in cost; it changes persisted outlines, so DETECTIONS_VERSION gains +linked-v1.

On the 215-page general sample (boxes fixed, mergeLinked vs the ceiling alone) 37 pages merged a pair: summed worstTextHidden 0.035 → 0.002 (−94 %, the last buried-text page, Venomverse 001-015, cleared), summed uncovered 0.627 → 0.617 (net down — the linked-pair ghosts closed: Shangri-La 02(80) 0.0033 → 0, 01(72) 0.0202 → 0.0180), pages with serious silhouette overlap 70 → 53. The threshold is a genuine tradeoff, not a clean separator: a real linked waist shares 0.36–0.45 of the smaller silhouette, but manga balloons the artist draws touching (One Piece) share 0.20–0.38 — the same range as the Venomverse pairs whose copies do bury text. 0.20 is set to catch the buried-text/ghost cases (the primary goal); its cost is over-merging a few manga pages where separate touching balloons become one unit (One Piece p.137-15 uncovered +0.0134, text still readable). Telling those apart needs the text-burial outcome, i.e. text extraction on device — deferred until the layout consumes text.

Reverted alongside: a larger “two regions per bubble” round (mutually exclusive owned silhouettes + an erased full-body footprint feeding CrescentFill) drove the text metric to 0 on those same pages but blew uncovered up 5–7× (grey inpaint smears from the extra separation), so only the absolute-overlap ceiling and the shrink floor were kept from it. Patch saved at .claude/handoffs/assets/2026-08-31-ceiling-ghost-discarded.patch.

Outlining a box (SpeechBubbles.outlined)

For each ML box the detector looks for the bubble body inside it: the connected component of solidPaper (white or cream) or of the solid dark mask (negative bubbles) that owns most cells of the box, searched in a frame grown by 1 % of the page side so the rim is connected. The component is clipped to the box, dilated by the rim thickness like heuristic bubbles (so the black border is part of the shape), and traced into outlines. If no body covers at least a quarter of the box (lettering straight over art, sound effects) the box itself becomes the bubble.

The paper mask is chosen per box (PaperTone, 2026-08-27). solidPaper is a global threshold (luminance ≥ 228 or cream) tuned for digital pages; on a 1990s scan the bubble paper sits at 224-231 with scanner grain, so half its cells fell under the threshold, the body was patchy and every bubble on the Spiderman 2099 ground-truth page fell back to its box. Like the ground-truth generator, the outliner now estimates the paper of each box as the median luminance of its brightest 40 % of cells and the median red-green / green-blue tints of the cells within 12 of it; when that paper is scanned — luminance in 200 until 236 — the body is the component of cells within max(14, (255 - paper) / 2 + 8) of the paper luminance and within 12 / 16 of its tints (PixelClasses.colors, the mean colour per cell). On strongly tinted paper those bands widen with the tint (2026-08-31, for LOK’s parchment-yellow captions, green-blue tint ≈ 70): the luminance tolerance also floors at 0.55 × (|red-green| + |green-blue|) and the drift limits at half the respective tint plus slack, and both drift limits additionally floor at 0.3 × the total tint — shading on a tinted paper warms the red-green balance in proportion to the whole tint (a LOK fold shadow moves red-green from 9 to ≈ 22 while its own red-green tint would only allow 12), parchment texture modulates a tinted tone far more than scanner grain does a neutral one, and the tint gates keep carrying the discrimination against neighbouring art. A neutral tone reproduces the old bands exactly. Before this the paper body of those captions disconnected after the first text band and the enlarged copy cut the last lines. Brighter paper keeps the global solidPaper so digital pages cannot move; darker “paper” is art (a box full of red would otherwise become its own body) and falls through to the old path. Spiderman 2099: 17 box fallbacks → 0, mean IoU 0.758 → 0.879, 11/17 ≥ 0.9; Defensores 0.898 → 0.902 with its one fallback gone; One Piece, Ben Reilly, Doctor Muerte and Doom identical; 98-page corpus uncovered area 0.2300 → 0.2268, only scan pages changed. The two 2099 outlines still under 0.75 leak into pale art beside the rim — the idea 3b family, not a paper-tone problem.

A paper body that covers that quarter is then grown towards the short sides of its box. Doctor Doom’s bordered square captions (2026-08-27) are filled with a white-to-light-grey gradient whose bottom rows fall under the paper threshold, so the paper body stopped at the last text line, the flat repaint covered the whole silhouette and the copy showed the line half painted over. The ML box is the second opinion: when the body’s margin to the box on one side exceeds twice the smallest margin on the other sides plus two cells (SHORT_SIDE_MARGIN_FACTOR, SHORT_SIDE_MARGIN_SLACK; Doom captions sit 2-6 cells from the box on three sides and 11-37 on the fourth), the body is flooded through solidPale cells (every source pixel with luminance ≥ 200 and chroma ≤ 22) lying beyond the body on that side. If the flood reaches the box edge it is discarded — the band was background, not shading. “Beyond the body” is judged per column and per row against the body’s own cells, not against its bounding box: on the Z Fold (analysis at 1000 px, bilinear downscale) five Doom #9 captions on pp. 5-8 still lost their last line (2026-08-27) because the paper body ended halfway down the last text row with one deeper spike, so the pale gaps between the letters of that row lay above the bounding-box bottom and the flood never reached the band. Reproduced on the JVM only with the device’s own analysed bitmaps (BubbleOutlineDump androidTest → DeviceOutlineRepro, see below). Rejected variants: growing through pale cells anywhere inside the box (light scanned sky and halftone next to a bubble are pale and happen to be enclosed by dark art at the box edges; Defensores 256, One Piece 28), gating by “the flood through non-dark cells stays enclosed” (a box whose four edges land on dark art encloses anything; Ben Reilly #01 p.2), and growing bodies that would otherwise fall back to the box (patchy scanned paper in Spiderman 2099 grew into shapes with notches cutting through text). Result: uncovered area on nine Doom #7/#9 pages 0.0128 → 0.0036 with all 22 captions reaching their bottom border, Ben Reilly #01 unchanged (no silhouette differs), 98-page corpus 0.2157 → 0.2152 with boxes.json regenerated by student v3.

Silhouette ground truth

Box F1 says whether a bubble was found, the uncovered-area metric how much of the original a copy leaves visible; neither sees a silhouette that stops a text line short or falls back to the box rectangle (the Doom last-line bug was invisible to both). tools/training/gt/silhouettes.json holds 115 hand-validated bubble outlines on seven pages of six series — Ben Reilly #01 (digital Marvel, touching pairs, tails), One Piece (manga, bursts, bubbles on white gutters), Defensores (1970s halftone), Spiderman 2099 (1990s scan), Doctor Muerte (double-bordered captions) and Doctor Doom #9 (bordered gradient captions, 33 outlines). Each entry stores the page file, a series name, the ML box (boxes.json coordinates, normalised to the uncropped page) and the polygon in the same frame. Four boxes were excluded as ambiguous: borderless lettering over art, a non-bubble detection, a burst open to a white background and a box covering two lines of a larger bubble.

The polygons were produced by tools/training/silhouettes.py at full resolution — an independent pipeline from the app’s outliner (paper colour estimated per box, dilated ink as the barrier, text bays closed through paper and ink pixels only, geodesic growth through pale shading and thin tails inside the box, dilation by the rim thickness) — then reviewed on contact sheets tile by tile, with hand overrides where it failed (convex hull for bubbles whose bold lettering spans the whole width, green-border rectangles for two Doom captions with notched corners, clip rectangles where touching bubbles or white gutters leaked in, one hand-traced polygon). The outlines stop at the ML box like the app’s do, so a tail the box cuts off is not held against the outliner.

SpeechBubbleVisualizer scores every ground-truth entry whose page is in the folder: the bubble is matched by box IoU, both shapes are rasterised at analysis resolution and the IoU goes into metrics.json per bubble, and out/silhouettes.json sums up per series and overall — count, unmatched, mean IoU, good (IoU ≥ 0.9) and boxFallbacks (outline filling ≥ 97 % of its box). On main after the per-box paper tone (2026-08-27; the baseline before it was mean 0.861, 64 ≥ 0.9, 18 fallbacks, 17 of them Spiderman 2099 at 0.758):

Series Outlines Mean IoU IoU ≥ 0.9 Box fallbacks
One Piece (manga) 11 0.914 10 0
Defensores (1970s halftone) 17 0.902 11 0
Ben Reilly #01 (digital) 24 0.881 15 0
Spiderman 2099 (1990s scan) 17 0.879 11 0
Doctor Doom #9 (digital, captions) 33 0.876 19 0
Doctor Muerte (1990s) 13 0.828 10 0
All 115 0.879 76 0

The worst cases are now one family — the paper body leaks into pale art beside the rim and the outline runs to the box (Doctor Muerte “FUE…” 0.36 and “¿QUÉ ES ESO?” 0.40 over grey walls, Ben Reilly “HOY PASASTE” 0.46, Doom 009-004 “PERO AUN ASÍ” 0.43, “¿SABÍAS” 0.72, “CORRECTO” 0.73) — plus one Doom 009-005 caption whose body still stops inside its last line (0.66) and Muerte’s double-bordered captions, outlined at the inner rule instead of the outer one (0.71). Two visualizer bugs surfaced while baselining: boxes.json escapes non-ASCII file names (D\u00eda), which silently sent the Defensores page through the heuristic detector, and the polygon parser dropped the last vertex of every outline; both are fixed.

The scanned series are where the outliner falls back to the rectangle; the ideas in docs/improvement-ideas.md are now measurable against this file. When adding outlines, run silhouettes.py candidates, review the sheets, extend the override tables rather than editing polygons by hand, and keep the excluded boxes excluded.

Detecting bubbles without the model (SpeechBubbles.detect)

Still used by SpeechBubbleVisualizer when no box file is given and kept as the reference pixel pipeline; the app no longer calls it.

Built on PixelClasses (see guided-view.md), plus five extra classes:

Detection is typography-first: the primary unit is the text run, not the bubble. It finds lettering, groups it into a text block, and enlarges that block (plus a small padding). A drawn container (bubble or caption box) is only an optional refinement — when a clean one is found it gives a nicer backing and silhouette, but the container is never a gate that can reject a valid text run. This is what fixed the recall failure where clear small bubbles enlarged nothing: the old pipeline grew a container and then discarded the text if that container failed strict shape filters. Three tones are processed independently and merged at the end: white (dark ink on white paper), cream (dark ink on the pale yellow of caption boxes, so a cream caption never grows into a white moon behind it), and negative (light ink on solidDark — white lettering on a solid-black bubble, the mirror of the white pass: the ink role is played by light cells and the paper role by solidDark). The negative tone is first restricted to solid dark bodies: only solidDark components that keep a core after an opening (radius 3) are used as paper, so a real filled bubble body survives with its interior text intact while the thin strokes of black lettering and small dark features (a pupil, a bit of hair) — which have no solid core — never act as a bubble and so are not enlarged over the artwork. Steps per tone:

  1. Strip gutters. Long thin runs of paper (≥ 30 % of the page long, ≤ 1.2 % thick) are removed, so a bubble cut by a panel edge no longer merges with an axis-aligned white gutter.
  2. Words. Non-paper cells enclosed by paper (not reachable from the page border) are segmented; a component is a word when it is between 0.4 % and 4.5 % of the page tall, at least 0.4 % wide, and enough of it is ink: 40 % for dark lettering on paper, 50 % for light lettering in a dark body (BlockRules.minWordInkShare). The ink test is what separates lettering from sky showing through clouds or moon texture. The paper threshold is lower because scanned print (1980s Aliens at 1282 px, 15-px letters) leaves an anti-aliased grey fringe around every stroke, and whole pages of plain white bubbles sat at 0.40-0.49 and were never seen.
  3. Text blocks. Words within a word gap horizontally (0.8 % of the page) and a line gap vertically (1.2 %) and sitting on the same connected paper region are clustered (union-find). The paper test is what keeps the texts of two touching bubbles apart (the drawn outline separates their paper), so each gets its own container instead of one wide block whose ring growth starves the arcs of both; the word’s paper region is the one above the first cell of its top row. A block must be dense (words cover ≥ 25 % of its box), be text-shaped (wider than 0.6 × its height, or ≥ 40 % dense when taller — a grid of building windows is neither), and contain at least one line-shaped row (words sharing a baseline, joined, at least 1.5 × wider than tall). Rows rather than single words: when every letter is its own hole, “ME” or “HARTÉ” has no word wider than tall, but the row is. Per tone (BlockRules): ink on paper needs 2 words; light on dark needs 4 and a density ≤ 1 — overlapping word boxes (density > 1) are art, never type, and two or three light holes in a dark shape (the eyes and emblem of a black costume, a shield star) are the typical false positive there.
  4. Grow the bubble. Paper is flooded outwards ring by ring (Chebyshev distance to the block box), only within a rounded reach around the block. The flood is seeded from the paper touching the words (each word box inflated by one cell), never from the block box itself: the corner of a block box regularly pokes out of a round bubble (a long middle line under a short first one), and a seed placed there floods the page background around the bubble. Growth stops as soon as a ring adds less than 1 % of what was grown so far (creeping along a thin keyline or down a tail into same-colour art stops; the bubble body is filled even for a tight caption). For ink-on-paper tones that 1 % is measured against the rings only (the block’s interior paper is excluded) — a tightly filled bubble has a large interior and small arcs beyond its text, and counting the interior made the last rings of its arcs look like a leak and cut through the first or last line of text. The negative tone keeps the interior in the total: black bubbles run into black art routinely and that stricter stop is their main containment. The reach is scale-invariant: it grows with the block (0.8 × its shorter side), floored at 2.4 % of the page and capped at 8 %. If the container grown at the block reach is not clean, or was still growing when it hit its reach (reachLimited), it is regrown at the 8 % cap; the far container is used when it is clean and cuts no word, otherwise the near one stands (a one-line block whose bubble is bigger than the minimum reach gets its full silhouette instead of a straight edge, and a far container that leaks through a gap in the outline is not preferred over a truncated but sound one).
  5. Container or padded text. The grown blob is a clean container when it does not touch the page border, covers the block, has a compact fill (≥ 35 % of its box), is at most 12 % of the page, not thinner than 1.2 % of the page, no longer than half the page’s shorter side (a recap page’s white text on black is a text block, not a bubble), aspect ≤ 6, and has 3–50 % ink inside with ≥ 75 % of it text-like (line/block heights are the larger of the page fraction and a multiple of the blob’s own median glyph height, so large manga lettering counts like dense Marvel type). A dark body must also be lettered: at least 60 % of the cells in its interior holes are light (minHoleInkShare, dark tone only). A black bubble’s holes are its lettering; an open mouth’s holes are a tongue and gums around a row of teeth (19–55 % light on the Venomverse pages versus ≥ 67 % for every real black bubble), and such a body is dropped outright — it never falls back to a padded rect. Otherwise the text still enlarges: the unit becomes the block padded by 1.2 × its glyph height (Blob.filled, enough to enclose the drawn outline of a bubble sitting on a white page margin), provided ≥ 60 % of the padding ring is paper (MIN_MARGIN_PAPER_SHARE — text must sit on flat paper; a TV ticker over art, a single open mouth or tattoo are dropped here), the rect does not touch any other inked hole (a rect is never pasted over a neighbour’s text), and the block is not dominated by oversized words (> 2 × the median glyph covering > 50 % of the word area — a white costume silhouette with a few specks).
  6. Shared paper. Containers whose cells overlap (two text blocks inside one drawn shape, e.g. a bubble chained to the next with no separator between them) are unioned, so the copy carries both texts instead of one covering the other. Bubbles that merely touch stay separate units.
  7. No cut text. Ring growth stops at a straight Chebyshev ring, so a container can end halfway through a neighbouring bubble’s letters (a wide bubble whose words were only partly found, a chain whose next link has no block of its own). After the union, a merged shape that still cuts a word — a word hole with pixels both inside and outside the shape’s row/column hull — is regrown from a seed extended with every word it overlaps (up to 5 rounds) so the neighbour becomes part of the unit; if no clean container results, the member blocks fall back to padded rects. The check runs after the union on purpose: a link’s own container legitimately stops at the neck and the neighbour’s completes it.
  8. Outline. For the dark tone, cells of the container that belong to a thin long band of the tone (a run ≥ 30 % of the page long whose band of such runs is ≤ 1.2 % thick — a panel border line) are removed first: a black caption sitting on a panel’s border line is one dark component with it, the gutter stripper keeps the piece of the line above the caption (its paper run continues into the caption), and the copy of that piece landed as a comb in the white gutter. This is done on the finished blob, not on the paper mask, so words and enclosure are unchanged (stripping the mask fragmented a recap page’s black background into new false containers). A finished container is then dropped when it is combed by art: the short gaps (≤ one glyph height) between its cells along every row and column are filled, and if more than 10 % of that filled shape is ink outside any word (MAX_GAP_ART_SHARE, Blob.gapArtShare) the shape is not a bubble but paper crawled through art — halo lettering over a building facade grows a clean-looking comb whose fingers follow the window mullions (Attack on Titan p003 “ESTE MUNDO…”, and the STOMP slabs), and bead/eye highlights grow a comb through the art lines around them. Real bubbles sit ≤ 4 % (Venom, all 140 containers); a merged pair with a concavity full of art stays under because a wide concavity is not a short gap (Doom “CLARO QUE LO ES” + “HAY MUCHOS PLANOS”, 8.5 %). No scalar shape gate separated that comb from real bubbles (box fill 0.46, hull solidity 0.66, opened solidity 0.79 all overlap Venom), nor did the ring-growth profile. The blob is then inflated by its rim thickness, at least 2 cells so the drawn outline stroke is included: the ink density of successive one-cell rings around the blob is measured up to 1.5 % of the page, and the rim is the last ring still ≥ 35 % ink (gaps inside a ragged rim are tolerated — a whisper bubble’s rim is a halo of detached fragments with grey between them); a rim that is still dense at the cap is dark art around the bubble, not a rim, and the default margin stands. Without this the copy of a ragged-rim bubble was a plain white blob and the original rim showed around it so the drawn outline stroke is included, and the exact contour of each of its connected components is traced (crack following along cell edges, corners only) into a polygon (SpeechBubble.outlines, normalized). The contour is exact on purpose: a per-row span would turn a three-bubble chain into its hull and the copy would carry the art between the links. One polygon per component matters because step 9 unions blobs whose cells are disjoint; tracing only the first component rendered such a unit as a sliver.
  9. Overlapping tones (boxes overlapping by ≥ 15 %) are merged into one unit, so they grow together instead of covering each other.

Known limits: lettering-only SFX and interjections are not enlarged by design (SFX lettering is not enclosed by any tone, big SFX are too tall to be words, one- or two-hole shouts fall under the word minimum); a bubble fused with a large white area (a moon, a white costume) is found only when its letters do not touch the outline, and its copy carries a bit of that background up to the reach limit, so a faint seam can show there; a letter touching the bubble outline merges with it and is lost from the block, which can trim the padded fallback by a glyph — and a whole line fused with the outline (the first or last line of a tightly lettered bubble) walls off the paper beyond it, so the container ends at that line and its copy shows it clipped; a bold jagged font whose letters fuse across lines is too tall to be a word, so such a bubble is missed rather than cut; a tinted (blue-grey shadowed) bubble is not paper at all and is invisible to every tone, including the cut check of its neighbours. A black bubble’s tail that runs into same-colour art is clipped where growth trickles out, which can leave a small seam at the tail. The negative pass trades a small false-positive risk for black-bubble support; the solid-core restriction removes the common cases (black lettering and small dark shapes read as tiny negative bubbles over the art), but a large solid dark shape with a light gap — a floodlight truss against a bright sky, a big eye or open mouth — still has a core and can be enlarged spuriously, rare and confined to genuinely dark artwork (white/cream pages are unaffected).

Laying out the enlargement (BubbleLayout)

enlarge(bubbles, scale) scales every bubble by the user’s scale (default BUBBLE_ENLARGE_SCALE = 1.3×, adjustable 1.1–2.0× in BUBBLE_SCALE_STEP (0.1) increments with the stepper that appears in the reader’s view-mode panel under the “Enlarged bubbles” switch; persisted in DataStore as bubble_scale). The whole original silhouette is filled from the art around it at render time (see CrescentFill under Rendering), so whatever the copy does not cover shows as a smudge of the surrounding colours the size of the displacement — invisible on flat backgrounds, a soft blur over detailed art. The policy is therefore still leave the smallest possible uncovered area: keep the copy over its original, give up scale down to a floor before sliding, and slide only when nothing else fits.

  1. Every bubble grows about its own centre, bounded by the page only: a bubble at a panel edge grows over the gutter instead of sliding inward.
  2. Two copies collide when their silhouettes intrude into each other more than the originals already did. Each bubble’s outlines are resampled to 48 points spread along their total perimeter (per bubble, not per outline: a leaked fragment of the neighbour must not get 48 points of its own); a point of one copy intrudes when it lands inside the other copy’s silhouette shrunk to its text body (TEXT_BODY_SHARE = 90 %, so rims may touch or overlap by a few pixels, as stacked balloons do in the art), and the intrusion is the sum of each such point’s distance to the host outline, relative to the host copy’s size. That sum is compared with the same sum on the originals: ML boxes of neighbours overlap routinely (tails, a caption over a balloon, a tail drawn across the next balloon) and reproducing that overlap is not a collision, while pushing a tail deeper into the neighbour is. Counting intruding points instead of summing depths missed exactly that case (Ben Reilly #01 p.17, “PERO SI PUEDO” over “BUENO”). Box-based collision was tried first and failed both ways — it either forbade placements the art itself uses or, with a box-overlap allowance, let one copy cover the neighbour’s text.
  3. Colliding copies are pushed apart (16 passes, along the axis with the smaller overlap, a quarter of the overlap each) without uncovering their original, and each colliding pair then searches the 3 × 3 grid of positions in which both copies still contain their originals for the one with the fewest collisions against every neighbour. The joint pair search matters: a chain such as caption → balloon → balloon on Ben Reilly #01 p.17 has two valid contained arrangements out of 729 and a per-bubble greedy search finds neither.
  4. Pairs that still collide are shrunk by 10 % steps down to CONTAINED_SCALE_FLOOR = 1.15×, pushed and re-anchored again.
  5. Only then do the coverage steps relax (COVERAGE_STEPS = 1.0, 0.85, 0.5, 0): a copy may uncover up to 15 %, 50 % and finally the whole original per axis, never farther than one box away; what still collides is shrunk towards 1× (BubbleLayoutTest.crowdedClusterEndsWithoutOverlappingCopies).
  6. A final cover-the-vacated-area pass (coverOriginals) pulls each copy back toward the position where it is centred on its own original (full coverage, Placed.grown), as far along that path (COVER_STEPS = 8) as it can go while a neighbour is not (a) collided, (b) overlapped in silhouette any more than it already is, or (c) intruded into in its text body any more than it already is. Steps 3–5 slide copies apart to break collisions, which leaves the vacated original showing as a CrescentFill smudge over the art; this pass reclaims that area wherever it is free, so the copy covers its footprint instead. The three neighbour tests keep it strictly safe: a copy may only grow back into space no other bubble holds — never further over a neighbour’s text. The text-body test (textIntrusion, the same measure as the collision check) is the one that matters: bounding-box overlap alone is too loose for a tight black-bubble cluster and let a copy graze a neighbour’s last line (Venomverse 001-019). On the 215-page general sample this took summed uncovered 0.617 → 0.394 (−36 %) with summed worstTextHidden unchanged at 0.002 (corpus max 0.018 → 0.002, no page’s text newly hidden); Shangri-La, Venomverse, Titán and the One Piece linked-pair page (0.030 → 0.013) all lose most of their smudge. Layout only, so no DETECTIONS_VERSION bump.

Measured with the uncovered-area metric in SpeechBubbleVisualizer (metrics.json: per bubble and per page, as a share of the page; original silhouette minus the union of all copies). Ben Reilly #01 (24 pages, 271 bubbles): 0.2005 → 0.0265 page-areas summed, no bubble uncovering more than 0.2 % of the page (26 before); the 98-page corpus (873 bubbles): 0.840 → 0.224, 1 bubble stuck at 1× (32 before) and 163 below the full scale (138 before, now all ≥ 1.15× unless the coverage steps ran out). Layout runs on Dispatchers.Default (PageLoader.overlay) and is cheap since 2026-08-28: it used to take 0.5–0.9 s on the Fold for a page of 6–9 bubbles and 3.8 s for a 13-caption Doctor Doom #9 page (191,000 collide calls), because reanchorPair recounted the collisions of both bubbles against every neighbour for each of its 81 anchor combinations, and the push/shrink passes re-tested pairs that had not moved. It now scores the 9 anchors of each bubble against the others once and adds the 81 pair checks, memoises collide per placement, builds the host outline only when a point lands inside the text body and rejects by silhouette extents first. The result is identical by construction (metrics.json bit-identical on the 101-page corpus); the corpus layout time on the JVM went 4.0 s → 0.28 s (worst page 486 → 24 ms) and that Doom page takes 62 ms on the Fold in release. The earlier “compact group zoom” step (scale a cluster about the group’s centre) was removed: it uncovered a strip on every outer member by construction and the contained-anchor search covers its cases.

Rendering

PageLoader.overlay(index, scale) is the one entry point for the reader: it returns the page’s PaintedBubbles (layout + fill bitmap) from an LruCache sized like the page cache, or computes them once — detection from memory, Room or the model, then BubbleLayout.enlarge and BubblePlan.of on Dispatchers.Default — and keeps them until the scale changes. Toggling bubbles off and on or turning back to a recent page therefore costs nothing, and preload computes the overlays of the neighbouring pages after the current one. The current page is served first: preload records the page the reader is on as the loader’s focus, and any request for another page first awaits the focus page’s overlay (without holding any lock, so a focus change mid-flight cannot deadlock) — before this, the visible page’s detection routinely queued behind a neighbour’s for ~1 s, which is why the toggle felt randomly slow. Measured on the Fold (release, Doctor Doom #9 p.4, 13 captions): toggle to enlarged 3.9 s → 69 ms with detections in Room and 387 ms cold (331 ms of that is the model + outlining); page turns 3–5 ms with Room.

ZoomablePage shows a small spinner in the page corner while its overlay is being computed (one detection at a time — PageLoader gates detection with a semaphore), draws the page as before and, in a sibling BubbleLayer under the same zoom/pan graphicsLayer (so the overlay pans and zooms with the page), draws in two passes straight into the Compose DrawScope (BubbleOverlay.drawBubbles). The draw is not repeated per frame: the layer caches it, and zooming, panning and page turns only transform the layer (fewer than 30 draws were counted across a zoom and fling session). The overlay layer uses CompositingStrategy.Offscreen while the page is unzoomed, so the pager’s turn animation composites one cached texture instead of re-running every bubble’s clipPath on the GPU (Fold, Doom #9: toggle frames 14 → 6 ms, GPU 13 → 5 ms; turns with bubbles now cost the same as without); it drops back to Auto when zoomed so the enlarged text stays sharp. No full-page bitmap is rasterised in between: an earlier version rendered a full-page ARGB overlay bitmap per page and per slider step, which on a 2953 × 4528 scan meant 53 MB allocations and page turns at ~10 fps. The only precomputed part is BubblePlan.of: one small fill bitmap per enlarged bubble, the size of its box plus margin, cut from the analysis bitmap by CrescentFill (pure Kotlin, ≈3 ms per bubble on the JVM; on the Fold in release the whole plan — layout plus fills — takes 0–37 ms per page, 11 ms on average over 21 pages of Doctor Doom #9; cached with the overlay). First every enlarged bubble’s original footprint is covered with that fill, then each copy is drawn: clip to the scaled outline polygons, draw the source bitmap region of the bubble box into its target box with high filter quality, plus a faint 1 dp outline so the copy reads as intentional. Bubbles at scale 1 (fully constrained) are skipped. The offline visualizer mirrors both passes so its output matches the device.

Filling the vacated area (CrescentFill)

Before 2026-08-28 the footprint was repainted flat with the bubble’s paper colour, so a copy that slid or shrank left a cut-out paper-coloured hole — rated ≈1.6/5 against LaMa inpainting’s 4+ in the inpainting spike, which at ≈4 s per crop was too slow to ship. The classical fill that replaced it:

  1. Rasterise the whole original silhouette (scanline, even-odd, same rule as SpeechBubble.contains) and dilate it by a margin (4 px or 4 % of the box’s long side) with a 3-4 chamfer distance transform, so the rim, border and tail are inside the mask. Filling only the exposed crescent would let the still-visible text leak into the fill (LaMa spike lesson).
  2. The source is every pixel of the crop outside that mask, minus the 2 px halo next to the mask edge (anti-aliased rim pixels) and minus any neighbouring bubble’s dilated silhouette — touching balloons otherwise bleed their paper into the fill (Ben Reilly #01 p.14, “PODRÍAS SER”).
  3. Onion peel: mask pixels are filled in chamfer-distance order from the source, each as the mean of its already-known 8-neighbours.
  4. A separable box blur (radius 6 % of the crop) is applied inside the mask: the raw peel leaves radial streaks, the blur turns them into a soft gradient. Pixels outside the mask stay transparent.

Verdict on the 77 corpus crescents that uncover > 0.05 % of the page (out/crescents/, original | flat | filled triptychs written by the visualizer): on flat backgrounds — manga, digital skies, cream paper — the hole disappears; on busy scans (Defensores) it becomes a soft colour smudge instead of a cut-out, roughly 3/5. What the method cannot do is invent texture: where the art behind the bubble is itself a pale glow, the fill is a pale smudge. A tiny inpainting model remains the escalation path for that.

Iterating on detection

SpeechBubbleVisualizer (unit test, skipped by default) renders both the detected outlines (<name>-bubbles.png) and the actual enlarged result (<name>-enlarged.png) for a folder of page JPEGs:

KAPOW_PANEL_VIZ_DIR=/path/to/pages ./gradlew :app:testDebugUnitTest \
    --tests '*SpeechBubbleVisualizer*' --rerun -i

It also writes out/metrics.json (per page: count, scale, box and target of every bubble), which makes recall regressions measurable: keep the file from before a change and match boxes by IoU afterwards.

With a boxes.json in the folder (the tools/training/detect_boxes.py dump format: [{"file": ..., "bubbles": [[l, t, r, b], ...]}, ...], normalized to the uncropped page) the visualizer runs SpeechBubbles.outlined on those boxes instead of the heuristic, which is how the ML path is checked offline. MlDetectorBenchmark (androidTest) dumps ML panels and bubble boxes plus timings from the device into files/mlspike/device.json for score.py. BubbleOutlineDump (androidTest) writes the device’s bubble boxes and outlines (files/mlspike/outlines.json) and the analysed 1000 px bitmap of every page (<name>-analysed.png); DeviceOutlineRepro (unit test, REPRO_PAGES = folder with those PNGs, REPRO_DUMP = that JSON) reruns the outliner on exactly those pixels, which is the only way a device-only silhouette defect has reproduced offline. Copy pages in with cat page.jpg | run-as com.comicify.debug sh -c 'cat > files/mlspike/page.jpg' and run the test with am instrument — connectedDebugAndroidTest uninstalls the app afterwards.

The ground truth is the device, not the source JPEGs: Android decodes and subsamples differently, marginal ink shares shift, and the reader shows spreads unsplit. Validate on the emulator by comparing a toggle-off and a toggle-on screenshot of every page; when a page misbehaves, reproduce it offline on a PNG of the page as the reader decoded it so the visualizer sees the same pixels, and instrument container (box, fill, reach, what the growth stopped on) before touching a threshold — the visible symptom rarely names the real cause.

Text extraction is not a detection gate (2026-09-04)

SpeechBubbles.outlined can extract text lines (extractText = true), and it was proposed as a way to tell a real balloon from a white blob: gate a detection on holding at least one text line, killing the text-free stops Guided View builds on false positives. Measured, the idea is refuted — the gate removes almost only true positives.

What the corpus says

BubbleTextDump (unit test, KAPOW_BUBBLE_DUMP_DIR=<eval comic dir>) runs the production outlined(extractText = true) over an eval comic and writes bubble-text.json: per bubble its box, its text-line count, its ink share and whether it fell back to the raw box. Over the eighteen-comic eval corpus, 2 146 detections, 567 (26 %) hold no text line at all, and the rate is wildly comic-dependent:

aliens-03 rapaces-1 androides-1 ruinas blacksad-1 titanes ben-reilly-01 one-piece
83 % 80 % 67 % 45 % 38 % 17 % 0.4 % 0.6 %

Scanned and painted books fail; clean digital books do not. A contact sheet of thirty-six randomly sampled “no text, body found” detections showed thirty-five ordinary speech balloons full of legible lettering (“YA ESTÁ MUERTO”, “TIENE RAZÓN, PAIGE”, “¡NO TENGO BASTANTES DEDOS!”) and exactly one true false positive.

What the ground truth says

Scored against the 1 510 hand-labelled bubbles on the 181 ground-truth pages that could be re-extracted from comics/ (IoU 0.3, tools/training/score.py):

variant detections true positives precision recall F1
no gate 1432 1378 0.962 0.913 0.937
drop “no text, body found” 1080 1034 0.957 0.685 0.798
drop every text-free detection 1001 962 0.961 0.637 0.766

The gate drops 352 detections and 344 of them (98 %) are true positives. Precision does not improve — it falls slightly. The gate is anti-correlated with what it was meant to do.

Why, and the two traps

textIn segments the interior holes of the silhouette body and keeps the ones shaped like a word. On a clean digital balloon the letters are enclosed holes in a flat white body and it works; on a scan with textured paper, or where the body leaks into the art, the holes merge and nothing passes the word filters. So the signal measures scan quality, not balloon-ness.

Two traps for anyone who tries this again:

An ink-share variant was tried as the narrow version — a detection whose body holds no ink is not a balloon. It does not work either: the one true false positive in the sample (titanes:034 #4, a blank corner of shirt) has 2.5 % ink, well above the low-ink tail, and a threshold high enough to catch it removes about seventy further detections. Text-free detections are not separable from real balloons by ink either.

What this means in practice

Production never asks for text: PanelDetector calls outlined(classes, boxes) with the default extractText = false, so nothing ships this cost or this signal today. The extraction failure does contaminate the offline enlarged-bubble metrics that use bubble.text (textOccluded, worstTextHidden) — read those as lower bounds on scanned books.

The text-free stops Guided View builds are therefore a composition question (an orphan detection with no text earning its own tap), not a detector gate — and at one page they sit far below the judge’s error bar, so they are not worth a rule. See docs/guided-view-eval.md.

Hand lettering is a text problem, not a bubble problem (2026-09-04)

The remaining Guided View bads on Arkham Asylum were filed as “hand lettering the bubble model never returns”, with a retrain queued as the fix. Looked at directly, a retrain cannot help: there is no bubble to detect.

On arkham:027 the model returns zero bubbles. The page is a McKean painted splash and its only dialogue is red hand-lettering floating on the black strip down the right edge — no white shape, no outline, no enclosing form of any kind. The tour therefore has one stop, the whole page, and the judge’s complaint is exactly that: the dialogue never gets a reading window. arkham:031b returns five detections all in the left half while the two real captions sit on the right; arkham:037 returns four, all in the top strip, missing the Joker’s long red caption below.

The ground truth cannot measure this family, because it does not contain it. Three of Arkham’s seven ground-truth pages (138, 163, 188) are labelled 0 bubbles and 0 panels — recorded as empty rather than as lettering. Bubble recall on Arkham scores 0.96, but only over the four pages that happen to hold conventional balloons.

So the work this needs is not the v4/v5 retrain pipeline pointed at more pages. It is either a new label class for free-floating lettering, added to the ground truth on the painted books first, or a different mechanism altogether — text-region detection over the art, used to place reading windows where no bubble exists. Both are larger than a retrain and neither can start until the labels exist.

Worth knowing before committing to it: the family is three pages of 372 (arkham:027, 031b, 037), worth at most 15 cost points if detection became perfect. That is a bigger prize than the whole fourth round moved, but it buys a new detector class and a new labelling pass.