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 0000000..c18127a Binary files /dev/null and b/docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_QA.jpg differ diff --git a/docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_REPLAY_AUDIT.md b/docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_REPLAY_AUDIT.md new file mode 100644 index 0000000..18e17d6 --- /dev/null +++ b/docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_REPLAY_AUDIT.md @@ -0,0 +1,167 @@ +# RAVNOVES004TREE canonical LAB replay audit + +Date: 2026-08-30 + +Scope: Mission Core recorded LAB replay, RAVNOVES004TREE, OPS perception state + +Excluded: Gaussian/simulation workers and their artifacts + +## Outcome + +RAVNOVES004TREE no longer owns a custom LAB viewer. It supplies recording and +model configuration to the same `M4ReplayThreatVisual` and +`CanonicalRecordedLabReplay` implementation used by the accepted recorded LAB. +No new window or status type was added. The stable interaction contract remains: + +- media: `SEMANTICS`, model selector, `VIDEO` / `CAMERA`; +- spatial: `SOURCE POINTS`, `LOCAL SLAM`, `TGS COSTMAP`, `SEMANTICS`, `3D` / `PLAN`; +- one timeline, one resizable split and one media-owned playback clock. + +Models, result IDs, endpoints, labels and replay transport are configuration. +Window structure, switching, seek, buffering and spatial scene code are shared. + +## Why the previous LAB failed + +### Video and spatial state had different clocks + +The removed RAV004 viewer advanced an animation/host clock even when the browser +decoder stopped. The point cloud therefore continued while the camera frame and +timeline could remain frozen. The shared viewer now uses the decoded media time +as the external clock, and image masks/boxes are rendered only when their time is +within 250 ms of the actually presented video time. + +The RAV004 MP4 itself is not clean. An independent `ffmpeg` decode around the +reproducible stop at 11.422 s reported non-monotonic DTS values and corrupt H.264 +macroblocks. The RAV004 profile therefore uses the shared segmented MSE transport +and an opt-in timestamp recovery rule. Recovery is allowed only when all of these +conditions are true: + +- playback is requested and the media element is not paused, ended or seeking; +- decoded media time has not advanced by 20 ms for at least 1.25 s; +- the browser reports decoded media buffered ahead of the frozen timestamp. + +Only then is the broken timestamp interval skipped by 180 ms. The media clock +immediately remains authoritative; the host does not free-run. A stale callback +from the old MSE window is also prevented from undoing an operator seek. + +### LiDAR orientation inherited the wrong axes + +The RRD declares `/world` as RFU (`Right`, `Forward`, `Up`) and logs +`/world/points` in map space. The earlier adapter treated raw LiDAR quaternion +columns as rover forward/left/up and inherited sensor roll/pitch. That is why the +grid, rover and facade could visibly disagree. + +The v3 adapter now uses: + +- map `+Z` as gravity/up; +- the smoothed pose-trajectory tangent projected onto the ground as forward; +- `left = up × forward`; +- projected sensor `+Y` only as a fallback when the tangent is unavailable. + +This is a deterministic coordinate contract, not a visual angle correction. + +### Sensor height was treated as a constant + +RAV004 does not have a stable 0.4 m mounting height throughout the recording. +The adapter now estimates the local ground plane from a causal one-second +near-field point window and uses the sealed session estimate only as fallback. +Observed local heights include approximately 0.17 m, 1.24 m, 1.05 m and 0.22 m +at different route positions; a single hand-entered value is therefore invalid. + +### Sparse LiDAR frames were held incorrectly + +Camera is approximately 9.51 Hz while source points arrive at approximately +2 Hz. A camera frame without a new LiDAR increment used to retain whichever +spatial frame happened to finish loading last; under fast playback this could be +dozens of seconds old. The buffer now loads the active chunk first, the preceding +chunk second and the next chunk as prefetch. The scene selects the latest proven +source increment whose sequence is not later than the active camera frame. + +At the final UI check, camera frame 189 causally held spatial frame 184. Before +the fix the same point could hold frame 16. + +## Capability ledger + +| Layer | RAV004 full route | UI behavior | Authority | +|---|---:|---|---| +| Recorded RIGHT camera | 6830/6830 | `VIDEO` / `CAMERA`, segmented playback | recorded evidence | +| DDRNet semantic mask | 6830/6830 | selectable, opaque enough for review | diagnostic prediction | +| EoMT semantic mask | 6830/6830 | selectable | diagnostic prediction | +| Diagnostic object boxes | derived from connected EoMT mask components | media-time gated | not an independent detector | +| Source points | 1444 increments | `SOURCE POINTS` | recorded geometry | +| Bounded Local SLAM | causal 5 s / 27k-point limit | `LOCAL SLAM` | visual-derived | +| Full-route TGS | **absent** | canonical `TGS COSTMAP` control is visible but disabled | unavailable, fail closed | +| Point-aligned 3D semantics | **absent** | canonical `SEMANTICS` control is visible but disabled | unavailable | +| Independent person/vehicle detector | **absent** | no STOP claim | unavailable | + +Ten old TGS review anchors exist, but they are not a continuous route artifact. +They are not repeated or held as if they were full TGS. The accepted RAVNOVES00 +full-TGS result is also not reused because it has a different source identity and +4489-frame timeline. + +## Performance evidence + +Measured on the canonical local service and current immutable artifacts: + +- replay launch POST: 3.55 s on first opening; +- timeline metadata: 0.02 s warm; +- active spatial chunk, eight camera frames: 35.17 s first process-local RRD + index build, 0.67 s warm, approximately 3.81 MB; +- UI replay: passed the previously deterministic 11.422 s decoder stop, then + continued to 59 s with media and timeline advancing together; +- operator reset seek: 16.4 s to 0 s, one mounted media worker, successful; +- browser console after the acceptance run: no warnings or errors. + +The first RRD index is still process-local rather than a persistent disk cache. +That is an explicit remaining performance gap; warm playback is the admitted +profile, cold restart latency is not yet accepted. + +## Nature perception: current OPS stopping point + +OPS card `MISSIONCOR-65` defines the intended independent layers as EoMT, +DDRNet, frozen YOLOX and TGS. The current immutable RAV004 artifact proves full +EoMT and DDRNet inference only. It does not prove full TGS, negative-obstacle +handling, an independent person/vehicle STOP layer or combined real-time load. + +Isolated full-route measurements: + +- DDRNet-39: p95 27.44 ms, 52.67 inference FPS, validation mean IoU 29.715%, + vegetation mean IoU 0.3701; +- EoMT: p95 361.62 ms, approximately 3.01 inference FPS; +- prior accepted RAVNOVES00 TGS: p95 1.694 ms CPU-only, but this is algorithm + performance on another source, not RAV004 proof. + +The DDRNet isolated throughput is sufficient for a 10 FPS budget. DDRNet is not +accepted for driving policy because temporal stability and nature quality are +not sufficient: the OPS temporal sample recorded adjacent-frame IoU near 0.195 +for high grass and 0.400 for woody vegetation. EoMT does not meet 10 FPS in its +current form. The next evidentiary milestone is therefore not another UI model +toggle; it is synchronized truth for grass/tree/ditch/drop-off, full TGS and +negative-obstacle evidence, frozen independent detector output and a combined +load test at at least 10 FPS. + +Worker 006 was audited read-only. Triton and the Gaussian containers were left +untouched. The separate Mission Core perception worker is currently in a restart +loop (404 during model inference startup); this audit did not stop, recreate or +deploy it. + +## Acceptance performed + +- 44 focused backend tests passed; +- 37 frontend replay, buffering and LAB contract tests passed; +- TypeScript project typecheck passed; +- production Vite build passed (only existing large-chunk warnings); +- `git diff --check` passed; +- live browser run verified the shared controls, disabled unsealed TGS/3D + semantics, continuous media recovery, causal spatial hold and clean console. + +Visual QA: `docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_QA.jpg`. + +## External coordinate and media references + +- Rerun ViewCoordinates: +- 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)