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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user