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
This commit is contained in:
@@ -1,2 +1,10 @@
|
||||
# Vanilla Tweaks ZIP files placed for automatic install — never commit these
|
||||
extras/datapacks/*.zip
|
||||
|
||||
# tools/anki-deck-*.py output and caches — generated locally, regenerable,
|
||||
# never meant to be committed (periodic_images/ especially: fetched
|
||||
# element photos, not source)
|
||||
tools/*.apkg
|
||||
tools/media_*/
|
||||
tools/periodic_images/
|
||||
tools/__pycache__/
|
||||
|
||||
@@ -300,8 +300,12 @@ account has no content — `tools/anki-deck-math.py`,
|
||||
`tools/anki-deck-periodic.py`, and `tools/anki-deck-visual.py` generate
|
||||
ready-to-import `.apkg` decks (multiplication/division/addition/
|
||||
subtraction/fractions/decimals, the periodic table, and shapes/clocks/
|
||||
coin-counting) with Anki's built-in type-the-answer input and offline
|
||||
neural TTS audio (Piper) on every card. All three are standalone Python
|
||||
coin-counting) with offline neural TTS audio (Piper) on every card. Most
|
||||
cards use Anki's built-in type-the-answer input; a few (periodic table
|
||||
categories, `--deck shapes_mc`) are multiple choice instead, shown as
|
||||
plain-text A/B/C/D options — type the letter rather than tapping, since a
|
||||
clickable UI needs a desktop-only Anki add-on and would break on
|
||||
AnkiDroid/AnkiMobile. All three are standalone Python
|
||||
scripts, unrelated to the `services/*.sh` installer framework — run them
|
||||
on any machine with Python, not necessarily the server itself. Full setup
|
||||
(a venv, `genanki` + `piper-tts`, downloading a voice) and every deck's
|
||||
@@ -317,6 +321,15 @@ 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.
|
||||
|
||||
The periodic table `prehs`/`hs` decks also show each element's real
|
||||
sample photo (fetched once from Wikipedia's own MediaWiki API and cached
|
||||
under `tools/periodic_images/`) alongside every card — the one part of
|
||||
this tooling that needs internet access at generation time; pass
|
||||
`--no-images` for the old text-only cards. Elements 100 (Fermium) through
|
||||
118 (Oganesson) are skipped, since none has ever existed in a
|
||||
photographable quantity. See `tools/anki-deck-periodic.py`'s own
|
||||
docstring for the details and its caveats.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
|
||||
+122
-8
@@ -16,33 +16,130 @@ Decks:
|
||||
superheavy elements whose category is still officially
|
||||
unconfirmed)
|
||||
|
||||
Element photos on prehs/hs: every card (symbol->name, name->symbol,
|
||||
number->symbol alike) also shows a real photo of the element, fetched
|
||||
once from Wikipedia's own MediaWiki API (the same "pageimage" shown in
|
||||
that element's infobox — the well-documented, standard
|
||||
`action=query&prop=pageimages` endpoint on en.wikipedia.org, not a
|
||||
guessed URL) and cached locally under tools/periodic_images/ so reruns
|
||||
don't re-download. This is the one part of this repo's Anki tooling that
|
||||
needs internet access at generation time — every other deck (math,
|
||||
shapes/clocks/currency, TTS audio) is fully offline. Pass --no-images to
|
||||
skip this and get the old text-only prehs/hs cards back.
|
||||
|
||||
Elements 100 (Fermium) through 118 (Oganesson) are hard-excluded from
|
||||
this — every atom of these ever made has been produced (or claimed) one
|
||||
at a time in a particle accelerator and never existed in macroscopic,
|
||||
visible quantity, so no real sample photo exists to fetch; anything
|
||||
Wikipedia's pageimage API returned for them would be a diagram or a
|
||||
scientist's portrait, not the element. A couple of element names collide
|
||||
with a more famous Wikipedia topic under the same plain title (Mercury
|
||||
the planet, for one) — TITLE_OVERRIDES below is the fix-up list; if a
|
||||
generated card shows an obviously wrong photo for some element, that's
|
||||
almost certainly another one of these collisions — add it there.
|
||||
|
||||
Caveat: this fetch logic was written and dry-run tested without live
|
||||
access to Wikipedia (sandboxed here with no route to en.wikipedia.org),
|
||||
so it's exercised structurally (see --dry-run-tts) but not against the
|
||||
real API response shape or real image content. Skips are per-element and
|
||||
non-fatal — one bad/missing photo won't abort the rest of the deck — and
|
||||
every fetch attempt prints what it did, so check that output the first
|
||||
time you actually run this deck for real.
|
||||
|
||||
Element data: Bowserinator/Periodic-Table-JSON (a widely used, actively
|
||||
maintained public dataset), fetched and spot-checked against known facts
|
||||
before being embedded below — not typed from memory.
|
||||
|
||||
Usage (run with the venv from anki-deck-math.py's docstring activated):
|
||||
Usage (run with the venv from anki-deck-math.py's docstring activated;
|
||||
also needs `pip install pillow` for resizing fetched photos):
|
||||
python3 anki-deck-periodic.py --deck prehs
|
||||
python3 anki-deck-periodic.py --deck hs
|
||||
python3 anki-deck-periodic.py --deck hs --no-images
|
||||
python3 anki-deck-periodic.py --deck category
|
||||
(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)
|
||||
voice model OR network access at all, using silent placeholder audio and
|
||||
a placeholder image)
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import genanki
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from PIL import Image
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--deck", required=True, choices=["prehs", "hs", "category"])
|
||||
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")
|
||||
parser.add_argument("--no-images", action="store_true",
|
||||
help="prehs/hs only: skip fetching element photos, keep the old text-only cards")
|
||||
args = parser.parse_args()
|
||||
|
||||
SCRATCH = os.path.dirname(os.path.abspath(__file__))
|
||||
IMAGE_CACHE = os.path.join(SCRATCH, "periodic_images")
|
||||
os.makedirs(IMAGE_CACHE, exist_ok=True)
|
||||
|
||||
# Elements with no macroscopic sample ever produced — see the docstring.
|
||||
NO_PHOTO_NUMBERS = set(range(100, 119))
|
||||
|
||||
# Element names whose plain Wikipedia article title is a different, more
|
||||
# famous topic — see the docstring. Add to this if a generated card shows
|
||||
# an obviously wrong photo for some element.
|
||||
TITLE_OVERRIDES = {"Hg": "Mercury (element)"}
|
||||
|
||||
|
||||
def fetch_element_image(symbol, name):
|
||||
"""Downloads (once, cached under periodic_images/) the real sample
|
||||
photo Wikipedia itself shows for this element — its page's
|
||||
'pageimage', the same image used in the infobox — via the standard
|
||||
MediaWiki query API. Returns the cached local path, or None if no
|
||||
image exists on the page (normal for some elements, not a bug)."""
|
||||
cache_path = os.path.join(IMAGE_CACHE, f"{symbol}.jpg")
|
||||
if os.path.isfile(cache_path):
|
||||
return cache_path
|
||||
|
||||
if args.dry_run_tts:
|
||||
# No real network call in dry-run mode — a flat placeholder lets the
|
||||
# rest of the pipeline (HTML wiring, media_files list, .apkg
|
||||
# packaging) still be exercised end-to-end without it.
|
||||
Image.new("RGB", (100, 100), (190, 190, 190)).save(cache_path, "JPEG")
|
||||
return cache_path
|
||||
|
||||
title = TITLE_OVERRIDES.get(symbol, name)
|
||||
api_url = ("https://en.wikipedia.org/w/api.php?action=query&format=json"
|
||||
"&prop=pageimages&piprop=original&redirects=1&titles="
|
||||
+ urllib.parse.quote(title))
|
||||
headers = {"User-Agent": "anki-deck-periodic.py (personal Anki deck generator)"}
|
||||
try:
|
||||
req = urllib.request.Request(api_url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
data = json.load(resp)
|
||||
pages = data.get("query", {}).get("pages", {})
|
||||
page = next(iter(pages.values()), {})
|
||||
image_url = page.get("original", {}).get("source")
|
||||
if not image_url:
|
||||
print(f" (no photo found for {name})")
|
||||
return None
|
||||
img_req = urllib.request.Request(image_url, headers=headers)
|
||||
with urllib.request.urlopen(img_req, timeout=15) as resp:
|
||||
raw = resp.read()
|
||||
img = Image.open(io.BytesIO(raw)).convert("RGB")
|
||||
img.thumbnail((300, 300))
|
||||
img.save(cache_path, "JPEG", quality=85)
|
||||
print(f" fetched photo for {name} ({title})")
|
||||
return cache_path
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError) as e:
|
||||
print(f" (photo fetch failed for {name}: {e})")
|
||||
return None
|
||||
|
||||
_CANDIDATES = [
|
||||
args.model_path,
|
||||
@@ -257,6 +354,7 @@ def build_model(deck_key):
|
||||
css="""
|
||||
.card { font-family: Arial, sans-serif; font-size: 26px; text-align: center; }
|
||||
.prompt { font-size: 40px; margin: 20px auto; white-space: pre-line; }
|
||||
.elem-img { max-width: 220px; max-height: 220px; display: block; margin: 0 auto 10px; }
|
||||
""",
|
||||
)
|
||||
|
||||
@@ -267,6 +365,20 @@ def build_deck(deck_key, deck_title):
|
||||
return genanki.Deck(deck_id, deck_title)
|
||||
|
||||
|
||||
def element_image_html(number, symbol, name, media_files):
|
||||
"""Returns an <img> tag for this element's cached/fetched photo, or ""
|
||||
if --no-images was passed, the element is in NO_PHOTO_NUMBERS, or no
|
||||
photo could be found/downloaded for it."""
|
||||
if args.no_images or number in NO_PHOTO_NUMBERS:
|
||||
return ""
|
||||
local_path = fetch_element_image(symbol, name)
|
||||
if local_path is None:
|
||||
return ""
|
||||
if local_path not in media_files:
|
||||
media_files.append(local_path)
|
||||
return f'<img class="elem-img" src="{os.path.basename(local_path)}">'
|
||||
|
||||
|
||||
def add_note(deck, model, prompt, answer, qtext, atext, media_files, tag):
|
||||
qfile = f"q_{tag}.wav"
|
||||
afile = f"a_{tag}.wav"
|
||||
@@ -294,12 +406,13 @@ def gen_prehs():
|
||||
random.seed(50)
|
||||
random.shuffle(cards)
|
||||
for kind, number, symbol, name in cards:
|
||||
img = element_image_html(number, symbol, name, media_files)
|
||||
if kind == "symbol_to_name":
|
||||
add_note(deck, model, symbol, name,
|
||||
add_note(deck, model, img + symbol, name,
|
||||
f"What element has the symbol {symbol}?", name,
|
||||
media_files, f"prehs_s2n_{number}")
|
||||
else:
|
||||
add_note(deck, model, name, symbol,
|
||||
add_note(deck, model, img + name, symbol,
|
||||
f"What is the symbol for {name}?", symbol,
|
||||
media_files, f"prehs_n2s_{number}")
|
||||
return deck, media_files, len(cards)
|
||||
@@ -318,16 +431,17 @@ def gen_hs():
|
||||
random.seed(51)
|
||||
random.shuffle(cards)
|
||||
for kind, number, symbol, name in cards:
|
||||
img = element_image_html(number, symbol, name, media_files)
|
||||
if kind == "symbol_to_name":
|
||||
add_note(deck, model, symbol, name,
|
||||
add_note(deck, model, img + symbol, name,
|
||||
f"What element has the symbol {symbol}?", name,
|
||||
media_files, f"hs_s2n_{number}")
|
||||
elif kind == "name_to_symbol":
|
||||
add_note(deck, model, name, symbol,
|
||||
add_note(deck, model, img + name, symbol,
|
||||
f"What is the symbol for {name}?", symbol,
|
||||
media_files, f"hs_n2s_{number}")
|
||||
else:
|
||||
add_note(deck, model, f"Element #{number}", symbol,
|
||||
add_note(deck, model, img + f"Element #{number}", symbol,
|
||||
f"What is the symbol for element number {num2words(number)}?", symbol,
|
||||
media_files, f"hs_num2s_{number}")
|
||||
return deck, media_files, len(cards)
|
||||
@@ -382,4 +496,4 @@ 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)} audio clips)")
|
||||
print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} media files)")
|
||||
|
||||
@@ -28,6 +28,12 @@ Decks:
|
||||
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 —
|
||||
@@ -39,6 +45,7 @@ Decks:
|
||||
|
||||
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
|
||||
@@ -56,7 +63,8 @@ from PIL import Image, ImageDraw, ImageFont
|
||||
import subprocess
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--deck", required=True, choices=["shapes", "clocks", "currency"])
|
||||
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")
|
||||
@@ -160,6 +168,8 @@ def build_model(deck_key):
|
||||
.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; }
|
||||
""",
|
||||
)
|
||||
|
||||
@@ -363,6 +373,48 @@ def gen_shapes():
|
||||
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)
|
||||
@@ -431,17 +483,15 @@ def gen_currency():
|
||||
return deck, media_files, len(combos)
|
||||
|
||||
|
||||
if args.deck == "shapes":
|
||||
deck_key = "shapes"
|
||||
elif args.deck == "clocks":
|
||||
deck_key = "clocks"
|
||||
else:
|
||||
deck_key = "currency"
|
||||
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, "clocks": gen_clocks, "currency": gen_currency}
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user