feat(perception): project recorded results into Rerun
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import k1link.compute.results as result_module
|
||||
from k1link.compute import (
|
||||
RecordedPerceptionOverlayStore,
|
||||
prepare_camera_compute_job,
|
||||
validate_recorded_perception_result,
|
||||
)
|
||||
from k1link.sessions import SessionIntegrityError
|
||||
from k1link.web.camera_archive import CameraArchiveWriter
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _job(tmp_path: Path) -> Any:
|
||||
origin_epoch_ns = 1_000_000_000
|
||||
origin_monotonic_ns = 2_000_000_000
|
||||
session = tmp_path / "session-1"
|
||||
session.mkdir()
|
||||
|
||||
def box(box_type: bytes, payload: bytes = b"") -> bytes:
|
||||
return (8 + len(payload)).to_bytes(4, "big") + box_type + payload
|
||||
|
||||
def full_box(box_type: bytes, payload: bytes = b"", *, flags: int = 0) -> bytes:
|
||||
return box(box_type, bytes([0]) + flags.to_bytes(3, "big") + payload)
|
||||
|
||||
track_id = 1
|
||||
tkhd = full_box(b"tkhd", b"\x00" * 8 + track_id.to_bytes(4, "big") + b"\x00" * 4)
|
||||
mdhd = full_box(
|
||||
b"mdhd",
|
||||
b"\x00" * 8 + (1_000).to_bytes(4, "big") + b"\x00" * 4,
|
||||
)
|
||||
hdlr = full_box(b"hdlr", b"\x00" * 4 + b"vide")
|
||||
trak = box(b"trak", tkhd + box(b"mdia", mdhd + hdlr))
|
||||
trex = full_box(
|
||||
b"trex",
|
||||
track_id.to_bytes(4, "big")
|
||||
+ (1).to_bytes(4, "big")
|
||||
+ (500).to_bytes(4, "big")
|
||||
+ b"\x00" * 8,
|
||||
)
|
||||
init = box(b"ftyp", b"isom") + box(
|
||||
b"moov",
|
||||
trak + box(b"mvex", trex) + box(b"avcC", b"\x01\x64\x00\x28"),
|
||||
)
|
||||
tfhd = full_box(b"tfhd", track_id.to_bytes(4, "big"), flags=0x020000)
|
||||
tfdt = full_box(b"tfdt", (0).to_bytes(4, "big"))
|
||||
trun = full_box(b"trun", (1).to_bytes(4, "big"))
|
||||
fragment = box(b"moof", box(b"traf", tfhd + tfdt + trun)) + box(b"mdat", b"frame")
|
||||
writer = CameraArchiveWriter(session, "sensor.camera.left", 1)
|
||||
writer.append(
|
||||
"init",
|
||||
init,
|
||||
host_epoch_ns=origin_epoch_ns + 100_000_000,
|
||||
host_monotonic_ns=origin_monotonic_ns + 100_000_000,
|
||||
)
|
||||
writer.append(
|
||||
"media",
|
||||
fragment,
|
||||
host_epoch_ns=origin_epoch_ns + 200_000_000,
|
||||
host_monotonic_ns=origin_monotonic_ns + 200_000_000,
|
||||
)
|
||||
writer.close()
|
||||
return prepare_camera_compute_job(
|
||||
session_root=session,
|
||||
source_id="sensor.camera.left",
|
||||
codec_epoch=1,
|
||||
origin_epoch_ns=origin_epoch_ns,
|
||||
origin_monotonic_ns=origin_monotonic_ns,
|
||||
output_root=tmp_path / "jobs",
|
||||
)
|
||||
|
||||
|
||||
def _result(tmp_path: Path, job: Any) -> Path:
|
||||
parameters = {
|
||||
"score_threshold": 0.25,
|
||||
"nms_threshold": 0.45,
|
||||
"input_shape": [1, 3, 640, 640],
|
||||
}
|
||||
pipeline = {"id": "recorded-camera-coco-detection", "version": 1}
|
||||
model = {
|
||||
"id": "synthetic",
|
||||
"version": 1,
|
||||
"sha256": "a" * 64,
|
||||
"source_url": "https://example.invalid/model.onnx",
|
||||
"license": "test-only",
|
||||
"classes": "synthetic",
|
||||
}
|
||||
identity = {
|
||||
"schema_version": "missioncore.compute-result-identity/v1",
|
||||
"job_id": job.job_id,
|
||||
"input_sha256": job.input_sha256,
|
||||
"pipeline": pipeline,
|
||||
"model": {key: model[key] for key in ("id", "version", "sha256")},
|
||||
"parameters": parameters,
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
result_id = f"result-{identity_sha256}"
|
||||
root = tmp_path / "results" / job.job_id / result_id
|
||||
root.mkdir(parents=True)
|
||||
detections = {
|
||||
"schema_version": "missioncore.object-detections/v1",
|
||||
"result_id": result_id,
|
||||
"job_id": job.job_id,
|
||||
"input_sha256": job.input_sha256,
|
||||
"timestamp_basis": "session-time-seconds",
|
||||
"frames": [
|
||||
{
|
||||
"frame_index": 0,
|
||||
"epoch_seconds": 0.0,
|
||||
"session_seconds": job.timeline_start_seconds,
|
||||
"detections": [
|
||||
{
|
||||
"class_id": 0,
|
||||
"class_name": "person",
|
||||
"score": 0.75,
|
||||
"bbox_xyxy": [10.0, 20.0, 30.0, 60.0],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
detection_payload = json.dumps(detections, indent=2).encode() + b"\n"
|
||||
(root / "detections.json").write_bytes(detection_payload)
|
||||
result = {
|
||||
"schema_version": "missioncore.compute-result/v1",
|
||||
"result_id": result_id,
|
||||
"identity_sha256": identity_sha256,
|
||||
"job_id": job.job_id,
|
||||
"input_sha256": job.input_sha256,
|
||||
"created_at_utc": "2026-07-19T00:00:00.000Z",
|
||||
"pipeline": pipeline,
|
||||
"runtime": {"kind": "synthetic"},
|
||||
"model": model,
|
||||
"parameters": parameters,
|
||||
"metrics": {"frames_processed": 1, "detections": 1},
|
||||
"artifacts": [
|
||||
{
|
||||
"kind": "object-detections",
|
||||
"path": "detections.json",
|
||||
"byte_length": len(detection_payload),
|
||||
"sha256": hashlib.sha256(detection_payload).hexdigest(),
|
||||
"schema_version": "missioncore.object-detections/v1",
|
||||
}
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
(root / "result.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def test_result_is_bound_to_job_and_cache_reuses_complete_overlay(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
job = _job(tmp_path)
|
||||
result_root = _result(tmp_path, job)
|
||||
result = validate_recorded_perception_result(job.job_root, result_root)
|
||||
assert result.job.session_id == "session-1"
|
||||
assert result.frames[0].detections[0].class_name == "person"
|
||||
|
||||
calls = 0
|
||||
|
||||
def render(*_: object, **__: object) -> bytes:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return b"RRF2synthetic-overlay"
|
||||
|
||||
monkeypatch.setattr(result_module, "_render_overlay", render)
|
||||
store = RecordedPerceptionOverlayStore(
|
||||
jobs_root=tmp_path / "jobs",
|
||||
results_root=tmp_path / "results",
|
||||
cache_root=tmp_path / "cache",
|
||||
ffmpeg_path=Path(__file__),
|
||||
ffprobe_path=Path(__file__),
|
||||
)
|
||||
first = store.render(
|
||||
"session-1",
|
||||
application_id="nodedc_mission_core_recorded",
|
||||
recording_id="recording-1",
|
||||
)
|
||||
second = store.render(
|
||||
"session-1",
|
||||
application_id="nodedc_mission_core_recorded",
|
||||
recording_id="recording-1",
|
||||
)
|
||||
assert first == second == b"RRF2synthetic-overlay"
|
||||
assert calls == 1
|
||||
|
||||
|
||||
def test_result_rejects_changed_detection_payload(tmp_path: Path) -> None:
|
||||
job = _job(tmp_path)
|
||||
result_root = _result(tmp_path, job)
|
||||
detection_path = result_root / "detections.json"
|
||||
detection_path.write_bytes(detection_path.read_bytes().replace(b"0.75", b"0.76"))
|
||||
with pytest.raises(SessionIntegrityError, match="artifact identity changed"):
|
||||
validate_recorded_perception_result(job.job_root, result_root)
|
||||
@@ -17,6 +17,7 @@ from k1link.device_plugins.xgrids_k1.mqtt.capture import (
|
||||
RAW_MAGIC,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.rrd_export import (
|
||||
RECORDED_CAMERA_VIEW_ID,
|
||||
RECORDED_METRICS_VIEW_ID,
|
||||
RECORDED_POINTS_VISUALIZER_ID,
|
||||
RECORDED_ROOT_CONTAINER_ID,
|
||||
@@ -232,8 +233,10 @@ def test_dynamic_blueprint_reuses_scene_ids_without_playback_mutation() -> None:
|
||||
first_view = first.root_container.contents[0]
|
||||
second_view = second.root_container.contents[0]
|
||||
assert first_view.id == second_view.id == RECORDED_SPATIAL_VIEW_ID
|
||||
assert first.root_container.contents[1].id == RECORDED_METRICS_VIEW_ID
|
||||
assert second.root_container.contents[1].id == RECORDED_METRICS_VIEW_ID
|
||||
assert first.root_container.contents[1].id == RECORDED_CAMERA_VIEW_ID
|
||||
assert second.root_container.contents[1].id == RECORDED_CAMERA_VIEW_ID
|
||||
assert first.root_container.contents[2].id == RECORDED_METRICS_VIEW_ID
|
||||
assert second.root_container.contents[2].id == RECORDED_METRICS_VIEW_ID
|
||||
|
||||
first_point_behavior, first_point_visualizer = first_view.visualizer_overrides["/world/points"]
|
||||
second_point_behavior, second_point_visualizer = second_view.visualizer_overrides[
|
||||
|
||||
@@ -35,6 +35,7 @@ from k1link.web.camera_archive import CameraArchiveWriter
|
||||
from k1link.web.session_api import (
|
||||
LayoutPutRequest,
|
||||
RecordedBlueprintRequest,
|
||||
RecordedPerceptionRequest,
|
||||
ReplayRequest,
|
||||
build_session_router,
|
||||
)
|
||||
@@ -1036,9 +1037,11 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
observed_settings: list[RerunSceneSettings] = []
|
||||
observed_kwargs: list[dict[str, Any]] = []
|
||||
|
||||
def capture_settings(settings: RerunSceneSettings, **kwargs: Any) -> bytes:
|
||||
observed_settings.append(settings)
|
||||
observed_kwargs.append(kwargs)
|
||||
return recorded_blueprint_rrd(settings, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -1065,6 +1068,7 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
|
||||
point_size=6.25,
|
||||
palette="custom",
|
||||
custom_color="#112233",
|
||||
active_view="perception",
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -1078,6 +1082,7 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
|
||||
assert len(observed_settings) == 1
|
||||
assert observed_settings[0].show_points is False
|
||||
assert observed_settings[0].show_trajectory is True
|
||||
assert observed_kwargs[0]["active_view"] == "perception"
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
RecordedBlueprintRequest.model_validate(
|
||||
@@ -1106,6 +1111,67 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
|
||||
assert missing.value.status_code == 404
|
||||
|
||||
|
||||
def test_recorded_perception_endpoint_returns_one_complete_optional_overlay(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
calls: list[tuple[str, str, str]] = []
|
||||
|
||||
class Provider:
|
||||
def render(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
application_id: str,
|
||||
recording_id: str,
|
||||
) -> bytes:
|
||||
calls.append((session_id, application_id, recording_id))
|
||||
return b"RRF2perception"
|
||||
|
||||
router = build_session_router(store, perception_overlay_provider=Provider())
|
||||
perception_route = endpoint(
|
||||
router,
|
||||
"/api/v1/observation-sessions/{session_id}/perception.rrd",
|
||||
"POST",
|
||||
)
|
||||
response = asyncio.run(
|
||||
perception_route(
|
||||
session_id=session.name,
|
||||
request=RecordedPerceptionRequest(
|
||||
application_id="nodedc_mission_core_recorded",
|
||||
recording_id="recording-001",
|
||||
),
|
||||
)
|
||||
)
|
||||
assert response.body == b"RRF2perception"
|
||||
assert response.media_type == "application/vnd.rerun.rrd"
|
||||
assert response.headers["content-length"] == str(len(response.body))
|
||||
assert calls == [
|
||||
(session.name, "nodedc_mission_core_recorded", "recording-001")
|
||||
]
|
||||
|
||||
empty_router = build_session_router(store)
|
||||
empty_route = endpoint(
|
||||
empty_router,
|
||||
"/api/v1/observation-sessions/{session_id}/perception.rrd",
|
||||
"POST",
|
||||
)
|
||||
empty = asyncio.run(
|
||||
empty_route(
|
||||
session_id=session.name,
|
||||
request=RecordedPerceptionRequest(
|
||||
application_id="nodedc_mission_core_recorded",
|
||||
recording_id="recording-001",
|
||||
),
|
||||
)
|
||||
)
|
||||
assert empty.status_code == 204
|
||||
|
||||
|
||||
def test_session_router_exposes_opaque_recorded_media_manifest_and_ranges(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user