cone-distance / src /depth /view_detections.py
Aryan Sethi
Score checkpoints end to end and keep the results
a1e201c
Raw History Blame Contribute Delete
7.96 kB
"""Contact sheet of detections with predicted distance against ground truth.
The numeric evaluation says the median error is a few metres. This says what
that looks like on a frame, which is the only way to notice that the misses are
all one scene, or all one class, or all boxes clipped at the image edge.
Each detection is drawn with its class, the predicted distance, and -- where a
ground-truth box overlaps it -- the true distance and the error. Boxes are
coloured by how far off the estimate is, so a frame can be read at a glance.
python -m src.depth.view_detections --weights trained/yolo11n/weights/best.pt
"""
from __future__ import annotations
import argparse
from pathlib import Path
import numpy as np
import yaml
from PIL import Image, ImageDraw, ImageFont
from src.common import calib, paths, schema
from src.depth import estimate as depth_estimate
from src.depth.evaluate import iou
# Green when the estimate is close, amber when it is loose, red when it is wrong.
# Relative, not absolute, because 3 m at 50 m is fine and 3 m at 5 m is not.
BANDS = [(0.10, (60, 220, 90)), (0.25, (245, 190, 60)), (1e9, (240, 70, 70))]
NO_TRUTH = (150, 150, 160)
def colour_for(relative_error: float | None):
if relative_error is None:
return NO_TRUTH
for limit, colour in BANDS:
if relative_error <= limit:
return colour
return BANDS[-1][1]
def load_font(size: int):
for candidate in ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"):
if Path(candidate).exists():
return ImageFont.truetype(candidate, size)
return ImageFont.load_default()
def render(image_path: str, detections: list[dict], root: Path,
cell_width: int) -> Image.Image | None:
full = paths.resolve_image(root, image_path)
try:
image = Image.open(full).convert("RGB")
except OSError:
return None
scale = cell_width / image.width
image = image.resize((cell_width, max(1, round(image.height * scale))))
draw = ImageDraw.Draw(image)
font = load_font(max(11, cell_width // 48))
for detection in detections:
colour = colour_for(detection["relative_error"])
box = [detection["x1"] * scale, detection["y1"] * scale,
detection["x2"] * scale, detection["y2"] * scale]
draw.rectangle(box, outline=colour, width=2)
label = f"{detection['class']} {detection['predicted_m']:.1f}m"
if detection["truth_m"] is not None:
label += f" (gt {detection['truth_m']:.1f}, {detection['error_m']:+.1f})"
position = (box[0] + 2, max(0.0, box[1] - font.size - 3))
draw.rectangle(draw.textbbox(position, label, font=font), fill=colour)
draw.text(position, label, fill=(0, 0, 0), font=font)
matched = [d for d in detections if d["truth_m"] is not None]
caption = (f"{Path(image_path).name[:38]} {len(detections)} det, "
f"{len(matched)} matched")
draw.rectangle(draw.textbbox((4, 4), caption, font=font), fill=(0, 0, 0))
draw.text((4, 4), caption, fill=(255, 255, 255), font=font)
return image
def tile(cells, columns: int, padding: int = 6) -> Image.Image:
width = max(c.width for c in cells)
height = max(c.height for c in cells)
rows = (len(cells) + columns - 1) // columns
sheet = Image.new("RGB", (columns * width + (columns + 1) * padding,
rows * height + (rows + 1) * padding), (22, 22, 26))
for index, cell in enumerate(cells):
column, row = index % columns, index // columns
sheet.paste(cell, (padding + column * (width + padding),
padding + row * (height + padding)))
return sheet
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--weights", required=True)
parser.add_argument("--unified-root", type=Path, default=None)
parser.add_argument("--dataset", default="nuscenes")
parser.add_argument("--split", default="val")
parser.add_argument("--frames", type=int, default=6)
parser.add_argument("--conf", type=float, default=0.25)
parser.add_argument("--min-iou", type=float, default=0.5)
parser.add_argument("--device", default="cpu")
parser.add_argument("--cell-width", type=int, default=760)
parser.add_argument("--cols", type=int, default=2)
parser.add_argument("--out", type=Path, default=Path("qa/detections_depth.png"))
parser.add_argument("--only-matched", action="store_true",
help="only draw detections that overlap a ground-truth box")
args = parser.parse_args()
from ultralytics import YOLO
root = paths.unified_root(args.unified_root)
manifest = schema.objects_only(schema.read_manifest(root / "manifest.parquet"))
manifest = manifest[(manifest["source"] == args.dataset)
& (manifest["split"] == args.split)
& manifest["gt_distance_m"].notna()]
priors = yaml.safe_load((root / "class_priors.yaml").read_text())
calibrations = calib.load_all(root)
model = YOLO(args.weights)
# One frame per scene, richest first within each, then scenes in turn. A
# sheet drawn from whichever frames have the most objects comes entirely
# from one location, which is exactly the wrong sample when the error is
# known to vary by scene.
counts = manifest.groupby(["scene_id", "image_path"]).size().reset_index(name="n")
counts = counts.sort_values("n", ascending=False)
order = counts.drop_duplicates("scene_id")["image_path"].tolist()
order += [p for p in counts["image_path"] if p not in set(order)]
cells = []
for image_path in order:
if len(cells) >= args.frames:
break
truth = manifest[manifest["image_path"] == image_path]
calibration = calibrations.get(truth.iloc[0]["sensor_id"])
if calibration is None:
continue
result = model.predict(str(paths.resolve_image(root, image_path)),
conf=args.conf, device=args.device, verbose=False)[0]
if not len(result.boxes):
continue
boxes = result.boxes.xyxy.cpu().numpy()
classes = result.boxes.cls.cpu().numpy().astype(int)
truth_boxes = truth[["x1", "y1", "x2", "y2"]].to_numpy()
detections = []
for box, class_index in zip(boxes, classes):
name = model.names[int(class_index)]
estimate = depth_estimate.estimate(tuple(box), name, calibration, priors)
scores = iou(box, truth_boxes) if len(truth_boxes) else np.zeros(0)
best = int(np.argmax(scores)) if len(scores) else -1
matched = best >= 0 and scores[best] >= args.min_iou
if args.only_matched and not matched:
continue
truth_m = float(truth.iloc[best]["gt_distance_m"]) if matched else None
error = (estimate.distance_m - truth_m) if matched else None
detections.append({
"class": name, "x1": box[0], "y1": box[1], "x2": box[2], "y2": box[3],
"predicted_m": estimate.distance_m, "truth_m": truth_m,
"error_m": error,
"relative_error": abs(error) / truth_m if matched and truth_m else None,
})
cell = render(image_path, detections, root, args.cell_width)
if cell is not None:
cells.append(cell)
if not cells:
raise SystemExit("no frames with detections")
args.out.parent.mkdir(parents=True, exist_ok=True)
tile(cells, args.cols).save(args.out)
print(f"wrote {args.out} ({len(cells)} frames)")
print(" green <=10% error, amber <=25%, red above, grey no ground truth")
if __name__ == "__main__":
main()