#!/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. All images are drawn programmatically as SVG (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. 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 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) # ─── SVG generation ─────────────────────────────────────────────────────────── def save_svg(svg_body, filename, viewbox="0 0 200 200"): path = os.path.join(MEDIA_DIR, filename) with open(path, "w") as f: f.write( f'{svg_body}' ) 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_svg(points): pts_str = " ".join(f"{x},{y}" for x, y in points) return f'' 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_svg(hour, minute): cx, cy, r = 100, 100, 90 minute_angle = minute * 6 - 90 hour_angle = (hour % 12) * 30 + minute * 0.5 - 90 def hand(angle_deg, length, width, color): rad = math.radians(angle_deg) x2 = cx + length * math.cos(rad) y2 = cy + length * math.sin(rad) return f'' ticks = [] numerals = [] 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) ticks.append(f'') nx, ny = cx + (r - 22) * math.cos(angle), cy + (r - 22) * math.sin(angle) numerals.append(f'{h}') body = ( f'' + "".join(ticks) + "".join(numerals) + hand(hour_angle, 45, 6, "black") + hand(minute_angle, 70, 4, "black") + f'' ) return body COIN_INFO = {5: ("#c0c0c0", "5¢"), 10: ("#d9d9d9", "10¢"), 25: ("#b8b8b8", "25¢")} COIN_NAMES = {5: "nickel", 10: "dime", 25: "quarter"} def coins_svg(coin_values): n = len(coin_values) spacing = 200 // (n + 1) parts = [] 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) parts.append( f'' f'{label}' ) return "".join(parts) 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] svg_path = save_svg(polygon_svg(regular_polygon_points(n)), f"poly_{n}.svg") media_files.append(svg_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 svg_path = save_svg(polygon_svg(QUADRILATERALS[qname]), f"quad_{qname}.svg") media_files.append(svg_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: svg_path = save_svg(clock_svg(hour, minute), f"clock_{hour}_{minute:02d}.svg") media_files.append(svg_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) svg_path = save_svg(coins_svg(list(coin_values)), f"coins_{'_'.join(map(str, coin_values))}.svg") media_files.append(svg_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_{'_'.join(map(str, coin_values))}") 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)")