From b5080671c935a4adda60e25da971d5db791d4cce Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Tue, 25 Aug 2026 17:51:50 +0300 Subject: [PATCH] fix(lab): stabilize replay and densify LiDAR overlay --- .../RecordedEvidencePointCloudOverlay.tsx | 35 +++- .../src/core/laboratory/m4ReplayThreat.ts | 79 +++++++- .../laboratory/M4ReplayThreatVisual.tsx | 44 +++-- .../laboratory/useM4ThreatTimeline.ts | 140 +++++++++++++- .../test/m4ReplayThreat.test.mjs | 50 ++++- src/k1link/perception/m48s_replay_timeline.py | 174 ++++++++++++++++-- .../web/m48s_fixed_class_detector_lab_api.py | 33 +++- .../test_m48s_fixed_class_detector_lab_api.py | 17 +- 8 files changed, 520 insertions(+), 52 deletions(-) diff --git a/apps/control-station/src/components/laboratory/RecordedEvidencePointCloudOverlay.tsx b/apps/control-station/src/components/laboratory/RecordedEvidencePointCloudOverlay.tsx index c6ac715..c94f971 100644 --- a/apps/control-station/src/components/laboratory/RecordedEvidencePointCloudOverlay.tsx +++ b/apps/control-station/src/components/laboratory/RecordedEvidencePointCloudOverlay.tsx @@ -6,15 +6,20 @@ export interface RecordedEvidencePointCloudOverlayData { pointsXyd: readonly RecordedEvidenceProjectedPoint[]; sourcePointCount: number; projectedPointCount: number; - projection: "factory-kb4-exact"; + projection: + | "factory-kb4-exact" + | "factory-kb4-causal-registered-accumulation"; ariaLabel: string; } const DEPTH_BUCKETS = 64; -function depthColor(depthM: number): string { +function depthBucket(depthM: number): number { const normalized = Math.max(0, Math.min(1, (depthM - 0.5) / 24)); - const bucket = Math.round(normalized * (DEPTH_BUCKETS - 1)); + return Math.round(normalized * (DEPTH_BUCKETS - 1)); +} + +function depthColor(bucket: number): string { const hue = 18 + bucket / (DEPTH_BUCKETS - 1) * 190; return `hsla(${hue}, 96%, 62%, 0.86)`; } @@ -52,16 +57,26 @@ export function RecordedEvidencePointCloudOverlay({ const offsetX = (width - imageWidth * scale) / 2; const offsetY = (height - imageHeight * scale) / 2; const radius = Math.max(0.8, Math.min(2.2, scale * 1.45)); + const buckets = Array.from( + { length: DEPTH_BUCKETS }, + (): [number, number][] => [], + ); for (const [imageX, imageY, depthM] of overlay.pointsXyd) { - context.beginPath(); - context.arc( + const bucket = depthBucket(depthM); + buckets[bucket]!.push([ offsetX + imageX * scale, offsetY + imageY * scale, - radius, - 0, - Math.PI * 2, - ); - context.fillStyle = depthColor(depthM); + ]); + } + for (let bucket = 0; bucket < buckets.length; bucket += 1) { + const points = buckets[bucket]; + if (!points?.length) continue; + context.beginPath(); + for (const [x, y] of points) { + context.moveTo(x + radius, y); + context.arc(x, y, radius, 0, Math.PI * 2); + } + context.fillStyle = depthColor(bucket); context.fill(); } }; diff --git a/apps/control-station/src/core/laboratory/m4ReplayThreat.ts b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts index 489ed93..f64391a 100644 --- a/apps/control-station/src/core/laboratory/m4ReplayThreat.ts +++ b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts @@ -170,7 +170,8 @@ export interface M4ThreatTimeline { pointSampleLimit: number; maximumSourcePointsPerFrame: number; pointDelivery: "exact-current-increment"; - cameraPointDelivery: "factory-kb4-projected-current-increment" | null; + cameraPointDelivery: "factory-kb4-causal-registered-accumulation" | null; + cameraPointWindowSeconds: number; cameraPointSampleLimit: number; worldStateDelivery: "source-paced-latest-wins" | null; worldStateFrameCount: number; @@ -189,6 +190,21 @@ export interface M4ThreatTimeline { corridor: M4ThreatVisualFrame["corridor"]; } +export interface M4ThreatCameraPointOverlay { + resultId: string; + sequence: number; + sourceTimeNs: number; + pointsXyd: readonly (readonly [number, number, number])[]; + sourceFrameCount: number; + sourcePointCount: number; + frontPointCount: number; + projectedPointCount: number; + sampleCount: number; + windowSeconds: number; + projection: "factory-kb4-causal-registered-accumulation"; + authority: "visual-derived"; +} + export interface M4ThreatTimelineChunk { resultId: string; startSequence: number; @@ -651,9 +667,12 @@ export async function fetchM4ThreatTimeline( ? null : exact( payload.camera_point_delivery, - "factory-kb4-projected-current-increment", + "factory-kb4-causal-registered-accumulation", "M4.6 camera point delivery", ), + cameraPointWindowSeconds: payload.camera_point_window_seconds === undefined + ? 0 + : number(payload.camera_point_window_seconds, "M4.6 camera point window"), cameraPointSampleLimit: payload.camera_point_sample_limit === undefined ? 0 : integer(payload.camera_point_sample_limit, "M4.6 camera point limit"), @@ -760,6 +779,62 @@ export async function fetchM4ThreatTimelineChunk( }; } +export async function fetchM4ThreatCameraPointOverlay( + result: string, + sequence: number, + { + fetcher = fetch, + signal, + endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT, + }: { + fetcher?: LaboratoryFetch; + signal?: AbortSignal; + endpointRoot?: string; + } = {}, +): Promise { + const response = await fetcher( + `${endpointRoot}/${result}/timeline/frames/${sequence}/camera-points`, + { headers: { Accept: "application/json" }, signal }, + ); + if (!response.ok) { + throw new M4ThreatContractError(`M4.6 camera points: HTTP ${response.status}.`); + } + const payload = object(await response.json(), "M4.6 camera points"); + exact( + payload.schema_version, + "missioncore.m48s-camera-point-overlay/v1", + "M4.6 camera point schema", + ); + exact(payload.result_id, result, "M4.6 camera point result"); + exact(payload.sequence, sequence, "M4.6 camera point sequence"); + exact(payload.authority, "visual-derived", "M4.6 camera point authority"); + const points = array(payload.points_xyd, "M4.6 accumulated camera points").map( + (point) => vector(point, 3, "M4.6 accumulated camera point") as [number, number, number], + ); + const sampleCount = integer(payload.sample_count, "M4.6 camera point sample count"); + if (points.length !== sampleCount) { + throw new M4ThreatContractError("M4.6 camera point sample count: нарушен контракт."); + } + return { + resultId: result, + sequence, + sourceTimeNs: integer(payload.source_time_ns, "M4.6 camera point source time"), + pointsXyd: points, + sourceFrameCount: integer(payload.source_frame_count, "M4.6 camera source frames"), + sourcePointCount: integer(payload.source_point_count, "M4.6 camera source points"), + frontPointCount: integer(payload.front_point_count, "M4.6 camera front points"), + projectedPointCount: integer(payload.projected_point_count, "M4.6 camera projected points"), + sampleCount, + windowSeconds: number(payload.window_seconds, "M4.6 camera point window"), + projection: exact( + payload.projection, + "factory-kb4-causal-registered-accumulation", + "M4.6 camera point projection", + ), + authority: "visual-derived", + }; +} + function parseTimelineFrame( value: unknown, result: string, diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx index c160e21..9504a5f 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -42,6 +42,7 @@ import { recordedObservationSources } from "../../core/observation/recordedObser import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions"; import type { ObservationSourceDescriptor } from "../../core/runtime/contracts"; import { + useM4ThreatCameraPointOverlay, useM4ThreatTimelineFrame, useM4ThreatTimelineMetadata, } from "./useM4ThreatTimeline"; @@ -224,6 +225,12 @@ export function M4ReplayThreatVisual({ : lastSpatialFrameRef.current?.resultId === resultId ? lastSpatialFrameRef.current.frame : null; + const cameraPointOverlay = useM4ThreatCameraPointOverlay({ + enabled: showMediaPoints, + resultId, + sequence: frame?.sequence ?? null, + endpointRoot: timelineEndpointRoot, + }); const semanticTimeline = useE47SemanticTimelineFrame({ resultId: semantic?.resultId ?? null, activeSequence: frame?.sequence ?? timelineFrame.activeSequence, @@ -373,16 +380,27 @@ export function M4ReplayThreatVisual({ ariaLabel: `E47 semantic mask frame ${frame.sequence + 1}`, } : undefined; + const accumulatedCameraPoints = cameraPointOverlay.overlay?.sequence === frame?.sequence + ? cameraPointOverlay.overlay + : null; const pointCloudOverlay: RecordedEvidencePointCloudOverlayData | undefined = - showMediaPoints && frame?.cameraProjection === "factory-kb4-exact" + showMediaPoints && accumulatedCameraPoints ? { - pointsXyd: frame.cameraProjectedPointsXyd, - sourcePointCount: frame.cameraProjectedSourceCount, - projectedPointCount: frame.cameraProjectedPointCount, - projection: "factory-kb4-exact", - ariaLabel: `${evidenceLabel} LiDAR projection: ${frame.cameraProjectedSampleCount} points`, + pointsXyd: accumulatedCameraPoints.pointsXyd, + sourcePointCount: accumulatedCameraPoints.sourcePointCount, + projectedPointCount: accumulatedCameraPoints.projectedPointCount, + projection: accumulatedCameraPoints.projection, + ariaLabel: `${evidenceLabel} causal registered LiDAR accumulation: ${accumulatedCameraPoints.sampleCount} points`, } - : undefined; + : showMediaPoints && frame?.cameraProjection === "factory-kb4-exact" + ? { + pointsXyd: frame.cameraProjectedPointsXyd, + sourcePointCount: frame.cameraProjectedSourceCount, + projectedPointCount: frame.cameraProjectedPointCount, + projection: "factory-kb4-exact", + ariaLabel: `${evidenceLabel} current LiDAR projection: ${frame.cameraProjectedSampleCount} points`, + } + : undefined; const handleMediaModeChange = (next: M4ThreatMediaSelection) => { if (next === "none") return; @@ -452,7 +470,7 @@ export function M4ReplayThreatVisual({ shape="pill" variant={showMediaPoints ? "primary" : "secondary"} aria-pressed={showMediaPoints} - title="Exact LiDAR increment · factory KB4 camera projection" + title="Actual registered LiDAR · causal accumulation · factory KB4 projection" onClick={() => setShowMediaPoints((visible) => !visible)} > POINTS @@ -608,9 +626,13 @@ export function M4ReplayThreatVisual({ {frame.worldStateAvailable ? " · world-state delivered" : ` · world-state gap (${frame.terminalOutcome})`} - {pointCloudOverlay - ? ` · camera points ${frame.cameraProjectedSampleCount}/${frame.cameraProjectedPointCount}` - : ""} + {accumulatedCameraPoints + ? ` · camera points ${accumulatedCameraPoints.sampleCount}/${accumulatedCameraPoints.projectedPointCount} · causal ${accumulatedCameraPoints.windowSeconds.toFixed(1)} с / ${accumulatedCameraPoints.sourceFrameCount} frames` + : pointCloudOverlay + ? ` · camera points ${frame.cameraProjectedSampleCount}/${frame.cameraProjectedPointCount} exact-current · накопление загружается` + : showMediaPoints && cameraPointOverlay.error + ? " · накопленное camera cloud недоступно" + : ""} {semantic && spatialSemanticFrame ? ` · semantic L ${spatialSemanticFrame.counts.labeled} · A ${spatialSemanticFrame.counts.ambiguous} · U ${spatialSemanticFrame.counts.unprojected} · Ø ${spatialSemanticFrame.counts.absent}` : semantic ? " · semantic buffer" : ""} diff --git a/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts b/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts index 3cdfac2..13f4cff 100644 --- a/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts +++ b/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts @@ -1,17 +1,20 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { + fetchM4ThreatCameraPointOverlay, fetchM4ThreatTimeline, fetchM4ThreatTimelineChunk, selectM4ThreatTimelineSequence, + type M4ThreatCameraPointOverlay, type M4ThreatTimeline, type M4ThreatTimelineChunk, type M4ThreatTimelineFrame, } from "../../core/laboratory/m4ReplayThreat"; const REQUESTED_CHUNK_FRAMES = 24; -const RETAINED_CHUNK_COUNT = 8; -const PREFETCH_CHUNKS_AHEAD = 2; +const RETAINED_CHUNK_COUNT = 4; +const PREFETCH_CHUNKS_AHEAD = 1; +const RETAINED_CAMERA_POINT_OVERLAYS = 12; function errorMessage(error: unknown, fallback: string): string { return error instanceof Error && error.message.trim() ? error.message : fallback; @@ -24,8 +27,8 @@ export function m4ThreatChunkWindowStarts( ): readonly number[] { if (chunkSize < 1 || frameCount < 1) return []; return Array.from( - { length: PREFETCH_CHUNKS_AHEAD + 2 }, - (_, index) => activeChunkStart + (index - 1) * chunkSize, + { length: PREFETCH_CHUNKS_AHEAD + 1 }, + (_, index) => activeChunkStart + index * chunkSize, ).filter((start) => start >= 0 && start < frameCount); } @@ -153,8 +156,11 @@ export function useM4ThreatTimelineFrame({ .finally(() => { if (inFlight.current.get(start) === controller) inFlight.current.delete(start); }); + // Keep server-side JSON work strictly serialized: the active chunk is + // loaded first, then the next chunk is prefetched on the following render. + break; } - }, [activeChunkStart, chunkSize, endpointRoot, resultId, timeline]); + }, [activeChunkStart, chunkSize, chunks, endpointRoot, resultId, timeline]); const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => { if (activeSequence === null || activeChunkStart === null) return null; @@ -178,3 +184,127 @@ export function useM4ThreatTimelineFrame({ error, }; } + +export function useM4ThreatCameraPointOverlay({ + enabled, + resultId, + sequence, + endpointRoot, +}: { + enabled: boolean; + resultId: string; + sequence: number | null; + endpointRoot?: string; +}) { + const [overlay, setOverlay] = useState(null); + const [error, setError] = useState(null); + const desiredRef = useRef<{ + resultId: string; + sequence: number; + endpointRoot?: string; + } | null>(null); + const cacheRef = useRef(new Map()); + const runningRef = useRef(false); + const mountedRef = useRef(true); + const pumpRef = useRef<() => void>(() => undefined); + + pumpRef.current = () => { + if (runningRef.current || !desiredRef.current) return; + runningRef.current = true; + let settledKey: string | null = null; + void (async () => { + while (mountedRef.current) { + const target = desiredRef.current; + if (!target) break; + const key = `${target.endpointRoot ?? ""}:${target.resultId}:${target.sequence}`; + const cached = cacheRef.current.get(key); + if (cached) { + setOverlay(cached); + setError(null); + settledKey = key; + break; + } + try { + const next = await fetchM4ThreatCameraPointOverlay( + target.resultId, + target.sequence, + { endpointRoot: target.endpointRoot }, + ); + cacheRef.current.set(key, next); + while (cacheRef.current.size > RETAINED_CAMERA_POINT_OVERLAYS) { + const oldest = cacheRef.current.keys().next().value as string | undefined; + if (oldest === undefined) break; + cacheRef.current.delete(oldest); + } + const desired = desiredRef.current; + if ( + desired + && desired.resultId === target.resultId + && desired.sequence === target.sequence + && desired.endpointRoot === target.endpointRoot + ) { + setOverlay(next); + setError(null); + settledKey = key; + break; + } + } catch (caught: unknown) { + const desired = desiredRef.current; + if ( + desired + && desired.resultId === target.resultId + && desired.sequence === target.sequence + && desired.endpointRoot === target.endpointRoot + ) { + setError(errorMessage(caught, "Накопленное LiDAR-облако камеры недоступно.")); + settledKey = key; + break; + } + } + } + })().finally(() => { + runningRef.current = false; + const desired = desiredRef.current; + const desiredKey = desired + ? `${desired.endpointRoot ?? ""}:${desired.resultId}:${desired.sequence}` + : null; + if (mountedRef.current && desiredKey && desiredKey !== settledKey) pumpRef.current(); + }); + }; + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + desiredRef.current = null; + }; + }, []); + + useEffect(() => { + cacheRef.current.clear(); + setOverlay(null); + setError(null); + }, [resultId]); + + useEffect(() => { + desiredRef.current = enabled && sequence !== null + ? { resultId, sequence, endpointRoot } + : null; + if (!desiredRef.current) { + setOverlay(null); + setError(null); + return; + } + const key = `${endpointRoot ?? ""}:${resultId}:${sequence}`; + const cached = cacheRef.current.get(key); + setOverlay(cached ?? null); + if (cached) setError(null); + pumpRef.current(); + }, [enabled, endpointRoot, resultId, sequence]); + + return { + overlay, + loading: enabled && sequence !== null && overlay?.sequence !== sequence && !error, + error, + }; +} diff --git a/apps/control-station/test/m4ReplayThreat.test.mjs b/apps/control-station/test/m4ReplayThreat.test.mjs index 71f0615..a573f71 100644 --- a/apps/control-station/test/m4ReplayThreat.test.mjs +++ b/apps/control-station/test/m4ReplayThreat.test.mjs @@ -9,6 +9,7 @@ let fetchM4ThreatReplayResult; let fetchM4ThreatVisual; let fetchM4ThreatTimeline; let fetchM4ThreatTimelineChunk; +let fetchM4ThreatCameraPointOverlay; let selectM4ThreatTimelineFrame; let selectM4ThreatTimelineSequence; let advanceRecordedEvidencePlayback; @@ -30,6 +31,7 @@ before(async () => { fetchM4ThreatVisual, fetchM4ThreatTimeline, fetchM4ThreatTimelineChunk, + fetchM4ThreatCameraPointOverlay, selectM4ThreatTimelineFrame, selectM4ThreatTimelineSequence, } = await server.ssrLoadModule("/src/core/laboratory/m4ReplayThreat.ts")); @@ -359,8 +361,9 @@ test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint" point_sample_limit: 4096, maximum_source_points_per_frame: 3092, point_delivery: "exact-current-increment", - camera_point_delivery: "factory-kb4-projected-current-increment", - camera_point_sample_limit: 4096, + camera_point_delivery: "factory-kb4-causal-registered-accumulation", + camera_point_window_seconds: 2, + camera_point_sample_limit: 20000, world_state_delivery: "source-paced-latest-wins", world_state_frame_count: 4481, superseded_frame_count: 8, @@ -385,7 +388,8 @@ test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint" }, }); assert.equal(requested, `${endpointRoot}/${replayResultId}/timeline`); - assert.equal(timeline.cameraPointDelivery, "factory-kb4-projected-current-increment"); + assert.equal(timeline.cameraPointDelivery, "factory-kb4-causal-registered-accumulation"); + assert.equal(timeline.cameraPointWindowSeconds, 2); assert.equal(timeline.worldStateFrameCount, 4481); assert.equal(timeline.supersededFrameCount, 8); @@ -418,6 +422,35 @@ test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint" assert.equal(chunk.frames[0].terminalOutcome, "superseded"); assert.deepEqual(chunk.frames[0].cameraProjectedPointsXyd[0], [100.5, 200.25, 3.75]); assert.equal(chunk.frames[0].cameraProjection, "factory-kb4-exact"); + + const overlay = await fetchM4ThreatCameraPointOverlay(replayResultId, 1, { + endpointRoot, + fetcher: async (input) => { + requested = String(input); + return new Response(JSON.stringify({ + schema_version: "missioncore.m48s-camera-point-overlay/v1", + result_id: replayResultId, + sequence: 1, + source_time_ns: 35_521_857_292, + points_xyd: [[100.5, 200.25, 3.75], [101.5, 201.25, 3.8]], + source_frame_count: 11, + source_point_count: 28000, + front_point_count: 18000, + projected_point_count: 12000, + sample_count: 2, + window_seconds: 2, + projection: "factory-kb4-causal-registered-accumulation", + authority: "visual-derived", + }), { status: 200 }); + }, + }); + assert.equal( + requested, + `${endpointRoot}/${replayResultId}/timeline/frames/1/camera-points`, + ); + assert.equal(overlay.sourceFrameCount, 11); + assert.equal(overlay.sampleCount, 2); + assert.equal(overlay.projection, "factory-kb4-causal-registered-accumulation"); }); test("M4.6 local SLAM surface reprojects registered increments into the active body frame", () => { @@ -468,9 +501,9 @@ test("M4.6 local SLAM surface reprojects registered increments into the active b assert.deepEqual(surface.pointsBodyXyzM, [[0, 1, 0], [1, 0, 0]]); }); -test("M4.6 spatial buffering keeps previous, active and two future chunks", () => { - assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [24, 48, 72, 96]); - assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24, 48]); +test("M4.6 spatial buffering keeps the active and one future chunk", () => { + assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 72]); + assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24]); }); test("M4.6 spatial buffering drops stale in-flight windows across rapid jumps", () => { @@ -486,8 +519,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()], [4464, 4488]); - assert.deepEqual(aborted, [0, 24, 48, 1464, 1488, 1512, 1536]); + assert.deepEqual([...inFlight.keys()], [4488]); + assert.deepEqual(aborted, [0, 24, 1488, 1512]); }); test("recorded evidence clock advances by selected rate and stops at the sealed end", () => { @@ -604,6 +637,7 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async () assert.match(imageScene, / dict[str, object]: intervals = [ @@ -114,11 +126,14 @@ class M48sReplayTimeline: "point_sample_limit": RECORDED_SPATIAL_POINT_LIMIT, "maximum_source_points_per_frame": self.store.maximum_current_point_count, "point_delivery": "exact-current-increment", - "camera_point_delivery": "factory-kb4-projected-current-increment", - "camera_point_sample_limit": RECORDED_SPATIAL_POINT_LIMIT, + "camera_point_delivery": "factory-kb4-causal-registered-accumulation", + "camera_point_window_seconds": CAMERA_ACCUMULATION_WINDOW_SECONDS, + "camera_point_sample_limit": CAMERA_ACCUMULATION_POINT_LIMIT, "world_state_delivery": "source-paced-latest-wins", "world_state_frame_count": len(self.index.offsets_by_sequence), - "superseded_frame_count": sum(value == "superseded" for value in self.outcomes.values()), + "superseded_frame_count": sum( + value == "superseded" for value in self.outcomes.values() + ), "local_surface_visualization": { "derivation": "bounded-registered-increment-accumulation", "window_seconds": RECORDED_LOCAL_SURFACE_WINDOW_SECONDS, @@ -155,8 +170,7 @@ class M48sReplayTimeline: if not 1 <= frame_count <= RECORDED_SPATIAL_MAX_CHUNK_FRAMES: raise M48sReplayTimelineError("M4.8S timeline chunk size is invalid") stop = min(EXPECTED_FRAME_COUNT, start_sequence + frame_count) - with self._lock: - frames = [self._project_frame(sequence) for sequence in range(start_sequence, stop)] + frames = [self._project_frame(sequence) for sequence in range(start_sequence, stop)] return { "schema_version": RECORDED_SPATIAL_CHUNK_SCHEMA, "result_id": self.result_id, @@ -169,6 +183,110 @@ class M48sReplayTimeline: "access": "read-only-bounded-recorded-replay", } + def chunk_json(self, *, start_sequence: int, frame_count: int) -> bytes: + """Return one bounded immutable chunk without repeating JSON encoding.""" + + key = (start_sequence, frame_count) + with self._cache_lock: + cached = self._chunk_json_cache.get(key) + if cached is not None: + self._chunk_json_cache.move_to_end(key) + return cached + content = json.dumps( + self.chunk(start_sequence=start_sequence, frame_count=frame_count), + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + with self._cache_lock: + self._chunk_json_cache[key] = content + self._chunk_json_cache.move_to_end(key) + while len(self._chunk_json_cache) > 12: + self._chunk_json_cache.popitem(last=False) + return content + + def camera_point_overlay_json(self, *, sequence: int) -> bytes: + """Project causal registered LiDAR increments into the current camera. + + Every rendered point comes from a sealed map-frame increment at or before + ``sequence``. Accumulation is visualization-only: it increases static + surface density but can leave short trails behind moving objects. + """ + + if not 0 <= sequence < EXPECTED_FRAME_COUNT: + raise M48sReplayTimelineError("M4.8S camera point sequence is invalid") + with self._cache_lock: + cached = self._camera_point_json_cache.get(sequence) + if cached is not None: + self._camera_point_json_cache.move_to_end(sequence) + return cached + current = self.store.frame_for_index(sequence) + source_time_ns = self.source_times_ns[sequence] + window_ns = round(CAMERA_ACCUMULATION_WINDOW_SECONDS * 1_000_000_000) + first_sequence = bisect_left(self.source_times_ns, source_time_ns - window_ns) + source_frames = [] + source_point_count = 0 + if current is not None: + for source_sequence in range(first_sequence, sequence + 1): + source = self.store.frame_for_index(source_sequence) + if source is None or source.points_map.size == 0: + continue + source_frames.append(source.points_map) + source_point_count += source.source_point_count + + points: list[list[float]] = [] + front_point_count = 0 + projected_point_count = 0 + if current is not None and source_frames: + accumulated = np.concatenate(source_frames, axis=0) + projected = project_map_points_kb4( + accumulated, + position_map_xyz=current.sensor_position_map, + orientation_map_from_lidar_xyzw=current.sensor_orientation_xyzw, + profile=current.projection, + ) + front_point_count = projected.camera_front_point_count + projected_point_count = projected.projected_point_count + sample_count = min(projected_point_count, CAMERA_ACCUMULATION_POINT_LIMIT) + indices = np.linspace( + 0, + projected_point_count - 1, + num=sample_count, + dtype=np.int64, + ) + if indices.size: + xy = projected.pixels_xy[indices] + depth = projected.depths_m[indices, None] + points = np.round(np.concatenate((xy, depth), axis=1), 2).tolist() + + payload = { + "schema_version": CAMERA_POINT_OVERLAY_SCHEMA, + "result_id": self.result_id, + "sequence": sequence, + "source_time_ns": source_time_ns, + "points_xyd": points, + "source_frame_count": len(source_frames), + "source_point_count": source_point_count, + "front_point_count": front_point_count, + "projected_point_count": projected_point_count, + "sample_count": len(points), + "window_seconds": CAMERA_ACCUMULATION_WINDOW_SECONDS, + "projection": "factory-kb4-causal-registered-accumulation", + "ground_truth": False, + "authority": "visual-derived", + "access": "read-only-bounded-recorded-replay", + } + content = json.dumps( + payload, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + with self._cache_lock: + self._camera_point_json_cache[sequence] = content + self._camera_point_json_cache.move_to_end(sequence) + while len(self._camera_point_json_cache) > 32: + self._camera_point_json_cache.popitem(last=False) + return content + def _project_frame(self, sequence: int) -> dict[str, object]: terminal_outcome = self.outcomes[sequence] row = self._row(sequence) @@ -273,7 +391,9 @@ class M48sReplayTimeline: "semantic_hint": proposal.get("semantic_hint"), "occupied_support": proposal_id in associated, "range_m": None, - "threat_decision": None if assessment is None else assessment.get("decision"), + "threat_decision": None + if assessment is None + else assessment.get("decision"), "threat_reason_codes": [] if assessment is None else assessment.get("reason_codes"), @@ -344,10 +464,7 @@ def _index_ledger( line = stream.readline() if not line: break - row = json.loads(line) - if not isinstance(row, dict) or row.get("schema_version") != FRAME_EVIDENCE_SCHEMA: - raise M48sReplayTimelineError("M4.8S ledger schema changed") - envelope = _object(row.get("source_envelope"), "source envelope") + envelope = _ledger_source_envelope(line) timestamps = _object(envelope.get("timestamps"), "source timestamps") sequence = envelope.get("sequence") if ( @@ -366,6 +483,31 @@ def _index_ledger( return _LedgerIndex(offsets) +def _ledger_source_envelope(line: bytes) -> dict[str, object]: + """Validate a ledger row while decoding only its small trailing envelope. + + The full row can exceed 100 KiB because it contains the delivered world + state. Indexing needs only the sealed top-level schema and source binding; + decoding the complete 473 MiB ledger on every backend start needlessly holds + the GIL for many seconds. + """ + + if ( + line.count(_FRAME_EVIDENCE_SCHEMA_MARKER) != 1 + or line.count(_SOURCE_ENVELOPE_MARKER) != 1 + ): + raise M48sReplayTimelineError("M4.8S ledger schema changed") + start = line.find(_SOURCE_ENVELOPE_MARKER) + len(_SOURCE_ENVELOPE_MARKER) + try: + tail = line[start:].decode("utf-8") + value, end = _JSON_DECODER.raw_decode(tail) + except (UnicodeDecodeError, json.JSONDecodeError): + raise M48sReplayTimelineError("M4.8S ledger source envelope is invalid") from None + if tail[end:].strip() != "}": + raise M48sReplayTimelineError("M4.8S ledger source envelope moved") + return _object(value, "source envelope") + + def _terminal_outcomes(worker: dict[str, object]) -> dict[int, str]: execution = _object(worker.get("execution"), "execution") loops = execution.get("loops") @@ -423,4 +565,10 @@ def _text(value: object, label: str) -> str: return value -__all__ = ["M48sReplayTimeline", "M48sReplayTimelineError"] +__all__ = [ + "CAMERA_ACCUMULATION_POINT_LIMIT", + "CAMERA_ACCUMULATION_WINDOW_SECONDS", + "CAMERA_POINT_OVERLAY_SCHEMA", + "M48sReplayTimeline", + "M48sReplayTimelineError", +] diff --git a/src/k1link/web/m48s_fixed_class_detector_lab_api.py b/src/k1link/web/m48s_fixed_class_detector_lab_api.py index e482be2..6c9155c 100644 --- a/src/k1link/web/m48s_fixed_class_detector_lab_api.py +++ b/src/k1link/web/m48s_fixed_class_detector_lab_api.py @@ -159,11 +159,40 @@ def build_m48s_fixed_class_detector_lab_router( result_id: str, start: int = Query(default=0, ge=0), count: int = Query(default=12, ge=1, le=RECORDED_SPATIAL_MAX_CHUNK_FRAMES), - ) -> dict[str, object]: + ) -> Response: try: - return timeline(result_id).chunk(start_sequence=start, frame_count=count) + content = timeline(result_id).chunk_json( + start_sequence=start, + frame_count=count, + ) except M48sReplayTimelineError: raise HTTPException(status_code=404, detail="M4.8S timeline chunk not found") from None + return Response( + content=content, + media_type="application/json", + headers={ + "Cache-Control": "private, max-age=31536000, immutable", + "X-Content-Type-Options": "nosniff", + }, + ) + + @router.get("/{result_id}/timeline/frames/{sequence}/camera-points") + def get_timeline_camera_points(result_id: str, sequence: int) -> Response: + try: + content = timeline(result_id).camera_point_overlay_json(sequence=sequence) + except M48sReplayTimelineError: + raise HTTPException( + status_code=404, + detail="M4.8S camera point overlay not found", + ) from None + return Response( + content=content, + media_type="application/json", + headers={ + "Cache-Control": "private, max-age=31536000, immutable", + "X-Content-Type-Options": "nosniff", + }, + ) @router.get("/{result_id}/timeline/frames/{sequence}/camera") def get_timeline_camera(result_id: str, sequence: int) -> Response: diff --git a/tests/test_m48s_fixed_class_detector_lab_api.py b/tests/test_m48s_fixed_class_detector_lab_api.py index ff86227..db32dd5 100644 --- a/tests/test_m48s_fixed_class_detector_lab_api.py +++ b/tests/test_m48s_fixed_class_detector_lab_api.py @@ -59,8 +59,10 @@ def test_m48s_lab_api_projects_verified_result_frame_and_camera(tmp_path: Path) assert timeline.json()["superseded_frame_count"] == 8 assert ( timeline.json()["camera_point_delivery"] - == "factory-kb4-projected-current-increment" + == "factory-kb4-causal-registered-accumulation" ) + assert timeline.json()["camera_point_window_seconds"] == 2.0 + assert timeline.json()["camera_point_sample_limit"] == 20_000 chunk = client.get( f"/api/v1/laboratory/m48s/fixed-class-detector/{result_id}/timeline/chunk", params={"start": 253, "count": 1}, @@ -73,6 +75,19 @@ def test_m48s_lab_api_projects_verified_result_frame_and_camera(tmp_path: Path) assert any( item["semantic_hint"] == "dog" for item in replay_frame["camera_proposals"] ) + camera_points = client.get( + f"/api/v1/laboratory/m48s/fixed-class-detector/{result_id}" + "/timeline/frames/253/camera-points" + ) + assert camera_points.status_code == 200 + camera_point_payload = camera_points.json() + assert camera_point_payload["schema_version"] == "missioncore.m48s-camera-point-overlay/v1" + assert camera_point_payload["projection"] == ( + "factory-kb4-causal-registered-accumulation" + ) + assert camera_point_payload["source_frame_count"] > 1 + assert camera_point_payload["sample_count"] > replay_frame["camera_projected_sample_count"] + assert camera_point_payload["sample_count"] <= 20_000 frame = client.get(f"/api/v1/laboratory/m48s/fixed-class-detector/{result_id}/frames/000253") assert frame.status_code == 200