TempuraML's picture
fix(tuning): label save-crash + export fusion panel + roadmap SAM3-CPU item
0dd653c
Raw
History Blame Contribute Delete
10.5 kB
"""Static, self-contained HTML export β€” the shareable artifact.
Two pages, both built from the same `tuning.render` panels the Gradio app uses:
- `audit_html(result, recipe, ...)` step-by-step audit of ONE run (like the old
scripts/audit_pipeline.py, but for any recipe), optionally with the gold IoU.
- `leaderboard_html(entries)` a recipe-vs-recipe metrics table to pick a winner.
All images are inlined as data-URI JPEGs, so a single .html file is the deliverable.
"""
from __future__ import annotations
from lawn_estimator.segmentation import (
CASCADE_PRIMARY_MODEL_ID,
LAWN_MODEL_ID,
_predict_classes,
)
from tuning import render as R
_STYLE = """
:root{--bg:#0f130d;--card:#181d14;--ink:#e6ebe0;--soft:#9fb090;--line:#2c3626;--acc:#5db85d}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.55 "Avenir Next",system-ui,sans-serif}
main{max-width:960px;margin:0 auto;padding:24px 16px 64px}
h1{font-size:clamp(22px,4vw,30px);margin:.2em 0}
.lead{color:var(--soft);margin:0 0 8px}
.final{background:var(--card);border:1px solid var(--line);border-left:4px solid var(--acc);
border-radius:10px;padding:12px 16px;margin:14px 0 6px;font-size:18px}
.final b{color:var(--acc);font-size:22px}
.step{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;margin:18px 0}
.hd{display:flex;align-items:center;gap:10px}
.hd .n{background:var(--acc);color:#06210a;font-weight:700;width:28px;height:28px;border-radius:50%;
display:flex;align-items:center;justify-content:center;flex:none}
.hd h2{font-size:17px;margin:0;flex:1}.hd .sq{color:var(--acc);font-weight:600;font-variant-numeric:tabular-nums}
.dsc{color:var(--soft);font-size:14px;margin:8px 2px 10px}
.step img{width:100%;border-radius:8px;display:block}
.legend{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
.chip{display:flex;align-items:center;gap:6px;background:#0f130d;border:1px solid var(--line);
border-radius:7px;padding:4px 9px;font-size:12.5px}
.chip .sw{width:13px;height:13px;border-radius:3px;border:1px solid #0006}
.tblwrap{overflow-x:auto;margin:18px 0}
table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}
th,td{border:1px solid var(--line);padding:9px 11px;text-align:right}
thead th{background:#20281a;color:var(--soft)}th.l,td.l{text-align:left}
tr.best td{background:#1c2a17}
"""
def _legend_html(legend) -> str:
if not legend:
return ""
chips = "".join(
f'<span class="chip"><span class="sw" style="background:{e["color"]}"></span>'
f'{e["name"]} <b>{e["pct"]}%</b></span>' for e in legend)
return f'<div class="legend">{chips}</div>'
def _step(n, title, desc, sqft, img, legend="") -> str:
return (f'<section class="step"><div class="hd"><span class="n">{n}</span>'
f'<h2>{title}</h2><span class="sq">{sqft}</span></div>'
f'<p class="dsc">{desc}</p><img src="{img}" alt="{title}">{legend}</section>')
def _segmentation_steps(cap, recipe, start_n) -> list[str]:
"""Per-class panels appropriate to the recipe's segmenter (or its fusion base)."""
img = cap["image"]
steps, n = [], start_n
# For fusion, show the LEAF-ON base segmenter's read (that's the lawn source).
eff = recipe.fusion_base_segmenter if recipe.segmenter == "leafon_leafoff_fusion" else recipe.segmenter
if eff == "eomt-cascade":
panel, leg = R.class_panel(img, _predict_classes(CASCADE_PRIMARY_MODEL_ID, img), R.ADE_NAMES)
steps.append(_step(n, "Segmentation β€” EoMT-DINOv3 (cascade primary)",
"Primary read, real ADE20K class names.", "", R.b64(panel), _legend_html(leg)))
n += 1
panel, leg = R.class_panel(img, _predict_classes(LAWN_MODEL_ID, img), R.M2F_NAMES)
steps.append(_step(n, "Segmentation β€” mask2former (arbiter)",
"Arbitrates the cascade's sidewalk/earth classes.", "", R.b64(panel), _legend_html(leg)))
n += 1
elif eff in ("mask2former", "eomt-solo", "trained"):
from tuning.harness import _segmenter_model_id
model_id = _segmenter_model_id(recipe) if eff == recipe.segmenter else (
"tue-mps/eomt-dinov3-ade-semantic-large-512" if eff == "eomt-solo" else LAWN_MODEL_ID)
names = R.ADE_NAMES if "eomt" in model_id or "ade" in model_id else R.M2F_NAMES
panel, leg = R.class_panel(img, _predict_classes(model_id, img), names)
steps.append(_step(n, f"Segmentation β€” {model_id}",
"Per-class prediction for the chosen model.", "", R.b64(panel), _legend_html(leg)))
n += 1
# color / sam3 have no per-class map to show here.
return steps
def audit_html(result, recipe, gold_mask=None, gold_meta=None) -> str:
"""Step-by-step audit page for one recipe run."""
cap = result.capture
img = cap["image"]
steps = []
steps.append(_step(1, "Analysis imagery",
f"{recipe.imagery} tile, {img.size[0]}Γ—{img.size[1]} px β€” every measurement runs on this.",
"", R.b64(img)))
steps.append(_step(2, "Legal parcel",
f"County parcel boundary (yellow). {cap['parcel_area_sqft']:,.0f} sqft legal lot.",
"", R.b64(R.draw_outlines(img, cap["legal_outlines"], (255, 220, 0)))))
ext = "; ".join(f"{e.get('street') or '?'} +{e['area_sqft']:,.0f}" for e in cap["extensions"])
geo_img = R.draw_outlines(R.draw_outlines(img, cap["legal_outlines"], (255, 220, 0)),
cap["estimation_outlines"], (0, 220, 220))
steps.append(_step(3, "Estimation geometry (to the curb)",
f"Parcel extended to street-facing curbs (cyan). {cap['estimation_area_sqft']:,.0f} sqft. "
f"Extensions: {ext or 'none'}.", "", R.b64(geo_img)))
steps.append(_step(4, "Color-threshold vegetation (baseline)",
"Pure HSV/green rule β€” the sanity baseline, not the estimate.",
f"{cap['rgb_veg_sqft']:,.0f} sqft",
R.b64(R.overlay(img, cap["veg_in_parcel"], (60, 220, 60)))))
n = 5
seg_steps = _segmentation_steps(cap, recipe, n)
steps.extend(seg_steps)
n += len(seg_steps)
if cap.get("leaf_off_image") is not None: # leaf-on/leaf-off fusion
loff = cap["leaf_off_image"]
steps.append(_step(n, "Leaf-off (DOGIS) β€” same frame",
"The pixel-registered leaf-off ortho: bare branches expose the ground under canopy.",
"", R.b64(loff)))
n += 1
steps.append(_step(n, f"Carved hardscape ({recipe.fusion_hardscape_detector})",
"Hardscape found on the leaf-off tile under the canopy (red) β€” SUBTRACTED from lawn. "
"This is the hidden driveway/patio the canopy flag only warns about.",
"", R.b64(R.overlay(loff, cap["fusion_carve"], (235, 60, 60)))))
n += 1
steps.append(_step(n, "Lawn-area mask (candidate lawn)",
"Segmenter decision (+ any reclaim) β€” the pixels eligible as lawn.",
"", R.b64(R.overlay(img, cap["lawn_area_in_parcel"], (60, 220, 60)))))
n += 1
if cap.get("not_lawn_mask") is not None:
steps.append(_step(n, f"Restrict β€” {recipe.restrict} not-lawn mask",
"The roof/driveway/sidewalk (red) this restrict tool removes from the "
"LiDAR count. Compare SAM1 vs SAM3 here β€” same lot, different recipe.",
"", R.b64(R.overlay(img, cap["not_lawn_mask"], (235, 60, 60)))))
n += 1
if cap["est"].viz.get("ground_px") is not None:
steps.append(_step(n, "LiDAR ground points β†’ classified",
"Ground returns colored: green = counted as lawn, red = removed (hardscape/roof, "
"incl. restrict). sqft = green / all-ground Γ— ground-sampled area.",
f"{result.lawn_sqft:,.0f} sqft", R.b64(R.lidar_panel(img, cap["est"].viz))))
n += 1
if gold_mask is not None:
scored = ""
from tuning.metrics import mask_scores
sc = mask_scores(cap["lawn_area_in_parcel"], gold_mask)
true_sqft = (gold_meta or {}).get("mask_sqft")
if true_sqft:
err = (result.lawn_sqft - true_sqft) / true_sqft * 100
scored = f" Β· true {true_sqft:,.0f} sqft, error {err:+.1f}%"
steps.append(_step(n, "vs. gold (hand-drawn mowable area)",
f"Green = agree, red = recipe over-counts, blue = recipe misses. "
f"IoU {sc['iou']}, precision {sc['precision']}, recall {sc['recall']}{scored}.",
"", R.b64(R.iou_overlay(img, cap["lawn_area_in_parcel"], gold_mask))))
final = (f'<div class="final">Measured lawn: <b>{result.lawn_sqft:,.0f} sqft</b> Β· '
f'recipe: {recipe.name} Β· method: {result.method} ({result.confidence})</div>')
body = f'<h1>Pipeline audit</h1><p class="lead">{result.address}</p>{final}' + "".join(steps)
return f'<title>Pipeline audit β€” {result.address}</title><style>{_STYLE}</style><main>{body}</main>'
def leaderboard_html(entries: list[dict]) -> str:
"""`entries` = [{name, summary(dict from metrics.aggregate)}], best MAE highlighted."""
ranked = sorted([e for e in entries if e["summary"].get("n")],
key=lambda e: e["summary"]["mae"])
cols = [("mae", "MAE sqft"), ("mape_pct", "MAPE %"), ("median_ape_pct", "median APE %"),
("bias_pct", "bias %"), ("rmse", "RMSE"), ("r2", "RΒ²"), ("mean_iou", "mean IoU"), ("n", "lots")]
head = "".join(f"<th>{lbl}</th>" for _, lbl in cols)
rows = ""
for i, e in enumerate(ranked):
s = e["summary"]
cells = "".join(f"<td>{'' if s.get(k) is None else s.get(k)}</td>" for k, _ in cols)
rows += f'<tr class="{"best" if i == 0 else ""}"><td class="l">{e["name"]}</td>{cells}</tr>'
body = (f'<h1>Recipe leaderboard</h1>'
f'<p class="lead">Ranked by MAE against the gold set β€” lowest error at top.</p>'
f'<div class="tblwrap"><table><thead><tr><th class="l">recipe</th>{head}</tr></thead>'
f'<tbody>{rows}</tbody></table></div>')
return f'<title>Recipe leaderboard</title><style>{_STYLE}</style><main>{body}</main>'