feat(perception): add mixed-route vegetation review

This commit is contained in:
DCCONSTRUCTIONS
2026-08-28 23:13:48 +03:00
parent e10b96b546
commit af1e530162
12 changed files with 2332 additions and 17 deletions
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""Publish exact, independently decodable camera islands for mixed-route review."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import subprocess
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from PIL import Image
from k1link.compute.jobs import validate_camera_compute_job
from k1link.device_plugins.xgrids_k1.mqtt.capture import read_capture_clock_origin
SCHEMA = "missioncore.mixed-route-review-pack/v1"
MAX_INDEX_LINE_BYTES = 64 * 1024
class MixedRouteReviewPackError(RuntimeError):
"""The selected camera evidence cannot be published without ambiguity."""
def _canonical_json(value: object) -> bytes:
return json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
).encode("utf-8")
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _sequences(value: str) -> tuple[int, ...]:
try:
sequences = tuple(int(item) for item in value.split(","))
except ValueError as exc:
raise argparse.ArgumentTypeError("sequences must be comma-separated integers") from exc
if not sequences or any(item < 1 for item in sequences):
raise argparse.ArgumentTypeError("sequences must be positive")
if len(set(sequences)) != len(sequences) or tuple(sorted(sequences)) != sequences:
raise argparse.ArgumentTypeError("sequences must be unique and increasing")
return sequences
def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--job", type=Path, required=True)
parser.add_argument("--session", type=Path, required=True)
parser.add_argument("--sequences", type=_sequences, required=True)
parser.add_argument("--output-root", type=Path, required=True)
parser.add_argument("--ffmpeg", type=Path, required=True)
return parser.parse_args()
def _read_selected_index(
path: Path,
sequences: tuple[int, ...],
) -> list[dict[str, Any]]:
wanted = set(sequences)
selected: dict[int, dict[str, Any]] = {}
with path.open("rb") as stream:
for expected_sequence, line in enumerate(stream, start=1):
if len(line) > MAX_INDEX_LINE_BYTES or not line.endswith(b"\n"):
raise MixedRouteReviewPackError("camera index line is invalid")
if expected_sequence not in wanted:
continue
try:
value = json.loads(line)
except json.JSONDecodeError as exc:
raise MixedRouteReviewPackError("camera index JSON is invalid") from exc
if (
not isinstance(value, dict)
or value.get("schema_version")
!= "missioncore.camera-recording-index/v1"
or value.get("kind") != "media"
or value.get("sequence") != expected_sequence
or value.get("path") != f"segments/{expected_sequence}.m4s"
or not isinstance(value.get("session_monotonic_ns"), int)
or not isinstance(value.get("host_monotonic_ns"), int)
or not isinstance(value.get("host_epoch_ns"), int)
):
raise MixedRouteReviewPackError("selected camera index row changed")
selected[expected_sequence] = value
if tuple(sorted(selected)) != sequences:
raise MixedRouteReviewPackError("selected camera sequence is incomplete")
return [selected[sequence] for sequence in sequences]
def _decode_exact_fragment(
*,
ffmpeg: Path,
init_path: Path,
segment_path: Path,
output_path: Path,
) -> None:
input_value = f"concat:{init_path}|{segment_path}"
completed = subprocess.run(
[
os.fspath(ffmpeg),
"-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-y",
"-i",
input_value,
"-frames:v",
"1",
os.fspath(output_path),
],
capture_output=True,
text=True,
timeout=30,
check=False,
)
if completed.returncode != 0 or not output_path.is_file():
detail = completed.stderr.strip().splitlines()[-1:] or ["no decoded frame"]
raise MixedRouteReviewPackError(
f"selected fragment is not independently decodable: {segment_path.name}: {detail[0]}"
)
with Image.open(output_path) as image:
if image.mode != "RGB" or image.size != (800, 600):
raise MixedRouteReviewPackError("selected camera frame shape changed")
def prepare(
*,
job_root: Path,
session_root: Path,
sequences: tuple[int, ...],
output_root: Path,
ffmpeg_path: Path,
) -> Path:
job = validate_camera_compute_job(job_root)
session = session_root.resolve(strict=True)
if not session.is_dir() or session.name != job.session_id:
raise MixedRouteReviewPackError("camera job and observation session differ")
capture_root = session / "captures" / "mqtt_live"
origin_path = capture_root / "mqtt.timeline.origin.json"
origin = read_capture_clock_origin(origin_path)
if sequences[-1] > job.segment_count:
raise MixedRouteReviewPackError("selected sequence escapes the camera epoch")
ffmpeg = ffmpeg_path.resolve(strict=True)
if not ffmpeg.is_file():
raise MixedRouteReviewPackError("ffmpeg is unavailable")
epoch_root = (
job.job_root
/ "input"
/ "camera"
/ job.source_id
/ f"epoch-{job.codec_epoch}"
)
selected = _read_selected_index(epoch_root / "index.jsonl", sequences)
identity = {
"schema_version": SCHEMA,
"job_id": job.job_id,
"input_sha256": job.input_sha256,
"session_id": job.session_id,
"source_id": job.source_id,
"codec_epoch": job.codec_epoch,
"clock_origin": {
"artifact_sha256": _sha256(origin_path),
"started_epoch_ns": origin.started_at_epoch_ns,
"started_monotonic_ns": origin.started_monotonic_ns,
},
"selected_sequences": list(sequences),
"selection_policy": "exact-independently-decodable-fragments/v1",
"ground_truth": False,
"authority": {
"navigation_or_safety_accepted": False,
"actuation_allowed": False,
},
"producer_sha256": _sha256(Path(__file__).resolve(strict=True)),
}
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
pack_id = f"mixed-route-review-pack-{identity_sha256}"
parent = output_root.resolve()
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
final = parent / pack_id
if final.exists():
return final
staging = Path(tempfile.mkdtemp(prefix=f".{pack_id}.", dir=parent))
published = False
try:
frames_root = staging / "frames"
frames_root.mkdir(mode=0o700)
timeline_rows: list[dict[str, Any]] = []
artifacts: list[dict[str, Any]] = []
for frame_index, (sequence, row) in enumerate(
zip(sequences, selected, strict=True)
):
output_path = frames_root / f"frame-{frame_index + 1:06d}.png"
segment_path = epoch_root / "segments" / f"{sequence}.m4s"
_decode_exact_fragment(
ffmpeg=ffmpeg,
init_path=epoch_root / "init.mp4",
segment_path=segment_path,
output_path=output_path,
)
host_monotonic_ns = int(row["host_monotonic_ns"])
if host_monotonic_ns < origin.started_monotonic_ns:
raise MixedRouteReviewPackError("selected frame predates the session clock origin")
session_seconds = (
host_monotonic_ns - origin.started_monotonic_ns
) / 1e9
timeline_rows.append(
{
"frame_index": frame_index,
"sequence": frame_index + 1,
"source_frame_index": sequence - 1,
"source_sequence": sequence,
"session_seconds": session_seconds,
"host_monotonic_ns": row["host_monotonic_ns"],
"host_epoch_ns": row["host_epoch_ns"],
}
)
artifacts.append(
{
"path": output_path.relative_to(staging).as_posix(),
"byte_length": output_path.stat().st_size,
"sha256": _sha256(output_path),
"source_segment_sha256": row["sha256"],
}
)
timeline_path = staging / "timeline.jsonl"
timeline_path.write_text(
"".join(
json.dumps(
row,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
+ "\n"
for row in timeline_rows
),
encoding="utf-8",
)
manifest = {
"schema_version": SCHEMA,
"pack_id": pack_id,
"identity_sha256": identity_sha256,
"identity": identity,
"created_at_utc": datetime.now(UTC)
.isoformat(timespec="milliseconds")
.replace("+00:00", "Z"),
"frame_count": len(sequences),
"timeline": {
"path": timeline_path.name,
"byte_length": timeline_path.stat().st_size,
"sha256": _sha256(timeline_path),
},
"frames": artifacts,
}
(staging / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
encoding="utf-8",
)
os.replace(staging, final)
published = True
finally:
if not published:
shutil.rmtree(staging, ignore_errors=True)
return final
def main() -> int:
args = _arguments()
result = prepare(
job_root=args.job,
session_root=args.session,
sequences=args.sequences,
output_root=args.output_root,
ffmpeg_path=args.ffmpeg,
)
print(json.dumps({"pack_id": result.name, "output": os.fspath(result)}, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())