"""Shared rendering for the demo: overlays and the looping sequence animation. Used by app.py (live runs) and scripts/precompute.py (shipped payloads). Color semantics everywhere: orange = the tube is judged smoke, gray = the tube is rejected by the classifier. """ from pathlib import Path from PIL import Image, ImageDraw, ImageFont SMOKE = "#ea580c" REJECTED = "#94a3b8" INK = "#1e293b" ANIM_WIDTH = 960 ANIM_FRAME_MS = 400 def font(size: int): try: return ImageFont.truetype("DejaVuSans-Bold.ttf", size) except OSError: return ImageFont.load_default() def tube_is_smoke(tube: dict, threshold: float) -> bool: prob = tube["probability"] return prob is not None and prob >= threshold def tube_color(tube: dict, threshold: float) -> str: return SMOKE if tube_is_smoke(tube, threshold) else REJECTED def overlay_frame(payload: dict, paths: list[Path], idx: int, width: int | None = None) -> Image.Image: """Frame idx with each kept tube's fixed crop window, colored by verdict.""" img = Image.open(paths[idx]).convert("RGB") if width and img.width > width: img = img.resize((width, round(img.height * width / img.width)), Image.LANCZOS) draw = ImageDraw.Draw(img) w, h = img.size fnt = font(16) threshold = payload["details"]["decision"]["threshold"] for tube in payload["details"]["tubes"]["kept"]: if not tube["stabilized_window"]: continue cx, cy, bw, bh = tube["stabilized_window"] x0, y0 = (cx - bw / 2) * w, (cy - bh / 2) * h x1, y1 = (cx + bw / 2) * w, (cy + bh / 2) * h color = tube_color(tube, threshold) draw.rectangle([x0, y0, x1, y1], outline=color, width=3) # tube_id is 0-based internally; display 1-based for readability draw.text((x0 + 3, max(0, y0 - 20)), f"tube {tube['tube_id'] + 1}", fill=color, font=fnt) draw.text((10, 8), f"frame {idx + 1}", fill="white", font=font(22)) return img def save_animation(payload: dict, paths: list[Path], out_path: Path) -> Path: """Looping animated WebP of the whole sequence with overlays burned in.""" frames = [overlay_frame(payload, paths, i, width=ANIM_WIDTH) for i in range(len(paths))] frames[0].save( out_path, format="WEBP", save_all=True, append_images=frames[1:], duration=ANIM_FRAME_MS, loop=0, quality=78, ) return out_path