Spaces:
Runtime error
Runtime error
| """ | |
| tutor/visual.py | |
| =============== | |
| PIL-based visual renderer for counting and comparison tasks. | |
| Generates child-friendly images that accompany curriculum items. | |
| No heavy ML model required โ all rendering is done with Pillow (CPU, instant). | |
| Usage | |
| ----- | |
| from tutor.visual import render_item_image | |
| img_path = render_item_image("goats_5") # returns path to saved PNG | |
| img_path = render_item_image("compare_4_7") | |
| img_path = render_item_image("beads_2_plus_3") | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import re | |
| import math | |
| from pathlib import Path | |
| from typing import Optional | |
| from PIL import Image, ImageDraw, ImageFont | |
| # โโ Output directory โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| _ROOT = Path(__file__).parent.parent | |
| ASSETS_DIR = _ROOT / "assets" | |
| ASSETS_DIR.mkdir(exist_ok=True) | |
| # โโ Visual config โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| IMG_W, IMG_H = 480, 320 | |
| BG_COLOR = "#FFF9F0" # warm off-white (child-friendly) | |
| ACCENT = "#FF8C42" # orange | |
| BLUE = "#4A90D9" | |
| GREEN = "#5CB85C" | |
| RED = "#D9534F" | |
| PURPLE = "#9B59B6" | |
| # Emoji-like object representations (text characters rendered via PIL) | |
| OBJECT_EMOJI: dict[str, str] = { | |
| "apples": "๐", | |
| "goats": "๐", | |
| "stars": "โญ", | |
| "birds": "๐ฆ", | |
| "fish": "๐", | |
| "bananas": "๐", | |
| "mangoes": "๐ฅญ", | |
| "stones": "๐ชจ", | |
| "beads": "โ", | |
| "blocks": "โ ", | |
| "kids": "๐ฆ", | |
| "cookies": "๐ช", | |
| "tomatoes": "๐ ", | |
| "drums": "๐ฅ", | |
| "beans": "๐ซ", | |
| } | |
| # Fallback plain character when emoji rendering is unavailable | |
| FALLBACK_CHAR = "โ" | |
| # Try to find a font that supports emoji; fall back to default | |
| def _get_font(size: int) -> ImageFont.ImageFont: | |
| candidates = [ | |
| "seguiemj.ttf", # Windows Segoe UI Emoji | |
| "NotoColorEmoji.ttf", # Linux | |
| "Apple Color Emoji.ttc", | |
| ] | |
| for name in candidates: | |
| try: | |
| return ImageFont.truetype(name, size) | |
| except (IOError, OSError): | |
| pass | |
| try: | |
| return ImageFont.load_default(size=size) | |
| except TypeError: | |
| return ImageFont.load_default() | |
| def _get_plain_font(size: int) -> ImageFont.ImageFont: | |
| candidates = [ | |
| "arialbd.ttf", "Arial Bold.ttf", "DejaVuSans-Bold.ttf", | |
| "LiberationSans-Bold.ttf", "FreeSansBold.ttf", | |
| ] | |
| for name in candidates: | |
| try: | |
| return ImageFont.truetype(name, size) | |
| except (IOError, OSError): | |
| pass | |
| try: | |
| return ImageFont.load_default(size=size) | |
| except TypeError: | |
| return ImageFont.load_default() | |
| # โโ Core renderer helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def _new_canvas() -> tuple[Image.Image, ImageDraw.ImageDraw]: | |
| img = Image.new("RGB", (IMG_W, IMG_H), BG_COLOR) | |
| draw = ImageDraw.Draw(img) | |
| return img, draw | |
| def _draw_title(draw: ImageDraw.ImageDraw, text: str, color: str = "#333333") -> None: | |
| font = _get_plain_font(22) | |
| draw.text((IMG_W // 2, 18), text, fill=color, font=font, anchor="mt") | |
| def _draw_objects(draw: ImageDraw.ImageDraw, count: int, | |
| symbol: str, color: str = ACCENT) -> None: | |
| """ | |
| Draw `count` symbols arranged in a grid in the centre of the canvas. | |
| Falls back to colored circles if symbol rendering looks broken. | |
| """ | |
| cols = min(count, 5) | |
| rows = math.ceil(count / cols) | |
| cell_w = min(64, (IMG_W - 60) // cols) | |
| cell_h = min(64, (IMG_H - 100) // max(rows, 1)) | |
| font = _get_plain_font(int(cell_w * 0.7)) | |
| total_w = cols * cell_w | |
| total_h = rows * cell_h | |
| x0 = (IMG_W - total_w) // 2 | |
| y0 = 60 + (IMG_H - 100 - total_h) // 2 | |
| idx = 0 | |
| for r in range(rows): | |
| for c in range(cols): | |
| if idx >= count: | |
| break | |
| cx = x0 + c * cell_w + cell_w // 2 | |
| cy = y0 + r * cell_h + cell_h // 2 | |
| r_px = cell_w // 3 | |
| draw.ellipse( | |
| [cx - r_px, cy - r_px, cx + r_px, cy + r_px], | |
| fill=color, outline="#ffffff", width=2 | |
| ) | |
| idx += 1 | |
| def _draw_number(draw: ImageDraw.ImageDraw, number: int, | |
| x: int, y: int, color: str = "#333333", | |
| size: int = 72) -> None: | |
| font = _get_plain_font(size) | |
| draw.text((x, y), str(number), fill=color, font=font, anchor="mm") | |
| # โโ Specific renderers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def _render_counting(count: int, obj_name: str) -> Image.Image: | |
| """Counting image: N colored circles with label.""" | |
| img, draw = _new_canvas() | |
| label = f"Count the {obj_name}!" | |
| _draw_title(draw, label) | |
| _draw_objects(draw, count, obj_name, color=ACCENT) | |
| # Count label bottom-right (hidden from child โ for debug builds only) | |
| return img | |
| def _render_compare(a: int, b: int) -> Image.Image: | |
| """Comparison image: two large numbers side by side with VS divider.""" | |
| img, draw = _new_canvas() | |
| _draw_title(draw, "Which is bigger?") | |
| mid = IMG_W // 2 | |
| # Left number | |
| draw.rectangle([30, 80, mid - 20, IMG_H - 40], fill=BLUE, outline="#ffffff", width=3) | |
| _draw_number(draw, a, mid // 2, IMG_H // 2 + 10, color="#ffffff", size=80) | |
| # Right number | |
| draw.rectangle([mid + 20, 80, IMG_W - 30, IMG_H - 40], fill=GREEN, outline="#ffffff", width=3) | |
| _draw_number(draw, b, mid + (IMG_W - mid) // 2, IMG_H // 2 + 10, color="#ffffff", size=80) | |
| # VS divider | |
| font = _get_plain_font(28) | |
| draw.text((mid, IMG_H // 2 + 10), "VS", fill="#888888", font=font, anchor="mm") | |
| return img | |
| def _render_addition(a: int, b: int) -> Image.Image: | |
| """Addition image: two groups of dots with + sign.""" | |
| img, draw = _new_canvas() | |
| _draw_title(draw, f"{a} + {b} = ?") | |
| mid = IMG_W // 2 | |
| # Left group | |
| cols_a = min(a, 4) | |
| rows_a = math.ceil(a / cols_a) if a > 0 else 1 | |
| cell = 44 | |
| x0 = 30 | |
| y0 = 80 | |
| idx = 0 | |
| for r in range(rows_a): | |
| for c in range(cols_a): | |
| if idx >= a: | |
| break | |
| cx = x0 + c * cell + cell // 2 | |
| cy = y0 + r * cell + cell // 2 | |
| draw.ellipse([cx-16, cy-16, cx+16, cy+16], fill=BLUE, outline="#fff", width=2) | |
| idx += 1 | |
| # Plus sign | |
| font = _get_plain_font(48) | |
| draw.text((mid, IMG_H // 2), "+", fill=ACCENT, font=font, anchor="mm") | |
| # Right group | |
| cols_b = min(b, 4) | |
| rows_b = math.ceil(b / cols_b) if b > 0 else 1 | |
| x0r = mid + 30 | |
| idx = 0 | |
| for r in range(rows_b): | |
| for c in range(cols_b): | |
| if idx >= b: | |
| break | |
| cx = x0r + c * cell + cell // 2 | |
| cy = y0 + r * cell + cell // 2 | |
| draw.ellipse([cx-16, cy-16, cx+16, cy+16], fill=GREEN, outline="#fff", width=2) | |
| idx += 1 | |
| return img | |
| def _render_subtraction(a: int, b: int) -> Image.Image: | |
| """Subtraction image: `a` dots, `b` crossed out in red.""" | |
| img, draw = _new_canvas() | |
| _draw_title(draw, f"{a} โ {b} = ?") | |
| cols = min(a, 6) | |
| rows = math.ceil(a / cols) if a > 0 else 1 | |
| cell = 44 | |
| x0 = (IMG_W - cols * cell) // 2 | |
| y0 = 80 | |
| for i in range(a): | |
| r = i // cols | |
| c = i % cols | |
| cx = x0 + c * cell + cell // 2 | |
| cy = y0 + r * cell + cell // 2 | |
| color = RED if i >= (a - b) else BLUE | |
| draw.ellipse([cx-16, cy-16, cx+16, cy+16], fill=color, outline="#fff", width=2) | |
| if i >= (a - b): | |
| # Cross out | |
| draw.line([cx-14, cy-14, cx+14, cy+14], fill="#ffffff", width=3) | |
| draw.line([cx+14, cy-14, cx-14, cy+14], fill="#ffffff", width=3) | |
| return img | |
| def _render_number_line(lo: int, hi: int) -> Image.Image: | |
| """Number line with lo and hi visible, middle blank (?).""" | |
| img, draw = _new_canvas() | |
| _draw_title(draw, "What number comes between?") | |
| mid_x = IMG_W // 2 | |
| mid_y = IMG_H // 2 + 20 | |
| gap = 120 | |
| # Draw line | |
| draw.line([mid_x - gap - 50, mid_y, mid_x + gap + 50, mid_y], | |
| fill="#888888", width=3) | |
| # Tick marks and numbers | |
| font = _get_plain_font(36) | |
| for val, xpos in [(lo, mid_x - gap), (hi, mid_x + gap)]: | |
| draw.line([xpos, mid_y - 15, xpos, mid_y + 15], fill="#333", width=3) | |
| draw.text((xpos, mid_y + 30), str(val), fill="#333333", font=font, anchor="mt") | |
| # Middle tick with ? | |
| draw.line([mid_x, mid_y - 15, mid_x, mid_y + 15], fill=ACCENT, width=3) | |
| draw.text((mid_x, mid_y + 30), "?", fill=ACCENT, font=font, anchor="mt") | |
| return img | |
| def _render_word_problem(label: str) -> Image.Image: | |
| """Generic word-problem card with a friendly icon.""" | |
| img, draw = _new_canvas() | |
| _draw_title(draw, "Word Problem", color=PURPLE) | |
| # Draw a simple "thinking" icon โ 3 dots | |
| for i, x in enumerate([IMG_W//2 - 40, IMG_W//2, IMG_W//2 + 40]): | |
| draw.ellipse([x-18, IMG_H//2-18, x+18, IMG_H//2+18], | |
| fill=PURPLE, outline="#fff", width=2) | |
| font = _get_plain_font(16) | |
| draw.text((IMG_W//2, IMG_H - 40), "Read the question above", | |
| fill="#888888", font=font, anchor="mm") | |
| return img | |
| # โโ Public API โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def render_item_image(visual_key: str, force: bool = False) -> str: | |
| """ | |
| Render a curriculum item image from its `visual` field key. | |
| Parameters | |
| ---------- | |
| visual_key : str | |
| The `visual` field from a CurriculumItem, e.g. "goats_5", | |
| "compare_4_7", "beads_2_plus_3", "blocks_8_minus_3". | |
| force : bool | |
| Re-render even if the file already exists on disk. | |
| Returns | |
| ------- | |
| str | |
| Absolute path to the saved PNG file. | |
| """ | |
| out_path = ASSETS_DIR / f"{visual_key}.png" | |
| if out_path.exists() and not force: | |
| return str(out_path) | |
| img = _dispatch(visual_key) | |
| img.save(str(out_path)) | |
| return str(out_path) | |
| def _dispatch(visual_key: str) -> Image.Image: | |
| """Parse visual_key and call the appropriate renderer.""" | |
| # compare_A_B โ comparison | |
| m = re.fullmatch(r"compare_(\d+)_(\d+)", visual_key) | |
| if m: | |
| return _render_compare(int(m.group(1)), int(m.group(2))) | |
| # number_line_A_B โ number line | |
| m = re.fullmatch(r"number_line_(\d+)_(\d+)", visual_key) | |
| if m: | |
| return _render_number_line(int(m.group(1)), int(m.group(2))) | |
| # beads_A_plus_B โ addition dots | |
| m = re.fullmatch(r"\w+_(\d+)_plus_(\d+)", visual_key) | |
| if m: | |
| return _render_addition(int(m.group(1)), int(m.group(2))) | |
| # blocks_A_minus_B or drums_A_minus_B โ subtraction dots | |
| m = re.fullmatch(r"\w+_(\d+)_minus_(\d+)", visual_key) | |
| if m: | |
| return _render_subtraction(int(m.group(1)), int(m.group(2))) | |
| # obj_N โ counting (e.g. "goats_5", "apples_3") | |
| m = re.fullmatch(r"([a-z]+)_(\d+)", visual_key) | |
| if m: | |
| return _render_counting(int(m.group(2)), m.group(1)) | |
| # Fallback: word problem card | |
| return _render_word_problem(visual_key) | |
| def prerender_all(loader) -> dict[str, str]: | |
| """ | |
| Pre-render images for every curriculum item that has a visual key. | |
| Returns a dict mapping visual_key โ file path. | |
| """ | |
| paths: dict[str, str] = {} | |
| items = loader.all_items() | |
| rendered = skipped = 0 | |
| for item in items: | |
| if not item.visual: | |
| continue | |
| path = render_item_image(item.visual) | |
| paths[item.visual] = path | |
| if Path(path).stat().st_size > 0: | |
| rendered += 1 | |
| else: | |
| skipped += 1 | |
| print(f"โ Pre-rendered {rendered} images โ assets/ (skipped {skipped})") | |
| return paths | |
| # โโ Smoke-test โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| if __name__ == "__main__": | |
| test_keys = [ | |
| "goats_5", | |
| "apples_3", | |
| "compare_4_7", | |
| "beads_2_plus_3", | |
| "blocks_8_minus_3", | |
| "number_line_47_49", | |
| "kids_3_cookies_9", | |
| ] | |
| print("Rendering test images โฆ") | |
| for key in test_keys: | |
| path = render_item_image(key, force=True) | |
| size = os.path.getsize(path) | |
| print(f" {key:<28} โ {path} ({size} bytes)") | |
| print("\nDone. Check the assets/ folder.") | |