fix(lab): stabilize replay and densify LiDAR overlay
This commit is contained in:
+25
-10
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<M4ThreatCameraPointOverlay> {
|
||||
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,
|
||||
|
||||
@@ -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" : ""}
|
||||
|
||||
@@ -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<M4ThreatCameraPointOverlay | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const desiredRef = useRef<{
|
||||
resultId: string;
|
||||
sequence: number;
|
||||
endpointRoot?: string;
|
||||
} | null>(null);
|
||||
const cacheRef = useRef(new Map<string, M4ThreatCameraPointOverlay>());
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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, /<RecordedEvidencePointCloudOverlay/);
|
||||
assert.match(videoScene, /<RecordedEvidencePointCloudOverlay/);
|
||||
assert.match(pointOverlay, /factory-kb4-exact/);
|
||||
assert.match(pointOverlay, /factory-kb4-causal-registered-accumulation/);
|
||||
assert.match(metricScene, /OrbitControls/);
|
||||
assert.match(visual, /LOCAL SLAM/);
|
||||
assert.match(visual, /showLocalSurface/);
|
||||
|
||||
@@ -6,10 +6,12 @@ import copy
|
||||
import json
|
||||
import math
|
||||
import statistics
|
||||
from bisect import bisect_left
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from itertools import pairwise
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from threading import Lock
|
||||
from typing import Final
|
||||
|
||||
import numpy as np
|
||||
@@ -37,6 +39,14 @@ from .threat_timeline import (
|
||||
|
||||
FRAME_EVIDENCE_SCHEMA: Final = "missioncore.m48s-reference-graph-frame-evidence/v0"
|
||||
EXPECTED_FRAME_COUNT: Final = 4_489
|
||||
CAMERA_ACCUMULATION_WINDOW_SECONDS: Final = 2.0
|
||||
CAMERA_ACCUMULATION_POINT_LIMIT: Final = 20_000
|
||||
CAMERA_POINT_OVERLAY_SCHEMA: Final = "missioncore.m48s-camera-point-overlay/v1"
|
||||
_FRAME_EVIDENCE_SCHEMA_MARKER: Final = (
|
||||
b'"schema_version":"missioncore.m48s-reference-graph-frame-evidence/v0"'
|
||||
)
|
||||
_SOURCE_ENVELOPE_MARKER: Final = b'"source_envelope":'
|
||||
_JSON_DECODER: Final = json.JSONDecoder()
|
||||
|
||||
|
||||
class M48sReplayTimelineError(RuntimeError):
|
||||
@@ -87,7 +97,9 @@ class M48sReplayTimeline:
|
||||
worker = _object(json.loads(self.worker_path.read_text("utf-8")), "worker result")
|
||||
self.outcomes = _terminal_outcomes(worker)
|
||||
self.index = _index_ledger(self.frames_path, self.source_times_ns, self.outcomes)
|
||||
self._lock = RLock()
|
||||
self._cache_lock = Lock()
|
||||
self._chunk_json_cache: OrderedDict[tuple[int, int], bytes] = OrderedDict()
|
||||
self._camera_point_json_cache: OrderedDict[int, bytes] = OrderedDict()
|
||||
|
||||
def metadata(self) -> 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",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user