From 67282521ac5455757584ad6af8135746272dd020 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 14:20:24 +0000 Subject: [PATCH] anki decks: add multiple-choice shapes variant; add real element photos to periodic table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shapes_mc (tools/anki-deck-visual.py) is the same shape images as "shapes" but multiple choice instead of type-the-name, matching the plain-text A/B/C/D pattern anki-deck-periodic.py's "category" deck already uses (no clickable UI, since that needs a desktop-only Anki add-on and breaks on AnkiDroid/AnkiMobile). periodic prehs/hs (tools/anki-deck-periodic.py) now show each element's real sample photo alongside every card, fetched once from Wikipedia's own MediaWiki pageimages API and cached under tools/periodic_images/ — the one part of this tooling that needs internet access at generation time; --no-images restores the old text-only cards. Elements 100 (Fermium) through 118 (Oganesson) are excluded, since none has ever existed in a photographable quantity — every fetch is otherwise per-element and non-fatal, with progress printed so failures are visible. This fetch logic could not be exercised against the real Wikipedia API from this sandbox (no route to en.wikipedia.org here) — --dry-run-tts now also substitutes a placeholder image so the pipeline is at least structurally tested end to end. Real-network behavior needs verifying on an actual run. Added tools/*.apkg, tools/media_*/, tools/periodic_images/, and tools/__pycache__/ to .gitignore — all generated/cached locally, never meant to be committed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5 --- .gitignore | 8 +++ README.md | 17 ++++- tools/anki-deck-periodic.py | 130 +++++++++++++++++++++++++++++++++--- tools/anki-deck-visual.py | 66 +++++++++++++++--- 4 files changed, 203 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 23188d7..c7a82ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,10 @@ # Vanilla Tweaks ZIP files placed for automatic install — never commit these 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__/ diff --git a/README.md b/README.md index 8ee56b1..893ad6d 100644 --- a/README.md +++ b/README.md @@ -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 ready-to-import `.apkg` decks (multiplication/division/addition/ subtraction/fractions/decimals, the periodic table, and shapes/clocks/ -coin-counting) with Anki's built-in type-the-answer input and offline -neural TTS audio (Piper) on every card. All three are standalone Python +coin-counting) with offline neural TTS audio (Piper) on every card. Most +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 on any machine with Python, not necessarily the server itself. Full setup (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 `` tags on mobile; see `tools/anki-deck-visual.py`'s own docstring 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 ``` diff --git a/tools/anki-deck-periodic.py b/tools/anki-deck-periodic.py index 784c6cb..25ccdae 100644 --- a/tools/anki-deck-periodic.py +++ b/tools/anki-deck-periodic.py @@ -16,33 +16,130 @@ Decks: superheavy elements whose category is still officially 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 maintained public dataset), fetched and spot-checked against known facts 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 hs + python3 anki-deck-periodic.py --deck hs --no-images python3 anki-deck-periodic.py --deck category (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 -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 hashlib +import io import genanki +import json import os import random import subprocess +import urllib.error +import urllib.parse +import urllib.request + +from PIL import Image parser = argparse.ArgumentParser() parser.add_argument("--deck", required=True, choices=["prehs", "hs", "category"]) parser.add_argument("--voice", default="en_US-lessac-medium") parser.add_argument("--model-path", default=None) 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() 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 = [ args.model_path, @@ -257,6 +354,7 @@ def build_model(deck_key): css=""" .card { font-family: Arial, sans-serif; font-size: 26px; text-align: center; } .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) +def element_image_html(number, symbol, name, media_files): + """Returns an 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'' + + def add_note(deck, model, prompt, answer, qtext, atext, media_files, tag): qfile = f"q_{tag}.wav" afile = f"a_{tag}.wav" @@ -294,12 +406,13 @@ def gen_prehs(): random.seed(50) random.shuffle(cards) for kind, number, symbol, name in cards: + img = element_image_html(number, symbol, name, media_files) 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, media_files, f"prehs_s2n_{number}") else: - add_note(deck, model, name, symbol, + add_note(deck, model, img + name, symbol, f"What is the symbol for {name}?", symbol, media_files, f"prehs_n2s_{number}") return deck, media_files, len(cards) @@ -318,16 +431,17 @@ def gen_hs(): random.seed(51) random.shuffle(cards) for kind, number, symbol, name in cards: + img = element_image_html(number, symbol, name, media_files) 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, media_files, f"hs_s2n_{number}") 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, media_files, f"hs_n2s_{number}") 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, media_files, f"hs_num2s_{number}") 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) 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)") diff --git a/tools/anki-deck-visual.py b/tools/anki-deck-visual.py index 1226386..948e5ea 100644 --- a/tools/anki-deck-visual.py +++ b/tools/anki-deck-visual.py @@ -28,6 +28,12 @@ Decks: plus 5 quadrilateral types (image->name: square, rectangle, rhombus, trapezoid, parallelogram — each one's geometry is 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, type the time as H:MM (the hour hand moves fractionally 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): 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 currency (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 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("--model-path", default=None) 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; } .imgwrap { margin: 10px auto; } .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) +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"
{letter}) {choice}
" + for letter, choice in zip(letters, choices)) + image_html = (f'' + f'
{choice_html}
') + + 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(): deck_key = "clocks" model_id, model = build_model(deck_key) @@ -431,17 +483,15 @@ def gen_currency(): return deck, media_files, len(combos) -if args.deck == "shapes": - deck_key = "shapes" -elif args.deck == "clocks": - deck_key = "clocks" -else: - deck_key = "currency" +deck_key = args.deck MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}") 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]() package = genanki.Package(deck)