File size: 4,598 Bytes
7a3a384
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
"""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))