refactor(lab): перевести RAV004 на канонический Rerun pipeline

This commit is contained in:
DCCONSTRUCTIONS
2026-08-30 12:59:16 +03:00
parent e9ffb829c9
commit f5ee42751d
30 changed files with 1425 additions and 288 deletions
+5
View File
@@ -1047,6 +1047,11 @@ app.include_router(
if session_recorded_camera_frame_service is not None
else None
),
jobs_root=REPOSITORY_ROOT / ".runtime" / "compute-jobs",
rerun_overlay_cache_root=(
session_store.data_dir / "laboratory-rerun-overlays"
),
ffmpeg_path=_ffmpeg,
)
)
app.include_router(
+4
View File
@@ -124,6 +124,8 @@ class RecordedBlueprintRequest(StrictApiModel):
active_view: Literal["spatial", "perception", "perception3d", "metrics"] = "spatial"
view_reset_generation: Literal[0, 1] = 0
unified_perception: StrictBool = False
semantic_layer: Literal["city", "vegetation"] | None = None
plan_view: StrictBool = False
show_detections_2d: StrictBool = False
show_segmentation: StrictBool = False
show_cuboids_3d: StrictBool = False
@@ -935,6 +937,8 @@ def build_session_router(
active_view=request.active_view,
view_reset_generation=request.view_reset_generation,
unified_perception=request.unified_perception,
semantic_layer=request.semantic_layer,
plan_view=request.plan_view,
show_detections_2d=request.show_detections_2d,
show_segmentation=request.show_segmentation,
show_cuboids_3d=request.show_cuboids_3d,
+84 -75
View File
@@ -12,13 +12,21 @@ import zipfile
from collections.abc import Callable
from functools import lru_cache
from pathlib import Path, PurePosixPath
from typing import Any, Final
from typing import Any, Final, Literal
import numpy as np
from fastapi import APIRouter, HTTPException
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse, JSONResponse, Response
from PIL import Image
from pydantic import BaseModel, ConfigDict, Field
from k1link.laboratory.canonical_rerun_overlay import (
CanonicalLabOverlayError,
_mask_component_boxes,
canonical_lab_overlay,
canonical_recording_id,
)
from k1link.laboratory.evidence_registry import LaboratoryEvidenceDefinition
from k1link.laboratory.evidence_report import (
LaboratoryEvidenceReportError,
@@ -34,6 +42,17 @@ from k1link.sessions.canonical_lab_spatial import (
RootProvider = Callable[[], Path | None]
CanonicalRecordingProvider = Callable[[str], tuple[Path, str] | None]
CameraFrameProvider = Callable[[str, int], RecordedCameraFrame]
class CanonicalLabRerunRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
application_id: Literal["nodedc_mission_core_recorded"]
recording_id: str = Field(
min_length=1,
max_length=128,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$",
)
_MAX_DOCUMENT_BYTES: Final = 1024 * 1024
_CANONICAL_ROUTE_CHUNK_FRAMES: Final = 24
_DEFINITION: Final = LaboratoryEvidenceDefinition(
@@ -57,6 +76,9 @@ def build_vegetation_shadow_lab_router(
root_provider: RootProvider = lambda: None,
canonical_recording_provider: CanonicalRecordingProvider | None = None,
camera_frame_provider: CameraFrameProvider | None = None,
jobs_root: Path | None = None,
rerun_overlay_cache_root: Path | None = None,
ffmpeg_path: Path | None = None,
) -> APIRouter:
return _build_vegetation_lab_router(
prefix="/api/v1/laboratory/vegetation-shadow",
@@ -64,6 +86,9 @@ def build_vegetation_shadow_lab_router(
root_provider=root_provider,
canonical_recording_provider=canonical_recording_provider,
camera_frame_provider=camera_frame_provider,
jobs_root=jobs_root,
rerun_overlay_cache_root=rerun_overlay_cache_root,
ffmpeg_path=ffmpeg_path,
)
@@ -84,6 +109,9 @@ def _build_vegetation_lab_router(
root_provider: RootProvider,
canonical_recording_provider: CanonicalRecordingProvider | None = None,
camera_frame_provider: CameraFrameProvider | None = None,
jobs_root: Path | None = None,
rerun_overlay_cache_root: Path | None = None,
ffmpeg_path: Path | None = None,
) -> APIRouter:
router = APIRouter(
prefix=prefix,
@@ -270,6 +298,61 @@ def _build_vegetation_lab_router(
},
)
@router.post("/{result_id}/canonical-overlay.rrd")
async def get_canonical_rerun_overlay(
result_id: str,
request: CanonicalLabRerunRequest,
) -> FileResponse:
"""Project LAB-only evidence into the base recording's native clock."""
if (
canonical_recording_provider is None
or jobs_root is None
or rerun_overlay_cache_root is None
or ffmpeg_path is None
):
raise HTTPException(status_code=503, detail="Canonical LAB Rerun overlay unavailable")
candidate = _resolve_candidate(root_provider, definition, result_id)
manifest = _read_verified(candidate, definition)
route, _ = _full_route_context(candidate, manifest)
recording = canonical_recording_provider(str(route["session_id"]))
if recording is None:
raise HTTPException(status_code=409, detail="Canonical spatial recording is not ready")
recording_path, generation_sha256 = recording
try:
expected_recording_id = await run_in_threadpool(
canonical_recording_id,
recording_path,
)
if request.recording_id != expected_recording_id:
raise HTTPException(status_code=412, detail="Canonical recording identity changed")
artifact = await run_in_threadpool(
canonical_lab_overlay,
candidate,
manifest,
recording_id=request.recording_id,
base_generation_sha256=generation_sha256,
jobs_root=jobs_root,
cache_root=rerun_overlay_cache_root,
ffmpeg_path=ffmpeg_path,
)
except HTTPException:
raise
except CanonicalLabOverlayError as exc:
raise HTTPException(
status_code=503,
detail="Canonical LAB Rerun overlay failed verification",
) from exc
return FileResponse(
artifact.path,
media_type="application/vnd.rerun.rrd",
headers={
"Cache-Control": "private, max-age=31536000, immutable",
"ETag": f'"{artifact.sha256}"',
"X-Content-Type-Options": "nosniff",
},
)
@router.get("/{result_id}/timeline")
def get_canonical_route_timeline(result_id: str) -> dict[str, object]:
candidate = _resolve_candidate(root_provider, definition, result_id)
@@ -811,80 +894,6 @@ def _semantic_component_proposals_cached(
return tuple(proposals[:32])
def _mask_component_boxes(
mask: np.ndarray,
class_id: int,
*,
minimum_pixels: int,
) -> list[tuple[int, int, int, int, int]]:
"""Return 8-connected run-length components without an OpenCV dependency."""
if mask.ndim != 2 or minimum_pixels < 1:
return []
parents: list[int] = []
runs: list[tuple[int, int, int, int]] = []
def root(index: int) -> int:
while parents[index] != index:
parents[index] = parents[parents[index]]
index = parents[index]
return index
def union(left: int, right: int) -> None:
left_root = root(left)
right_root = root(right)
if left_root != right_root:
parents[right_root] = left_root
previous: list[int] = []
for row_index, row in enumerate(mask):
matches = np.flatnonzero(row == class_id)
if matches.size == 0:
previous = []
continue
split_at = np.flatnonzero(np.diff(matches) > 1) + 1
groups = np.split(matches, split_at)
current: list[int] = []
previous_cursor = 0
for group in groups:
start = int(group[0])
stop = int(group[-1]) + 1
run_index = len(runs)
runs.append((row_index, start, stop, stop - start))
parents.append(run_index)
current.append(run_index)
while (
previous_cursor < len(previous)
and runs[previous[previous_cursor]][2] < start
):
previous_cursor += 1
candidate_cursor = previous_cursor
while candidate_cursor < len(previous):
previous_index = previous[candidate_cursor]
_, previous_start, previous_stop, _ = runs[previous_index]
if previous_start > stop:
break
union(run_index, previous_index)
candidate_cursor += 1
previous = current
components: dict[int, list[int]] = {}
for run_index, (row, start, stop, count) in enumerate(runs):
component = components.setdefault(root(run_index), [start, row, stop, row + 1, 0])
component[0] = min(component[0], start)
component[1] = min(component[1], row)
component[2] = max(component[2], stop)
component[3] = max(component[3], row + 1)
component[4] += count
result = [
(left, top, right, bottom, count)
for left, top, right, bottom, count in components.values()
if count >= minimum_pixels and right - left >= 2 and bottom - top >= 3
]
result.sort(key=lambda box: (-box[4], box[1], box[0]))
return result
def _route_tgs_anchor_payload(path: Path, source_sequence: int) -> dict[str, object]:
before = path.stat()
with np.load(path, allow_pickle=False) as archive: