Download src/depth/view_detections.py from Aryan006/cone-distance: direct link, hf CLI and curl.
- Browser
- Download file 7.96 kB
-
https://huggingface.co/Aryan006/cone-distance/resolve/main/src/depth/view_detections.py
- Command line
-
hf download hf://Aryan006/cone-distance/src/depth/view_detections.py
-
curl -L -o view_detections.py https://huggingface.co/Aryan006/cone-distance/resolve/main/src/depth/view_detections.py
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() | |