<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[soluto]]></title><description><![CDATA[soluto]]></description><link>https://soluto.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 23 Oct 2026 21:16:13 GMT</lastBuildDate><atom:link href="https://soluto.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Erasing text from manga in the browser, without an inpainting model (and where that ran out)]]></title><description><![CDATA[A hand-written text eraser that cut failures from 358 to 96 on real images, how I measured it without ground truth, and the four improvements that made it…]]></description><link>https://soluto.hashnode.dev/erasing-text-from-manga-in-the-browser-without-an-inpainting-model-and-where-that-ran-out</link><guid isPermaLink="true">https://soluto.hashnode.dev/erasing-text-from-manga-in-the-browser-without-an-inpainting-model-and-where-that-ran-out</guid><category><![CDATA[Computer Vision]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[image processing]]></category><category><![CDATA[mana]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[lxhsoluto-star]]></dc:creator><pubDate>Thu, 22 Oct 2026 08:35:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad41ca188c4c7a7463de8c3/d6aeab57-15b8-46d9-b394-488a7c972f51.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><a href="https://imgtranslator.com">imgtranslator.com</a> translates the text in an image and redraws it in place: a menu, a product photo, a manga page. OCR and translation run on a server. Erasing the original text and typesetting the translation happen on a canvas in your browser.</p>
<p>That erasing step is the subject of this post. The obvious tool for it is an inpainting model, which I deliberately didn't use for the first two months. A generative fill can quietly change things that weren't text, like a price on a ticket or a face behind a speech bubble, and nothing in its output tells you it did. So I wrote the eraser by hand, as pixel rules.</p>
<p>What follows is how the second version of those rules worked, how I measured it when there's no ground truth for a real photo, and the point where more rules stopped helping, which I can show with numbers.</p>
<h2>The input</h2>
<p>For each line of text, OCR gives me a box. Sometimes it also gives per-character boxes, depending on the engine. The eraser gets the image and the boxes and has to make the text inside each box disappear, while leaving everything else alone.</p>
<p>"Everything else" is the hard part. In manga it includes the speech bubble's outline 2px from the text, the neighbouring column of text, screentone, and line art drawn right through the lettering.</p>
<h2>Version 1: find the text colour, grow the strokes</h2>
<p>The first eraser read the background from the strips <strong>above and below</strong> the box, decided the text colour was whatever contrasted most with that, seeded from pixels of that colour, and grew the strokes outward. Then it filled the strokes in from their surroundings.</p>
<p>On clean UI screenshots and product photos it was fine. Then I ran it over 166 real uploads (2,115 boxes) and sorted the failures:</p>
<ul>
<li><strong>68% were the growth flooding the window.</strong> Black text touches a bubble outline or line art, and growing from one floods into the other. A bubble's white gets read as the text colour.</li>
<li><strong>45% of all boxes were vertical columns.</strong> In a vertical manga column, "above and below the box" is the artwork, so the text colour was wrong before anything started.</li>
</ul>
<p>Neither was a tuning problem. The model of "text = the contrasting colour next to the background above and below" doesn't describe a manga page.</p>
<h2>Version 2: what's inside the box that isn't around it?</h2>
<p>The second eraser asks a different question. Text is <strong>whatever colour is common inside the box and rare just outside it</strong>. That's true for black on white, white with a black outline, coloured title lettering, and horizontal or vertical text. It needs no text colour up front.</p>
<ol>
<li><strong>Cluster</strong> the colours of the box plus a narrow ring around it (k-means, k=6, clusters closer than 22 RGB units merged). The ring goes all the way round, and <em>other OCR boxes are excluded from it</em>, so a neighbouring column doesn't count as "background".</li>
<li><strong>A cluster is text</strong> when its share inside the box is at least <strong>3×</strong> its share in the ring, and it covers at least 1.5% of the box. Anti-aliasing and outlines form their own clusters and are enriched too, so they come along automatically.</li>
<li><strong>Mask</strong> the text-cluster pixels near the box. A connected run of text colour that's mostly outside the box and reaches the edge of the window is structure (a bubble outline, a panel border, line art) and is left alone.</li>
<li><strong>Fill</strong> the mask by push-pull interpolation: build a pyramid that averages only <em>known</em> pixels on the way down, then fills holes from coarser levels on the way back up. It's smooth, fast, and has no opinions.</li>
<li><strong>Judge the result</strong> the way a viewer would: compare edge energy inside the box after the erase with the edge energy of the background around it. When it fails, cover the box with a plain rounded patch and write the translation on that.</li>
</ol>
<p><img src="https://cdn.hashnode.com/uploads/covers/6ad41ca188c4c7a7463de8c3/8d8f5031-27d2-4384-9c2c-d908219017c4.png" alt="v2's real clustering on two boxes: a dialogue column where black is 15.8% of the box and 0% of the ring, and a sound effect on screentone where no colour reaches 3×" /></p>
<p>The second row in that figure is worth a look. On screentone, the dots are black and white too, so the letters' own colours are already common in the ring. Nothing reaches 3×, and the sound effect isn't erased as text. Keep that one in mind for the end of the post.</p>
<p>Two details that each took a failed version to learn:</p>
<ul>
<li><strong>The ring has to be narrow</strong> (0.3× the glyph size). A wide ring reached past a button into the page behind it. The button's own colour then looked far commoner inside the box than around it, and was erased as "text". On synthetic tests, panels came out 89–99% covered.</li>
<li><strong>Black-and-white manga needs a second route.</strong> On a dense B/W page the anti-aliasing splits into four or five grey clusters, none enriched on its own, and the ring is 13–26% dark from other ink. So there's a separate "far enough from the background colour" route for dark ink, kept only as blobs that sit mostly inside the box. The two routes do connected components <em>separately</em>; merging them first threw lettering drawn on the art away as line art.</li>
</ul>
<h3>The result</h3>
<p>Here are both versions on a panel I drew for this post, run with the production code and nothing changed. v1 leaves half the dialogue behind. Both of them report all five boxes as clean.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6ad41ca188c4c7a7463de8c3/5bb8085b-2514-4e72-98d2-3e3b43bba585.png" alt="A synthetic manga panel with OCR boxes, then v1's erase leaving half of the vertical dialogue, then v2's erase with clean bubbles but a ghost of the outlined sound effect on screentone" />
And on real uploads:</p>
<table>
<thead>
<tr>
<th>Real uploads, 166 images / 2,115 boxes</th>
<th>v1</th>
<th>v2</th>
</tr>
</thead>
<tbody><tr>
<td>Boxes the eraser itself judged failed</td>
<td>358</td>
<td><strong>96</strong></td>
</tr>
<tr>
<td>Patches drawn</td>
<td>275</td>
<td>82</td>
</tr>
<tr>
<td>Images with any patch</td>
<td>89</td>
<td>48</td>
</tr>
<tr>
<td>Image area covered by patches</td>
<td>5.0%</td>
<td>2.1%</td>
</tr>
<tr>
<td>Time for the whole set</td>
<td>87 s</td>
<td><strong>19 s</strong></td>
</tr>
</tbody></table>
<p>On 327 synthetic cases (real images as backgrounds, rendered text on top, so the answer is known), leftover original text went from 0.130 to 0.091, and damage to the surroundings from 0.137 to 0.067. Vertical text in speech bubbles went from 0.303 leftover to 0.</p>
<p>It lost in two places: horizontal CJK on busy backgrounds (0.079 → 0.082) and outlined horizontal text (0.201 → 0.241). I shipped it anyway, because the categories it won are most of the traffic.</p>
<h3>The one-line bug that drew a dotted line in every bubble</h3>
<p>51% of real boxes overlap another box, because in manga the gap between columns is narrower than the boxes OCR draws around them. Push-pull was using the neighbouring column's grey anti-aliasing as "known background", so every erased bubble had a faint dotted grey line where two columns met. The self-check called those boxes clean.</p>
<p>The fix was to stop treating ink inside <em>other</em> boxes as a fill source. The patch count didn't move (82 → 82), but 68 of 166 images changed. The synthetic tests couldn't see this bug at all. Only looking at real output did.</p>
<h2>Measuring when there's no answer key</h2>
<p>The table above uses the eraser's <strong>own</strong> verdict. That's a problem, because the verdict is part of what I was trying to improve.</p>
<p>For real images there's no ground truth. So I built a labeller. It crops each box before and after the erase and asks a headless <code>claude -p</code> run (Opus) to rate it as one of four labels:</p>
<ul>
<li><strong>clean</strong></li>
<li><strong>faint</strong>: visible only up close</li>
<li><strong>trace</strong>: visible at normal viewing</li>
<li><strong>damage</strong>: a smear, the wrong colour, or screentone wiped flat</li>
</ul>
<p>It runs locally, on my machine, as an offline batch job.</p>
<p>The first thing it measured was v2 itself:</p>
<table>
<thead>
<tr>
<th>v2 on 166 real images</th>
<th>share of boxes</th>
</tr>
</thead>
<tbody><tr>
<td>clean</td>
<td>58%</td>
</tr>
<tr>
<td>faint ghost</td>
<td>20%</td>
</tr>
<tr>
<td>visible leftover</td>
<td>9%</td>
</tr>
<tr>
<td>damaged</td>
<td>13%</td>
</tr>
</tbody></table>
<p>About <strong>400 boxes (19%) shipped with a problem while the eraser judged them clean</strong>. Of the 96 boxes it <em>did</em> patch, 36 had actually been erased fine. Its main self-check signal, post-erase edge energy, scored an AUC of 0.53 against the labels, which is a coin flip.</p>
<p>Then I measured the labeller. I labelled the same 296 crops twice:</p>
<ul>
<li>The two runs agreed on the exact label 85% of the time, and on "bad vs. not bad" 93% of the time.</li>
<li>My original method compared versions on a weighted sample of boxes. With that much labeller noise, its margin of error was about ±0.017 in utility (clean = 1, faint = 0.6, everything else = 0). That was <strong>larger than most of the differences I'd been acting on.</strong></li>
</ul>
<p>The fix was to label <strong>only boxes whose output visibly changed</strong> between two versions, label both versions, and compare them as pairs. On around 400 changed boxes that brings the error down to about ±0.003. Several earlier "improvements" didn't survive the switch.</p>
<h2>Four things that made it worse</h2>
<p>With a ruler I trusted, I tried the obvious next steps. Baseline utility was <strong>0.708</strong>.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6ad41ca188c4c7a7463de8c3/4e8657b5-4a40-4bc5-b8f9-2b2beb023f33.png" alt="Utility per box for v2 and seven alternatives: all at or below the 0.708 baseline, texture fill lowest at 0.443" /></p>
<ol>
<li><strong>Texture fill.</strong> After the smooth fill, copy high-frequency detail from a nearby clean region, so erased text on screentone isn't a smooth smudge. Result: <strong>0.516</strong>. The "clean region" next to manga text usually has a bubble edge, another glyph or line art in it, and copying that added new strokes. A second version that also widened the mask on textured backgrounds scored 0.443.</li>
<li><strong>Block matching (multi-scale PatchMatch) instead of push-pull.</strong> Of 375 changed boxes, 44 improved and 45 got worse, and it was <strong>8× slower</strong> (0.13 s → 0.94 s per image). Where the mask had missed some ink, it found that ink and copied it in. So the fill wasn't the bottleneck.</li>
<li><strong>A learned stroke mask.</strong> A small U-Net predicts the text pixels in a window, including outline and shadow. I trained it only on synthetic text over wallpapers and procedurally generated screentone and line art, never on user uploads. On synthetic validation, IoU went from 0.84 to 0.88. On real boxes, utility went <strong>down</strong> by 0.065 when it replaced v2's mask, and by 0.007 when the two masks were combined. It fixed about 60 of the 409 boxes v2 had ruined, and broke more clean ones than that. <strong>The higher its synthetic score, the worse it did on real pages.</strong> The gap is in the data, not the model.</li>
<li><strong>Patch more aggressively.</strong> If the self-check is weak, just patch more boxes? Patching <em>every</em> box scored 0.643. Patches look worst exactly where erasing does too, on textured backgrounds: 50% of patches looked fine there versus 67% of erases. The best threshold-based rule I could fit scored 0.712, or 0.704 under 5-fold cross-validation. That's no improvement.</li>
</ol>
<p>That's the evidence behind the conclusion I wrote in the project notes on 2026-09-27: <strong>hand-written rules have reached their end.</strong> Every remaining lever (flat backgrounds, the fill, the patch decision) measured at zero. The bad boxes left are large lettering on textured art, and they need either training data that looks like real pages or a fill that understands content.</p>
<h2>What I did instead</h2>
<p><strong>For manga sound effects, I stopped erasing them.</strong> Splitting 65 vertical pages by background showed that dialogue inside bubbles erased cleanly. Almost all the bad boxes were sound effects drawn directly on the art. Scanlation groups solved this years ago: leave the original lettering and put a small translation next to it. On black-and-white pages that's now the default. On 84 lines from 150 held-out pages, it was good on about 67, neutral on 8 and bad on 9.</p>
<p>On colour pages the same rule mostly caught small bubbles and translucent ones (12 good, 21 bad), so it stays off there until I can detect bubbles reliably.</p>
<p><strong>I tested inpainting properly, scoped to patched boxes only.</strong> LaMa (the Apache-2.0 ONNX export), with a DBNet text-detection mask:</p>
<ul>
<li><strong>Synthetic cases:</strong> leftover text went from 0.239 to 0.099 on the hardest categories (outlined, shadowed and Latin text), with damage from 0.101 to 0.022. It won 166 cases and lost 4.</li>
<li><strong>Real boxes:</strong> about 15 of the 22 patched boxes I checked by eye came out better.</li>
<li><strong>Speed:</strong> on a small CPU server it took <strong>10.4 s per box</strong> (p50). Going from 1 to 2 threads didn't help at all, probably because the two vCPUs are hyperthreads of one core.</li>
</ul>
<p>That rules out "click and wait". The next step is running it in the background for patched boxes only, and swapping the result in when it's ready. Boxes v2 already erases cleanly stay with v2, because on those LaMa was flat or occasionally worse.</p>
<h2>What I'd keep from this</h2>
<ul>
<li><strong>Sort failures by cause before tuning anything.</strong> "68% are flooding, 45% of boxes are vertical" told me v1's model was wrong, which no amount of threshold tuning would have shown.</li>
<li><strong>Your self-check is a model too, and probably a bad one.</strong> Mine had an AUC of 0.53. Measure it before you build on it.</li>
<li><strong>Measure the labeller's noise before reading its results.</strong> An LLM judge that agrees with itself 85% of the time can't tell apart versions that differ by 1%. Paired comparison on changed items is far cheaper than more labels.</li>
<li><strong>Synthetic wins don't transfer by default.</strong> Two of my four failed ideas looked clearly better on synthetic data and clearly worse on real pages.</li>
<li><strong>Sometimes the fix is in the product, not the algorithm.</strong> Keeping the original sound effects took one rule, and the result looks like what manga readers already expect.</li>
</ul>
<p>The eraser is about 1,100 lines of TypeScript on <code>Uint8ClampedArray</code>, with no dependencies. If you're working on the same problem and want the numbers behind any row above, ask in the comments.</p>
]]></content:encoded></item><item><title><![CDATA[I wrote facts the model wasn't allowed to know, then checked if it used them]]></title><description><![CDATA[How I test whether an LLM writes good Anki cloze cards]]></description><link>https://soluto.hashnode.dev/i-wrote-facts-the-model-wasn-t-allowed-to-know-then-checked-if-it-used-them</link><guid isPermaLink="true">https://soluto.hashnode.dev/i-wrote-facts-the-model-wasn-t-allowed-to-know-then-checked-if-it-used-them</guid><category><![CDATA[llm]]></category><category><![CDATA[Testing]]></category><category><![CDATA[#anki]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[lxhsoluto-star]]></dc:creator><pubDate>Sun, 18 Oct 2026 01:18:47 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad41ca188c4c7a7463de8c3/6efb3ea4-cc02-4fdf-b99c-d0a6fc9084f4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I make a tool that turns lecture notes into Anki cloze cards (<a href="https://ankiforge.app">AnkiForge</a>). For the first version my quality check was reading the output and thinking "yeah, these look fine." They did look fine. When I finally scored them properly, fewer than 55% passed.</p>
<p>This is how the scoring works, what it found, and the one kind of test I'd copy into any LLM project.</p>
<h2>What "good" means for a flashcard</h2>
<p>Flashcard advice mostly comes from Piotr Wozniak's <a href="https://www.supermemo.com/en/blog/twenty-rules-of-formulating-knowledge">Twenty rules of formulating knowledge</a> (1999). The ones that matter for generated cloze cards:</p>
<ul>
<li><strong>One fact per card.</strong> If a card tests two things, you'll fail it for one of them and relearn both.</li>
<li><strong>No sets.</strong> "Name the three..." cards are hard to remember and hard to grade yourself on.</li>
<li><strong>A blank with only one right answer.</strong> If two answers fit, you'll learn whichever one the card happens to show.</li>
</ul>
<p>Then there's one more that's specific to LLMs: <strong>don't add anything the source didn't say.</strong> A student making cards from a lecture wants the lecture. If the model adds a true fact from its training data, that fact isn't checked against what the professor taught, and the student can't tell which cards came from where.</p>
<h2>The eval set</h2>
<p>Nine text samples, each a few pages, each chosen to break something:</p>
<ul>
<li>A pharmacology page on ACE inhibitors, in drug-card mode.</li>
<li>A Wikipedia page on Crohn's vs ulcerative colitis, in compare mode.</li>
<li>A real PDF of the metformin article, run through the same pdf.js extraction the browser uses. That includes the broken infobox, the <code>[12]</code> citation markers, and a references page that should produce no cards.</li>
<li>Lecture slides where the key fact is only in the speaker notes.</li>
<li>An Obsidian note full of wikilinks and callouts, and a Notion export with toggles.</li>
<li>A Spanish page, where the cards have to stay in Spanish.</li>
<li>A page that lists eight drug names and nothing else.</li>
</ul>
<p>Each sample has a hand-written list of key facts the cards should cover. Most of them also have a list of <strong>forbidden claims</strong>. Those are statements that are true, that a model would very much like to add, and that the source never makes.</p>
<p>For the Crohn's vs UC sample, the forbidden list includes granulomas, crypt abscesses, smoking and p-ANCA. Any medical student knows those as the classic differences. The Wikipedia revision I pinned doesn't mention them. If a card mentions granulomas, the model got it from memory.</p>
<p>The drug-list page is the purest version of this. It names eight drugs and states nothing about them, so the correct output is zero cards. An eager model will happily write you a mechanism-of-action card for each one.</p>
<h2>Scoring</h2>
<p>The pipeline is the production one minus billing: same prompt, same model, same parser and validator. A separate worker exposes it behind a token, so the eval can't drift from what users get.</p>
<p>Then two kinds of scoring:</p>
<p><strong>Mechanical</strong>, no judgement needed: card count, cards per page, cards that cite a page that should have none, blanks over six words, cards with more than one cloze number, and so on.</p>
<p><strong>A judge model</strong>, which reads the source and each card and marks the card on five things: faithful (only what the source says), atomic, unambiguous, worth reviewing, right language. It also checks which key facts are covered and whether any forbidden claim appears. The judge is told that outside knowledge doesn't count as faithful, even if it's correct.</p>
<p>The headline number is the share of cards that pass all five. Plus three numbers I look at every time: unfaithful cards, forbidden hits, and coverage.</p>
<p>The judge is noisy, so I don't trust single-point moves. I read the per-card verdicts instead, and they're the most useful part of the report.</p>
<h2>What the first real run found</h2>
<p>55 cards across the nine samples:</p>
<table>
<thead>
<tr>
<th></th>
<th>first run</th>
<th>after two prompt rules</th>
</tr>
</thead>
<tbody><tr>
<td>cards passing every check</td>
<td>54%</td>
<td>82%</td>
</tr>
<tr>
<td>unfaithful cards</td>
<td>21</td>
<td>3</td>
</tr>
<tr>
<td>forbidden claims</td>
<td>4</td>
<td>0</td>
</tr>
<tr>
<td>key facts covered</td>
<td>65%</td>
<td>67%</td>
</tr>
</tbody></table>
<p>The two rules that did most of this:</p>
<ol>
<li>The extra context shown after the answer has to come from the source, and a testable fact belongs in its own card, never only in the extra.</li>
<li>Use only what the source states, in the card, the hint and the extra. A page that only names things gets no cards.</li>
</ol>
<p>The second rule is the one that matters. Before it, the "extra" field was where most of the outside knowledge went. The card itself would be faithful, and the note on the back would add a mechanism or a cause the lecture never mentioned. Nobody checks the back of a card that carefully, so it's the easiest place for an unsourced fact to hide.</p>
<h2>The three that still fail</h2>
<p>These are from the current run, and I'm leaving them in on purpose.</p>
<blockquote>
<p>Concomitant use of {{c1::cyclooxygenase inhibitors (NSAIDs)}} tends to {{c2::decrease}} the hypotensive effect of ACE inhibitors.</p>
</blockquote>
<p>The source says "cyclooxygenase inhibitors". The model added "(NSAIDs)". It's correct, but it isn't in the lecture.</p>
<blockquote>
<p>Vancomycin-resistant enterococci (VRE) modify the cell wall precursor target from D-Ala-D-Ala to {{c1::D-Ala-D-Lac}}.</p>
</blockquote>
<p>The slides say "VRE" and never expand it. Same pattern: a helpful expansion from memory.</p>
<blockquote>
<p>Angiotensin converting enzyme (ACE) converts {{c1::angiotensin I}} into {{c2::angiotensin II}}.
<em>Extra:</em> ACE is primarily found in the pulmonary circulation and endothelial cells.</p>
</blockquote>
<p>The card is fine. The source says ACE is found in the pulmonary circulation and in the endothelium of many blood vessels. It doesn't say "primarily". That's the old failure, hiding in the extra, one of the 3 left out of 21.</p>
<p>All three are small. All three are exactly the kind of thing a student wouldn't notice, which is why they get caught by a list of forbidden claims and not by reading.</p>
<h2>What coverage tells you</h2>
<p>Coverage barely moved (65% to 67%), and it's the weakest number. The Spanish sample covers 43% of its key facts. The Obsidian acid-base note covers 45%, and none of its cards test normal values like pH 7.35 to 7.45.</p>
<p>High-yield mode is supposed to skip minor facts, so some of this is by design. Normal lab ranges aren't minor, though. Tightening the rules made the model more careful, and the careful version also writes fewer cards. I haven't fixed this yet. The tradeoff shows up in the numbers, which is better than not knowing about it.</p>
<h2>If you copy one thing</h2>
<p>Write down what the model <strong>shouldn't</strong> say. Testing what it should say is the obvious part, and it catches missing content. A list of forbidden claims, true but absent from the source, catches the failure that looks most like success: a confident, correct, unsourced fact.</p>
<p>It costs about ten minutes per sample. Pick the claims a domain expert would add without thinking. For medicine those are the classic textbook associations, and models know them as well as any second-year student does.</p>
<p>The eval runs before and after every change to the prompt, model, or validator, and the before/after numbers go in the commit message.</p>
]]></content:encoded></item></channel></rss>