| """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) |
| |
| 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 |
|
|