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[];
|
pointsXyd: readonly RecordedEvidenceProjectedPoint[];
|
||||||
sourcePointCount: number;
|
sourcePointCount: number;
|
||||||
projectedPointCount: number;
|
projectedPointCount: number;
|
||||||
projection: "factory-kb4-exact";
|
projection:
|
||||||
|
| "factory-kb4-exact"
|
||||||
|
| "factory-kb4-causal-registered-accumulation";
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEPTH_BUCKETS = 64;
|
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 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;
|
const hue = 18 + bucket / (DEPTH_BUCKETS - 1) * 190;
|
||||||
return `hsla(${hue}, 96%, 62%, 0.86)`;
|
return `hsla(${hue}, 96%, 62%, 0.86)`;
|
||||||
}
|
}
|
||||||
@@ -52,16 +57,26 @@ export function RecordedEvidencePointCloudOverlay({
|
|||||||
const offsetX = (width - imageWidth * scale) / 2;
|
const offsetX = (width - imageWidth * scale) / 2;
|
||||||
const offsetY = (height - imageHeight * scale) / 2;
|
const offsetY = (height - imageHeight * scale) / 2;
|
||||||
const radius = Math.max(0.8, Math.min(2.2, scale * 1.45));
|
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) {
|
for (const [imageX, imageY, depthM] of overlay.pointsXyd) {
|
||||||
context.beginPath();
|
const bucket = depthBucket(depthM);
|
||||||
context.arc(
|
buckets[bucket]!.push([
|
||||||
offsetX + imageX * scale,
|
offsetX + imageX * scale,
|
||||||
offsetY + imageY * scale,
|
offsetY + imageY * scale,
|
||||||
radius,
|
]);
|
||||||
0,
|
}
|
||||||
Math.PI * 2,
|
for (let bucket = 0; bucket < buckets.length; bucket += 1) {
|
||||||
);
|
const points = buckets[bucket];
|
||||||
context.fillStyle = depthColor(depthM);
|
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();
|
context.fill();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -170,7 +170,8 @@ export interface M4ThreatTimeline {
|
|||||||
pointSampleLimit: number;
|
pointSampleLimit: number;
|
||||||
maximumSourcePointsPerFrame: number;
|
maximumSourcePointsPerFrame: number;
|
||||||
pointDelivery: "exact-current-increment";
|
pointDelivery: "exact-current-increment";
|
||||||
cameraPointDelivery: "factory-kb4-projected-current-increment" | null;
|
cameraPointDelivery: "factory-kb4-causal-registered-accumulation" | null;
|
||||||
|
cameraPointWindowSeconds: number;
|
||||||
cameraPointSampleLimit: number;
|
cameraPointSampleLimit: number;
|
||||||
worldStateDelivery: "source-paced-latest-wins" | null;
|
worldStateDelivery: "source-paced-latest-wins" | null;
|
||||||
worldStateFrameCount: number;
|
worldStateFrameCount: number;
|
||||||
@@ -189,6 +190,21 @@ export interface M4ThreatTimeline {
|
|||||||
corridor: M4ThreatVisualFrame["corridor"];
|
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 {
|
export interface M4ThreatTimelineChunk {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
startSequence: number;
|
startSequence: number;
|
||||||
@@ -651,9 +667,12 @@ export async function fetchM4ThreatTimeline(
|
|||||||
? null
|
? null
|
||||||
: exact(
|
: exact(
|
||||||
payload.camera_point_delivery,
|
payload.camera_point_delivery,
|
||||||
"factory-kb4-projected-current-increment",
|
"factory-kb4-causal-registered-accumulation",
|
||||||
"M4.6 camera point delivery",
|
"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
|
cameraPointSampleLimit: payload.camera_point_sample_limit === undefined
|
||||||
? 0
|
? 0
|
||||||
: integer(payload.camera_point_sample_limit, "M4.6 camera point limit"),
|
: 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(
|
function parseTimelineFrame(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
result: string,
|
result: string,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { recordedObservationSources } from "../../core/observation/recordedObser
|
|||||||
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
||||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||||
import {
|
import {
|
||||||
|
useM4ThreatCameraPointOverlay,
|
||||||
useM4ThreatTimelineFrame,
|
useM4ThreatTimelineFrame,
|
||||||
useM4ThreatTimelineMetadata,
|
useM4ThreatTimelineMetadata,
|
||||||
} from "./useM4ThreatTimeline";
|
} from "./useM4ThreatTimeline";
|
||||||
@@ -224,6 +225,12 @@ export function M4ReplayThreatVisual({
|
|||||||
: lastSpatialFrameRef.current?.resultId === resultId
|
: lastSpatialFrameRef.current?.resultId === resultId
|
||||||
? lastSpatialFrameRef.current.frame
|
? lastSpatialFrameRef.current.frame
|
||||||
: null;
|
: null;
|
||||||
|
const cameraPointOverlay = useM4ThreatCameraPointOverlay({
|
||||||
|
enabled: showMediaPoints,
|
||||||
|
resultId,
|
||||||
|
sequence: frame?.sequence ?? null,
|
||||||
|
endpointRoot: timelineEndpointRoot,
|
||||||
|
});
|
||||||
const semanticTimeline = useE47SemanticTimelineFrame({
|
const semanticTimeline = useE47SemanticTimelineFrame({
|
||||||
resultId: semantic?.resultId ?? null,
|
resultId: semantic?.resultId ?? null,
|
||||||
activeSequence: frame?.sequence ?? timelineFrame.activeSequence,
|
activeSequence: frame?.sequence ?? timelineFrame.activeSequence,
|
||||||
@@ -373,16 +380,27 @@ export function M4ReplayThreatVisual({
|
|||||||
ariaLabel: `E47 semantic mask frame ${frame.sequence + 1}`,
|
ariaLabel: `E47 semantic mask frame ${frame.sequence + 1}`,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const accumulatedCameraPoints = cameraPointOverlay.overlay?.sequence === frame?.sequence
|
||||||
|
? cameraPointOverlay.overlay
|
||||||
|
: null;
|
||||||
const pointCloudOverlay: RecordedEvidencePointCloudOverlayData | undefined =
|
const pointCloudOverlay: RecordedEvidencePointCloudOverlayData | undefined =
|
||||||
showMediaPoints && frame?.cameraProjection === "factory-kb4-exact"
|
showMediaPoints && accumulatedCameraPoints
|
||||||
? {
|
? {
|
||||||
pointsXyd: frame.cameraProjectedPointsXyd,
|
pointsXyd: accumulatedCameraPoints.pointsXyd,
|
||||||
sourcePointCount: frame.cameraProjectedSourceCount,
|
sourcePointCount: accumulatedCameraPoints.sourcePointCount,
|
||||||
projectedPointCount: frame.cameraProjectedPointCount,
|
projectedPointCount: accumulatedCameraPoints.projectedPointCount,
|
||||||
projection: "factory-kb4-exact",
|
projection: accumulatedCameraPoints.projection,
|
||||||
ariaLabel: `${evidenceLabel} LiDAR projection: ${frame.cameraProjectedSampleCount} points`,
|
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) => {
|
const handleMediaModeChange = (next: M4ThreatMediaSelection) => {
|
||||||
if (next === "none") return;
|
if (next === "none") return;
|
||||||
@@ -452,7 +470,7 @@ export function M4ReplayThreatVisual({
|
|||||||
shape="pill"
|
shape="pill"
|
||||||
variant={showMediaPoints ? "primary" : "secondary"}
|
variant={showMediaPoints ? "primary" : "secondary"}
|
||||||
aria-pressed={showMediaPoints}
|
aria-pressed={showMediaPoints}
|
||||||
title="Exact LiDAR increment · factory KB4 camera projection"
|
title="Actual registered LiDAR · causal accumulation · factory KB4 projection"
|
||||||
onClick={() => setShowMediaPoints((visible) => !visible)}
|
onClick={() => setShowMediaPoints((visible) => !visible)}
|
||||||
>
|
>
|
||||||
POINTS
|
POINTS
|
||||||
@@ -608,9 +626,13 @@ export function M4ReplayThreatVisual({
|
|||||||
{frame.worldStateAvailable
|
{frame.worldStateAvailable
|
||||||
? " · world-state delivered"
|
? " · world-state delivered"
|
||||||
: ` · world-state gap (${frame.terminalOutcome})`}
|
: ` · world-state gap (${frame.terminalOutcome})`}
|
||||||
{pointCloudOverlay
|
{accumulatedCameraPoints
|
||||||
? ` · camera points ${frame.cameraProjectedSampleCount}/${frame.cameraProjectedPointCount}`
|
? ` · 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 && spatialSemanticFrame
|
||||||
? ` · semantic L ${spatialSemanticFrame.counts.labeled} · A ${spatialSemanticFrame.counts.ambiguous} · U ${spatialSemanticFrame.counts.unprojected} · Ø ${spatialSemanticFrame.counts.absent}`
|
? ` · semantic L ${spatialSemanticFrame.counts.labeled} · A ${spatialSemanticFrame.counts.ambiguous} · U ${spatialSemanticFrame.counts.unprojected} · Ø ${spatialSemanticFrame.counts.absent}`
|
||||||
: semantic ? " · semantic buffer" : ""}
|
: semantic ? " · semantic buffer" : ""}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
fetchM4ThreatCameraPointOverlay,
|
||||||
fetchM4ThreatTimeline,
|
fetchM4ThreatTimeline,
|
||||||
fetchM4ThreatTimelineChunk,
|
fetchM4ThreatTimelineChunk,
|
||||||
selectM4ThreatTimelineSequence,
|
selectM4ThreatTimelineSequence,
|
||||||
|
type M4ThreatCameraPointOverlay,
|
||||||
type M4ThreatTimeline,
|
type M4ThreatTimeline,
|
||||||
type M4ThreatTimelineChunk,
|
type M4ThreatTimelineChunk,
|
||||||
type M4ThreatTimelineFrame,
|
type M4ThreatTimelineFrame,
|
||||||
} from "../../core/laboratory/m4ReplayThreat";
|
} from "../../core/laboratory/m4ReplayThreat";
|
||||||
|
|
||||||
const REQUESTED_CHUNK_FRAMES = 24;
|
const REQUESTED_CHUNK_FRAMES = 24;
|
||||||
const RETAINED_CHUNK_COUNT = 8;
|
const RETAINED_CHUNK_COUNT = 4;
|
||||||
const PREFETCH_CHUNKS_AHEAD = 2;
|
const PREFETCH_CHUNKS_AHEAD = 1;
|
||||||
|
const RETAINED_CAMERA_POINT_OVERLAYS = 12;
|
||||||
|
|
||||||
function errorMessage(error: unknown, fallback: string): string {
|
function errorMessage(error: unknown, fallback: string): string {
|
||||||
return error instanceof Error && error.message.trim() ? error.message : fallback;
|
return error instanceof Error && error.message.trim() ? error.message : fallback;
|
||||||
@@ -24,8 +27,8 @@ export function m4ThreatChunkWindowStarts(
|
|||||||
): readonly number[] {
|
): readonly number[] {
|
||||||
if (chunkSize < 1 || frameCount < 1) return [];
|
if (chunkSize < 1 || frameCount < 1) return [];
|
||||||
return Array.from(
|
return Array.from(
|
||||||
{ length: PREFETCH_CHUNKS_AHEAD + 2 },
|
{ length: PREFETCH_CHUNKS_AHEAD + 1 },
|
||||||
(_, index) => activeChunkStart + (index - 1) * chunkSize,
|
(_, index) => activeChunkStart + index * chunkSize,
|
||||||
).filter((start) => start >= 0 && start < frameCount);
|
).filter((start) => start >= 0 && start < frameCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,8 +156,11 @@ export function useM4ThreatTimelineFrame({
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (inFlight.current.get(start) === controller) inFlight.current.delete(start);
|
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(() => {
|
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
|
||||||
if (activeSequence === null || activeChunkStart === null) return null;
|
if (activeSequence === null || activeChunkStart === null) return null;
|
||||||
@@ -178,3 +184,127 @@ export function useM4ThreatTimelineFrame({
|
|||||||
error,
|
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 fetchM4ThreatVisual;
|
||||||
let fetchM4ThreatTimeline;
|
let fetchM4ThreatTimeline;
|
||||||
let fetchM4ThreatTimelineChunk;
|
let fetchM4ThreatTimelineChunk;
|
||||||
|
let fetchM4ThreatCameraPointOverlay;
|
||||||
let selectM4ThreatTimelineFrame;
|
let selectM4ThreatTimelineFrame;
|
||||||
let selectM4ThreatTimelineSequence;
|
let selectM4ThreatTimelineSequence;
|
||||||
let advanceRecordedEvidencePlayback;
|
let advanceRecordedEvidencePlayback;
|
||||||
@@ -30,6 +31,7 @@ before(async () => {
|
|||||||
fetchM4ThreatVisual,
|
fetchM4ThreatVisual,
|
||||||
fetchM4ThreatTimeline,
|
fetchM4ThreatTimeline,
|
||||||
fetchM4ThreatTimelineChunk,
|
fetchM4ThreatTimelineChunk,
|
||||||
|
fetchM4ThreatCameraPointOverlay,
|
||||||
selectM4ThreatTimelineFrame,
|
selectM4ThreatTimelineFrame,
|
||||||
selectM4ThreatTimelineSequence,
|
selectM4ThreatTimelineSequence,
|
||||||
} = await server.ssrLoadModule("/src/core/laboratory/m4ReplayThreat.ts"));
|
} = 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,
|
point_sample_limit: 4096,
|
||||||
maximum_source_points_per_frame: 3092,
|
maximum_source_points_per_frame: 3092,
|
||||||
point_delivery: "exact-current-increment",
|
point_delivery: "exact-current-increment",
|
||||||
camera_point_delivery: "factory-kb4-projected-current-increment",
|
camera_point_delivery: "factory-kb4-causal-registered-accumulation",
|
||||||
camera_point_sample_limit: 4096,
|
camera_point_window_seconds: 2,
|
||||||
|
camera_point_sample_limit: 20000,
|
||||||
world_state_delivery: "source-paced-latest-wins",
|
world_state_delivery: "source-paced-latest-wins",
|
||||||
world_state_frame_count: 4481,
|
world_state_frame_count: 4481,
|
||||||
superseded_frame_count: 8,
|
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(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.worldStateFrameCount, 4481);
|
||||||
assert.equal(timeline.supersededFrameCount, 8);
|
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.equal(chunk.frames[0].terminalOutcome, "superseded");
|
||||||
assert.deepEqual(chunk.frames[0].cameraProjectedPointsXyd[0], [100.5, 200.25, 3.75]);
|
assert.deepEqual(chunk.frames[0].cameraProjectedPointsXyd[0], [100.5, 200.25, 3.75]);
|
||||||
assert.equal(chunk.frames[0].cameraProjection, "factory-kb4-exact");
|
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", () => {
|
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]]);
|
assert.deepEqual(surface.pointsBodyXyzM, [[0, 1, 0], [1, 0, 0]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("M4.6 spatial buffering keeps previous, active and two future chunks", () => {
|
test("M4.6 spatial buffering keeps the active and one future chunk", () => {
|
||||||
assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [24, 48, 72, 96]);
|
assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 72]);
|
||||||
assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24, 48]);
|
assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("M4.6 spatial buffering drops stale in-flight windows across rapid jumps", () => {
|
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));
|
if (!inFlight.has(start)) inFlight.set(start, controller(start));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert.deepEqual([...inFlight.keys()], [4464, 4488]);
|
assert.deepEqual([...inFlight.keys()], [4488]);
|
||||||
assert.deepEqual(aborted, [0, 24, 48, 1464, 1488, 1512, 1536]);
|
assert.deepEqual(aborted, [0, 24, 1488, 1512]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("recorded evidence clock advances by selected rate and stops at the sealed end", () => {
|
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(imageScene, /<RecordedEvidencePointCloudOverlay/);
|
||||||
assert.match(videoScene, /<RecordedEvidencePointCloudOverlay/);
|
assert.match(videoScene, /<RecordedEvidencePointCloudOverlay/);
|
||||||
assert.match(pointOverlay, /factory-kb4-exact/);
|
assert.match(pointOverlay, /factory-kb4-exact/);
|
||||||
|
assert.match(pointOverlay, /factory-kb4-causal-registered-accumulation/);
|
||||||
assert.match(metricScene, /OrbitControls/);
|
assert.match(metricScene, /OrbitControls/);
|
||||||
assert.match(visual, /LOCAL SLAM/);
|
assert.match(visual, /LOCAL SLAM/);
|
||||||
assert.match(visual, /showLocalSurface/);
|
assert.match(visual, /showLocalSurface/);
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import copy
|
|||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import statistics
|
import statistics
|
||||||
|
from bisect import bisect_left
|
||||||
|
from collections import OrderedDict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from itertools import pairwise
|
from itertools import pairwise
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import RLock
|
from threading import Lock
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -37,6 +39,14 @@ from .threat_timeline import (
|
|||||||
|
|
||||||
FRAME_EVIDENCE_SCHEMA: Final = "missioncore.m48s-reference-graph-frame-evidence/v0"
|
FRAME_EVIDENCE_SCHEMA: Final = "missioncore.m48s-reference-graph-frame-evidence/v0"
|
||||||
EXPECTED_FRAME_COUNT: Final = 4_489
|
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):
|
class M48sReplayTimelineError(RuntimeError):
|
||||||
@@ -87,7 +97,9 @@ class M48sReplayTimeline:
|
|||||||
worker = _object(json.loads(self.worker_path.read_text("utf-8")), "worker result")
|
worker = _object(json.loads(self.worker_path.read_text("utf-8")), "worker result")
|
||||||
self.outcomes = _terminal_outcomes(worker)
|
self.outcomes = _terminal_outcomes(worker)
|
||||||
self.index = _index_ledger(self.frames_path, self.source_times_ns, self.outcomes)
|
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]:
|
def metadata(self) -> dict[str, object]:
|
||||||
intervals = [
|
intervals = [
|
||||||
@@ -114,11 +126,14 @@ class M48sReplayTimeline:
|
|||||||
"point_sample_limit": RECORDED_SPATIAL_POINT_LIMIT,
|
"point_sample_limit": RECORDED_SPATIAL_POINT_LIMIT,
|
||||||
"maximum_source_points_per_frame": self.store.maximum_current_point_count,
|
"maximum_source_points_per_frame": self.store.maximum_current_point_count,
|
||||||
"point_delivery": "exact-current-increment",
|
"point_delivery": "exact-current-increment",
|
||||||
"camera_point_delivery": "factory-kb4-projected-current-increment",
|
"camera_point_delivery": "factory-kb4-causal-registered-accumulation",
|
||||||
"camera_point_sample_limit": RECORDED_SPATIAL_POINT_LIMIT,
|
"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_delivery": "source-paced-latest-wins",
|
||||||
"world_state_frame_count": len(self.index.offsets_by_sequence),
|
"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": {
|
"local_surface_visualization": {
|
||||||
"derivation": "bounded-registered-increment-accumulation",
|
"derivation": "bounded-registered-increment-accumulation",
|
||||||
"window_seconds": RECORDED_LOCAL_SURFACE_WINDOW_SECONDS,
|
"window_seconds": RECORDED_LOCAL_SURFACE_WINDOW_SECONDS,
|
||||||
@@ -155,8 +170,7 @@ class M48sReplayTimeline:
|
|||||||
if not 1 <= frame_count <= RECORDED_SPATIAL_MAX_CHUNK_FRAMES:
|
if not 1 <= frame_count <= RECORDED_SPATIAL_MAX_CHUNK_FRAMES:
|
||||||
raise M48sReplayTimelineError("M4.8S timeline chunk size is invalid")
|
raise M48sReplayTimelineError("M4.8S timeline chunk size is invalid")
|
||||||
stop = min(EXPECTED_FRAME_COUNT, start_sequence + frame_count)
|
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 {
|
return {
|
||||||
"schema_version": RECORDED_SPATIAL_CHUNK_SCHEMA,
|
"schema_version": RECORDED_SPATIAL_CHUNK_SCHEMA,
|
||||||
"result_id": self.result_id,
|
"result_id": self.result_id,
|
||||||
@@ -169,6 +183,110 @@ class M48sReplayTimeline:
|
|||||||
"access": "read-only-bounded-recorded-replay",
|
"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]:
|
def _project_frame(self, sequence: int) -> dict[str, object]:
|
||||||
terminal_outcome = self.outcomes[sequence]
|
terminal_outcome = self.outcomes[sequence]
|
||||||
row = self._row(sequence)
|
row = self._row(sequence)
|
||||||
@@ -273,7 +391,9 @@ class M48sReplayTimeline:
|
|||||||
"semantic_hint": proposal.get("semantic_hint"),
|
"semantic_hint": proposal.get("semantic_hint"),
|
||||||
"occupied_support": proposal_id in associated,
|
"occupied_support": proposal_id in associated,
|
||||||
"range_m": None,
|
"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": []
|
"threat_reason_codes": []
|
||||||
if assessment is None
|
if assessment is None
|
||||||
else assessment.get("reason_codes"),
|
else assessment.get("reason_codes"),
|
||||||
@@ -344,10 +464,7 @@ def _index_ledger(
|
|||||||
line = stream.readline()
|
line = stream.readline()
|
||||||
if not line:
|
if not line:
|
||||||
break
|
break
|
||||||
row = json.loads(line)
|
envelope = _ledger_source_envelope(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")
|
|
||||||
timestamps = _object(envelope.get("timestamps"), "source timestamps")
|
timestamps = _object(envelope.get("timestamps"), "source timestamps")
|
||||||
sequence = envelope.get("sequence")
|
sequence = envelope.get("sequence")
|
||||||
if (
|
if (
|
||||||
@@ -366,6 +483,31 @@ def _index_ledger(
|
|||||||
return _LedgerIndex(offsets)
|
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]:
|
def _terminal_outcomes(worker: dict[str, object]) -> dict[int, str]:
|
||||||
execution = _object(worker.get("execution"), "execution")
|
execution = _object(worker.get("execution"), "execution")
|
||||||
loops = execution.get("loops")
|
loops = execution.get("loops")
|
||||||
@@ -423,4 +565,10 @@ def _text(value: object, label: str) -> str:
|
|||||||
return value
|
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,
|
result_id: str,
|
||||||
start: int = Query(default=0, ge=0),
|
start: int = Query(default=0, ge=0),
|
||||||
count: int = Query(default=12, ge=1, le=RECORDED_SPATIAL_MAX_CHUNK_FRAMES),
|
count: int = Query(default=12, ge=1, le=RECORDED_SPATIAL_MAX_CHUNK_FRAMES),
|
||||||
) -> dict[str, object]:
|
) -> Response:
|
||||||
try:
|
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:
|
except M48sReplayTimelineError:
|
||||||
raise HTTPException(status_code=404, detail="M4.8S timeline chunk not found") from None
|
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")
|
@router.get("/{result_id}/timeline/frames/{sequence}/camera")
|
||||||
def get_timeline_camera(result_id: str, sequence: int) -> Response:
|
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()["superseded_frame_count"] == 8
|
||||||
assert (
|
assert (
|
||||||
timeline.json()["camera_point_delivery"]
|
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(
|
chunk = client.get(
|
||||||
f"/api/v1/laboratory/m48s/fixed-class-detector/{result_id}/timeline/chunk",
|
f"/api/v1/laboratory/m48s/fixed-class-detector/{result_id}/timeline/chunk",
|
||||||
params={"start": 253, "count": 1},
|
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(
|
assert any(
|
||||||
item["semantic_hint"] == "dog" for item in replay_frame["camera_proposals"]
|
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")
|
frame = client.get(f"/api/v1/laboratory/m48s/fixed-class-detector/{result_id}/frames/000253")
|
||||||
assert frame.status_code == 200
|
assert frame.status_code == 200
|
||||||
|
|||||||
Reference in New Issue
Block a user