<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: lxhsoluto-star</title>
    <description>The latest articles on DEV Community by lxhsoluto-star (@lxhsoluto-star).</description>
    <link>https://dev.to/lxhsoluto-star</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4191269%2F93c999fb-d9ef-4c1b-a898-3089f7a664c0.jpg</url>
      <title>DEV Community: lxhsoluto-star</title>
      <link>https://dev.to/lxhsoluto-star</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lxhsoluto-star"/>
    <language>en</language>
    <item>
      <title>Erasing text from manga in the browser, without an inpainting model (and where that ran out)</title>
      <dc:creator>lxhsoluto-star</dc:creator>
      <pubDate>Wed, 21 Oct 2026 13:55:00 +0000</pubDate>
      <link>https://dev.to/lxhsoluto-star/erasing-text-from-manga-in-the-browser-without-an-inpainting-model-and-where-that-ran-out-2436</link>
      <guid>https://dev.to/lxhsoluto-star/erasing-text-from-manga-in-the-browser-without-an-inpainting-model-and-where-that-ran-out-2436</guid>
      <description>&lt;p&gt;&lt;a href="https://imgtranslator.com" rel="noopener noreferrer"&gt;imgtranslator.com&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The input
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;"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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 1: find the text colour, grow the strokes
&lt;/h2&gt;

&lt;p&gt;The first eraser read the background from the strips &lt;strong&gt;above and below&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;68% were the growth flooding the window.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;45% of all boxes were vertical columns.&lt;/strong&gt; In a vertical manga column, "above and below the box" is the artwork, so the text colour was wrong before anything started.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 2: what's inside the box that isn't around it?
&lt;/h2&gt;

&lt;p&gt;The second eraser asks a different question. Text is &lt;strong&gt;whatever colour is common inside the box and rare just outside it&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cluster&lt;/strong&gt; 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 &lt;em&gt;other OCR boxes are excluded from it&lt;/em&gt;, so a neighbouring column doesn't count as "background".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cluster is text&lt;/strong&gt; when its share inside the box is at least &lt;strong&gt;3×&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mask&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fill&lt;/strong&gt; the mask by push-pull interpolation: build a pyramid that averages only &lt;em&gt;known&lt;/em&gt; pixels on the way down, then fills holes from coarser levels on the way back up. It's smooth, fast, and has no opinions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judge the result&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F55i2yh3jrlqblv1ytrn8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F55i2yh3jrlqblv1ytrn8.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×" width="800" height="506"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Two details that each took a failed version to learn:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The ring has to be narrow&lt;/strong&gt; (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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Black-and-white manga needs a second route.&lt;/strong&gt; 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 &lt;em&gt;separately&lt;/em&gt;; merging them first threw lettering drawn on the art away as line art.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The result
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flwpurao5667k10967x7u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flwpurao5667k10967x7u.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" width="798" height="258"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And on real uploads:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Real uploads, 166 images / 2,115 boxes&lt;/th&gt;
&lt;th&gt;v1&lt;/th&gt;
&lt;th&gt;v2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Boxes the eraser itself judged failed&lt;/td&gt;
&lt;td&gt;358&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;96&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Patches drawn&lt;/td&gt;
&lt;td&gt;275&lt;/td&gt;
&lt;td&gt;82&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Images with any patch&lt;/td&gt;
&lt;td&gt;89&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image area covered by patches&lt;/td&gt;
&lt;td&gt;5.0%&lt;/td&gt;
&lt;td&gt;2.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time for the whole set&lt;/td&gt;
&lt;td&gt;87 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;19 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The one-line bug that drew a dotted line in every bubble
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The fix was to stop treating ink inside &lt;em&gt;other&lt;/em&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring when there's no answer key
&lt;/h2&gt;

&lt;p&gt;The table above uses the eraser's &lt;strong&gt;own&lt;/strong&gt; verdict. That's a problem, because the verdict is part of what I was trying to improve.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;claude -p&lt;/code&gt; run (Opus) to rate it as one of four labels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;clean&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;faint&lt;/strong&gt;: visible only up close&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;trace&lt;/strong&gt;: visible at normal viewing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;damage&lt;/strong&gt;: a smear, the wrong colour, or screentone wiped flat&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It runs locally, on my machine, as an offline batch job.&lt;/p&gt;

&lt;p&gt;The first thing it measured was v2 itself:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;v2 on 166 real images&lt;/th&gt;
&lt;th&gt;share of boxes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;clean&lt;/td&gt;
&lt;td&gt;58%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;faint ghost&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;visible leftover&lt;/td&gt;
&lt;td&gt;9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;damaged&lt;/td&gt;
&lt;td&gt;13%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;About &lt;strong&gt;400 boxes (19%) shipped with a problem while the eraser judged them clean&lt;/strong&gt;. Of the 96 boxes it &lt;em&gt;did&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;Then I measured the labeller. I labelled the same 296 crops twice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The two runs agreed on the exact label 85% of the time, and on "bad vs. not bad" 93% of the time.&lt;/li&gt;
&lt;li&gt;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 &lt;strong&gt;larger than most of the differences I'd been acting on.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix was to label &lt;strong&gt;only boxes whose output visibly changed&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things that made it worse
&lt;/h2&gt;

&lt;p&gt;With a ruler I trusted, I tried the obvious next steps. Baseline utility was &lt;strong&gt;0.708&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9y86jxxf2l30ct39v6nj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9y86jxxf2l30ct39v6nj.png" alt="Utility per box for v2 and seven alternatives: all at or below the 0.708 baseline, texture fill lowest at 0.443" width="800" height="325"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Texture fill.&lt;/strong&gt; After the smooth fill, copy high-frequency detail from a nearby clean region, so erased text on screentone isn't a smooth smudge. Result: &lt;strong&gt;0.516&lt;/strong&gt;. 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Block matching (multi-scale PatchMatch) instead of push-pull.&lt;/strong&gt; Of 375 changed boxes, 44 improved and 45 got worse, and it was &lt;strong&gt;8× slower&lt;/strong&gt; (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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A learned stroke mask.&lt;/strong&gt; 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 &lt;strong&gt;down&lt;/strong&gt; 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. &lt;strong&gt;The higher its synthetic score, the worse it did on real pages.&lt;/strong&gt; The gap is in the data, not the model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Patch more aggressively.&lt;/strong&gt; If the self-check is weak, just patch more boxes? Patching &lt;em&gt;every&lt;/em&gt; 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the evidence behind the conclusion I wrote in the project notes on 2026-09-27: &lt;strong&gt;hand-written rules have reached their end.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did instead
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;For manga sound effects, I stopped erasing them.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I tested inpainting properly, scoped to patched boxes only.&lt;/strong&gt; LaMa (the Apache-2.0 ONNX export), with a DBNet text-detection mask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Synthetic cases:&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real boxes:&lt;/strong&gt; about 15 of the 22 patched boxes I checked by eye came out better.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speed:&lt;/strong&gt; on a small CPU server it took &lt;strong&gt;10.4 s per box&lt;/strong&gt; (p50). Going from 1 to 2 threads didn't help at all, probably because the two vCPUs are hyperthreads of one core.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd keep from this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sort failures by cause before tuning anything.&lt;/strong&gt; "68% are flooding, 45% of boxes are vertical" told me v1's model was wrong, which no amount of threshold tuning would have shown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your self-check is a model too, and probably a bad one.&lt;/strong&gt; Mine had an AUC of 0.53. Measure it before you build on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure the labeller's noise before reading its results.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synthetic wins don't transfer by default.&lt;/strong&gt; Two of my four failed ideas looked clearly better on synthetic data and clearly worse on real pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sometimes the fix is in the product, not the algorithm.&lt;/strong&gt; Keeping the original sound effects took one rule, and the result looks like what manga readers already expect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The eraser is about 1,100 lines of TypeScript on &lt;code&gt;Uint8ClampedArray&lt;/code&gt;, with no dependencies. If you're working on the same problem and want the numbers behind any row above, ask in the comments.&lt;/p&gt;

</description>
      <category>computervision</category>
      <category>ai</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why your generated Anki image occlusion cards import as plain cloze (and the one-line fix)</title>
      <dc:creator>lxhsoluto-star</dc:creator>
      <pubDate>Sun, 18 Oct 2026 00:52:48 +0000</pubDate>
      <link>https://dev.to/lxhsoluto-star/why-your-generated-anki-image-occlusion-cards-import-as-plain-cloze-and-the-one-line-fix-3hj3</link>
      <guid>https://dev.to/lxhsoluto-star/why-your-generated-anki-image-occlusion-cards-import-as-plain-cloze-and-the-one-line-fix-3hj3</guid>
      <description>&lt;p&gt;I've been building &lt;a href="https://ankiforge.app" rel="noopener noreferrer"&gt;AnkiForge&lt;/a&gt;, a web app that turns lecture PDFs and labelled diagrams into Anki cards. For diagrams it makes image occlusion cards: every label on, say, a heart diagram gets hidden, one card per label.&lt;/p&gt;

&lt;p&gt;Getting the masks in the right place took most of the work. Getting Anki to treat them as real image occlusion took one line of config, and finding out what that line actually does took reading Anki's source twice. Here it is, along with the parts that are still broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note type named "Image Occlusion" isn't image occlusion
&lt;/h2&gt;

&lt;p&gt;Anki has had image occlusion built in since 23.10. Before that it was an add-on (Image Occlusion Enhanced) with its own note type, and plenty of shared decks still use that old format. The Anki forums have a steady trickle of threads like &lt;a href="https://forums.ankiweb.net/t/anki-deck-with-image-occlusion-shows-no-images/43854" rel="noopener noreferrer"&gt;"Anki deck with image occlusion shows no images"&lt;/a&gt; from people importing them.&lt;/p&gt;

&lt;p&gt;If you generate decks in code, the obvious approach is to copy the templates and field names from the built-in IO note type. You end up with a cloze note type that happens to be called "Image Occlusion". The cards even review fine, because the template draws the masks itself.&lt;/p&gt;

&lt;p&gt;The difference shows up when someone opens a card to fix a mask.This is how Anki desktop decides whether to show its mask editor (&lt;code&gt;aqt/editor_legacy.py&lt;/code&gt; in the 26.9 package):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;current_notetype_is_image_occlusion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;note_type&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;originalStockKind&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;StockNotetype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OriginalStockKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ORIGINAL_STOCK_KIND_IMAGE_OCCLUSION&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not the name, not the fields, not the template. One value in the note type's config, and &lt;code&gt;ORIGINAL_STOCK_KIND_IMAGE_OCCLUSION&lt;/code&gt; is &lt;code&gt;6&lt;/code&gt;. Without it the image occlusion button stays hidden and the editor shows plain text fields, with the raw &lt;code&gt;{{c1::image-occlusion:rect:...}}&lt;/code&gt; string where the masks should be.&lt;/p&gt;

&lt;p&gt;In a schema 11 collection, note types live as JSON in the &lt;code&gt;col&lt;/code&gt; table, so this is what mine looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Image Occlusion (AnkiForge)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="c1"&gt;// cloze&lt;/span&gt;
  &lt;span class="nx"&gt;originalStockKind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// the line that matters&lt;/span&gt;
  &lt;span class="nx"&gt;flds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Occlusion&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;preventDeletion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;preventDeletion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Header&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;preventDeletion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Back Extra&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;preventDeletion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Comments&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;preventDeletion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="c1"&gt;// templates copied from Anki's stock IO note type&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The field tags and &lt;code&gt;preventDeletion&lt;/code&gt; flags are copied from the stock note type too, so mine matches the built-in one field for field.&lt;/p&gt;

&lt;h2&gt;
  
  
  The occlusion field is a string, and I copied it exactly
&lt;/h2&gt;

&lt;p&gt;Each mask is a cloze deletion with a shape inside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;{{c1::image-occlusion:rect:left=.2325:top=.1104:width=.1371:height=.0412}}&lt;span class="nt"&gt;&amp;lt;br&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Coordinates are fractions of the image size. Anki formats them with a small function called &lt;code&gt;floatToDisplay&lt;/code&gt;: four decimals, then strip leading and trailing zeros, so &lt;code&gt;0.2325&lt;/code&gt; becomes &lt;code&gt;.2325&lt;/code&gt;. "Hide all, guess one" mode adds &lt;code&gt;:oi=1&lt;/code&gt; to each shape.&lt;/p&gt;

&lt;p&gt;I matched that output character for character, so a note from my export and the same note after a round trip through Anki's editor are identical. I haven't tested how forgiving Anki's parser is about other formats, and I'd rather not find out from a user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing with Anki itself
&lt;/h2&gt;

&lt;p&gt;I didn't want to check this by opening Anki and clicking around after every change. &lt;code&gt;pip install anki&lt;/code&gt; gives you the same Rust backend the desktop app runs, so the test imports the generated &lt;code&gt;.apkg&lt;/code&gt; into a fresh collection and asks Anki for the occlusions with &lt;code&gt;get_image_occlusion_note&lt;/code&gt;, the call the mask editor uses to load a note.&lt;/p&gt;

&lt;p&gt;While writing this post I removed the flag from a test deck to show the failure. The test still passed. The backend parses occlusions out of any note with the right field, flag or no flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight irb"&gt;&lt;code&gt;&lt;span class="gp"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'originalStockKind'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="go"&gt;None
&lt;/span&gt;&lt;span class="gp"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_image_occlusion_note&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;nid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;note: 3 occlusions, image 'af-test.png'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the desktop UI reads the flag, so a backend-only test can't see it missing. The test now checks it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;collection.anki2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;import_anki_package&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ImportAnkiPackageRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;package_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ImportAnkiPackageOptions&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;nt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all_names_and_ids&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;nids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_notes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mid:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;nt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;nids&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;flds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Occlusion&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;originalStockKind&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;editor won&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t treat this as IO&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;io&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_image_occlusion_note&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;nids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WhichOneof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;note&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same script renders the cloze cards and checks that the question side hides the deletion. It runs on every change to the note types or the packer. Lesson for me: a test that passes before and after you break the thing isn't testing the thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the .apkg in the browser
&lt;/h2&gt;

&lt;p&gt;An &lt;code&gt;.apkg&lt;/code&gt; is a zip with a SQLite database (&lt;code&gt;collection.anki2&lt;/code&gt;), a &lt;code&gt;media&lt;/code&gt; file mapping &lt;code&gt;"0"&lt;/code&gt;, &lt;code&gt;"1"&lt;/code&gt;... to real filenames, and the media files themselves, named &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;1&lt;/code&gt;, ....&lt;/p&gt;

&lt;p&gt;I build it in the browser with &lt;a href="https://github.com/sql-js/sql.js" rel="noopener noreferrer"&gt;sql.js&lt;/a&gt; (SQLite compiled to WebAssembly) and &lt;a href="https://github.com/101arrowz/fflate" rel="noopener noreferrer"&gt;fflate&lt;/a&gt; for the zip. sql.js is about 1 MB of wasm, so it's only loaded when someone clicks Download. The point is that the deck never exists on my server. Diagrams do go through the server to the OCR step, but they aren't stored, and the finished deck is assembled on the user's machine.&lt;/p&gt;

&lt;p&gt;A few details that took more than one try:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Note GUIDs come from the content&lt;/strong&gt;, using the same base91 scheme genanki uses. Re-exporting the same card gives the same GUID, so importing an updated deck updates notes instead of duplicating them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deck IDs come from the deck name&lt;/strong&gt;, for the same reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Note type IDs are fixed and never change in place.&lt;/strong&gt; If I change a template, it ships as a new note type with a new name and a new ID. Changing a published note type under the same ID means people who imported the old one get a conflict on their next import.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same packer runs two free tools that don't call a server at all. The &lt;a href="https://ankiforge.app/tools/anki-card-template-styler" rel="noopener noreferrer"&gt;Anki Card CSS Styler&lt;/a&gt; lets you tune a card theme (colours, font, cloze highlight, night mode) against a live preview and downloads the note types as an &lt;code&gt;.apkg&lt;/code&gt;. Since the stylesheet is part of the note type, its ID is a hash of the CSS, so a changed look ships as a new note type instead of overwriting one you already imported. The &lt;a href="https://ankiforge.app/tools/cloze-deletion-formatter" rel="noopener noreferrer"&gt;Atomic Cloze Formatter&lt;/a&gt; is for typing clozes by hand: select a phrase, press Alt+C, and it gets the next cloze number, or turn every bold word or number in a pasted block into its own blank.&lt;/p&gt;

&lt;h2&gt;
  
  
  Masks: OCR was the easy part
&lt;/h2&gt;

&lt;p&gt;OCR returns lines of text with bounding boxes. Turning those lines into one mask per label is where the work was.&lt;/p&gt;

&lt;p&gt;Labels on anatomy diagrams wrap across lines ("Superior / vena cava"), so you have to merge lines. But labels also stack in columns ("Nose / Nasal cavity / Pharynx"), and a column of short labels looks exactly like one wrapped label if all you have is geometry. Merge too little and a two-line label becomes two masks. Merge too much and one card shows five answers at once.&lt;/p&gt;

&lt;p&gt;To measure it I built an eval set: diagrams from Wikimedia Commons with every label written down by hand. For each label the question is whether a student gets exactly one mask over it, switched on, without touching anything. I call that the one-click rate.&lt;/p&gt;

&lt;p&gt;My first version merged by proximity and scored 64%. For comparison I ran &lt;a href="https://pypi.org/project/nb41/" rel="noopener noreferrer"&gt;NIOBIUM&lt;/a&gt;, a free CLI that does the same job with a 10 px proximity merge, through the same scorer. It got 67%. Both of us found about 93% of the labels. The whole gap was merging: 43 merged masks for NIOBIUM against 2 for my current version.&lt;/p&gt;

&lt;p&gt;Here's one diagram from the set, same OCR engine, before and after:&lt;/p&gt;

&lt;p&gt;![Respiratory system diagram with OCR masks. Left, merging by position only: 14 of 48 labels get their own mask, and whole columns like "Nose / Nasal cavity / Nasal conchae" sit under one red mask. Right, merging only when the text continues: 46 of 48 labels get their own mask.]&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fksbskmgr7yzjo15on9ww.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fksbskmgr7yzjo15on9ww.png" alt=" " width="800" height="506"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Diagram: "Respiratory system complete" by LadyofHats (Mariana Ruiz), public domain, via Wikimedia Commons.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What fixed it was making a merge depend on the text as well as the boxes. Two lines merge only if they line up and the text reads as a continuation: the next line starts lowercase or with &lt;code&gt;(&lt;/code&gt;, the previous line ends on something like "of", or the next line is a single modifier word. Then the exceptions that the eval found:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;subclavius n.&lt;/code&gt; above &lt;code&gt;phrenic n.&lt;/code&gt; is two nerves, not one label. A line ending in a period ends the label, even if the next line starts lowercase.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;musculo-&lt;/code&gt; above &lt;code&gt;cutaneous n.&lt;/code&gt; is one word split by a hyphen and should join.&lt;/li&gt;
&lt;li&gt;On lecture slides, the title, bullet points and page number are text too. They now get masks that are off by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the 9 diagrams I tuned against (163 labels), the two OCR engines I use score 95% and 92%. On 7 diagrams I kept aside, the latest rule changes moved them from 91% to 98% and from 87% to 94%. I tuned those rule changes after looking at the failures on that held-out set, so it isn't really held out any more. The next round needs new images.&lt;/p&gt;

&lt;p&gt;One thing I didn't expect: the newer document-parsing OCR models were much worse at this. Two of them scored 7% and 25%. They see an anatomy diagram as one figure block and don't read the text inside it. They're built to turn scanned papers into Markdown, and for that they're probably great.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cloze side, briefly
&lt;/h2&gt;

&lt;p&gt;The text-to-cloze part uses an LLM, and it got its own eval, which I wrote up &lt;a href="https://soluto.hashnode.dev/i-wrote-facts-the-model-wasn-t-allowed-to-know-then-checked-if-it-used-them" rel="noopener noreferrer"&gt;on Hashnode&lt;/a&gt;. Two things worth knowing if you're doing something similar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The model I use reasons before it answers, even at the lowest reasoning setting, and those tokens count against &lt;code&gt;max_tokens&lt;/code&gt;. A sibling project had every response cut off at 400 tokens before anyone noticed. I now give it 16,000 and keep the reasoning out of the response.&lt;/li&gt;
&lt;li&gt;Models love putting a list under one cloze number: &lt;code&gt;{{c1::mitral}} and {{c1::tricuspid}}&lt;/code&gt;. That's a card asking you to recall a set, which is the thing spaced repetition is worst at. There's a prompt rule against it now, and the eval still catches it now and then.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's still broken
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Numbered diagrams.&lt;/strong&gt; If the figure has "1, 2, 3" on it and a legend elsewhere, the mask hides "3" and the answer on the back is "3". The real answer is in the legend. I need to pair numbers with legend entries, and that isn't built yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tiny old scans.&lt;/strong&gt; A 409×500 plate from the 1918 Gray's Anatomy defeats every engine I've tried. Upscaling before OCR got one engine from 0 to 5 of 11 labels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phones.&lt;/strong&gt; Anki's own library says the cards are image occlusion. I haven't yet opened them on a real phone in AnkiMobile or AnkiDroid.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're generating decks yourself and Anki's mask editor won't open your IO notes, check &lt;code&gt;originalStockKind&lt;/code&gt; first. The tool is at &lt;a href="https://ankiforge.app" rel="noopener noreferrer"&gt;ankiforge.app&lt;/a&gt;, and the card stylesheets I made along the way are MIT-licensed on &lt;a href="https://github.com/lxhsoluto-star/anki-card-styles" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>anki</category>
      <category>webdev</category>
      <category>sqlite</category>
    </item>
  </channel>
</rss>
