cone-distance / src /depth /view_gallery.py
Aryan Sethi
Claude Opus 5 (1M context)
Quantise the ONNX export, and record how the model performs
35206ab
Raw History Blame Contribute Delete
8.09 kB
"""Crops of individual detections, grouped by confidence, with their distances.
`view_detections.py` draws whole frames, which shows context. This shows the
objects themselves, side by side, which is what makes a pattern visible: a wall
of confident detections that are all close and unoccluded next to a wall of
uncertain ones that are all small, clipped or half behind a car.
Reads the CSV that `accuracy.py` already wrote, so it needs no model and no
inference -- it is re-reading a scored run, not producing a new one.
python -m src.depth.accuracy --weights best.pt --tag best
python -m src.depth.view_gallery --tag best
Each crop is bordered by how far off the estimate was, relative to the true
distance, and captioned with confidence, the estimate, the truth and the error.
"""
from __future__ import annotations
import argparse
from pathlib import Path
import numpy as np
import pandas as pd
from PIL import Image, ImageDraw, ImageFont
from src.common import paths
BANDS = [(0.05, 0.25), (0.25, 0.50), (0.50, 0.75), (0.75, 1.01)]
# Relative, not absolute: 3 m out at 50 m is fine, 3 m out at 5 m is not.
COLOURS = [(0.10, (70, 200, 100)), (0.25, (240, 185, 60)), (1e9, (235, 75, 75))]
CELL = 190
PAD = 6
def colour_for(percent_off: float):
for limit, colour in COLOURS:
if percent_off / 100 <= limit:
return colour
return COLOURS[-1][1]
def font(size: int):
for path in ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"):
if Path(path).exists():
return ImageFont.truetype(path, size)
return ImageFont.load_default()
def crop(row, root: Path, context: float) -> Image.Image | None:
"""One detection, cropped with surrounding context, letterboxed to CELL."""
try:
image = Image.open(paths.resolve_image(root, row.image_path)).convert("RGB")
except OSError:
return None
width, height = row.x2 - row.x1, row.y2 - row.y1
# Context scales with the box, so a distant cone is not a single pixel and a
# near barrier does not fill the frame with road.
margin = max(width, height) * context
box = (max(0, row.x1 - margin), max(0, row.y1 - margin),
min(image.width, row.x2 + margin), min(image.height, row.y2 + margin))
patch = image.crop(tuple(int(v) for v in box))
if patch.width < 2 or patch.height < 2:
return None
scale = min(CELL / patch.width, CELL / patch.height)
patch = patch.resize((max(1, int(patch.width * scale)),
max(1, int(patch.height * scale))))
cell = Image.new("RGB", (CELL, CELL + 30), (28, 28, 32))
cell.paste(patch, ((CELL - patch.width) // 2, (CELL - patch.height) // 2))
draw = ImageDraw.Draw(cell)
# The detection box, in crop coordinates.
draw.rectangle([(row.x1 - box[0]) * scale + (CELL - patch.width) // 2,
(row.y1 - box[1]) * scale + (CELL - patch.height) // 2,
(row.x2 - box[0]) * scale + (CELL - patch.width) // 2,
(row.y2 - box[1]) * scale + (CELL - patch.height) // 2],
outline=colour_for(row.percent_off), width=2)
small = font(12)
draw.text((5, CELL + 2), f"{row.predicted_class} conf {row.confidence:.2f}",
fill=(225, 225, 230), font=small)
draw.text((5, CELL + 15),
f"ours {row.predicted_m:.1f} true {row.truth_m:.1f} "
f"{row.error_m:+.1f}m", fill=colour_for(row.percent_off), font=small)
return cell
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--tag", required=True)
parser.add_argument("--results-dir", type=Path,
default=Path("results/depth_accuracy"))
parser.add_argument("--unified-root", type=Path, default=None)
parser.add_argument("--per-band", type=int, default=10)
parser.add_argument("--context", type=float, default=0.6,
help="padding around each box, as a fraction of its size")
parser.add_argument("--out", type=Path, default=None)
parser.add_argument("--worst", action="store_true",
help="show the largest errors instead of a confidence "
"sweep -- the view that says what is going wrong")
parser.add_argument("--unique", action="store_true",
help="one crop per ground-truth object. Several "
"detections of the same cone otherwise fill a row "
"with the same error repeated.")
args = parser.parse_args()
root = paths.unified_root(args.unified_root)
csv = args.results_dir / f"{args.tag}_detections.csv"
frame = pd.read_csv(csv)
frame = frame[frame["correct"] & frame["truth_m"].notna()]
print(f"{len(frame)} correct detections in {csv}")
if args.unique:
# A ground-truth object is identified by its frame and its true
# distance; repeated detections of it share both.
before = len(frame)
frame = (frame.sort_values("confidence", ascending=False)
.drop_duplicates(["image_path", "truth_m"]))
print(f" {before} detections cover {len(frame)} distinct objects")
if args.worst:
worst = frame.sort_values("percent_off", ascending=False)
per_row = args.per_band
rows, labels = [], []
for start in range(0, min(len(worst), per_row * 3), per_row):
chunk = worst.iloc[start:start + per_row]
cells = [c for c in (crop(r, root, args.context)
for r in chunk.itertuples()) if c is not None]
if cells:
rows.append(cells)
labels.append(f"worst {start + 1}-{start + len(chunk)} by relative "
f"error {chunk.percent_off.min():.0f}-"
f"{chunk.percent_off.max():.0f}% off")
draw_sheet(rows, labels, args.out or Path(f"qa/gallery_{args.tag}_worst.png"))
return
rows, labels = [], []
for low, high in BANDS:
band = frame[(frame.confidence >= low) & (frame.confidence < high)]
band = band.sort_values("confidence", ascending=False)
if band.empty:
continue
# Evenly spaced through the band, so the row spans it rather than
# showing ten near-identical detections from its top edge.
step = max(1, len(band) // args.per_band)
picked = band.iloc[::step][:args.per_band]
cells = [c for c in (crop(r, root, args.context)
for r in picked.itertuples()) if c is not None]
if cells:
rows.append(cells)
labels.append(f"confidence {low:.2f}-{min(high, 1.0):.2f} "
f"n={len(band)} median error "
f"{band.error_m.abs().median():.1f} m")
draw_sheet(rows, labels, args.out or Path(f"qa/gallery_{args.tag}.png"))
def draw_sheet(rows, labels, out: Path) -> None:
if not rows:
raise SystemExit("nothing to draw")
columns = max(len(r) for r in rows)
header = 26
sheet = Image.new("RGB",
(PAD + columns * (CELL + PAD),
sum(header + CELL + 30 + PAD for _ in rows) + PAD),
(18, 18, 22))
draw = ImageDraw.Draw(sheet)
title = font(14)
y = PAD
for cells, label in zip(rows, labels):
draw.text((PAD, y + 5), label, fill=(235, 235, 240), font=title)
y += header
for index, cell in enumerate(cells):
sheet.paste(cell, (PAD + index * (CELL + PAD), y))
y += CELL + 30 + PAD
out.parent.mkdir(parents=True, exist_ok=True)
sheet.save(out)
print(f"wrote {out} ({sum(len(r) for r in rows)} crops)")
print(" border: green within 10% of truth, amber within 25%, red beyond")
if __name__ == "__main__":
main()