fix(lab): show only actionable obstacle boxes
This commit is contained in:
@@ -186,7 +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;
|
||||
cameraObstacleProjectionDelivery: "factory-kb4-actionable-added-corridor-bounds" | null;
|
||||
worldStateFrameCount: number;
|
||||
supersededFrameCount: number;
|
||||
sourceRepresentationId: "registered-map-increment-v1";
|
||||
@@ -747,7 +747,7 @@ export async function fetchM4ThreatTimeline(
|
||||
? null
|
||||
: exact(
|
||||
payload.camera_obstacle_projection_delivery,
|
||||
"factory-kb4-occupied-voxel-bounds",
|
||||
"factory-kb4-actionable-added-corridor-bounds",
|
||||
"M4.8R3 camera obstacle projection delivery",
|
||||
),
|
||||
worldStateFrameCount: payload.world_state_frame_count === undefined
|
||||
|
||||
@@ -513,7 +513,7 @@ export function M4ReplayThreatVisual({
|
||||
shape="pill"
|
||||
variant={showStaticObstacles ? "primary" : "secondary"}
|
||||
aria-pressed={showStaticObstacles}
|
||||
title="Автоматические рамки занятых LiDAR-компонентов · без ручной разметки"
|
||||
title="Только LOW-STEP LiDAR-препятствия с решением threat · без ручной разметки"
|
||||
onClick={() => setShowStaticObstacles((visible) => !visible)}
|
||||
>
|
||||
OBSTACLES
|
||||
|
||||
@@ -2,6 +2,12 @@ import type { RecordedEvidenceBox } from "../../components/laboratory/RecordedEv
|
||||
import type { M4ThreatMetricVisual } from "../../core/laboratory/m4ReplayThreat";
|
||||
|
||||
const MINIMUM_BOX_SIZE_PX = 12;
|
||||
const RETAINED_CURRENT_OVERLAP_LIMIT = 0.5;
|
||||
|
||||
interface ObstacleBoxCandidate {
|
||||
readonly box: RecordedEvidenceBox;
|
||||
readonly state: M4ThreatMetricVisual["state"];
|
||||
}
|
||||
|
||||
function shortComponentId(componentId: string): string {
|
||||
const finalSegment = componentId.split(/[-_]/).pop();
|
||||
@@ -37,26 +43,42 @@ function visibleBox(
|
||||
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";
|
||||
function overlapFractionOfSmaller(
|
||||
left: readonly [number, number, number, number],
|
||||
right: readonly [number, number, number, number],
|
||||
): number {
|
||||
const intersectionWidth = Math.max(
|
||||
0,
|
||||
Math.min(left[2], right[2]) - Math.max(left[0], right[0]),
|
||||
);
|
||||
const intersectionHeight = Math.max(
|
||||
0,
|
||||
Math.min(left[3], right[3]) - Math.max(left[1], right[1]),
|
||||
);
|
||||
const intersectionArea = intersectionWidth * intersectionHeight;
|
||||
const leftArea = (left[2] - left[0]) * (left[3] - left[1]);
|
||||
const rightArea = (right[2] - right[0]) * (right[3] - right[1]);
|
||||
const smallerArea = Math.min(leftArea, rightArea);
|
||||
return smallerArea > 0 ? intersectionArea / smallerArea : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build camera evidence from the same world-state components shown in 3D.
|
||||
* No image detector or manual review extent participates in these boxes.
|
||||
* Build actionable camera evidence from the same world-state shown in 3D.
|
||||
* Unknown and clear components remain in world-state, but do not compete with
|
||||
* actual corridor threats for the operator's attention.
|
||||
*/
|
||||
export function buildM4StaticObstacleBoxes(
|
||||
obstacles: readonly M4ThreatMetricVisual[],
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
): readonly RecordedEvidenceBox[] {
|
||||
const result: RecordedEvidenceBox[] = [];
|
||||
const candidates: ObstacleBoxCandidate[] = [];
|
||||
for (const obstacle of obstacles) {
|
||||
if (
|
||||
obstacle.occupancySource === "baseline"
|
||||
|| (obstacle.state !== "current" && obstacle.state !== "retained")
|
||||
|| obstacle.assessment.decision !== "threat"
|
||||
|| obstacle.assessment.corridorIntersection !== "intersects"
|
||||
|| obstacle.cameraProjection === null
|
||||
) continue;
|
||||
const projection = obstacle.cameraProjection;
|
||||
@@ -65,16 +87,33 @@ export function buildM4StaticObstacleBoxes(
|
||||
const depth = projection.nearestDepthM.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
result.push({
|
||||
boxXyxy,
|
||||
label: `OBS #${shortComponentId(obstacle.componentId)} · ${depth} м`,
|
||||
tone: tone(obstacle),
|
||||
dashed: false,
|
||||
candidates.push({
|
||||
state: obstacle.state,
|
||||
box: {
|
||||
boxXyxy,
|
||||
label: `OBS #${shortComponentId(obstacle.componentId)} · ${depth} м`,
|
||||
tone: "danger",
|
||||
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;
|
||||
});
|
||||
const currentBoxes = candidates
|
||||
.filter((candidate) => candidate.state === "current")
|
||||
.map((candidate) => candidate.box.boxXyxy);
|
||||
return candidates
|
||||
.filter((candidate) => (
|
||||
candidate.state !== "retained"
|
||||
|| !currentBoxes.some((currentBox) => (
|
||||
overlapFractionOfSmaller(candidate.box.boxXyxy, currentBox)
|
||||
>= RETAINED_CURRENT_OVERLAP_LIMIT
|
||||
))
|
||||
))
|
||||
.map((candidate) => candidate.box)
|
||||
.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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -370,7 +370,7 @@ test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint"
|
||||
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",
|
||||
camera_obstacle_projection_delivery: "factory-kb4-actionable-added-corridor-bounds",
|
||||
world_state_frame_count: 4481,
|
||||
superseded_frame_count: 8,
|
||||
local_surface_visualization: {
|
||||
@@ -400,7 +400,7 @@ test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint"
|
||||
assert.equal(timeline.supersededFrameCount, 8);
|
||||
assert.equal(
|
||||
timeline.cameraObstacleProjectionDelivery,
|
||||
"factory-kb4-occupied-voxel-bounds",
|
||||
"factory-kb4-actionable-added-corridor-bounds",
|
||||
);
|
||||
|
||||
const chunk = await fetchM4ThreatTimelineChunk(replayResultId, 1, 1, {
|
||||
@@ -455,7 +455,7 @@ 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/,
|
||||
/obstacle_projection=factory-kb4-actionable-added-corridor-bounds/,
|
||||
);
|
||||
assert.equal(chunk.frames[0].worldStateAvailable, false);
|
||||
assert.equal(chunk.frames[0].terminalOutcome, "superseded");
|
||||
@@ -525,6 +525,24 @@ test("M4.8R3 turns active low-step components into native fisheye obstacle boxes
|
||||
obstacle,
|
||||
{ ...obstacle, componentId: "baseline", occupancySource: "baseline" },
|
||||
{ ...obstacle, componentId: "held", state: "held" },
|
||||
{
|
||||
...obstacle,
|
||||
componentId: "unknown",
|
||||
assessment: {
|
||||
...obstacle.assessment,
|
||||
decision: "unknown",
|
||||
corridorIntersection: "unknown",
|
||||
},
|
||||
},
|
||||
{
|
||||
...obstacle,
|
||||
componentId: "clear",
|
||||
assessment: {
|
||||
...obstacle.assessment,
|
||||
decision: "not-threat",
|
||||
corridorIntersection: "clear",
|
||||
},
|
||||
},
|
||||
], 800, 600);
|
||||
assert.equal(boxes.length, 1);
|
||||
assert.deepEqual(boxes[0].boxXyxy, [394, 294, 406, 306]);
|
||||
@@ -533,6 +551,59 @@ test("M4.8R3 turns active low-step components into native fisheye obstacle boxes
|
||||
assert.equal(boxes[0].dashed, false);
|
||||
});
|
||||
|
||||
test("M4.8R3 keeps separate CURRENT threats and suppresses their spanning ROLLING box", () => {
|
||||
const current = {
|
||||
componentId: "temporal-left-1",
|
||||
state: "current",
|
||||
motion: "stationary",
|
||||
centroidBodyXyzM: [3, 0, 0.3],
|
||||
cellCentersBodyXyzM: [[3, 0, 0.3]],
|
||||
occupancySource: "additive-low-step",
|
||||
cameraProjection: {
|
||||
bboxXyxy: [350, 260, 410, 310],
|
||||
nearestDepthM: 3,
|
||||
projectedCellCount: 2,
|
||||
projection: "factory-kb4-occupied-voxel-bounds",
|
||||
authority: "visual-derived",
|
||||
},
|
||||
assessment: {
|
||||
componentId: "temporal-left-1",
|
||||
decision: "threat",
|
||||
corridorIntersection: "intersects",
|
||||
relativeSpeedMps: null,
|
||||
closestApproachM: 2.5,
|
||||
ttcSeconds: null,
|
||||
reasonCodes: ["current-corridor-intersection"],
|
||||
},
|
||||
};
|
||||
const boxes = buildM4StaticObstacleBoxes([
|
||||
current,
|
||||
{
|
||||
...current,
|
||||
componentId: "temporal-right-2",
|
||||
cameraProjection: {
|
||||
...current.cameraProjection,
|
||||
bboxXyxy: [405, 265, 445, 320],
|
||||
},
|
||||
},
|
||||
{
|
||||
...current,
|
||||
componentId: "rolling-spanning",
|
||||
state: "retained",
|
||||
cameraProjection: {
|
||||
...current.cameraProjection,
|
||||
bboxXyxy: [340, 255, 450, 325],
|
||||
},
|
||||
},
|
||||
], 800, 600);
|
||||
|
||||
assert.equal(boxes.length, 2);
|
||||
assert.deepEqual(boxes.map((box) => box.label).sort(), [
|
||||
"OBS #1 · 3 м",
|
||||
"OBS #2 · 3 м",
|
||||
]);
|
||||
});
|
||||
|
||||
test("M4.6 local SLAM surface reprojects registered increments into the active body frame", () => {
|
||||
const frames = [
|
||||
timelineFrame(0, 10, {
|
||||
|
||||
Reference in New Issue
Block a user