ktt-math-tutor / tutor /visual.py
johneze's picture
feat: KTT Edge-AI Child Math Tutor โ€” AIMS Hackathon S2.T3.1
1433553 verified
"""
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.")