feat(perception): add mixed-route vegetation review
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user