"""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''
f'{e["name"]} {e["pct"]}%' for e in legend)
return f'
{chips}
'
def _step(n, title, desc, sqft, img, legend="") -> str:
return (f'{n}'
f'
{title}
{sqft}'
f'{desc}
{legend}')
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'Measured lawn: {result.lawn_sqft:,.0f} sqft · '
f'recipe: {recipe.name} · method: {result.method} ({result.confidence})
')
body = f'Pipeline audit
{result.address}
{final}' + "".join(steps)
return f'Pipeline audit — {result.address}{body}'
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"{lbl} | " for _, lbl in cols)
rows = ""
for i, e in enumerate(ranked):
s = e["summary"]
cells = "".join(f"{'' if s.get(k) is None else s.get(k)} | " for k, _ in cols)
rows += f'| {e["name"]} | {cells}
'
body = (f'Recipe leaderboard
'
f'Ranked by MAE against the gold set — lowest error at top.
'
f'')
return f'Recipe leaderboard{body}'