Merge pull request #452 from outis1one/claude/pensive-hopper-4c9e7i
anki decks: add --skip-ones multiplication/division variant; switch s…
This commit is contained in:
@@ -309,11 +309,13 @@ exact usage is documented in `tools/anki-deck-math.py`'s own header
|
||||
docstring; the other two scripts point back to it rather than repeating
|
||||
the same instructions three times.
|
||||
|
||||
Shapes, clocks, and coin images are drawn programmatically (SVG) rather
|
||||
than AI-generated — image generation is a poor fit for content that has
|
||||
to be exactly correct (an exact clock time, an exact side count), not
|
||||
just plausible-looking; see `tools/anki-deck-visual.py`'s own docstring
|
||||
for more on that tradeoff.
|
||||
Shapes, clocks, and coin images are drawn programmatically (PNG, via
|
||||
Pillow) rather than AI-generated — image generation is a poor fit for
|
||||
content that has to be exactly correct (an exact clock time, an exact
|
||||
side count), not just plausible-looking. PNG rather than SVG specifically
|
||||
because AnkiDroid has long-standing, still-open bugs rendering SVG
|
||||
`<img>` tags on mobile; see `tools/anki-deck-visual.py`'s own docstring
|
||||
for more on both tradeoffs.
|
||||
|
||||
## Layout
|
||||
|
||||
|
||||
+22
-6
@@ -15,8 +15,11 @@ progress dashboard this content is meant to be studied through.
|
||||
Decks:
|
||||
multiplication 1-12, all 144 ordered pairs (a x b), shuffled
|
||||
(not sequential — see the note near
|
||||
random.shuffle(pairs) below for why)
|
||||
division inverse of the multiplication deck (144 facts)
|
||||
random.shuffle(pairs) below for why). Add
|
||||
--skip-ones to drop every fact involving 1
|
||||
(trivial, 121 facts instead of 144).
|
||||
division inverse of the multiplication deck (144 facts,
|
||||
or 121 with --skip-ones)
|
||||
addsub --lo L --hi H addition + subtraction fact family for [L, H]
|
||||
(subtraction facts derived from the addition
|
||||
facts, e.g. 7+3=10 also gives 10-7=3 and
|
||||
@@ -45,6 +48,7 @@ Setup (one time):
|
||||
|
||||
Usage (run with the venv activated):
|
||||
python3 anki-deck-math.py --deck multiplication
|
||||
python3 anki-deck-math.py --deck multiplication --skip-ones
|
||||
python3 anki-deck-math.py --deck division
|
||||
python3 anki-deck-math.py --deck addsub --lo 3 --hi 7
|
||||
python3 anki-deck-math.py --deck addsub --lo 3 --hi 13
|
||||
@@ -74,6 +78,10 @@ parser.add_argument("--deck", required=True,
|
||||
choices=["multiplication", "division", "addsub", "fractions", "decimals"])
|
||||
parser.add_argument("--lo", type=int, default=None, help="addsub only: low end of range")
|
||||
parser.add_argument("--hi", type=int, default=None, help="addsub only: high end of range")
|
||||
parser.add_argument("--skip-ones", action="store_true",
|
||||
help="multiplication/division only: drop every fact where either"
|
||||
" number is 1 (1x1..1x12, 2x1..12x1, and their division"
|
||||
" inverses) — those are trivial and not worth drilling.")
|
||||
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",
|
||||
@@ -269,11 +277,14 @@ def add_note(deck, model, top, bottom, answer, qtext, atext, media_files, tag):
|
||||
|
||||
# ─── Per-deck generators ─────────────────────────────────────────────────────
|
||||
def gen_multiplication():
|
||||
deck_key = "multiplication"
|
||||
deck_key = "multiplication" + ("_no_ones" if args.skip_ones else "")
|
||||
model_id, model = build_model(deck_key)
|
||||
deck = build_deck(deck_key, "Multiplication Facts (1-12)")
|
||||
title = "Multiplication Facts (1-12)" + (", no 1s" if args.skip_ones else "")
|
||||
deck = build_deck(deck_key, title)
|
||||
media_files = []
|
||||
pairs = [(a, b) for a in range(1, 13) for b in range(1, 13)]
|
||||
if args.skip_ones:
|
||||
pairs = [(a, b) for a, b in pairs if a != 1 and b != 1]
|
||||
random.seed(42)
|
||||
random.shuffle(pairs)
|
||||
for a, b in pairs:
|
||||
@@ -285,13 +296,16 @@ def gen_multiplication():
|
||||
|
||||
|
||||
def gen_division():
|
||||
deck_key = "division"
|
||||
deck_key = "division" + ("_no_ones" if args.skip_ones else "")
|
||||
model_id, model = build_model(deck_key)
|
||||
deck = build_deck(deck_key, "Division Facts (inverse of 1-12 times tables)")
|
||||
title = "Division Facts (inverse of 1-12 times tables)" + (", no 1s" if args.skip_ones else "")
|
||||
deck = build_deck(deck_key, title)
|
||||
media_files = []
|
||||
# Same (a, b) pairs as multiplication: product / a = b. This is the
|
||||
# direct inverse of every multiplication card in that deck.
|
||||
pairs = [(a, b) for a in range(1, 13) for b in range(1, 13)]
|
||||
if args.skip_ones:
|
||||
pairs = [(a, b) for a, b in pairs if a != 1 and b != 1]
|
||||
random.seed(43)
|
||||
random.shuffle(pairs)
|
||||
for a, b in pairs:
|
||||
@@ -393,6 +407,8 @@ def gen_decimals():
|
||||
# ─── Dispatch ─────────────────────────────────────────────────────────────────
|
||||
if args.deck == "addsub":
|
||||
deck_key = f"addsub_{args.lo}_{args.hi}"
|
||||
elif args.deck in ("multiplication", "division") and args.skip_ones:
|
||||
deck_key = f"{args.deck}_no_ones"
|
||||
else:
|
||||
deck_key = args.deck
|
||||
|
||||
|
||||
+95
-50
@@ -3,9 +3,11 @@
|
||||
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.
|
||||
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 as SVG (regular-polygon geometry,
|
||||
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
|
||||
@@ -13,6 +15,14 @@ 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,
|
||||
@@ -41,6 +51,8 @@ import genanki
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import subprocess
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
@@ -178,14 +190,45 @@ def add_text_note(deck, model, text, answer, qtext, atext, media_files, tag):
|
||||
qtext, atext, media_files, tag)
|
||||
|
||||
|
||||
# ─── SVG generation ───────────────────────────────────────────────────────────
|
||||
def save_svg(svg_body, filename, viewbox="0 0 200 200"):
|
||||
# ─── 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)
|
||||
with open(path, "w") as f:
|
||||
f.write(
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{viewbox}" '
|
||||
f'width="200" height="200">{svg_body}</svg>'
|
||||
)
|
||||
img.save(path)
|
||||
return path
|
||||
|
||||
|
||||
@@ -202,9 +245,10 @@ def regular_polygon_points(n_sides, cx=100, cy=100, r=80):
|
||||
return points
|
||||
|
||||
|
||||
def polygon_svg(points):
|
||||
pts_str = " ".join(f"{x},{y}" for x, y in points)
|
||||
return f'<polygon points="{pts_str}" fill="#6fa8dc" stroke="#1c4587" stroke-width="4"/>'
|
||||
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 = {
|
||||
@@ -221,54 +265,54 @@ QUADRILATERALS = {
|
||||
}
|
||||
|
||||
|
||||
def clock_svg(hour, minute):
|
||||
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 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'<line x1="{cx}" y1="{cy}" x2="{x2:.1f}" y2="{y2:.1f}" stroke="{color}" stroke-width="{width}" stroke-linecap="round"/>'
|
||||
def draw_fn(draw):
|
||||
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill="white", outline="black", width=3)
|
||||
|
||||
ticks = []
|
||||
numerals = []
|
||||
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)
|
||||
ticks.append(f'<line x1="{tx1:.1f}" y1="{ty1:.1f}" x2="{tx2:.1f}" y2="{ty2:.1f}" stroke="black" stroke-width="2"/>')
|
||||
draw.line([(tx1, ty1), (tx2, ty2)], fill="black", width=2)
|
||||
nx, ny = cx + (r - 22) * math.cos(angle), cy + (r - 22) * math.sin(angle)
|
||||
numerals.append(f'<text x="{nx:.1f}" y="{ny:.1f}" font-size="14" text-anchor="middle" dominant-baseline="middle">{h}</text>')
|
||||
draw_text_centered(draw, (nx, ny), str(h), font)
|
||||
|
||||
body = (
|
||||
f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="white" stroke="black" stroke-width="3"/>'
|
||||
+ "".join(ticks) + "".join(numerals)
|
||||
+ hand(hour_angle, 45, 6, "black")
|
||||
+ hand(minute_angle, 70, 4, "black")
|
||||
+ f'<circle cx="{cx}" cy="{cy}" r="4" fill="black"/>'
|
||||
)
|
||||
return body
|
||||
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_svg(coin_values):
|
||||
def coins_png(coin_values, filename):
|
||||
n = len(coin_values)
|
||||
spacing = 200 // (n + 1)
|
||||
parts = []
|
||||
|
||||
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)
|
||||
parts.append(
|
||||
f'<circle cx="{cx}" cy="100" r="{radius}" fill="{color}" stroke="#444" stroke-width="2"/>'
|
||||
f'<text x="{cx}" y="105" font-size="14" text-anchor="middle">{label}</text>'
|
||||
)
|
||||
return "".join(parts)
|
||||
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):
|
||||
@@ -300,9 +344,9 @@ def gen_shapes():
|
||||
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'<img src="poly_{n}.svg">', name,
|
||||
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
|
||||
@@ -312,9 +356,9 @@ def gen_shapes():
|
||||
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'<img src="quad_{qname}.svg">', qname,
|
||||
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)
|
||||
|
||||
@@ -336,8 +380,8 @@ def gen_clocks():
|
||||
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)
|
||||
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)
|
||||
@@ -345,7 +389,7 @@ def gen_clocks():
|
||||
media_files.append(apath)
|
||||
deck.add_note(genanki.Note(
|
||||
model=model,
|
||||
fields=[f'<img src="clock_{hour}_{minute:02d}.svg">', answer,
|
||||
fields=[f'<img src="clock_{hour}_{minute:02d}.png">', answer,
|
||||
f"[sound:{shared_qfile}]", f"[sound:{afile}]"],
|
||||
))
|
||||
return deck, media_files, len(times)
|
||||
@@ -377,12 +421,13 @@ def gen_currency():
|
||||
|
||||
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)
|
||||
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_{"_".join(map(str, coin_values))}.svg">',
|
||||
str(total), qtext, atext, media_files, f"coins_{'_'.join(map(str, coin_values))}")
|
||||
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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user