From f1cbe0061aed76719b17a7b790de76659ba5ef73 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 30 Aug 2026 01:22:44 +0300 Subject: [PATCH] refactor(lab): restore canonical RAV004 replay --- .../src/components/RecordedFmp4Player.tsx | 68 ++ .../laboratory/RecordedEvidenceVideoScene.tsx | 30 +- .../laboratory/useRecordedEvidencePlayback.ts | 6 +- .../core/laboratory/canonicalRecordedLab.ts | 2 +- .../laboratory/canonicalRecordedLabSpatial.ts | 19 +- .../src/core/laboratory/m4ReplayThreat.ts | 35 +- .../laboratory/M4ReplayThreatVisual.tsx | 95 ++- .../laboratory/VegetationShadowResult.tsx | 666 +++--------------- .../laboratory/useM4ThreatTimeline.ts | 16 +- .../test/m4ReplayThreat.test.mjs | 21 +- .../test/recordedCameraBuffering.test.mjs | 8 + .../test/vegetationShadow.test.mjs | 31 +- .../2026-08-30_RAV004_CANONICAL_LAB_QA.jpg | Bin 0 -> 87296 bytes ...08-30_RAV004_CANONICAL_LAB_REPLAY_AUDIT.md | 167 +++++ src/k1link/sessions/canonical_lab_spatial.py | 243 ++++++- src/k1link/web/app.py | 15 + src/k1link/web/session_api.py | 4 +- src/k1link/web/vegetation_shadow_lab_api.py | 391 +++++++++- tests/test_canonical_lab_spatial.py | 60 +- tests/test_session_api.py | 10 +- tests/test_vegetation_shadow_lab.py | 13 + 21 files changed, 1181 insertions(+), 719 deletions(-) create mode 100644 docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_QA.jpg create mode 100644 docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_REPLAY_AUDIT.md diff --git a/apps/control-station/src/components/RecordedFmp4Player.tsx b/apps/control-station/src/components/RecordedFmp4Player.tsx index 5ba5c07..d071a63 100644 --- a/apps/control-station/src/components/RecordedFmp4Player.tsx +++ b/apps/control-station/src/components/RecordedFmp4Player.tsx @@ -182,6 +182,27 @@ function recordedMediaTimeRangesContain( return false; } +export function recordedMediaTimestampStallRecoveryTarget( + currentSeconds: number, + bufferedRanges: readonly (readonly [number, number])[], + skipSeconds = 0.18, +): number | null { + if (!Number.isFinite(currentSeconds) || !Number.isFinite(skipSeconds) || skipSeconds <= 0) { + return null; + } + for (const [startSeconds, endSeconds] of bufferedRanges) { + if ( + !Number.isFinite(startSeconds) + || !Number.isFinite(endSeconds) + || currentSeconds < startSeconds - 0.05 + || currentSeconds > endSeconds + ) continue; + const targetSeconds = Math.min(currentSeconds + skipSeconds, endSeconds - 0.05); + return targetSeconds >= currentSeconds + 0.04 ? targetSeconds : null; + } + return null; +} + export function recordedMediaPresentationState( state: "loading" | "ready" | "error", readyGeneration: string | null, @@ -783,6 +804,7 @@ export function RecordedFmp4Player({ onPlayingRejected, playbackAuthority = "media", playbackTransport = "segmented", + recoverTimestampStalls = false, }: { source: ObservationSourceDescriptor; playback?: RecordedObservationPlayback | null; @@ -797,6 +819,7 @@ export function RecordedFmp4Player({ onPlayingRejected?: () => void; playbackAuthority?: "media" | "host"; playbackTransport?: "segmented" | "epoch-stream"; + recoverTimestampStalls?: boolean; }) { const videoRef = useRef(null); const onAdmissionChangeRef = useRef(onAdmissionChange); @@ -1450,6 +1473,51 @@ export function RecordedFmp4Player({ visualState, ]); + useEffect(() => { + const video = videoRef.current; + if (!video || !recoverTimestampStalls || !playback?.playing || visualState !== "ready") { + return; + } + let lastSeconds = video.currentTime; + let lastProgressAtMs = performance.now(); + const interval = window.setInterval(() => { + if ( + !playbackPlayingRef.current + || video.paused + || video.ended + || video.seeking + || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA + ) { + lastSeconds = video.currentTime; + lastProgressAtMs = performance.now(); + return; + } + const nowMs = performance.now(); + if (video.currentTime >= lastSeconds + 0.02) { + lastSeconds = video.currentTime; + lastProgressAtMs = nowMs; + return; + } + if (nowMs - lastProgressAtMs < 1_250) return; + const bufferedRanges = Array.from( + { length: video.buffered.length }, + (_, index) => [video.buffered.start(index), video.buffered.end(index)] as const, + ); + const targetSeconds = recordedMediaTimestampStallRecoveryTarget( + video.currentTime, + bufferedRanges, + ); + lastProgressAtMs = nowMs; + if (targetSeconds === null) return; + // Field recordings can contain non-monotonic or corrupt H.264 timestamps. + // If decoded time is frozen despite proven buffered media ahead, skip only + // the broken timestamp interval and return authority to the media clock. + video.currentTime = targetSeconds; + lastSeconds = targetSeconds; + }, 250); + return () => window.clearInterval(interval); + }, [playback?.playing, recoverTimestampStalls, visualState]); + useEffect(() => { const video = videoRef.current; if (!video || !segmented || !playback?.playing || visualState !== "ready") return; diff --git a/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx b/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx index d191815..aabb3d9 100644 --- a/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx +++ b/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx @@ -39,6 +39,7 @@ export function RecordedEvidenceVideoScene({ onAdmissionChange, playbackAuthority = "media", playbackTransport = "segmented", + recoverTimestampStalls = false, }: { source: ObservationSourceDescriptor; playback: RecordedObservationPlayback; @@ -56,6 +57,7 @@ export function RecordedEvidenceVideoScene({ onAdmissionChange?: (state: RecordedCameraAdmissionState) => void; playbackAuthority?: "media" | "host"; playbackTransport?: "segmented" | "epoch-stream"; + recoverTimestampStalls?: boolean; }) { const generation = source.delivery?.kind === "recorded-fmp4-manifest" ? source.delivery.manifestGenerationSha256 @@ -63,12 +65,29 @@ export function RecordedEvidenceVideoScene({ const [admissionPhase, setAdmissionPhase] = useState( "loading", ); - useEffect(() => setAdmissionPhase("loading"), [generation, source.id]); + const [presentedSeconds, setPresentedSeconds] = useState(null); + useEffect(() => { + setAdmissionPhase("loading"); + setPresentedSeconds(null); + }, [generation, source.id]); const handleAdmissionChange = (next: RecordedCameraAdmissionState) => { setAdmissionPhase(next.phase); onAdmissionChange?.(next); }; const sourceReady = admissionPhase === "ready"; + const overlaysPresented = sourceReady + && presentedSeconds !== null + && Math.abs(presentedSeconds - playback.currentSeconds) <= 0.25; + const handlePlaybackChange = (next: RecordedObservationPlayback) => { + setPresentedSeconds(next.currentSeconds); + // During a paused operator seek the existing media element can emit its old + // timestamp while the requested MSE window is being rebuilt. That stale + // callback must not undo the host target before the decoder reaches it. + if (!playback.playing && Math.abs(next.currentSeconds - playback.currentSeconds) > 0.35) { + return; + } + onPlaybackChange?.(next); + }; return (
- {sourceReady && semanticOverlay ? ( + {overlaysPresented && semanticOverlay ? ( ) : null} - {sourceReady && pointCloudOverlay ? ( + {overlaysPresented && pointCloudOverlay ? ( ) : null} - {sourceReady ? ( + {overlaysPresented ? ( { if (!validRange(range) || !Number.isFinite(next.currentSeconds)) return; - // The canonical LAB host clock is authoritative. Native media callbacks - // are observational only in this mode: a stalled decoder must never stop - // the common timeline or let an independently playing spatial view drift. + // Animation-clock mode is retained only for non-media diagnostics. A + // recorded LAB with video uses the external media clock so spatial and + // overlays never advance past the frame the decoder actually presented. if (clock === "animation") return; setPlayback((current) => synchronizeRecordedEvidencePlayback(current, next, range)); }, [clock, range]); diff --git a/apps/control-station/src/core/laboratory/canonicalRecordedLab.ts b/apps/control-station/src/core/laboratory/canonicalRecordedLab.ts index a028eab..ef49ca0 100644 --- a/apps/control-station/src/core/laboratory/canonicalRecordedLab.ts +++ b/apps/control-station/src/core/laboratory/canonicalRecordedLab.ts @@ -1,5 +1,5 @@ export const CANONICAL_RECORDED_LAB_TGS_HISTORY_SECONDS = 1; -export const CANONICAL_RECORDED_LAB_SPATIAL_PROFILE = "source-paced-ground-v2"; +export const CANONICAL_RECORDED_LAB_SPATIAL_PROFILE = "source-paced-ground-v3"; export interface CanonicalRecordedLabPackedCellEvidence { centersBodyXyM: Float32Array; diff --git a/apps/control-station/src/core/laboratory/canonicalRecordedLabSpatial.ts b/apps/control-station/src/core/laboratory/canonicalRecordedLabSpatial.ts index 25d2e0a..abdb628 100644 --- a/apps/control-station/src/core/laboratory/canonicalRecordedLabSpatial.ts +++ b/apps/control-station/src/core/laboratory/canonicalRecordedLabSpatial.ts @@ -15,7 +15,7 @@ export interface CanonicalRecordedLabSpatialFrame { coordinateFrame: "body-ground"; sensorHeight: { meters: number; - source: "initial-source-cloud-lower-quantile-median"; + source: "local-source-cloud-ground-quantile-median" | "session-source-cloud-fallback"; sampleCount: number; madM: number; authority: "visual-derived"; @@ -129,7 +129,7 @@ export async function fetchCanonicalRecordedLabSpatialFrame( const payload = objectValue(await response.json(), "canonical_lab.spatial_frame"); exact( payload.schema_version, - "missioncore.canonical-recorded-lab-spatial-frame/v2", + "missioncore.canonical-recorded-lab-spatial-frame/v3", "canonical_lab.spatial_frame.schema_version", ); exact(payload.coordinate_frame, "body-ground", "canonical_lab.spatial_frame.coordinate_frame"); @@ -179,11 +179,14 @@ export async function fetchCanonicalRecordedLabSpatialFrame( ); } const sensorHeight = objectValue(payload.sensor_height, "canonical_lab.spatial_frame.sensor_height"); - exact( - sensorHeight.source, - "initial-source-cloud-lower-quantile-median", - "canonical_lab.spatial_frame.sensor_height.source", - ); + if ( + sensorHeight.source !== "local-source-cloud-ground-quantile-median" + && sensorHeight.source !== "session-source-cloud-fallback" + ) { + throw new CanonicalRecordedLabSpatialContractError( + "canonical_lab.spatial_frame.sensor_height.source: контракт изменён.", + ); + } exact( sensorHeight.authority, "visual-derived", @@ -210,7 +213,7 @@ export async function fetchCanonicalRecordedLabSpatialFrame( coordinateFrame: "body-ground", sensorHeight: { meters: numberValue(sensorHeight.meters, "canonical_lab.spatial_frame.sensor_height.meters"), - source: "initial-source-cloud-lower-quantile-median", + source: sensorHeight.source, sampleCount: integerValue( sensorHeight.sample_count, "canonical_lab.spatial_frame.sensor_height.sample_count", diff --git a/apps/control-station/src/core/laboratory/m4ReplayThreat.ts b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts index 284ba5d..4537d8b 100644 --- a/apps/control-station/src/core/laboratory/m4ReplayThreat.ts +++ b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts @@ -154,6 +154,9 @@ export interface M4ThreatTimelineFrame { pointCloudSourceCount: number; pointCloudSampleCount: number; pointCloudLayer: "current-increment"; + localSlamBodyXyzM?: readonly M4Point3[]; + localSlamSourceFrameCount?: number; + localSlamSourcePointCount?: number; cameraProjectedPointsXyd: readonly (readonly [number, number, number])[]; cameraProjectedSourceCount: number; cameraProjectedPointCount: number; @@ -168,10 +171,11 @@ export interface M4ThreatTimelineFrame { export interface M4ThreatTimeline { resultId: string; - recordedSourceSessionId: "20260720T065719Z_viewer_live"; + recordedSourceSessionId: string; + recordedSourceId: string; imageWidth: 800; imageHeight: 600; - frameCount: 4489; + frameCount: number; frameTimesNs: readonly number[]; timelineStartSeconds: number; timelineEndSeconds: number; @@ -704,12 +708,8 @@ export async function fetchM4ThreatTimeline( exact(payload.result_id, result, "M4.6 timeline result"); exact(payload.authority, "replay-simulated", "M4.6 timeline authority"); const recorded = object(payload.recorded_source, "M4.6 recorded source"); - exact( - recorded.session_id, - "20260720T065719Z_viewer_live", - "M4.6 recorded session", - ); - exact(recorded.source_id, "RAVNOVES00", "M4.6 recorded source id"); + const recordedSessionId = text(recorded.session_id, "M4.6 recorded session"); + const recordedSourceId = text(recorded.source_id, "M4.6 recorded source id"); exact( recorded.representation_id, "registered-map-increment-v1", @@ -720,7 +720,10 @@ export async function fetchM4ThreatTimeline( "host-arrival-best-effort", "M4.6 recorded synchronization", ); - const frameCount = exact(payload.frame_count, 4489, "M4.6 timeline frame count"); + const frameCount = integer(payload.frame_count, "M4.6 timeline frame count"); + if (frameCount < 1) { + throw new M4ThreatContractError("M4.6 timeline frame count: пустой timeline."); + } const frameTimesNs = array(payload.frame_times_ns, "M4.6 timeline index").map( (value) => integer(value, "M4.6 timeline time"), ); @@ -738,7 +741,8 @@ export async function fetchM4ThreatTimeline( ); return { resultId: result, - recordedSourceSessionId: "20260720T065719Z_viewer_live", + recordedSourceSessionId: recordedSessionId, + recordedSourceId, imageWidth: exact(payload.image_width, 800, "M4.6 image width"), imageHeight: exact(payload.image_height, 600, "M4.6 image height"), frameCount, @@ -1334,6 +1338,17 @@ function parseTimelineFrame( "current-increment", "M4.6 timeline point layer", ), + localSlamBodyXyzM: item.local_slam_body_xyz_m === undefined + ? [] + : array(item.local_slam_body_xyz_m, "M4.6 local SLAM points").map( + (point) => vector(point, 3, "M4.6 local SLAM point") as [number, number, number], + ), + localSlamSourceFrameCount: item.local_slam_source_frame_count === undefined + ? undefined + : integer(item.local_slam_source_frame_count, "M4.6 local SLAM source frames"), + localSlamSourcePointCount: item.local_slam_source_point_count === undefined + ? undefined + : integer(item.local_slam_source_point_count, "M4.6 local SLAM source points"), cameraProjectedPointsXyd: item.camera_projected_points_xyd === undefined ? [] : array(item.camera_projected_points_xyd, "M4.6 camera points").map( diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx index b41ee6f..950b2c1 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -153,6 +153,7 @@ export interface M4ReplayClassifiedSpatialLayer { label: string; pointLayerLabel: string; cellLayerLabel: string; + cellLayerAvailable?: boolean; expectedAtSequence: boolean; frame: M4ReplayClassifiedSpatialFrame | null; loading: boolean; @@ -178,6 +179,8 @@ export function M4ReplayThreatVisual({ classifiedSpatialLayer, showReferenceMediaLayers = true, showSpatialOverlaySummary = true, + playbackTransport = "epoch-stream", + recoverTimestampStalls = false, onActiveSequenceChange, }: { resultId: string; @@ -194,6 +197,8 @@ export function M4ReplayThreatVisual({ classifiedSpatialLayer?: M4ReplayClassifiedSpatialLayer; showReferenceMediaLayers?: boolean; showSpatialOverlaySummary?: boolean; + playbackTransport?: "segmented" | "epoch-stream"; + recoverTimestampStalls?: boolean; onActiveSequenceChange?: (sequence: number | null) => void; }) { const { @@ -256,7 +261,7 @@ export function M4ReplayThreatVisual({ showMediaSemantic: Boolean(activeSemantic) && showMediaSemantic, showSpatialSemantic: Boolean(activeSpatialSemantic) && showSpatialSemantic, showMediaPoints, - classifiedSpatialMode: !classifiedSpatialLayer + classifiedSpatialMode: !classifiedSpatialLayer || classifiedSpatialLayer.cellLayerAvailable === false ? "none" : classifiedSpatialLayer.replacePointCloud ? "replace-source" @@ -278,7 +283,7 @@ export function M4ReplayThreatVisual({ endSeconds: metadata.timeline.timelineEndSeconds, }) : null, [metadata.timeline]); const playbackController = useRecordedEvidencePlayback(playbackRange, { - clock: "animation", + clock: "external", }); const seekPlayback = playbackController.seek; const setPlaybackPlaying = playbackController.setPlaying; @@ -356,11 +361,19 @@ export function M4ReplayThreatVisual({ useEffect(() => { lastSpatialFrameRef.current = null; }, [evidenceDemand.sourceSpatialPoints, resultId]); - if (frame?.spatialAvailable) { - lastSpatialFrameRef.current = { resultId, frame }; + const latestAvailableSpatialFrame = [...timelineFrame.availableFrames] + .reverse() + .find((candidate) => ( + candidate.spatialAvailable + && (timelineFrame.activeSequence === null + || candidate.sequence <= timelineFrame.activeSequence) + )) ?? null; + const currentSpatialFrame = frame?.spatialAvailable ? frame : latestAvailableSpatialFrame; + if (currentSpatialFrame) { + lastSpatialFrameRef.current = { resultId, frame: currentSpatialFrame }; } - const spatialFrame = frame?.spatialAvailable - ? frame + const spatialFrame = currentSpatialFrame + ? currentSpatialFrame : lastSpatialFrameRef.current?.resultId === resultId ? lastSpatialFrameRef.current.frame : null; @@ -541,14 +554,20 @@ export function M4ReplayThreatVisual({ const activeSpatialFrame = spatialFrame?.sequence === timelineFrame.activeSequence ? spatialFrame : null; - const classifiedSpatialFrame = classifiedSpatialLayer?.frame?.sourceSequence === timelineFrame.activeSequence + const hasClassifiedSpatialOutput = Boolean( + classifiedSpatialLayer && classifiedSpatialLayer.cellLayerAvailable !== false, + ); + const classifiedSpatialFrame = hasClassifiedSpatialOutput + && classifiedSpatialLayer?.frame?.sourceSequence === timelineFrame.activeSequence ? classifiedSpatialLayer?.frame ?? null : null; const lastClassifiedSpatialFrameRef = useRef<{ resultId: string; frame: M4ReplayClassifiedSpatialFrame; } | null>(null); - const incomingClassifiedSpatialFrame = classifiedSpatialLayer?.frame ?? null; + const incomingClassifiedSpatialFrame = hasClassifiedSpatialOutput + ? classifiedSpatialLayer?.frame ?? null + : null; if (incomingClassifiedSpatialFrame && incomingClassifiedSpatialFrame.sampleAvailable !== false) { lastClassifiedSpatialFrameRef.current = { resultId, frame: incomingClassifiedSpatialFrame }; } @@ -577,7 +596,9 @@ export function M4ReplayThreatVisual({ ? spatialFrame : null) : null; - const replaceClassifiedPointCloud = classifiedSpatialLayer?.replacePointCloud ?? true; + const replaceClassifiedPointCloud = hasClassifiedSpatialOutput + ? classifiedSpatialLayer?.replacePointCloud ?? true + : false; const nominalSensorHeightM = metadata.timeline?.rig.nominalSensorHeightM ?? 0; const mapGravityLocalSensorToBodyGround = useCallback(( point: readonly [number, number, number], @@ -695,7 +716,12 @@ export function M4ReplayThreatVisual({ .map((item) => item.assessment.closestApproachM) .filter((value): value is number => value !== null) .sort((left, right) => left - right)[0] ?? null; - const localSurface = useMemo(() => buildM4LocalSurface( + const localSurface = useMemo(() => spatialFrame?.localSlamBodyXyzM?.length ? ({ + pointsBodyXyzM: spatialFrame.localSlamBodyXyzM, + sourceFrameCount: spatialFrame.localSlamSourceFrameCount ?? 0, + sourcePointCount: spatialFrame.localSlamSourcePointCount ?? 0, + voxelCount: spatialFrame.localSlamBodyXyzM.length, + }) : buildM4LocalSurface( timelineFrame.availableFrames, spatialFrame, metadata.timeline?.localSurfaceVisualization ?? { @@ -843,16 +869,24 @@ export function M4ReplayThreatVisual({ shape="pill" variant={showRollingMap ? "primary" : "secondary"} aria-pressed={showRollingMap} + disabled={classifiedSpatialLayer.cellLayerAvailable === false} + title={classifiedSpatialLayer.cellLayerAvailable === false + ? `${classifiedSpatialLayer.cellLayerLabel} недоступен: для этой записи нет запечатанного полного результата` + : undefined} onClick={() => setShowRollingMap((visible) => !visible)} > {classifiedSpatialLayer.cellLayerLabel} - {semanticSpatialResultId ? ( + {activeSpatialSemantic ? ( ) : null} - {semanticSpatialResultId ? ( + {activeSpatialSemantic ? ( - { - setSemanticLayer(value); - setShowCameraSemantic(true); - }} - /> -
- ); - - const spatialLayerControls = ( -
- - - - -
- ); - - const resetSpatialView = ( - metricSceneRef.current?.resetView()} - > - - - ); - - const overlayPanePercent = splitView && splitOrientation === "vertical" - ? splitPrimarySize - : 100; - const overlay = ( -
-
- RAVNOVES004TREE · recorded realtime - frame {sequence}/{review.frameCount} - - +{(playbackController.playback.currentSeconds - review.timelineStartSeconds).toFixed(3)} с - · {playbackController.playback.playing ? "воспроизведение" : "пауза / seek"} - -
-
- Spatial evidence - {showTgs && selectedTgsCase && tgsWithinEvidenceWindow - ? `TGS anchor ${selectedTgsCase.sourceSequence} · ${selectedTgsCase.tgs.occupiedCells} occupied` - : "source RRD · points + bounded Local SLAM"} - {showTgs - ? "TGS visible only inside sealed 1 s evidence window · playback retained" - : "5 s bounded Local SLAM · ground-rebased recorded source"} -
-
- ); - - const transport = ( - playbackController.seek(timeNs / 1_000_000_000)} - onPlayingChange={playbackController.setPlaying} - onPlaybackRateChange={playbackController.setRate} - showJumpToEnd={false} - /> - ); + const semanticLayers = useMemo(() => ([ + { + id: "city", + controlLabel: "ГОРОД · EoMT", + resultId, + spatialResultId: null, + taxonomy: review.city.taxonomy, + maskUrl: (sequence) => vegetationFullRouteMaskUrl(resultId, "city", sequence), + label: review.city.name, + maskAriaLabel: "EoMT city semantic prediction", + }, + { + id: "vegetation", + controlLabel: "ПРИРОДА · DDRNet", + resultId, + spatialResultId: null, + taxonomy: review.vegetation.taxonomy, + maskUrl: (sequence) => vegetationFullRouteMaskUrl(resultId, "vegetation", sequence), + label: review.vegetation.name, + maskAriaLabel: "DDRNet nature semantic prediction", + }, + ]), [resultId, review.city, review.vegetation]); + const sealedSpatialGap = useMemo(() => ({ + label: "RAVNOVES004TREE", + pointLayerLabel: "SOURCE POINTS", + cellLayerLabel: "TGS COSTMAP", + cellLayerAvailable: false, + expectedAtSequence: false, + frame: null, + loading: false, + error: null, + replacePointCloud: false, + }), []); return ( - ); } @@ -575,32 +101,32 @@ function FullRouteReviewResult({ summary={( @@ -617,19 +143,19 @@ function FullRouteReviewResult({ )} result={( )} @@ -754,27 +280,9 @@ export function VegetationShadowResultView({ executionClass: "ai-inference", pipelineId: "ravnoves-eomt-ddrnet-yolox-causal-tgs-recorded-review/v1", components: [ - { - kind: "model", - name: "EoMT Cityscapes semantic", - version: "sealed E47 archive", - role: "urban semantic review", - identitySha256: null, - }, - { - kind: "model", - name: selected.loadedModelName, - version: selected.candidate, - role: "vegetation material candidate", - identitySha256: selected.checkpointSha256, - }, - { - kind: "algorithm", - name: "Frozen YOLOX + causal TGS", - version: "linked M4/M4.9 archives", - role: "independent object and geometry veto", - identitySha256: null, - }, + { kind: "model", name: "EoMT Cityscapes semantic", version: "sealed E47 archive", role: "urban semantic review", identitySha256: null }, + { kind: "model", name: selected.loadedModelName, version: selected.candidate, role: "vegetation material candidate", identitySha256: selected.checkpointSha256 }, + { kind: "algorithm", name: "Frozen YOLOX + causal TGS", version: "linked M4/M4.9 archives", role: "independent object and geometry veto", identitySha256: null }, ], }} /> @@ -795,26 +303,10 @@ export function VegetationShadowResultView({ status="Semantics advisory · YOLOX/TGS veto cannot be cleared" statusTone="warning" metrics={[ - { - label: "Route masks", - value: `${route.frameCount}/${route.frameCount}`, - hint: "sealed local playback · Worker для открытия не нужен", - }, - { - label: "Semantic sources", - value: "2 independent layers", - hint: "EoMT CITY / DDRNet VEGETATION · display switches, evidence does not fuse", - }, - { - label: "Vegetation worker p95", - value: `${decimal(selected.shadowLatencyP95Ms, 2)} ms`, - hint: "изолированный DDRNet inference; не совместный realtime stack", - }, - { - label: "Vegetation peak VRAM", - value: `${decimal(selected.peakReservedVramBytes / 1024 ** 3, 2)} GiB`, - hint: "DDRNet candidate на Worker 006", - }, + { label: "Route masks", value: `${route.frameCount}/${route.frameCount}`, hint: "sealed local playback · Worker для открытия не нужен" }, + { label: "Semantic sources", value: "2 independent layers", hint: "EoMT CITY / DDRNet VEGETATION · display switches, evidence does not fuse" }, + { label: "Vegetation worker p95", value: `${decimal(selected.shadowLatencyP95Ms, 2)} ms`, hint: "изолированный DDRNet inference; не совместный realtime stack" }, + { label: "Vegetation peak VRAM", value: `${decimal(selected.peakReservedVramBytes / 1024 ** 3, 2)} GiB`, hint: "DDRNet candidate на Worker 006" }, ]} conclusion={{ proved: "На одной recorded timeline доступны городской EoMT, природный DDRNet, YOLOX detections и causal TGS; LAB автономна от Worker.", diff --git a/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts b/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts index bcb686f..0d6f08b 100644 --- a/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts +++ b/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts @@ -18,6 +18,7 @@ import { const REQUESTED_CHUNK_FRAMES = 24; const RETAINED_CHUNK_COUNT = 4; +const RETAINED_CHUNKS_BEHIND = 1; const PREFETCH_CHUNKS_AHEAD = 1; const RETAINED_CAMERA_POINT_OVERLAYS = 12; @@ -31,10 +32,17 @@ export function m4ThreatChunkWindowStarts( frameCount: number, ): readonly number[] { if (chunkSize < 1 || frameCount < 1) return []; - return Array.from( - { length: PREFETCH_CHUNKS_AHEAD + 1 }, - (_, index) => activeChunkStart + index * chunkSize, - ).filter((start) => start >= 0 && start < frameCount); + return [ + activeChunkStart, + ...Array.from( + { length: RETAINED_CHUNKS_BEHIND }, + (_, index) => activeChunkStart - (index + 1) * chunkSize, + ), + ...Array.from( + { length: PREFETCH_CHUNKS_AHEAD }, + (_, index) => activeChunkStart + (index + 1) * chunkSize, + ), + ].filter((start) => start >= 0 && start < frameCount); } export function cancelM4ThreatChunkRequestsOutsideWindow( diff --git a/apps/control-station/test/m4ReplayThreat.test.mjs b/apps/control-station/test/m4ReplayThreat.test.mjs index 21f932d..c54aa3b 100644 --- a/apps/control-station/test/m4ReplayThreat.test.mjs +++ b/apps/control-station/test/m4ReplayThreat.test.mjs @@ -769,7 +769,7 @@ test("M4.6 local SLAM surface reprojects registered increments into the active b }); test("M4.6 spatial buffering keeps the active and one future chunk", () => { - assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 72]); + assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 24, 72]); assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24]); }); @@ -786,8 +786,8 @@ test("M4.6 spatial buffering drops stale in-flight windows across rapid jumps", if (!inFlight.has(start)) inFlight.set(start, controller(start)); } } - assert.deepEqual([...inFlight.keys()], [4488]); - assert.deepEqual(aborted, [0, 24, 1488, 1512]); + assert.deepEqual([...inFlight.keys()], [4488, 4464]); + assert.deepEqual(aborted, [0, 24, 1488, 1464, 1512]); }); test("recorded evidence clock advances by selected rate and stops at the sealed end", () => { @@ -863,7 +863,7 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async () assert.match(canonical, /m4-replay-threat-visual__deck/); assert.match(visual, /lastFrameRef/); assert.match(visual, /lastSpatialFrameRef/); - assert.match(visual, /const spatialFrame = frame\?\.spatialAvailable/); + assert.match(visual, /const spatialFrame = currentSpatialFrame/); assert.match(visual, / 0\.35/); assert.match(imageScene, / { recordedMediaSegmentAppendOrder, recordedMediaSegmentSequenceAtTime, recordedMediaCanRollTarget, + recordedMediaTimestampStallRecoveryTarget, nextRecordedMediaRandomAccessSequence, recordedMediaRecoveryTargetSequence, selectRecordedMediaPreparationEpoch, @@ -270,6 +272,12 @@ test("recorded player preserves forward rolling playback but seeks backward clip assert.equal(recordedMediaCanRollTarget(20, 21, true, false), false); }); +test("recorded player skips only a proven buffered corrupt timestamp interval", () => { + assert.equal(recordedMediaTimestampStallRecoveryTarget(11.422, [[0, 16.287]]), 11.602); + assert.equal(recordedMediaTimestampStallRecoveryTarget(16.25, [[0, 16.287]]), null); + assert.equal(recordedMediaTimestampStallRecoveryTarget(20, [[0, 16.287]]), null); +}); + test("loading and error overlays fully conceal recorded camera pixels", async () => { const css = await readFile( new URL("../src/styles/observation.css", import.meta.url), diff --git a/apps/control-station/test/vegetationShadow.test.mjs b/apps/control-station/test/vegetationShadow.test.mjs index e15bd2e..ab7f39f 100644 --- a/apps/control-station/test/vegetationShadow.test.mjs +++ b/apps/control-station/test/vegetationShadow.test.mjs @@ -407,7 +407,7 @@ test("canonical recorded LAB spatial frame keeps source, SLAM and body identity fetcher: async (url) => { requestedUrl = String(url); return new Response(JSON.stringify({ - schema_version: "missioncore.canonical-recorded-lab-spatial-frame/v2", + schema_version: "missioncore.canonical-recorded-lab-spatial-frame/v3", target_time_ns: 82_770_000_000, source_time_ns: 82_769_535_708, pose_time_ns: 82_769_535_708, @@ -415,13 +415,13 @@ test("canonical recorded LAB spatial frame keeps source, SLAM and body identity coordinate_frame: "body-ground", sensor_height: { meters: 0.32, - source: "initial-source-cloud-lower-quantile-median", + source: "local-source-cloud-ground-quantile-median", sample_count: 20, mad_m: 0.03, authority: "visual-derived", }, spatial_profile: { - profile_id: "source-paced-ground-v2", + profile_id: "source-paced-ground-v3", local_slam_history_seconds: 5, local_slam_radius_m: 30, local_slam_voxel_size_m: 0.12, @@ -443,7 +443,7 @@ test("canonical recorded LAB spatial frame keeps source, SLAM and body identity }); assert.equal( requestedUrl, - `/api/v1/observation-sessions/session-004/canonical-lab/spatial-frame?generation=${generation}&time_ns=82770000000&profile=source-paced-ground-v2`, + `/api/v1/observation-sessions/session-004/canonical-lab/spatial-frame?generation=${generation}&time_ns=82770000000&profile=source-paced-ground-v3`, ); assert.equal(frame.sourcePointCount, 2); assert.equal(frame.localSlamBodyXyzM.length, 2); @@ -473,32 +473,23 @@ test("vegetation realtime LAB and archival benchmark use separate admitted instr assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/); assert.match(resultSource, /M49TgsFullShadowEvidence/); assert.match(resultSource, /semanticOverride/); - assert.match(resultSource, /EoMT CITY \/ DDRNet VEGETATION/); assert.match(m49Source, /spatialSemantic=\{spatialSemantic\}/); assert.match(m49Source, /controlLabel: "SEMANTICS"/); assert.equal(resultSource.match(/ setShowTgs\(\(visible\) => !visible\)\}/); - assert.match(resultSource, /showClassifiedCells=\{showTgs && Boolean\(packedTgsCells\)\}/); - assert.doesNotMatch(resultSource, /setShowTgs\(false\)/); - assert.doesNotMatch(resultSource, /setPlaying\(false\);[\s\S]{0,160}setShowTgs/); + assert.match(resultSource, /point-aligned 3D semantics пока не запечатаны/); + assert.match(resultSource, /cellLayerAvailable: false/); assert.match(canonicalSource, /primary=\{mediaPane\}/); assert.match(canonicalSource, /secondary=\{spatialPane/); assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/); diff --git a/docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_QA.jpg b/docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_QA.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c18127afed5a4cedcd06ca06f966ea99cb2c3b1c GIT binary patch literal 87296 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

HkSvFCx_G6P<-K0q+k(1SYJ{n(EfR&tGcwMi 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 +- Rerun transform relation: +- Rerun transforms: +- Rerun Transform3D: +- WHATWG media element model: +- W3C Media Source Extensions: diff --git a/src/k1link/sessions/canonical_lab_spatial.py b/src/k1link/sessions/canonical_lab_spatial.py index cb414c1..50a7c12 100644 --- a/src/k1link/sessions/canonical_lab_spatial.py +++ b/src/k1link/sessions/canonical_lab_spatial.py @@ -5,7 +5,7 @@ transport. This adapter reads the immutable recording once, indexes the recorded source cloud and sensor pose, estimates the session sensor height from the initial stationary cloud, and returns both the current increment and a bounded accumulated local-SLAM cloud in a ground-rebased body frame. Camera, -spatial layers and the common timeline can therefore be driven by one host +spatial layers and the common timeline can therefore be driven by one media clock without a per-LAB coordinate adapter. """ @@ -21,7 +21,7 @@ from typing import Any, Final import numpy as np import rerun_bindings as rr_bindings -CANONICAL_LAB_SPATIAL_PROFILE: Final = "source-paced-ground-v2" +CANONICAL_LAB_SPATIAL_PROFILE: Final = "source-paced-ground-v3" _POINT_ENTITY: Final = "/world/points" _POSE_ENTITY: Final = "/world/sensor_pose" _TRAJECTORY_ENTITY: Final = "/world/trajectory" @@ -35,11 +35,15 @@ _HEIGHT_CALIBRATION_MAX_FRAMES: Final = 120 _HEIGHT_NEAR_MIN_RADIUS_M: Final = 1.0 _HEIGHT_NEAR_MAX_RADIUS_M: Final = 6.0 _HEIGHT_LOWER_QUANTILE: Final = 0.025 +_LOCAL_HEIGHT_QUANTILE: Final = 0.10 +_LOCAL_HEIGHT_HALF_WINDOW_SECONDS: Final = 1.0 _LOCAL_SLAM_HISTORY_SECONDS: Final = 5.0 _LOCAL_SLAM_RADIUS_M: Final = 30.0 _LOCAL_SLAM_VERTICAL_LIMIT_M: Final = 6.0 _LOCAL_SLAM_VOXEL_SIZE_M: Final = 0.12 _LOCAL_SLAM_POINT_LIMIT: Final = 27_000 +_FORWARD_HALF_WINDOW_SECONDS: Final = 1.0 +_FORWARD_MINIMUM_DISPLACEMENT_M: Final = 0.15 @dataclass(frozen=True) @@ -233,6 +237,67 @@ def _map_points_to_body( return body.astype(np.float32) +def _gravity_stable_basis_map_from_body( + poses: _TimedPoses, + target_time_ns: int, +) -> tuple[np.ndarray, str]: + """Return a right-handed forward/left/up base frame in the RFU map. + + Rerun declares this recording map as RFU, while the metric LAB scene + consumes points as forward/left/up. The LiDAR quaternion columns are sensor + right/forward/up and also contain rover or handheld roll/pitch, so they are + not a body basis. Route displacement owns yaw when available; the sensor's + local +Y (Rerun Forward) projected onto map gravity is the stationary + fallback. Map +Z always owns up. + """ + + center = _latest_index(poses.times_ns, target_time_ns) + half_window_ns = round(_FORWARD_HALF_WINDOW_SECONDS * 1_000_000_000) + first = _latest_index(poses.times_ns, max(0, target_time_ns - half_window_ns)) + last = min( + len(poses.times_ns) - 1, + max(0, bisect_right(poses.times_ns, target_time_ns + half_window_ns) - 1), + ) + route = poses.translations[last] - poses.translations[first] + route_xy = np.asarray([route[0], route[1], 0.0], dtype=np.float64) + route_norm = float(np.linalg.norm(route_xy)) + + sensor_rotation = _rotation_map_from_body(poses.quaternions_xyzw[center]) + sensor_forward = np.asarray( + [sensor_rotation[0, 1], sensor_rotation[1, 1], 0.0], + dtype=np.float64, + ) + sensor_forward_norm = float(np.linalg.norm(sensor_forward)) + if sensor_forward_norm <= 1e-9: + raise ValueError("Recorded LAB sensor forward axis is invalid") + sensor_forward /= sensor_forward_norm + + if route_norm >= _FORWARD_MINIMUM_DISPLACEMENT_M: + forward = route_xy / route_norm + if float(np.dot(forward, sensor_forward)) < 0.0: + forward = -forward + forward_source = "smoothed-pose-trajectory-tangent" + else: + forward = sensor_forward + forward_source = "rerun-rfu-sensor-forward-fallback" + + up = np.asarray([0.0, 0.0, 1.0], dtype=np.float64) + left = np.cross(up, forward) + left_norm = float(np.linalg.norm(left)) + if left_norm <= 1e-9: + raise ValueError("Recorded LAB body left axis is invalid") + left /= left_norm + forward = np.cross(left, up) + forward /= float(np.linalg.norm(forward)) + basis = np.column_stack((forward, left, up)) + if ( + not np.allclose(basis.T @ basis, np.eye(3), atol=1e-7) + or np.linalg.det(basis) < 0.999999 + ): + raise ValueError("Recorded LAB gravity-stable body basis is invalid") + return basis, forward_source + + def _estimate_sensor_height(points: _TimedPoints, poses: _TimedPoses) -> tuple[float, int, float]: """Estimate one session mount height from the initial qualified cloud. @@ -253,17 +318,13 @@ def _estimate_sensor_height(points: _TimedPoints, poses: _TimedPoses) -> tuple[f estimates: list[float] = [] for point_index in candidates: pose_index = _latest_index(poses.times_ns, points.times_ns[point_index]) - body = _map_points_to_body( - points.values[point_index], - poses.translations[pose_index], - poses.quaternions_xyzw[pose_index], - ) - radius = np.linalg.norm(body[:, :2], axis=1) - eligible = body[ + delta = points.values[point_index].astype(np.float64) - poses.translations[pose_index] + radius = np.linalg.norm(delta[:, :2], axis=1) + eligible = delta[ (radius >= _HEIGHT_NEAR_MIN_RADIUS_M) & (radius <= _HEIGHT_NEAR_MAX_RADIUS_M) - & (body[:, 2] >= -2.0) - & (body[:, 2] <= 0.5) + & (delta[:, 2] >= -2.0) + & (delta[:, 2] <= 0.5) ] if eligible.shape[0] < 100: continue @@ -278,12 +339,55 @@ def _estimate_sensor_height(points: _TimedPoints, poses: _TimedPoses) -> tuple[f return height, len(estimates), mad +def _estimate_local_sensor_height( + points: _TimedPoints, + poses: _TimedPoses, + target_time_ns: int, + fallback_height_m: float, +) -> tuple[float, int, float, str]: + """Estimate the current gravity-axis height without a fixed camera mount. + + RAVNOVES004TREE changes sensor height during the route. A session-wide + constant therefore moves the scene vertically whenever the operator raises + or lowers K1. Use a short source-time window and a conservative near-field + ground quantile; fall back to the sealed session calibration only when the + current cloud has insufficient support. + """ + + half_window_ns = round(_LOCAL_HEIGHT_HALF_WINDOW_SECONDS * 1_000_000_000) + first = bisect_right(points.times_ns, max(0, target_time_ns - half_window_ns) - 1) + last = bisect_right(points.times_ns, target_time_ns + half_window_ns) + estimates: list[float] = [] + for point_index in range(first, last): + pose_index = _latest_index(poses.times_ns, points.times_ns[point_index]) + delta = points.values[point_index].astype(np.float64) - poses.translations[pose_index] + radius = np.linalg.norm(delta[:, :2], axis=1) + eligible = delta[ + (radius >= _HEIGHT_NEAR_MIN_RADIUS_M) + & (radius <= _HEIGHT_NEAR_MAX_RADIUS_M) + & (delta[:, 2] >= -2.5) + & (delta[:, 2] <= 0.5) + ] + if eligible.shape[0] < 100: + continue + estimate = -float(np.quantile(eligible[:, 2], _LOCAL_HEIGHT_QUANTILE)) + if 0.03 <= estimate <= 2.5: + estimates.append(estimate) + if not estimates: + return fallback_height_m, 0, 0.0, "session-source-cloud-fallback" + values = np.asarray(estimates, dtype=np.float64) + height = float(np.median(values)) + mad = float(np.median(np.abs(values - height))) + return height, len(estimates), mad, "local-source-cloud-ground-quantile-median" + + def _ground_origin_map( sensor_origin_map: np.ndarray, - basis_map_from_body: np.ndarray, sensor_height_m: float, ) -> np.ndarray: - return sensor_origin_map - basis_map_from_body[:, 2] * sensor_height_m + # The calibrated height belongs to the map gravity axis. Sensor roll/pitch + # must never tilt the ground origin or the accumulated world cloud. + return sensor_origin_map - np.asarray([0.0, 0.0, sensor_height_m]) def _map_points_to_ground_body( @@ -329,32 +433,29 @@ def _bounded_local_slam( return np.ascontiguousarray(local, dtype=np.float32), len(selected), source_count -def canonical_lab_spatial_frame( - recording_path: Path, - generation_sha256: str, +def _canonical_lab_spatial_frame_from_index( + index: _CanonicalSpatialIndex, target_time_ns: int, ) -> dict[str, object]: - """Return the current source cloud and bounded Local SLAM on one host time.""" - - if target_time_ns < 0: - raise ValueError("Recorded LAB target time is invalid") - stat = recording_path.stat() - index = _load_index( - str(recording_path), - stat.st_size, - stat.st_mtime_ns, - generation_sha256, - ) point_index = _latest_index(index.points.times_ns, target_time_ns) pose_index = _latest_index(index.poses.times_ns, index.points.times_ns[point_index]) trajectory_index = _latest_index(index.trajectories.times_ns, target_time_ns) translation = index.poses.translations[pose_index] - quaternion = index.poses.quaternions_xyzw[pose_index] - basis_map_from_body = _rotation_map_from_body(quaternion) + sensor_height_m, sensor_height_sample_count, sensor_height_mad_m, height_source = ( + _estimate_local_sensor_height( + index.points, + index.poses, + index.points.times_ns[point_index], + index.sensor_height_m, + ) + ) + basis_map_from_body, forward_source = _gravity_stable_basis_map_from_body( + index.poses, + index.points.times_ns[point_index], + ) ground_origin = _ground_origin_map( translation, - basis_map_from_body, - index.sensor_height_m, + sensor_height_m, ) points_body = _map_points_to_ground_body( index.points.values[point_index], @@ -368,17 +469,18 @@ def canonical_lab_spatial_frame( basis_map_from_body, ) return { - "schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v2", + "schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v3", "target_time_ns": target_time_ns, "source_time_ns": index.points.times_ns[point_index], "pose_time_ns": index.poses.times_ns[pose_index], "trajectory_time_ns": index.trajectories.times_ns[trajectory_index], "coordinate_frame": "body-ground", "sensor_height": { - "meters": index.sensor_height_m, - "source": "initial-source-cloud-lower-quantile-median", - "sample_count": index.sensor_height_sample_count, - "mad_m": index.sensor_height_mad_m, + "meters": sensor_height_m, + "source": height_source, + "sample_count": sensor_height_sample_count, + "mad_m": sensor_height_mad_m, + "session_fallback_meters": index.sensor_height_m, "authority": "visual-derived", }, "spatial_profile": { @@ -392,6 +494,8 @@ def canonical_lab_spatial_frame( "origin_map_xyz_m": ground_origin.tolist(), "sensor_origin_map_xyz_m": translation.tolist(), "basis_map_from_body": basis_map_from_body.tolist(), + "up_source": "rerun-rfu-map-gravity-axis", + "forward_source": forward_source, }, "source_point_count": int(points_body.shape[0]), "source_points_body_xyz_m": points_body.tolist(), @@ -400,3 +504,70 @@ def canonical_lab_spatial_frame( "local_slam_point_count": int(local_slam.shape[0]), "local_slam_body_xyz_m": local_slam.tolist(), } + + +def canonical_lab_spatial_frame( + recording_path: Path, + generation_sha256: str, + target_time_ns: int, +) -> dict[str, object]: + """Return the current source cloud and bounded Local SLAM on one media time.""" + + if target_time_ns < 0: + raise ValueError("Recorded LAB target time is invalid") + stat = recording_path.stat() + index = _load_index( + str(recording_path), + stat.st_size, + stat.st_mtime_ns, + generation_sha256, + ) + return _canonical_lab_spatial_frame_from_index(index, target_time_ns) + + +def canonical_lab_spatial_timeline_samples( + recording_path: Path, + generation_sha256: str, + frame_times_ns: tuple[int, ...], + start_sequence: int, + frame_count: int, +) -> tuple[dict[str, object] | None, ...]: + """Project only new source increments onto a denser camera timeline. + + Camera is roughly 10 Hz in RAVNOVES004TREE while the sealed source cloud is + roughly 2 Hz. Returning the same JSON point array for every camera frame + multiplies transfer and parse cost and makes the viewer chase itself. A row + is populated only when its nearest causal source increment changes; the + canonical viewer retains that spatial frame until the next increment. + """ + + if ( + start_sequence < 0 + or frame_count < 1 + or start_sequence >= len(frame_times_ns) + or any(current <= previous for previous, current in zip(frame_times_ns, frame_times_ns[1:])) + ): + raise ValueError("Recorded LAB timeline sample request is invalid") + stat = recording_path.stat() + index = _load_index( + str(recording_path), + stat.st_size, + stat.st_mtime_ns, + generation_sha256, + ) + stop = min(len(frame_times_ns), start_sequence + frame_count) + samples: list[dict[str, object] | None] = [] + for sequence in range(start_sequence, stop): + target_time_ns = frame_times_ns[sequence] + point_index = _latest_index(index.points.times_ns, target_time_ns) + previous_point_index = ( + -1 + if sequence == 0 + else _latest_index(index.points.times_ns, frame_times_ns[sequence - 1]) + ) + samples.append( + _canonical_lab_spatial_frame_from_index(index, target_time_ns) + if point_index != previous_point_index + else None + ) + return tuple(samples) diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index 198b6a0..496a83c 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -360,6 +360,15 @@ def _m48_recorded_camera_playback_source( return session_recorded_camera_frame_service.playback_source(session_id) +def _canonical_lab_recording_source(session_id: str) -> tuple[Path, str] | None: + """Resolve one already-published immutable RRD without starting new work.""" + + snapshot = session_recording_preparation_manager.status(session_id) + if snapshot is None or snapshot.state != "ready" or snapshot.recording is None: + return None + return snapshot.recording.path, snapshot.recording.sha256 + + def refresh_observation_catalog() -> tuple[str, ...]: """Discover completed or recoverable local evidence without copying payloads.""" @@ -1032,6 +1041,12 @@ app.include_router( / "lab-v1-vegetation" / "results" ), + canonical_recording_provider=_canonical_lab_recording_source, + camera_frame_provider=( + session_recorded_camera_frame_service.extract + if session_recorded_camera_frame_service is not None + else None + ), ) ) app.include_router( diff --git a/src/k1link/web/session_api.py b/src/k1link/web/session_api.py index e84f1e0..0322ad7 100644 --- a/src/k1link/web/session_api.py +++ b/src/k1link/web/session_api.py @@ -835,11 +835,11 @@ def build_session_router( session_id: str, generation: Annotated[str, Query(min_length=64, max_length=64)], time_ns: Annotated[int, Query(ge=0, le=MAX_SAFE_INTEGER)], - profile: Literal["source-paced-ground-v2"], + profile: Literal["source-paced-ground-v3"], ) -> JSONResponse: """Serve one body-frame sample for the canonical recorded-LAB clock. - The camera timeline owns playback. Spatial evidence is sampled from + The camera media clock owns playback. Spatial evidence is sampled from the same immutable recording instead of starting a second Rerun clock. """ diff --git a/src/k1link/web/vegetation_shadow_lab_api.py b/src/k1link/web/vegetation_shadow_lab_api.py index bda2885..2d8459a 100644 --- a/src/k1link/web/vegetation_shadow_lab_api.py +++ b/src/k1link/web/vegetation_shadow_lab_api.py @@ -4,7 +4,10 @@ from __future__ import annotations import copy import hashlib +import io import json +import math +import statistics import zipfile from collections.abc import Callable from functools import lru_cache @@ -14,6 +17,7 @@ from typing import Any, Final import numpy as np from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse, JSONResponse, Response +from PIL import Image from k1link.laboratory.evidence_registry import LaboratoryEvidenceDefinition from k1link.laboratory.evidence_report import ( @@ -21,9 +25,14 @@ from k1link.laboratory.evidence_report import ( verify_laboratory_evidence_result, ) from k1link.laboratory.vegetation_shadow_lab import LAB_SCHEMA +from k1link.sessions import RecordedCameraFrame, SessionIntegrityError +from k1link.sessions.canonical_lab_spatial import canonical_lab_spatial_timeline_samples RootProvider = Callable[[], Path | None] +CanonicalRecordingProvider = Callable[[str], tuple[Path, str] | None] +CameraFrameProvider = Callable[[str, int], RecordedCameraFrame] _MAX_DOCUMENT_BYTES: Final = 1024 * 1024 +_CANONICAL_ROUTE_CHUNK_FRAMES: Final = 8 _DEFINITION: Final = LaboratoryEvidenceDefinition( work_id="lab-v1-vegetation-shadow", runtime_relative_root=PurePosixPath("lab-v1-vegetation/results"), @@ -41,12 +50,17 @@ _BENCHMARK_DEFINITION: Final = LaboratoryEvidenceDefinition( def build_vegetation_shadow_lab_router( - *, root_provider: RootProvider = lambda: None, + *, + root_provider: RootProvider = lambda: None, + canonical_recording_provider: CanonicalRecordingProvider | None = None, + camera_frame_provider: CameraFrameProvider | None = None, ) -> APIRouter: return _build_vegetation_lab_router( prefix="/api/v1/laboratory/vegetation-shadow", definition=_DEFINITION, root_provider=root_provider, + canonical_recording_provider=canonical_recording_provider, + camera_frame_provider=camera_frame_provider, ) @@ -65,6 +79,8 @@ def _build_vegetation_lab_router( prefix: str, definition: LaboratoryEvidenceDefinition, root_provider: RootProvider, + canonical_recording_provider: CanonicalRecordingProvider | None = None, + camera_frame_provider: CameraFrameProvider | None = None, ) -> APIRouter: router = APIRouter( prefix=prefix, @@ -263,6 +279,143 @@ def _build_vegetation_lab_router( }, ) + @router.get("/{result_id}/timeline") + def get_canonical_route_timeline(result_id: str) -> dict[str, object]: + candidate = _resolve_candidate(root_provider, definition, result_id) + manifest = _read_verified(candidate, definition) + route, frame_times_ns = _full_route_context(candidate, manifest) + intervals = [ + (current - previous) / 1_000_000_000 + for previous, current in zip(frame_times_ns, frame_times_ns[1:]) + ] + nominal_interval = statistics.median(intervals) + if not math.isfinite(nominal_interval) or nominal_interval <= 0: + raise HTTPException(status_code=503, detail="Full-route timeline cadence is invalid") + return { + "schema_version": "missioncore.recorded-spatial-evidence-timeline/v1", + "result_id": result_id, + "recorded_source": { + "session_id": route["session_id"], + "source_id": route["source_id"], + "representation_id": "registered-map-increment-v1", + "synchronization": "host-arrival-best-effort", + }, + "frame_count": len(frame_times_ns), + "frame_times_ns": list(frame_times_ns), + "timeline_start_seconds": frame_times_ns[0] / 1_000_000_000, + "timeline_end_seconds": frame_times_ns[-1] / 1_000_000_000, + "nominal_frame_interval_seconds": nominal_interval, + "nominal_rate_hz": 1.0 / nominal_interval, + "max_chunk_frames": _CANONICAL_ROUTE_CHUNK_FRAMES, + "point_sample_limit": 100_000, + "maximum_source_points_per_frame": 100_000, + "point_delivery": "exact-current-increment", + "world_state_frame_count": len(frame_times_ns), + "superseded_frame_count": 0, + "local_surface_visualization": { + "derivation": "bounded-registered-increment-accumulation", + "window_seconds": 5.0, + "voxel_size_m": 0.12, + "radius_m": 30.0, + "point_limit": 27_000, + "authority": "visual-derived", + }, + "image_width": route["width"], + "image_height": route["height"], + "rig": {"length_m": 1.0, "width_m": 0.8, "nominal_sensor_height_m": 0.4}, + "corridor": { + "forward_length_m": 8.0, + "rear_margin_m": 0.5, + "occupied_voxel_size_m": 0.45, + "half_width_m": 0.6, + "prediction_horizon_seconds": 8.0, + }, + "ground_truth": False, + "authority": "replay-simulated", + "access": "read-only-bounded-recorded-replay", + } + + @router.get("/{result_id}/timeline/chunk") + def get_canonical_route_timeline_chunk( + result_id: str, + start: int = 0, + count: int = _CANONICAL_ROUTE_CHUNK_FRAMES, + include_points: bool = True, + ) -> dict[str, object]: + if start < 0 or not 1 <= count <= _CANONICAL_ROUTE_CHUNK_FRAMES: + raise HTTPException(status_code=422, detail="Full-route timeline chunk is invalid") + candidate = _resolve_candidate(root_provider, definition, result_id) + manifest = _read_verified(candidate, definition) + route, frame_times_ns = _full_route_context(candidate, manifest) + if start >= len(frame_times_ns): + raise HTTPException(status_code=404, detail="Full-route timeline chunk not found") + if canonical_recording_provider is None: + raise HTTPException(status_code=503, detail="Canonical spatial recording is unavailable") + 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: + samples = canonical_lab_spatial_timeline_samples( + recording_path, + generation_sha256, + frame_times_ns, + start, + count, + ) + except (OSError, ValueError): + raise HTTPException(status_code=503, detail="Canonical spatial chunk failed") from None + stop = start + len(samples) + frames = [ + _canonical_timeline_frame( + result_id=result_id, + endpoint_prefix=prefix, + candidate=candidate, + route=route, + sequence=sequence, + source_time_ns=frame_times_ns[sequence], + spatial=sample, + include_points=include_points, + ) + for sequence, sample in zip(range(start, stop), samples, strict=True) + ] + return { + "schema_version": "missioncore.recorded-spatial-evidence-chunk/v1", + "result_id": result_id, + "start_sequence": start, + "frame_count": len(frames), + "next_sequence": stop if stop < len(frame_times_ns) else None, + "frames": frames, + "ground_truth": False, + "authority": "replay-simulated", + "access": "read-only-bounded-recorded-replay", + } + + @router.get("/{result_id}/timeline/frames/{sequence}/camera") + def get_canonical_route_camera(result_id: str, sequence: int) -> Response: + if camera_frame_provider is None: + raise HTTPException(status_code=503, detail="Recorded camera decoder is unavailable") + candidate = _resolve_candidate(root_provider, definition, result_id) + manifest = _read_verified(candidate, definition) + route, frame_times_ns = _full_route_context(candidate, manifest) + if not 0 <= sequence < len(frame_times_ns): + raise HTTPException(status_code=404, detail="Full-route camera frame not found") + try: + camera = camera_frame_provider(str(route["session_id"]), sequence) + except (OSError, SessionIntegrityError, ValueError): + raise HTTPException(status_code=503, detail="Full-route camera frame unavailable") from None + if camera.width != route["width"] or camera.height != route["height"]: + raise HTTPException(status_code=503, detail="Full-route camera dimensions changed") + return Response( + content=camera.payload, + media_type=camera.media_type, + headers={ + "Cache-Control": "private, max-age=31536000, immutable", + "ETag": f'"{camera.sha256}"', + "X-Content-Type-Options": "nosniff", + }, + ) + @router.get("/{result_id}/route-tgs-anchor/{source_sequence}") def get_route_tgs_anchor(result_id: str, source_sequence: int) -> JSONResponse: candidate = _resolve_candidate(root_provider, definition, result_id) @@ -314,6 +467,242 @@ def _build_vegetation_lab_router( return router +def _full_route_context( + candidate: Path, + manifest: dict[str, Any], +) -> tuple[dict[str, Any], tuple[int, ...]]: + route = manifest.get("route_full_review") + timeline = route.get("timeline") if isinstance(route, dict) else None + relative_text = timeline.get("path") if isinstance(timeline, dict) else None + if ( + not isinstance(route, dict) + or route.get("source_id") != "RAVNOVES004TREE" + or route.get("session_id") != "20260828T130511Z_viewer_live" + or route.get("frame_count") != 6830 + or route.get("width") != 800 + or route.get("height") != 600 + or not isinstance(relative_text, str) + ): + raise HTTPException(status_code=404, detail="Full-route canonical timeline not found") + path = candidate.joinpath(*PurePosixPath(relative_text).parts) + try: + payload = path.read_bytes() + if ( + len(payload) != timeline.get("byte_length") + or hashlib.sha256(payload).hexdigest() != timeline.get("sha256") + ): + raise ValueError("timeline digest changed") + values = np.frombuffer(payload, dtype=" dict[str, object]: + points = [] if spatial is None or not include_points else spatial["source_points_body_xyz_m"] + point_count = 0 if spatial is None else int(spatial["source_point_count"]) + body_frame = None if spatial is None else spatial["body_frame"] + local_slam = [] if spatial is None else spatial["local_slam_body_xyz_m"] + return { + "schema_version": "missioncore.recorded-spatial-evidence-frame/v1", + "sequence": sequence, + "frame_id": f"frame-{sequence:06d}", + "source_time_ns": source_time_ns, + "session_seconds": source_time_ns / 1_000_000_000, + "source_available": spatial is not None, + "spatial_available": spatial is not None, + "world_state_available": True, + "terminal_outcome": "delivered", + "body_frame": body_frame, + "point_cloud_body_xyz_m": points, + "point_cloud_source_count": point_count, + "point_cloud_sample_count": point_count if not include_points else len(points), + "point_cloud_layer": "current-increment", + "local_slam_body_xyz_m": local_slam, + "local_slam_source_frame_count": 0 + if spatial is None else spatial["local_slam_source_frame_count"], + "local_slam_source_point_count": 0 + if spatial is None else spatial["local_slam_source_point_count"], + "rolling_map_component_count": 0, + "metric_obstacles": [], + "camera_proposals": _semantic_component_proposals(candidate, route, sequence), + "decision_counts": {"threat": 0, "not-threat": 0, "unknown": 0}, + "camera_url": ( + f"{endpoint_prefix}/{result_id}/timeline/frames/{sequence}/camera" + ), + "ground_truth": False, + "authority": "replay-simulated", + } + + +def _semantic_component_proposals( + candidate: Path, + route: dict[str, Any], + sequence: int, +) -> list[dict[str, object]]: + layers = route.get("layers") + city = layers.get("city") if isinstance(layers, dict) else None + archive = city.get("mask_archive") if isinstance(city, dict) else None + relative = archive.get("path") if isinstance(archive, dict) else None + if not isinstance(relative, str): + return [] + archive_path = candidate.joinpath(*PurePosixPath(relative).parts) + try: + stat = archive_path.stat() + except OSError: + return [] + return [ + dict(proposal) + for proposal in _semantic_component_proposals_cached( + str(archive_path), + stat.st_size, + stat.st_mtime_ns, + sequence, + ) + ] + + +@lru_cache(maxsize=256) +def _semantic_component_proposals_cached( + archive_path_text: str, + archive_size: int, + archive_mtime_ns: int, + sequence: int, +) -> tuple[dict[str, object], ...]: + del archive_size, archive_mtime_ns + archive_path = Path(archive_path_text) + member = f"masks/frame-{sequence + 1:06d}.png" + try: + with zipfile.ZipFile(archive_path) as frozen: + payload = frozen.read(member) + with Image.open(io.BytesIO(payload)) as image: + mask = np.asarray(image.convert("L"), dtype=np.uint8) + except (KeyError, OSError, ValueError, zipfile.BadZipFile): + return () + labels = { + 1: "semantic person", + 2: "semantic bicycle", + 3: "semantic motorcycle", + 4: "semantic car", + 5: "semantic heavy vehicle", + 13: "semantic static obstacle", + 14: "semantic animal", + } + proposals: list[dict[str, object]] = [] + for class_id, label in labels.items(): + minimum_pixels = 80 if class_id == 13 else 24 + for component_index, (left, top, right, bottom, pixel_count) in enumerate( + _mask_component_boxes(mask, class_id, minimum_pixels=minimum_pixels)[:12] + ): + proposals.append({ + "proposal_id": f"semantic-{class_id}-{sequence}-{component_index}", + "bbox_xyxy": [left, top, right, bottom], + "objectness": round(min(0.99, 0.5 + pixel_count / 20_000), 4), + "semantic_hint": label, + "occupied_support": False, + "range_m": None, + "threat_decision": None, + "threat_reason_codes": ["semantic-mask-derived-not-fail-safe-detector"], + }) + proposals.sort( + key=lambda proposal: ( + -float(proposal["objectness"]), + str(proposal["proposal_id"]), + ) + ) + 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: diff --git a/tests/test_canonical_lab_spatial.py b/tests/test_canonical_lab_spatial.py index 5ea0375..73085b0 100644 --- a/tests/test_canonical_lab_spatial.py +++ b/tests/test_canonical_lab_spatial.py @@ -8,6 +8,8 @@ from k1link.sessions.canonical_lab_spatial import ( _TimedPoses, _bounded_local_slam, _estimate_sensor_height, + _estimate_local_sensor_height, + _gravity_stable_basis_map_from_body, _ground_origin_map, ) @@ -57,7 +59,7 @@ def test_local_slam_accumulates_source_increments_in_ground_body_frame() -> None ), ) basis = np.eye(3) - ground_origin = _ground_origin_map(np.asarray([0.0, 0.0, 0.0]), basis, 0.32) + ground_origin = _ground_origin_map(np.asarray([0.0, 0.0, 0.0]), 0.32) local, frame_count, source_count = _bounded_local_slam( points, @@ -69,3 +71,59 @@ def test_local_slam_accumulates_source_increments_in_ground_body_frame() -> None assert frame_count == 3 assert source_count == 3 assert local[:, 2].tolist() == pytest.approx([0.0, 0.0, 0.0], abs=1e-6) + + +def test_gravity_stable_body_frame_converts_rfu_to_forward_left_up() -> None: + times = (0, 1_000_000_000, 2_000_000_000) + poses = _TimedPoses( + times_ns=times, + translations=( + np.asarray([0.0, 0.0, 0.4]), + np.asarray([0.0, 1.0, 0.5]), + np.asarray([0.0, 2.0, 0.3]), + ), + quaternions_xyzw=tuple( + np.asarray([0.25, 0.0, 0.0, np.sqrt(1.0 - 0.25**2)]) for _ in times + ), + ) + + basis, source = _gravity_stable_basis_map_from_body(poses, 1_000_000_000) + + assert source == "smoothed-pose-trajectory-tangent" + assert basis[:, 0].tolist() == pytest.approx([0.0, 1.0, 0.0], abs=1e-7) + assert basis[:, 1].tolist() == pytest.approx([-1.0, 0.0, 0.0], abs=1e-7) + assert basis[:, 2].tolist() == pytest.approx([0.0, 0.0, 1.0], abs=1e-7) + assert np.linalg.det(basis) == pytest.approx(1.0, abs=1e-7) + + +def test_ground_origin_is_projected_only_along_map_gravity() -> None: + origin = _ground_origin_map(np.asarray([4.0, -2.0, 1.25]), 0.32) + assert origin.tolist() == pytest.approx([4.0, -2.0, 0.93], abs=1e-9) + + +def test_sensor_height_tracks_current_source_window_instead_of_fixed_mount() -> None: + times = tuple(index * 500_000_000 for index in range(8)) + points = _TimedPoints( + times_ns=times, + values=tuple( + _calibration_cloud(0.18 if index < 4 else 1.05, index) + for index in range(8) + ), + ) + poses = _TimedPoses( + times_ns=times, + translations=tuple(np.zeros(3) for _ in times), + quaternions_xyzw=tuple(np.asarray([0.0, 0.0, 0.0, 1.0]) for _ in times), + ) + + low, low_samples, _, low_source = _estimate_local_sensor_height( + points, poses, 500_000_000, 0.5, + ) + high, high_samples, _, high_source = _estimate_local_sensor_height( + points, poses, 3_000_000_000, 0.5, + ) + + assert low == pytest.approx(0.18, abs=0.03) + assert high == pytest.approx(1.05, abs=0.03) + assert low_samples >= 3 and high_samples >= 3 + assert low_source == high_source == "local-source-cloud-ground-quantile-median" diff --git a/tests/test_session_api.py b/tests/test_session_api.py index a4300a1..256e188 100644 --- a/tests/test_session_api.py +++ b/tests/test_session_api.py @@ -489,7 +489,7 @@ def test_canonical_lab_spatial_frame_uses_ready_immutable_recording( assert resolved is not None and resolved.recording is not None generation = hashlib.sha256(payload).hexdigest() expected = { - "schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v2", + "schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v3", "target_time_ns": 500_000_000, "source_time_ns": 499_000_000, "pose_time_ns": 499_000_000, @@ -497,13 +497,13 @@ def test_canonical_lab_spatial_frame_uses_ready_immutable_recording( "coordinate_frame": "body-ground", "sensor_height": { "meters": 0.32, - "source": "initial-source-cloud-lower-quantile-median", + "source": "local-source-cloud-ground-quantile-median", "sample_count": 20, "mad_m": 0.03, "authority": "visual-derived", }, "spatial_profile": { - "profile_id": "source-paced-ground-v2", + "profile_id": "source-paced-ground-v3", "local_slam_history_seconds": 5.0, "local_slam_radius_m": 30.0, "local_slam_voxel_size_m": 0.12, @@ -544,11 +544,11 @@ def test_canonical_lab_spatial_frame_uses_ready_immutable_recording( session_id=session.name, generation=generation, time_ns=500_000_000, - profile="source-paced-ground-v2", + profile="source-paced-ground-v3", )) assert json.loads(response.body) == expected assert response.headers["etag"] == ( - f'"{generation}:source-paced-ground-v2:499000000"' + f'"{generation}:source-paced-ground-v3:499000000"' ) assert response.headers["cache-control"].endswith("immutable") finally: diff --git a/tests/test_vegetation_shadow_lab.py b/tests/test_vegetation_shadow_lab.py index 0d077e3..6102118 100644 --- a/tests/test_vegetation_shadow_lab.py +++ b/tests/test_vegetation_shadow_lab.py @@ -22,6 +22,7 @@ from k1link.laboratory.evidence_report import verify_laboratory_evidence_result from k1link.laboratory.vegetation_policy_review import seal_vegetation_policy_review from k1link.laboratory.vegetation_shadow_lab import seal_vegetation_shadow_lab from k1link.web.vegetation_shadow_lab_api import ( + _mask_component_boxes, _route_tgs_anchor_payload, build_vegetation_shadow_lab_router, ) @@ -29,6 +30,18 @@ from k1link.web.vegetation_shadow_lab_api import ( REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +def test_semantic_component_boxes_keep_distinct_objects_separate() -> None: + mask = np.zeros((20, 30), dtype=np.uint8) + mask[2:10, 3:8] = 4 + mask[4:12, 18:24] = 4 + mask[15:17, 3:5] = 4 + + assert _mask_component_boxes(mask, 4, minimum_pixels=20) == [ + (18, 4, 24, 12, 48), + (3, 2, 8, 10, 40), + ] + + def test_route_tgs_anchor_payload_preserves_metric_evidence(tmp_path: Path) -> None: path = tmp_path / "tgs-evidence.npz" point_counts = np.arange(1, 11, dtype=np.int64)