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.
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).
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.
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.
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.
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.
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.
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:
solidPaper: every source pixel in the cell is white or cream (the pale
yellow of Marvel-style caption boxes) — bubble and caption interiors.solidPale: every source pixel has luminance ≥ 200 and chroma ≤ 22 — the
shaded bottom of a gradient-filled caption; only used to grow ML-boxed
bodies towards a short box side (see above).solidDark: every source pixel in the cell is dark and near-neutral (low
chroma) — the body of a black/negative bubble.ink: some source pixel in the cell is dark — lettering strokes.light: some source pixel in the cell is light (luminance ≥ 155) — the
lettering of negative bubbles. It is much looser than white on purpose:
white type is anti-aliased and JPEG-softened, so its strokes fail the strict
white test and whole lines (“¿QUÉ QUIE-“) went missing.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:
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.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.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).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).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.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).
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.
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.CONTAINED_SCALE_FLOOR = 1.15×, pushed and re-anchored again.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).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.
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.
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:
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).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.
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.
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.
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.
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.
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:
silhouetteOf returns
Silhouette(Blob.filled(inner), emptyList()) when neither the paper body nor the
dark body is credible, so a naive text.isEmpty() test also deletes every
fallback. 238 of the 567 text-free detections are these.mergeLinked, never before: merged silhouettes concatenate their
text lists, so half of a linked pair can be text-free on its own.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.
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.
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.