feat(perception): add mixed-route vegetation review
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
"""Seal RAVNOVES004TREE mixed-route review into the existing vegetation LAB."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from k1link.laboratory.vegetation_shadow_lab import (
|
||||
LAB_SCHEMA,
|
||||
RESULT_PREFIX,
|
||||
VegetationShadowLabError,
|
||||
canonical_json,
|
||||
sha256_path,
|
||||
)
|
||||
|
||||
REVIEW_SCHEMA = "missioncore.mixed-route-review-pack/v1"
|
||||
DDRNET_SCHEMA = "missioncore.mixed-route-ddrnet-islands/v1"
|
||||
TGS_SCHEMA = "missioncore.mixed-route-tgs-result/v1"
|
||||
FRAME_COUNT = 10
|
||||
PHASES = (
|
||||
"rural",
|
||||
"rural",
|
||||
"rural",
|
||||
"rural",
|
||||
"rural",
|
||||
"transition",
|
||||
"urban",
|
||||
"urban",
|
||||
"urban",
|
||||
"urban",
|
||||
)
|
||||
TGS_COLORS = {
|
||||
0: (5, 7, 9),
|
||||
1: (132, 188, 86),
|
||||
2: (235, 112, 122),
|
||||
3: (150, 154, 163),
|
||||
}
|
||||
|
||||
|
||||
def _read_json(path: Path, label: str) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise VegetationShadowLabError(f"{label} is invalid") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise VegetationShadowLabError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _artifact(
|
||||
source: Path,
|
||||
staging: Path,
|
||||
relative: str,
|
||||
artifacts: list[dict[str, object]],
|
||||
*,
|
||||
role: str,
|
||||
media_type: str,
|
||||
) -> dict[str, object]:
|
||||
if source.is_symlink() or not source.is_file():
|
||||
raise VegetationShadowLabError(f"mixed-route artifact is unavailable: {relative}")
|
||||
target = staging / relative
|
||||
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, target)
|
||||
descriptor = {
|
||||
"role": role,
|
||||
"path": relative,
|
||||
"byte_length": target.stat().st_size,
|
||||
"sha256": sha256_path(target),
|
||||
"media_type": media_type,
|
||||
}
|
||||
artifacts.append(descriptor)
|
||||
return descriptor
|
||||
|
||||
|
||||
def _image_proof(descriptor: dict[str, object]) -> dict[str, object]:
|
||||
return {"path": descriptor["path"], "sha256": descriptor["sha256"]}
|
||||
|
||||
|
||||
def _render_tgs_costmaps(tgs_root: Path, destination: Path) -> list[Path]:
|
||||
result = _read_json(tgs_root / "result.json", "mixed-route TGS result")
|
||||
evidence = result.get("evidence")
|
||||
costmap = result.get("costmap")
|
||||
if (
|
||||
result.get("schema_version") != TGS_SCHEMA
|
||||
or result.get("status") != "passed-review-only"
|
||||
or not isinstance(evidence, dict)
|
||||
or not isinstance(costmap, dict)
|
||||
or result.get("summary", {}).get("frame_count") != FRAME_COUNT
|
||||
or result.get("authority", {}).get("actuation_allowed") is not False
|
||||
):
|
||||
raise VegetationShadowLabError("mixed-route TGS contract changed")
|
||||
evidence_path = tgs_root / str(evidence.get("path"))
|
||||
if (
|
||||
not evidence_path.is_file()
|
||||
or evidence.get("bytes") != evidence_path.stat().st_size
|
||||
or evidence.get("sha256") != sha256_path(evidence_path)
|
||||
):
|
||||
raise VegetationShadowLabError("mixed-route TGS evidence changed")
|
||||
with np.load(evidence_path, allow_pickle=False) as archive:
|
||||
centers = archive["costmap_cell_centers_xy_m"]
|
||||
states = archive["causal_rolling_1s_costmap_states"]
|
||||
if centers.shape != (2244, 2) or states.shape != (FRAME_COUNT, 2244):
|
||||
raise VegetationShadowLabError("mixed-route TGS costmap shape changed")
|
||||
radius = float(costmap["radius_m"])
|
||||
cell_size = float(costmap["cell_size_m"])
|
||||
size = 600
|
||||
scale = size / (radius * 2.0)
|
||||
outputs: list[Path] = []
|
||||
destination.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
for slot in range(FRAME_COUNT):
|
||||
image = Image.new("RGB", (size, size), TGS_COLORS[0])
|
||||
draw = ImageDraw.Draw(image)
|
||||
half = cell_size * scale / 2.0
|
||||
for center, state in zip(centers, states[slot], strict=True):
|
||||
x = (float(center[0]) + radius) * scale
|
||||
y = (radius - float(center[1])) * scale
|
||||
draw.rectangle((x - half, y - half, x + half, y + half), fill=TGS_COLORS[int(state)])
|
||||
rover_w = 0.8 * scale
|
||||
rover_l = 1.0 * scale
|
||||
cx = size / 2.0
|
||||
cy = size / 2.0
|
||||
draw.rectangle(
|
||||
(cx - rover_w / 2, cy - rover_l / 2, cx + rover_w / 2, cy + rover_l / 2),
|
||||
outline=(255, 255, 255),
|
||||
width=3,
|
||||
)
|
||||
path = destination / f"frame-{slot + 1:06d}.png"
|
||||
image.save(path, format="PNG", optimize=True)
|
||||
outputs.append(path)
|
||||
return outputs
|
||||
|
||||
|
||||
def seal_mixed_route_vegetation_review(
|
||||
*,
|
||||
base_lab_root: Path,
|
||||
review_pack_root: Path,
|
||||
eomt_root: Path,
|
||||
ddrnet_root: Path,
|
||||
tgs_root: Path,
|
||||
output_root: Path,
|
||||
) -> Path:
|
||||
base_root = base_lab_root.resolve(strict=True)
|
||||
base = _read_json(base_root / "result.json", "base vegetation LAB")
|
||||
base_identity = base.get("identity")
|
||||
if (
|
||||
base.get("schema_version") != LAB_SCHEMA
|
||||
or not isinstance(base_identity, dict)
|
||||
or hashlib.sha256(canonical_json(base_identity)).hexdigest()
|
||||
!= base.get("identity_sha256")
|
||||
or base.get("result_id") != base_root.name
|
||||
or not base_root.name.startswith(RESULT_PREFIX)
|
||||
or base.get("authority", {}).get("commands_enabled") is not False
|
||||
):
|
||||
raise VegetationShadowLabError("base vegetation LAB proof changed")
|
||||
|
||||
pack_root = review_pack_root.resolve(strict=True)
|
||||
pack = _read_json(pack_root / "manifest.json", "mixed-route review pack")
|
||||
timeline_path = pack_root / str(pack.get("timeline", {}).get("path"))
|
||||
if (
|
||||
pack.get("schema_version") != REVIEW_SCHEMA
|
||||
or pack.get("frame_count") != FRAME_COUNT
|
||||
or pack.get("identity", {}).get("session_id") != "20260828T130511Z_viewer_live"
|
||||
or pack.get("identity", {}).get("ground_truth") is not False
|
||||
or not timeline_path.is_file()
|
||||
or pack.get("timeline", {}).get("sha256") != sha256_path(timeline_path)
|
||||
):
|
||||
raise VegetationShadowLabError("mixed-route review pack changed")
|
||||
timeline = [json.loads(line) for line in timeline_path.read_text(encoding="utf-8").splitlines()]
|
||||
if len(timeline) != FRAME_COUNT:
|
||||
raise VegetationShadowLabError("mixed-route timeline is incomplete")
|
||||
|
||||
eomt = _read_json(eomt_root / "run-report.partial.json", "mixed-route EoMT result")
|
||||
ddrnet = _read_json(ddrnet_root / "result.json", "mixed-route DDRNet result")
|
||||
tgs = _read_json(tgs_root / "result.json", "mixed-route TGS result")
|
||||
if (
|
||||
eomt.get("input", {}).get("frames_admitted") != FRAME_COUNT
|
||||
or eomt.get("metrics", {}).get("frames_processed") != FRAME_COUNT
|
||||
or eomt.get("ground_truth") is not False
|
||||
or ddrnet.get("schema_version") != DDRNET_SCHEMA
|
||||
or ddrnet.get("source", {}).get("pack_id") != pack["pack_id"]
|
||||
or len(ddrnet.get("frames", [])) != FRAME_COUNT
|
||||
or ddrnet.get("authority", {}).get("candidate_accepted") is not False
|
||||
or tgs.get("schema_version") != TGS_SCHEMA
|
||||
or tgs.get("source", {}).get("review_pack_id") != pack["pack_id"]
|
||||
or tgs.get("summary", {}).get("frame_count") != FRAME_COUNT
|
||||
):
|
||||
raise VegetationShadowLabError("mixed-route model identities differ")
|
||||
|
||||
output_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
temporary = Path(tempfile.mkdtemp(prefix=".mixed-route-vegetation-", dir=output_root))
|
||||
artifacts: list[dict[str, object]] = []
|
||||
try:
|
||||
tgs_images = _render_tgs_costmaps(tgs_root, temporary / ".tgs-render")
|
||||
cases: list[dict[str, object]] = []
|
||||
tgs_anchors = {
|
||||
int(row["slot"]): row
|
||||
for row in tgs["anchors"]
|
||||
if row.get("profile_id") == "causal_rolling_1s"
|
||||
}
|
||||
for slot, row in enumerate(timeline):
|
||||
case_id = f"route-{slot + 1:02d}"
|
||||
relative_root = f"route-review/{case_id}"
|
||||
source_descriptor = _artifact(
|
||||
pack_root / "frames" / f"frame-{slot + 1:06d}.png",
|
||||
temporary,
|
||||
f"{relative_root}/source.png",
|
||||
artifacts,
|
||||
role="mixed-route-source-frame",
|
||||
media_type="image/png",
|
||||
)
|
||||
city_descriptor = _artifact(
|
||||
eomt_root / "overlay-frames" / f"frame-{slot + 1:06d}.png",
|
||||
temporary,
|
||||
f"{relative_root}/city.png",
|
||||
artifacts,
|
||||
role="mixed-route-eomt-overlay",
|
||||
media_type="image/png",
|
||||
)
|
||||
vegetation_descriptor = _artifact(
|
||||
ddrnet_root / "overlay-frames" / f"frame-{slot + 1:06d}.png",
|
||||
temporary,
|
||||
f"{relative_root}/vegetation.png",
|
||||
artifacts,
|
||||
role="mixed-route-ddrnet-overlay",
|
||||
media_type="image/png",
|
||||
)
|
||||
tgs_descriptor = _artifact(
|
||||
tgs_images[slot],
|
||||
temporary,
|
||||
f"{relative_root}/tgs.png",
|
||||
artifacts,
|
||||
role="mixed-route-tgs-costmap",
|
||||
media_type="image/png",
|
||||
)
|
||||
anchor = tgs_anchors[slot]
|
||||
cases.append(
|
||||
{
|
||||
"case_id": case_id,
|
||||
"phase": PHASES[slot],
|
||||
"source_sequence": int(row["source_sequence"]),
|
||||
"session_seconds": float(row["session_seconds"]),
|
||||
"assets": {
|
||||
"source": _image_proof(source_descriptor),
|
||||
"city": _image_proof(city_descriptor),
|
||||
"vegetation": _image_proof(vegetation_descriptor),
|
||||
"tgs": _image_proof(tgs_descriptor),
|
||||
},
|
||||
"tgs": {
|
||||
"ground_cells": int(anchor["ground_cell_count"]),
|
||||
"occupied_cells": int(anchor["nonground_cell_count"]),
|
||||
"rejected_cells": int(anchor["rejected_cell_count"]),
|
||||
"unobserved_cells": int(anchor["unobserved_cell_count"]),
|
||||
},
|
||||
}
|
||||
)
|
||||
shutil.rmtree(temporary / ".tgs-render")
|
||||
|
||||
proofs = {}
|
||||
for key, path in (
|
||||
("base", base_root / "result.json"),
|
||||
("eomt", eomt_root / "run-report.partial.json"),
|
||||
("ddrnet", ddrnet_root / "result.json"),
|
||||
("tgs", tgs_root / "result.json"),
|
||||
):
|
||||
descriptor = _artifact(
|
||||
path,
|
||||
temporary,
|
||||
f"proofs/{key}.json",
|
||||
artifacts,
|
||||
role="mixed-route-proof",
|
||||
media_type="application/json",
|
||||
)
|
||||
proofs[key] = _image_proof(descriptor)
|
||||
_artifact(
|
||||
tgs_root / str(tgs["evidence"]["path"]),
|
||||
temporary,
|
||||
"proofs/tgs-evidence.npz",
|
||||
artifacts,
|
||||
role="mixed-route-tgs-evidence",
|
||||
media_type="application/x-npz",
|
||||
)
|
||||
|
||||
route_review = {
|
||||
"source_id": "RAVNOVES004TREE",
|
||||
"session_id": "20260828T130511Z_viewer_live",
|
||||
"pack_id": pack["pack_id"],
|
||||
"frame_count": FRAME_COUNT,
|
||||
"ground_truth": False,
|
||||
"selection_policy": "same-scene-camera-lidar-aligned-review-islands/v1",
|
||||
"models": {
|
||||
"city": {
|
||||
"name": "EoMT Cityscapes",
|
||||
"frames": FRAME_COUNT,
|
||||
"inference_fps": eomt["metrics"]["inference_frames_per_second"],
|
||||
"end_to_end_p95_ms": eomt["metrics"]["latency_ms"]["end_to_end_ms"]["p95"],
|
||||
},
|
||||
"vegetation": {
|
||||
"name": ddrnet["candidate"]["loaded_model_name"],
|
||||
"result_id": ddrnet["result_id"],
|
||||
"frames": FRAME_COUNT,
|
||||
"latency_p95_ms": ddrnet["timing"]["latency_ms_p95"],
|
||||
},
|
||||
"tgs": {
|
||||
"name": "TRAVEL/TGS causal rolling 1 s",
|
||||
"frames": FRAME_COUNT,
|
||||
"latency_p95_ms": tgs["timing"]["wall_seconds_p95"] * 1000.0,
|
||||
"cell_size_m": tgs["costmap"]["cell_size_m"],
|
||||
"radius_m": tgs["costmap"]["radius_m"],
|
||||
},
|
||||
},
|
||||
"cases": cases,
|
||||
"proofs": proofs,
|
||||
"limitations": [
|
||||
"Ten aligned review islands are not a complete route timeline.",
|
||||
"RAVNOVES004TREE has no manual truth.",
|
||||
"DDRNet vegetation subtypes remain visually noisy and are not planner authority.",
|
||||
"TGS does not prove ditch or negative-obstacle detection.",
|
||||
"People and vehicles require an independent fail-safe detector and STOP path.",
|
||||
],
|
||||
}
|
||||
authority = {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"actuation_accepted": False,
|
||||
"camera_semantics_can_clear_rigid_geometry": False,
|
||||
}
|
||||
identity = {
|
||||
"lab_id": "lab-v1-vegetation-mission-policy",
|
||||
"base_result_id": base["result_id"],
|
||||
"selected_candidate": base_identity["selected_candidate"],
|
||||
"candidate_metrics": base_identity["candidate_metrics"],
|
||||
"source": {
|
||||
"shadow_session": "RAVNOVES004TREE",
|
||||
"shadow_camera": "sensor.camera.right",
|
||||
"shadow_frame_count": FRAME_COUNT,
|
||||
"video_shadow_frame_count": 0,
|
||||
},
|
||||
"route_review": route_review,
|
||||
"authority": authority,
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(canonical_json(identity)).hexdigest()
|
||||
result_id = f"{RESULT_PREFIX}{identity_sha256}"
|
||||
manifest = {
|
||||
"schema_version": LAB_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"identity_sha256": identity_sha256,
|
||||
"created_at_utc": datetime.now(UTC).isoformat(),
|
||||
"ground_truth": False,
|
||||
"status": "visual-shadow-ready-policy-not-authorized",
|
||||
"identity": identity,
|
||||
"source": identity["source"],
|
||||
"route_video": None,
|
||||
"route_review": route_review,
|
||||
"method": {
|
||||
"completeness": "bounded-review-islands",
|
||||
"execution_class": "ai-inference",
|
||||
"pipeline_id": "ravnoves004tree-eomt-ddrnet-causal-tgs-review/v1",
|
||||
},
|
||||
"metrics": {"candidates": base["metrics"]["candidates"]},
|
||||
"decision": {
|
||||
"selected_candidate": base_identity["selected_candidate"],
|
||||
"visual_shadow_ready": True,
|
||||
"full_video_shadow_ready": False,
|
||||
"mission_policy_ready_for_configuration": True,
|
||||
"multilayer_policy_review_ready": True,
|
||||
"navigation_accepted": False,
|
||||
"production_accepted": False,
|
||||
},
|
||||
"limitations": route_review["limitations"],
|
||||
"authority": authority,
|
||||
"catalogs": {"goose": [], "ravnoves": []},
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
(temporary / "result.json").write_bytes(canonical_json(manifest) + b"\n")
|
||||
destination = output_root / result_id
|
||||
if destination.exists():
|
||||
raise VegetationShadowLabError("immutable mixed-route LAB result already exists")
|
||||
os.replace(temporary, destination)
|
||||
return destination
|
||||
except Exception:
|
||||
shutil.rmtree(temporary, ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--base-lab-root", type=Path, required=True)
|
||||
parser.add_argument("--review-pack-root", type=Path, required=True)
|
||||
parser.add_argument("--eomt-root", type=Path, required=True)
|
||||
parser.add_argument("--ddrnet-root", type=Path, required=True)
|
||||
parser.add_argument("--tgs-root", type=Path, required=True)
|
||||
parser.add_argument("--output-root", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
print(
|
||||
seal_mixed_route_vegetation_review(
|
||||
base_lab_root=args.base_lab_root,
|
||||
review_pack_root=args.review_pack_root,
|
||||
eomt_root=args.eomt_root,
|
||||
ddrnet_root=args.ddrnet_root,
|
||||
tgs_root=args.tgs_root,
|
||||
output_root=args.output_root,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user