Spaces:
Running
Running
| """Shared step-visual renderers — the single source of truth for how a pipeline stage | |
| is drawn, used by both the Gradio app and the static HTML export. | |
| Lifted from `scripts/audit_pipeline.py` (b64/overlay/draw_outlines/class_panel/ | |
| lidar_panel) so the interactive and shareable views render identically, plus an | |
| `iou_overlay` for scoring a recipe mask against a hand-drawn gold mask. | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import io | |
| import numpy as np | |
| from PIL import Image, ImageDraw | |
| # ADE20K names for the EoMT cascade primary (outdoor-relevant subset). | |
| ADE_NAMES = {0: "wall", 1: "building", 2: "sky", 4: "tree", 6: "road", 9: "grass", | |
| 11: "sidewalk", 13: "earth", 17: "plant", 21: "water", 20: "car", 25: "?", | |
| 29: "field", 46: "sand", 52: "path", 94: "land"} | |
| # Empirical meanings for the incumbent mask2former's generic LABEL_0..7. | |
| M2F_NAMES = {0: "background", 1: "open (grass+dirt)", 2: "street-edge band", 3: "pavement", | |
| 4: "canopy", 6: '"water" (fires on flat turf)', 7: "roofs (as cropland)"} | |
| PALETTE = [(80, 200, 60), (0, 110, 40), (220, 60, 60), (150, 110, 70), (235, 220, 120), | |
| (170, 120, 40), (60, 130, 235), (120, 120, 130), (200, 60, 200), (230, 130, 30), | |
| (90, 200, 200), (200, 120, 200), (255, 180, 40)] | |
| def b64(img: Image.Image, max_w: int = 900, quality: int = 82) -> str: | |
| """A data-URI JPEG, downscaled to `max_w` — embeds directly in shareable HTML.""" | |
| if img.width > max_w: | |
| img = img.resize((max_w, round(img.height * max_w / img.width)), Image.LANCZOS) | |
| buf = io.BytesIO() | |
| img.convert("RGB").save(buf, "JPEG", quality=quality) | |
| return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode() | |
| def overlay(base: Image.Image, mask: np.ndarray, color, alpha: float = 0.55) -> Image.Image: | |
| arr = np.asarray(base.convert("RGB")).astype(np.float32) | |
| arr[mask] = arr[mask] * (1 - alpha) + np.array(color, np.float32) * alpha | |
| return Image.fromarray(arr.astype(np.uint8)) | |
| def draw_outlines(base: Image.Image, outlines, color, width: int = 4) -> Image.Image: | |
| """`outlines` is a list of (xs, ys) coord-list pairs (polygon_pixel_outlines).""" | |
| img = base.convert("RGB").copy() | |
| d = ImageDraw.Draw(img) | |
| for xs, ys in outlines: | |
| pts = [(float(x), float(y)) for x, y in zip(xs, ys, strict=False)] | |
| if len(pts) > 1: | |
| d.line(pts + [pts[0]], fill=color, width=width) | |
| return img | |
| def class_panel(base: Image.Image, pred: np.ndarray, names: dict, | |
| min_frac: float = 0.003) -> tuple[Image.Image, list]: | |
| """Per-class colored overlay + legend chips (name, %, color) for present classes.""" | |
| arr = np.asarray(base.convert("RGB")).astype(np.float32) * 0.45 | |
| legend = [] | |
| codes = [c for c in np.unique(pred) if (pred == c).sum() / pred.size >= min_frac] | |
| for i, c in enumerate(sorted(codes, key=lambda c: -(pred == c).sum())): | |
| col = PALETTE[i % len(PALETTE)] | |
| arr[pred == c] += np.array(col, np.float32) * 0.55 | |
| legend.append({"name": names.get(int(c), f"class {c}"), | |
| "color": "#{:02x}{:02x}{:02x}".format(*col), | |
| "pct": round(float((pred == c).sum() / pred.size * 100), 1)}) | |
| return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8)), legend | |
| def lidar_panel(base: Image.Image, viz: dict) -> Image.Image: | |
| """Ground points colored: green = counted lawn, red = removed (hardscape/roof).""" | |
| img = base.convert("RGB").copy() | |
| gpx, gpy = viz.get("ground_px"), viz.get("ground_py") | |
| if gpx is None: | |
| return img | |
| d = ImageDraw.Draw(img) | |
| is_lawn = viz.get("lawn_mask") | |
| for k in range(len(gpx)): | |
| x, y = float(gpx[k]), float(gpy[k]) | |
| col = (60, 220, 60) if (is_lawn is not None and is_lawn[k]) else (235, 60, 60) | |
| d.ellipse([x - 2, y - 2, x + 2, y + 2], fill=col) | |
| return img | |
| def iou_overlay(base: Image.Image, recipe_mask: np.ndarray, gold_mask: np.ndarray, | |
| alpha: float = 0.5) -> Image.Image: | |
| """True-positive (green) / false-positive (red, recipe-only) / false-negative | |
| (blue, gold-only) overlay — a visual read on where a recipe misses the truth.""" | |
| arr = np.asarray(base.convert("RGB")).astype(np.float32) | |
| tp = recipe_mask & gold_mask | |
| fp = recipe_mask & ~gold_mask | |
| fn = ~recipe_mask & gold_mask | |
| for mask, color in ((tp, (60, 220, 60)), (fp, (235, 60, 60)), (fn, (70, 130, 235))): | |
| arr[mask] = arr[mask] * (1 - alpha) + np.array(color, np.float32) * alpha | |
| return Image.fromarray(arr.astype(np.uint8)) | |