feat(lab): project system obstacles into fisheye
This commit is contained in:
@@ -70,6 +70,14 @@ export interface M4ThreatAssessment {
|
|||||||
reasonCodes: readonly string[];
|
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 {
|
export interface M4ThreatMetricVisual {
|
||||||
componentId: string;
|
componentId: string;
|
||||||
state: "current" | "retained" | "held" | "expired";
|
state: "current" | "retained" | "held" | "expired";
|
||||||
@@ -78,6 +86,7 @@ export interface M4ThreatMetricVisual {
|
|||||||
cellCentersBodyXyzM: readonly M4Point3[];
|
cellCentersBodyXyzM: readonly M4Point3[];
|
||||||
assessment: M4ThreatAssessment;
|
assessment: M4ThreatAssessment;
|
||||||
occupancySource: M4OccupancySource;
|
occupancySource: M4OccupancySource;
|
||||||
|
cameraProjection: M4ThreatCameraObstacleProjection | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface M4ThreatCameraProposal {
|
export interface M4ThreatCameraProposal {
|
||||||
@@ -177,6 +186,7 @@ export interface M4ThreatTimeline {
|
|||||||
cameraPointSampleLimit: number;
|
cameraPointSampleLimit: number;
|
||||||
worldStateDelivery: "source-paced-latest-wins" | null;
|
worldStateDelivery: "source-paced-latest-wins" | null;
|
||||||
occupancyProvenanceDelivery: "baseline-versus-additive-component-diff" | null;
|
occupancyProvenanceDelivery: "baseline-versus-additive-component-diff" | null;
|
||||||
|
cameraObstacleProjectionDelivery: "factory-kb4-occupied-voxel-bounds" | null;
|
||||||
worldStateFrameCount: number;
|
worldStateFrameCount: number;
|
||||||
supersededFrameCount: number;
|
supersededFrameCount: number;
|
||||||
sourceRepresentationId: "registered-map-increment-v1";
|
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 {
|
function parseMetricVisual(value: unknown): M4ThreatMetricVisual {
|
||||||
const item = object(value, "M4.6 metric visual");
|
const item = object(value, "M4.6 metric visual");
|
||||||
const state = text(item.state, "M4.6 temporal state");
|
const state = text(item.state, "M4.6 temporal state");
|
||||||
@@ -347,6 +383,9 @@ function parseMetricVisual(value: unknown): M4ThreatMetricVisual {
|
|||||||
),
|
),
|
||||||
assessment: parseAssessment(item.assessment),
|
assessment: parseAssessment(item.assessment),
|
||||||
occupancySource,
|
occupancySource,
|
||||||
|
cameraProjection: item.camera_projection === undefined
|
||||||
|
? null
|
||||||
|
: parseCameraObstacleProjection(item.camera_projection),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,6 +743,13 @@ export async function fetchM4ThreatTimeline(
|
|||||||
"baseline-versus-additive-component-diff",
|
"baseline-versus-additive-component-diff",
|
||||||
"M4.8R3 occupancy provenance",
|
"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
|
worldStateFrameCount: payload.world_state_frame_count === undefined
|
||||||
? frameCount
|
? frameCount
|
||||||
: integer(payload.world_state_frame_count, "M4.6 world-state frames"),
|
: integer(payload.world_state_frame_count, "M4.6 world-state frames"),
|
||||||
@@ -756,16 +802,21 @@ export async function fetchM4ThreatTimelineChunk(
|
|||||||
fetcher = fetch,
|
fetcher = fetch,
|
||||||
signal,
|
signal,
|
||||||
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
||||||
|
cameraObstacleProjectionDelivery = null,
|
||||||
}: {
|
}: {
|
||||||
fetcher?: LaboratoryFetch;
|
fetcher?: LaboratoryFetch;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
endpointRoot?: string;
|
endpointRoot?: string;
|
||||||
|
cameraObstacleProjectionDelivery?: M4ThreatTimeline["cameraObstacleProjectionDelivery"];
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<M4ThreatTimelineChunk> {
|
): Promise<M4ThreatTimelineChunk> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
start: String(startSequence),
|
start: String(startSequence),
|
||||||
count: String(frameCount),
|
count: String(frameCount),
|
||||||
});
|
});
|
||||||
|
if (cameraObstacleProjectionDelivery !== null) {
|
||||||
|
params.set("obstacle_projection", cameraObstacleProjectionDelivery);
|
||||||
|
}
|
||||||
const response = await fetcher(
|
const response = await fetcher(
|
||||||
`${endpointRoot}/${result}/timeline/chunk?${params}`,
|
`${endpointRoot}/${result}/timeline/chunk?${params}`,
|
||||||
{ headers: { Accept: "application/json" }, signal },
|
{ headers: { Accept: "application/json" }, signal },
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
useM4ThreatTimelineMetadata,
|
useM4ThreatTimelineMetadata,
|
||||||
} from "./useM4ThreatTimeline";
|
} from "./useM4ThreatTimeline";
|
||||||
import { useE47SemanticTimelineFrame } from "./useE47SemanticTimeline";
|
import { useE47SemanticTimelineFrame } from "./useE47SemanticTimeline";
|
||||||
|
import { buildM4StaticObstacleBoxes } from "./m4StaticObstacleBoxes";
|
||||||
|
|
||||||
type M4ThreatMediaMode = "video" | "camera";
|
type M4ThreatMediaMode = "video" | "camera";
|
||||||
type M4ThreatMediaSelection = M4ThreatMediaMode | "none";
|
type M4ThreatMediaSelection = M4ThreatMediaMode | "none";
|
||||||
@@ -132,6 +133,7 @@ export function M4ReplayThreatVisual({
|
|||||||
const [showMediaSemantic, setShowMediaSemantic] = useState(true);
|
const [showMediaSemantic, setShowMediaSemantic] = useState(true);
|
||||||
const [showSpatialSemantic, setShowSpatialSemantic] = useState(true);
|
const [showSpatialSemantic, setShowSpatialSemantic] = useState(true);
|
||||||
const [showMediaPoints, setShowMediaPoints] = useState(false);
|
const [showMediaPoints, setShowMediaPoints] = useState(false);
|
||||||
|
const [showStaticObstacles, setShowStaticObstacles] = useState(true);
|
||||||
const [splitPrimarySize, setSplitPrimarySize] = useState(50);
|
const [splitPrimarySize, setSplitPrimarySize] = useState(50);
|
||||||
const [splitOrientation, setSplitOrientation] = useState<SplitPaneOrientation>(() => (
|
const [splitOrientation, setSplitOrientation] = useState<SplitPaneOrientation>(() => (
|
||||||
typeof window !== "undefined" && window.matchMedia("(max-width: 900px)").matches
|
typeof window !== "undefined" && window.matchMedia("(max-width: 900px)").matches
|
||||||
@@ -287,9 +289,26 @@ export function M4ReplayThreatVisual({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [frame, metadata.timeline, reviewAnchors, showReviewAnchorBoxes]);
|
}, [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(
|
const activeBoxes = useMemo(
|
||||||
() => [...boxes(frame?.cameraProposals ?? []), ...reviewAnchorBoxes],
|
() => [
|
||||||
[frame, reviewAnchorBoxes],
|
...boxes(frame?.cameraProposals ?? []),
|
||||||
|
...staticObstacleBoxes,
|
||||||
|
...reviewAnchorBoxes,
|
||||||
|
],
|
||||||
|
[frame, reviewAnchorBoxes, staticObstacleBoxes],
|
||||||
);
|
);
|
||||||
const semanticClasses = useMemo<readonly RecordedEvidenceSemanticClass[]>(
|
const semanticClasses = useMemo<readonly RecordedEvidenceSemanticClass[]>(
|
||||||
() => semantic?.taxonomy.map((item) => ({
|
() => semantic?.taxonomy.map((item) => ({
|
||||||
@@ -359,7 +378,8 @@ export function M4ReplayThreatVisual({
|
|||||||
(item) => item.state === "retained",
|
(item) => item.state === "retained",
|
||||||
) ?? [];
|
) ?? [];
|
||||||
const lowStepObstacles = spatialFrame?.metricObstacles.filter(
|
const lowStepObstacles = spatialFrame?.metricObstacles.filter(
|
||||||
(item) => item.occupancySource !== "baseline",
|
(item) => item.occupancySource !== "baseline"
|
||||||
|
&& (item.state === "current" || item.state === "retained"),
|
||||||
) ?? [];
|
) ?? [];
|
||||||
const nearest = spatialFrame?.metricObstacles
|
const nearest = spatialFrame?.metricObstacles
|
||||||
.map((item) => item.assessment.closestApproachM)
|
.map((item) => item.assessment.closestApproachM)
|
||||||
@@ -456,7 +476,9 @@ export function M4ReplayThreatVisual({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const mediaLayerControls = semantic || metadata.timeline?.cameraPointDelivery ? (
|
const mediaLayerControls = semantic
|
||||||
|
|| metadata.timeline?.cameraPointDelivery
|
||||||
|
|| metadata.timeline?.cameraObstacleProjectionDelivery ? (
|
||||||
<div
|
<div
|
||||||
className="m4-replay-threat-visual__pane-layer-controls"
|
className="m4-replay-threat-visual__pane-layer-controls"
|
||||||
role="group"
|
role="group"
|
||||||
@@ -485,6 +507,18 @@ export function M4ReplayThreatVisual({
|
|||||||
POINTS
|
POINTS
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
) : null;
|
) : 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, {
|
void fetchM4ThreatTimelineChunk(resultId, start, chunkSize, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
endpointRoot,
|
endpointRoot,
|
||||||
|
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
|
||||||
})
|
})
|
||||||
.then((chunk) => {
|
.then((chunk) => {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ let synchronizeRecordedEvidencePlayback;
|
|||||||
let m4ThreatChunkWindowStarts;
|
let m4ThreatChunkWindowStarts;
|
||||||
let cancelM4ThreatChunkRequestsOutsideWindow;
|
let cancelM4ThreatChunkRequestsOutsideWindow;
|
||||||
let buildM4LocalSurface;
|
let buildM4LocalSurface;
|
||||||
|
let buildM4StaticObstacleBoxes;
|
||||||
|
|
||||||
const resultId = `m4-threat-replay-${"a".repeat(64)}`;
|
const resultId = `m4-threat-replay-${"a".repeat(64)}`;
|
||||||
|
|
||||||
@@ -50,6 +51,9 @@ before(async () => {
|
|||||||
({ buildM4LocalSurface } = await server.ssrLoadModule(
|
({ buildM4LocalSurface } = await server.ssrLoadModule(
|
||||||
"/src/core/laboratory/m4LocalSurface.ts",
|
"/src/core/laboratory/m4LocalSurface.ts",
|
||||||
));
|
));
|
||||||
|
({ buildM4StaticObstacleBoxes } = await server.ssrLoadModule(
|
||||||
|
"/src/workspaces/laboratory/m4StaticObstacleBoxes.ts",
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
after(async () => {
|
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_window_seconds: 2,
|
||||||
camera_point_sample_limit: 20000,
|
camera_point_sample_limit: 20000,
|
||||||
world_state_delivery: "source-paced-latest-wins",
|
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,
|
world_state_frame_count: 4481,
|
||||||
superseded_frame_count: 8,
|
superseded_frame_count: 8,
|
||||||
local_surface_visualization: {
|
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.cameraPointWindowSeconds, 2);
|
||||||
assert.equal(timeline.worldStateFrameCount, 4481);
|
assert.equal(timeline.worldStateFrameCount, 4481);
|
||||||
assert.equal(timeline.supersededFrameCount, 8);
|
assert.equal(timeline.supersededFrameCount, 8);
|
||||||
|
assert.equal(
|
||||||
|
timeline.cameraObstacleProjectionDelivery,
|
||||||
|
"factory-kb4-occupied-voxel-bounds",
|
||||||
|
);
|
||||||
|
|
||||||
const chunk = await fetchM4ThreatTimelineChunk(replayResultId, 1, 1, {
|
const chunk = await fetchM4ThreatTimelineChunk(replayResultId, 1, 1, {
|
||||||
endpointRoot,
|
endpointRoot,
|
||||||
|
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
|
||||||
fetcher: async (input) => {
|
fetcher: async (input) => {
|
||||||
requested = String(input);
|
requested = String(input);
|
||||||
return new Response(JSON.stringify({
|
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_point_count: 1,
|
||||||
camera_projected_sample_count: 1,
|
camera_projected_sample_count: 1,
|
||||||
camera_projection: "factory-kb4-exact",
|
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`,
|
camera_url: `${endpointRoot}/${replayResultId}/timeline/frames/1/camera`,
|
||||||
})],
|
})],
|
||||||
authority: "replay-simulated",
|
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, 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].worldStateAvailable, false);
|
||||||
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");
|
||||||
|
assert.deepEqual(
|
||||||
|
chunk.frames[0].metricObstacles[0].cameraProjection.bboxXyxy,
|
||||||
|
[390.5, 280.25, 408.75, 318.5],
|
||||||
|
);
|
||||||
|
|
||||||
const overlay = await fetchM4ThreatCameraPointOverlay(replayResultId, 1, {
|
const overlay = await fetchM4ThreatCameraPointOverlay(replayResultId, 1, {
|
||||||
endpointRoot,
|
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");
|
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", () => {
|
test("M4.6 local SLAM surface reprojects registered increments into the active body frame", () => {
|
||||||
const frames = [
|
const frames = [
|
||||||
timelineFrame(0, 10, {
|
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: "3D"/);
|
||||||
assert.match(visual, /label: "PLAN"/);
|
assert.match(visual, /label: "PLAN"/);
|
||||||
assert.match(visual, />\s*POINTS\s*</);
|
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, /pointCloudOverlay=/);
|
||||||
assert.match(visual, /mediaMode/);
|
assert.match(visual, /mediaMode/);
|
||||||
assert.match(visual, /spatialMode/);
|
assert.match(visual, /spatialMode/);
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ import numpy as np
|
|||||||
from .geometry import RecordedGeometryStore
|
from .geometry import RecordedGeometryStore
|
||||||
from .geometry_math import project_map_points_kb4
|
from .geometry_math import project_map_points_kb4
|
||||||
from .recorded_source import RECORDED_REPRESENTATION_ID
|
from .recorded_source import RECORDED_REPRESENTATION_ID
|
||||||
from .spatial_evidence import project_metric_obstacles_to_body, sample_points_in_body_frame
|
from .spatial_evidence import (
|
||||||
|
project_metric_obstacles_to_body,
|
||||||
|
project_metric_obstacles_to_camera,
|
||||||
|
sample_points_in_body_frame,
|
||||||
|
)
|
||||||
from .threat import (
|
from .threat import (
|
||||||
DEFAULT_REPLAY_THREAT_PROFILE_PATH,
|
DEFAULT_REPLAY_THREAT_PROFILE_PATH,
|
||||||
RecordedReplayBodyFrameResolver,
|
RecordedReplayBodyFrameResolver,
|
||||||
@@ -183,6 +187,11 @@ class M48sReplayTimeline:
|
|||||||
if self.frame_diff_path is not None
|
if self.frame_diff_path is not None
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
"camera_obstacle_projection_delivery": (
|
||||||
|
"factory-kb4-occupied-voxel-bounds"
|
||||||
|
if self.frame_diff_path is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
"world_state_frame_count": len(self.index.offsets_by_sequence),
|
"world_state_frame_count": len(self.index.offsets_by_sequence),
|
||||||
"superseded_frame_count": sum(
|
"superseded_frame_count": sum(
|
||||||
value == "superseded" for value in self.outcomes.values()
|
value == "superseded" for value in self.outcomes.values()
|
||||||
@@ -383,6 +392,7 @@ class M48sReplayTimeline:
|
|||||||
)
|
)
|
||||||
|
|
||||||
metric_visuals: list[dict[str, object]] = []
|
metric_visuals: list[dict[str, object]] = []
|
||||||
|
metric_rows: list[dict[str, object]] = []
|
||||||
camera_proposals: list[dict[str, object]] = []
|
camera_proposals: list[dict[str, object]] = []
|
||||||
assessments: list[dict[str, object]] = []
|
assessments: list[dict[str, object]] = []
|
||||||
if row is not None:
|
if row is not None:
|
||||||
@@ -401,7 +411,6 @@ class M48sReplayTimeline:
|
|||||||
_text(item.get("component_id"), "assessment component"): item
|
_text(item.get("component_id"), "assessment component"): item
|
||||||
for item in assessments
|
for item in assessments
|
||||||
}
|
}
|
||||||
metric_rows: list[dict[str, object]] = []
|
|
||||||
for obstacle in (
|
for obstacle in (
|
||||||
*_objects(obstacle_map.get("occupied"), "occupied obstacles"),
|
*_objects(obstacle_map.get("occupied"), "occupied obstacles"),
|
||||||
*_objects(obstacle_map.get("unknown"), "unknown obstacles"),
|
*_objects(obstacle_map.get("unknown"), "unknown obstacles"),
|
||||||
@@ -427,11 +436,26 @@ class M48sReplayTimeline:
|
|||||||
occupied_voxel_size_m=self.profile.corridor.occupied_voxel_size_m,
|
occupied_voxel_size_m=self.profile.corridor.occupied_voxel_size_m,
|
||||||
)
|
)
|
||||||
provenance = self._component_provenance(sequence)
|
provenance = self._component_provenance(sequence)
|
||||||
for visual in metric_visuals:
|
camera_projections = (
|
||||||
visual["occupancy_source"] = provenance.get(
|
{}
|
||||||
str(visual["component_id"]),
|
if frame is None or not provenance
|
||||||
"baseline",
|
else project_metric_obstacles_to_camera(
|
||||||
|
metric_rows,
|
||||||
|
position_map_xyz=frame.sensor_position_map,
|
||||||
|
orientation_map_from_lidar_xyzw=frame.sensor_orientation_xyzw,
|
||||||
|
profile=frame.projection,
|
||||||
|
occupied_voxel_size_m=(
|
||||||
|
self.profile.corridor.occupied_voxel_size_m
|
||||||
|
),
|
||||||
|
component_ids=set(provenance),
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
for visual in metric_visuals:
|
||||||
|
component_id = str(visual["component_id"])
|
||||||
|
visual["occupancy_source"] = provenance.get(component_id, "baseline")
|
||||||
|
projection = camera_projections.get(component_id)
|
||||||
|
if projection is not None:
|
||||||
|
visual["camera_projection"] = projection
|
||||||
associated = set(_strings(row.get("associated_proposal_ids"), "associated ids"))
|
associated = set(_strings(row.get("associated_proposal_ids"), "associated ids"))
|
||||||
for proposal in _objects(row.get("detector_proposals"), "detector proposals"):
|
for proposal in _objects(row.get("detector_proposals"), "detector proposals"):
|
||||||
proposal_id = _text(proposal.get("proposal_id"), "proposal id")
|
proposal_id = _text(proposal.get("proposal_id"), "proposal id")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from collections.abc import Mapping, Sequence
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import numpy.typing as npt
|
import numpy.typing as npt
|
||||||
|
|
||||||
|
from .geometry_math import Kb4ProjectionProfile, project_map_points_kb4
|
||||||
from .threat import ReplayBodyFrame
|
from .threat import ReplayBodyFrame
|
||||||
|
|
||||||
FloatArray = npt.NDArray[np.float64]
|
FloatArray = npt.NDArray[np.float64]
|
||||||
@@ -86,6 +87,96 @@ def project_metric_obstacles_to_body(
|
|||||||
return visuals
|
return visuals
|
||||||
|
|
||||||
|
|
||||||
|
def project_metric_obstacles_to_camera(
|
||||||
|
metric_rows: Sequence[Mapping[str, object]],
|
||||||
|
*,
|
||||||
|
position_map_xyz: npt.ArrayLike,
|
||||||
|
orientation_map_from_lidar_xyzw: npt.ArrayLike,
|
||||||
|
profile: Kb4ProjectionProfile,
|
||||||
|
occupied_voxel_size_m: float,
|
||||||
|
component_ids: set[str] | None = None,
|
||||||
|
) -> dict[str, dict[str, object]]:
|
||||||
|
"""Project ledger-owned occupied voxel bounds into the native KB4 frame.
|
||||||
|
|
||||||
|
This is a visualization projection of already accepted world-state
|
||||||
|
components. It neither reclusters geometry nor changes threat authority.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not math.isfinite(occupied_voxel_size_m) or occupied_voxel_size_m <= 0:
|
||||||
|
raise SpatialEvidenceProjectionError("occupied voxel size is invalid")
|
||||||
|
points: list[tuple[float, float, float]] = []
|
||||||
|
point_owners: list[tuple[str, int]] = []
|
||||||
|
for row in metric_rows:
|
||||||
|
component_id = row.get("component_id")
|
||||||
|
cells = row.get("cells")
|
||||||
|
if (
|
||||||
|
not isinstance(component_id, str)
|
||||||
|
or not component_id
|
||||||
|
or (component_ids is not None and component_id not in component_ids)
|
||||||
|
or not isinstance(cells, list)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
for cell_index, raw_cell in enumerate(cells):
|
||||||
|
if not isinstance(raw_cell, dict):
|
||||||
|
raise SpatialEvidenceProjectionError("occupied cell is invalid")
|
||||||
|
indices = (
|
||||||
|
_signed_integer(raw_cell.get("x"), "cell x"),
|
||||||
|
_signed_integer(raw_cell.get("y"), "cell y"),
|
||||||
|
_signed_integer(raw_cell.get("z"), "cell z"),
|
||||||
|
)
|
||||||
|
bounds = tuple(
|
||||||
|
(index * occupied_voxel_size_m, (index + 1) * occupied_voxel_size_m)
|
||||||
|
for index in indices
|
||||||
|
)
|
||||||
|
for x in bounds[0]:
|
||||||
|
for y in bounds[1]:
|
||||||
|
for z in bounds[2]:
|
||||||
|
points.append((x, y, z))
|
||||||
|
point_owners.append((component_id, cell_index))
|
||||||
|
if not points:
|
||||||
|
return {}
|
||||||
|
projected = project_map_points_kb4(
|
||||||
|
points,
|
||||||
|
position_map_xyz=position_map_xyz,
|
||||||
|
orientation_map_from_lidar_xyzw=orientation_map_from_lidar_xyzw,
|
||||||
|
profile=profile,
|
||||||
|
)
|
||||||
|
grouped_pixels: dict[str, list[npt.NDArray[np.float64]]] = {}
|
||||||
|
grouped_depths: dict[str, list[float]] = {}
|
||||||
|
grouped_cells: dict[str, set[int]] = {}
|
||||||
|
for pixel, depth, source_index in zip(
|
||||||
|
projected.pixels_xy,
|
||||||
|
projected.depths_m,
|
||||||
|
projected.source_indices,
|
||||||
|
strict=True,
|
||||||
|
):
|
||||||
|
component_id, cell_index = point_owners[int(source_index)]
|
||||||
|
grouped_pixels.setdefault(component_id, []).append(pixel)
|
||||||
|
grouped_depths.setdefault(component_id, []).append(float(depth))
|
||||||
|
grouped_cells.setdefault(component_id, set()).add(cell_index)
|
||||||
|
result: dict[str, dict[str, object]] = {}
|
||||||
|
for component_id, pixel_values in grouped_pixels.items():
|
||||||
|
pixels = np.asarray(pixel_values, dtype=np.float64)
|
||||||
|
depths = np.asarray(grouped_depths[component_id], dtype=np.float64)
|
||||||
|
if pixels.size == 0 or depths.size == 0:
|
||||||
|
continue
|
||||||
|
minimum = np.min(pixels, axis=0)
|
||||||
|
maximum = np.max(pixels, axis=0)
|
||||||
|
result[component_id] = {
|
||||||
|
"bbox_xyxy": [
|
||||||
|
round(float(minimum[0]), 3),
|
||||||
|
round(float(minimum[1]), 3),
|
||||||
|
round(float(maximum[0]), 3),
|
||||||
|
round(float(maximum[1]), 3),
|
||||||
|
],
|
||||||
|
"nearest_depth_m": round(float(np.min(depths)), 6),
|
||||||
|
"projected_cell_count": len(grouped_cells[component_id]),
|
||||||
|
"projection": "factory-kb4-occupied-voxel-bounds",
|
||||||
|
"authority": "visual-derived",
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _finite_vector3(value: Sequence[object], label: str) -> tuple[float, float, float]:
|
def _finite_vector3(value: Sequence[object], label: str) -> tuple[float, float, float]:
|
||||||
if len(value) != 3:
|
if len(value) != 3:
|
||||||
raise SpatialEvidenceProjectionError(f"{label} is invalid")
|
raise SpatialEvidenceProjectionError(f"{label} is invalid")
|
||||||
@@ -113,6 +204,7 @@ def _signed_integer(value: object, label: str) -> int:
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"SpatialEvidenceProjectionError",
|
"SpatialEvidenceProjectionError",
|
||||||
|
"project_metric_obstacles_to_camera",
|
||||||
"project_metric_obstacles_to_body",
|
"project_metric_obstacles_to_body",
|
||||||
"sample_points_in_body_frame",
|
"sample_points_in_body_frame",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.perception.geometry_math import Kb4ProjectionProfile
|
||||||
|
from k1link.perception.spatial_evidence import project_metric_obstacles_to_camera
|
||||||
|
|
||||||
|
|
||||||
|
def _identity_profile() -> Kb4ProjectionProfile:
|
||||||
|
transform = np.eye(4, dtype=np.float64)
|
||||||
|
transform.setflags(write=False)
|
||||||
|
return Kb4ProjectionProfile(
|
||||||
|
width=800,
|
||||||
|
height=600,
|
||||||
|
intrinsic_fx_fy_cx_cy=(100.0, 100.0, 400.0, 300.0),
|
||||||
|
distortion_kb4=(0.0, 0.0, 0.0, 0.0),
|
||||||
|
t_camera_from_lidar=transform,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_metric_obstacle_projection_uses_native_kb4_voxel_bounds() -> None:
|
||||||
|
projections = project_metric_obstacles_to_camera(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"component_id": "static-a",
|
||||||
|
"cells": [{"x": -1, "y": -1, "z": 2}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "baseline-b",
|
||||||
|
"cells": [{"x": 1, "y": 1, "z": 2}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
position_map_xyz=(0.0, 0.0, 0.0),
|
||||||
|
orientation_map_from_lidar_xyzw=(0.0, 0.0, 0.0, 1.0),
|
||||||
|
profile=_identity_profile(),
|
||||||
|
occupied_voxel_size_m=1.0,
|
||||||
|
component_ids={"static-a"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert set(projections) == {"static-a"}
|
||||||
|
projection = projections["static-a"]
|
||||||
|
assert projection["projection"] == "factory-kb4-occupied-voxel-bounds"
|
||||||
|
assert projection["authority"] == "visual-derived"
|
||||||
|
assert projection["projected_cell_count"] == 1
|
||||||
|
assert projection["nearest_depth_m"] == pytest.approx(2.0)
|
||||||
|
left, top, right, bottom = projection["bbox_xyxy"]
|
||||||
|
assert left < right == pytest.approx(400.0)
|
||||||
|
assert top < bottom == pytest.approx(300.0)
|
||||||
Reference in New Issue
Block a user