Files
ubuntu-post-install/tools/anki-deck-visual.py
T
Claude 67282521ac 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
2026-09-10 14:20:24 +00:00

504 lines
19 KiB
Python

#!/usr/bin/env python3
"""tools/anki-deck-visual.py — Generate image-based Anki decks (.apkg) for
shapes, clocks, and coin-counting, with Anki's built-in type-the-answer
input and Piper (offline, local neural TTS) audio. See
tools/anki-deck-math.py's docstring for one-time setup (venv, genanki +
piper-tts, downloading a voice) — same steps apply here, plus one more
package this script alone needs: `pip install pillow` (for drawing the
images — see the note below on PNG vs SVG for why).
All images are drawn programmatically (regular-polygon geometry,
clock-hand trigonometry, coin layouts) rather than AI-generated — image
generation (local or cloud) is a poor fit for content that has to be
exactly correct (an exact clock time, an exact side count, an exact coin
total), not just plausible-looking. See this script's own point/angle
generation functions for how each shape's geometry is computed directly
rather than approximated.
Rendered as PNG (via Pillow), not SVG — AnkiDroid has a long-documented
history of unreliable SVG rendering (multiple open ankidroid/Anki-Android
GitHub issues going back years: some SVGs render, some silently don't,
with no clear pattern tied to how the file itself is written). PNG has no
such history on any Anki client. Confirmed live: an earlier SVG-based
version of this script produced images that displayed fine on desktop
Anki but never appeared at all on a mobile client.
Decks:
shapes regular polygons (3-10 sides, image->name and name->sides)
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 —
a static hour hand is the most common "looks right but
teaches wrong" bug in generated clock faces)
currency US coins (nickel/dime/quarter — no pennies, since they're
barely used day to day at this point), 1-4 coins per card,
type the total in cents
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
automatically; --dry-run-tts to test the deck-building logic without any
voice model at all, using silent placeholder audio)
"""
import argparse
import hashlib
import genanki
import math
import os
import random
from PIL import Image, ImageDraw, ImageFont
import subprocess
parser = argparse.ArgumentParser()
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")
args = parser.parse_args()
SCRATCH = os.path.dirname(os.path.abspath(__file__))
_CANDIDATES = [
args.model_path,
f"{args.voice}.onnx",
os.path.join(SCRATCH, f"{args.voice}.onnx"),
os.path.expanduser(f"~/{args.voice}.onnx"),
os.path.expanduser(f"~/.local/share/piper/voices/{args.voice}.onnx"),
]
VOICE_MODEL = next((p for p in _CANDIDATES if p and os.path.isfile(p)), None)
if VOICE_MODEL is None and not args.dry_run_tts:
raise SystemExit(
f"Voice model for '{args.voice}' not found. Checked:\n"
+ "\n".join(f" {p}" for p in _CANDIDATES if p)
+ f"\n\nFind it with: find / -iname '{args.voice}.onnx' 2>/dev/null"
+ "\nThen pass its exact path with --model-path /the/real/path.onnx"
+ "\n(or pass --dry-run-tts to test deck-building without any voice at all)"
)
def piper_tts(text: str, out_path: str) -> None:
if args.dry_run_tts:
with open(out_path, "wb") as f:
f.write(
b"RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00"
b"\x22\x56\x00\x00\x44\xac\x00\x00\x02\x00\x10\x00data\x00\x00\x00\x00"
)
return
subprocess.run(
["python3", "-m", "piper", "-m", VOICE_MODEL, "-f", out_path],
input=text.encode("utf-8"),
check=True,
capture_output=True,
)
ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven",
"eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
"fifteen", "sixteen", "seventeen", "eighteen", "nineteen"]
TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
"eighty", "ninety"]
def num2words(n):
if n < 20:
return ONES[n]
if n < 100:
t, o = divmod(n, 10)
return TENS[t] + ("-" + ONES[o] if o else "")
h, rem = divmod(n, 100)
return ONES[h] + " hundred" + (" " + num2words(rem) if rem else "")
assert num2words(15) == "fifteen"
assert num2words(40) == "forty"
def time_words(hour, minute):
"""3, 5 -> 'three oh five'; 3, 15 -> 'three fifteen'; 3, 0 -> 'three o'clock'."""
if minute == 0:
return f"{num2words(hour)} o'clock"
if minute < 10:
return f"{num2words(hour)} oh {num2words(minute)}"
return f"{num2words(hour)} {num2words(minute)}"
assert time_words(3, 0) == "three o'clock"
assert time_words(3, 5) == "three oh five"
assert time_words(3, 15) == "three fifteen"
assert time_words(12, 45) == "twelve forty-five"
def build_model(deck_key):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
model_id = 1_800_000_000 + (voice_hash % 90_000_000)
return model_id, genanki.Model(
model_id,
f"Visual Fact ({deck_key}, {args.voice})",
fields=[{"name": "Image"}, {"name": "Answer"}, {"name": "QSound"}, {"name": "ASound"}],
templates=[{
"name": "Card",
"qfmt": """
<div class="imgwrap">{{Image}}</div>
{{QSound}}
{{type:Answer}}
""",
"afmt": """
<div class="imgwrap">{{Image}}</div>
<hr id="answer">
{{type:Answer}}
{{ASound}}
""",
}],
css="""
.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; }
""",
)
def build_deck(deck_key, deck_title):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
deck_id = 2_200_000_000 + (voice_hash % 90_000_000)
return genanki.Deck(deck_id, deck_title)
def add_note(deck, model, image_html, answer, qtext, atext, media_files, tag):
qfile = f"q_{tag}.wav"
afile = f"a_{tag}.wav"
qpath = os.path.join(MEDIA_DIR, qfile)
apath = os.path.join(MEDIA_DIR, afile)
piper_tts(qtext, qpath)
piper_tts(atext, apath)
media_files += [qpath, apath]
deck.add_note(genanki.Note(
model=model,
fields=[image_html, answer, f"[sound:{qfile}]", f"[sound:{afile}]"],
))
def add_text_note(deck, model, text, answer, qtext, atext, media_files, tag):
"""For directions that don't need an image (e.g. name -> number of sides)."""
add_note(deck, model, f'<div style="font-size:36px;">{text}</div>', answer,
qtext, atext, media_files, tag)
# ─── PNG generation (Pillow) ─────────────────────────────────────────────────
_FONT_CACHE = {}
_FONT_PATH_CANDIDATES = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
]
def load_font(size):
"""Loads a real bold TTF font if one of the common Debian/Ubuntu paths
exists (DejaVu Sans Bold ships with fonts-dejavu-core, a very common
baseline package); falls back to Pillow's own scalable default font
otherwise, so this never crashes even if no system font is found."""
if size in _FONT_CACHE:
return _FONT_CACHE[size]
for path in _FONT_PATH_CANDIDATES:
if os.path.isfile(path):
font = ImageFont.truetype(path, size)
_FONT_CACHE[size] = font
return font
font = ImageFont.load_default(size=size)
_FONT_CACHE[size] = font
return font
def draw_text_centered(draw, xy, text, font, fill="black"):
bbox = draw.textbbox((0, 0), text, font=font)
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
x, y = xy
draw.text((x - w / 2 - bbox[0], y - h / 2 - bbox[1]), text, font=font, fill=fill)
def save_png(filename, draw_fn, size=(200, 200)):
img = Image.new("RGB", size, "white")
draw = ImageDraw.Draw(img)
draw_fn(draw)
path = os.path.join(MEDIA_DIR, filename)
img.save(path)
return path
def regular_polygon_points(n_sides, cx=100, cy=100, r=80):
points = []
# Start pointing up (-90deg) so shapes sit "upright" rather than vertex-right.
start_angle = -90
for i in range(n_sides):
angle_deg = start_angle + i * (360 / n_sides)
angle_rad = math.radians(angle_deg)
x = cx + r * math.cos(angle_rad)
y = cy + r * math.sin(angle_rad)
points.append((round(x, 1), round(y, 1)))
return points
def polygon_png(points, filename):
def draw_fn(draw):
draw.polygon(points, fill=(111, 168, 220), outline=(28, 69, 135), width=4)
return save_png(filename, draw_fn)
POLYGON_NAMES = {
3: "triangle", 4: "square", 5: "pentagon", 6: "hexagon", 7: "heptagon",
8: "octagon", 9: "nonagon", 10: "decagon",
}
QUADRILATERALS = {
"square": [(50, 50), (150, 50), (150, 150), (50, 150)],
"rectangle": [(30, 60), (170, 60), (170, 140), (30, 140)],
"rhombus": [(100, 20), (170, 100), (100, 180), (30, 100)],
"trapezoid": [(60, 60), (140, 60), (170, 140), (30, 140)],
"parallelogram": [(60, 60), (160, 60), (140, 140), (40, 140)],
}
def clock_png(hour, minute, filename):
cx, cy, r = 100, 100, 90
minute_angle = minute * 6 - 90
hour_angle = (hour % 12) * 30 + minute * 0.5 - 90
def draw_fn(draw):
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill="white", outline="black", width=3)
font = load_font(15)
for h in range(1, 13):
angle = math.radians(h * 30 - 90)
tx1, ty1 = cx + (r - 10) * math.cos(angle), cy + (r - 10) * math.sin(angle)
tx2, ty2 = cx + r * math.cos(angle), cy + r * math.sin(angle)
draw.line([(tx1, ty1), (tx2, ty2)], fill="black", width=2)
nx, ny = cx + (r - 22) * math.cos(angle), cy + (r - 22) * math.sin(angle)
draw_text_centered(draw, (nx, ny), str(h), font)
def hand(angle_deg, length, width):
rad = math.radians(angle_deg)
x2, y2 = cx + length * math.cos(rad), cy + length * math.sin(rad)
draw.line([(cx, cy), (x2, y2)], fill="black", width=width)
hand(hour_angle, 45, 6)
hand(minute_angle, 70, 4)
draw.ellipse([cx - 4, cy - 4, cx + 4, cy + 4], fill="black")
return save_png(filename, draw_fn)
COIN_INFO = {5: ("#c0c0c0", "5¢"), 10: ("#d9d9d9", "10¢"), 25: ("#b8b8b8", "25¢")}
COIN_NAMES = {5: "nickel", 10: "dime", 25: "quarter"}
def coins_png(coin_values, filename):
n = len(coin_values)
spacing = 200 // (n + 1)
def draw_fn(draw):
font = load_font(14)
for i, v in enumerate(coin_values):
cx = spacing * (i + 1)
color, label = COIN_INFO[v]
radius = 30 if v == 25 else (26 if v == 10 else 28)
draw.ellipse([cx - radius, 100 - radius, cx + radius, 100 + radius],
fill=color, outline=(68, 68, 68), width=2)
draw_text_centered(draw, (cx, 100), label, font)
return save_png(filename, draw_fn)
def coin_list_words(coin_values):
names = [COIN_NAMES[v] for v in coin_values]
if len(names) == 1:
return f"a {names[0]}"
if len(names) == 2:
return f"a {names[0]} and a {names[1]}"
return ", ".join(f"a {n}" for n in names[:-1]) + f", and a {names[-1]}"
# ─── Per-deck generators ─────────────────────────────────────────────────────
def gen_shapes():
deck_key = "shapes"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Shapes: Polygons & Quadrilaterals")
media_files = []
jobs = []
for n in range(3, 11):
jobs.append(("polygon_image", n))
jobs.append(("polygon_sides", n))
for qname in QUADRILATERALS:
jobs.append(("quad_image", qname))
random.seed(60)
random.shuffle(jobs)
for kind, val in jobs:
if kind == "polygon_image":
n = val
name = POLYGON_NAMES[n]
png_path = polygon_png(regular_polygon_points(n), f"poly_{n}.png")
media_files.append(png_path)
add_note(deck, model, f'<img src="poly_{n}.png">', name,
"What shape is this?", name, media_files, f"shape_img_{n}")
elif kind == "polygon_sides":
n = val
name = POLYGON_NAMES[n]
add_text_note(deck, model, name.capitalize(), str(n),
f"How many sides does a {name} have?", num2words(n),
media_files, f"shape_sides_{n}")
else:
qname = val
png_path = polygon_png(QUADRILATERALS[qname], f"quad_{qname}.png")
media_files.append(png_path)
add_note(deck, model, f'<img src="quad_{qname}.png">', qname,
"What shape is this?", qname, media_files, f"shape_quad_{qname}")
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():
deck_key = "clocks"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Telling Time: Analog Clocks")
media_files = []
times = [(h, m) for h in range(1, 13) for m in range(0, 60, 5)]
random.seed(61)
random.shuffle(times)
# The question prompt ("What time is it?") is identical for every card —
# generate it once instead of 144 times.
shared_qfile = "q_clock_prompt.wav"
piper_tts("What time is it?", os.path.join(MEDIA_DIR, shared_qfile))
media_files.append(os.path.join(MEDIA_DIR, shared_qfile))
for hour, minute in times:
png_path = clock_png(hour, minute, f"clock_{hour}_{minute:02d}.png")
media_files.append(png_path)
answer = f"{hour}:{minute:02d}"
afile = f"a_clock_{hour}_{minute:02d}.wav"
apath = os.path.join(MEDIA_DIR, afile)
piper_tts(time_words(hour, minute), apath)
media_files.append(apath)
deck.add_note(genanki.Note(
model=model,
fields=[f'<img src="clock_{hour}_{minute:02d}.png">', answer,
f"[sound:{shared_qfile}]", f"[sound:{afile}]"],
))
return deck, media_files, len(times)
def gen_currency():
deck_key = "currency"
model_id, model = build_model(deck_key)
# Deliberately nickel/dime/quarter only, no pennies — pennies are barely
# used day to day at this point, and skipping them keeps every total a
# multiple of 5 cents, which is a cleaner first pass at coin counting.
deck = build_deck(deck_key, "Counting Coins (nickels, dimes, quarters)")
media_files = []
denoms = [5, 10, 25]
combos = set()
for count in range(1, 5):
def rec(remaining, current):
if remaining == 0:
combos.add(tuple(sorted(current)))
return
for d in denoms:
if not current or d >= current[-1]:
rec(remaining - 1, current + [d])
rec(count, [])
combos = sorted(combos)
random.seed(62)
random.shuffle(combos)
for coin_values in combos:
total = sum(coin_values)
tag = "_".join(map(str, coin_values))
png_path = coins_png(list(coin_values), f"coins_{tag}.png")
media_files.append(png_path)
qtext = f"How much money is {coin_list_words(list(coin_values))}?"
atext = f"{num2words(total)} cents"
add_note(deck, model, f'<img src="coins_{tag}.png">',
str(total), qtext, atext, media_files, f"coins_{tag}")
return deck, media_files, len(combos)
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, "shapes_mc": gen_shapes_mc,
"clocks": gen_clocks, "currency": gen_currency,
}
deck, media_files, count = GENERATORS[args.deck]()
package = genanki.Package(deck)
package.media_files = media_files
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)} media files)")