anki decks: add multiple-choice shapes variant; add real element photos to periodic table

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
This commit is contained in:
Claude
2026-09-10 14:20:24 +00:00
parent e4e32f20e7
commit 67282521ac
4 changed files with 203 additions and 18 deletions
+122 -8
View File
@@ -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 <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):
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)")