cone-distance / src /data /check_ground_plane.py
Aryan Sethi
Claude Opus 5 (1M context)
Fix two cross-dataset convention bugs found by scoring against ground truth
b9ff8f9
Raw History Blame Contribute Delete
5 kB
"""Recover distance from the box bottom row and score it against the truth.
This is the quantitative form of "do box bottoms sit at the ground contact
point". Looking at a contact sheet catches a box that is obviously wrong; it
does not catch a box that is twenty pixels low, which is a systematic depth bias
worth several metres at range.
It is also, deliberately, phase 2's ground-plane estimator run early: a pixel is
back-projected to a ray and intersected with the ground plane from
calib/<sensor_id>.yaml, using nothing but focal length, principal point, camera
height and the ground normal. Running it against gt_distance_m today says
whether the calibration handoff actually works before anything is built on it.
Stop signs are excluded by default: they are pole-mounted, so the bottom of the
box is not a ground contact and the estimator does not apply to them.
Usage:
python -m src.data.check_ground_plane
python -m src.data.check_ground_plane --min-visibility 0.7 --min-horizon-margin 20
"""
from __future__ import annotations
import argparse
from pathlib import Path
import numpy as np
import pandas as pd
from src.common import calib, paths, schema
BUCKETS = [(0, 10), (10, 20), (20, 30), (30, 45), (45, 1000)]
def horizon_row(calibration: calib.Calibration) -> float:
"""The image row whose back-projected ray runs parallel to the ground.
Distance estimates diverge as a box bottom approaches this row, so it is the
natural place to put a validity margin.
"""
normal = calibration.ground_normal_cam()
# Walk down the centre column until the ray turns parallel to the ground.
rows = np.linspace(0.0, float(calibration.height), 4001)
columns = np.full_like(rows, calibration.cx)
alignment = normal @ calibration.ray(columns, rows)
crossings = np.flatnonzero(np.sign(alignment[:-1]) != np.sign(alignment[1:]))
return float(rows[crossings[0]]) if len(crossings) else float(calibration.cy)
def estimate(calibration: calib.Calibration, u: np.ndarray, v: np.ndarray) -> np.ndarray:
"""Ray-plane intersection. Returns forward depth, matching gt_distance_m."""
normal = calibration.ground_normal_cam()
rays = calibration.ray(u, v)
scale = -calibration.cam_height_m / (normal @ rays)
return scale * rays[2]
def report(frame: pd.DataFrame, calibration: calib.Calibration,
min_visibility: float, min_margin: float) -> None:
objects = frame[frame["gt_distance_m"].notna() & (frame["truncation"] < 0.01)]
objects = objects[objects["class"].isin(schema.GROUND_CONTACT_CLASSES)]
if objects.empty:
print(" no ground-contact objects with distance")
return
u = ((objects["x1"] + objects["x2"]) / 2).to_numpy()
v = objects["y2"].to_numpy()
truth = objects["gt_distance_m"].to_numpy()
predicted = estimate(calibration, u, v)
margin = v - horizon_row(calibration)
visibility = objects["visibility"].to_numpy()
usable = (
np.isfinite(predicted) & (predicted > 0) & (margin >= min_margin)
& (np.isnan(visibility) | (visibility >= min_visibility))
)
print(f" horizon row {horizon_row(calibration):.1f} px, "
f"{usable.sum()}/{len(objects)} objects pass the validity gate")
print(f" {'range':>10} {'n':>5} {'m/px':>6} {'MAE':>8} {'bias':>8}")
for low, high in BUCKETS:
in_bucket = usable & (truth >= low) & (truth < high)
if in_bucket.sum() < 5:
continue
error = predicted[in_bucket] - truth[in_bucket]
sensitivity = np.median(truth[in_bucket] ** 2
/ (calibration.fy * calibration.cam_height_m))
label = f"{low}-{high} m" if high < 1000 else f"{low}+ m"
print(f" {label:>10} {in_bucket.sum():5d} {sensitivity:6.2f} "
f"{np.abs(error).mean():7.2f}m {np.median(error):+7.2f}m")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--unified-root", type=Path, default=None)
parser.add_argument("--min-visibility", type=float, default=0.0)
parser.add_argument("--min-horizon-margin", type=float, default=0.0,
help="pixels a box bottom must sit below the horizon row")
args = parser.parse_args()
root = paths.unified_root(args.unified_root)
manifest = schema.objects_only(schema.read_manifest(root / "manifest.parquet"))
calibrations = calib.load_all(root)
for sensor_id, calibration in sorted(calibrations.items()):
rows = manifest[manifest["sensor_id"] == sensor_id]
print(f"\n{sensor_id} (cam_height {calibration.cam_height_m:.3f} m, "
f"fy {calibration.fy:.0f})")
if rows.empty:
print(" no objects")
continue
report(rows, calibration, args.min_visibility, args.min_horizon_margin)
if __name__ == "__main__":
main()