Merge pull request #454 from outis1one/claude/pensive-hopper-4c9e7i

anki decks: add multiple-choice shapes variant; add real element phot…
This commit is contained in:
Outis
2026-09-10 10:26:38 -04:00
committed by GitHub
4 changed files with 203 additions and 18 deletions
+8
View File
@@ -1,2 +1,10 @@
# Vanilla Tweaks ZIP files placed for automatic install — never commit these # Vanilla Tweaks ZIP files placed for automatic install — never commit these
extras/datapacks/*.zip extras/datapacks/*.zip
# tools/anki-deck-*.py output and caches — generated locally, regenerable,
# never meant to be committed (periodic_images/ especially: fetched
# element photos, not source)
tools/*.apkg
tools/media_*/
tools/periodic_images/
tools/__pycache__/
+15 -2
View File
@@ -300,8 +300,12 @@ account has no content — `tools/anki-deck-math.py`,
`tools/anki-deck-periodic.py`, and `tools/anki-deck-visual.py` generate `tools/anki-deck-periodic.py`, and `tools/anki-deck-visual.py` generate
ready-to-import `.apkg` decks (multiplication/division/addition/ ready-to-import `.apkg` decks (multiplication/division/addition/
subtraction/fractions/decimals, the periodic table, and shapes/clocks/ subtraction/fractions/decimals, the periodic table, and shapes/clocks/
coin-counting) with Anki's built-in type-the-answer input and offline coin-counting) with offline neural TTS audio (Piper) on every card. Most
neural TTS audio (Piper) on every card. All three are standalone Python cards use Anki's built-in type-the-answer input; a few (periodic table
categories, `--deck shapes_mc`) are multiple choice instead, shown as
plain-text A/B/C/D options — type the letter rather than tapping, since a
clickable UI needs a desktop-only Anki add-on and would break on
AnkiDroid/AnkiMobile. All three are standalone Python
scripts, unrelated to the `services/*.sh` installer framework — run them scripts, unrelated to the `services/*.sh` installer framework — run them
on any machine with Python, not necessarily the server itself. Full setup on any machine with Python, not necessarily the server itself. Full setup
(a venv, `genanki` + `piper-tts`, downloading a voice) and every deck's (a venv, `genanki` + `piper-tts`, downloading a voice) and every deck's
@@ -317,6 +321,15 @@ because AnkiDroid has long-standing, still-open bugs rendering SVG
`<img>` tags on mobile; see `tools/anki-deck-visual.py`'s own docstring `<img>` tags on mobile; see `tools/anki-deck-visual.py`'s own docstring
for more on both tradeoffs. for more on both tradeoffs.
The periodic table `prehs`/`hs` decks also show each element's real
sample photo (fetched once from Wikipedia's own MediaWiki API and cached
under `tools/periodic_images/`) alongside every card — the one part of
this tooling that needs internet access at generation time; pass
`--no-images` for the old text-only cards. Elements 100 (Fermium) through
118 (Oganesson) are skipped, since none has ever existed in a
photographable quantity. See `tools/anki-deck-periodic.py`'s own
docstring for the details and its caveats.
## Layout ## Layout
``` ```
+122 -8
View File
@@ -16,33 +16,130 @@ Decks:
superheavy elements whose category is still officially superheavy elements whose category is still officially
unconfirmed) unconfirmed)
Element photos on prehs/hs: every card (symbol->name, name->symbol,
number->symbol alike) also shows a real photo of the element, fetched
once from Wikipedia's own MediaWiki API (the same "pageimage" shown in
that element's infobox — the well-documented, standard
`action=query&prop=pageimages` endpoint on en.wikipedia.org, not a
guessed URL) and cached locally under tools/periodic_images/ so reruns
don't re-download. This is the one part of this repo's Anki tooling that
needs internet access at generation time — every other deck (math,
shapes/clocks/currency, TTS audio) is fully offline. Pass --no-images to
skip this and get the old text-only prehs/hs cards back.
Elements 100 (Fermium) through 118 (Oganesson) are hard-excluded from
this — every atom of these ever made has been produced (or claimed) one
at a time in a particle accelerator and never existed in macroscopic,
visible quantity, so no real sample photo exists to fetch; anything
Wikipedia's pageimage API returned for them would be a diagram or a
scientist's portrait, not the element. A couple of element names collide
with a more famous Wikipedia topic under the same plain title (Mercury
the planet, for one) — TITLE_OVERRIDES below is the fix-up list; if a
generated card shows an obviously wrong photo for some element, that's
almost certainly another one of these collisions — add it there.
Caveat: this fetch logic was written and dry-run tested without live
access to Wikipedia (sandboxed here with no route to en.wikipedia.org),
so it's exercised structurally (see --dry-run-tts) but not against the
real API response shape or real image content. Skips are per-element and
non-fatal — one bad/missing photo won't abort the rest of the deck — and
every fetch attempt prints what it did, so check that output the first
time you actually run this deck for real.
Element data: Bowserinator/Periodic-Table-JSON (a widely used, actively Element data: Bowserinator/Periodic-Table-JSON (a widely used, actively
maintained public dataset), fetched and spot-checked against known facts maintained public dataset), fetched and spot-checked against known facts
before being embedded below — not typed from memory. before being embedded below — not typed from memory.
Usage (run with the venv from anki-deck-math.py's docstring activated): Usage (run with the venv from anki-deck-math.py's docstring activated;
also needs `pip install pillow` for resizing fetched photos):
python3 anki-deck-periodic.py --deck prehs python3 anki-deck-periodic.py --deck prehs
python3 anki-deck-periodic.py --deck hs python3 anki-deck-periodic.py --deck hs
python3 anki-deck-periodic.py --deck hs --no-images
python3 anki-deck-periodic.py --deck category python3 anki-deck-periodic.py --deck category
(add --voice en_US-amy-medium etc.; --model-path if a voice isn't found (add --voice en_US-amy-medium etc.; --model-path if a voice isn't found
automatically; --dry-run-tts to test the deck-building logic without any automatically; --dry-run-tts to test the deck-building logic without any
voice model at all, using silent placeholder audio) voice model OR network access at all, using silent placeholder audio and
a placeholder image)
""" """
import argparse import argparse
import hashlib import hashlib
import io
import genanki import genanki
import json
import os import os
import random import random
import subprocess import subprocess
import urllib.error
import urllib.parse
import urllib.request
from PIL import Image
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--deck", required=True, choices=["prehs", "hs", "category"]) parser.add_argument("--deck", required=True, choices=["prehs", "hs", "category"])
parser.add_argument("--voice", default="en_US-lessac-medium") parser.add_argument("--voice", default="en_US-lessac-medium")
parser.add_argument("--model-path", default=None) parser.add_argument("--model-path", default=None)
parser.add_argument("--dry-run-tts", action="store_true") parser.add_argument("--dry-run-tts", action="store_true")
parser.add_argument("--no-images", action="store_true",
help="prehs/hs only: skip fetching element photos, keep the old text-only cards")
args = parser.parse_args() args = parser.parse_args()
SCRATCH = os.path.dirname(os.path.abspath(__file__)) SCRATCH = os.path.dirname(os.path.abspath(__file__))
IMAGE_CACHE = os.path.join(SCRATCH, "periodic_images")
os.makedirs(IMAGE_CACHE, exist_ok=True)
# Elements with no macroscopic sample ever produced — see the docstring.
NO_PHOTO_NUMBERS = set(range(100, 119))
# Element names whose plain Wikipedia article title is a different, more
# famous topic — see the docstring. Add to this if a generated card shows
# an obviously wrong photo for some element.
TITLE_OVERRIDES = {"Hg": "Mercury (element)"}
def fetch_element_image(symbol, name):
"""Downloads (once, cached under periodic_images/) the real sample
photo Wikipedia itself shows for this element — its page's
'pageimage', the same image used in the infobox — via the standard
MediaWiki query API. Returns the cached local path, or None if no
image exists on the page (normal for some elements, not a bug)."""
cache_path = os.path.join(IMAGE_CACHE, f"{symbol}.jpg")
if os.path.isfile(cache_path):
return cache_path
if args.dry_run_tts:
# No real network call in dry-run mode — a flat placeholder lets the
# rest of the pipeline (HTML wiring, media_files list, .apkg
# packaging) still be exercised end-to-end without it.
Image.new("RGB", (100, 100), (190, 190, 190)).save(cache_path, "JPEG")
return cache_path
title = TITLE_OVERRIDES.get(symbol, name)
api_url = ("https://en.wikipedia.org/w/api.php?action=query&format=json"
"&prop=pageimages&piprop=original&redirects=1&titles="
+ urllib.parse.quote(title))
headers = {"User-Agent": "anki-deck-periodic.py (personal Anki deck generator)"}
try:
req = urllib.request.Request(api_url, headers=headers)
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.load(resp)
pages = data.get("query", {}).get("pages", {})
page = next(iter(pages.values()), {})
image_url = page.get("original", {}).get("source")
if not image_url:
print(f" (no photo found for {name})")
return None
img_req = urllib.request.Request(image_url, headers=headers)
with urllib.request.urlopen(img_req, timeout=15) as resp:
raw = resp.read()
img = Image.open(io.BytesIO(raw)).convert("RGB")
img.thumbnail((300, 300))
img.save(cache_path, "JPEG", quality=85)
print(f" fetched photo for {name} ({title})")
return cache_path
except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError) as e:
print(f" (photo fetch failed for {name}: {e})")
return None
_CANDIDATES = [ _CANDIDATES = [
args.model_path, args.model_path,
@@ -257,6 +354,7 @@ def build_model(deck_key):
css=""" css="""
.card { font-family: Arial, sans-serif; font-size: 26px; text-align: center; } .card { font-family: Arial, sans-serif; font-size: 26px; text-align: center; }
.prompt { font-size: 40px; margin: 20px auto; white-space: pre-line; } .prompt { font-size: 40px; margin: 20px auto; white-space: pre-line; }
.elem-img { max-width: 220px; max-height: 220px; display: block; margin: 0 auto 10px; }
""", """,
) )
@@ -267,6 +365,20 @@ def build_deck(deck_key, deck_title):
return genanki.Deck(deck_id, deck_title) return genanki.Deck(deck_id, deck_title)
def element_image_html(number, symbol, name, media_files):
"""Returns an <img> tag for this element's cached/fetched photo, or ""
if --no-images was passed, the element is in NO_PHOTO_NUMBERS, or no
photo could be found/downloaded for it."""
if args.no_images or number in NO_PHOTO_NUMBERS:
return ""
local_path = fetch_element_image(symbol, name)
if local_path is None:
return ""
if local_path not in media_files:
media_files.append(local_path)
return f'<img class="elem-img" src="{os.path.basename(local_path)}">'
def add_note(deck, model, prompt, answer, qtext, atext, media_files, tag): def add_note(deck, model, prompt, answer, qtext, atext, media_files, tag):
qfile = f"q_{tag}.wav" qfile = f"q_{tag}.wav"
afile = f"a_{tag}.wav" afile = f"a_{tag}.wav"
@@ -294,12 +406,13 @@ def gen_prehs():
random.seed(50) random.seed(50)
random.shuffle(cards) random.shuffle(cards)
for kind, number, symbol, name in cards: for kind, number, symbol, name in cards:
img = element_image_html(number, symbol, name, media_files)
if kind == "symbol_to_name": if kind == "symbol_to_name":
add_note(deck, model, symbol, name, add_note(deck, model, img + symbol, name,
f"What element has the symbol {symbol}?", name, f"What element has the symbol {symbol}?", name,
media_files, f"prehs_s2n_{number}") media_files, f"prehs_s2n_{number}")
else: else:
add_note(deck, model, name, symbol, add_note(deck, model, img + name, symbol,
f"What is the symbol for {name}?", symbol, f"What is the symbol for {name}?", symbol,
media_files, f"prehs_n2s_{number}") media_files, f"prehs_n2s_{number}")
return deck, media_files, len(cards) return deck, media_files, len(cards)
@@ -318,16 +431,17 @@ def gen_hs():
random.seed(51) random.seed(51)
random.shuffle(cards) random.shuffle(cards)
for kind, number, symbol, name in cards: for kind, number, symbol, name in cards:
img = element_image_html(number, symbol, name, media_files)
if kind == "symbol_to_name": if kind == "symbol_to_name":
add_note(deck, model, symbol, name, add_note(deck, model, img + symbol, name,
f"What element has the symbol {symbol}?", name, f"What element has the symbol {symbol}?", name,
media_files, f"hs_s2n_{number}") media_files, f"hs_s2n_{number}")
elif kind == "name_to_symbol": elif kind == "name_to_symbol":
add_note(deck, model, name, symbol, add_note(deck, model, img + name, symbol,
f"What is the symbol for {name}?", symbol, f"What is the symbol for {name}?", symbol,
media_files, f"hs_n2s_{number}") media_files, f"hs_n2s_{number}")
else: else:
add_note(deck, model, f"Element #{number}", symbol, add_note(deck, model, img + f"Element #{number}", symbol,
f"What is the symbol for element number {num2words(number)}?", symbol, f"What is the symbol for element number {num2words(number)}?", symbol,
media_files, f"hs_num2s_{number}") media_files, f"hs_num2s_{number}")
return deck, media_files, len(cards) return deck, media_files, len(cards)
@@ -382,4 +496,4 @@ out_path = os.path.join(SCRATCH, f"{deck_key}_{args.voice}.apkg")
package.write_to_file(out_path) package.write_to_file(out_path)
size_mb = os.path.getsize(out_path) / (1024 * 1024) size_mb = os.path.getsize(out_path) / (1024 * 1024)
print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} audio clips)") print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} media files)")
+58 -8
View File
@@ -28,6 +28,12 @@ Decks:
plus 5 quadrilateral types (image->name: square, rectangle, plus 5 quadrilateral types (image->name: square, rectangle,
rhombus, trapezoid, parallelogram — each one's geometry is rhombus, trapezoid, parallelogram — each one's geometry is
genuinely distinct, not just differently labeled) genuinely distinct, not just differently labeled)
shapes_mc same shape images as "shapes", but as multiple choice
(A/B/C/D shown as plain text below the image — not a
clickable UI, since that needs a desktop-only Anki add-on
and would break on AnkiDroid/AnkiMobile — same approach as
tools/anki-deck-periodic.py's "category" deck), type the
letter
clocks analog clock faces, all 144 hour/5-min combinations, clocks analog clock faces, all 144 hour/5-min combinations,
type the time as H:MM (the hour hand moves fractionally type the time as H:MM (the hour hand moves fractionally
with the minutes, e.g. 6:30 sits halfway between 6 and 7 — with the minutes, e.g. 6:30 sits halfway between 6 and 7 —
@@ -39,6 +45,7 @@ Decks:
Usage (run with the venv from anki-deck-math.py's docstring activated): Usage (run with the venv from anki-deck-math.py's docstring activated):
python3 anki-deck-visual.py --deck shapes python3 anki-deck-visual.py --deck shapes
python3 anki-deck-visual.py --deck shapes_mc
python3 anki-deck-visual.py --deck clocks python3 anki-deck-visual.py --deck clocks
python3 anki-deck-visual.py --deck currency python3 anki-deck-visual.py --deck currency
(add --voice en_US-amy-medium etc.; --model-path if a voice isn't found (add --voice en_US-amy-medium etc.; --model-path if a voice isn't found
@@ -56,7 +63,8 @@ from PIL import Image, ImageDraw, ImageFont
import subprocess import subprocess
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--deck", required=True, choices=["shapes", "clocks", "currency"]) parser.add_argument("--deck", required=True,
choices=["shapes", "shapes_mc", "clocks", "currency"])
parser.add_argument("--voice", default="en_US-lessac-medium") parser.add_argument("--voice", default="en_US-lessac-medium")
parser.add_argument("--model-path", default=None) parser.add_argument("--model-path", default=None)
parser.add_argument("--dry-run-tts", action="store_true") parser.add_argument("--dry-run-tts", action="store_true")
@@ -160,6 +168,8 @@ def build_model(deck_key):
.card { font-family: Arial, sans-serif; font-size: 24px; text-align: center; } .card { font-family: Arial, sans-serif; font-size: 24px; text-align: center; }
.imgwrap { margin: 10px auto; } .imgwrap { margin: 10px auto; }
.imgwrap img { max-width: 260px; max-height: 260px; } .imgwrap img { max-width: 260px; max-height: 260px; }
.mc-choices { display: inline-block; text-align: left; margin-top: 14px; font-size: 22px; }
.mc-choices div { margin: 4px 0; }
""", """,
) )
@@ -363,6 +373,48 @@ def gen_shapes():
return deck, media_files, len(jobs) return deck, media_files, len(jobs)
def gen_shapes_mc():
"""Same shape images as gen_shapes(), but multiple choice instead of
type-the-name — A/B/C/D distractors drawn from every other shape name
in the pool (polygons and quadrilaterals share one distractor pool, so
a polygon question can pull "square" as a wrong answer and vice versa)."""
deck_key = "shapes_mc"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Shapes: Polygons & Quadrilaterals (multiple choice)")
media_files = []
jobs = [("polygon", n, POLYGON_NAMES[n]) for n in range(3, 11)]
jobs += [("quad", qname, qname) for qname in QUADRILATERALS]
all_names = [name for _, _, name in jobs]
random.seed(63)
random.shuffle(jobs)
letters = ["A", "B", "C", "D"]
for kind, val, name in jobs:
if kind == "polygon":
png_path = polygon_png(regular_polygon_points(val), f"mc_poly_{val}.png")
tag = f"shape_mc_poly_{val}"
else:
png_path = polygon_png(QUADRILATERALS[val], f"mc_quad_{val}.png")
tag = f"shape_mc_quad_{val}"
media_files.append(png_path)
distractor_pool = [n for n in all_names if n != name]
distractors = random.sample(distractor_pool, 3)
choices = distractors + [name]
random.shuffle(choices)
correct_letter = letters[choices.index(name)]
choice_html = "".join(f"<div>{letter}) {choice}</div>"
for letter, choice in zip(letters, choices))
image_html = (f'<img src="{os.path.basename(png_path)}">'
f'<div class="mc-choices">{choice_html}</div>')
add_note(deck, model, image_html, correct_letter,
"What shape is this?", name, media_files, tag)
return deck, media_files, len(jobs)
def gen_clocks(): def gen_clocks():
deck_key = "clocks" deck_key = "clocks"
model_id, model = build_model(deck_key) model_id, model = build_model(deck_key)
@@ -431,17 +483,15 @@ def gen_currency():
return deck, media_files, len(combos) return deck, media_files, len(combos)
if args.deck == "shapes": deck_key = args.deck
deck_key = "shapes"
elif args.deck == "clocks":
deck_key = "clocks"
else:
deck_key = "currency"
MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}") MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}")
os.makedirs(MEDIA_DIR, exist_ok=True) os.makedirs(MEDIA_DIR, exist_ok=True)
GENERATORS = {"shapes": gen_shapes, "clocks": gen_clocks, "currency": gen_currency} GENERATORS = {
"shapes": gen_shapes, "shapes_mc": gen_shapes_mc,
"clocks": gen_clocks, "currency": gen_currency,
}
deck, media_files, count = GENERATORS[args.deck]() deck, media_files, count = GENERATORS[args.deck]()
package = genanki.Package(deck) package = genanki.Package(deck)