feat(lab): project system obstacles into fisheye

This commit is contained in:
DCCONSTRUCTIONS
2026-08-26 15:31:06 +03:00
parent 30227b661f
commit 5a9738d6aa
8 changed files with 424 additions and 10 deletions
@@ -70,6 +70,14 @@ export interface M4ThreatAssessment {
reasonCodes: readonly string[];
}
export interface M4ThreatCameraObstacleProjection {
bboxXyxy: readonly [number, number, number, number];
nearestDepthM: number;
projectedCellCount: number;
projection: "factory-kb4-occupied-voxel-bounds";
authority: "visual-derived";
}
export interface M4ThreatMetricVisual {
componentId: string;
state: "current" | "retained" | "held" | "expired";
@@ -78,6 +86,7 @@ export interface M4ThreatMetricVisual {
cellCentersBodyXyzM: readonly M4Point3[];
assessment: M4ThreatAssessment;
occupancySource: M4OccupancySource;
cameraProjection: M4ThreatCameraObstacleProjection | null;
}
export interface M4ThreatCameraProposal {
@@ -177,6 +186,7 @@ export interface M4ThreatTimeline {
cameraPointSampleLimit: number;
worldStateDelivery: "source-paced-latest-wins" | null;
occupancyProvenanceDelivery: "baseline-versus-additive-component-diff" | null;
cameraObstacleProjectionDelivery: "factory-kb4-occupied-voxel-bounds" | null;
worldStateFrameCount: number;
supersededFrameCount: number;
sourceRepresentationId: "registered-map-increment-v1";
@@ -323,6 +333,32 @@ function parseCameraProposal(value: unknown): M4ThreatCameraProposal {
};
}
function parseCameraObstacleProjection(value: unknown): M4ThreatCameraObstacleProjection {
const item = object(value, "M4.8R3 camera obstacle projection");
const bbox = vector(item.bbox_xyxy, 4, "M4.8R3 camera obstacle bbox");
if (bbox[2]! < bbox[0]! || bbox[3]! < bbox[1]!) {
throw new M4ThreatContractError("M4.8R3 camera obstacle bbox: нарушена геометрия.");
}
return {
bboxXyxy: [bbox[0]!, bbox[1]!, bbox[2]!, bbox[3]!],
nearestDepthM: number(item.nearest_depth_m, "M4.8R3 camera obstacle depth"),
projectedCellCount: integer(
item.projected_cell_count,
"M4.8R3 camera obstacle cells",
),
projection: exact(
item.projection,
"factory-kb4-occupied-voxel-bounds",
"M4.8R3 camera obstacle projection",
),
authority: exact(
item.authority,
"visual-derived",
"M4.8R3 camera obstacle authority",
),
};
}
function parseMetricVisual(value: unknown): M4ThreatMetricVisual {
const item = object(value, "M4.6 metric visual");
const state = text(item.state, "M4.6 temporal state");
@@ -347,6 +383,9 @@ function parseMetricVisual(value: unknown): M4ThreatMetricVisual {
),
assessment: parseAssessment(item.assessment),
occupancySource,
cameraProjection: item.camera_projection === undefined
? null
: parseCameraObstacleProjection(item.camera_projection),
};
}
@@ -704,6 +743,13 @@ export async function fetchM4ThreatTimeline(
"baseline-versus-additive-component-diff",
"M4.8R3 occupancy provenance",
),
cameraObstacleProjectionDelivery: payload.camera_obstacle_projection_delivery == null
? null
: exact(
payload.camera_obstacle_projection_delivery,
"factory-kb4-occupied-voxel-bounds",
"M4.8R3 camera obstacle projection delivery",
),
worldStateFrameCount: payload.world_state_frame_count === undefined
? frameCount
: integer(payload.world_state_frame_count, "M4.6 world-state frames"),
@@ -756,16 +802,21 @@ export async function fetchM4ThreatTimelineChunk(
fetcher = fetch,
signal,
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
cameraObstacleProjectionDelivery = null,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
endpointRoot?: string;
cameraObstacleProjectionDelivery?: M4ThreatTimeline["cameraObstacleProjectionDelivery"];
} = {},
): Promise<M4ThreatTimelineChunk> {
const params = new URLSearchParams({
start: String(startSequence),
count: String(frameCount),
});
if (cameraObstacleProjectionDelivery !== null) {
params.set("obstacle_projection", cameraObstacleProjectionDelivery);
}
const response = await fetcher(
`${endpointRoot}/${result}/timeline/chunk?${params}`,
{ headers: { Accept: "application/json" }, signal },
@@ -47,6 +47,7 @@ import {
useM4ThreatTimelineMetadata,
} from "./useM4ThreatTimeline";
import { useE47SemanticTimelineFrame } from "./useE47SemanticTimeline";
import { buildM4StaticObstacleBoxes } from "./m4StaticObstacleBoxes";
type M4ThreatMediaMode = "video" | "camera";
type M4ThreatMediaSelection = M4ThreatMediaMode | "none";
@@ -132,6 +133,7 @@ export function M4ReplayThreatVisual({
const [showMediaSemantic, setShowMediaSemantic] = useState(true);
const [showSpatialSemantic, setShowSpatialSemantic] = useState(true);
const [showMediaPoints, setShowMediaPoints] = useState(false);
const [showStaticObstacles, setShowStaticObstacles] = useState(true);
const [splitPrimarySize, setSplitPrimarySize] = useState(50);
const [splitOrientation, setSplitOrientation] = useState<SplitPaneOrientation>(() => (
typeof window !== "undefined" && window.matchMedia("(max-width: 900px)").matches
@@ -287,9 +289,26 @@ export function M4ReplayThreatVisual({
};
});
}, [frame, metadata.timeline, reviewAnchors, showReviewAnchorBoxes]);
const staticObstacleBoxes = useMemo<readonly RecordedEvidenceBox[]>(() => {
const timeline = metadata.timeline;
if (
!frame
|| !timeline?.cameraObstacleProjectionDelivery
|| !showStaticObstacles
) return [];
return buildM4StaticObstacleBoxes(
frame.metricObstacles,
timeline.imageWidth,
timeline.imageHeight,
);
}, [frame, metadata.timeline, showStaticObstacles]);
const activeBoxes = useMemo(
() => [...boxes(frame?.cameraProposals ?? []), ...reviewAnchorBoxes],
[frame, reviewAnchorBoxes],
() => [
...boxes(frame?.cameraProposals ?? []),
...staticObstacleBoxes,
...reviewAnchorBoxes,
],
[frame, reviewAnchorBoxes, staticObstacleBoxes],
);
const semanticClasses = useMemo<readonly RecordedEvidenceSemanticClass[]>(
() => semantic?.taxonomy.map((item) => ({
@@ -359,7 +378,8 @@ export function M4ReplayThreatVisual({
(item) => item.state === "retained",
) ?? [];
const lowStepObstacles = spatialFrame?.metricObstacles.filter(
(item) => item.occupancySource !== "baseline",
(item) => item.occupancySource !== "baseline"
&& (item.state === "current" || item.state === "retained"),
) ?? [];
const nearest = spatialFrame?.metricObstacles
.map((item) => item.assessment.closestApproachM)
@@ -456,7 +476,9 @@ export function M4ReplayThreatVisual({
</div>
);
const mediaLayerControls = semantic || metadata.timeline?.cameraPointDelivery ? (
const mediaLayerControls = semantic
|| metadata.timeline?.cameraPointDelivery
|| metadata.timeline?.cameraObstacleProjectionDelivery ? (
<div
className="m4-replay-threat-visual__pane-layer-controls"
role="group"
@@ -485,6 +507,18 @@ export function M4ReplayThreatVisual({
POINTS
</Button>
) : null}
{metadata.timeline?.cameraObstacleProjectionDelivery ? (
<Button
size="compact"
shape="pill"
variant={showStaticObstacles ? "primary" : "secondary"}
aria-pressed={showStaticObstacles}
title="Автоматические рамки занятых LiDAR-компонентов · без ручной разметки"
onClick={() => setShowStaticObstacles((visible) => !visible)}
>
OBSTACLES
</Button>
) : null}
</div>
) : null;
@@ -0,0 +1,80 @@
import type { RecordedEvidenceBox } from "../../components/laboratory/RecordedEvidenceVideoScene";
import type { M4ThreatMetricVisual } from "../../core/laboratory/m4ReplayThreat";
const MINIMUM_BOX_SIZE_PX = 12;
function shortComponentId(componentId: string): string {
const finalSegment = componentId.split(/[-_]/).pop();
return finalSegment && /^\d+$/.test(finalSegment)
? finalSegment
: componentId.slice(-6).toUpperCase();
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
function visibleBox(
box: readonly [number, number, number, number],
imageWidth: number,
imageHeight: number,
): readonly [number, number, number, number] | null {
const [sourceLeft, sourceTop, sourceRight, sourceBottom] = box;
if (
sourceRight < 0
|| sourceBottom < 0
|| sourceLeft >= imageWidth
|| sourceTop >= imageHeight
) return null;
const centerX = (sourceLeft + sourceRight) / 2;
const centerY = (sourceTop + sourceBottom) / 2;
const halfWidth = Math.max((sourceRight - sourceLeft) / 2, MINIMUM_BOX_SIZE_PX / 2);
const halfHeight = Math.max((sourceBottom - sourceTop) / 2, MINIMUM_BOX_SIZE_PX / 2);
const left = clamp(centerX - halfWidth, 0, imageWidth - 1);
const top = clamp(centerY - halfHeight, 0, imageHeight - 1);
const right = clamp(centerX + halfWidth, left + 1, imageWidth);
const bottom = clamp(centerY + halfHeight, top + 1, imageHeight);
return [left, top, right, bottom];
}
function tone(obstacle: M4ThreatMetricVisual): RecordedEvidenceBox["tone"] {
if (obstacle.assessment.decision === "threat") return "danger";
if (obstacle.assessment.decision === "not-threat") return "success";
return "warning";
}
/**
* Build camera evidence from the same world-state components shown in 3D.
* No image detector or manual review extent participates in these boxes.
*/
export function buildM4StaticObstacleBoxes(
obstacles: readonly M4ThreatMetricVisual[],
imageWidth: number,
imageHeight: number,
): readonly RecordedEvidenceBox[] {
const result: RecordedEvidenceBox[] = [];
for (const obstacle of obstacles) {
if (
obstacle.occupancySource === "baseline"
|| (obstacle.state !== "current" && obstacle.state !== "retained")
|| obstacle.cameraProjection === null
) continue;
const projection = obstacle.cameraProjection;
const boxXyxy = visibleBox(projection.bboxXyxy, imageWidth, imageHeight);
if (!boxXyxy) continue;
const depth = projection.nearestDepthM.toLocaleString("ru-RU", {
maximumFractionDigits: 1,
});
result.push({
boxXyxy,
label: `OBS #${shortComponentId(obstacle.componentId)} · ${depth} м`,
tone: tone(obstacle),
dashed: false,
});
}
return result.sort((left, right) => {
const leftArea = (left.boxXyxy[2] - left.boxXyxy[0]) * (left.boxXyxy[3] - left.boxXyxy[1]);
const rightArea = (right.boxXyxy[2] - right.boxXyxy[0]) * (right.boxXyxy[3] - right.boxXyxy[1]);
return rightArea - leftArea;
});
}
@@ -130,6 +130,7 @@ export function useM4ThreatTimelineFrame({
void fetchM4ThreatTimelineChunk(resultId, start, chunkSize, {
signal: controller.signal,
endpointRoot,
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
})
.then((chunk) => {
if (controller.signal.aborted) return;
@@ -17,6 +17,7 @@ let synchronizeRecordedEvidencePlayback;
let m4ThreatChunkWindowStarts;
let cancelM4ThreatChunkRequestsOutsideWindow;
let buildM4LocalSurface;
let buildM4StaticObstacleBoxes;
const resultId = `m4-threat-replay-${"a".repeat(64)}`;
@@ -50,6 +51,9 @@ before(async () => {
({ buildM4LocalSurface } = await server.ssrLoadModule(
"/src/core/laboratory/m4LocalSurface.ts",
));
({ buildM4StaticObstacleBoxes } = await server.ssrLoadModule(
"/src/workspaces/laboratory/m4StaticObstacleBoxes.ts",
));
});
after(async () => {
@@ -365,6 +369,8 @@ test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint"
camera_point_window_seconds: 2,
camera_point_sample_limit: 20000,
world_state_delivery: "source-paced-latest-wins",
occupancy_provenance_delivery: "baseline-versus-additive-component-diff",
camera_obstacle_projection_delivery: "factory-kb4-occupied-voxel-bounds",
world_state_frame_count: 4481,
superseded_frame_count: 8,
local_surface_visualization: {
@@ -392,9 +398,14 @@ test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint"
assert.equal(timeline.cameraPointWindowSeconds, 2);
assert.equal(timeline.worldStateFrameCount, 4481);
assert.equal(timeline.supersededFrameCount, 8);
assert.equal(
timeline.cameraObstacleProjectionDelivery,
"factory-kb4-occupied-voxel-bounds",
);
const chunk = await fetchM4ThreatTimelineChunk(replayResultId, 1, 1, {
endpointRoot,
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
fetcher: async (input) => {
requested = String(input);
return new Response(JSON.stringify({
@@ -411,6 +422,30 @@ test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint"
camera_projected_point_count: 1,
camera_projected_sample_count: 1,
camera_projection: "factory-kb4-exact",
metric_obstacles: [{
component_id: "temporal-1855-7",
state: "current",
motion: "stationary",
centroid_body_xyz_m: [2.4, 0.1, 0.3],
cell_centers_body_xyz_m: [[2.4, 0.1, 0.3]],
occupancy_source: "additive-low-step",
camera_projection: {
bbox_xyxy: [390.5, 280.25, 408.75, 318.5],
nearest_depth_m: 2.3,
projected_cell_count: 1,
projection: "factory-kb4-occupied-voxel-bounds",
authority: "visual-derived",
},
assessment: {
component_id: "temporal-1855-7",
decision: "threat",
corridor_intersection: "intersects",
relative_speed_mps: null,
closest_approach_m: 0.2,
ttc_seconds: null,
reason_codes: ["current-corridor-intersection"],
},
}],
camera_url: `${endpointRoot}/${replayResultId}/timeline/frames/1/camera`,
})],
authority: "replay-simulated",
@@ -418,10 +453,18 @@ test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint"
},
});
assert.match(requested, new RegExp(`^${endpointRoot}/${replayResultId}/timeline/chunk`));
assert.match(
requested,
/obstacle_projection=factory-kb4-occupied-voxel-bounds/,
);
assert.equal(chunk.frames[0].worldStateAvailable, false);
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");
assert.deepEqual(
chunk.frames[0].metricObstacles[0].cameraProjection.bboxXyxy,
[390.5, 280.25, 408.75, 318.5],
);
const overlay = await fetchM4ThreatCameraPointOverlay(replayResultId, 1, {
endpointRoot,
@@ -453,6 +496,43 @@ test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint"
assert.equal(overlay.projection, "factory-kb4-causal-registered-accumulation");
});
test("M4.8R3 turns active low-step components into native fisheye obstacle boxes", () => {
const obstacle = {
componentId: "temporal-1855-7",
state: "current",
motion: "stationary",
centroidBodyXyzM: [2.4, 0.1, 0.3],
cellCentersBodyXyzM: [[2.4, 0.1, 0.3]],
occupancySource: "additive-low-step",
cameraProjection: {
bboxXyxy: [398, 298, 402, 302],
nearestDepthM: 2.3,
projectedCellCount: 1,
projection: "factory-kb4-occupied-voxel-bounds",
authority: "visual-derived",
},
assessment: {
componentId: "temporal-1855-7",
decision: "threat",
corridorIntersection: "intersects",
relativeSpeedMps: null,
closestApproachM: 0.2,
ttcSeconds: null,
reasonCodes: ["current-corridor-intersection"],
},
};
const boxes = buildM4StaticObstacleBoxes([
obstacle,
{ ...obstacle, componentId: "baseline", occupancySource: "baseline" },
{ ...obstacle, componentId: "held", state: "held" },
], 800, 600);
assert.equal(boxes.length, 1);
assert.deepEqual(boxes[0].boxXyxy, [394, 294, 406, 306]);
assert.equal(boxes[0].label, "OBS #7 · 2,3 м");
assert.equal(boxes[0].tone, "danger");
assert.equal(boxes[0].dashed, false);
});
test("M4.6 local SLAM surface reprojects registered increments into the active body frame", () => {
const frames = [
timelineFrame(0, 10, {
@@ -603,6 +683,9 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
assert.match(visual, /label: "3D"/);
assert.match(visual, /label: "PLAN"/);
assert.match(visual, />\s*POINTS\s*</);
assert.match(visual, />\s*OBSTACLES\s*</);
assert.match(visual, /useState\(true\);[\s\S]*setShowStaticObstacles/);
assert.match(visual, /buildM4StaticObstacleBoxes/);
assert.match(visual, /pointCloudOverlay=/);
assert.match(visual, /mediaMode/);
assert.match(visual, /spatialMode/);