#!/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": """
{{Image}}
{{QSound}} {{type:Answer}} """, "afmt": """
{{Image}}

{{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; } """, ) 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'
{text}
', 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'', 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)")