mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-07-04 13:43:02 +02:00
Consolidate all Anki deck generators into python_pkg/anki_decks/
Move warsaw_bridges, warsaw_districts, warsaw_landmarks, warsaw_metro, warsaw_osiedla, warsaw_streets, and car_brand_logos into the existing python_pkg/anki_decks/ directory alongside the polish_* generators. Also move preview_all.html into anki_decks/. Update all import paths, filesystem references in geo_data.py, docstrings, READMEs, test imports, and .gitignore accordingly.
This commit is contained in:
parent
2de7801b13
commit
fe2c6628e2
1
python_pkg/anki_decks/__init__.py
Normal file
1
python_pkg/anki_decks/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Anki flashcard deck generators."""
|
||||
@ -0,0 +1 @@
|
||||
"""Polish coastal features Anki generator."""
|
||||
@ -0,0 +1,333 @@
|
||||
"""Anki flashcard generator for Polish coastal features.
|
||||
|
||||
Generates Anki-compatible flashcard decks with maps showing coastal features
|
||||
(peninsulas, cliffs, beaches, etc.) highlighted on a Poland map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
import multiprocessing as mp
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import genanki
|
||||
import geopandas as gpd
|
||||
import matplotlib as mpl
|
||||
|
||||
mpl.use("Agg") # Non-interactive backend for multiprocessing
|
||||
import matplotlib.pyplot as plt
|
||||
from shapely.geometry import LineString, MultiLineString, MultiPolygon, Polygon
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from geo_data import get_poland_boundary, get_polish_coastal_features
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
FEATURE_COLOR_POLYGON = "#D4AC0D" # Gold for polygon features
|
||||
FEATURE_COLOR_LINE = "#D4AC0D" # Gold for line features
|
||||
LINE_WIDTH = 4
|
||||
|
||||
|
||||
def create_coastal_map(
|
||||
feature_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
) -> Figure:
|
||||
"""Create a map showing Poland with one coastal feature highlighted."""
|
||||
fig, ax = plt.subplots(figsize=(10, 12))
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.patch.set_alpha(0)
|
||||
ax.patch.set_alpha(0)
|
||||
|
||||
# Plot Poland as a plain gray shape
|
||||
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
|
||||
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
|
||||
|
||||
# Plot the feature based on geometry type
|
||||
geom = feature_gdf.iloc[0].geometry
|
||||
if isinstance(geom, Polygon | MultiPolygon):
|
||||
feature_gdf.plot(ax=ax, color=FEATURE_COLOR_POLYGON, alpha=0.9)
|
||||
feature_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=3)
|
||||
elif isinstance(geom, LineString | MultiLineString):
|
||||
feature_gdf.plot(ax=ax, color=FEATURE_COLOR_LINE, linewidth=LINE_WIDTH)
|
||||
|
||||
# Set bounds to Poland
|
||||
bounds = poland_boundary.total_bounds
|
||||
ax.set_xlim(bounds[0], bounds[2])
|
||||
ax.set_ylim(bounds[1], bounds[3])
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_coastal_image_bytes(
|
||||
feature_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
) -> bytes:
|
||||
"""Generate a coastal feature map image as bytes."""
|
||||
fig = create_coastal_map(feature_gdf, poland_boundary)
|
||||
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
return buf.read()
|
||||
|
||||
|
||||
# Global variables for multiprocessing (set via initializer)
|
||||
_mp_poland_boundary: gpd.GeoDataFrame | None = None
|
||||
|
||||
|
||||
def _init_worker(poland_geojson: str) -> None:
|
||||
"""Initialize worker process with shared data."""
|
||||
global _mp_poland_boundary # noqa: PLW0603
|
||||
_mp_poland_boundary = gpd.read_file(poland_geojson)
|
||||
|
||||
|
||||
def _render_single_feature(args: tuple[str, str]) -> tuple[str, bytes]:
|
||||
"""Render a single feature image (worker function).
|
||||
|
||||
Args:
|
||||
args: Tuple of (feature_name, feature_geojson_str).
|
||||
|
||||
Returns:
|
||||
Tuple of (feature_name, image_bytes).
|
||||
"""
|
||||
feature_name, feature_geojson = args
|
||||
feature_gdf = gpd.read_file(feature_geojson)
|
||||
|
||||
assert _mp_poland_boundary is not None # noqa: S101
|
||||
|
||||
image_data = generate_coastal_image_bytes(feature_gdf, _mp_poland_boundary)
|
||||
return feature_name, image_data
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
features: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
deck_name: str = "Polish Coastal Features",
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package for Polish coastal features."""
|
||||
model_id_hash = hashlib.md5( # noqa: S324
|
||||
f"polish_coastal_features_{deck_name}".encode()
|
||||
)
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.answer-text {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.card.night_mode .answer-text {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
.info-text {
|
||||
font-size: 18px;
|
||||
color: #7F8C8D;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.card.night_mode .info-text {
|
||||
color: #BDC3C7;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Polish Coastal Feature Model",
|
||||
fields=[
|
||||
{"name": "FeatureMap"},
|
||||
{"name": "FeatureName"},
|
||||
{"name": "FeatureType"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": '<div class="map-container">{{FeatureMap}}</div>',
|
||||
"afmt": '<div class="map-container">{{FeatureMap}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer-text">{{FeatureName}}</div>'
|
||||
'<div class="info-text">{{FeatureType}}</div>',
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
media_files = []
|
||||
|
||||
# Prepare data for parallel processing
|
||||
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
|
||||
poland_boundary.to_file(f.name, driver="GeoJSON")
|
||||
poland_geojson_path = f.name
|
||||
|
||||
# Prepare work items: (feature_name, feature_geojson_str)
|
||||
work_items: list[tuple[str, str]] = []
|
||||
for _, row in features.iterrows():
|
||||
feature_gdf = gpd.GeoDataFrame([row], crs=features.crs)
|
||||
feature_geojson = feature_gdf.to_json()
|
||||
work_items.append((row["name"], feature_geojson))
|
||||
|
||||
# Use multiprocessing for parallel rendering
|
||||
num_workers = min(mp.cpu_count(), 8)
|
||||
sys.stdout.write(
|
||||
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
|
||||
)
|
||||
|
||||
results: dict[str, bytes] = {}
|
||||
with mp.Pool(
|
||||
num_workers,
|
||||
initializer=_init_worker,
|
||||
initargs=(poland_geojson_path,),
|
||||
) as pool:
|
||||
for i, (feature_name, image_data) in enumerate(
|
||||
pool.imap_unordered(_render_single_feature, work_items)
|
||||
):
|
||||
results[feature_name] = image_data
|
||||
if (i + 1) % 10 == 0:
|
||||
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
|
||||
|
||||
# Clean up temp file
|
||||
Path(poland_geojson_path).unlink(missing_ok=True)
|
||||
|
||||
# Create notes from results
|
||||
for _, row in features.iterrows():
|
||||
feature_name = row["name"]
|
||||
feature_type = row.get("type", "coastal feature")
|
||||
image_data = results[feature_name]
|
||||
filename = f"coastal_{feature_name.replace(' ', '_').replace('/', '_')}.png"
|
||||
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[f'<img src="{filename}">', feature_name, feature_type],
|
||||
tags=["geography", "poland", "coastal", "baltic"],
|
||||
)
|
||||
my_deck.add_note(note)
|
||||
|
||||
temp_path = Path(f"/tmp/{filename}") # noqa: S108
|
||||
temp_path.write_bytes(image_data)
|
||||
media_files.append(str(temp_path))
|
||||
|
||||
package = genanki.Package(my_deck)
|
||||
package.media_files = media_files
|
||||
return package
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Polish coastal features.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: polish_coastal_features.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Polish Coastal Features",
|
||||
help="Name for the Anki deck",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
"-p",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Export preview images to specified directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview-count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of preview images to export (default: 5)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
output_path = (
|
||||
Path(args.output) if args.output else Path("polish_coastal_features.apkg")
|
||||
)
|
||||
|
||||
try:
|
||||
sys.stdout.write("Loading coastal features data...\n")
|
||||
features = get_polish_coastal_features()
|
||||
poland_boundary = get_poland_boundary()
|
||||
num_features = len(features)
|
||||
|
||||
sys.stdout.write(f"Found {num_features} coastal features.\n")
|
||||
sys.stdout.write("Generating flashcards...\n")
|
||||
|
||||
package = generate_anki_package(features, poland_boundary, args.deck_name)
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
# Export preview images if requested
|
||||
if args.preview:
|
||||
preview_dir = Path(args.preview)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_features = list(features.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_features)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_features:
|
||||
feature_name = row["name"]
|
||||
feature_gdf = gpd.GeoDataFrame([row], crs=features.crs)
|
||||
image_data = generate_coastal_image_bytes(feature_gdf, poland_boundary)
|
||||
safe_name = feature_name.replace(" ", "_").replace("/", "_")
|
||||
preview_path = preview_dir / f"{safe_name}.png"
|
||||
preview_path.write_bytes(image_data)
|
||||
sys.stdout.write(f" Saved: {preview_path.name}\n")
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write(f"Coastal features: {num_features}\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
if args.preview:
|
||||
sys.stdout.write(f"Preview images: {args.preview}\n")
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
6
python_pkg/anki_decks/polish_coastal_features/run.sh
Executable file
6
python_pkg/anki_decks/polish_coastal_features/run.sh
Executable file
@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Run the Polish coastal features Anki generator
|
||||
|
||||
cd "$(dirname "$0")" || exit
|
||||
|
||||
python polish_coastal_features_anki.py --preview preview_images --preview-count 5 "$@"
|
||||
1
python_pkg/anki_decks/polish_forests/__init__.py
Normal file
1
python_pkg/anki_decks/polish_forests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Polish forests Anki generator."""
|
||||
322
python_pkg/anki_decks/polish_forests/polish_forests_anki.py
Normal file
322
python_pkg/anki_decks/polish_forests/polish_forests_anki.py
Normal file
@ -0,0 +1,322 @@
|
||||
"""Anki flashcard generator for Polish forests (puszcze).
|
||||
|
||||
Generates Anki-compatible flashcard decks with maps showing large forests
|
||||
highlighted on a Poland map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
import multiprocessing as mp
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import genanki
|
||||
import geopandas as gpd
|
||||
import matplotlib as mpl
|
||||
|
||||
mpl.use("Agg") # Non-interactive backend for multiprocessing
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from geo_data import get_poland_boundary, get_polish_forests
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
FOREST_COLOR = "#1D4E2B" # Dark green for forests
|
||||
|
||||
|
||||
def create_forest_map(
|
||||
forest_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
) -> Figure:
|
||||
"""Create a map showing Poland with one forest highlighted."""
|
||||
fig, ax = plt.subplots(figsize=(10, 12))
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.patch.set_alpha(0)
|
||||
ax.patch.set_alpha(0)
|
||||
|
||||
# Plot Poland as a plain gray shape
|
||||
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
|
||||
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
|
||||
|
||||
# Plot the forest
|
||||
forest_gdf.plot(ax=ax, color=FOREST_COLOR, alpha=0.9)
|
||||
forest_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=1.5)
|
||||
|
||||
# Set bounds to Poland
|
||||
bounds = poland_boundary.total_bounds
|
||||
ax.set_xlim(bounds[0], bounds[2])
|
||||
ax.set_ylim(bounds[1], bounds[3])
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_forest_image_bytes(
|
||||
forest_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
) -> bytes:
|
||||
"""Generate a forest map image as bytes."""
|
||||
fig = create_forest_map(forest_gdf, poland_boundary)
|
||||
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
return buf.read()
|
||||
|
||||
|
||||
# Global variables for multiprocessing (set via initializer)
|
||||
_mp_poland_boundary: gpd.GeoDataFrame | None = None
|
||||
|
||||
|
||||
def _init_worker(poland_geojson: str) -> None:
|
||||
"""Initialize worker process with shared data."""
|
||||
global _mp_poland_boundary # noqa: PLW0603
|
||||
_mp_poland_boundary = gpd.read_file(poland_geojson)
|
||||
|
||||
|
||||
def _render_single_forest(args: tuple[str, str]) -> tuple[str, bytes]:
|
||||
"""Render a single forest image (worker function).
|
||||
|
||||
Args:
|
||||
args: Tuple of (forest_name, forest_geojson_str).
|
||||
|
||||
Returns:
|
||||
Tuple of (forest_name, image_bytes).
|
||||
"""
|
||||
forest_name, forest_geojson = args
|
||||
forest_gdf = gpd.read_file(forest_geojson)
|
||||
|
||||
assert _mp_poland_boundary is not None # noqa: S101
|
||||
|
||||
image_data = generate_forest_image_bytes(forest_gdf, _mp_poland_boundary)
|
||||
return forest_name, image_data
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
forests: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
deck_name: str = "Polish Forests (Puszcze)",
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package for Polish forests."""
|
||||
model_id_hash = hashlib.md5(f"polish_forests_{deck_name}".encode()) # noqa: S324
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.answer-text {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.card.night_mode .answer-text {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
.info-text {
|
||||
font-size: 18px;
|
||||
color: #7F8C8D;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.card.night_mode .info-text {
|
||||
color: #BDC3C7;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Polish Forest Model",
|
||||
fields=[
|
||||
{"name": "ForestMap"},
|
||||
{"name": "ForestName"},
|
||||
{"name": "Area"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": '<div class="map-container">{{ForestMap}}</div>',
|
||||
"afmt": '<div class="map-container">{{ForestMap}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer-text">{{ForestName}}</div>'
|
||||
'<div class="info-text">{{Area}} km²</div>',
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
media_files = []
|
||||
|
||||
# Prepare data for parallel processing
|
||||
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
|
||||
poland_boundary.to_file(f.name, driver="GeoJSON")
|
||||
poland_geojson_path = f.name
|
||||
|
||||
# Prepare work items: (forest_name, forest_geojson_str)
|
||||
work_items: list[tuple[str, str]] = []
|
||||
for _, row in forests.iterrows():
|
||||
forest_gdf = gpd.GeoDataFrame([row], crs=forests.crs)
|
||||
forest_geojson = forest_gdf.to_json()
|
||||
work_items.append((row["name"], forest_geojson))
|
||||
|
||||
# Use multiprocessing for parallel rendering
|
||||
num_workers = min(mp.cpu_count(), 8)
|
||||
sys.stdout.write(
|
||||
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
|
||||
)
|
||||
|
||||
results: dict[str, bytes] = {}
|
||||
with mp.Pool(
|
||||
num_workers,
|
||||
initializer=_init_worker,
|
||||
initargs=(poland_geojson_path,),
|
||||
) as pool:
|
||||
for i, (forest_name, image_data) in enumerate(
|
||||
pool.imap_unordered(_render_single_forest, work_items)
|
||||
):
|
||||
results[forest_name] = image_data
|
||||
if (i + 1) % 10 == 0:
|
||||
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
|
||||
|
||||
# Clean up temp file
|
||||
Path(poland_geojson_path).unlink(missing_ok=True)
|
||||
|
||||
# Create notes from results
|
||||
for _, row in forests.iterrows():
|
||||
forest_name = row["name"]
|
||||
area_km2 = round(row["area_km2"], 1) if "area_km2" in row else 0
|
||||
image_data = results[forest_name]
|
||||
filename = f"forest_{forest_name.replace(' ', '_').replace('/', '_')}.png"
|
||||
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[f'<img src="{filename}">', forest_name, str(area_km2)],
|
||||
tags=["geography", "poland", "forests", "puszcza", "nature"],
|
||||
)
|
||||
my_deck.add_note(note)
|
||||
|
||||
temp_path = Path(f"/tmp/{filename}") # noqa: S108
|
||||
temp_path.write_bytes(image_data)
|
||||
media_files.append(str(temp_path))
|
||||
|
||||
package = genanki.Package(my_deck)
|
||||
package.media_files = media_files
|
||||
return package
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Polish forests.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: polish_forests.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Polish Forests (Puszcze)",
|
||||
help="Name for the Anki deck",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
"-p",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Export preview images to specified directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview-count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of preview images to export (default: 5)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
output_path = Path(args.output) if args.output else Path("polish_forests.apkg")
|
||||
|
||||
try:
|
||||
sys.stdout.write("Loading forests data...\n")
|
||||
forests = get_polish_forests()
|
||||
poland_boundary = get_poland_boundary()
|
||||
num_forests = len(forests)
|
||||
|
||||
sys.stdout.write(f"Found {num_forests} forests.\n")
|
||||
sys.stdout.write("Generating flashcards...\n")
|
||||
|
||||
package = generate_anki_package(forests, poland_boundary, args.deck_name)
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
# Export preview images if requested
|
||||
if args.preview:
|
||||
preview_dir = Path(args.preview)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_forests = list(forests.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_forests)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_forests:
|
||||
forest_name = row["name"]
|
||||
forest_gdf = gpd.GeoDataFrame([row], crs=forests.crs)
|
||||
image_data = generate_forest_image_bytes(forest_gdf, poland_boundary)
|
||||
safe_name = forest_name.replace(" ", "_").replace("/", "_")
|
||||
preview_path = preview_dir / f"{safe_name}.png"
|
||||
preview_path.write_bytes(image_data)
|
||||
sys.stdout.write(f" Saved: {preview_path.name}\n")
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write(f"Forests: {num_forests}\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
if args.preview:
|
||||
sys.stdout.write(f"Preview images: {args.preview}\n")
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
6
python_pkg/anki_decks/polish_forests/run.sh
Executable file
6
python_pkg/anki_decks/polish_forests/run.sh
Executable file
@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Run the Polish forests Anki generator
|
||||
|
||||
cd "$(dirname "$0")" || exit
|
||||
|
||||
python polish_forests_anki.py --preview preview_images --preview-count 5 "$@"
|
||||
1
python_pkg/anki_decks/polish_gminy/__init__.py
Normal file
1
python_pkg/anki_decks/polish_gminy/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Polish gminy (municipalities) Anki flashcard generator."""
|
||||
404
python_pkg/anki_decks/polish_gminy/polish_gminy_anki.py
Executable file
404
python_pkg/anki_decks/polish_gminy/polish_gminy_anki.py
Executable file
@ -0,0 +1,404 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Anki flashcard generator for Polish gminy (municipalities).
|
||||
|
||||
Generates Anki-compatible flashcard decks with maps showing individual
|
||||
Polish municipalities highlighted on a country map.
|
||||
|
||||
Uses multiprocessing to parallelize image generation for ~4x speedup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
import multiprocessing as mp
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import genanki
|
||||
import geopandas as gpd
|
||||
import matplotlib as mpl
|
||||
|
||||
mpl.use("Agg") # Non-interactive backend for multiprocessing
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from geo_data import get_poland_boundary, get_polish_gminy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
# 2500 colors for gminy (cycling through)
|
||||
GMINA_COLORS = [
|
||||
"#E74C3C",
|
||||
"#3498DB",
|
||||
"#2ECC71",
|
||||
"#9B59B6",
|
||||
"#F39C12",
|
||||
"#1ABC9C",
|
||||
"#E91E63",
|
||||
"#00BCD4",
|
||||
"#8BC34A",
|
||||
"#FF5722",
|
||||
"#673AB7",
|
||||
"#FFEB3B",
|
||||
"#795548",
|
||||
"#607D8B",
|
||||
"#CDDC39",
|
||||
"#FF9800",
|
||||
"#4CAF50",
|
||||
"#03A9F4",
|
||||
"#F44336",
|
||||
"#009688",
|
||||
"#3F51B5",
|
||||
"#FFC107",
|
||||
"#9E9E9E",
|
||||
"#00E676",
|
||||
"#FF4081",
|
||||
"#448AFF",
|
||||
"#69F0AE",
|
||||
"#FFD740",
|
||||
"#40C4FF",
|
||||
"#B388FF",
|
||||
"#EA80FC",
|
||||
"#82B1FF",
|
||||
"#A7FFEB",
|
||||
"#FFFF8D",
|
||||
"#FF80AB",
|
||||
"#536DFE",
|
||||
"#64FFDA",
|
||||
"#FFE57F",
|
||||
"#80D8FF",
|
||||
"#B9F6CA",
|
||||
"#CF6679",
|
||||
"#BB86FC",
|
||||
"#03DAC6",
|
||||
"#018786",
|
||||
"#6200EE",
|
||||
"#3700B3",
|
||||
"#B00020",
|
||||
"#FF0266",
|
||||
"#C51162",
|
||||
"#AA00FF",
|
||||
]
|
||||
|
||||
|
||||
def create_gmina_map(
|
||||
gmina_name: str,
|
||||
gmina_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
color_map: dict[str, str],
|
||||
) -> Figure:
|
||||
"""Create a map showing Poland with one gmina highlighted."""
|
||||
fig, ax = plt.subplots(figsize=(10, 12))
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.patch.set_alpha(0)
|
||||
ax.patch.set_alpha(0)
|
||||
|
||||
# Plot Poland as a plain gray shape
|
||||
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
|
||||
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
|
||||
|
||||
# Get pre-computed color
|
||||
fill_color = color_map.get(gmina_name, GMINA_COLORS[0])
|
||||
|
||||
# Plot the highlighted gmina
|
||||
gmina_gdf.plot(ax=ax, color=fill_color, alpha=0.9)
|
||||
gmina_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=1.5)
|
||||
|
||||
# Set bounds to Poland
|
||||
bounds = poland_boundary.total_bounds
|
||||
ax.set_xlim(bounds[0], bounds[2])
|
||||
ax.set_ylim(bounds[1], bounds[3])
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_gmina_image_bytes(
|
||||
gmina_name: str,
|
||||
gmina_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
color_map: dict[str, str],
|
||||
) -> bytes:
|
||||
"""Generate a gmina map image as bytes."""
|
||||
fig = create_gmina_map(gmina_name, gmina_gdf, poland_boundary, color_map)
|
||||
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
return buf.read()
|
||||
|
||||
|
||||
def _build_color_map(names: list[str]) -> dict[str, str]:
|
||||
"""Pre-compute color mapping for all names.
|
||||
|
||||
Args:
|
||||
names: List of all gmina names.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping name to color.
|
||||
"""
|
||||
sorted_names = sorted(names)
|
||||
return {
|
||||
name: GMINA_COLORS[i % len(GMINA_COLORS)] for i, name in enumerate(sorted_names)
|
||||
}
|
||||
|
||||
|
||||
# Global variables for multiprocessing (set via initializer)
|
||||
_mp_poland_boundary: gpd.GeoDataFrame | None = None
|
||||
_mp_color_map: dict[str, str] | None = None
|
||||
|
||||
|
||||
def _init_worker(
|
||||
poland_geojson: str,
|
||||
color_map: dict[str, str],
|
||||
) -> None:
|
||||
"""Initialize worker process with shared data."""
|
||||
global _mp_poland_boundary, _mp_color_map # noqa: PLW0603
|
||||
_mp_poland_boundary = gpd.read_file(poland_geojson)
|
||||
_mp_color_map = color_map
|
||||
|
||||
|
||||
def _render_single_gmina(args: tuple[str, str]) -> tuple[str, bytes]:
|
||||
"""Render a single gmina image (worker function).
|
||||
|
||||
Args:
|
||||
args: Tuple of (gmina_name, gmina_geojson_str).
|
||||
|
||||
Returns:
|
||||
Tuple of (gmina_name, image_bytes).
|
||||
"""
|
||||
gmina_name, gmina_geojson = args
|
||||
gmina_gdf = gpd.read_file(gmina_geojson)
|
||||
|
||||
assert _mp_poland_boundary is not None # noqa: S101
|
||||
assert _mp_color_map is not None # noqa: S101
|
||||
|
||||
image_data = generate_gmina_image_bytes(
|
||||
gmina_name, gmina_gdf, _mp_poland_boundary, _mp_color_map
|
||||
)
|
||||
return gmina_name, image_data
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
gminy: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
deck_name: str = "Polish Gminy",
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package for Polish gminy."""
|
||||
model_id_hash = hashlib.md5(f"polish_gminy_{deck_name}".encode()) # noqa: S324
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.answer-text {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.card.night_mode .answer-text {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Polish Gmina Model",
|
||||
fields=[
|
||||
{"name": "GminaMap"},
|
||||
{"name": "GminaName"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": '<div class="map-container">{{GminaMap}}</div>',
|
||||
"afmt": '<div class="map-container">{{GminaMap}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer-text">{{GminaName}}</div>',
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
media_files = []
|
||||
|
||||
# Pre-compute color mapping once (avoids O(n²) sorting)
|
||||
color_map = _build_color_map(gminy["name"].tolist())
|
||||
|
||||
# Prepare data for parallel processing
|
||||
# Serialize GeoDataFrames to GeoJSON strings for pickling
|
||||
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
|
||||
poland_boundary.to_file(f.name, driver="GeoJSON")
|
||||
poland_geojson_path = f.name
|
||||
|
||||
# Prepare work items: (gmina_name, gmina_geojson_str)
|
||||
work_items: list[tuple[str, str]] = []
|
||||
for _, row in gminy.iterrows():
|
||||
gmina_gdf = gpd.GeoDataFrame([row], crs=gminy.crs)
|
||||
gmina_geojson = gmina_gdf.to_json()
|
||||
work_items.append((row["name"], gmina_geojson))
|
||||
|
||||
# Use multiprocessing for parallel rendering
|
||||
num_workers = min(mp.cpu_count(), 8)
|
||||
sys.stdout.write(
|
||||
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
|
||||
)
|
||||
|
||||
results: dict[str, bytes] = {}
|
||||
with mp.Pool(
|
||||
num_workers,
|
||||
initializer=_init_worker,
|
||||
initargs=(poland_geojson_path, color_map),
|
||||
) as pool:
|
||||
for i, (gmina_name, image_data) in enumerate(
|
||||
pool.imap_unordered(_render_single_gmina, work_items)
|
||||
):
|
||||
results[gmina_name] = image_data
|
||||
if (i + 1) % 100 == 0:
|
||||
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
|
||||
|
||||
# Clean up temp file
|
||||
Path(poland_geojson_path).unlink(missing_ok=True)
|
||||
|
||||
# Create notes from results
|
||||
for _, row in gminy.iterrows():
|
||||
gmina_name = row["name"]
|
||||
image_data = results[gmina_name]
|
||||
filename = f"gmina_{gmina_name.replace(' ', '_').replace('/', '_')}.png"
|
||||
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[f'<img src="{filename}">', gmina_name],
|
||||
tags=["geography", "poland", "gminy"],
|
||||
)
|
||||
my_deck.add_note(note)
|
||||
|
||||
temp_path = Path(f"/tmp/{filename}") # noqa: S108
|
||||
temp_path.write_bytes(image_data)
|
||||
media_files.append(str(temp_path))
|
||||
|
||||
package = genanki.Package(my_deck)
|
||||
package.media_files = media_files
|
||||
return package
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Polish gminy.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: polish_gminy.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Polish Gminy",
|
||||
help="Name for the Anki deck",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
"-p",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Export preview images to specified directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview-count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of preview images to export (default: 5)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
output_path = Path(args.output) if args.output else Path("polish_gminy.apkg")
|
||||
|
||||
try:
|
||||
sys.stdout.write("Loading gminy data...\n")
|
||||
gminy = get_polish_gminy()
|
||||
poland_boundary = get_poland_boundary()
|
||||
num_gminy = len(gminy)
|
||||
|
||||
sys.stdout.write(f"Generating flashcards for {num_gminy} gminy...\n")
|
||||
sys.stdout.write("This will take a while for ~2500 gminy...\n")
|
||||
|
||||
package = generate_anki_package(gminy, poland_boundary, args.deck_name)
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
# Export preview images if requested
|
||||
if args.preview:
|
||||
preview_dir = Path(args.preview)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_gminy = list(gminy.iterrows())[: args.preview_count]
|
||||
# Pre-compute color mapping for previews
|
||||
color_map = _build_color_map(gminy["name"].tolist())
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_gminy)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_gminy:
|
||||
gmina_name = row["name"]
|
||||
gmina_gdf = gpd.GeoDataFrame([row], crs=gminy.crs)
|
||||
image_data = generate_gmina_image_bytes(
|
||||
gmina_name, gmina_gdf, poland_boundary, color_map
|
||||
)
|
||||
safe_name = gmina_name.replace(" ", "_").replace("/", "_")
|
||||
preview_path = preview_dir / f"{safe_name}.png"
|
||||
preview_path.write_bytes(image_data)
|
||||
sys.stdout.write(f" Saved: {preview_path.name}\n")
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write(f"Gminy: {num_gminy}\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
if args.preview:
|
||||
sys.stdout.write(f"Preview images: {args.preview}\n")
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
36
python_pkg/anki_decks/polish_gminy/run.sh
Executable file
36
python_pkg/anki_decks/polish_gminy/run.sh
Executable file
@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Script to generate Polish Gminy Anki deck
|
||||
# WARNING: This will take a long time (~2500 gminy)
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||
PREVIEW_DIR="$SCRIPT_DIR/preview_images"
|
||||
|
||||
echo "=== Polish Gminy Anki Generator ==="
|
||||
echo "WARNING: This may take a very long time (fetching ~2500 gminy)"
|
||||
echo
|
||||
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
echo "Activating virtual environment..."
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
echo "Installing dependencies..."
|
||||
pip install --quiet --upgrade pip
|
||||
pip install --quiet matplotlib genanki geopandas requests shapely
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Create preview images directory
|
||||
mkdir -p "$PREVIEW_DIR"
|
||||
|
||||
python -m polish_gminy_anki --output polish_gminy.apkg --preview "$PREVIEW_DIR" --preview-count 5
|
||||
|
||||
echo
|
||||
echo "Done! The Anki deck is at: $SCRIPT_DIR/polish_gminy.apkg"
|
||||
echo "Preview images are in: $PREVIEW_DIR"
|
||||
1
python_pkg/anki_decks/polish_islands/__init__.py
Normal file
1
python_pkg/anki_decks/polish_islands/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Polish islands Anki generator."""
|
||||
410
python_pkg/anki_decks/polish_islands/polish_islands_anki.py
Normal file
410
python_pkg/anki_decks/polish_islands/polish_islands_anki.py
Normal file
@ -0,0 +1,410 @@
|
||||
"""Anki flashcard generator for Polish islands.
|
||||
|
||||
Generates Anki-compatible flashcard decks with maps showing islands
|
||||
highlighted on a Poland map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
import multiprocessing as mp
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import genanki
|
||||
import geopandas as gpd
|
||||
import matplotlib as mpl
|
||||
|
||||
mpl.use("Agg") # Non-interactive backend for multiprocessing
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from geo_data import get_poland_boundary, get_polish_islands
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
ISLAND_COLOR = "#E67E22" # Orange for islands
|
||||
NEIGHBOR_COLOR = "#EAECEE" # Lighter gray for extended view
|
||||
|
||||
# Padding for zoom (in degrees)
|
||||
ZOOM_PADDING_DEG = 0.2
|
||||
|
||||
|
||||
def _island_extends_beyond(
|
||||
island_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
) -> bool:
|
||||
"""Check if island extends beyond Poland's boundaries."""
|
||||
poland_bounds = poland_boundary.total_bounds # [minx, miny, maxx, maxy]
|
||||
island_bounds = island_gdf.total_bounds
|
||||
|
||||
# Check if any part of island is outside Poland
|
||||
extends_west = island_bounds[0] < poland_bounds[0]
|
||||
extends_south = island_bounds[1] < poland_bounds[1]
|
||||
extends_east = island_bounds[2] > poland_bounds[2]
|
||||
extends_north = island_bounds[3] > poland_bounds[3]
|
||||
|
||||
return extends_west or extends_south or extends_east or extends_north
|
||||
|
||||
|
||||
def create_island_map(
|
||||
island_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
*,
|
||||
zoom: bool,
|
||||
) -> Figure:
|
||||
"""Create a map showing Poland with one island highlighted.
|
||||
|
||||
Args:
|
||||
island_gdf: GeoDataFrame with the island to highlight.
|
||||
poland_boundary: GeoDataFrame with Poland's boundary.
|
||||
zoom: If True, zoom to island area for better visibility.
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=(10, 12))
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.patch.set_alpha(0)
|
||||
ax.patch.set_alpha(0)
|
||||
|
||||
extends_beyond = _island_extends_beyond(island_gdf, poland_boundary)
|
||||
|
||||
if extends_beyond:
|
||||
# Draw extended background if island goes beyond Poland
|
||||
island_bounds = island_gdf.total_bounds
|
||||
padding = 0.5
|
||||
ax.fill(
|
||||
[
|
||||
island_bounds[0] - padding,
|
||||
island_bounds[2] + padding,
|
||||
island_bounds[2] + padding,
|
||||
island_bounds[0] - padding,
|
||||
],
|
||||
[
|
||||
island_bounds[1] - padding,
|
||||
island_bounds[1] - padding,
|
||||
island_bounds[3] + padding,
|
||||
island_bounds[3] + padding,
|
||||
],
|
||||
color=NEIGHBOR_COLOR,
|
||||
zorder=0,
|
||||
)
|
||||
|
||||
# Plot Poland as a plain gray shape
|
||||
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
|
||||
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
|
||||
|
||||
# Plot the island with thinner lines
|
||||
island_gdf.plot(ax=ax, color=ISLAND_COLOR, alpha=0.9)
|
||||
island_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=1.5)
|
||||
|
||||
# Set bounds based on zoom mode and whether island extends beyond
|
||||
if zoom:
|
||||
# Zoom to island area with padding
|
||||
island_bounds = island_gdf.total_bounds
|
||||
ax.set_xlim(
|
||||
island_bounds[0] - ZOOM_PADDING_DEG,
|
||||
island_bounds[2] + ZOOM_PADDING_DEG,
|
||||
)
|
||||
ax.set_ylim(
|
||||
island_bounds[1] - ZOOM_PADDING_DEG,
|
||||
island_bounds[3] + ZOOM_PADDING_DEG,
|
||||
)
|
||||
elif extends_beyond:
|
||||
# Include the full island in view
|
||||
island_bounds = island_gdf.total_bounds
|
||||
poland_bounds = poland_boundary.total_bounds
|
||||
ax.set_xlim(
|
||||
min(poland_bounds[0], island_bounds[0] - 0.1),
|
||||
max(poland_bounds[2], island_bounds[2] + 0.1),
|
||||
)
|
||||
ax.set_ylim(
|
||||
min(poland_bounds[1], island_bounds[1] - 0.1),
|
||||
max(poland_bounds[3], island_bounds[3] + 0.1),
|
||||
)
|
||||
else:
|
||||
# Normal Poland bounds
|
||||
bounds = poland_boundary.total_bounds
|
||||
ax.set_xlim(bounds[0], bounds[2])
|
||||
ax.set_ylim(bounds[1], bounds[3])
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_island_image_bytes(
|
||||
island_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
*,
|
||||
zoom: bool,
|
||||
) -> bytes:
|
||||
"""Generate an island map image as bytes."""
|
||||
fig = create_island_map(island_gdf, poland_boundary, zoom=zoom)
|
||||
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
return buf.read()
|
||||
|
||||
|
||||
# Global variables for multiprocessing (set via initializer)
|
||||
_mp_poland_boundary: gpd.GeoDataFrame | None = None
|
||||
_mp_zoom_mode: str = "no-zoom"
|
||||
|
||||
|
||||
def _init_worker(poland_geojson: str, zoom_mode: str) -> None:
|
||||
"""Initialize worker process with shared data."""
|
||||
global _mp_poland_boundary, _mp_zoom_mode # noqa: PLW0603
|
||||
_mp_poland_boundary = gpd.read_file(poland_geojson)
|
||||
_mp_zoom_mode = zoom_mode
|
||||
|
||||
|
||||
def _render_single_island(args: tuple[str, str]) -> tuple[str, bytes]:
|
||||
"""Render a single island image (worker function).
|
||||
|
||||
Args:
|
||||
args: Tuple of (island_name, island_geojson_str).
|
||||
|
||||
Returns:
|
||||
Tuple of (island_name, image_bytes).
|
||||
"""
|
||||
island_name, island_geojson = args
|
||||
island_gdf = gpd.read_file(island_geojson)
|
||||
|
||||
assert _mp_poland_boundary is not None # noqa: S101
|
||||
|
||||
image_data = generate_island_image_bytes(
|
||||
island_gdf, _mp_poland_boundary, zoom=(_mp_zoom_mode == "zoom")
|
||||
)
|
||||
return island_name, image_data
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
islands: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
deck_name: str = "Polish Islands",
|
||||
*,
|
||||
zoom: bool = True,
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package for Polish islands."""
|
||||
model_id_hash = hashlib.md5(f"polish_islands_{deck_name}".encode()) # noqa: S324
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.answer-text {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.card.night_mode .answer-text {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
.info-text {
|
||||
font-size: 18px;
|
||||
color: #7F8C8D;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.card.night_mode .info-text {
|
||||
color: #BDC3C7;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Polish Island Model",
|
||||
fields=[
|
||||
{"name": "IslandMap"},
|
||||
{"name": "IslandName"},
|
||||
{"name": "Area"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": '<div class="map-container">{{IslandMap}}</div>',
|
||||
"afmt": '<div class="map-container">{{IslandMap}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer-text">{{IslandName}}</div>'
|
||||
'<div class="info-text">{{Area}} km²</div>',
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
media_files = []
|
||||
|
||||
# Prepare data for parallel processing
|
||||
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
|
||||
poland_boundary.to_file(f.name, driver="GeoJSON")
|
||||
poland_geojson_path = f.name
|
||||
|
||||
# Prepare work items: (island_name, island_geojson_str)
|
||||
work_items: list[tuple[str, str]] = []
|
||||
for _, row in islands.iterrows():
|
||||
island_gdf = gpd.GeoDataFrame([row], crs=islands.crs)
|
||||
island_geojson = island_gdf.to_json()
|
||||
work_items.append((row["name"], island_geojson))
|
||||
|
||||
# Use multiprocessing for parallel rendering
|
||||
zoom_mode = "zoom" if zoom else "no-zoom"
|
||||
num_workers = min(mp.cpu_count(), 8)
|
||||
sys.stdout.write(
|
||||
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
|
||||
)
|
||||
|
||||
results: dict[str, bytes] = {}
|
||||
with mp.Pool(
|
||||
num_workers,
|
||||
initializer=_init_worker,
|
||||
initargs=(poland_geojson_path, zoom_mode),
|
||||
) as pool:
|
||||
for i, (island_name, image_data) in enumerate(
|
||||
pool.imap_unordered(_render_single_island, work_items)
|
||||
):
|
||||
results[island_name] = image_data
|
||||
if (i + 1) % 10 == 0:
|
||||
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
|
||||
|
||||
# Clean up temp file
|
||||
Path(poland_geojson_path).unlink(missing_ok=True)
|
||||
|
||||
# Create notes from results
|
||||
for _, row in islands.iterrows():
|
||||
island_name = row["name"]
|
||||
area_km2 = round(row["area_km2"], 1) if "area_km2" in row else 0
|
||||
image_data = results[island_name]
|
||||
filename = f"island_{island_name.replace(' ', '_').replace('/', '_')}.png"
|
||||
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[f'<img src="{filename}">', island_name, str(area_km2)],
|
||||
tags=["geography", "poland", "islands", "coastal"],
|
||||
)
|
||||
my_deck.add_note(note)
|
||||
|
||||
temp_path = Path(f"/tmp/{filename}") # noqa: S108
|
||||
temp_path.write_bytes(image_data)
|
||||
media_files.append(str(temp_path))
|
||||
|
||||
package = genanki.Package(my_deck)
|
||||
package.media_files = media_files
|
||||
return package
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Polish islands.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: polish_islands.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Polish Islands",
|
||||
help="Name for the Anki deck",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
"-p",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Export preview images to specified directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview-count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of preview images to export (default: 5)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
output_path = Path(args.output) if args.output else Path("polish_islands.apkg")
|
||||
|
||||
try:
|
||||
sys.stdout.write("Loading islands data...\n")
|
||||
islands = get_polish_islands()
|
||||
poland_boundary = get_poland_boundary()
|
||||
num_islands = len(islands)
|
||||
|
||||
sys.stdout.write(f"Found {num_islands} islands.\n")
|
||||
sys.stdout.write("Generating flashcards...\n")
|
||||
|
||||
package = generate_anki_package(islands, poland_boundary, args.deck_name)
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
# Export preview images if requested
|
||||
if args.preview:
|
||||
preview_dir = Path(args.preview)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_islands = list(islands.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_islands)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_islands:
|
||||
island_name = row["name"]
|
||||
island_gdf = gpd.GeoDataFrame([row], crs=islands.crs)
|
||||
image_data = generate_island_image_bytes(
|
||||
island_gdf, poland_boundary, zoom=True
|
||||
)
|
||||
safe_name = island_name.replace(" ", "_").replace("/", "_")
|
||||
preview_path = preview_dir / f"{safe_name}.png"
|
||||
preview_path.write_bytes(image_data)
|
||||
sys.stdout.write(f" Saved: {preview_path.name}\n")
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write(f"Islands: {num_islands}\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
if args.preview:
|
||||
sys.stdout.write(f"Preview images: {args.preview}\n")
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
6
python_pkg/anki_decks/polish_islands/run.sh
Executable file
6
python_pkg/anki_decks/polish_islands/run.sh
Executable file
@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Run the Polish islands Anki generator
|
||||
|
||||
cd "$(dirname "$0")" || exit
|
||||
|
||||
python polish_islands_anki.py --preview preview_images --preview-count 5 "$@"
|
||||
1
python_pkg/anki_decks/polish_lakes/__init__.py
Normal file
1
python_pkg/anki_decks/polish_lakes/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Polish lakes Anki deck generator."""
|
||||
362
python_pkg/anki_decks/polish_lakes/polish_lakes_anki.py
Normal file
362
python_pkg/anki_decks/polish_lakes/polish_lakes_anki.py
Normal file
@ -0,0 +1,362 @@
|
||||
"""Anki flashcard generator for Polish lakes.
|
||||
|
||||
Generates Anki-compatible flashcard decks with ZOOMED maps showing lakes
|
||||
highlighted for better visibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
import multiprocessing as mp
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import genanki
|
||||
import geopandas as gpd
|
||||
import matplotlib as mpl
|
||||
|
||||
mpl.use("Agg") # Non-interactive backend for multiprocessing
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from geo_data import get_poland_boundary, get_polish_lakes
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
LAKE_COLOR = "#3498DB" # Blue for lakes
|
||||
ZOOM_PADDING_DEG = 0.3 # Degrees of padding around lake for zoomed view
|
||||
|
||||
|
||||
def create_lake_map(
|
||||
lake_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
*,
|
||||
zoom: bool = True,
|
||||
) -> Figure:
|
||||
"""Create a map showing Poland with one lake highlighted."""
|
||||
fig, ax = plt.subplots(figsize=(10, 12))
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.patch.set_alpha(0)
|
||||
ax.patch.set_alpha(0)
|
||||
|
||||
# Plot Poland as a plain gray shape
|
||||
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
|
||||
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
|
||||
|
||||
# Plot the lake with thinner border
|
||||
lake_gdf.plot(ax=ax, color=LAKE_COLOR, alpha=0.9)
|
||||
lake_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=1.5)
|
||||
|
||||
if zoom:
|
||||
# Zoom to lake area with padding
|
||||
bounds = lake_gdf.total_bounds
|
||||
min_x, min_y, max_x, max_y = bounds
|
||||
# Add padding
|
||||
ax.set_xlim(min_x - ZOOM_PADDING_DEG, max_x + ZOOM_PADDING_DEG)
|
||||
ax.set_ylim(min_y - ZOOM_PADDING_DEG, max_y + ZOOM_PADDING_DEG)
|
||||
else:
|
||||
# Set bounds to Poland
|
||||
bounds = poland_boundary.total_bounds
|
||||
ax.set_xlim(bounds[0], bounds[2])
|
||||
ax.set_ylim(bounds[1], bounds[3])
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_lake_image_bytes(
|
||||
lake_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
*,
|
||||
zoom: bool = True,
|
||||
) -> bytes:
|
||||
"""Generate a lake map image as bytes."""
|
||||
fig = create_lake_map(lake_gdf, poland_boundary, zoom=zoom)
|
||||
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
return buf.read()
|
||||
|
||||
|
||||
# Global variables for multiprocessing (set via initializer)
|
||||
_mp_poland_boundary: gpd.GeoDataFrame | None = None
|
||||
_mp_zoom: bool = True
|
||||
|
||||
|
||||
def _init_worker(poland_geojson: str, zoom_mode: str) -> None:
|
||||
"""Initialize worker process with shared data."""
|
||||
global _mp_poland_boundary, _mp_zoom # noqa: PLW0603
|
||||
_mp_poland_boundary = gpd.read_file(poland_geojson)
|
||||
_mp_zoom = zoom_mode == "zoom"
|
||||
|
||||
|
||||
def _render_single_lake(args: tuple[str, str]) -> tuple[str, bytes]:
|
||||
"""Render a single lake image (worker function).
|
||||
|
||||
Args:
|
||||
args: Tuple of (lake_name, lake_geojson_str).
|
||||
|
||||
Returns:
|
||||
Tuple of (lake_name, image_bytes).
|
||||
"""
|
||||
lake_name, lake_geojson = args
|
||||
lake_gdf = gpd.read_file(lake_geojson)
|
||||
|
||||
assert _mp_poland_boundary is not None # noqa: S101
|
||||
|
||||
image_data = generate_lake_image_bytes(lake_gdf, _mp_poland_boundary, zoom=_mp_zoom)
|
||||
return lake_name, image_data
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
lakes: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
deck_name: str = "Polish Lakes",
|
||||
*,
|
||||
zoom: bool = True,
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package for Polish lakes."""
|
||||
model_id_hash = hashlib.md5(f"polish_lakes_{deck_name}".encode()) # noqa: S324
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.answer-text {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.card.night_mode .answer-text {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
.info-text {
|
||||
font-size: 18px;
|
||||
color: #7F8C8D;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.card.night_mode .info-text {
|
||||
color: #BDC3C7;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Polish Lake Model",
|
||||
fields=[
|
||||
{"name": "LakeMap"},
|
||||
{"name": "LakeName"},
|
||||
{"name": "Area"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": '<div class="map-container">{{LakeMap}}</div>',
|
||||
"afmt": '<div class="map-container">{{LakeMap}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer-text">{{LakeName}}</div>'
|
||||
'<div class="info-text">{{Area}} km²</div>',
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
media_files = []
|
||||
|
||||
# Prepare data for parallel processing
|
||||
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
|
||||
poland_boundary.to_file(f.name, driver="GeoJSON")
|
||||
poland_geojson_path = f.name
|
||||
|
||||
# Prepare work items: (lake_name, lake_geojson_str)
|
||||
work_items: list[tuple[str, str]] = []
|
||||
for _, row in lakes.iterrows():
|
||||
lake_gdf = gpd.GeoDataFrame([row], crs=lakes.crs)
|
||||
lake_geojson = lake_gdf.to_json()
|
||||
work_items.append((row["name"], lake_geojson))
|
||||
|
||||
# Use multiprocessing for parallel rendering
|
||||
num_workers = min(mp.cpu_count(), 8)
|
||||
sys.stdout.write(
|
||||
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
|
||||
)
|
||||
|
||||
results: dict[str, bytes] = {}
|
||||
with mp.Pool(
|
||||
num_workers,
|
||||
initializer=_init_worker,
|
||||
initargs=(poland_geojson_path, "zoom" if zoom else "no-zoom"),
|
||||
) as pool:
|
||||
for i, (lake_name, image_data) in enumerate(
|
||||
pool.imap_unordered(_render_single_lake, work_items)
|
||||
):
|
||||
results[lake_name] = image_data
|
||||
if (i + 1) % 50 == 0:
|
||||
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
|
||||
|
||||
# Clean up temp file
|
||||
Path(poland_geojson_path).unlink(missing_ok=True)
|
||||
|
||||
# Create notes from results
|
||||
for _, row in lakes.iterrows():
|
||||
lake_name = row["name"]
|
||||
area_km2 = round(row["area_km2"], 1) if "area_km2" in row else 0
|
||||
image_data = results[lake_name]
|
||||
filename = f"lake_{lake_name.replace(' ', '_').replace('/', '_')}.png"
|
||||
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[f'<img src="{filename}">', lake_name, str(area_km2)],
|
||||
tags=["geography", "poland", "lakes", "water"],
|
||||
)
|
||||
my_deck.add_note(note)
|
||||
|
||||
temp_path = Path(f"/tmp/{filename}") # noqa: S108
|
||||
temp_path.write_bytes(image_data)
|
||||
media_files.append(str(temp_path))
|
||||
|
||||
package = genanki.Package(my_deck)
|
||||
package.media_files = media_files
|
||||
return package
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Polish lakes.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: polish_lakes.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Polish Lakes",
|
||||
help="Name for the Anki deck",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
"-p",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Export preview images to specified directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview-count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of preview images to export (default: 5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-zoom",
|
||||
action="store_true",
|
||||
help="Disable zoom (show entire Poland instead of zoomed region)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
"-l",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Limit number of lakes (for testing)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
output_path = Path(args.output) if args.output else Path("polish_lakes.apkg")
|
||||
zoom = not args.no_zoom
|
||||
|
||||
try:
|
||||
sys.stdout.write("Loading lakes data...\n")
|
||||
lakes = get_polish_lakes()
|
||||
poland_boundary = get_poland_boundary()
|
||||
|
||||
if args.limit:
|
||||
lakes = lakes.head(args.limit)
|
||||
sys.stdout.write(f"Limiting to {args.limit} lakes.\n")
|
||||
|
||||
num_lakes = len(lakes)
|
||||
|
||||
sys.stdout.write(f"Found {num_lakes} lakes.\n")
|
||||
sys.stdout.write(f"Zoom mode: {'enabled' if zoom else 'disabled'}\n")
|
||||
sys.stdout.write("Generating flashcards...\n")
|
||||
|
||||
package = generate_anki_package(
|
||||
lakes, poland_boundary, args.deck_name, zoom=zoom
|
||||
)
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
# Export preview images if requested
|
||||
if args.preview:
|
||||
preview_dir = Path(args.preview)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_lakes = list(lakes.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_lakes)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_lakes:
|
||||
lake_name = row["name"]
|
||||
lake_gdf = gpd.GeoDataFrame([row], crs=lakes.crs)
|
||||
image_data = generate_lake_image_bytes(
|
||||
lake_gdf, poland_boundary, zoom=zoom
|
||||
)
|
||||
safe_name = lake_name.replace(" ", "_").replace("/", "_")
|
||||
preview_path = preview_dir / f"{safe_name}.png"
|
||||
preview_path.write_bytes(image_data)
|
||||
sys.stdout.write(f" Saved: {preview_path.name}\n")
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write(f"Lakes: {num_lakes}\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
if args.preview:
|
||||
sys.stdout.write(f"Preview images: {args.preview}\n")
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
6
python_pkg/anki_decks/polish_lakes/run.sh
Executable file
6
python_pkg/anki_decks/polish_lakes/run.sh
Executable file
@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Run the Polish lakes Anki generator
|
||||
|
||||
cd "$(dirname "$0")" || exit
|
||||
|
||||
python polish_lakes_anki.py --preview preview_images --preview-count 5 "$@"
|
||||
1
python_pkg/anki_decks/polish_landscape_parks/__init__.py
Normal file
1
python_pkg/anki_decks/polish_landscape_parks/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Polish landscape parks Anki deck generator."""
|
||||
@ -0,0 +1,336 @@
|
||||
"""Anki flashcard generator for Polish landscape parks.
|
||||
|
||||
Generates Anki-compatible flashcard decks with maps showing landscape parks
|
||||
highlighted on a Poland map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
import multiprocessing as mp
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import genanki
|
||||
import geopandas as gpd
|
||||
import matplotlib as mpl
|
||||
|
||||
mpl.use("Agg") # Non-interactive backend for multiprocessing
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from geo_data import get_poland_boundary, get_polish_landscape_parks
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
PARK_COLOR = "#27AE60" # Green for landscape parks
|
||||
|
||||
|
||||
def create_park_map(
|
||||
park_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
) -> Figure:
|
||||
"""Create a map showing Poland with one landscape park highlighted.
|
||||
|
||||
Clips park geometry to Poland boundary for clean edges.
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=(10, 12))
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.patch.set_alpha(0)
|
||||
ax.patch.set_alpha(0)
|
||||
|
||||
# Plot Poland as a plain gray shape
|
||||
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
|
||||
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
|
||||
|
||||
# Clip park geometry to Poland boundary for clean edges
|
||||
boundary_union = poland_boundary.union_all()
|
||||
clipped_gdf = park_gdf.copy()
|
||||
clipped_gdf["geometry"] = park_gdf.geometry.intersection(boundary_union)
|
||||
|
||||
# Plot the landscape park with thinner lines
|
||||
clipped_gdf.plot(ax=ax, color=PARK_COLOR, alpha=0.9)
|
||||
clipped_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=1.5)
|
||||
|
||||
# Set bounds to Poland
|
||||
bounds = poland_boundary.total_bounds
|
||||
ax.set_xlim(bounds[0], bounds[2])
|
||||
ax.set_ylim(bounds[1], bounds[3])
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_park_image_bytes(
|
||||
park_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
) -> bytes:
|
||||
"""Generate a park map image as bytes."""
|
||||
fig = create_park_map(park_gdf, poland_boundary)
|
||||
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
return buf.read()
|
||||
|
||||
|
||||
# Global variables for multiprocessing (set via initializer)
|
||||
_mp_poland_boundary: gpd.GeoDataFrame | None = None
|
||||
|
||||
|
||||
def _init_worker(poland_geojson: str) -> None:
|
||||
"""Initialize worker process with shared data."""
|
||||
global _mp_poland_boundary # noqa: PLW0603
|
||||
_mp_poland_boundary = gpd.read_file(poland_geojson)
|
||||
|
||||
|
||||
def _render_single_park(args: tuple[str, str]) -> tuple[str, bytes]:
|
||||
"""Render a single park image (worker function).
|
||||
|
||||
Args:
|
||||
args: Tuple of (park_name, park_geojson_str).
|
||||
|
||||
Returns:
|
||||
Tuple of (park_name, image_bytes).
|
||||
"""
|
||||
park_name, park_geojson = args
|
||||
park_gdf = gpd.read_file(park_geojson)
|
||||
# Fix any geometry issues from serialization
|
||||
park_gdf["geometry"] = park_gdf.geometry.make_valid()
|
||||
|
||||
assert _mp_poland_boundary is not None # noqa: S101
|
||||
|
||||
image_data = generate_park_image_bytes(park_gdf, _mp_poland_boundary)
|
||||
return park_name, image_data
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
parks: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
deck_name: str = "Polish Landscape Parks",
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package for Polish landscape parks."""
|
||||
model_id_hash = hashlib.md5( # noqa: S324
|
||||
f"polish_landscape_parks_{deck_name}".encode()
|
||||
)
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.answer-text {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.card.night_mode .answer-text {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
.info-text {
|
||||
font-size: 18px;
|
||||
color: #7F8C8D;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.card.night_mode .info-text {
|
||||
color: #BDC3C7;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Polish Landscape Park Model",
|
||||
fields=[
|
||||
{"name": "ParkMap"},
|
||||
{"name": "ParkName"},
|
||||
{"name": "Area"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": '<div class="map-container">{{ParkMap}}</div>',
|
||||
"afmt": '<div class="map-container">{{ParkMap}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer-text">{{ParkName}}</div>'
|
||||
'<div class="info-text">{{Area}} km²</div>',
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
media_files = []
|
||||
|
||||
# Prepare data for parallel processing
|
||||
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
|
||||
poland_boundary.to_file(f.name, driver="GeoJSON")
|
||||
poland_geojson_path = f.name
|
||||
|
||||
# Prepare work items: (park_name, park_geojson_str)
|
||||
work_items: list[tuple[str, str]] = []
|
||||
for _, row in parks.iterrows():
|
||||
park_gdf = gpd.GeoDataFrame([row], crs=parks.crs)
|
||||
park_geojson = park_gdf.to_json()
|
||||
work_items.append((row["name"], park_geojson))
|
||||
|
||||
# Use multiprocessing for parallel rendering
|
||||
num_workers = min(mp.cpu_count(), 8)
|
||||
sys.stdout.write(
|
||||
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
|
||||
)
|
||||
|
||||
results: dict[str, bytes] = {}
|
||||
with mp.Pool(
|
||||
num_workers,
|
||||
initializer=_init_worker,
|
||||
initargs=(poland_geojson_path,),
|
||||
) as pool:
|
||||
for i, (park_name, image_data) in enumerate(
|
||||
pool.imap_unordered(_render_single_park, work_items)
|
||||
):
|
||||
results[park_name] = image_data
|
||||
if (i + 1) % 25 == 0:
|
||||
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
|
||||
|
||||
# Clean up temp file
|
||||
Path(poland_geojson_path).unlink(missing_ok=True)
|
||||
|
||||
# Create notes from results
|
||||
for _, row in parks.iterrows():
|
||||
park_name = row["name"]
|
||||
area_km2 = round(row["area_km2"], 1) if "area_km2" in row else 0
|
||||
image_data = results[park_name]
|
||||
filename = f"lpark_{park_name.replace(' ', '_').replace('/', '_')}.png"
|
||||
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[f'<img src="{filename}">', park_name, str(area_km2)],
|
||||
tags=["geography", "poland", "landscape-parks", "nature"],
|
||||
)
|
||||
my_deck.add_note(note)
|
||||
|
||||
temp_path = Path(f"/tmp/{filename}") # noqa: S108
|
||||
temp_path.write_bytes(image_data)
|
||||
media_files.append(str(temp_path))
|
||||
|
||||
package = genanki.Package(my_deck)
|
||||
package.media_files = media_files
|
||||
return package
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Polish landscape parks.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: polish_landscape_parks.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Polish Landscape Parks",
|
||||
help="Name for the Anki deck",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
"-p",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Export preview images to specified directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview-count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of preview images to export (default: 5)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
output_path = (
|
||||
Path(args.output) if args.output else Path("polish_landscape_parks.apkg")
|
||||
)
|
||||
|
||||
try:
|
||||
sys.stdout.write("Loading landscape parks data...\n")
|
||||
parks = get_polish_landscape_parks()
|
||||
poland_boundary = get_poland_boundary()
|
||||
num_parks = len(parks)
|
||||
|
||||
sys.stdout.write(f"Found {num_parks} landscape parks.\n")
|
||||
sys.stdout.write("Generating flashcards...\n")
|
||||
|
||||
package = generate_anki_package(parks, poland_boundary, args.deck_name)
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
# Export preview images if requested
|
||||
if args.preview:
|
||||
preview_dir = Path(args.preview)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_parks = list(parks.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_parks)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_parks:
|
||||
park_name = row["name"]
|
||||
park_gdf = gpd.GeoDataFrame([row], crs=parks.crs)
|
||||
image_data = generate_park_image_bytes(park_gdf, poland_boundary)
|
||||
safe_name = park_name.replace(" ", "_").replace("/", "_")
|
||||
preview_path = preview_dir / f"{safe_name}.png"
|
||||
preview_path.write_bytes(image_data)
|
||||
sys.stdout.write(f" Saved: {preview_path.name}\n")
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write(f"Landscape parks: {num_parks}\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
if args.preview:
|
||||
sys.stdout.write(f"Preview images: {args.preview}\n")
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
6
python_pkg/anki_decks/polish_landscape_parks/run.sh
Executable file
6
python_pkg/anki_decks/polish_landscape_parks/run.sh
Executable file
@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Run the Polish landscape parks Anki generator
|
||||
|
||||
cd "$(dirname "$0")" || exit
|
||||
|
||||
python polish_landscape_parks_anki.py --preview preview_images --preview-count 5 "$@"
|
||||
151
python_pkg/anki_decks/polish_license_plates/README.md
Normal file
151
python_pkg/anki_decks/polish_license_plates/README.md
Normal file
@ -0,0 +1,151 @@
|
||||
# Polish License Plates Anki Generator
|
||||
|
||||
Generate Anki flashcards for learning Polish car license plate codes.
|
||||
|
||||
## Overview
|
||||
|
||||
This package generates Anki-compatible flashcard decks for all Polish vehicle registration plate codes. Each code is mapped to its corresponding location (city or powiat).
|
||||
|
||||
Polish license plates use a system where:
|
||||
|
||||
- First letter indicates the **voivodeship** (province)
|
||||
- Following 1-2 letters indicate the specific **city** or **powiat** (county)
|
||||
|
||||
## Features
|
||||
|
||||
- **444 license plate codes** covering all Polish voivodeships, cities, and powiats
|
||||
- **Bidirectional flashcards**:
|
||||
- Code → Location (e.g., `WY` → `Warszawa Wola`)
|
||||
- Location → Code (e.g., `Warszawa Wola` → `WY`)
|
||||
- **888 total flashcards** for comprehensive learning
|
||||
- Visual license plate styling in flashcards (yellow background, monospace font)
|
||||
- Dark mode support
|
||||
- Self-contained `.apkg` file - no manual setup required
|
||||
|
||||
## Data Source
|
||||
|
||||
License plate data is automatically extracted from Wikipedia's authoritative table:
|
||||
|
||||
- **Source**: [Vehicle registration plates of Poland](https://en.wikipedia.org/wiki/Vehicle_registration_plates_of_Poland)
|
||||
- **Update**: Run `python -m python_pkg.anki_decks.polish_license_plates.fetch_license_plates` to refresh data
|
||||
|
||||
This ensures the codes are always based on the most current public information.
|
||||
|
||||
## Usage
|
||||
|
||||
### Generate Flashcards
|
||||
|
||||
```bash
|
||||
# Generate with default settings
|
||||
python -m python_pkg.anki_decks.polish_license_plates.polish_license_plates_anki
|
||||
|
||||
# Specify custom output file
|
||||
python -m python_pkg.anki_decks.polish_license_plates.polish_license_plates_anki \
|
||||
--output my_plates.apkg
|
||||
|
||||
# Use custom deck name
|
||||
python -m python_pkg.anki_decks.polish_license_plates.polish_license_plates_anki \
|
||||
--deck-name "My Polish Plates"
|
||||
```
|
||||
|
||||
### Update License Plate Data
|
||||
|
||||
To fetch the latest data from Wikipedia:
|
||||
|
||||
```bash
|
||||
# Use cached data if available (default)
|
||||
python -m python_pkg.anki_decks.polish_license_plates.fetch_license_plates
|
||||
|
||||
# Force refresh from Wikipedia (ignore cache)
|
||||
python -m python_pkg.anki_decks.polish_license_plates.fetch_license_plates --force
|
||||
```
|
||||
|
||||
**Caching**: Downloaded Wikipedia data is cached for 7 days in `.wikipedia_cache/` to avoid unnecessary requests. Use `--force` to bypass the cache.
|
||||
|
||||
This will update `license_plate_data.py` with the current codes from Wikipedia.
|
||||
|
||||
**Requirements**: `pip install requests beautifulsoup4 lxml`
|
||||
|
||||
### Import into Anki
|
||||
|
||||
1. Open Anki
|
||||
2. File → Import
|
||||
3. Select the generated `.apkg` file
|
||||
4. Click Import
|
||||
|
||||
## Examples
|
||||
|
||||
### License Plate Codes by Voivodeship
|
||||
|
||||
| Voivodeship | First Letter | Example Codes |
|
||||
| ------------------- | ------------ | ------------------------------------------------ |
|
||||
| Dolnośląskie | D | DA (Wrocław), DB (Wałbrzych), DJ (Jelenia Góra) |
|
||||
| Kujawsko-Pomorskie | C | CB (Bydgoszcz), CT (Toruń), CG (Grudziądz) |
|
||||
| Lubelskie | L | LL (Lublin), LC (Chełm), LZ (Zamość) |
|
||||
| Lubuskie | F | FZ (Zielona Góra), FG (Gorzów Wielkopolski) |
|
||||
| Łódzkie | E | ED (Łódź), EP (Piotrków Trybunalski) |
|
||||
| Małopolskie | K | KR (Kraków), KT (Tarnów), KN (Nowy Sącz) |
|
||||
| Mazowieckie | W | WA-WZ (Warsaw), WR (Radom), WS (Siedlce) |
|
||||
| Opolskie | O | OP (Opole), OK (Kędzierzyn-Koźle) |
|
||||
| Podkarpackie | R | RR (Rzeszów), RP (Przemyśl), RK (Krosno) |
|
||||
| Podlaskie | B | BI (Białystok), BL (Łomża), BSU (Suwałki) |
|
||||
| Pomorskie | G | GD (Gdańsk), GDY (Gdynia), GS (Słupsk) |
|
||||
| Śląskie | S | SK (Katowice), SC (Chorzów), SB (Bielsko-Biała) |
|
||||
| Świętokrzyskie | T | TK (Kielce), TSK (Skarżysko-Kamienna) |
|
||||
| Warmińsko-Mazurskie | N | NO (Olsztyn), NE (Elbląg), NG (Giżycko) |
|
||||
| Wielkopolskie | P | PO (Poznań), PKA (Kalisz), PIA (Piła) |
|
||||
| Zachodniopomorskie | Z | ZS (Szczecin), ZKO (Koszalin), ZSW (Świnoujście) |
|
||||
|
||||
### Warsaw (Warszawa) Codes
|
||||
|
||||
Warsaw has an extensive range of codes (WA-WZ):
|
||||
|
||||
- WA: Warszawa (general)
|
||||
- WB: Warszawa Bemowo
|
||||
- WC: Ciechanów
|
||||
- WD: Warszawa Praga Południe
|
||||
- WE: Warszawa Praga Północ
|
||||
- WH: Warszawa Mokotów
|
||||
- WY: Warszawa Wola
|
||||
- And many more...
|
||||
|
||||
## Data
|
||||
|
||||
The package includes 444 license plate codes covering:
|
||||
|
||||
- All 16 Polish voivodeships
|
||||
- Major cities with powiat rights (e.g., Kraków, Gdańsk, Poznań)
|
||||
- All powiats (counties) across Poland
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite:
|
||||
|
||||
```bash
|
||||
python -m pytest python_pkg/polish_license_plates/tests/ -v
|
||||
```
|
||||
|
||||
All 17 tests validate:
|
||||
|
||||
- Data integrity (444 codes, no duplicates)
|
||||
- Correct voivodeship prefixes
|
||||
- Major cities present
|
||||
- Anki package generation
|
||||
- Bidirectional card templates
|
||||
- CLI functionality
|
||||
|
||||
## Technical Details
|
||||
|
||||
- **Package format**: Anki `.apkg` (SQLite database)
|
||||
- **Card model**: Bidirectional with two templates per note
|
||||
- **Styling**: Custom CSS with license plate visual design
|
||||
- **Tags**: `geography`, `poland`, `license-plates`, `transportation`
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- genanki (for Anki package generation)
|
||||
|
||||
## License
|
||||
|
||||
Part of the testsAndMisc repository.
|
||||
9
python_pkg/anki_decks/polish_license_plates/__init__.py
Normal file
9
python_pkg/anki_decks/polish_license_plates/__init__.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""Polish license plate Anki flashcard generator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["LICENSE_PLATE_CODES"]
|
||||
|
||||
from python_pkg.anki_decks.polish_license_plates.license_plate_data import (
|
||||
LICENSE_PLATE_CODES,
|
||||
)
|
||||
391
python_pkg/anki_decks/polish_license_plates/fetch_license_plates.py
Executable file
391
python_pkg/anki_decks/polish_license_plates/fetch_license_plates.py
Executable file
@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch Polish license plate codes from Wikipedia.
|
||||
|
||||
This script scrapes the Wikipedia page "Vehicle registration plates of Poland"
|
||||
to extract the official license plate codes and their corresponding locations.
|
||||
|
||||
The data is extracted from the wikitable on the page and saved to license_plate_data.py.
|
||||
|
||||
Caching:
|
||||
Fetched Wikipedia HTML is cached to avoid unnecessary requests.
|
||||
Cache location: .wikipedia_cache/license_plates.html
|
||||
Cache expires after 7 days by default.
|
||||
|
||||
Usage:
|
||||
python -m python_pkg.anki_decks.polish_license_plates.fetch_license_plates
|
||||
|
||||
# Force refresh (ignore cache)
|
||||
python -m python_pkg.anki_decks.polish_license_plates.fetch_license_plates --force
|
||||
|
||||
Source:
|
||||
https://en.wikipedia.org/wiki/Vehicle_registration_plates_of_Poland
|
||||
|
||||
Note:
|
||||
This script requires internet access and the following packages:
|
||||
- requests
|
||||
- beautifulsoup4
|
||||
- lxml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
import requests
|
||||
except ImportError:
|
||||
sys.stderr.write(
|
||||
"Error: Required packages not installed.\n"
|
||||
"Install with: pip install requests beautifulsoup4 lxml\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Constants
|
||||
MIN_TABLE_COLUMNS = 2 # Minimum columns needed to extract code and location
|
||||
MAX_CODE_LENGTH = 4 # Maximum length for a valid license plate code
|
||||
CACHE_EXPIRY_DAYS = 7 # Cache expires after 7 days
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/120.0.0.0 Safari/537.36" # Updated to recent version
|
||||
)
|
||||
|
||||
|
||||
def get_cache_path() -> Path:
|
||||
"""Get the path to the cache file.
|
||||
|
||||
Returns:
|
||||
Path to the cache file.
|
||||
"""
|
||||
script_dir = Path(__file__).parent
|
||||
cache_dir = script_dir / ".wikipedia_cache"
|
||||
cache_dir.mkdir(exist_ok=True)
|
||||
return cache_dir / "license_plates.html"
|
||||
|
||||
|
||||
def is_cache_valid(cache_path: Path, max_age_days: int = CACHE_EXPIRY_DAYS) -> bool:
|
||||
"""Check if the cache file exists and is not expired.
|
||||
|
||||
Args:
|
||||
cache_path: Path to the cache file.
|
||||
max_age_days: Maximum age in days before cache is considered expired.
|
||||
|
||||
Returns:
|
||||
True if cache is valid, False otherwise.
|
||||
"""
|
||||
if not cache_path.exists():
|
||||
return False
|
||||
|
||||
# Check age
|
||||
file_age_seconds = time.time() - cache_path.stat().st_mtime
|
||||
max_age_seconds = max_age_days * 24 * 60 * 60
|
||||
|
||||
return file_age_seconds < max_age_seconds
|
||||
|
||||
|
||||
def fetch_wikipedia_html(*, force_refresh: bool = False) -> str:
|
||||
"""Fetch Wikipedia HTML, using cache if available.
|
||||
|
||||
Args:
|
||||
force_refresh: If True, ignore cache and fetch fresh data.
|
||||
|
||||
Returns:
|
||||
HTML content of the Wikipedia page.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the page cannot be fetched.
|
||||
"""
|
||||
cache_path = get_cache_path()
|
||||
|
||||
# Check if we can use cache
|
||||
if not force_refresh and is_cache_valid(cache_path):
|
||||
try:
|
||||
sys.stdout.write(f"Using cached data from {cache_path}\n")
|
||||
cache_age_hours = int((time.time() - cache_path.stat().st_mtime) / 3600)
|
||||
sys.stdout.write(f"Cache age: {cache_age_hours} hours\n")
|
||||
return cache_path.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
sys.stderr.write(f"Warning: Failed to read cache: {e}\n")
|
||||
sys.stderr.write("Fetching fresh data from Wikipedia...\n")
|
||||
# Fall through to fetch from Wikipedia
|
||||
|
||||
# Fetch from Wikipedia
|
||||
url = "https://en.wikipedia.org/wiki/Vehicle_registration_plates_of_Poland"
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
|
||||
if force_refresh:
|
||||
sys.stdout.write("Force refresh: Ignoring cache\n")
|
||||
|
||||
sys.stdout.write(f"Fetching data from {url}...\n")
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
msg = f"Failed to fetch Wikipedia page: {e}"
|
||||
raise RuntimeError(msg) from e
|
||||
|
||||
# Cache the response
|
||||
try:
|
||||
cache_path.write_text(response.text, encoding="utf-8")
|
||||
sys.stdout.write(f"Cached response to {cache_path}\n")
|
||||
except OSError as e:
|
||||
sys.stderr.write(f"Warning: Failed to write cache: {e}\n")
|
||||
# Continue anyway - the data was fetched successfully
|
||||
|
||||
return response.text
|
||||
|
||||
|
||||
def parse_license_plates_from_html(html_content: str) -> dict[str, str]:
|
||||
"""Parse license plate codes from Wikipedia HTML.
|
||||
|
||||
Args:
|
||||
html_content: HTML content of the Wikipedia page.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping license plate codes to their locations.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no valid tables are found.
|
||||
"""
|
||||
soup = BeautifulSoup(html_content, "html.parser")
|
||||
|
||||
# Find all wikitables
|
||||
tables = soup.find_all("table", {"class": "wikitable"})
|
||||
|
||||
if not tables:
|
||||
msg = "No wikitable found on the page"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
sys.stdout.write(f"Found {len(tables)} tables on the page\n")
|
||||
|
||||
license_plates: dict[str, str] = {}
|
||||
|
||||
# Process each table
|
||||
for table_idx, table in enumerate(tables):
|
||||
rows = table.find_all("tr")
|
||||
|
||||
sys.stdout.write(f"Processing table {table_idx + 1} with {len(rows)} rows...\n")
|
||||
|
||||
for row in rows[1:]: # Skip header row
|
||||
cells = row.find_all(["td", "th"])
|
||||
|
||||
if len(cells) >= MIN_TABLE_COLUMNS:
|
||||
# Extract code and location
|
||||
code_text = cells[0].get_text(strip=True)
|
||||
location_text = cells[1].get_text(strip=True)
|
||||
|
||||
# Clean up the code (remove spaces, keep only letters)
|
||||
code = re.sub(r"[^A-Z]", "", code_text.upper())
|
||||
|
||||
# Skip if code is invalid
|
||||
if not code or len(code) > MAX_CODE_LENGTH:
|
||||
continue
|
||||
|
||||
# Clean up location text (remove citations, extra spaces)
|
||||
location = re.sub(r"\[[0-9]+\]", "", location_text)
|
||||
location = " ".join(location.split())
|
||||
|
||||
if location:
|
||||
license_plates[code] = location
|
||||
|
||||
sys.stdout.write(f"Extracted {len(license_plates)} license plate codes\n")
|
||||
|
||||
return license_plates
|
||||
|
||||
|
||||
def fetch_wikipedia_license_plates(*, force_refresh: bool = False) -> dict[str, str]:
|
||||
"""Fetch Polish license plate codes from Wikipedia.
|
||||
|
||||
Args:
|
||||
force_refresh: If True, ignore cache and fetch fresh data.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping license plate codes to their locations.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the page cannot be fetched or parsed.
|
||||
"""
|
||||
html_content = fetch_wikipedia_html(force_refresh=force_refresh)
|
||||
return parse_license_plates_from_html(html_content)
|
||||
|
||||
|
||||
def generate_license_plate_data_file(
|
||||
license_plates: dict[str, str],
|
||||
output_path: Path,
|
||||
) -> None:
|
||||
"""Generate license_plate_data.py file with the extracted data.
|
||||
|
||||
Args:
|
||||
license_plates: Dictionary mapping codes to locations.
|
||||
output_path: Path to the output file.
|
||||
"""
|
||||
# Group by first letter (voivodeship)
|
||||
voivodeships: dict[str, list[tuple[str, str]]] = {}
|
||||
for code, location in sorted(license_plates.items()):
|
||||
first_letter = code[0]
|
||||
if first_letter not in voivodeships:
|
||||
voivodeships[first_letter] = []
|
||||
voivodeships[first_letter].append((code, location))
|
||||
|
||||
# Voivodeship names
|
||||
voivodeship_names = {
|
||||
"B": "Podlaskie",
|
||||
"C": "Kujawsko-Pomorskie",
|
||||
"D": "Dolnośląskie",
|
||||
"E": "Łódzkie",
|
||||
"F": "Lubuskie",
|
||||
"G": "Pomorskie",
|
||||
"K": "Małopolskie",
|
||||
"L": "Lubelskie",
|
||||
"N": "Warmińsko-Mazurskie",
|
||||
"O": "Opolskie",
|
||||
"P": "Wielkopolskie",
|
||||
"R": "Podkarpackie",
|
||||
"S": "Śląskie",
|
||||
"T": "Świętokrzyskie",
|
||||
"W": "Mazowieckie",
|
||||
"Z": "Zachodniopomorskie",
|
||||
}
|
||||
|
||||
# Generate file content
|
||||
content = '''"""Database of Polish car license plate registration codes.
|
||||
|
||||
This module contains a comprehensive mapping of Polish vehicle registration
|
||||
plate codes to their corresponding locations (cities, powiats, voivodeships).
|
||||
|
||||
Polish license plates use a system where:
|
||||
- First letter indicates the voivodeship (province)
|
||||
- Following 1-2 letters indicate the specific city or powiat (county)
|
||||
|
||||
The database is organized by voivodeships in alphabetical order:
|
||||
- B: Podlaskie
|
||||
- C: Kujawsko-Pomorskie
|
||||
- D: Dolnośląskie
|
||||
- E: Łódzkie
|
||||
- F: Lubuskie
|
||||
- G: Pomorskie
|
||||
- K: Małopolskie
|
||||
- L: Lubelskie
|
||||
- N: Warmińsko-Mazurskie
|
||||
- O: Opolskie
|
||||
- P: Wielkopolskie
|
||||
- R: Podkarpackie
|
||||
- S: Śląskie
|
||||
- T: Świętokrzyskie
|
||||
- W: Mazowieckie
|
||||
- Z: Zachodniopomorskie
|
||||
|
||||
Data source:
|
||||
Wikipedia - Vehicle registration plates of Poland
|
||||
https://en.wikipedia.org/wiki/Vehicle_registration_plates_of_Poland
|
||||
|
||||
Auto-generated by:
|
||||
python -m python_pkg.anki_decks.polish_license_plates.fetch_license_plates
|
||||
|
||||
Examples:
|
||||
WA = Warszawa (Warsaw)
|
||||
KR = Kraków
|
||||
GD = Gdańsk
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
LICENSE_PLATE_CODES: dict[str, str] = {
|
||||
'''
|
||||
|
||||
# Add entries grouped by voivodeship
|
||||
for letter in sorted(voivodeships.keys()):
|
||||
voivodeship_name = voivodeship_names.get(letter, f"Voivodeship {letter}")
|
||||
codes = voivodeships[letter]
|
||||
|
||||
content += f" # {letter} - {voivodeship_name} ({len(codes)} codes)\n"
|
||||
|
||||
for code, location in codes:
|
||||
# Escape quotes in location
|
||||
location_escaped = location.replace('"', '\\"')
|
||||
content += f' "{code}": "{location_escaped}",\n'
|
||||
|
||||
content += "\n"
|
||||
|
||||
# Remove last comma and newline, then close the dict
|
||||
content = content.rstrip(",\n") + "\n}\n"
|
||||
|
||||
# Write to file
|
||||
output_path.write_text(content, encoding="utf-8")
|
||||
sys.stdout.write(f"Generated {output_path}\n")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point.
|
||||
|
||||
Returns:
|
||||
Exit code.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Fetch Polish license plate codes from Wikipedia.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
"-f",
|
||||
action="store_true",
|
||||
help="Force refresh: ignore cache and fetch fresh data from Wikipedia",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
# Fetch data from Wikipedia
|
||||
license_plates = fetch_wikipedia_license_plates(force_refresh=args.force)
|
||||
|
||||
# Determine output path
|
||||
script_dir = Path(__file__).parent
|
||||
output_path = script_dir / "license_plate_data.py"
|
||||
|
||||
# Generate the file
|
||||
generate_license_plate_data_file(license_plates, output_path)
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 70 + "\n")
|
||||
sys.stdout.write("LICENSE PLATE DATA UPDATE COMPLETE\n")
|
||||
sys.stdout.write("=" * 70 + "\n")
|
||||
sys.stdout.write(f"Total codes: {len(license_plates)}\n")
|
||||
sys.stdout.write(f"Output file: {output_path}\n")
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("Data source: Wikipedia\n")
|
||||
sys.stdout.write(
|
||||
"URL: https://en.wikipedia.org/wiki/"
|
||||
"Vehicle_registration_plates_of_Poland\n"
|
||||
)
|
||||
sys.stdout.write(f"Cache location: {get_cache_path()}\n")
|
||||
sys.stdout.write(f"Cache expiry: {CACHE_EXPIRY_DAYS} days\n")
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("Next steps:\n")
|
||||
sys.stdout.write(" 1. Review the generated file\n")
|
||||
sys.stdout.write(
|
||||
" 2. Run tests: "
|
||||
"pytest python_pkg/anki_decks/"
|
||||
"polish_license_plates/tests/\n"
|
||||
)
|
||||
sys.stdout.write(
|
||||
" 3. Regenerate Anki package: "
|
||||
"python -m python_pkg.anki_decks."
|
||||
"polish_license_plates."
|
||||
"polish_license_plates_anki\n"
|
||||
)
|
||||
|
||||
except RuntimeError as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -0,0 +1,489 @@
|
||||
"""Database of Polish car license plate registration codes.
|
||||
|
||||
This module contains a comprehensive mapping of Polish vehicle registration
|
||||
plate codes to their corresponding locations (cities, powiats, voivodeships).
|
||||
|
||||
Polish license plates use a system where:
|
||||
- First letter indicates the voivodeship (province)
|
||||
- Following 1-2 letters indicate the specific city or powiat (county)
|
||||
|
||||
The database is organized by voivodeships in alphabetical order:
|
||||
- B: Podlaskie
|
||||
- C: Kujawsko-Pomorskie
|
||||
- D: Dolnośląskie
|
||||
- E: Łódzkie
|
||||
- F: Lubuskie
|
||||
- G: Pomorskie
|
||||
- K: Małopolskie
|
||||
- L: Lubelskie
|
||||
- N: Warmińsko-Mazurskie
|
||||
- O: Opolskie
|
||||
- P: Wielkopolskie
|
||||
- R: Podkarpackie
|
||||
- S: Śląskie
|
||||
- T: Świętokrzyskie
|
||||
- W: Mazowieckie
|
||||
- Z: Zachodniopomorskie
|
||||
|
||||
Data source:
|
||||
Wikipedia - Vehicle registration plates of Poland
|
||||
https://en.wikipedia.org/wiki/Vehicle_registration_plates_of_Poland
|
||||
|
||||
Note:
|
||||
This data can be automatically updated by running:
|
||||
python -m python_pkg.anki_decks.polish_license_plates.fetch_license_plates
|
||||
|
||||
Examples:
|
||||
WA = Warszawa (Warsaw)
|
||||
KR = Kraków
|
||||
GD = Gdańsk
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
LICENSE_PLATE_CODES: dict[str, str] = {
|
||||
"DA": "Wrocław Fabryczna",
|
||||
"DB": "Wałbrzych",
|
||||
"DC": "Wrocław Śródmieście",
|
||||
"DD": "Dzierżoniów",
|
||||
"DE": "Wrocław Psie Pole",
|
||||
"DF": "Wrocław Krzyki",
|
||||
"DG": "Głogów",
|
||||
"DH": "Wrocław Stare Miasto",
|
||||
"DJ": "Jelenia Góra",
|
||||
"DK": "Kłodzko",
|
||||
"DL": "Legnica",
|
||||
"DLB": "Lubań",
|
||||
"DLE": "Legnica powiat",
|
||||
"DMI": "Milicz",
|
||||
"DN": "Wrocław Nowy Dwór",
|
||||
"DO": "Oława",
|
||||
"DP": "Polkowice",
|
||||
"DR": "Wrocław Krzyki",
|
||||
"DS": "Świdnica",
|
||||
"DSR": "Środa Śląska",
|
||||
"DSW": "Świebodzice",
|
||||
"DT": "Twardogóra",
|
||||
"DTR": "Trzebnica",
|
||||
"DW": "Wałbrzych powiat",
|
||||
"DWL": "Wołów",
|
||||
"DWR": "Wrocław",
|
||||
"DZ": "Zgorzelec",
|
||||
"DZA": "Ząbkowice Śląskie",
|
||||
"DZG": "Zgorzelec powiat",
|
||||
"CB": "Bydgoszcz",
|
||||
"CBR": "Brodnica",
|
||||
"CC": "Chełmno",
|
||||
"CD": "Świecie",
|
||||
"CE": "Inowrocław",
|
||||
"CG": "Grudziądz",
|
||||
"CH": "Chojnice",
|
||||
"CI": "Inowrocław powiat",
|
||||
"CL": "Lipno",
|
||||
"CMG": "Mogilno",
|
||||
"CN": "Nakło nad Notecią",
|
||||
"CR": "Radziejów",
|
||||
"CT": "Toruń",
|
||||
"CTR": "Toruń powiat",
|
||||
"CTU": "Tuchola",
|
||||
"CW": "Włocławek",
|
||||
"CWA": "Wąbrzeźno",
|
||||
"CWL": "Włocławek powiat",
|
||||
"CZ": "Żnin",
|
||||
"LB": "Biała Podlaska",
|
||||
"LBI": "Biłgoraj",
|
||||
"LC": "Chełm",
|
||||
"LCH": "Chełm powiat",
|
||||
"LHR": "Hrubieszów",
|
||||
"LI": "Janów Lubelski",
|
||||
"LKR": "Kraśnik",
|
||||
"LKS": "Krasnystaw",
|
||||
"LL": "Lublin",
|
||||
"LLE": "Łęczna",
|
||||
"LLU": "Łuków",
|
||||
"LM": "Biała Podlaska powiat",
|
||||
"LOP": "Opole Lubelskie",
|
||||
"LPA": "Parczew",
|
||||
"LPU": "Puławy",
|
||||
"LRA": "Radzyń Podlaski",
|
||||
"LRY": "Ryki",
|
||||
"LSI": "Świdnik",
|
||||
"LT": "Tomaszów Lubelski",
|
||||
"LU": "Lublin powiat",
|
||||
"LWL": "Włodawa",
|
||||
"LZ": "Zamość",
|
||||
"LZA": "Zamość powiat",
|
||||
"FG": "Gorzów Wielkopolski",
|
||||
"FKR": "Krosno Odrzańskie",
|
||||
"FMI": "Międzyrzecz",
|
||||
"FNW": "Nowa Sól",
|
||||
"FSD": "Strzelce-Drezdenko",
|
||||
"FSL": "Słubice",
|
||||
"FSU": "Sulęcin",
|
||||
"FSW": "Świebodzin",
|
||||
"FWS": "Wschowa",
|
||||
"FZ": "Zielona Góra",
|
||||
"FZG": "Zielona Góra powiat",
|
||||
"FZI": "Żagań",
|
||||
"FZY": "Żary",
|
||||
"EA": "Bełchatów",
|
||||
"EB": "Łódź Bałuty",
|
||||
"EBE": "Bełchatów powiat",
|
||||
"EBR": "Brzeziny",
|
||||
"EC": "Łęczyca",
|
||||
"ED": "Łódź Śródmieście",
|
||||
"EE": "Łódź Górna",
|
||||
"EG": "Głowno",
|
||||
"EK": "Kutno",
|
||||
"EKU": "Kutno powiat",
|
||||
"EL": "Łask",
|
||||
"ELA": "Łowicz",
|
||||
"ELE": "Łęczyca powiat",
|
||||
"ELW": "Łowicz powiat",
|
||||
"EM": "Opoczno",
|
||||
"EO": "Opoczno powiat",
|
||||
"EP": "Piotrków Trybunalski",
|
||||
"EPA": "Pajęczno",
|
||||
"EPD": "Poddębice",
|
||||
"EPI": "Piotrków Trybunalski powiat",
|
||||
"ER": "Rawa Mazowiecka",
|
||||
"ERA": "Radomsko",
|
||||
"ERW": "Rawa Mazowiecka powiat",
|
||||
"ES": "Sieradz",
|
||||
"ESI": "Sieradz powiat",
|
||||
"ESK": "Skierniewice",
|
||||
"ESR": "Skierniewice powiat",
|
||||
"ET": "Tomaszów Mazowiecki",
|
||||
"EW": "Wieluń",
|
||||
"EWI": "Wieluń powiat",
|
||||
"EZ": "Zduńska Wola",
|
||||
"EZD": "Zgierz",
|
||||
"KA": "Kraków Krowodrza",
|
||||
"KB": "Bochnia",
|
||||
"KBC": "Brzesko",
|
||||
"KC": "Chrzanów",
|
||||
"KCH": "Chrzanów powiat",
|
||||
"KD": "Kraków Nowa Huta",
|
||||
"KDA": "Dąbrowa Tarnowska",
|
||||
"KE": "Kraków Śródmieście",
|
||||
"KG": "Gorlice",
|
||||
"KH": "Kraków Podgórze",
|
||||
"KI": "Miechów",
|
||||
"KK": "Kraków Śródmieście",
|
||||
"KL": "Limanowa",
|
||||
"KLI": "Limanowa powiat",
|
||||
"KM": "Myślenice",
|
||||
"KN": "Nowy Sącz",
|
||||
"KNS": "Nowy Sącz powiat",
|
||||
"KNT": "Nowy Targ",
|
||||
"KO": "Olkusz",
|
||||
"KOL": "Olkusz powiat",
|
||||
"KOS": "Oświęcim",
|
||||
"KP": "Proszowice",
|
||||
"KR": "Kraków",
|
||||
"KRA": "Kraków powiat",
|
||||
"KS": "Sucha Beskidzka",
|
||||
"KT": "Tarnów",
|
||||
"KTA": "Tarnów powiat",
|
||||
"KTT": "Tatry",
|
||||
"KW": "Wadowice",
|
||||
"KWA": "Wadowice powiat",
|
||||
"WA": "Warszawa",
|
||||
"WB": "Warszawa Bemowo",
|
||||
"WBR": "Białobrzegi",
|
||||
"WC": "Ciechanów",
|
||||
"WCI": "Ciechanów powiat",
|
||||
"WD": "Warszawa Praga Południe",
|
||||
"WE": "Warszawa Praga Północ",
|
||||
"WF": "Garwolin",
|
||||
"WG": "Grodzisk Mazowiecki",
|
||||
"WGM": "Grójec",
|
||||
"WGO": "Gostynin",
|
||||
"WGR": "Garwolin powiat",
|
||||
"WH": "Warszawa Mokotów",
|
||||
"WI": "Pruszków",
|
||||
"WJ": "Józefów",
|
||||
"WK": "Kozienice",
|
||||
"WL": "Legionowo",
|
||||
"WLI": "Lipsko",
|
||||
"WLS": "Łosice",
|
||||
"WM": "Mińsk Mazowiecki",
|
||||
"WMA": "Maków Mazowiecki",
|
||||
"WML": "Mława",
|
||||
"WN": "Warszawa Białołęka",
|
||||
"WND": "Nowy Dwór Mazowiecki",
|
||||
"WO": "Otwock",
|
||||
"WOR": "Ostrołęka",
|
||||
"WOS": "Ostrów Mazowiecka",
|
||||
"WOT": "Otwock powiat",
|
||||
"WP": "Piaseczno",
|
||||
"WPI": "Płońsk",
|
||||
"WPL": "Płock",
|
||||
"WPN": "Przasnysz",
|
||||
"WPR": "Przysucha",
|
||||
"WPU": "Pułtusk",
|
||||
"WPY": "Płońsk powiat",
|
||||
"WPZ": "Przasnysz powiat",
|
||||
"WR": "Radom",
|
||||
"WRA": "Radom powiat",
|
||||
"WS": "Siedlce",
|
||||
"WSC": "Sokołów Podlaski",
|
||||
"WSE": "Siedlce powiat",
|
||||
"WSI": "Sierpc",
|
||||
"WSK": "Sochaczew",
|
||||
"WSZ": "Szydłowiec",
|
||||
"WT": "Warszawa Wawer",
|
||||
"WU": "Warszawa Ursus",
|
||||
"WV": "Ostrołęka powiat",
|
||||
"WW": "Warszawa Ochota",
|
||||
"WWL": "Wołomin",
|
||||
"WWY": "Wyszków",
|
||||
"WX": "Warszawa Ursynów",
|
||||
"WY": "Warszawa Wola",
|
||||
"WZ": "Żyrardów",
|
||||
"WZW": "Zwoleń",
|
||||
"OA": "Brzeg",
|
||||
"OB": "Namysłów",
|
||||
"OGL": "Głubczyce",
|
||||
"OK": "Kędzierzyn-Koźle",
|
||||
"OKL": "Kluczbork",
|
||||
"OKR": "Krapkowice",
|
||||
"OL": "Nysa",
|
||||
"ONA": "Namysłów powiat",
|
||||
"ONY": "Nysa powiat",
|
||||
"OP": "Opole",
|
||||
"OO": "Opole powiat",
|
||||
"OOL": "Olesno",
|
||||
"OPO": "Prudnik",
|
||||
"OST": "Strzelce Opolskie",
|
||||
"RB": "Brzozów",
|
||||
"RBI": "Biłgoraj",
|
||||
"RC": "Rzeszów Centrum",
|
||||
"RD": "Dębica",
|
||||
"RDE": "Dębica powiat",
|
||||
"RJ": "Jarosław",
|
||||
"RJA": "Jarosław powiat",
|
||||
"RJS": "Jasło",
|
||||
"RK": "Krosno",
|
||||
"RKL": "Kolbuszowa",
|
||||
"RKR": "Krosno powiat",
|
||||
"RL": "Leżajsk",
|
||||
"RLE": "Lesko",
|
||||
"RLS": "Lubaczów",
|
||||
"RLU": "Łańcut",
|
||||
"RM": "Mielec",
|
||||
"RMI": "Mielec powiat",
|
||||
"RN": "Nisko",
|
||||
"RP": "Przemyśl",
|
||||
"RPR": "Przemyśl powiat",
|
||||
"RPZ": "Przeworsk",
|
||||
"RR": "Rzeszów",
|
||||
"RRS": "Ropczyce-Sędziszów",
|
||||
"RRZ": "Rzeszów powiat",
|
||||
"RSA": "Sanok",
|
||||
"RSN": "Sanok powiat",
|
||||
"RSR": "Stalowa Wola",
|
||||
"RST": "Strzyżów",
|
||||
"RTA": "Tarnobrzeg",
|
||||
"RZ": "Rzeszów",
|
||||
"BA": "Augustów",
|
||||
"BBI": "Białystok",
|
||||
"BC": "Hajnówka",
|
||||
"BD": "Bielsk Podlaski",
|
||||
"BE": "Wysokie Mazowieckie",
|
||||
"BG": "Grajewo",
|
||||
"BGR": "Grajewo powiat",
|
||||
"BH": "Hajnówka powiat",
|
||||
"BHA": "Hajnówka",
|
||||
"BI": "Białystok",
|
||||
"BIA": "Białystok powiat",
|
||||
"BJ": "Kolno",
|
||||
"BK": "Kolno powiat",
|
||||
"BKL": "Kolno",
|
||||
"BL": "Łomża",
|
||||
"BLM": "Łomża powiat",
|
||||
"BLS": "Łomża",
|
||||
"BM": "Mońki",
|
||||
"BMN": "Mońki powiat",
|
||||
"BO": "Sokółka",
|
||||
"BP": "Zambrów",
|
||||
"BPI": "Piątnica",
|
||||
"BR": "Siemiatycze",
|
||||
"BS": "Sokółka powiat",
|
||||
"BSE": "Sejny",
|
||||
"BSI": "Siemiatycze powiat",
|
||||
"BSK": "Sokółka",
|
||||
"BSU": "Suwałki",
|
||||
"BT": "Suwałki powiat",
|
||||
"BWM": "Wysokie Mazowieckie powiat",
|
||||
"BZA": "Zambrów powiat",
|
||||
"GA": "Gdańsk",
|
||||
"GB": "Bytów",
|
||||
"GBY": "Bytów powiat",
|
||||
"GC": "Chojnice",
|
||||
"GCH": "Chojnice powiat",
|
||||
"GCZ": "Człuchów",
|
||||
"GD": "Gdańsk",
|
||||
"GDA": "Gdańsk powiat",
|
||||
"GDY": "Gdynia",
|
||||
"GI": "Kościerzyna",
|
||||
"GKA": "Kartuzy",
|
||||
"GKS": "Kościerzyna powiat",
|
||||
"GKW": "Kwidzyn",
|
||||
"GL": "Lębork",
|
||||
"GLE": "Lębork powiat",
|
||||
"GMB": "Malbork",
|
||||
"GND": "Nowy Dwór Gdański",
|
||||
"GP": "Puck",
|
||||
"GPU": "Puck powiat",
|
||||
"GS": "Słupsk",
|
||||
"GSL": "Słupsk powiat",
|
||||
"GSP": "Starogard Gdański",
|
||||
"GST": "Sztum",
|
||||
"GT": "Tczew",
|
||||
"GTB": "Tczew powiat",
|
||||
"GW": "Wejherowo",
|
||||
"GWE": "Wejherowo powiat",
|
||||
"SA": "Sosnowiec",
|
||||
"SB": "Bielsko-Biała",
|
||||
"SBB": "Bielsko-Biała powiat",
|
||||
"SBE": "Będzin",
|
||||
"SBI": "Bieruń-Lędziny",
|
||||
"SC": "Chorzów",
|
||||
"SCH": "Cieszyn",
|
||||
"SCI": "Cieszyn powiat",
|
||||
"SD": "Dąbrowa Górnicza",
|
||||
"SF": "Racibórz",
|
||||
"SG": "Gliwice",
|
||||
"SGI": "Gliwice powiat",
|
||||
"SH": "Chorzów",
|
||||
"SI": "Siemianowice Śląskie",
|
||||
"SJ": "Jastrzębie-Zdrój",
|
||||
"SJZ": "Jastrzębie-Zdrój",
|
||||
"SK": "Katowice",
|
||||
"SKA": "Katowice powiat",
|
||||
"SKL": "Kłobuck",
|
||||
"SKT": "Lubliniec",
|
||||
"SL": "Rybnik",
|
||||
"SLU": "Lubliniec powiat",
|
||||
"SM": "Mysłowice",
|
||||
"SMI": "Mikołów",
|
||||
"SML": "Myszków",
|
||||
"SN": "Nowy Targ",
|
||||
"SO": "Sosnowiec powiat",
|
||||
"SP": "Piekary Śląskie",
|
||||
"SPI": "Pszczyna",
|
||||
"SPS": "Pszczyna powiat",
|
||||
"SR": "Rybnik powiat",
|
||||
"SRC": "Racibórz powiat",
|
||||
"SRY": "Rybnik",
|
||||
"SS": "Świętochłowice",
|
||||
"ST": "Tychy",
|
||||
"STA": "Tarnowskie Góry",
|
||||
"STG": "Tarnowskie Góry powiat",
|
||||
"SW": "Wodzisław Śląski",
|
||||
"SWD": "Wodzisław Śląski powiat",
|
||||
"SY": "Ruda Śląska",
|
||||
"SZ": "Zabrze",
|
||||
"SZA": "Zawiercie",
|
||||
"SZO": "Żory",
|
||||
"SZY": "Żywiec",
|
||||
"TB": "Busko-Zdrój",
|
||||
"TBU": "Busko-Zdrój powiat",
|
||||
"TJE": "Jędrzejów",
|
||||
"TK": "Kielce",
|
||||
"TKA": "Kazimierza Wielka",
|
||||
"TKI": "Kielce powiat",
|
||||
"TKN": "Końskie",
|
||||
"TKO": "Końskie powiat",
|
||||
"TOS": "Ostrołęka",
|
||||
"TPI": "Pińczów",
|
||||
"TSA": "Sandomierz",
|
||||
"TSK": "Skarżysko-Kamienna",
|
||||
"TST": "Starachowice",
|
||||
"TWL": "Włoszczowa",
|
||||
"NBA": "Bartoszyce",
|
||||
"NBR": "Braniewo",
|
||||
"NDZ": "Działdowo",
|
||||
"NE": "Elbląg",
|
||||
"NEL": "Elbląg powiat",
|
||||
"NEB": "Ełk",
|
||||
"NEK": "Ełk powiat",
|
||||
"NG": "Giżycko",
|
||||
"NGI": "Giżycko powiat",
|
||||
"NGO": "Gołdap",
|
||||
"NI": "Iława",
|
||||
"NKE": "Kętrzyn",
|
||||
"NL": "Lidzbark Warmiński",
|
||||
"NMR": "Mrągowo",
|
||||
"NNI": "Nidzica",
|
||||
"NO": "Olsztyn",
|
||||
"NOE": "Olecko",
|
||||
"NOL": "Olsztyn powiat",
|
||||
"NOS": "Ostróda",
|
||||
"NPI": "Pisz",
|
||||
"NSZ": "Szczytno",
|
||||
"NW": "Węgorzewo",
|
||||
"PCD": "Czarnków-Trzcianka",
|
||||
"PCH": "Chodzież",
|
||||
"PGN": "Gniezno",
|
||||
"PGO": "Gostyń",
|
||||
"PGR": "Grodzisk Wielkopolski",
|
||||
"PIA": "Piła",
|
||||
"PJ": "Jarocin",
|
||||
"PJA": "Jarocin powiat",
|
||||
"PK": "Kępno",
|
||||
"PKA": "Kalisz",
|
||||
"PKL": "Kalisz powiat",
|
||||
"PKN": "Koło",
|
||||
"PKO": "Konin",
|
||||
"PKS": "Kościan",
|
||||
"PL": "Leszno",
|
||||
"PLE": "Leszno powiat",
|
||||
"PMI": "Międzychód",
|
||||
"PNT": "Nowy Tomyśl",
|
||||
"PO": "Poznań",
|
||||
"POB": "Oborniki",
|
||||
"POL": "Ostrów Wielkopolski",
|
||||
"POP": "Opole",
|
||||
"POS": "Ostrzeszów",
|
||||
"POT": "Ostrów Wielkopolski powiat",
|
||||
"PP": "Pleszew",
|
||||
"PPI": "Piła powiat",
|
||||
"PPL": "Pleszew powiat",
|
||||
"PRA": "Poznań powiat",
|
||||
"PRS": "Rawicz",
|
||||
"PSE": "Śrem",
|
||||
"PSL": "Słupca",
|
||||
"PSR": "Środa Wielkopolska",
|
||||
"PSZ": "Szamotuły",
|
||||
"PT": "Turek",
|
||||
"PTU": "Turek powiat",
|
||||
"PW": "Wągrowiec",
|
||||
"PWA": "Wągrowiec powiat",
|
||||
"PWL": "Wolsztyn",
|
||||
"PWR": "Września",
|
||||
"PZ": "Poznań",
|
||||
"PZL": "Złotów",
|
||||
"ZBI": "Białogard",
|
||||
"ZCH": "Choszczno",
|
||||
"ZG": "Gryfice",
|
||||
"ZGR": "Gryfino",
|
||||
"ZI": "Stargard",
|
||||
"ZK": "Kołobrzeg",
|
||||
"ZKA": "Kamień Pomorski",
|
||||
"ZKL": "Kołobrzeg powiat",
|
||||
"ZKO": "Koszalin",
|
||||
"ZKS": "Koszalin powiat",
|
||||
"ZL": "Łobez",
|
||||
"ZM": "Myślibórz",
|
||||
"ZPL": "Pyrzyce",
|
||||
"ZPO": "Police",
|
||||
"ZS": "Szczecin",
|
||||
"ZSL": "Sławno",
|
||||
"ZST": "Stargard powiat",
|
||||
"ZSW": "Świnoujście",
|
||||
"ZSZ": "Szczecinek",
|
||||
"ZW": "Wałcz",
|
||||
"ZZ": "Szczecin powiat",
|
||||
}
|
||||
@ -0,0 +1,244 @@
|
||||
"""Anki flashcard generator for Polish car license plates.
|
||||
|
||||
Generates Anki-compatible flashcard decks with bidirectional cards for Polish
|
||||
vehicle registration plate codes and their corresponding locations.
|
||||
|
||||
Creates two types of cards:
|
||||
1. Code → Location (e.g., WY → Warszawa Wola)
|
||||
2. Location → Code (e.g., Warszawa Wola → WY)
|
||||
|
||||
Usage:
|
||||
# Generate Anki cards for all Polish license plates
|
||||
python -m python_pkg.anki_decks.polish_license_plates.polish_license_plates_anki
|
||||
|
||||
# Specify custom output file
|
||||
python -m python_pkg.anki_decks.polish_license_plates.polish_license_plates_anki \
|
||||
--output plates.apkg
|
||||
|
||||
Output:
|
||||
Creates a self-contained .apkg file that can be directly imported into Anki.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import genanki
|
||||
|
||||
from python_pkg.anki_decks.polish_license_plates.license_plate_data import (
|
||||
LICENSE_PLATE_CODES,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
deck_name: str = "Polish License Plates",
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package (.apkg) for Polish license plates.
|
||||
|
||||
Creates two cards for each license plate code:
|
||||
1. Code → Location
|
||||
2. Location → Code
|
||||
|
||||
Args:
|
||||
deck_name: Name for the Anki deck.
|
||||
|
||||
Returns:
|
||||
genanki.Package object ready to be written to file.
|
||||
"""
|
||||
# Create unique model ID based on deck name
|
||||
model_id_hash = hashlib.md5(
|
||||
f"polish_license_plates_{deck_name}".encode(),
|
||||
usedforsecurity=False,
|
||||
)
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
# Define the note model with centered styling and bidirectional templates
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 28px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.question {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.card.night_mode .question {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
.answer {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #27AE60;
|
||||
}
|
||||
.card.night_mode .answer {
|
||||
color: #2ECC71;
|
||||
}
|
||||
.plate-code {
|
||||
font-family: 'Courier New', monospace;
|
||||
background-color: #FFD700;
|
||||
color: #000;
|
||||
padding: 15px 30px;
|
||||
border: 3px solid #000;
|
||||
border-radius: 8px;
|
||||
display: inline-block;
|
||||
letter-spacing: 5px;
|
||||
}
|
||||
.card.night_mode .plate-code {
|
||||
background-color: #FFA500;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Polish License Plate Model",
|
||||
fields=[
|
||||
{"name": "Code"},
|
||||
{"name": "Location"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Code → Location",
|
||||
"qfmt": '<div class="question">'
|
||||
'<span class="plate-code">{{Code}}</span>'
|
||||
"</div>",
|
||||
"afmt": '<div class="question">'
|
||||
'<span class="plate-code">{{Code}}</span>'
|
||||
"</div>"
|
||||
'<hr id="answer">'
|
||||
'<div class="answer">{{Location}}</div>',
|
||||
},
|
||||
{
|
||||
"name": "Location → Code",
|
||||
"qfmt": '<div class="question">{{Location}}</div>',
|
||||
"afmt": '<div class="question">{{Location}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer">'
|
||||
'<span class="plate-code">{{Code}}</span>'
|
||||
"</div>",
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
# Create unique deck ID
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
|
||||
# Create the deck
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
|
||||
# Generate notes for each license plate code
|
||||
for code, location in sorted(LICENSE_PLATE_CODES.items()):
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[code, location],
|
||||
tags=["geography", "poland", "license-plates", "transportation"],
|
||||
)
|
||||
my_deck.add_note(note)
|
||||
|
||||
# Create package
|
||||
return genanki.Package(my_deck)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point.
|
||||
|
||||
Args:
|
||||
argv: Command line arguments.
|
||||
|
||||
Returns:
|
||||
Exit code.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Polish license plates.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: polish_license_plates.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Polish License Plates",
|
||||
help="Name for the Anki deck (default: 'Polish License Plates')",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# Determine output path
|
||||
output_path = (
|
||||
Path(args.output) if args.output else Path("polish_license_plates.apkg")
|
||||
)
|
||||
|
||||
try:
|
||||
num_codes = len(LICENSE_PLATE_CODES)
|
||||
num_cards = num_codes * 2 # Two cards per code (bidirectional)
|
||||
|
||||
sys.stdout.write(
|
||||
f"Generating flashcards for {num_codes} Polish license plate codes...\n"
|
||||
)
|
||||
sys.stdout.write(
|
||||
"Each code will have 2 cards: Code → Location and Location → Code\n"
|
||||
)
|
||||
|
||||
# Generate the package
|
||||
package = generate_anki_package(args.deck_name)
|
||||
|
||||
# Write to file
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 70 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 70 + "\n")
|
||||
sys.stdout.write(f"License plate codes: {num_codes}\n")
|
||||
sys.stdout.write(f"Total flashcards: {num_cards} (bidirectional)\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("Card types:\n")
|
||||
sys.stdout.write(" 1. Code → Location (e.g., WY → Warszawa Wola)\n")
|
||||
sys.stdout.write(" 2. Location → Code (e.g., Warszawa Wola → WY)\n")
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("To import into Anki:\n")
|
||||
sys.stdout.write(" 1. Open Anki\n")
|
||||
sys.stdout.write(" 2. File → Import\n")
|
||||
sys.stdout.write(f" 3. Select: {output_path.absolute()}\n")
|
||||
sys.stdout.write(" 4. Click Import\n")
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("You can now learn Polish license plates both ways!\n")
|
||||
except (OSError, ValueError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -0,0 +1 @@
|
||||
"""Tests init file."""
|
||||
@ -0,0 +1,231 @@
|
||||
"""Tests for the Polish license plates Anki generator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from python_pkg.anki_decks.polish_license_plates.license_plate_data import (
|
||||
LICENSE_PLATE_CODES,
|
||||
)
|
||||
from python_pkg.anki_decks.polish_license_plates.polish_license_plates_anki import (
|
||||
generate_anki_package,
|
||||
main,
|
||||
)
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
|
||||
from python_pkg.anki_decks.polish_license_plates.license_plate_data import (
|
||||
LICENSE_PLATE_CODES,
|
||||
)
|
||||
from python_pkg.anki_decks.polish_license_plates.polish_license_plates_anki import (
|
||||
generate_anki_package,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
class TestLicensePlateData:
|
||||
"""Tests for license plate data."""
|
||||
|
||||
def test_has_codes(self) -> None:
|
||||
"""Test that we have license plate codes."""
|
||||
assert len(LICENSE_PLATE_CODES) > 0
|
||||
|
||||
def test_all_codes_are_uppercase(self) -> None:
|
||||
"""Test that all codes are uppercase strings."""
|
||||
for code in LICENSE_PLATE_CODES:
|
||||
assert isinstance(code, str)
|
||||
assert code.isupper()
|
||||
assert len(code) >= 2
|
||||
|
||||
def test_all_locations_are_strings(self) -> None:
|
||||
"""Test that all locations are non-empty strings."""
|
||||
for location in LICENSE_PLATE_CODES.values():
|
||||
assert isinstance(location, str)
|
||||
assert len(location) > 0
|
||||
|
||||
def test_no_duplicate_codes(self) -> None:
|
||||
"""Test that all codes are unique."""
|
||||
codes = list(LICENSE_PLATE_CODES.keys())
|
||||
assert len(codes) == len(set(codes))
|
||||
|
||||
def test_warsaw_codes_present(self) -> None:
|
||||
"""Test that Warsaw codes are in the database."""
|
||||
warsaw_codes = [
|
||||
"WA",
|
||||
"WB",
|
||||
"WC",
|
||||
"WD",
|
||||
"WE",
|
||||
"WF",
|
||||
"WG",
|
||||
"WH",
|
||||
"WI",
|
||||
"WJ",
|
||||
"WK",
|
||||
"WL",
|
||||
"WM",
|
||||
"WN",
|
||||
"WO",
|
||||
"WP",
|
||||
"WR",
|
||||
"WS",
|
||||
"WT",
|
||||
"WU",
|
||||
"WW",
|
||||
"WX",
|
||||
"WY",
|
||||
"WZ",
|
||||
]
|
||||
for code in warsaw_codes:
|
||||
assert code in LICENSE_PLATE_CODES
|
||||
|
||||
def test_major_cities_present(self) -> None:
|
||||
"""Test that major Polish cities have codes."""
|
||||
major_cities = {
|
||||
"WA": "Warszawa",
|
||||
"KR": "Kraków",
|
||||
"GD": "Gdańsk",
|
||||
"PO": "Poznań",
|
||||
"WR": "Radom",
|
||||
"BI": "Białystok",
|
||||
}
|
||||
for code, city_part in major_cities.items():
|
||||
assert code in LICENSE_PLATE_CODES
|
||||
assert city_part.lower() in LICENSE_PLATE_CODES[code].lower()
|
||||
|
||||
def test_voivodeship_prefixes_present(self) -> None:
|
||||
"""Test that all 16 voivodeship prefixes are represented."""
|
||||
voivodeship_prefixes = {
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
"G",
|
||||
"K",
|
||||
"L",
|
||||
"N",
|
||||
"O",
|
||||
"P",
|
||||
"R",
|
||||
"S",
|
||||
"T",
|
||||
"W",
|
||||
"Z",
|
||||
}
|
||||
found_prefixes = {code[0] for code in LICENSE_PLATE_CODES}
|
||||
assert voivodeship_prefixes.issubset(found_prefixes)
|
||||
|
||||
|
||||
class TestGenerateAnkiPackage:
|
||||
"""Tests for generating Anki package."""
|
||||
|
||||
def test_generates_package(self) -> None:
|
||||
"""Test that output is a genanki Package."""
|
||||
package = generate_anki_package("Test Deck")
|
||||
assert package is not None
|
||||
assert len(package.decks) == 1
|
||||
|
||||
def test_generates_notes_for_all_codes(self) -> None:
|
||||
"""Test that package contains notes for all license plate codes."""
|
||||
package = generate_anki_package()
|
||||
deck = package.decks[0]
|
||||
# Each code generates one note with two card templates
|
||||
assert len(deck.notes) == len(LICENSE_PLATE_CODES)
|
||||
|
||||
def test_custom_deck_name(self) -> None:
|
||||
"""Test that custom deck name is used."""
|
||||
package = generate_anki_package("Custom Deck")
|
||||
deck = package.decks[0]
|
||||
assert deck.name == "Custom Deck"
|
||||
|
||||
def test_notes_have_correct_fields(self) -> None:
|
||||
"""Test that notes have Code and Location fields."""
|
||||
package = generate_anki_package()
|
||||
deck = package.decks[0]
|
||||
note = deck.notes[0]
|
||||
# Note should have 2 fields: Code and Location
|
||||
assert len(note.fields) == 2
|
||||
# Fields should be non-empty strings
|
||||
assert len(note.fields[0]) > 0
|
||||
assert len(note.fields[1]) > 0
|
||||
|
||||
def test_notes_have_tags(self) -> None:
|
||||
"""Test that notes have appropriate tags."""
|
||||
package = generate_anki_package()
|
||||
deck = package.decks[0]
|
||||
note = deck.notes[0]
|
||||
assert "geography" in note.tags
|
||||
assert "poland" in note.tags
|
||||
assert "license-plates" in note.tags
|
||||
|
||||
def test_model_has_bidirectional_templates(self) -> None:
|
||||
"""Test that the model has two card templates (bidirectional)."""
|
||||
package = generate_anki_package()
|
||||
deck = package.decks[0]
|
||||
model = deck.notes[0].model
|
||||
# Should have 2 templates: Code → Location and Location → Code
|
||||
assert len(model.templates) == 2
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Tests for the main CLI function."""
|
||||
|
||||
def test_creates_output_file(self, tmp_path: Path) -> None:
|
||||
"""Test that main creates the output file."""
|
||||
output_file = tmp_path / "test_output.apkg"
|
||||
|
||||
result = main(
|
||||
[
|
||||
"--output",
|
||||
str(output_file),
|
||||
]
|
||||
)
|
||||
|
||||
assert result == 0
|
||||
assert output_file.exists()
|
||||
|
||||
def test_custom_deck_name(self, tmp_path: Path) -> None:
|
||||
"""Test that custom deck name is used."""
|
||||
output_file = tmp_path / "test_output.apkg"
|
||||
|
||||
result = main(
|
||||
[
|
||||
"--output",
|
||||
str(output_file),
|
||||
"--deck-name",
|
||||
"Custom Deck",
|
||||
]
|
||||
)
|
||||
|
||||
assert result == 0
|
||||
assert output_file.exists()
|
||||
|
||||
def test_default_output_path(self) -> None:
|
||||
"""Test that default output path is used when not specified."""
|
||||
# Clean up any existing file
|
||||
default_path = Path("polish_license_plates.apkg")
|
||||
if default_path.exists():
|
||||
default_path.unlink()
|
||||
|
||||
result = main([])
|
||||
|
||||
assert result == 0
|
||||
assert default_path.exists()
|
||||
|
||||
# Clean up
|
||||
default_path.unlink()
|
||||
|
||||
def test_help_flag(self) -> None:
|
||||
"""Test that --help works."""
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["--help"])
|
||||
assert exc_info.value.code == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
1
python_pkg/anki_decks/polish_mountain_peaks/__init__.py
Normal file
1
python_pkg/anki_decks/polish_mountain_peaks/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Polish mountain peaks Anki deck generator."""
|
||||
@ -0,0 +1,378 @@
|
||||
"""Anki flashcard generator for Polish mountain peaks.
|
||||
|
||||
Generates Anki-compatible flashcard decks with ZOOMED maps showing mountain peaks
|
||||
highlighted on a regional map for better visibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
import multiprocessing as mp
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import genanki
|
||||
import geopandas as gpd
|
||||
import matplotlib as mpl
|
||||
|
||||
mpl.use("Agg") # Non-interactive backend for multiprocessing
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from geo_data import get_poland_boundary, get_polish_mountain_peaks
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
MARKER_COLOR = "#E74C3C" # Red marker for peaks
|
||||
ZOOM_PADDING_DEG = 0.5 # Degrees of padding around peak for zoomed view
|
||||
|
||||
|
||||
def create_peak_map(
|
||||
peak_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
*,
|
||||
zoom: bool = True,
|
||||
) -> Figure:
|
||||
"""Create a map showing Poland with one peak highlighted.
|
||||
|
||||
Args:
|
||||
peak_gdf: GeoDataFrame with the peak point.
|
||||
poland_boundary: GeoDataFrame with Poland boundary.
|
||||
zoom: If True, zoom to peak area; if False, show entire Poland.
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=(10, 12))
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.patch.set_alpha(0)
|
||||
ax.patch.set_alpha(0)
|
||||
|
||||
# Plot Poland as a plain gray shape
|
||||
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
|
||||
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
|
||||
|
||||
# Plot the peak as a marker
|
||||
peak_gdf.plot(
|
||||
ax=ax,
|
||||
color=MARKER_COLOR,
|
||||
markersize=400 if zoom else 200,
|
||||
marker="^", # Triangle for mountain
|
||||
edgecolor="#1A1A1A",
|
||||
linewidth=2,
|
||||
zorder=5,
|
||||
)
|
||||
|
||||
if zoom:
|
||||
# Zoom to peak area with padding
|
||||
geom = peak_gdf.iloc[0].geometry
|
||||
peak_x, peak_y = geom.x, geom.y
|
||||
ax.set_xlim(peak_x - ZOOM_PADDING_DEG, peak_x + ZOOM_PADDING_DEG)
|
||||
ax.set_ylim(peak_y - ZOOM_PADDING_DEG, peak_y + ZOOM_PADDING_DEG)
|
||||
else:
|
||||
# Set bounds to Poland
|
||||
bounds = poland_boundary.total_bounds
|
||||
ax.set_xlim(bounds[0], bounds[2])
|
||||
ax.set_ylim(bounds[1], bounds[3])
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_peak_image_bytes(
|
||||
peak_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
*,
|
||||
zoom: bool = True,
|
||||
) -> bytes:
|
||||
"""Generate a peak map image as bytes."""
|
||||
fig = create_peak_map(peak_gdf, poland_boundary, zoom=zoom)
|
||||
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
return buf.read()
|
||||
|
||||
|
||||
# Global variables for multiprocessing (set via initializer)
|
||||
_mp_poland_boundary: gpd.GeoDataFrame | None = None
|
||||
_mp_zoom: bool = True
|
||||
|
||||
|
||||
def _init_worker(poland_geojson: str, zoom_mode: str) -> None:
|
||||
"""Initialize worker process with shared data."""
|
||||
global _mp_poland_boundary, _mp_zoom # noqa: PLW0603
|
||||
_mp_poland_boundary = gpd.read_file(poland_geojson)
|
||||
_mp_zoom = zoom_mode == "zoom"
|
||||
|
||||
|
||||
def _render_single_peak(args: tuple[str, str]) -> tuple[str, bytes]:
|
||||
"""Render a single peak image (worker function).
|
||||
|
||||
Args:
|
||||
args: Tuple of (peak_name, peak_geojson_str).
|
||||
|
||||
Returns:
|
||||
Tuple of (peak_name, image_bytes).
|
||||
"""
|
||||
peak_name, peak_geojson = args
|
||||
peak_gdf = gpd.read_file(peak_geojson)
|
||||
|
||||
assert _mp_poland_boundary is not None # noqa: S101
|
||||
|
||||
image_data = generate_peak_image_bytes(peak_gdf, _mp_poland_boundary, zoom=_mp_zoom)
|
||||
return peak_name, image_data
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
peaks: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
deck_name: str = "Polish Mountain Peaks",
|
||||
*,
|
||||
zoom: bool = True,
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package for Polish mountain peaks."""
|
||||
model_id_hash = hashlib.md5( # noqa: S324
|
||||
f"polish_mountain_peaks_{deck_name}".encode()
|
||||
)
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.answer-text {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.card.night_mode .answer-text {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
.info-text {
|
||||
font-size: 18px;
|
||||
color: #7F8C8D;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.card.night_mode .info-text {
|
||||
color: #BDC3C7;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Polish Mountain Peak Model",
|
||||
fields=[
|
||||
{"name": "PeakMap"},
|
||||
{"name": "PeakName"},
|
||||
{"name": "Elevation"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": '<div class="map-container">{{PeakMap}}</div>',
|
||||
"afmt": '<div class="map-container">{{PeakMap}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer-text">{{PeakName}}</div>'
|
||||
'<div class="info-text">{{Elevation}} m n.p.m.</div>',
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
media_files = []
|
||||
|
||||
# Prepare data for parallel processing
|
||||
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
|
||||
poland_boundary.to_file(f.name, driver="GeoJSON")
|
||||
poland_geojson_path = f.name
|
||||
|
||||
# Prepare work items: (peak_name, peak_geojson_str)
|
||||
work_items: list[tuple[str, str]] = []
|
||||
for _, row in peaks.iterrows():
|
||||
peak_gdf = gpd.GeoDataFrame([row], crs=peaks.crs)
|
||||
peak_geojson = peak_gdf.to_json()
|
||||
work_items.append((row["name"], peak_geojson))
|
||||
|
||||
# Use multiprocessing for parallel rendering
|
||||
num_workers = min(mp.cpu_count(), 8)
|
||||
sys.stdout.write(
|
||||
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
|
||||
)
|
||||
|
||||
results: dict[str, bytes] = {}
|
||||
with mp.Pool(
|
||||
num_workers,
|
||||
initializer=_init_worker,
|
||||
initargs=(poland_geojson_path, "zoom" if zoom else "no-zoom"),
|
||||
) as pool:
|
||||
for i, (peak_name, image_data) in enumerate(
|
||||
pool.imap_unordered(_render_single_peak, work_items)
|
||||
):
|
||||
results[peak_name] = image_data
|
||||
if (i + 1) % 50 == 0:
|
||||
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
|
||||
|
||||
# Clean up temp file
|
||||
Path(poland_geojson_path).unlink(missing_ok=True)
|
||||
|
||||
# Create notes from results
|
||||
for _, row in peaks.iterrows():
|
||||
peak_name = row["name"]
|
||||
elevation = int(row["elevation"])
|
||||
image_data = results[peak_name]
|
||||
filename = f"peak_{peak_name.replace(' ', '_').replace('/', '_')}.png"
|
||||
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[f'<img src="{filename}">', peak_name, str(elevation)],
|
||||
tags=["geography", "poland", "mountains", "peaks"],
|
||||
)
|
||||
my_deck.add_note(note)
|
||||
|
||||
temp_path = Path(f"/tmp/{filename}") # noqa: S108
|
||||
temp_path.write_bytes(image_data)
|
||||
media_files.append(str(temp_path))
|
||||
|
||||
package = genanki.Package(my_deck)
|
||||
package.media_files = media_files
|
||||
return package
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Polish mountain peaks.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: polish_mountain_peaks.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Polish Mountain Peaks",
|
||||
help="Name for the Anki deck",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
"-p",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Export preview images to specified directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview-count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of preview images to export (default: 5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-zoom",
|
||||
action="store_true",
|
||||
help="Disable zoom (show entire Poland instead of zoomed region)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
"-l",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Limit number of peaks (for testing)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
output_path = (
|
||||
Path(args.output) if args.output else Path("polish_mountain_peaks.apkg")
|
||||
)
|
||||
zoom = not args.no_zoom
|
||||
|
||||
try:
|
||||
sys.stdout.write("Loading mountain peaks data...\n")
|
||||
peaks = get_polish_mountain_peaks()
|
||||
poland_boundary = get_poland_boundary()
|
||||
|
||||
if args.limit:
|
||||
peaks = peaks.head(args.limit)
|
||||
sys.stdout.write(f"Limiting to {args.limit} peaks.\n")
|
||||
|
||||
num_peaks = len(peaks)
|
||||
|
||||
sys.stdout.write(f"Found {num_peaks} mountain peaks.\n")
|
||||
sys.stdout.write(f"Zoom mode: {'enabled' if zoom else 'disabled'}\n")
|
||||
sys.stdout.write("Generating flashcards...\n")
|
||||
|
||||
package = generate_anki_package(
|
||||
peaks, poland_boundary, args.deck_name, zoom=zoom
|
||||
)
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
# Export preview images if requested
|
||||
if args.preview:
|
||||
preview_dir = Path(args.preview)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_peaks = list(peaks.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_peaks)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_peaks:
|
||||
peak_name = row["name"]
|
||||
peak_gdf = gpd.GeoDataFrame([row], crs=peaks.crs)
|
||||
image_data = generate_peak_image_bytes(
|
||||
peak_gdf, poland_boundary, zoom=zoom
|
||||
)
|
||||
safe_name = peak_name.replace(" ", "_").replace("/", "_")
|
||||
preview_path = preview_dir / f"{safe_name}.png"
|
||||
preview_path.write_bytes(image_data)
|
||||
sys.stdout.write(f" Saved: {preview_path.name}\n")
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write(f"Mountain peaks: {num_peaks}\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
if args.preview:
|
||||
sys.stdout.write(f"Preview images: {args.preview}\n")
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
6
python_pkg/anki_decks/polish_mountain_peaks/run.sh
Executable file
6
python_pkg/anki_decks/polish_mountain_peaks/run.sh
Executable file
@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Run the Polish mountain peaks Anki generator
|
||||
|
||||
cd "$(dirname "$0")" || exit
|
||||
|
||||
python polish_mountain_peaks_anki.py --preview preview_images --preview-count 5 "$@"
|
||||
1
python_pkg/anki_decks/polish_mountain_ranges/__init__.py
Normal file
1
python_pkg/anki_decks/polish_mountain_ranges/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Polish mountain ranges Anki generator."""
|
||||
@ -0,0 +1,332 @@
|
||||
"""Anki flashcard generator for Polish mountain ranges.
|
||||
|
||||
Generates Anki-compatible flashcard decks with maps showing mountain ranges
|
||||
highlighted on a Poland map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
import multiprocessing as mp
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import genanki
|
||||
import geopandas as gpd
|
||||
import matplotlib as mpl
|
||||
|
||||
mpl.use("Agg") # Non-interactive backend for multiprocessing
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from geo_data import get_poland_boundary, get_polish_mountain_ranges
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
RANGE_COLOR = "#7B5A31" # Brown for mountain ranges
|
||||
|
||||
|
||||
def create_range_map(
|
||||
range_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
) -> Figure:
|
||||
"""Create a map showing Poland with one mountain range highlighted."""
|
||||
fig, ax = plt.subplots(figsize=(10, 12))
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.patch.set_alpha(0)
|
||||
ax.patch.set_alpha(0)
|
||||
|
||||
# Plot Poland as a plain gray shape
|
||||
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
|
||||
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
|
||||
|
||||
# Clip mountain range to Poland boundary
|
||||
clipped_gdf = range_gdf.copy()
|
||||
clipped_gdf["geometry"] = range_gdf.geometry.intersection(
|
||||
poland_boundary.union_all()
|
||||
)
|
||||
|
||||
# Plot the mountain range (clipped to Poland)
|
||||
clipped_gdf.plot(ax=ax, color=RANGE_COLOR, alpha=0.9)
|
||||
clipped_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=1.5)
|
||||
|
||||
# Set bounds to Poland
|
||||
bounds = poland_boundary.total_bounds
|
||||
ax.set_xlim(bounds[0], bounds[2])
|
||||
ax.set_ylim(bounds[1], bounds[3])
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_range_image_bytes(
|
||||
range_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
) -> bytes:
|
||||
"""Generate a range map image as bytes."""
|
||||
fig = create_range_map(range_gdf, poland_boundary)
|
||||
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
return buf.read()
|
||||
|
||||
|
||||
# Global variables for multiprocessing (set via initializer)
|
||||
_mp_poland_boundary: gpd.GeoDataFrame | None = None
|
||||
|
||||
|
||||
def _init_worker(poland_geojson: str) -> None:
|
||||
"""Initialize worker process with shared data."""
|
||||
global _mp_poland_boundary # noqa: PLW0603
|
||||
_mp_poland_boundary = gpd.read_file(poland_geojson)
|
||||
|
||||
|
||||
def _render_single_range(args: tuple[str, str]) -> tuple[str, bytes]:
|
||||
"""Render a single range image (worker function).
|
||||
|
||||
Args:
|
||||
args: Tuple of (range_name, range_geojson_str).
|
||||
|
||||
Returns:
|
||||
Tuple of (range_name, image_bytes).
|
||||
"""
|
||||
range_name, range_geojson = args
|
||||
range_gdf = gpd.read_file(range_geojson)
|
||||
|
||||
assert _mp_poland_boundary is not None # noqa: S101
|
||||
|
||||
image_data = generate_range_image_bytes(range_gdf, _mp_poland_boundary)
|
||||
return range_name, image_data
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
ranges: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
deck_name: str = "Polish Mountain Ranges",
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package for Polish mountain ranges."""
|
||||
model_id_hash = hashlib.md5( # noqa: S324
|
||||
f"polish_mountain_ranges_{deck_name}".encode()
|
||||
)
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.answer-text {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.card.night_mode .answer-text {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
.info-text {
|
||||
font-size: 18px;
|
||||
color: #7F8C8D;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.card.night_mode .info-text {
|
||||
color: #BDC3C7;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Polish Mountain Range Model",
|
||||
fields=[
|
||||
{"name": "RangeMap"},
|
||||
{"name": "RangeName"},
|
||||
{"name": "Area"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": '<div class="map-container">{{RangeMap}}</div>',
|
||||
"afmt": '<div class="map-container">{{RangeMap}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer-text">{{RangeName}}</div>'
|
||||
'<div class="info-text">{{Area}} km²</div>',
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
media_files = []
|
||||
|
||||
# Prepare data for parallel processing
|
||||
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
|
||||
poland_boundary.to_file(f.name, driver="GeoJSON")
|
||||
poland_geojson_path = f.name
|
||||
|
||||
# Prepare work items: (range_name, range_geojson_str)
|
||||
work_items: list[tuple[str, str]] = []
|
||||
for _, row in ranges.iterrows():
|
||||
range_gdf = gpd.GeoDataFrame([row], crs=ranges.crs)
|
||||
range_geojson = range_gdf.to_json()
|
||||
work_items.append((row["name"], range_geojson))
|
||||
|
||||
# Use multiprocessing for parallel rendering
|
||||
num_workers = min(mp.cpu_count(), 8)
|
||||
sys.stdout.write(
|
||||
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
|
||||
)
|
||||
|
||||
results: dict[str, bytes] = {}
|
||||
with mp.Pool(
|
||||
num_workers,
|
||||
initializer=_init_worker,
|
||||
initargs=(poland_geojson_path,),
|
||||
) as pool:
|
||||
for i, (range_name, image_data) in enumerate(
|
||||
pool.imap_unordered(_render_single_range, work_items)
|
||||
):
|
||||
results[range_name] = image_data
|
||||
if (i + 1) % 10 == 0:
|
||||
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
|
||||
|
||||
# Clean up temp file
|
||||
Path(poland_geojson_path).unlink(missing_ok=True)
|
||||
|
||||
# Create notes from results
|
||||
for _, row in ranges.iterrows():
|
||||
range_name = row["name"]
|
||||
area_km2 = round(row["area_km2"], 1) if "area_km2" in row else 0
|
||||
image_data = results[range_name]
|
||||
filename = f"range_{range_name.replace(' ', '_').replace('/', '_')}.png"
|
||||
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[f'<img src="{filename}">', range_name, str(area_km2)],
|
||||
tags=["geography", "poland", "mountain-ranges", "mountains"],
|
||||
)
|
||||
my_deck.add_note(note)
|
||||
|
||||
temp_path = Path(f"/tmp/{filename}") # noqa: S108
|
||||
temp_path.write_bytes(image_data)
|
||||
media_files.append(str(temp_path))
|
||||
|
||||
package = genanki.Package(my_deck)
|
||||
package.media_files = media_files
|
||||
return package
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Polish mountain ranges.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: polish_mountain_ranges.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Polish Mountain Ranges",
|
||||
help="Name for the Anki deck",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
"-p",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Export preview images to specified directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview-count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of preview images to export (default: 5)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
output_path = (
|
||||
Path(args.output) if args.output else Path("polish_mountain_ranges.apkg")
|
||||
)
|
||||
|
||||
try:
|
||||
sys.stdout.write("Loading mountain ranges data...\n")
|
||||
ranges = get_polish_mountain_ranges()
|
||||
poland_boundary = get_poland_boundary()
|
||||
num_ranges = len(ranges)
|
||||
|
||||
sys.stdout.write(f"Found {num_ranges} mountain ranges.\n")
|
||||
sys.stdout.write("Generating flashcards...\n")
|
||||
|
||||
package = generate_anki_package(ranges, poland_boundary, args.deck_name)
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
# Export preview images if requested
|
||||
if args.preview:
|
||||
preview_dir = Path(args.preview)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_ranges = list(ranges.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_ranges)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_ranges:
|
||||
range_name = row["name"]
|
||||
range_gdf = gpd.GeoDataFrame([row], crs=ranges.crs)
|
||||
image_data = generate_range_image_bytes(range_gdf, poland_boundary)
|
||||
safe_name = range_name.replace(" ", "_").replace("/", "_")
|
||||
preview_path = preview_dir / f"{safe_name}.png"
|
||||
preview_path.write_bytes(image_data)
|
||||
sys.stdout.write(f" Saved: {preview_path.name}\n")
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write(f"Mountain ranges: {num_ranges}\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
if args.preview:
|
||||
sys.stdout.write(f"Preview images: {args.preview}\n")
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
6
python_pkg/anki_decks/polish_mountain_ranges/run.sh
Executable file
6
python_pkg/anki_decks/polish_mountain_ranges/run.sh
Executable file
@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Run the Polish mountain ranges Anki generator
|
||||
|
||||
cd "$(dirname "$0")" || exit
|
||||
|
||||
python polish_mountain_ranges_anki.py --preview preview_images --preview-count 5 "$@"
|
||||
1
python_pkg/anki_decks/polish_national_parks/__init__.py
Normal file
1
python_pkg/anki_decks/polish_national_parks/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Polish national parks Anki deck generator."""
|
||||
@ -0,0 +1,348 @@
|
||||
"""Anki flashcard generator for Polish national parks.
|
||||
|
||||
Generates Anki-compatible flashcard decks with maps showing national parks
|
||||
highlighted on a Poland map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
import multiprocessing as mp
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import genanki
|
||||
import geopandas as gpd
|
||||
import matplotlib as mpl
|
||||
|
||||
mpl.use("Agg") # Non-interactive backend for multiprocessing
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from geo_data import get_poland_boundary, get_polish_national_parks
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
PARK_COLOR = "#2ECC71" # Green for national parks
|
||||
|
||||
# Threshold for "small" parks that need an icon (in km²)
|
||||
SMALL_PARK_THRESHOLD_KM2 = 100
|
||||
|
||||
|
||||
def create_park_map(
|
||||
park_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
) -> Figure:
|
||||
"""Create a map showing Poland with one national park highlighted.
|
||||
|
||||
For small parks, also shows a tree marker at the centroid for visibility.
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=(10, 12))
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.patch.set_alpha(0)
|
||||
ax.patch.set_alpha(0)
|
||||
|
||||
# Plot Poland as a plain gray shape
|
||||
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
|
||||
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
|
||||
|
||||
# Plot the national park
|
||||
park_gdf.plot(ax=ax, color=PARK_COLOR, alpha=0.9)
|
||||
park_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=1.5)
|
||||
|
||||
# For small parks, add a tree marker at the centroid
|
||||
area_km2 = park_gdf.iloc[0].get("area_km2", 0)
|
||||
if area_km2 < SMALL_PARK_THRESHOLD_KM2:
|
||||
centroid = park_gdf.iloc[0].geometry.centroid
|
||||
# Use a tree-like marker (triangle pointing up)
|
||||
ax.scatter(
|
||||
[centroid.x],
|
||||
[centroid.y],
|
||||
s=600,
|
||||
c="#006400",
|
||||
marker="^",
|
||||
edgecolor="#1A1A1A",
|
||||
linewidth=2,
|
||||
zorder=10,
|
||||
)
|
||||
|
||||
# Set bounds to Poland
|
||||
bounds = poland_boundary.total_bounds
|
||||
ax.set_xlim(bounds[0], bounds[2])
|
||||
ax.set_ylim(bounds[1], bounds[3])
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_park_image_bytes(
|
||||
park_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
) -> bytes:
|
||||
"""Generate a park map image as bytes."""
|
||||
fig = create_park_map(park_gdf, poland_boundary)
|
||||
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
return buf.read()
|
||||
|
||||
|
||||
# Global variables for multiprocessing (set via initializer)
|
||||
_mp_poland_boundary: gpd.GeoDataFrame | None = None
|
||||
|
||||
|
||||
def _init_worker(poland_geojson: str) -> None:
|
||||
"""Initialize worker process with shared data."""
|
||||
global _mp_poland_boundary # noqa: PLW0603
|
||||
_mp_poland_boundary = gpd.read_file(poland_geojson)
|
||||
|
||||
|
||||
def _render_single_park(args: tuple[str, str]) -> tuple[str, bytes]:
|
||||
"""Render a single park image (worker function).
|
||||
|
||||
Args:
|
||||
args: Tuple of (park_name, park_geojson_str).
|
||||
|
||||
Returns:
|
||||
Tuple of (park_name, image_bytes).
|
||||
"""
|
||||
park_name, park_geojson = args
|
||||
park_gdf = gpd.read_file(park_geojson)
|
||||
|
||||
assert _mp_poland_boundary is not None # noqa: S101
|
||||
|
||||
image_data = generate_park_image_bytes(park_gdf, _mp_poland_boundary)
|
||||
return park_name, image_data
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
parks: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
deck_name: str = "Polish National Parks",
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package for Polish national parks."""
|
||||
model_id_hash = hashlib.md5( # noqa: S324
|
||||
f"polish_national_parks_{deck_name}".encode()
|
||||
)
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.answer-text {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.card.night_mode .answer-text {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
.info-text {
|
||||
font-size: 18px;
|
||||
color: #7F8C8D;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.card.night_mode .info-text {
|
||||
color: #BDC3C7;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Polish National Park Model",
|
||||
fields=[
|
||||
{"name": "ParkMap"},
|
||||
{"name": "ParkName"},
|
||||
{"name": "Area"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": '<div class="map-container">{{ParkMap}}</div>',
|
||||
"afmt": '<div class="map-container">{{ParkMap}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer-text">{{ParkName}}</div>'
|
||||
'<div class="info-text">{{Area}} km²</div>',
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
media_files = []
|
||||
|
||||
# Prepare data for parallel processing
|
||||
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
|
||||
poland_boundary.to_file(f.name, driver="GeoJSON")
|
||||
poland_geojson_path = f.name
|
||||
|
||||
# Prepare work items: (park_name, park_geojson_str)
|
||||
work_items: list[tuple[str, str]] = []
|
||||
for _, row in parks.iterrows():
|
||||
park_gdf = gpd.GeoDataFrame([row], crs=parks.crs)
|
||||
park_geojson = park_gdf.to_json()
|
||||
work_items.append((row["name"], park_geojson))
|
||||
|
||||
# Use multiprocessing for parallel rendering
|
||||
num_workers = min(mp.cpu_count(), 8)
|
||||
sys.stdout.write(
|
||||
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
|
||||
)
|
||||
|
||||
results: dict[str, bytes] = {}
|
||||
with mp.Pool(
|
||||
num_workers,
|
||||
initializer=_init_worker,
|
||||
initargs=(poland_geojson_path,),
|
||||
) as pool:
|
||||
for i, (park_name, image_data) in enumerate(
|
||||
pool.imap_unordered(_render_single_park, work_items)
|
||||
):
|
||||
results[park_name] = image_data
|
||||
if (i + 1) % 10 == 0:
|
||||
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
|
||||
|
||||
# Clean up temp file
|
||||
Path(poland_geojson_path).unlink(missing_ok=True)
|
||||
|
||||
# Create notes from results
|
||||
for _, row in parks.iterrows():
|
||||
park_name = row["name"]
|
||||
area_km2 = round(row["area_km2"], 1) if "area_km2" in row else 0
|
||||
image_data = results[park_name]
|
||||
filename = f"park_{park_name.replace(' ', '_').replace('/', '_')}.png"
|
||||
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[f'<img src="{filename}">', park_name, str(area_km2)],
|
||||
tags=["geography", "poland", "national-parks", "nature"],
|
||||
)
|
||||
my_deck.add_note(note)
|
||||
|
||||
temp_path = Path(f"/tmp/{filename}") # noqa: S108
|
||||
temp_path.write_bytes(image_data)
|
||||
media_files.append(str(temp_path))
|
||||
|
||||
package = genanki.Package(my_deck)
|
||||
package.media_files = media_files
|
||||
return package
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Polish national parks.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: polish_national_parks.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Polish National Parks",
|
||||
help="Name for the Anki deck",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
"-p",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Export preview images to specified directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview-count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of preview images to export (default: 5)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
output_path = (
|
||||
Path(args.output) if args.output else Path("polish_national_parks.apkg")
|
||||
)
|
||||
|
||||
try:
|
||||
sys.stdout.write("Loading national parks data...\n")
|
||||
parks = get_polish_national_parks()
|
||||
poland_boundary = get_poland_boundary()
|
||||
num_parks = len(parks)
|
||||
|
||||
sys.stdout.write(f"Found {num_parks} national parks.\n")
|
||||
sys.stdout.write("Generating flashcards...\n")
|
||||
|
||||
package = generate_anki_package(parks, poland_boundary, args.deck_name)
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
# Export preview images if requested
|
||||
if args.preview:
|
||||
preview_dir = Path(args.preview)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_parks = list(parks.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_parks)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_parks:
|
||||
park_name = row["name"]
|
||||
park_gdf = gpd.GeoDataFrame([row], crs=parks.crs)
|
||||
image_data = generate_park_image_bytes(park_gdf, poland_boundary)
|
||||
safe_name = park_name.replace(" ", "_").replace("/", "_")
|
||||
preview_path = preview_dir / f"{safe_name}.png"
|
||||
preview_path.write_bytes(image_data)
|
||||
sys.stdout.write(f" Saved: {preview_path.name}\n")
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write(f"National parks: {num_parks}\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
if args.preview:
|
||||
sys.stdout.write(f"Preview images: {args.preview}\n")
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
6
python_pkg/anki_decks/polish_national_parks/run.sh
Executable file
6
python_pkg/anki_decks/polish_national_parks/run.sh
Executable file
@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Run the Polish national parks Anki generator
|
||||
|
||||
cd "$(dirname "$0")" || exit
|
||||
|
||||
python polish_national_parks_anki.py --preview preview_images --preview-count 5 "$@"
|
||||
1
python_pkg/anki_decks/polish_nature_reserves/__init__.py
Normal file
1
python_pkg/anki_decks/polish_nature_reserves/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Polish nature reserves Anki generator."""
|
||||
@ -0,0 +1,345 @@
|
||||
"""Anki flashcard generator for Polish nature reserves.
|
||||
|
||||
Generates Anki-compatible flashcard decks with maps showing nature reserves
|
||||
highlighted on a Poland map. Optimized for large datasets (~1500 reserves).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
import multiprocessing as mp
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import genanki
|
||||
import geopandas as gpd
|
||||
import matplotlib as mpl
|
||||
|
||||
mpl.use("Agg") # Non-interactive backend for multiprocessing
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from geo_data import get_poland_boundary, get_polish_nature_reserves
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
RESERVE_COLOR = "#16A085" # Teal for nature reserves
|
||||
|
||||
|
||||
def create_reserve_map(
|
||||
reserve_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
) -> Figure:
|
||||
"""Create a map showing Poland with one nature reserve highlighted."""
|
||||
fig, ax = plt.subplots(figsize=(10, 12))
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.patch.set_alpha(0)
|
||||
ax.patch.set_alpha(0)
|
||||
|
||||
# Plot Poland as a plain gray shape
|
||||
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
|
||||
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
|
||||
|
||||
# Plot the nature reserve
|
||||
reserve_gdf.plot(ax=ax, color=RESERVE_COLOR, alpha=0.9)
|
||||
reserve_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=3)
|
||||
|
||||
# Set bounds to Poland
|
||||
bounds = poland_boundary.total_bounds
|
||||
ax.set_xlim(bounds[0], bounds[2])
|
||||
ax.set_ylim(bounds[1], bounds[3])
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_reserve_image_bytes(
|
||||
reserve_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
) -> bytes:
|
||||
"""Generate a reserve map image as bytes."""
|
||||
fig = create_reserve_map(reserve_gdf, poland_boundary)
|
||||
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
return buf.read()
|
||||
|
||||
|
||||
# Global variables for multiprocessing (set via initializer)
|
||||
_mp_poland_boundary: gpd.GeoDataFrame | None = None
|
||||
|
||||
|
||||
def _init_worker(poland_geojson: str) -> None:
|
||||
"""Initialize worker process with shared data."""
|
||||
global _mp_poland_boundary # noqa: PLW0603
|
||||
_mp_poland_boundary = gpd.read_file(poland_geojson)
|
||||
|
||||
|
||||
def _render_single_reserve(args: tuple[str, str]) -> tuple[str, bytes]:
|
||||
"""Render a single reserve image (worker function).
|
||||
|
||||
Args:
|
||||
args: Tuple of (reserve_name, reserve_geojson_str).
|
||||
|
||||
Returns:
|
||||
Tuple of (reserve_name, image_bytes).
|
||||
"""
|
||||
reserve_name, reserve_geojson = args
|
||||
reserve_gdf = gpd.read_file(reserve_geojson)
|
||||
|
||||
assert _mp_poland_boundary is not None # noqa: S101
|
||||
|
||||
image_data = generate_reserve_image_bytes(reserve_gdf, _mp_poland_boundary)
|
||||
return reserve_name, image_data
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
reserves: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
deck_name: str = "Polish Nature Reserves",
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package for Polish nature reserves."""
|
||||
model_id_hash = hashlib.md5( # noqa: S324
|
||||
f"polish_nature_reserves_{deck_name}".encode()
|
||||
)
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.answer-text {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.card.night_mode .answer-text {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
.info-text {
|
||||
font-size: 18px;
|
||||
color: #7F8C8D;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.card.night_mode .info-text {
|
||||
color: #BDC3C7;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Polish Nature Reserve Model",
|
||||
fields=[
|
||||
{"name": "ReserveMap"},
|
||||
{"name": "ReserveName"},
|
||||
{"name": "Area"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": '<div class="map-container">{{ReserveMap}}</div>',
|
||||
"afmt": '<div class="map-container">{{ReserveMap}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer-text">{{ReserveName}}</div>'
|
||||
'<div class="info-text">{{Area}} km²</div>',
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
media_files = []
|
||||
|
||||
# Prepare data for parallel processing
|
||||
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
|
||||
poland_boundary.to_file(f.name, driver="GeoJSON")
|
||||
poland_geojson_path = f.name
|
||||
|
||||
# Prepare work items: (reserve_name, reserve_geojson_str)
|
||||
work_items: list[tuple[str, str]] = []
|
||||
for _, row in reserves.iterrows():
|
||||
reserve_gdf = gpd.GeoDataFrame([row], crs=reserves.crs)
|
||||
reserve_geojson = reserve_gdf.to_json()
|
||||
work_items.append((row["name"], reserve_geojson))
|
||||
|
||||
# Use multiprocessing for parallel rendering (more workers for large datasets)
|
||||
num_workers = min(mp.cpu_count(), 8)
|
||||
sys.stdout.write(
|
||||
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
|
||||
)
|
||||
sys.stdout.write("(This may take a while due to the large number of reserves)\n")
|
||||
|
||||
results: dict[str, bytes] = {}
|
||||
with mp.Pool(
|
||||
num_workers,
|
||||
initializer=_init_worker,
|
||||
initargs=(poland_geojson_path,),
|
||||
) as pool:
|
||||
for i, (reserve_name, image_data) in enumerate(
|
||||
pool.imap_unordered(_render_single_reserve, work_items)
|
||||
):
|
||||
results[reserve_name] = image_data
|
||||
if (i + 1) % 100 == 0:
|
||||
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
|
||||
|
||||
sys.stdout.write(f" Rendered {len(work_items)}/{len(work_items)}.\n")
|
||||
|
||||
# Clean up temp file
|
||||
Path(poland_geojson_path).unlink(missing_ok=True)
|
||||
|
||||
# Create notes from results
|
||||
for _, row in reserves.iterrows():
|
||||
reserve_name = row["name"]
|
||||
area_km2 = round(row["area_km2"], 2) if "area_km2" in row else 0
|
||||
image_data = results[reserve_name]
|
||||
# Use hash for unique filename since names may have special chars
|
||||
name_hash = hashlib.md5(reserve_name.encode()).hexdigest()[:8] # noqa: S324
|
||||
safe_name = reserve_name.replace(" ", "_").replace("/", "_")[:30]
|
||||
filename = f"reserve_{safe_name}_{name_hash}.png"
|
||||
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[f'<img src="{filename}">', reserve_name, str(area_km2)],
|
||||
tags=["geography", "poland", "nature-reserves", "protected-areas"],
|
||||
)
|
||||
my_deck.add_note(note)
|
||||
|
||||
temp_path = Path(f"/tmp/{filename}") # noqa: S108
|
||||
temp_path.write_bytes(image_data)
|
||||
media_files.append(str(temp_path))
|
||||
|
||||
package = genanki.Package(my_deck)
|
||||
package.media_files = media_files
|
||||
return package
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Polish nature reserves.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: polish_nature_reserves.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Polish Nature Reserves",
|
||||
help="Name for the Anki deck",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
"-p",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Export preview images to specified directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview-count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of preview images to export (default: 5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
"-l",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Limit number of reserves (for testing, default: all)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
output_path = (
|
||||
Path(args.output) if args.output else Path("polish_nature_reserves.apkg")
|
||||
)
|
||||
|
||||
try:
|
||||
sys.stdout.write("Loading nature reserves data...\n")
|
||||
reserves = get_polish_nature_reserves()
|
||||
poland_boundary = get_poland_boundary()
|
||||
|
||||
# Apply limit if specified
|
||||
if args.limit:
|
||||
reserves = reserves.head(args.limit)
|
||||
sys.stdout.write(f"Limiting to {args.limit} reserves.\n")
|
||||
|
||||
num_reserves = len(reserves)
|
||||
|
||||
sys.stdout.write(f"Found {num_reserves} nature reserves.\n")
|
||||
sys.stdout.write("Generating flashcards...\n")
|
||||
|
||||
package = generate_anki_package(reserves, poland_boundary, args.deck_name)
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
# Export preview images if requested
|
||||
if args.preview:
|
||||
preview_dir = Path(args.preview)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_reserves = list(reserves.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_reserves)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_reserves:
|
||||
reserve_name = row["name"]
|
||||
reserve_gdf = gpd.GeoDataFrame([row], crs=reserves.crs)
|
||||
image_data = generate_reserve_image_bytes(reserve_gdf, poland_boundary)
|
||||
safe_name = reserve_name.replace(" ", "_").replace("/", "_")[:30]
|
||||
preview_path = preview_dir / f"{safe_name}.png"
|
||||
preview_path.write_bytes(image_data)
|
||||
sys.stdout.write(f" Saved: {preview_path.name}\n")
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write(f"Nature reserves: {num_reserves}\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
if args.preview:
|
||||
sys.stdout.write(f"Preview images: {args.preview}\n")
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
7
python_pkg/anki_decks/polish_nature_reserves/run.sh
Executable file
7
python_pkg/anki_decks/polish_nature_reserves/run.sh
Executable file
@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
# Run the Polish nature reserves Anki generator
|
||||
|
||||
cd "$(dirname "$0")" || exit
|
||||
|
||||
# Default runs all reserves - use --limit for testing
|
||||
python polish_nature_reserves_anki.py --preview preview_images --preview-count 5 "$@"
|
||||
1
python_pkg/anki_decks/polish_powiaty/__init__.py
Normal file
1
python_pkg/anki_decks/polish_powiaty/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Polish powiaty (counties) Anki flashcard generator."""
|
||||
310
python_pkg/anki_decks/polish_powiaty/polish_powiaty_anki.py
Executable file
310
python_pkg/anki_decks/polish_powiaty/polish_powiaty_anki.py
Executable file
@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Anki flashcard generator for Polish powiaty (counties).
|
||||
|
||||
Generates Anki-compatible flashcard decks with maps showing individual
|
||||
Polish counties highlighted on a country map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import genanki
|
||||
import geopandas as gpd
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from geo_data import get_poland_boundary, get_polish_powiaty
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
# 400 distinct colors for powiaty (cycling through)
|
||||
POWIAT_COLORS = [
|
||||
"#E74C3C",
|
||||
"#3498DB",
|
||||
"#2ECC71",
|
||||
"#9B59B6",
|
||||
"#F39C12",
|
||||
"#1ABC9C",
|
||||
"#E91E63",
|
||||
"#00BCD4",
|
||||
"#8BC34A",
|
||||
"#FF5722",
|
||||
"#673AB7",
|
||||
"#FFEB3B",
|
||||
"#795548",
|
||||
"#607D8B",
|
||||
"#CDDC39",
|
||||
"#FF9800",
|
||||
"#4CAF50",
|
||||
"#03A9F4",
|
||||
"#F44336",
|
||||
"#009688",
|
||||
"#3F51B5",
|
||||
"#FFC107",
|
||||
"#9E9E9E",
|
||||
"#00E676",
|
||||
"#FF4081",
|
||||
"#448AFF",
|
||||
"#69F0AE",
|
||||
"#FFD740",
|
||||
"#40C4FF",
|
||||
"#B388FF",
|
||||
"#EA80FC",
|
||||
"#82B1FF",
|
||||
"#A7FFEB",
|
||||
"#FFFF8D",
|
||||
"#FF80AB",
|
||||
"#536DFE",
|
||||
"#64FFDA",
|
||||
"#FFE57F",
|
||||
"#80D8FF",
|
||||
"#B9F6CA",
|
||||
"#CF6679",
|
||||
"#BB86FC",
|
||||
"#03DAC6",
|
||||
"#018786",
|
||||
"#6200EE",
|
||||
"#3700B3",
|
||||
"#B00020",
|
||||
"#FF0266",
|
||||
"#C51162",
|
||||
"#AA00FF",
|
||||
]
|
||||
|
||||
|
||||
def create_powiat_map(
|
||||
powiat_name: str,
|
||||
powiat_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
all_powiaty: gpd.GeoDataFrame,
|
||||
) -> Figure:
|
||||
"""Create a map showing Poland with one powiat highlighted."""
|
||||
fig, ax = plt.subplots(figsize=(10, 12))
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.patch.set_alpha(0)
|
||||
ax.patch.set_alpha(0)
|
||||
|
||||
# Plot Poland as a plain gray shape
|
||||
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
|
||||
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
|
||||
|
||||
# Assign color based on sorted names
|
||||
sorted_names = sorted(all_powiaty["nazwa"].tolist())
|
||||
color_idx = sorted_names.index(powiat_name) % len(POWIAT_COLORS)
|
||||
fill_color = POWIAT_COLORS[color_idx]
|
||||
|
||||
# Plot the highlighted powiat
|
||||
powiat_gdf.plot(ax=ax, color=fill_color, alpha=0.9)
|
||||
powiat_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=3)
|
||||
|
||||
# Set bounds to Poland
|
||||
bounds = poland_boundary.total_bounds
|
||||
ax.set_xlim(bounds[0], bounds[2])
|
||||
ax.set_ylim(bounds[1], bounds[3])
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_powiat_image_bytes(
|
||||
powiat_name: str,
|
||||
powiat_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
all_powiaty: gpd.GeoDataFrame,
|
||||
) -> bytes:
|
||||
"""Generate a powiat map image as bytes."""
|
||||
fig = create_powiat_map(powiat_name, powiat_gdf, poland_boundary, all_powiaty)
|
||||
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
return buf.read()
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
powiaty: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
deck_name: str = "Polish Powiaty",
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package for Polish powiaty."""
|
||||
model_id_hash = hashlib.md5(f"polish_powiaty_{deck_name}".encode()) # noqa: S324
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.answer-text {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.card.night_mode .answer-text {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Polish Powiat Model",
|
||||
fields=[
|
||||
{"name": "PowiatMap"},
|
||||
{"name": "PowiatName"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": '<div class="map-container">{{PowiatMap}}</div>',
|
||||
"afmt": '<div class="map-container">{{PowiatMap}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer-text">{{PowiatName}}</div>',
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
media_files = []
|
||||
|
||||
for _, row in powiaty.iterrows():
|
||||
powiat_name = row["nazwa"]
|
||||
powiat_gdf = gpd.GeoDataFrame([row], crs=powiaty.crs)
|
||||
|
||||
image_data = generate_powiat_image_bytes(
|
||||
powiat_name, powiat_gdf, poland_boundary, powiaty
|
||||
)
|
||||
filename = f"powiat_{powiat_name.replace(' ', '_').replace('/', '_')}.png"
|
||||
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[f'<img src="{filename}">', powiat_name],
|
||||
tags=["geography", "poland", "powiaty"],
|
||||
)
|
||||
my_deck.add_note(note)
|
||||
|
||||
temp_path = Path(f"/tmp/{filename}") # noqa: S108
|
||||
temp_path.write_bytes(image_data)
|
||||
media_files.append(str(temp_path))
|
||||
|
||||
package = genanki.Package(my_deck)
|
||||
package.media_files = media_files
|
||||
return package
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Polish powiaty.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: polish_powiaty.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Polish Powiaty",
|
||||
help="Name for the Anki deck",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
"-p",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Export preview images to specified directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview-count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of preview images to export (default: 5)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
output_path = Path(args.output) if args.output else Path("polish_powiaty.apkg")
|
||||
|
||||
try:
|
||||
sys.stdout.write("Loading powiaty data...\n")
|
||||
powiaty = get_polish_powiaty()
|
||||
poland_boundary = get_poland_boundary()
|
||||
num_powiaty = len(powiaty)
|
||||
|
||||
sys.stdout.write(f"Generating flashcards for {num_powiaty} powiaty...\n")
|
||||
|
||||
package = generate_anki_package(powiaty, poland_boundary, args.deck_name)
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
# Export preview images if requested
|
||||
if args.preview:
|
||||
preview_dir = Path(args.preview)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_powiaty = list(powiaty.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_powiaty)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_powiaty:
|
||||
powiat_name = row["nazwa"]
|
||||
powiat_gdf = gpd.GeoDataFrame([row], crs=powiaty.crs)
|
||||
image_data = generate_powiat_image_bytes(
|
||||
powiat_name, powiat_gdf, poland_boundary, powiaty
|
||||
)
|
||||
safe_name = powiat_name.replace(" ", "_").replace("/", "_")
|
||||
preview_path = preview_dir / f"{safe_name}.png"
|
||||
preview_path.write_bytes(image_data)
|
||||
sys.stdout.write(f" Saved: {preview_path.name}\n")
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write(f"Powiaty: {num_powiaty}\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
if args.preview:
|
||||
sys.stdout.write(f"Preview images: {args.preview}\n")
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
34
python_pkg/anki_decks/polish_powiaty/run.sh
Executable file
34
python_pkg/anki_decks/polish_powiaty/run.sh
Executable file
@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# Script to generate Polish Powiaty Anki deck
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||
PREVIEW_DIR="$SCRIPT_DIR/preview_images"
|
||||
|
||||
echo "=== Polish Powiaty Anki Generator ==="
|
||||
echo
|
||||
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
echo "Activating virtual environment..."
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
echo "Installing dependencies..."
|
||||
pip install --quiet --upgrade pip
|
||||
pip install --quiet matplotlib genanki geopandas
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Create preview images directory
|
||||
mkdir -p "$PREVIEW_DIR"
|
||||
|
||||
python -m polish_powiaty_anki --output polish_powiaty.apkg --preview "$PREVIEW_DIR" --preview-count 5
|
||||
|
||||
echo
|
||||
echo "Done! The Anki deck is at: $SCRIPT_DIR/polish_powiaty.apkg"
|
||||
echo "Preview images are in: $PREVIEW_DIR"
|
||||
1
python_pkg/anki_decks/polish_rivers/__init__.py
Normal file
1
python_pkg/anki_decks/polish_rivers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Polish rivers Anki deck generator."""
|
||||
355
python_pkg/anki_decks/polish_rivers/polish_rivers_anki.py
Normal file
355
python_pkg/anki_decks/polish_rivers/polish_rivers_anki.py
Normal file
@ -0,0 +1,355 @@
|
||||
"""Anki flashcard generator for Polish rivers.
|
||||
|
||||
Generates Anki-compatible flashcard decks with maps showing rivers
|
||||
highlighted on a Poland map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
import multiprocessing as mp
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import genanki
|
||||
import geopandas as gpd
|
||||
import matplotlib as mpl
|
||||
|
||||
mpl.use("Agg") # Non-interactive backend for multiprocessing
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from geo_data import get_poland_boundary, get_polish_rivers
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
RIVER_COLOR = "#2980B9" # Dark blue for rivers
|
||||
NEIGHBOR_COLOR = "#EAECEE" # Light gray for neighboring areas
|
||||
|
||||
|
||||
def create_river_map(
|
||||
river_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
) -> Figure:
|
||||
"""Create a map showing Poland with one river highlighted.
|
||||
|
||||
Rivers that extend beyond Poland show an extended view.
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=(10, 12))
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.patch.set_alpha(0)
|
||||
ax.patch.set_alpha(0)
|
||||
|
||||
# Get Poland bounds
|
||||
poland_bounds = poland_boundary.total_bounds
|
||||
river_bounds = river_gdf.total_bounds
|
||||
|
||||
# Check if river extends beyond Poland
|
||||
extends_beyond = (
|
||||
river_bounds[0] < poland_bounds[0]
|
||||
or river_bounds[1] < poland_bounds[1]
|
||||
or river_bounds[2] > poland_bounds[2]
|
||||
or river_bounds[3] > poland_bounds[3]
|
||||
)
|
||||
|
||||
if extends_beyond:
|
||||
# Calculate extended bounds with some padding
|
||||
min_x = min(poland_bounds[0], river_bounds[0]) - 0.2
|
||||
min_y = min(poland_bounds[1], river_bounds[1]) - 0.2
|
||||
max_x = max(poland_bounds[2], river_bounds[2]) + 0.2
|
||||
max_y = max(poland_bounds[3], river_bounds[3]) + 0.2
|
||||
|
||||
# Draw background for extended area (neighboring countries)
|
||||
ax.fill(
|
||||
[min_x, max_x, max_x, min_x, min_x],
|
||||
[min_y, min_y, max_y, max_y, min_y],
|
||||
color=NEIGHBOR_COLOR,
|
||||
alpha=0.3,
|
||||
)
|
||||
|
||||
# Plot Poland as a plain gray shape
|
||||
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
|
||||
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
|
||||
|
||||
# Plot the river
|
||||
river_gdf.plot(ax=ax, color=RIVER_COLOR, linewidth=3, alpha=0.9)
|
||||
|
||||
if extends_beyond:
|
||||
ax.set_xlim(min_x, max_x)
|
||||
ax.set_ylim(min_y, max_y)
|
||||
else:
|
||||
# Set bounds to Poland
|
||||
ax.set_xlim(poland_bounds[0], poland_bounds[2])
|
||||
ax.set_ylim(poland_bounds[1], poland_bounds[3])
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_river_image_bytes(
|
||||
river_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
) -> bytes:
|
||||
"""Generate a river map image as bytes."""
|
||||
fig = create_river_map(river_gdf, poland_boundary)
|
||||
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
return buf.read()
|
||||
|
||||
|
||||
# Global variables for multiprocessing (set via initializer)
|
||||
_mp_poland_boundary: gpd.GeoDataFrame | None = None
|
||||
|
||||
|
||||
def _init_worker(poland_geojson: str) -> None:
|
||||
"""Initialize worker process with shared data."""
|
||||
global _mp_poland_boundary # noqa: PLW0603
|
||||
_mp_poland_boundary = gpd.read_file(poland_geojson)
|
||||
|
||||
|
||||
def _render_single_river(args: tuple[str, str]) -> tuple[str, bytes]:
|
||||
"""Render a single river image (worker function).
|
||||
|
||||
Args:
|
||||
args: Tuple of (river_name, river_geojson_str).
|
||||
|
||||
Returns:
|
||||
Tuple of (river_name, image_bytes).
|
||||
"""
|
||||
river_name, river_geojson = args
|
||||
river_gdf = gpd.read_file(river_geojson)
|
||||
|
||||
assert _mp_poland_boundary is not None # noqa: S101
|
||||
|
||||
image_data = generate_river_image_bytes(river_gdf, _mp_poland_boundary)
|
||||
return river_name, image_data
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
rivers: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
deck_name: str = "Polish Rivers",
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package for Polish rivers."""
|
||||
model_id_hash = hashlib.md5(f"polish_rivers_{deck_name}".encode()) # noqa: S324
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.answer-text {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.card.night_mode .answer-text {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
.info-text {
|
||||
font-size: 18px;
|
||||
color: #7F8C8D;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.card.night_mode .info-text {
|
||||
color: #BDC3C7;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Polish River Model",
|
||||
fields=[
|
||||
{"name": "RiverMap"},
|
||||
{"name": "RiverName"},
|
||||
{"name": "Length"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": '<div class="map-container">{{RiverMap}}</div>',
|
||||
"afmt": '<div class="map-container">{{RiverMap}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer-text">{{RiverName}}</div>'
|
||||
'<div class="info-text">~{{Length}} km w Polsce</div>',
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
media_files = []
|
||||
|
||||
# Prepare data for parallel processing
|
||||
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
|
||||
poland_boundary.to_file(f.name, driver="GeoJSON")
|
||||
poland_geojson_path = f.name
|
||||
|
||||
# Prepare work items: (river_name, river_geojson_str)
|
||||
work_items: list[tuple[str, str]] = []
|
||||
for _, row in rivers.iterrows():
|
||||
river_gdf = gpd.GeoDataFrame([row], crs=rivers.crs)
|
||||
river_geojson = river_gdf.to_json()
|
||||
work_items.append((row["name"], river_geojson))
|
||||
|
||||
# Use multiprocessing for parallel rendering
|
||||
num_workers = min(mp.cpu_count(), 8)
|
||||
sys.stdout.write(
|
||||
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
|
||||
)
|
||||
|
||||
results: dict[str, bytes] = {}
|
||||
with mp.Pool(
|
||||
num_workers,
|
||||
initializer=_init_worker,
|
||||
initargs=(poland_geojson_path,),
|
||||
) as pool:
|
||||
for i, (river_name, image_data) in enumerate(
|
||||
pool.imap_unordered(_render_single_river, work_items)
|
||||
):
|
||||
results[river_name] = image_data
|
||||
if (i + 1) % 50 == 0:
|
||||
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
|
||||
|
||||
# Clean up temp file
|
||||
Path(poland_geojson_path).unlink(missing_ok=True)
|
||||
|
||||
# Create notes from results
|
||||
for _, row in rivers.iterrows():
|
||||
river_name = row["name"]
|
||||
length_km = round(row["length_km"]) if "length_km" in row else 0
|
||||
image_data = results[river_name]
|
||||
filename = f"river_{river_name.replace(' ', '_').replace('/', '_')}.png"
|
||||
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[f'<img src="{filename}">', river_name, str(length_km)],
|
||||
tags=["geography", "poland", "rivers", "water"],
|
||||
)
|
||||
my_deck.add_note(note)
|
||||
|
||||
temp_path = Path(f"/tmp/{filename}") # noqa: S108
|
||||
temp_path.write_bytes(image_data)
|
||||
media_files.append(str(temp_path))
|
||||
|
||||
package = genanki.Package(my_deck)
|
||||
package.media_files = media_files
|
||||
return package
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Polish rivers.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: polish_rivers.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Polish Rivers",
|
||||
help="Name for the Anki deck",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
"-p",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Export preview images to specified directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview-count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of preview images to export (default: 5)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
output_path = Path(args.output) if args.output else Path("polish_rivers.apkg")
|
||||
|
||||
try:
|
||||
sys.stdout.write("Loading rivers data...\n")
|
||||
rivers = get_polish_rivers()
|
||||
poland_boundary = get_poland_boundary()
|
||||
num_rivers = len(rivers)
|
||||
|
||||
sys.stdout.write(f"Found {num_rivers} rivers.\n")
|
||||
sys.stdout.write("Generating flashcards...\n")
|
||||
|
||||
package = generate_anki_package(rivers, poland_boundary, args.deck_name)
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
# Export preview images if requested
|
||||
if args.preview:
|
||||
preview_dir = Path(args.preview)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_rivers = list(rivers.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_rivers)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_rivers:
|
||||
river_name = row["name"]
|
||||
river_gdf = gpd.GeoDataFrame([row], crs=rivers.crs)
|
||||
image_data = generate_river_image_bytes(river_gdf, poland_boundary)
|
||||
safe_name = river_name.replace(" ", "_").replace("/", "_")
|
||||
preview_path = preview_dir / f"{safe_name}.png"
|
||||
preview_path.write_bytes(image_data)
|
||||
sys.stdout.write(f" Saved: {preview_path.name}\n")
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write(f"Rivers: {num_rivers}\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
if args.preview:
|
||||
sys.stdout.write(f"Preview images: {args.preview}\n")
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
6
python_pkg/anki_decks/polish_rivers/run.sh
Executable file
6
python_pkg/anki_decks/polish_rivers/run.sh
Executable file
@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Run the Polish rivers Anki generator
|
||||
|
||||
cd "$(dirname "$0")" || exit
|
||||
|
||||
python polish_rivers_anki.py --preview preview_images --preview-count 5 "$@"
|
||||
1
python_pkg/anki_decks/polish_unesco_sites/__init__.py
Normal file
1
python_pkg/anki_decks/polish_unesco_sites/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Polish UNESCO sites Anki generator."""
|
||||
@ -0,0 +1,365 @@
|
||||
"""Anki flashcard generator for Polish UNESCO World Heritage Sites.
|
||||
|
||||
Generates Anki-compatible flashcard decks with maps showing UNESCO sites
|
||||
highlighted on a Poland map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
import multiprocessing as mp
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import genanki
|
||||
import geopandas as gpd
|
||||
import matplotlib as mpl
|
||||
|
||||
mpl.use("Agg") # Non-interactive backend for multiprocessing
|
||||
import matplotlib.pyplot as plt
|
||||
from shapely.geometry import Point
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from geo_data import get_poland_boundary, get_polish_unesco_sites
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
SITE_COLOR_POLYGON = "#9B59B6" # Purple for polygon sites
|
||||
SITE_COLOR_POINT = "#9B59B6" # Purple for point markers
|
||||
|
||||
|
||||
def create_unesco_map(
|
||||
site_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
) -> Figure:
|
||||
"""Create a map showing Poland with one UNESCO site highlighted.
|
||||
|
||||
Always shows a star marker at the centroid for consistency.
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=(10, 12))
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.patch.set_alpha(0)
|
||||
ax.patch.set_alpha(0)
|
||||
|
||||
# Plot Poland as a plain gray shape
|
||||
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
|
||||
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
|
||||
|
||||
# Get centroid for star marker
|
||||
geom = site_gdf.iloc[0].geometry
|
||||
if isinstance(geom, Point):
|
||||
x, y = geom.x, geom.y
|
||||
else:
|
||||
centroid = geom.centroid
|
||||
x, y = centroid.x, centroid.y
|
||||
|
||||
# Always show a star marker for consistency
|
||||
ax.scatter(
|
||||
[x],
|
||||
[y],
|
||||
s=800,
|
||||
c=SITE_COLOR_POINT,
|
||||
marker="*",
|
||||
edgecolor="#1A1A1A",
|
||||
linewidth=2,
|
||||
zorder=10,
|
||||
)
|
||||
|
||||
# Set bounds to Poland
|
||||
bounds = poland_boundary.total_bounds
|
||||
ax.set_xlim(bounds[0], bounds[2])
|
||||
ax.set_ylim(bounds[1], bounds[3])
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_unesco_image_bytes(
|
||||
site_gdf: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
) -> bytes:
|
||||
"""Generate a UNESCO site map image as bytes."""
|
||||
fig = create_unesco_map(site_gdf, poland_boundary)
|
||||
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
return buf.read()
|
||||
|
||||
|
||||
# Global variables for multiprocessing (set via initializer)
|
||||
_mp_poland_boundary: gpd.GeoDataFrame | None = None
|
||||
|
||||
|
||||
def _init_worker(poland_geojson: str) -> None:
|
||||
"""Initialize worker process with shared data."""
|
||||
global _mp_poland_boundary # noqa: PLW0603
|
||||
_mp_poland_boundary = gpd.read_file(poland_geojson)
|
||||
|
||||
|
||||
def _render_single_site(args: tuple[str, str]) -> tuple[str, bytes]:
|
||||
"""Render a single site image (worker function).
|
||||
|
||||
Args:
|
||||
args: Tuple of (site_name, site_geojson_str).
|
||||
|
||||
Returns:
|
||||
Tuple of (site_name, image_bytes).
|
||||
"""
|
||||
site_name, site_geojson = args
|
||||
site_gdf = gpd.read_file(site_geojson)
|
||||
|
||||
assert _mp_poland_boundary is not None # noqa: S101
|
||||
|
||||
image_data = generate_unesco_image_bytes(site_gdf, _mp_poland_boundary)
|
||||
return site_name, image_data
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
sites: gpd.GeoDataFrame,
|
||||
poland_boundary: gpd.GeoDataFrame,
|
||||
deck_name: str = "Polish UNESCO World Heritage Sites",
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package for Polish UNESCO sites."""
|
||||
model_id_hash = hashlib.md5( # noqa: S324
|
||||
f"polish_unesco_sites_{deck_name}".encode()
|
||||
)
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.answer-text {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.card.night_mode .answer-text {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
.info-text {
|
||||
font-size: 18px;
|
||||
color: #7F8C8D;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.card.night_mode .info-text {
|
||||
color: #BDC3C7;
|
||||
}
|
||||
.year-badge {
|
||||
display: inline-block;
|
||||
background: #9B59B6;
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 15px;
|
||||
font-size: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.card.night_mode .year-badge {
|
||||
background: #8E44AD;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Polish UNESCO Site Model",
|
||||
fields=[
|
||||
{"name": "SiteMap"},
|
||||
{"name": "SiteName"},
|
||||
{"name": "InscribedYear"},
|
||||
{"name": "Category"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": '<div class="map-container">{{SiteMap}}</div>',
|
||||
"afmt": '<div class="map-container">{{SiteMap}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer-text">{{SiteName}}</div>'
|
||||
'<div class="info-text">{{Category}}</div>'
|
||||
'<div class="year-badge">Inscribed: {{InscribedYear}}</div>',
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
media_files = []
|
||||
|
||||
# Prepare data for parallel processing
|
||||
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
|
||||
poland_boundary.to_file(f.name, driver="GeoJSON")
|
||||
poland_geojson_path = f.name
|
||||
|
||||
# Prepare work items: (site_name, site_geojson_str)
|
||||
work_items: list[tuple[str, str]] = []
|
||||
for _, row in sites.iterrows():
|
||||
site_gdf = gpd.GeoDataFrame([row], crs=sites.crs)
|
||||
site_geojson = site_gdf.to_json()
|
||||
work_items.append((row["name"], site_geojson))
|
||||
|
||||
# Use multiprocessing for parallel rendering
|
||||
num_workers = min(mp.cpu_count(), 8)
|
||||
sys.stdout.write(
|
||||
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
|
||||
)
|
||||
|
||||
results: dict[str, bytes] = {}
|
||||
with mp.Pool(
|
||||
num_workers,
|
||||
initializer=_init_worker,
|
||||
initargs=(poland_geojson_path,),
|
||||
) as pool:
|
||||
for i, (site_name, image_data) in enumerate(
|
||||
pool.imap_unordered(_render_single_site, work_items)
|
||||
):
|
||||
results[site_name] = image_data
|
||||
if (i + 1) % 5 == 0:
|
||||
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
|
||||
|
||||
# Clean up temp file
|
||||
Path(poland_geojson_path).unlink(missing_ok=True)
|
||||
|
||||
# Create notes from results
|
||||
for _, row in sites.iterrows():
|
||||
site_name = row["name"]
|
||||
inscribed_year = row.get("inscribed_year", "Unknown")
|
||||
category = row.get("category", "Cultural/Natural")
|
||||
image_data = results[site_name]
|
||||
filename = f"unesco_{site_name.replace(' ', '_').replace('/', '_')}.png"
|
||||
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[
|
||||
f'<img src="{filename}">',
|
||||
site_name,
|
||||
str(inscribed_year),
|
||||
category,
|
||||
],
|
||||
tags=["geography", "poland", "unesco", "heritage"],
|
||||
)
|
||||
my_deck.add_note(note)
|
||||
|
||||
temp_path = Path(f"/tmp/{filename}") # noqa: S108
|
||||
temp_path.write_bytes(image_data)
|
||||
media_files.append(str(temp_path))
|
||||
|
||||
package = genanki.Package(my_deck)
|
||||
package.media_files = media_files
|
||||
return package
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Polish UNESCO sites.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: polish_unesco_sites.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Polish UNESCO World Heritage Sites",
|
||||
help="Name for the Anki deck",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
"-p",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Export preview images to specified directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview-count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of preview images to export (default: 5)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
output_path = Path(args.output) if args.output else Path("polish_unesco_sites.apkg")
|
||||
|
||||
try:
|
||||
sys.stdout.write("Loading UNESCO sites data...\n")
|
||||
sites = get_polish_unesco_sites()
|
||||
poland_boundary = get_poland_boundary()
|
||||
num_sites = len(sites)
|
||||
|
||||
sys.stdout.write(f"Found {num_sites} UNESCO World Heritage Sites.\n")
|
||||
sys.stdout.write("Generating flashcards...\n")
|
||||
|
||||
package = generate_anki_package(sites, poland_boundary, args.deck_name)
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
# Export preview images if requested
|
||||
if args.preview:
|
||||
preview_dir = Path(args.preview)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_sites = list(sites.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_sites)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_sites:
|
||||
site_name = row["name"]
|
||||
site_gdf = gpd.GeoDataFrame([row], crs=sites.crs)
|
||||
image_data = generate_unesco_image_bytes(site_gdf, poland_boundary)
|
||||
safe_name = site_name.replace(" ", "_").replace("/", "_")
|
||||
preview_path = preview_dir / f"{safe_name}.png"
|
||||
preview_path.write_bytes(image_data)
|
||||
sys.stdout.write(f" Saved: {preview_path.name}\n")
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write(f"UNESCO sites: {num_sites}\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
if args.preview:
|
||||
sys.stdout.write(f"Preview images: {args.preview}\n")
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
6
python_pkg/anki_decks/polish_unesco_sites/run.sh
Executable file
6
python_pkg/anki_decks/polish_unesco_sites/run.sh
Executable file
@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Run the Polish UNESCO sites Anki generator
|
||||
|
||||
cd "$(dirname "$0")" || exit
|
||||
|
||||
python polish_unesco_sites_anki.py --preview preview_images --preview-count 5 "$@"
|
||||
82
python_pkg/anki_decks/preview_all.html
Normal file
82
python_pkg/anki_decks/preview_all.html
Normal file
@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>All Preview Images</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; }
|
||||
h1 { color: #333; }
|
||||
h2 { color: #666; margin-top: 30px; border-bottom: 2px solid #ddd; padding-bottom: 10px; }
|
||||
.gallery { display: flex; flex-wrap: wrap; gap: 20px; }
|
||||
.card { background: white; padding: 15px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
|
||||
.card img { max-width: 300px; max-height: 300px; object-fit: contain; }
|
||||
.card p { margin-top: 10px; text-align: center; font-weight: bold; color: #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🗺️ Anki Geography Cards - Preview Images</h1>
|
||||
|
||||
<h2>🛣️ Warsaw Streets (Top 5 Longest)</h2>
|
||||
<div class="gallery">
|
||||
<div class="card"><img src="warsaw_streets/preview_images/Wał_Miedzeszyński.png"><p>Wał Miedzeszyński (27.8km)</p></div>
|
||||
<div class="card"><img src="warsaw_streets/preview_images/Puławska.png"><p>Puławska (25.3km)</p></div>
|
||||
<div class="card"><img src="warsaw_streets/preview_images/Aleje_Jerozolimskie.png"><p>Aleje Jerozolimskie (22.2km)</p></div>
|
||||
<div class="card"><img src="warsaw_streets/preview_images/Modlińska.png"><p>Modlińska (19.2km)</p></div>
|
||||
<div class="card"><img src="warsaw_streets/preview_images/Patriotów.png"><p>Patriotów (15.9km)</p></div>
|
||||
</div>
|
||||
|
||||
<h2>🚇 Warsaw Metro Stations</h2>
|
||||
<div class="gallery">
|
||||
<div class="card"><img src="warsaw_metro/preview_images/Ratusz-Arsenał.png"><p>Ratusz-Arsenał</p></div>
|
||||
<div class="card"><img src="warsaw_metro/preview_images/Marymont.png"><p>Marymont</p></div>
|
||||
<div class="card"><img src="warsaw_metro/preview_images/Stare_Bielany.png"><p>Stare Bielany</p></div>
|
||||
<div class="card"><img src="warsaw_metro/preview_images/Wawrzyszew.png"><p>Wawrzyszew</p></div>
|
||||
<div class="card"><img src="warsaw_metro/preview_images/Młociny.png"><p>Młociny</p></div>
|
||||
</div>
|
||||
|
||||
<h2>🌉 Warsaw Bridges</h2>
|
||||
<div class="gallery">
|
||||
<div class="card"><img src="warsaw_bridges/preview_images/Most_Siekierkowski.png"><p>Most Siekierkowski</p></div>
|
||||
<div class="card"><img src="warsaw_bridges/preview_images/Most_Świętokrzyski.png"><p>Most Świętokrzyski</p></div>
|
||||
<div class="card"><img src="warsaw_bridges/preview_images/Most_Generała_Stefana_Grota-Roweckiego.png"><p>Most Grota-Roweckiego</p></div>
|
||||
<div class="card"><img src="warsaw_bridges/preview_images/Most_Marii_Skłodowskiej-Curie.png"><p>Most M. Skłodowskiej-Curie</p></div>
|
||||
<div class="card"><img src="warsaw_bridges/preview_images/Most_Anny_Jagiellonki.png"><p>Most Anny Jagiellonki</p></div>
|
||||
</div>
|
||||
|
||||
<h2>🏛️ Warsaw Landmarks</h2>
|
||||
<div class="gallery">
|
||||
<div class="card"><img src="warsaw_landmarks/preview_images/Pomnik_Kościuszkowców.png"><p>Pomnik Kościuszkowców</p></div>
|
||||
<div class="card"><img src="warsaw_landmarks/preview_images/Pomnik_Państwa_Podziemnego_i_AK.png"><p>Pomnik Państwa Podziemnego i AK</p></div>
|
||||
<div class="card"><img src="warsaw_landmarks/preview_images/Muzeum_Etnograficzne.png"><p>Muzeum Etnograficzne</p></div>
|
||||
<div class="card"><img src="warsaw_landmarks/preview_images/Żydowski_Instytut_Historyczny.png"><p>Żydowski Instytut Historyczny</p></div>
|
||||
<div class="card"><img src="warsaw_landmarks/preview_images/Muzeum_Marii_Skłodowskiej-Curie.png"><p>Muzeum M. Skłodowskiej-Curie</p></div>
|
||||
</div>
|
||||
|
||||
<h2>🏘️ Warsaw Osiedla</h2>
|
||||
<div class="gallery">
|
||||
<div class="card"><img src="warsaw_osiedla/preview_images/Grochów-Centrum.png"><p>Grochów-Centrum</p></div>
|
||||
<div class="card"><img src="warsaw_osiedla/preview_images/Grochów-Kinowa.png"><p>Grochów-Kinowa</p></div>
|
||||
<div class="card"><img src="warsaw_osiedla/preview_images/Grochów-Południowy.png"><p>Grochów-Południowy</p></div>
|
||||
<div class="card"><img src="warsaw_osiedla/preview_images/Grochów-Północny.png"><p>Grochów-Północny</p></div>
|
||||
<div class="card"><img src="warsaw_osiedla/preview_images/Przyczółek_Grochowski.png"><p>Przyczółek Grochowski</p></div>
|
||||
</div>
|
||||
|
||||
<h2>🗺️ Polish Powiaty</h2>
|
||||
<div class="gallery">
|
||||
<div class="card"><img src="polish_powiaty/preview_images/powiat_ropczycko-sędziszowski.png"><p>Powiat Ropczycko-Sędziszowski</p></div>
|
||||
<div class="card"><img src="polish_powiaty/preview_images/powiat_łosicki.png"><p>Powiat Łosicki</p></div>
|
||||
<div class="card"><img src="polish_powiaty/preview_images/powiat_piaseczyński.png"><p>Powiat Piaseczyński</p></div>
|
||||
<div class="card"><img src="polish_powiaty/preview_images/powiat_radomski.png"><p>Powiat Radomski</p></div>
|
||||
<div class="card"><img src="polish_powiaty/preview_images/powiat_sierpecki.png"><p>Powiat Sierpecki</p></div>
|
||||
</div>
|
||||
|
||||
<h2>🏙️ Warsaw Districts</h2>
|
||||
<div class="gallery">
|
||||
<div class="card"><img src="warsaw_districts/preview_images/Śródmieście.png"><p>Śródmieście</p></div>
|
||||
<div class="card"><img src="warsaw_districts/preview_images/Mokotów.png"><p>Mokotów</p></div>
|
||||
<div class="card"><img src="warsaw_districts/preview_images/Praga_Północ.png"><p>Praga Północ</p></div>
|
||||
<div class="card"><img src="warsaw_districts/preview_images/Praga_Południe.png"><p>Praga Południe</p></div>
|
||||
<div class="card"><img src="warsaw_districts/preview_images/Wola.png"><p>Wola</p></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1
python_pkg/anki_decks/warsaw_bridges/__init__.py
Normal file
1
python_pkg/anki_decks/warsaw_bridges/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Warsaw bridges Anki flashcard generator."""
|
||||
34
python_pkg/anki_decks/warsaw_bridges/run.sh
Executable file
34
python_pkg/anki_decks/warsaw_bridges/run.sh
Executable file
@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# Script to generate Warsaw Bridges Anki deck
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||
PREVIEW_DIR="$SCRIPT_DIR/preview_images"
|
||||
|
||||
echo "=== Warsaw Bridges Anki Generator ==="
|
||||
echo
|
||||
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
echo "Activating virtual environment..."
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
echo "Installing dependencies..."
|
||||
pip install --quiet --upgrade pip
|
||||
pip install --quiet matplotlib genanki geopandas requests shapely
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Create preview images directory
|
||||
mkdir -p "$PREVIEW_DIR"
|
||||
|
||||
python -m warsaw_bridges_anki --output warsaw_bridges.apkg --preview "$PREVIEW_DIR" --preview-count 5
|
||||
|
||||
echo
|
||||
echo "Done! The Anki deck is at: $SCRIPT_DIR/warsaw_bridges.apkg"
|
||||
echo "Preview images are in: $PREVIEW_DIR"
|
||||
318
python_pkg/anki_decks/warsaw_bridges/warsaw_bridges_anki.py
Executable file
318
python_pkg/anki_decks/warsaw_bridges/warsaw_bridges_anki.py
Executable file
@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Anki flashcard generator for Warsaw bridges over the Vistula.
|
||||
|
||||
Generates Anki-compatible flashcard decks with maps showing individual
|
||||
Warsaw bridges highlighted on a city map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import genanki
|
||||
import geopandas as gpd
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# Import shared data module
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from geo_data import get_vistula_river, get_warsaw_bridges
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
# Bridge color
|
||||
BRIDGE_COLOR = "#E74C3C" # Red
|
||||
RIVER_COLOR = "#3498DB" # Blue
|
||||
|
||||
|
||||
def load_warsaw_boundary() -> gpd.GeoDataFrame:
|
||||
"""Load Warsaw boundary from districts GeoJSON.
|
||||
|
||||
Returns:
|
||||
GeoDataFrame with Warsaw boundary.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If boundary data file not found.
|
||||
"""
|
||||
districts_path = (
|
||||
Path(__file__).parent.parent / "warsaw_districts" / "warszawa-dzielnice.geojson"
|
||||
)
|
||||
if districts_path.exists():
|
||||
warsaw_gdf = gpd.read_file(districts_path)
|
||||
warsaw_boundary = warsaw_gdf[warsaw_gdf["name"] == "Warszawa"]
|
||||
if len(warsaw_boundary) == 0:
|
||||
warsaw_boundary = gpd.GeoDataFrame(
|
||||
geometry=[warsaw_gdf.union_all()], crs=warsaw_gdf.crs
|
||||
)
|
||||
return warsaw_boundary
|
||||
|
||||
msg = "Warsaw boundary data not found"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
|
||||
def create_bridge_map(
|
||||
bridge_gdf: gpd.GeoDataFrame,
|
||||
warsaw_boundary: gpd.GeoDataFrame,
|
||||
vistula: gpd.GeoDataFrame,
|
||||
) -> Figure:
|
||||
"""Create a map showing Warsaw with one bridge highlighted.
|
||||
|
||||
Args:
|
||||
bridge_gdf: GeoDataFrame with the bridge to highlight.
|
||||
warsaw_boundary: GeoDataFrame with Warsaw boundary.
|
||||
vistula: GeoDataFrame with Vistula river geometry.
|
||||
|
||||
Returns:
|
||||
Matplotlib figure with the map.
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=(10, 10))
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.patch.set_alpha(0)
|
||||
ax.patch.set_alpha(0)
|
||||
|
||||
# Plot Warsaw as a plain gray shape
|
||||
warsaw_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
|
||||
warsaw_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=2)
|
||||
|
||||
# Plot Vistula river
|
||||
vistula.plot(ax=ax, color=RIVER_COLOR, linewidth=3, alpha=0.7)
|
||||
|
||||
# Plot the bridge
|
||||
bridge_gdf.plot(ax=ax, color=BRIDGE_COLOR, linewidth=6, alpha=0.9)
|
||||
|
||||
# Set bounds to Warsaw
|
||||
bounds = warsaw_boundary.total_bounds
|
||||
ax.set_xlim(bounds[0], bounds[2])
|
||||
ax.set_ylim(bounds[1], bounds[3])
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_bridge_image_bytes(
|
||||
bridge_gdf: gpd.GeoDataFrame,
|
||||
warsaw_boundary: gpd.GeoDataFrame,
|
||||
vistula: gpd.GeoDataFrame,
|
||||
) -> bytes:
|
||||
"""Generate a bridge map image as bytes.
|
||||
|
||||
Args:
|
||||
bridge_gdf: GeoDataFrame with the bridge.
|
||||
warsaw_boundary: GeoDataFrame with Warsaw boundary.
|
||||
vistula: GeoDataFrame with Vistula river.
|
||||
|
||||
Returns:
|
||||
PNG image bytes.
|
||||
"""
|
||||
fig = create_bridge_map(bridge_gdf, warsaw_boundary, vistula)
|
||||
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
return buf.read()
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
bridges: gpd.GeoDataFrame,
|
||||
warsaw_boundary: gpd.GeoDataFrame,
|
||||
vistula: gpd.GeoDataFrame,
|
||||
deck_name: str = "Warsaw Bridges",
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package for Warsaw bridges.
|
||||
|
||||
Args:
|
||||
bridges: GeoDataFrame with all bridges.
|
||||
warsaw_boundary: GeoDataFrame with Warsaw boundary.
|
||||
vistula: GeoDataFrame with Vistula river.
|
||||
deck_name: Name for the Anki deck.
|
||||
|
||||
Returns:
|
||||
Generated Anki package.
|
||||
"""
|
||||
model_id_hash = hashlib.md5(f"warsaw_bridges_{deck_name}".encode()) # noqa: S324
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.answer-text {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.card.night_mode .answer-text {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Warsaw Bridge Model",
|
||||
fields=[
|
||||
{"name": "BridgeMap"},
|
||||
{"name": "BridgeName"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": '<div class="map-container">{{BridgeMap}}</div>',
|
||||
"afmt": '<div class="map-container">{{BridgeMap}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer-text">{{BridgeName}}</div>',
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
media_files = []
|
||||
|
||||
for _, row in bridges.iterrows():
|
||||
bridge_name = row["name"]
|
||||
bridge_gdf = gpd.GeoDataFrame([row], crs=bridges.crs)
|
||||
|
||||
image_data = generate_bridge_image_bytes(bridge_gdf, warsaw_boundary, vistula)
|
||||
filename = f"bridge_{bridge_name.replace(' ', '_').replace('/', '_')}.png"
|
||||
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[f'<img src="{filename}">', bridge_name],
|
||||
tags=["geography", "warsaw", "bridges"],
|
||||
)
|
||||
my_deck.add_note(note)
|
||||
|
||||
temp_path = Path(f"/tmp/{filename}") # noqa: S108
|
||||
temp_path.write_bytes(image_data)
|
||||
media_files.append(str(temp_path))
|
||||
|
||||
package = genanki.Package(my_deck)
|
||||
package.media_files = media_files
|
||||
return package
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point.
|
||||
|
||||
Args:
|
||||
argv: Command-line arguments.
|
||||
|
||||
Returns:
|
||||
Exit code.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Warsaw bridges.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: warsaw_bridges.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Warsaw Bridges",
|
||||
help="Name for the Anki deck",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
"-p",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Export preview images to specified directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview-count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of preview images to export (default: 5)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
output_path = Path(args.output) if args.output else Path("warsaw_bridges.apkg")
|
||||
|
||||
try:
|
||||
sys.stdout.write("Loading bridge data...\n")
|
||||
bridges = get_warsaw_bridges()
|
||||
vistula = get_vistula_river()
|
||||
warsaw_boundary = load_warsaw_boundary()
|
||||
num_bridges = len(bridges)
|
||||
|
||||
sys.stdout.write(f"Generating flashcards for {num_bridges} bridges...\n")
|
||||
|
||||
package = generate_anki_package(
|
||||
bridges, warsaw_boundary, vistula, args.deck_name
|
||||
)
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
# Export preview images if requested
|
||||
if args.preview:
|
||||
preview_dir = Path(args.preview)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_bridges = list(bridges.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_bridges)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_bridges:
|
||||
bridge_name = row["name"]
|
||||
bridge_gdf = gpd.GeoDataFrame([row], crs=bridges.crs)
|
||||
image_data = generate_bridge_image_bytes(
|
||||
bridge_gdf, warsaw_boundary, vistula
|
||||
)
|
||||
safe_name = bridge_name.replace(" ", "_").replace("/", "_")
|
||||
preview_path = preview_dir / f"{safe_name}.png"
|
||||
preview_path.write_bytes(image_data)
|
||||
sys.stdout.write(f" Saved: {preview_path.name}\n")
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write(f"Bridges: {num_bridges}\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
if args.preview:
|
||||
sys.stdout.write(f"Preview images: {args.preview}\n")
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
120
python_pkg/anki_decks/warsaw_districts/README.md
Normal file
120
python_pkg/anki_decks/warsaw_districts/README.md
Normal file
@ -0,0 +1,120 @@
|
||||
# Warsaw Districts Anki Generator
|
||||
|
||||
Generate Anki flashcards for learning the 18 districts (dzielnice) of Warsaw, Poland.
|
||||
|
||||
## Features
|
||||
|
||||
- Generates flashcards for all 18 Warsaw districts
|
||||
- **Uses real district boundaries from OpenStreetMap data**
|
||||
- Front of card: Map showing the full city with only the target district's border highlighted in bold
|
||||
- Back of card: District name in Polish
|
||||
- Self-contained .apkg file with embedded images
|
||||
- Compatible with AnkiWeb and AnkiDroid
|
||||
|
||||
## Data Source
|
||||
|
||||
District boundaries are sourced from [andilabs/warszawa-dzielnice-geojson](https://github.com/andilabs/warszawa-dzielnice-geojson), which provides accurate OpenStreetMap-based GeoJSON data for all Warsaw districts.
|
||||
|
||||
## Installation
|
||||
|
||||
Install dependencies using your preferred method:
|
||||
|
||||
### Using pyenv (recommended)
|
||||
|
||||
```bash
|
||||
pyenv install 3.10 # or later
|
||||
pyenv shell 3.10
|
||||
pip install matplotlib genanki geopandas
|
||||
```
|
||||
|
||||
### Using pipx
|
||||
|
||||
```bash
|
||||
pipx install --python python3.10 matplotlib genanki geopandas
|
||||
```
|
||||
|
||||
### Using system package manager (Arch Linux)
|
||||
|
||||
```bash
|
||||
sudo pacman -S python-matplotlib python-geopandas
|
||||
pip install genanki
|
||||
```
|
||||
|
||||
### Using pip directly
|
||||
|
||||
```bash
|
||||
pip install matplotlib genanki geopandas
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Generate flashcards
|
||||
|
||||
```bash
|
||||
# From the repository root
|
||||
python -m python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki
|
||||
```
|
||||
|
||||
This creates:
|
||||
|
||||
- `warsaw_districts.apkg` - Self-contained Anki package with all images embedded
|
||||
|
||||
### Custom options
|
||||
|
||||
```bash
|
||||
# Custom output file
|
||||
python -m python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki --output my_cards.apkg
|
||||
|
||||
# Custom deck name
|
||||
python -m python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki --deck-name "Warszawa - Dzielnice"
|
||||
```
|
||||
|
||||
## Importing into Anki
|
||||
|
||||
1. Open Anki
|
||||
2. File → Import
|
||||
3. Select the generated `warsaw_districts.apkg` file
|
||||
4. Click Import
|
||||
|
||||
That's it! All images are already embedded in the .apkg file.
|
||||
|
||||
## Warsaw Districts
|
||||
|
||||
The generator includes all 18 official districts of Warsaw:
|
||||
|
||||
1. Bemowo
|
||||
2. Białołęka
|
||||
3. Bielany
|
||||
4. Mokotów
|
||||
5. Ochota
|
||||
6. Praga-Południe
|
||||
7. Praga-Północ
|
||||
8. Rembertów
|
||||
9. Śródmieście
|
||||
10. Targówek
|
||||
11. Ursus
|
||||
12. Ursynów
|
||||
13. Wawer
|
||||
14. Wesoła
|
||||
15. Wilanów
|
||||
16. Włochy
|
||||
17. Wola
|
||||
18. Żoliborz
|
||||
|
||||
## Development
|
||||
|
||||
### Running tests
|
||||
|
||||
```bash
|
||||
pytest python_pkg/warsaw_districts/tests/
|
||||
```
|
||||
|
||||
### Code quality
|
||||
|
||||
```bash
|
||||
ruff check python_pkg/warsaw_districts/
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Same as the parent repository.
|
||||
5
python_pkg/anki_decks/warsaw_districts/__init__.py
Normal file
5
python_pkg/anki_decks/warsaw_districts/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Warsaw districts Anki flashcard generator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["generate_anki_deck", "main"]
|
||||
56
python_pkg/anki_decks/warsaw_districts/run.sh
Executable file
56
python_pkg/anki_decks/warsaw_districts/run.sh
Executable file
@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
# Script to set up environment, install dependencies, and generate Warsaw Districts Anki deck
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||
PREVIEW_DIR="$SCRIPT_DIR/preview_images"
|
||||
|
||||
echo "=== Warsaw Districts Anki Generator ==="
|
||||
echo
|
||||
|
||||
# Create virtual environment if it doesn't exist
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
# Activate virtual environment
|
||||
echo "Activating virtual environment..."
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
# Install dependencies
|
||||
echo "Installing dependencies..."
|
||||
pip install --quiet --upgrade pip
|
||||
pip install --quiet matplotlib genanki geopandas
|
||||
|
||||
# Export preview images
|
||||
echo
|
||||
echo "Exporting preview images to $PREVIEW_DIR..."
|
||||
mkdir -p "$PREVIEW_DIR"
|
||||
cd "$SCRIPT_DIR"
|
||||
python -c "
|
||||
from warsaw_districts_anki import WARSAW_DISTRICTS, generate_district_image_bytes
|
||||
from pathlib import Path
|
||||
|
||||
preview_dir = Path('$PREVIEW_DIR')
|
||||
for district in WARSAW_DISTRICTS:
|
||||
filename = district.replace(' ', '_').replace('-', '_') + '.png'
|
||||
filepath = preview_dir / filename
|
||||
filepath.write_bytes(generate_district_image_bytes(district))
|
||||
print(f' Exported: {filename}')
|
||||
"
|
||||
|
||||
echo
|
||||
echo "Preview images exported! Check: $PREVIEW_DIR"
|
||||
|
||||
# Generate Anki deck
|
||||
echo
|
||||
echo "Generating Anki flashcards..."
|
||||
python -m warsaw_districts_anki --output warsaw_districts.apkg
|
||||
|
||||
echo
|
||||
echo "Done!"
|
||||
echo " - Preview images: $PREVIEW_DIR"
|
||||
echo " - Anki deck: $SCRIPT_DIR/warsaw_districts.apkg"
|
||||
5
python_pkg/anki_decks/warsaw_districts/tests/__init__.py
Normal file
5
python_pkg/anki_decks/warsaw_districts/tests/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Tests init file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__: list[str] = []
|
||||
@ -0,0 +1,175 @@
|
||||
"""Tests for the Warsaw districts Anki generator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki import (
|
||||
WARSAW_DISTRICTS,
|
||||
create_district_map,
|
||||
generate_anki_package,
|
||||
generate_district_image_bytes,
|
||||
main,
|
||||
)
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
|
||||
from python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki import (
|
||||
WARSAW_DISTRICTS,
|
||||
create_district_map,
|
||||
generate_anki_package,
|
||||
generate_district_image_bytes,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
class TestDistricts:
|
||||
"""Tests for Warsaw districts data."""
|
||||
|
||||
def test_has_18_districts(self) -> None:
|
||||
"""Test that we have exactly 18 Warsaw districts."""
|
||||
assert len(WARSAW_DISTRICTS) == 18
|
||||
|
||||
def test_all_districts_are_strings(self) -> None:
|
||||
"""Test that all district entries are strings."""
|
||||
for district in WARSAW_DISTRICTS:
|
||||
assert isinstance(district, str)
|
||||
assert len(district) > 0
|
||||
|
||||
def test_districts_are_unique(self) -> None:
|
||||
"""Test that all district names are unique."""
|
||||
assert len(WARSAW_DISTRICTS) == len(set(WARSAW_DISTRICTS))
|
||||
|
||||
def test_known_districts_present(self) -> None:
|
||||
"""Test that all known Warsaw districts are in the list."""
|
||||
district_set = set(WARSAW_DISTRICTS)
|
||||
# Check all 18 districts
|
||||
expected_districts = {
|
||||
"Bemowo",
|
||||
"Białołęka",
|
||||
"Bielany",
|
||||
"Mokotów",
|
||||
"Ochota",
|
||||
"Praga Południe", # Note: space, not hyphen
|
||||
"Praga Północ", # Note: space, not hyphen
|
||||
"Rembertów",
|
||||
"Śródmieście",
|
||||
"Targówek",
|
||||
"Ursus",
|
||||
"Ursynów",
|
||||
"Wawer",
|
||||
"Wesoła",
|
||||
"Wilanów",
|
||||
"Włochy",
|
||||
"Wola",
|
||||
"Żoliborz",
|
||||
}
|
||||
assert district_set == expected_districts
|
||||
|
||||
|
||||
class TestCreateDistrictMap:
|
||||
"""Tests for creating district maps."""
|
||||
|
||||
def test_creates_figure(self) -> None:
|
||||
"""Test that create_district_map returns a Figure."""
|
||||
district = WARSAW_DISTRICTS[0]
|
||||
fig = create_district_map(district)
|
||||
assert fig is not None
|
||||
# Clean up
|
||||
plt.close(fig)
|
||||
|
||||
def test_creates_figure_for_all_districts(self) -> None:
|
||||
"""Test that we can create maps for all districts."""
|
||||
for district in WARSAW_DISTRICTS:
|
||||
fig = create_district_map(district)
|
||||
assert fig is not None
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
class TestGenerateDistrictImageBytes:
|
||||
"""Tests for generating district image bytes."""
|
||||
|
||||
def test_generates_bytes(self) -> None:
|
||||
"""Test that generate_district_image_bytes returns bytes."""
|
||||
district = WARSAW_DISTRICTS[0]
|
||||
image_bytes = generate_district_image_bytes(district)
|
||||
assert isinstance(image_bytes, bytes)
|
||||
assert len(image_bytes) > 0
|
||||
|
||||
def test_generates_for_all_districts(self) -> None:
|
||||
"""Test that we can generate images for all districts."""
|
||||
for district in WARSAW_DISTRICTS:
|
||||
image_bytes = generate_district_image_bytes(district)
|
||||
assert isinstance(image_bytes, bytes)
|
||||
assert len(image_bytes) > 0
|
||||
|
||||
|
||||
class TestGenerateAnkiPackage:
|
||||
"""Tests for generating Anki package."""
|
||||
|
||||
def test_generates_package(self) -> None:
|
||||
"""Test that output is a genanki Package."""
|
||||
package = generate_anki_package("Test Deck")
|
||||
assert package is not None
|
||||
assert len(package.decks) == 1
|
||||
|
||||
def test_generates_notes_for_all_districts(self) -> None:
|
||||
"""Test that package contains cards for all 18 districts."""
|
||||
package = generate_anki_package()
|
||||
deck = package.decks[0]
|
||||
assert len(deck.notes) == len(WARSAW_DISTRICTS)
|
||||
|
||||
def test_custom_deck_name(self) -> None:
|
||||
"""Test that custom deck name is used."""
|
||||
package = generate_anki_package("Custom Deck")
|
||||
deck = package.decks[0]
|
||||
assert deck.name == "Custom Deck"
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Tests for the main CLI function."""
|
||||
|
||||
def test_creates_output_file(self, tmp_path: Path) -> None:
|
||||
"""Test that main creates the output file."""
|
||||
output_file = tmp_path / "test_output.apkg"
|
||||
|
||||
result = main(
|
||||
[
|
||||
"--output",
|
||||
str(output_file),
|
||||
]
|
||||
)
|
||||
|
||||
assert result == 0
|
||||
assert output_file.exists()
|
||||
|
||||
def test_custom_deck_name(self, tmp_path: Path) -> None:
|
||||
"""Test that custom deck name is used."""
|
||||
output_file = tmp_path / "test_output.apkg"
|
||||
|
||||
result = main(
|
||||
[
|
||||
"--output",
|
||||
str(output_file),
|
||||
"--deck-name",
|
||||
"Custom Deck",
|
||||
]
|
||||
)
|
||||
|
||||
assert result == 0
|
||||
assert output_file.exists()
|
||||
|
||||
def test_help_flag(self) -> None:
|
||||
"""Test that --help works."""
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["--help"])
|
||||
assert exc_info.value.code == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
BIN
python_pkg/anki_decks/warsaw_districts/warsaw_districts.apkg
Normal file
BIN
python_pkg/anki_decks/warsaw_districts/warsaw_districts.apkg
Normal file
Binary file not shown.
356
python_pkg/anki_decks/warsaw_districts/warsaw_districts_anki.py
Executable file
356
python_pkg/anki_decks/warsaw_districts/warsaw_districts_anki.py
Executable file
@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Anki flashcard generator for Warsaw districts.
|
||||
|
||||
Generates Anki-compatible flashcard decks with maps showing individual
|
||||
Warsaw districts (dzielnice) with their borders using real boundary data
|
||||
from OpenStreetMap.
|
||||
|
||||
Usage:
|
||||
# Generate Anki cards for all Warsaw districts
|
||||
python -m python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki
|
||||
|
||||
# Specify custom output file
|
||||
python -m python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki \
|
||||
--output warsaw.apkg
|
||||
|
||||
Output:
|
||||
Creates a self-contained .apkg file that can be directly imported into Anki.
|
||||
The file includes all images embedded, so no manual file copying is needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import genanki
|
||||
import geopandas as gpd
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
|
||||
# Path to GeoJSON file with Warsaw district boundaries
|
||||
GEOJSON_PATH = Path(__file__).parent / "warszawa-dzielnice.geojson"
|
||||
|
||||
|
||||
def load_district_data() -> gpd.GeoDataFrame:
|
||||
"""Load Warsaw district boundaries from GeoJSON.
|
||||
|
||||
Returns:
|
||||
GeoDataFrame with district boundaries.
|
||||
"""
|
||||
if not GEOJSON_PATH.exists():
|
||||
msg = f"GeoJSON file not found at {GEOJSON_PATH}"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
gdf = gpd.read_file(GEOJSON_PATH)
|
||||
# Filter out the "Warszawa" entry (whole city) and keep only districts
|
||||
return gdf[gdf["name"] != "Warszawa"].copy()
|
||||
|
||||
|
||||
def get_district_names() -> list[str]:
|
||||
"""Get list of all district names from GeoJSON data.
|
||||
|
||||
Returns:
|
||||
Sorted list of district names.
|
||||
"""
|
||||
gdf = load_district_data()
|
||||
return sorted(gdf["name"].tolist())
|
||||
|
||||
|
||||
# Load district names from actual data
|
||||
WARSAW_DISTRICTS = get_district_names()
|
||||
|
||||
|
||||
# 18 unique distinct colors for all Warsaw districts
|
||||
# Chosen to be visually distinct from each other in both light and dark modes
|
||||
DISTRICT_COLORS = [
|
||||
"#E74C3C", # Red
|
||||
"#3498DB", # Blue
|
||||
"#2ECC71", # Emerald green
|
||||
"#9B59B6", # Purple
|
||||
"#F39C12", # Orange
|
||||
"#1ABC9C", # Turquoise
|
||||
"#E91E63", # Pink
|
||||
"#00BCD4", # Cyan
|
||||
"#8BC34A", # Light green
|
||||
"#FF5722", # Deep orange
|
||||
"#673AB7", # Deep purple
|
||||
"#FFEB3B", # Yellow
|
||||
"#795548", # Brown
|
||||
"#607D8B", # Blue grey
|
||||
"#CDDC39", # Lime
|
||||
"#FF9800", # Amber
|
||||
"#4CAF50", # Green
|
||||
"#03A9F4", # Light blue
|
||||
]
|
||||
|
||||
|
||||
def create_district_map(district_name: str) -> Figure:
|
||||
"""Create a map showing Warsaw with one district highlighted.
|
||||
|
||||
The map shows Warsaw as a plain shape (no internal district borders)
|
||||
with only the target district highlighted in color with a bold border.
|
||||
This makes it harder to guess the district using contextual cues.
|
||||
|
||||
Args:
|
||||
district_name: Name of the district to highlight.
|
||||
|
||||
Returns:
|
||||
A matplotlib Figure object.
|
||||
"""
|
||||
# Load all district data
|
||||
gdf = load_district_data()
|
||||
|
||||
# Create figure with transparent background
|
||||
fig, ax = plt.subplots(figsize=(10, 10))
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.patch.set_alpha(0)
|
||||
ax.patch.set_alpha(0)
|
||||
|
||||
# Find the target district
|
||||
target = gdf[gdf["name"] == district_name]
|
||||
if len(target) == 0:
|
||||
msg = f"District {district_name} not found in data"
|
||||
raise ValueError(msg)
|
||||
|
||||
# Create unified Warsaw shape by dissolving all districts
|
||||
warsaw_unified = gdf.union_all()
|
||||
|
||||
# Plot Warsaw as a plain gray shape (no internal borders)
|
||||
warsaw_gdf = gpd.GeoDataFrame(geometry=[warsaw_unified], crs=gdf.crs)
|
||||
warsaw_gdf.plot(ax=ax, color="#D5D8DC", alpha=0.6)
|
||||
warsaw_gdf.boundary.plot(ax=ax, color="#2C3E50", linewidth=2)
|
||||
|
||||
# Assign colors to districts based on sorted names for consistency
|
||||
sorted_names = sorted(gdf["name"].tolist())
|
||||
color_map = {
|
||||
name: DISTRICT_COLORS[i % len(DISTRICT_COLORS)]
|
||||
for i, name in enumerate(sorted_names)
|
||||
}
|
||||
|
||||
# Highlight only the target district with bright color and bold border
|
||||
fill_color = color_map[district_name]
|
||||
target.plot(ax=ax, color=fill_color, alpha=0.9)
|
||||
target.boundary.plot(ax=ax, color="#1A1A1A", linewidth=4)
|
||||
|
||||
# Set tight layout
|
||||
ax.set_xlim(gdf.total_bounds[0], gdf.total_bounds[2])
|
||||
ax.set_ylim(gdf.total_bounds[1], gdf.total_bounds[3])
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_district_image_bytes(district_name: str) -> bytes:
|
||||
"""Generate a district map image as bytes.
|
||||
|
||||
Args:
|
||||
district_name: Name of the district to visualize.
|
||||
|
||||
Returns:
|
||||
PNG image data as bytes.
|
||||
"""
|
||||
fig = create_district_map(district_name)
|
||||
|
||||
# Save to bytes buffer
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
return buf.read()
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
deck_name: str = "Warsaw Districts",
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package (.apkg) for Warsaw districts.
|
||||
|
||||
Args:
|
||||
deck_name: Name for the Anki deck.
|
||||
|
||||
Returns:
|
||||
genanki.Package object ready to be written to file.
|
||||
"""
|
||||
# Create a unique model ID based on deck name
|
||||
model_id_hash = hashlib.md5( # noqa: S324
|
||||
f"warsaw_districts_{deck_name}".encode()
|
||||
)
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
# Define the note model (card template) with centered styling
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.answer-text {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.card.night_mode .answer-text {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Warsaw District Model",
|
||||
fields=[
|
||||
{"name": "DistrictMap"},
|
||||
{"name": "DistrictName"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": '<div class="map-container">{{DistrictMap}}</div>',
|
||||
"afmt": '<div class="map-container">{{DistrictMap}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer-text">{{DistrictName}}</div>',
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
# Create a unique deck ID based on deck name
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
|
||||
# Create the deck
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
|
||||
# Store media files
|
||||
media_files = []
|
||||
|
||||
# Generate notes for each district
|
||||
for district_name in WARSAW_DISTRICTS:
|
||||
# Generate image
|
||||
image_data = generate_district_image_bytes(district_name)
|
||||
|
||||
# Create unique filename
|
||||
filename = f"{district_name.replace(' ', '_').replace('-', '_')}.png"
|
||||
|
||||
# Create note
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[
|
||||
f'<img src="{filename}">',
|
||||
district_name,
|
||||
],
|
||||
tags=["geography", "warsaw", "poland"],
|
||||
)
|
||||
|
||||
my_deck.add_note(note)
|
||||
|
||||
# Save image data to temporary file for packaging
|
||||
temp_path = Path(f"/tmp/{filename}") # noqa: S108
|
||||
temp_path.write_bytes(image_data)
|
||||
media_files.append(str(temp_path))
|
||||
|
||||
# Create package
|
||||
package = genanki.Package(my_deck)
|
||||
package.media_files = media_files
|
||||
|
||||
return package
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point.
|
||||
|
||||
Args:
|
||||
argv: Command line arguments.
|
||||
|
||||
Returns:
|
||||
Exit code.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Warsaw districts.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: warsaw_districts.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Warsaw Districts",
|
||||
help="Name for the Anki deck (default: 'Warsaw Districts')",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# Determine output path
|
||||
output_path = Path(args.output) if args.output else Path("warsaw_districts.apkg")
|
||||
|
||||
try:
|
||||
num_districts = len(WARSAW_DISTRICTS)
|
||||
sys.stdout.write(
|
||||
f"Generating flashcards for {num_districts} Warsaw districts...\n"
|
||||
)
|
||||
sys.stdout.write("Using real district boundaries from OpenStreetMap data.\n")
|
||||
|
||||
# Generate the package
|
||||
package = generate_anki_package(args.deck_name)
|
||||
|
||||
# Write to file
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write(f"Districts: {num_districts}\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("To import into Anki:\n")
|
||||
sys.stdout.write(" 1. Open Anki\n")
|
||||
sys.stdout.write(" 2. File -> Import\n")
|
||||
sys.stdout.write(f" 3. Select: {output_path.absolute()}\n")
|
||||
sys.stdout.write(" 4. Click Import\n")
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("All images are embedded in the .apkg file!\n")
|
||||
except (OSError, ValueError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
15849
python_pkg/anki_decks/warsaw_districts/warszawa-dzielnice.geojson
Normal file
15849
python_pkg/anki_decks/warsaw_districts/warszawa-dzielnice.geojson
Normal file
File diff suppressed because it is too large
Load Diff
1
python_pkg/anki_decks/warsaw_landmarks/__init__.py
Normal file
1
python_pkg/anki_decks/warsaw_landmarks/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Warsaw landmarks Anki flashcard generator."""
|
||||
34
python_pkg/anki_decks/warsaw_landmarks/run.sh
Executable file
34
python_pkg/anki_decks/warsaw_landmarks/run.sh
Executable file
@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# Script to generate Warsaw Landmarks Anki deck
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||
PREVIEW_DIR="$SCRIPT_DIR/preview_images"
|
||||
|
||||
echo "=== Warsaw Landmarks Anki Generator ==="
|
||||
echo
|
||||
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
echo "Activating virtual environment..."
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
echo "Installing dependencies..."
|
||||
pip install --quiet --upgrade pip
|
||||
pip install --quiet matplotlib genanki geopandas requests shapely
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Create preview images directory
|
||||
mkdir -p "$PREVIEW_DIR"
|
||||
|
||||
python -m warsaw_landmarks_anki --output warsaw_landmarks.apkg --preview "$PREVIEW_DIR" --preview-count 5
|
||||
|
||||
echo
|
||||
echo "Done! The Anki deck is at: $SCRIPT_DIR/warsaw_landmarks.apkg"
|
||||
echo "Preview images are in: $PREVIEW_DIR"
|
||||
272
python_pkg/anki_decks/warsaw_landmarks/warsaw_landmarks_anki.py
Executable file
272
python_pkg/anki_decks/warsaw_landmarks/warsaw_landmarks_anki.py
Executable file
@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Anki flashcard generator for Warsaw landmarks.
|
||||
|
||||
Generates Anki-compatible flashcard decks with maps showing individual
|
||||
Warsaw landmarks (monuments, museums, parks, historic sites).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
import genanki
|
||||
from geo_data import get_warsaw_landmarks
|
||||
import geopandas as gpd
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
# Landmark marker color
|
||||
LANDMARK_COLOR = "#9B59B6" # Purple
|
||||
|
||||
|
||||
def load_warsaw_boundary() -> gpd.GeoDataFrame:
|
||||
"""Load Warsaw boundary from districts GeoJSON."""
|
||||
districts_path = (
|
||||
Path(__file__).parent.parent / "warsaw_districts" / "warszawa-dzielnice.geojson"
|
||||
)
|
||||
if districts_path.exists():
|
||||
warsaw_gdf = gpd.read_file(districts_path)
|
||||
warsaw_boundary = warsaw_gdf[warsaw_gdf["name"] == "Warszawa"]
|
||||
if len(warsaw_boundary) == 0:
|
||||
warsaw_boundary = gpd.GeoDataFrame(
|
||||
geometry=[warsaw_gdf.union_all()], crs=warsaw_gdf.crs
|
||||
)
|
||||
return warsaw_boundary
|
||||
|
||||
msg = "Warsaw boundary data not found"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
|
||||
def create_landmark_map(
|
||||
landmark_gdf: gpd.GeoDataFrame,
|
||||
warsaw_boundary: gpd.GeoDataFrame,
|
||||
) -> Figure:
|
||||
"""Create a map showing Warsaw with one landmark highlighted."""
|
||||
fig, ax = plt.subplots(figsize=(10, 10))
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.patch.set_alpha(0)
|
||||
ax.patch.set_alpha(0)
|
||||
|
||||
# Plot Warsaw as a plain gray shape
|
||||
warsaw_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
|
||||
warsaw_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=2)
|
||||
|
||||
# Plot the landmark as a star marker
|
||||
landmark_gdf.plot(
|
||||
ax=ax,
|
||||
color=LANDMARK_COLOR,
|
||||
markersize=400,
|
||||
marker="*",
|
||||
alpha=0.9,
|
||||
edgecolor="#1A1A1A",
|
||||
linewidth=1.5,
|
||||
)
|
||||
|
||||
# Set bounds to Warsaw
|
||||
bounds = warsaw_boundary.total_bounds
|
||||
ax.set_xlim(bounds[0], bounds[2])
|
||||
ax.set_ylim(bounds[1], bounds[3])
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_landmark_image_bytes(
|
||||
landmark_gdf: gpd.GeoDataFrame,
|
||||
warsaw_boundary: gpd.GeoDataFrame,
|
||||
) -> bytes:
|
||||
"""Generate a landmark map image as bytes."""
|
||||
fig = create_landmark_map(landmark_gdf, warsaw_boundary)
|
||||
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
return buf.read()
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
landmarks: gpd.GeoDataFrame,
|
||||
warsaw_boundary: gpd.GeoDataFrame,
|
||||
deck_name: str = "Warsaw Landmarks",
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package for Warsaw landmarks."""
|
||||
model_id_hash = hashlib.md5(f"warsaw_landmarks_{deck_name}".encode()) # noqa: S324
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.answer-text {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.card.night_mode .answer-text {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Warsaw Landmark Model",
|
||||
fields=[
|
||||
{"name": "LandmarkMap"},
|
||||
{"name": "LandmarkName"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": '<div class="map-container">{{LandmarkMap}}</div>',
|
||||
"afmt": '<div class="map-container">{{LandmarkMap}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer-text">{{LandmarkName}}</div>',
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
media_files = []
|
||||
|
||||
for _, row in landmarks.iterrows():
|
||||
landmark_name = row["name"]
|
||||
landmark_gdf = gpd.GeoDataFrame([row], crs=landmarks.crs)
|
||||
|
||||
image_data = generate_landmark_image_bytes(landmark_gdf, warsaw_boundary)
|
||||
filename = f"landmark_{landmark_name.replace(' ', '_').replace('/', '_')}.png"
|
||||
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[f'<img src="{filename}">', landmark_name],
|
||||
tags=["geography", "warsaw", "landmarks"],
|
||||
)
|
||||
my_deck.add_note(note)
|
||||
|
||||
temp_path = Path(f"/tmp/{filename}") # noqa: S108
|
||||
temp_path.write_bytes(image_data)
|
||||
media_files.append(str(temp_path))
|
||||
|
||||
package = genanki.Package(my_deck)
|
||||
package.media_files = media_files
|
||||
return package
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Warsaw landmarks.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: warsaw_landmarks.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Warsaw Landmarks",
|
||||
help="Name for the Anki deck",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
"-p",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Export preview images to specified directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview-count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of preview images to export (default: 5)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
output_path = Path(args.output) if args.output else Path("warsaw_landmarks.apkg")
|
||||
|
||||
try:
|
||||
sys.stdout.write("Loading landmark data...\n")
|
||||
landmarks = get_warsaw_landmarks()
|
||||
warsaw_boundary = load_warsaw_boundary()
|
||||
num_landmarks = len(landmarks)
|
||||
|
||||
sys.stdout.write(f"Generating flashcards for {num_landmarks} landmarks...\n")
|
||||
|
||||
package = generate_anki_package(landmarks, warsaw_boundary, args.deck_name)
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
# Export preview images if requested
|
||||
if args.preview:
|
||||
preview_dir = Path(args.preview)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_landmarks = list(landmarks.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_landmarks)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_landmarks:
|
||||
landmark_name = row["name"]
|
||||
landmark_gdf = gpd.GeoDataFrame([row], crs=landmarks.crs)
|
||||
image_data = generate_landmark_image_bytes(
|
||||
landmark_gdf, warsaw_boundary
|
||||
)
|
||||
safe_name = landmark_name.replace(" ", "_").replace("/", "_")
|
||||
preview_path = preview_dir / f"{safe_name}.png"
|
||||
preview_path.write_bytes(image_data)
|
||||
sys.stdout.write(f" Saved: {preview_path.name}\n")
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write(f"Landmarks: {num_landmarks}\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
if args.preview:
|
||||
sys.stdout.write(f"Preview images: {args.preview}\n")
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
1
python_pkg/anki_decks/warsaw_metro/__init__.py
Normal file
1
python_pkg/anki_decks/warsaw_metro/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Warsaw metro stations Anki flashcard generator."""
|
||||
287
python_pkg/anki_decks/warsaw_metro/cache/warsaw_metro.geojson
vendored
Normal file
287
python_pkg/anki_decks/warsaw_metro/cache/warsaw_metro.geojson
vendored
Normal file
@ -0,0 +1,287 @@
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Ratusz-Arsena\u0142",
|
||||
"line": "",
|
||||
"osm_id": 35121250
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [21.0008823, 52.2452163] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": { "name": "Marymont", "line": "", "osm_id": 291414851 },
|
||||
"geometry": { "type": "Point", "coordinates": [20.9719399, 52.2715768] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Stare Bielany",
|
||||
"line": "",
|
||||
"osm_id": 307389114
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [20.9493511, 52.2818277] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": { "name": "Wawrzyszew", "line": "", "osm_id": 307389115 },
|
||||
"geometry": { "type": "Point", "coordinates": [20.939515, 52.2863474] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": { "name": "M\u0142ociny", "line": "", "osm_id": 307389116 },
|
||||
"geometry": { "type": "Point", "coordinates": [20.9298678, 52.2907703] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "S\u0142odowiec",
|
||||
"line": "",
|
||||
"osm_id": 1958108834
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [20.9601259, 52.2768261] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Dworzec Wile\u0144ski",
|
||||
"line": "",
|
||||
"osm_id": 3390187815
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [21.0357972, 52.2537771] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Stadion Narodowy",
|
||||
"line": "",
|
||||
"osm_id": 3390197100
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [21.042847, 52.2468346] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Centrum Nauki Kopernik",
|
||||
"line": "",
|
||||
"osm_id": 3390230685
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [21.0317878, 52.2399148] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Nowy \u015awiat-Uniwersytet",
|
||||
"line": "",
|
||||
"osm_id": 3390237898
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [21.0168168, 52.2368196] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": { "name": "Rondo ONZ", "line": "", "osm_id": 3390266492 },
|
||||
"geometry": { "type": "Point", "coordinates": [20.9981024, 52.2330735] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Rondo Daszy\u0144skiego",
|
||||
"line": "",
|
||||
"osm_id": 3390267294
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [20.9828946, 52.2300827] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": { "name": "Kabaty", "line": "", "osm_id": 3390283399 },
|
||||
"geometry": { "type": "Point", "coordinates": [21.0650711, 52.1320765] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Stok\u0142osy",
|
||||
"line": "",
|
||||
"osm_id": 3390289961
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [21.0347233, 52.1560759] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": { "name": "Natolin", "line": "", "osm_id": 3390290324 },
|
||||
"geometry": { "type": "Point", "coordinates": [21.0564351, 52.1411007] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": { "name": "Imielin", "line": "", "osm_id": 3390290598 },
|
||||
"geometry": { "type": "Point", "coordinates": [21.0461062, 52.1493] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Ursyn\u00f3w",
|
||||
"line": "",
|
||||
"osm_id": 3390304927
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [21.0276283, 52.1620456] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "S\u0142u\u017cew",
|
||||
"line": "",
|
||||
"osm_id": 3390306374
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [21.0262866, 52.1727624] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Rac\u0142awicka",
|
||||
"line": "",
|
||||
"osm_id": 3390327068
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [21.0122349, 52.1988637] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": { "name": "Wierzbno", "line": "", "osm_id": 3390333313 },
|
||||
"geometry": { "type": "Point", "coordinates": [21.0167966, 52.1898719] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": { "name": "Wilanowska", "line": "", "osm_id": 3390336021 },
|
||||
"geometry": { "type": "Point", "coordinates": [21.0231452, 52.1818168] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Pole Mokotowskie",
|
||||
"line": "",
|
||||
"osm_id": 3390342469
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [21.0079298, 52.2087775] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Politechnika",
|
||||
"line": "",
|
||||
"osm_id": 3390361095
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [21.0153031, 52.2186581] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": { "name": "Centrum", "line": "", "osm_id": 3390365405 },
|
||||
"geometry": { "type": "Point", "coordinates": [21.010186, 52.2310069] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Dworzec Gda\u0144ski",
|
||||
"line": "",
|
||||
"osm_id": 3416840991
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [20.9941857, 52.2580586] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Plac Wilsona",
|
||||
"line": "",
|
||||
"osm_id": 3615569910
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [20.9844973, 52.2692619] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": { "name": "P\u0142ocka", "line": "", "osm_id": 4930657482 },
|
||||
"geometry": { "type": "Point", "coordinates": [20.9663847, 52.2324542] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": { "name": "Trocka", "line": "", "osm_id": 4930657487 },
|
||||
"geometry": { "type": "Point", "coordinates": [21.0550586, 52.2751021] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "\u015awi\u0119tokrzyska",
|
||||
"line": "",
|
||||
"osm_id": 5117464830
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [21.0078988, 52.2350954] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Ksi\u0119cia Janusza",
|
||||
"line": "",
|
||||
"osm_id": 5907821373
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [20.9443771, 52.2391822] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "M\u0142yn\u00f3w",
|
||||
"line": "",
|
||||
"osm_id": 5907821374
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [20.9601048, 52.2376624] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": { "name": "Szwedzka", "line": "", "osm_id": 6053348692 },
|
||||
"geometry": { "type": "Point", "coordinates": [21.0455232, 52.2634709] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Targ\u00f3wek Mieszkaniowy",
|
||||
"line": "",
|
||||
"osm_id": 6564265270
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [21.0513658, 52.2692518] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": { "name": "Bemowo", "line": "", "osm_id": 7362922676 },
|
||||
"geometry": { "type": "Point", "coordinates": [20.9154991, 52.2392071] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Ulrych\u00f3w",
|
||||
"line": "",
|
||||
"osm_id": 7362922677
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [20.9298652, 52.2403314] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Br\u00f3dno",
|
||||
"line": "",
|
||||
"osm_id": 10058224345
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [21.0289387, 52.293585] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Kondratowicza",
|
||||
"line": "",
|
||||
"osm_id": 10058224348
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [21.0486889, 52.2920848] }
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": { "name": "Zacisze", "line": "", "osm_id": 10058224349 },
|
||||
"geometry": { "type": "Point", "coordinates": [21.062148, 52.2837496] }
|
||||
}
|
||||
]
|
||||
}
|
||||
34
python_pkg/anki_decks/warsaw_metro/run.sh
Executable file
34
python_pkg/anki_decks/warsaw_metro/run.sh
Executable file
@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# Script to generate Warsaw Metro Anki deck
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||
PREVIEW_DIR="$SCRIPT_DIR/preview_images"
|
||||
|
||||
echo "=== Warsaw Metro Stations Anki Generator ==="
|
||||
echo
|
||||
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
echo "Activating virtual environment..."
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
echo "Installing dependencies..."
|
||||
pip install --quiet --upgrade pip
|
||||
pip install --quiet matplotlib genanki geopandas requests shapely
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Create preview images directory
|
||||
mkdir -p "$PREVIEW_DIR"
|
||||
|
||||
python -m warsaw_metro_anki --output warsaw_metro.apkg --preview "$PREVIEW_DIR" --preview-count 5
|
||||
|
||||
echo
|
||||
echo "Done! The Anki deck is at: $SCRIPT_DIR/warsaw_metro.apkg"
|
||||
echo "Preview images are in: $PREVIEW_DIR"
|
||||
295
python_pkg/anki_decks/warsaw_metro/warsaw_metro_anki.py
Executable file
295
python_pkg/anki_decks/warsaw_metro/warsaw_metro_anki.py
Executable file
@ -0,0 +1,295 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Anki flashcard generator for Warsaw metro stations.
|
||||
|
||||
Generates Anki-compatible flashcard decks with maps showing individual
|
||||
Warsaw metro stations highlighted on a city map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
import genanki
|
||||
from geo_data import get_warsaw_metro_stations
|
||||
import geopandas as gpd
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
# Station marker color
|
||||
STATION_COLOR = "#E74C3C"
|
||||
|
||||
|
||||
def load_warsaw_boundary() -> gpd.GeoDataFrame:
|
||||
"""Load Warsaw boundary from districts GeoJSON.
|
||||
|
||||
Returns:
|
||||
GeoDataFrame with Warsaw boundary.
|
||||
"""
|
||||
districts_path = (
|
||||
Path(__file__).parent.parent / "warsaw_districts" / "warszawa-dzielnice.geojson"
|
||||
)
|
||||
if districts_path.exists():
|
||||
warsaw_gdf = gpd.read_file(districts_path)
|
||||
warsaw_boundary = warsaw_gdf[warsaw_gdf["name"] == "Warszawa"]
|
||||
if len(warsaw_boundary) == 0:
|
||||
warsaw_boundary = gpd.GeoDataFrame(
|
||||
geometry=[warsaw_gdf.union_all()], crs=warsaw_gdf.crs
|
||||
)
|
||||
return warsaw_boundary
|
||||
|
||||
msg = "Warsaw boundary data not found"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
|
||||
def create_station_map(
|
||||
station_gdf: gpd.GeoDataFrame,
|
||||
warsaw_boundary: gpd.GeoDataFrame,
|
||||
) -> Figure:
|
||||
"""Create a map showing Warsaw with one metro station highlighted.
|
||||
|
||||
Args:
|
||||
station_gdf: GeoDataFrame with the station point.
|
||||
warsaw_boundary: GeoDataFrame with Warsaw boundary.
|
||||
|
||||
Returns:
|
||||
A matplotlib Figure object.
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=(10, 10))
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.patch.set_alpha(0)
|
||||
ax.patch.set_alpha(0)
|
||||
|
||||
# Plot Warsaw as a plain gray shape
|
||||
warsaw_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
|
||||
warsaw_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=2)
|
||||
|
||||
# Plot the station as a large dot
|
||||
station_gdf.plot(
|
||||
ax=ax,
|
||||
color=STATION_COLOR,
|
||||
markersize=300,
|
||||
marker="o",
|
||||
alpha=0.9,
|
||||
edgecolor="#1A1A1A",
|
||||
linewidth=2,
|
||||
)
|
||||
|
||||
# Set bounds to Warsaw
|
||||
bounds = warsaw_boundary.total_bounds
|
||||
ax.set_xlim(bounds[0], bounds[2])
|
||||
ax.set_ylim(bounds[1], bounds[3])
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_station_image_bytes(
|
||||
station_gdf: gpd.GeoDataFrame,
|
||||
warsaw_boundary: gpd.GeoDataFrame,
|
||||
) -> bytes:
|
||||
"""Generate a station map image as bytes."""
|
||||
fig = create_station_map(station_gdf, warsaw_boundary)
|
||||
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
return buf.read()
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
stations: gpd.GeoDataFrame,
|
||||
warsaw_boundary: gpd.GeoDataFrame,
|
||||
deck_name: str = "Warsaw Metro Stations",
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package for Warsaw metro stations."""
|
||||
model_id_hash = hashlib.md5(f"warsaw_metro_{deck_name}".encode()) # noqa: S324
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.answer-text {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.line-info {
|
||||
font-size: 24px;
|
||||
margin-top: 10px;
|
||||
color: #666;
|
||||
}
|
||||
.card.night_mode .answer-text {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
.card.night_mode .line-info {
|
||||
color: #AAA;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Warsaw Metro Model",
|
||||
fields=[
|
||||
{"name": "StationMap"},
|
||||
{"name": "StationName"},
|
||||
{"name": "Line"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": '<div class="map-container">{{StationMap}}</div>',
|
||||
"afmt": '<div class="map-container">{{StationMap}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer-text">{{StationName}}</div>'
|
||||
'<div class="line-info">{{Line}}</div>',
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
media_files = []
|
||||
|
||||
for _, row in stations.iterrows():
|
||||
station_name = row["name"]
|
||||
line = row.get("line", "")
|
||||
station_gdf = gpd.GeoDataFrame([row], crs=stations.crs)
|
||||
|
||||
image_data = generate_station_image_bytes(station_gdf, warsaw_boundary)
|
||||
filename = f"metro_{station_name.replace(' ', '_').replace('/', '_')}.png"
|
||||
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[f'<img src="{filename}">', station_name, line],
|
||||
tags=["geography", "warsaw", "metro"],
|
||||
)
|
||||
my_deck.add_note(note)
|
||||
|
||||
temp_path = Path(f"/tmp/{filename}") # noqa: S108
|
||||
temp_path.write_bytes(image_data)
|
||||
media_files.append(str(temp_path))
|
||||
|
||||
package = genanki.Package(my_deck)
|
||||
package.media_files = media_files
|
||||
return package
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Warsaw metro stations.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: warsaw_metro.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Warsaw Metro Stations",
|
||||
help="Name for the Anki deck",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
"-p",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Export preview images to specified directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview-count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of preview images to export (default: 5)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
output_path = Path(args.output) if args.output else Path("warsaw_metro.apkg")
|
||||
|
||||
try:
|
||||
sys.stdout.write("Loading metro station data...\n")
|
||||
stations = get_warsaw_metro_stations()
|
||||
warsaw_boundary = load_warsaw_boundary()
|
||||
num_stations = len(stations)
|
||||
|
||||
sys.stdout.write(
|
||||
f"Generating flashcards for {num_stations} metro stations...\n"
|
||||
)
|
||||
|
||||
package = generate_anki_package(stations, warsaw_boundary, args.deck_name)
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
# Export preview images if requested
|
||||
if args.preview:
|
||||
preview_dir = Path(args.preview)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_stations = list(stations.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_stations)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_stations:
|
||||
station_name = row["name"]
|
||||
station_gdf = gpd.GeoDataFrame([row], crs=stations.crs)
|
||||
image_data = generate_station_image_bytes(station_gdf, warsaw_boundary)
|
||||
safe_name = station_name.replace(" ", "_").replace("/", "_")
|
||||
preview_path = preview_dir / f"{safe_name}.png"
|
||||
preview_path.write_bytes(image_data)
|
||||
sys.stdout.write(f" Saved: {preview_path.name}\n")
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write(f"Stations: {num_stations}\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
if args.preview:
|
||||
sys.stdout.write(f"Preview images: {args.preview}\n")
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
1
python_pkg/anki_decks/warsaw_osiedla/__init__.py
Normal file
1
python_pkg/anki_decks/warsaw_osiedla/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Warsaw osiedla (neighborhoods) Anki flashcard generator."""
|
||||
34
python_pkg/anki_decks/warsaw_osiedla/run.sh
Executable file
34
python_pkg/anki_decks/warsaw_osiedla/run.sh
Executable file
@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# Script to generate Warsaw Osiedla Anki deck
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||
PREVIEW_DIR="$SCRIPT_DIR/preview_images"
|
||||
|
||||
echo "=== Warsaw Osiedla Anki Generator ==="
|
||||
echo
|
||||
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
echo "Activating virtual environment..."
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
echo "Installing dependencies..."
|
||||
pip install --quiet --upgrade pip
|
||||
pip install --quiet matplotlib genanki geopandas requests shapely
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Create preview images directory
|
||||
mkdir -p "$PREVIEW_DIR"
|
||||
|
||||
python -m warsaw_osiedla_anki --output warsaw_osiedla.apkg --preview "$PREVIEW_DIR" --preview-count 5
|
||||
|
||||
echo
|
||||
echo "Done! The Anki deck is at: $SCRIPT_DIR/warsaw_osiedla.apkg"
|
||||
echo "Preview images are in: $PREVIEW_DIR"
|
||||
327
python_pkg/anki_decks/warsaw_osiedla/warsaw_osiedla_anki.py
Executable file
327
python_pkg/anki_decks/warsaw_osiedla/warsaw_osiedla_anki.py
Executable file
@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Anki flashcard generator for Warsaw osiedla (neighborhoods).
|
||||
|
||||
Generates Anki-compatible flashcard decks with maps showing individual
|
||||
Warsaw neighborhoods highlighted on a city map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
import genanki
|
||||
from geo_data import get_warsaw_osiedla
|
||||
import geopandas as gpd
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
# 50 unique colors for neighborhoods
|
||||
OSIEDLE_COLORS = [
|
||||
"#E74C3C",
|
||||
"#3498DB",
|
||||
"#2ECC71",
|
||||
"#9B59B6",
|
||||
"#F39C12",
|
||||
"#1ABC9C",
|
||||
"#E91E63",
|
||||
"#00BCD4",
|
||||
"#8BC34A",
|
||||
"#FF5722",
|
||||
"#673AB7",
|
||||
"#FFEB3B",
|
||||
"#795548",
|
||||
"#607D8B",
|
||||
"#CDDC39",
|
||||
"#FF9800",
|
||||
"#4CAF50",
|
||||
"#03A9F4",
|
||||
"#F44336",
|
||||
"#009688",
|
||||
"#3F51B5",
|
||||
"#FFC107",
|
||||
"#9E9E9E",
|
||||
"#00E676",
|
||||
"#FF4081",
|
||||
"#448AFF",
|
||||
"#69F0AE",
|
||||
"#FFD740",
|
||||
"#40C4FF",
|
||||
"#B388FF",
|
||||
"#EA80FC",
|
||||
"#82B1FF",
|
||||
"#A7FFEB",
|
||||
"#FFFF8D",
|
||||
"#FF80AB",
|
||||
"#536DFE",
|
||||
"#64FFDA",
|
||||
"#FFE57F",
|
||||
"#80D8FF",
|
||||
"#B9F6CA",
|
||||
"#CF6679",
|
||||
"#BB86FC",
|
||||
"#03DAC6",
|
||||
"#018786",
|
||||
"#6200EE",
|
||||
"#3700B3",
|
||||
"#B00020",
|
||||
"#FF0266",
|
||||
"#C51162",
|
||||
"#AA00FF",
|
||||
]
|
||||
|
||||
|
||||
def load_warsaw_boundary() -> gpd.GeoDataFrame:
|
||||
"""Load Warsaw boundary from districts GeoJSON."""
|
||||
districts_path = (
|
||||
Path(__file__).parent.parent / "warsaw_districts" / "warszawa-dzielnice.geojson"
|
||||
)
|
||||
if districts_path.exists():
|
||||
warsaw_gdf = gpd.read_file(districts_path)
|
||||
warsaw_boundary = warsaw_gdf[warsaw_gdf["name"] == "Warszawa"]
|
||||
if len(warsaw_boundary) == 0:
|
||||
warsaw_boundary = gpd.GeoDataFrame(
|
||||
geometry=[warsaw_gdf.union_all()], crs=warsaw_gdf.crs
|
||||
)
|
||||
return warsaw_boundary
|
||||
|
||||
msg = "Warsaw boundary data not found"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
|
||||
def create_osiedle_map(
|
||||
osiedle_name: str,
|
||||
osiedle_gdf: gpd.GeoDataFrame,
|
||||
warsaw_boundary: gpd.GeoDataFrame,
|
||||
all_osiedla: gpd.GeoDataFrame,
|
||||
) -> Figure:
|
||||
"""Create a map showing Warsaw with one osiedle highlighted."""
|
||||
fig, ax = plt.subplots(figsize=(10, 10))
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.patch.set_alpha(0)
|
||||
ax.patch.set_alpha(0)
|
||||
|
||||
# Plot Warsaw as a plain gray shape
|
||||
warsaw_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
|
||||
warsaw_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=2)
|
||||
|
||||
# Assign color based on sorted names
|
||||
sorted_names = sorted(all_osiedla["name"].tolist())
|
||||
color_idx = sorted_names.index(osiedle_name) % len(OSIEDLE_COLORS)
|
||||
fill_color = OSIEDLE_COLORS[color_idx]
|
||||
|
||||
# Plot the highlighted osiedle
|
||||
osiedle_gdf.plot(ax=ax, color=fill_color, alpha=0.9)
|
||||
osiedle_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=4)
|
||||
|
||||
# Set bounds to Warsaw
|
||||
bounds = warsaw_boundary.total_bounds
|
||||
ax.set_xlim(bounds[0], bounds[2])
|
||||
ax.set_ylim(bounds[1], bounds[3])
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_osiedle_image_bytes(
|
||||
osiedle_name: str,
|
||||
osiedle_gdf: gpd.GeoDataFrame,
|
||||
warsaw_boundary: gpd.GeoDataFrame,
|
||||
all_osiedla: gpd.GeoDataFrame,
|
||||
) -> bytes:
|
||||
"""Generate an osiedle map image as bytes."""
|
||||
fig = create_osiedle_map(osiedle_name, osiedle_gdf, warsaw_boundary, all_osiedla)
|
||||
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
return buf.read()
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
osiedla: gpd.GeoDataFrame,
|
||||
warsaw_boundary: gpd.GeoDataFrame,
|
||||
deck_name: str = "Warsaw Osiedla",
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package for Warsaw osiedla."""
|
||||
model_id_hash = hashlib.md5(f"warsaw_osiedla_{deck_name}".encode()) # noqa: S324
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.answer-text {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.card.night_mode .answer-text {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Warsaw Osiedle Model",
|
||||
fields=[
|
||||
{"name": "OsiedleMap"},
|
||||
{"name": "OsiedleName"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": '<div class="map-container">{{OsiedleMap}}</div>',
|
||||
"afmt": '<div class="map-container">{{OsiedleMap}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer-text">{{OsiedleName}}</div>',
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
media_files = []
|
||||
|
||||
for _, row in osiedla.iterrows():
|
||||
osiedle_name = row["name"]
|
||||
osiedle_gdf = gpd.GeoDataFrame([row], crs=osiedla.crs)
|
||||
|
||||
image_data = generate_osiedle_image_bytes(
|
||||
osiedle_name, osiedle_gdf, warsaw_boundary, osiedla
|
||||
)
|
||||
filename = f"osiedle_{osiedle_name.replace(' ', '_').replace('/', '_')}.png"
|
||||
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[f'<img src="{filename}">', osiedle_name],
|
||||
tags=["geography", "warsaw", "osiedla"],
|
||||
)
|
||||
my_deck.add_note(note)
|
||||
|
||||
temp_path = Path(f"/tmp/{filename}") # noqa: S108
|
||||
temp_path.write_bytes(image_data)
|
||||
media_files.append(str(temp_path))
|
||||
|
||||
package = genanki.Package(my_deck)
|
||||
package.media_files = media_files
|
||||
return package
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Warsaw osiedla.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: warsaw_osiedla.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Warsaw Osiedla",
|
||||
help="Name for the Anki deck",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
"-p",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Export preview images to specified directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview-count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of preview images to export (default: 5)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
output_path = Path(args.output) if args.output else Path("warsaw_osiedla.apkg")
|
||||
|
||||
try:
|
||||
sys.stdout.write("Loading osiedla data...\n")
|
||||
osiedla = get_warsaw_osiedla()
|
||||
warsaw_boundary = load_warsaw_boundary()
|
||||
num_osiedla = len(osiedla)
|
||||
|
||||
sys.stdout.write(f"Generating flashcards for {num_osiedla} osiedla...\n")
|
||||
|
||||
package = generate_anki_package(osiedla, warsaw_boundary, args.deck_name)
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
# Export preview images if requested
|
||||
if args.preview:
|
||||
preview_dir = Path(args.preview)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_osiedla = list(osiedla.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_osiedla)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_osiedla:
|
||||
osiedle_name = row["name"]
|
||||
osiedle_gdf = gpd.GeoDataFrame([row], crs=osiedla.crs)
|
||||
image_data = generate_osiedle_image_bytes(
|
||||
osiedle_name, osiedle_gdf, warsaw_boundary, osiedla
|
||||
)
|
||||
safe_name = osiedle_name.replace(" ", "_").replace("/", "_")
|
||||
preview_path = preview_dir / f"{safe_name}.png"
|
||||
preview_path.write_bytes(image_data)
|
||||
sys.stdout.write(f" Saved: {preview_path.name}\n")
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write(f"Osiedla: {num_osiedla}\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
if args.preview:
|
||||
sys.stdout.write(f"Preview images: {args.preview}\n")
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
37
python_pkg/anki_decks/warsaw_streets/README.md
Normal file
37
python_pkg/anki_decks/warsaw_streets/README.md
Normal file
@ -0,0 +1,37 @@
|
||||
# Warsaw Streets Anki Generator
|
||||
|
||||
Generate Anki flashcards for learning major Warsaw streets.
|
||||
|
||||
## Features
|
||||
|
||||
- Generates flashcards for major Warsaw streets (primary, secondary, tertiary roads)
|
||||
- Uses real street data from OpenStreetMap
|
||||
- Front of card: Map showing Warsaw with the street highlighted
|
||||
- Back of card: Street name in Polish
|
||||
- Self-contained .apkg file with embedded images
|
||||
|
||||
## Data Source
|
||||
|
||||
Street data is fetched from OpenStreetMap via the Overpass API.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install matplotlib genanki geopandas requests shapely
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Generate flashcards (fetches data from OSM)
|
||||
./run.sh
|
||||
|
||||
# Or run directly
|
||||
python -m warsaw_streets_anki
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Only includes named streets tagged as primary, secondary, or tertiary highways
|
||||
- Streets are filtered to remove duplicates and very short segments
|
||||
- The first run will download data from Overpass API (may take a minute)
|
||||
1
python_pkg/anki_decks/warsaw_streets/__init__.py
Normal file
1
python_pkg/anki_decks/warsaw_streets/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Warsaw streets Anki flashcard generator."""
|
||||
34
python_pkg/anki_decks/warsaw_streets/run.sh
Executable file
34
python_pkg/anki_decks/warsaw_streets/run.sh
Executable file
@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# Script to generate Warsaw Streets Anki deck
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||
PREVIEW_DIR="$SCRIPT_DIR/preview_images"
|
||||
|
||||
echo "=== Warsaw Streets Anki Generator ==="
|
||||
echo
|
||||
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
echo "Activating virtual environment..."
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
echo "Installing dependencies..."
|
||||
pip install --quiet --upgrade pip
|
||||
pip install --quiet matplotlib genanki geopandas requests shapely
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Create preview images directory
|
||||
mkdir -p "$PREVIEW_DIR"
|
||||
|
||||
python -m warsaw_streets_anki --output warsaw_streets.apkg --preview "$PREVIEW_DIR" --preview-count 5
|
||||
|
||||
echo
|
||||
echo "Done! The Anki deck is at: $SCRIPT_DIR/warsaw_streets.apkg"
|
||||
echo "Preview images are in: $PREVIEW_DIR"
|
||||
356
python_pkg/anki_decks/warsaw_streets/warsaw_streets_anki.py
Executable file
356
python_pkg/anki_decks/warsaw_streets/warsaw_streets_anki.py
Executable file
@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Anki flashcard generator for Warsaw streets.
|
||||
|
||||
Generates Anki-compatible flashcard decks with maps showing individual
|
||||
Warsaw streets highlighted on a city map.
|
||||
|
||||
Usage:
|
||||
python -m python_pkg.anki_decks.warsaw_streets.warsaw_streets_anki
|
||||
|
||||
Output:
|
||||
Creates a self-contained .apkg file that can be directly imported into Anki.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
import genanki
|
||||
from geo_data import get_warsaw_streets
|
||||
import geopandas as gpd
|
||||
import matplotlib.pyplot as plt
|
||||
from shapely.geometry import MultiLineString
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
# Minimum street length in meters to include
|
||||
MIN_STREET_LENGTH = 500
|
||||
|
||||
|
||||
def get_unique_streets(
|
||||
gdf: gpd.GeoDataFrame,
|
||||
) -> list[tuple[str, gpd.GeoDataFrame, float]]:
|
||||
"""Group street segments by name and merge geometries.
|
||||
|
||||
Args:
|
||||
gdf: GeoDataFrame with street segments.
|
||||
|
||||
Returns:
|
||||
List of (name, GeoDataFrame, length_m) tuples, sorted by length (longest first).
|
||||
"""
|
||||
# Group by street name
|
||||
streets: dict[str, list[Any]] = {}
|
||||
for _, row in gdf.iterrows():
|
||||
name = row["name"]
|
||||
if name and name != "Unknown":
|
||||
if name not in streets:
|
||||
streets[name] = []
|
||||
streets[name].append(row.geometry)
|
||||
|
||||
# Merge geometries and calculate length
|
||||
result = []
|
||||
for name, geometries in streets.items():
|
||||
merged = geometries[0] if len(geometries) == 1 else MultiLineString(geometries)
|
||||
|
||||
# Create a GeoDataFrame for this street
|
||||
street_gdf = gpd.GeoDataFrame(
|
||||
[{"name": name, "geometry": merged}], crs="EPSG:4326"
|
||||
)
|
||||
|
||||
# Calculate length in meters (approximate)
|
||||
street_gdf_proj = street_gdf.to_crs("EPSG:2180") # Polish coordinate system
|
||||
length = street_gdf_proj.geometry.length.iloc[0]
|
||||
|
||||
if length >= MIN_STREET_LENGTH:
|
||||
result.append((name, street_gdf, length))
|
||||
|
||||
# Sort by length (longest first)
|
||||
result.sort(key=lambda x: x[2], reverse=True)
|
||||
return result
|
||||
|
||||
|
||||
def load_street_data() -> (
|
||||
tuple[list[tuple[str, gpd.GeoDataFrame, float]], gpd.GeoDataFrame]
|
||||
):
|
||||
"""Load Warsaw streets and boundary.
|
||||
|
||||
Returns:
|
||||
Tuple of (streets list sorted by length, warsaw boundary GeoDataFrame).
|
||||
"""
|
||||
streets_gdf = get_warsaw_streets(min_length=MIN_STREET_LENGTH)
|
||||
streets = get_unique_streets(streets_gdf)
|
||||
|
||||
# Load Warsaw districts for boundary (reuse from warsaw_districts)
|
||||
districts_path = (
|
||||
Path(__file__).parent.parent / "warsaw_districts" / "warszawa-dzielnice.geojson"
|
||||
)
|
||||
if districts_path.exists():
|
||||
warsaw_gdf = gpd.read_file(districts_path)
|
||||
# Get just Warsaw boundary
|
||||
warsaw_boundary = warsaw_gdf[warsaw_gdf["name"] == "Warszawa"]
|
||||
if len(warsaw_boundary) == 0:
|
||||
# Dissolve all districts
|
||||
warsaw_boundary = gpd.GeoDataFrame(
|
||||
geometry=[warsaw_gdf.union_all()], crs=warsaw_gdf.crs
|
||||
)
|
||||
else:
|
||||
msg = "Warsaw boundary data not found"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
return streets, warsaw_boundary
|
||||
|
||||
|
||||
# Color for highlighted street
|
||||
STREET_COLOR = "#E74C3C" # Red
|
||||
|
||||
|
||||
def create_street_map(
|
||||
street_gdf: gpd.GeoDataFrame,
|
||||
warsaw_boundary: gpd.GeoDataFrame,
|
||||
) -> Figure:
|
||||
"""Create a map showing Warsaw with one street highlighted.
|
||||
|
||||
Args:
|
||||
street_name: Name of the street.
|
||||
street_gdf: GeoDataFrame with the street geometry.
|
||||
warsaw_boundary: GeoDataFrame with Warsaw boundary.
|
||||
|
||||
Returns:
|
||||
A matplotlib Figure object.
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=(10, 10))
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.patch.set_alpha(0)
|
||||
ax.patch.set_alpha(0)
|
||||
|
||||
# Plot Warsaw as a plain gray shape
|
||||
warsaw_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
|
||||
warsaw_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=2)
|
||||
|
||||
# Plot the highlighted street
|
||||
street_gdf.plot(ax=ax, color=STREET_COLOR, linewidth=4, alpha=0.9)
|
||||
|
||||
# Set bounds to Warsaw
|
||||
bounds = warsaw_boundary.total_bounds
|
||||
ax.set_xlim(bounds[0], bounds[2])
|
||||
ax.set_ylim(bounds[1], bounds[3])
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_street_image_bytes(
|
||||
street_gdf: gpd.GeoDataFrame,
|
||||
warsaw_boundary: gpd.GeoDataFrame,
|
||||
) -> bytes:
|
||||
"""Generate a street map image as bytes.
|
||||
|
||||
Args:
|
||||
street_gdf: GeoDataFrame with the street geometry.
|
||||
warsaw_boundary: GeoDataFrame with Warsaw boundary.
|
||||
|
||||
Returns:
|
||||
PNG image data as bytes.
|
||||
"""
|
||||
fig = create_street_map(street_gdf, warsaw_boundary)
|
||||
|
||||
buf = BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
return buf.read()
|
||||
|
||||
|
||||
def generate_anki_package(
|
||||
streets: list[tuple[str, gpd.GeoDataFrame, float]],
|
||||
warsaw_boundary: gpd.GeoDataFrame,
|
||||
deck_name: str = "Warsaw Streets",
|
||||
) -> genanki.Package:
|
||||
"""Generate Anki package for Warsaw streets.
|
||||
|
||||
Args:
|
||||
streets: List of (name, GeoDataFrame, length) tuples, sorted by length.
|
||||
warsaw_boundary: GeoDataFrame with Warsaw boundary.
|
||||
deck_name: Name for the Anki deck.
|
||||
|
||||
Returns:
|
||||
genanki.Package object.
|
||||
"""
|
||||
model_id_hash = hashlib.md5(f"warsaw_streets_{deck_name}".encode()) # noqa: S324
|
||||
model_id = int(model_id_hash.hexdigest()[:8], 16)
|
||||
|
||||
card_css = """
|
||||
.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
.card.night_mode {
|
||||
color: #eee;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.answer-text {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
color: #2C3E50;
|
||||
}
|
||||
.card.night_mode .answer-text {
|
||||
color: #ECF0F1;
|
||||
}
|
||||
"""
|
||||
|
||||
my_model = genanki.Model(
|
||||
model_id,
|
||||
"Warsaw Street Model",
|
||||
fields=[
|
||||
{"name": "StreetMap"},
|
||||
{"name": "StreetName"},
|
||||
],
|
||||
templates=[
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": '<div class="map-container">{{StreetMap}}</div>',
|
||||
"afmt": '<div class="map-container">{{StreetMap}}</div>'
|
||||
'<hr id="answer">'
|
||||
'<div class="answer-text">{{StreetName}}</div>',
|
||||
},
|
||||
],
|
||||
css=card_css,
|
||||
)
|
||||
|
||||
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
|
||||
my_deck = genanki.Deck(deck_id, deck_name)
|
||||
media_files = []
|
||||
|
||||
# Streets are already sorted by length (longest first)
|
||||
for street_name, street_gdf, _length in streets:
|
||||
image_data = generate_street_image_bytes(street_gdf, warsaw_boundary)
|
||||
filename = f"street_{street_name.replace(' ', '_').replace('/', '_')}.png"
|
||||
|
||||
note = genanki.Note(
|
||||
model=my_model,
|
||||
fields=[f'<img src="{filename}">', street_name],
|
||||
tags=["geography", "warsaw", "streets"],
|
||||
)
|
||||
my_deck.add_note(note)
|
||||
|
||||
temp_path = Path(f"/tmp/{filename}") # noqa: S108
|
||||
temp_path.write_bytes(image_data)
|
||||
media_files.append(str(temp_path))
|
||||
|
||||
package = genanki.Package(my_deck)
|
||||
package.media_files = media_files
|
||||
return package
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Anki flashcards for Warsaw streets.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output file path (default: warsaw_streets.apkg)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deck-name",
|
||||
"-d",
|
||||
type=str,
|
||||
default="Warsaw Streets",
|
||||
help="Name for the Anki deck",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-length",
|
||||
"-m",
|
||||
type=int,
|
||||
default=MIN_STREET_LENGTH,
|
||||
help=f"Minimum street length in meters (default: {MIN_STREET_LENGTH})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview",
|
||||
"-p",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Export preview images to specified directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview-count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of preview images to export (default: 5)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
output_path = Path(args.output) if args.output else Path("warsaw_streets.apkg")
|
||||
|
||||
try:
|
||||
sys.stdout.write("Loading street data...\n")
|
||||
streets, warsaw_boundary = load_street_data()
|
||||
num_streets = len(streets)
|
||||
|
||||
sys.stdout.write(f"Generating flashcards for {num_streets} Warsaw streets...\n")
|
||||
|
||||
package = generate_anki_package(streets, warsaw_boundary, args.deck_name)
|
||||
package.write_to_file(str(output_path))
|
||||
|
||||
# Export preview images if requested (top N longest streets)
|
||||
if args.preview:
|
||||
preview_dir = Path(args.preview)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_streets = streets[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_streets)} preview images "
|
||||
f"(longest streets) to {preview_dir}...\n"
|
||||
)
|
||||
for street_name, street_gdf, length_m in preview_streets:
|
||||
image_data = generate_street_image_bytes(street_gdf, warsaw_boundary)
|
||||
safe_name = street_name.replace(" ", "_").replace("/", "_")
|
||||
preview_path = preview_dir / f"{safe_name}.png"
|
||||
preview_path.write_bytes(image_data)
|
||||
sys.stdout.write(f" Saved: {preview_path.name} ({length_m:.0f}m)\n")
|
||||
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
|
||||
sys.stdout.write("=" * 60 + "\n")
|
||||
sys.stdout.write(f"Streets: {num_streets}\n")
|
||||
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
|
||||
if args.preview:
|
||||
sys.stdout.write(f"Preview images: {args.preview}\n")
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in New Issue
Block a user