跳到正文
DEV Community· lxhsoluto-star·· 4 小時前AI 評分53

Anki 圖像遮擋卡為何匯入後變成普通填空卡,以及一行修正方法

Why your generated Anki image occlusion cards import as plain cloze (and the one-line fix)

AI 導讀

Anki 桌面版只有在筆記類型設定包含 `originalStockKind` 值 `6` 時,才會把生成的圖像遮擋筆記識別為內置圖像遮擋類型並顯示遮罩編輯器。Anki 後端即使沒有該設定仍可解析遮罩,因此只測試匯入及解析不足以發現問題,作者在測試中直接檢查該值。

正文

I've been building AnkiForge, 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.

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.

A note type named "Image Occlusion" isn't image occlusion

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 "Anki deck with image occlusion shows no images" from people importing them.

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.

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 (aqt/editor_legacy.py in the 26.9 package):

def current_notetype_is_image_occlusion(self) -> bool:
    if not self.note:
        return False

    return (
        self.note_type().get("originalStockKind", None)
        == StockNotetype.OriginalStockKind.ORIGINAL_STOCK_KIND_IMAGE_OCCLUSION
    )

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

In a schema 11 collection, note types live as JSON in the col table, so this is what mine looks like:

{
  name: 'Image Occlusion (AnkiForge)',
  type: 1,                // cloze
  originalStockKind: 6,   // the line that matters
  flds: [
    field('Occlusion',  0, { tag: 0, preventDeletion: true }),
    field('Image',      1, { tag: 1, preventDeletion: true }),
    field('Header',     2, { tag: 2, preventDeletion: true }),
    field('Back Extra', 3, { tag: 3, preventDeletion: true }),
    field('Comments',   4, { tag: 4, preventDeletion: false }),
  ],
  // templates copied from Anki's stock IO note type
}

The field tags and preventDeletion flags are copied from the stock note type too, so mine matches the built-in one field for field.

The occlusion field is a string, and I copied it exactly

Each mask is a cloze deletion with a shape inside it:

{{c1::image-occlusion:rect:left=.2325:top=.1104:width=.1371:height=.0412}}<br>

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

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.

Testing with Anki itself

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

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:

>>> m['originalStockKind']
None
>>> col.get_image_occlusion_note(note_id=nid)
note: 3 occlusions, image 'af-test.png'

Only the desktop UI reads the flag, so a backend-only test can't see it missing. The test now checks it directly:

col = Collection(os.path.join(tmp, "collection.anki2"))
col.import_anki_package(ImportAnkiPackageRequest(
    package_path=path, options=ImportAnkiPackageOptions()))

for nt in col.models.all_names_and_ids():
    m = col.models.get(nt.id)
    nids = col.find_notes(f"mid:{nt.id}")
    if nids and m["flds"][0]["name"] == "Occlusion":
        assert m.get("originalStockKind") == 6, "editor won't treat this as IO"
        io = col.get_image_occlusion_note(note_id=nids[0])
        assert io.WhichOneof("value") == "note", io

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.

Building the .apkg in the browser

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

I build it in the browser with sql.js (SQLite compiled to WebAssembly) and fflate 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.

A few details that took more than one try:

  • Note GUIDs come from the content, 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.
  • Deck IDs come from the deck name, for the same reason.
  • Note type IDs are fixed and never change in place. 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.

Masks: OCR was the easy part

OCR returns lines of text with bounding boxes. Turning those lines into one mask per label is where the work was.

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.

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.

My first version merged by proximity and scored 64%. For comparison I ran NIOBIUM, 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.

Here's one diagram from the set, same OCR engine, before and after:

![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.]

Diagram: "Respiratory system complete" by LadyofHats (Mariana Ruiz), public domain, via Wikimedia Commons.

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 (, the previous line ends on something like "of", or the next line is a single modifier word. Then the exceptions that the eval found:

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

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.

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.

The cloze side, briefly

The text-to-cloze part uses an LLM, and it got its own eval, which I'll write up separately. Two things worth knowing if you're doing something similar:

  • The model I use reasons before it answers, even at the lowest reasoning setting, and those tokens count against max_tokens. 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.
  • Models love putting a list under one cloze number: {{c1::mitral}} and {{c1::tricuspid}}. 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.

What's still broken

  • Numbered diagrams. 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.
  • Tiny old scans. 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.
  • Phones. Anki's own library says the cards are image occlusion. I haven't yet opened them on a real phone in AnkiMobile or AnkiDroid.

If you're generating decks yourself and Anki's mask editor won't open your IO notes, check originalStockKind first. The tool is at ankiforge.app

來源:DEV Community · dev.to