#!/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) 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 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", "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": """
', 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'
', qname,
"What shape is this?", qname, media_files, f"shape_quad_{qname}")
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'
', 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'
',
str(total), qtext, atext, media_files, f"coins_{tag}")
return deck, media_files, len(combos)
if args.deck == "shapes":
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}")
os.makedirs(MEDIA_DIR, exist_ok=True)
GENERATORS = {"shapes": gen_shapes, "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)")