fix(lab): show only actionable obstacle boxes

This commit is contained in:
DCCONSTRUCTIONS
2026-08-26 16:09:32 +03:00
parent a98ad929d9
commit ac1dc7d930
6 changed files with 306 additions and 31 deletions
@@ -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;
});
}