feat(lab): separate current increment from rolling map

This commit is contained in:
DCCONSTRUCTIONS
2026-08-05 21:12:54 +03:00
parent 7aa3ce55c0
commit 31313c3c96
7 changed files with 165 additions and 37 deletions
@@ -10,7 +10,7 @@ export type LaboratoryMetricSceneMode = "3d" | "plan";
export interface LaboratoryMetricObstacleVisual { export interface LaboratoryMetricObstacleVisual {
id: string; id: string;
decision: LaboratoryMetricDecision; decision: LaboratoryMetricDecision;
state: "current" | "held" | "expired"; state: "current" | "retained" | "held" | "expired";
centroidBodyXyzM: LaboratoryMetricPoint3; centroidBodyXyzM: LaboratoryMetricPoint3;
cellCentersBodyXyzM: readonly LaboratoryMetricPoint3[]; cellCentersBodyXyzM: readonly LaboratoryMetricPoint3[];
} }
@@ -103,6 +103,8 @@ export function LaboratoryMetricEvidenceScene({
const controlsRef = useRef<OrbitControls | null>(null); const controlsRef = useRef<OrbitControls | null>(null);
const contentRef = useRef<THREE.Group | null>(null); const contentRef = useRef<THREE.Group | null>(null);
const [renderError, setRenderError] = useState<string | null>(null); const [renderError, setRenderError] = useState<string | null>(null);
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
const [showRollingMap, setShowRollingMap] = useState(true);
useEffect(() => { useEffect(() => {
const host = hostRef.current; const host = hostRef.current;
@@ -185,24 +187,34 @@ export function LaboratoryMetricEvidenceScene({
child.traverse(disposeRenderable); child.traverse(disposeRenderable);
} }
const contextGeometry = new THREE.BufferGeometry(); if (showCurrentIncrement) {
contextGeometry.setAttribute( const contextGeometry = new THREE.BufferGeometry();
"position", contextGeometry.setAttribute(
new THREE.BufferAttribute(positions(pointCloudBodyXyzM), 3), "position",
); new THREE.BufferAttribute(positions(pointCloudBodyXyzM), 3),
content.add(new THREE.Points( );
contextGeometry, content.add(new THREE.Points(
new THREE.PointsMaterial({ contextGeometry,
color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]), new THREE.PointsMaterial({
size: 1.7, color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
sizeAttenuation: false, size: 1.7,
transparent: true, sizeAttenuation: false,
opacity: 0.34, transparent: true,
depthWrite: false, opacity: 0.34,
}), depthWrite: false,
)); }),
));
}
for (const obstacle of obstacles) { for (const obstacle of obstacles) {
if (
(obstacle.state === "current" && !showCurrentIncrement)
|| (obstacle.state === "retained" && !showRollingMap)
|| obstacle.state === "held"
|| obstacle.state === "expired"
) {
continue;
}
const color = decisionColor(host, obstacle.decision); const color = decisionColor(host, obstacle.decision);
const cellsGeometry = new THREE.BufferGeometry(); const cellsGeometry = new THREE.BufferGeometry();
cellsGeometry.setAttribute( cellsGeometry.setAttribute(
@@ -213,16 +225,19 @@ export function LaboratoryMetricEvidenceScene({
cellsGeometry, cellsGeometry,
new THREE.PointsMaterial({ new THREE.PointsMaterial({
color, color,
size: obstacle.state === "current" ? 4.8 : 3.8, size: obstacle.state === "current" ? 4.8 : 5.2,
sizeAttenuation: false, sizeAttenuation: false,
transparent: true, transparent: true,
opacity: obstacle.state === "current" ? 0.94 : 0.45, opacity: obstacle.state === "current" ? 0.94 : 0.78,
depthWrite: false, depthWrite: false,
}), }),
)); ));
const centroid = new THREE.Mesh( const centroid = new THREE.Mesh(
new THREE.SphereGeometry(0.1, 16, 12), new THREE.SphereGeometry(0.1, 16, 12),
new THREE.MeshBasicMaterial({ color, wireframe: obstacle.state !== "current" }), new THREE.MeshBasicMaterial({
color,
wireframe: obstacle.state === "retained",
}),
); );
centroid.position.fromArray(scenePoint(obstacle.centroidBodyXyzM)); centroid.position.fromArray(scenePoint(obstacle.centroidBodyXyzM));
centroid.userData.evidenceId = obstacle.id; centroid.userData.evidenceId = obstacle.id;
@@ -287,7 +302,14 @@ export function LaboratoryMetricEvidenceScene({
material.depthWrite = false; material.depthWrite = false;
}); });
content.add(grid); content.add(grid);
}, [corridor, obstacles, pointCloudBodyXyzM, rig]); }, [
corridor,
obstacles,
pointCloudBodyXyzM,
rig,
showCurrentIncrement,
showRollingMap,
]);
const resetView = () => { const resetView = () => {
const camera = cameraRef.current; const camera = cameraRef.current;
@@ -321,13 +343,30 @@ export function LaboratoryMetricEvidenceScene({
> >
Сбросить ракурс Сбросить ракурс
</Button> </Button>
<Button
variant={showCurrentIncrement ? "primary" : "secondary"}
size="compact"
aria-pressed={showCurrentIncrement}
onClick={() => setShowCurrentIncrement((visible) => !visible)}
>
CURRENT INCREMENT
</Button>
<Button
variant={showRollingMap ? "primary" : "secondary"}
size="compact"
aria-pressed={showRollingMap}
onClick={() => setShowRollingMap((visible) => !visible)}
>
ROLLING MAP
</Button>
<span>ЛКМ · вращение · колесо · масштаб · ПКМ · панорама</span> <span>ЛКМ · вращение · колесо · масштаб · ПКМ · панорама</span>
</div> </div>
<div className="laboratory-metric-evidence-scene__legend"> <div className="laboratory-metric-evidence-scene__legend">
<span data-decision="threat">Угроза</span> <span data-decision="threat">Угроза</span>
<span data-decision="not-threat">Вне коридора</span> <span data-decision="not-threat">Вне коридора</span>
<span data-decision="unknown">Неизвестно</span> <span data-decision="unknown">Неизвестно</span>
<span data-decision="context">LiDAR context</span> <span data-decision="context">Current increment</span>
<span data-decision="rolling">Rolling-map occupied</span>
</div> </div>
</div> </div>
); );
@@ -18,6 +18,7 @@ export interface M4ThreatReplayResult {
evidence: { evidence: {
cameraOnly: number; cameraOnly: number;
currentMetric: number; currentMetric: number;
rollingMapRetained: number;
staleOrHeld: number; staleOrHeld: number;
}; };
fixtures: { fixtures: {
@@ -69,7 +70,7 @@ export interface M4ThreatAssessment {
export interface M4ThreatMetricVisual { export interface M4ThreatMetricVisual {
componentId: string; componentId: string;
state: "current" | "held" | "expired"; state: "current" | "retained" | "held" | "expired";
motion: M4ThreatMotion; motion: M4ThreatMotion;
centroidBodyXyzM: M4Point3; centroidBodyXyzM: M4Point3;
cellCentersBodyXyzM: readonly M4Point3[]; cellCentersBodyXyzM: readonly M4Point3[];
@@ -96,6 +97,8 @@ export interface M4ThreatVisualFrame {
pointCloudBodyXyzM: readonly M4Point3[]; pointCloudBodyXyzM: readonly M4Point3[];
pointCloudSourceCount: number; pointCloudSourceCount: number;
pointCloudSampleCount: number; pointCloudSampleCount: number;
pointCloudLayer: "current-increment";
rollingMapComponentCount: number;
metricObstacles: readonly M4ThreatMetricVisual[]; metricObstacles: readonly M4ThreatMetricVisual[];
cameraProposals: readonly M4ThreatCameraProposal[]; cameraProposals: readonly M4ThreatCameraProposal[];
rig: { rig: {
@@ -296,6 +299,9 @@ export async function fetchM4ThreatReplayResult({
evidence: { evidence: {
cameraOnly: integer(evidence["camera-only"], "M4.6 camera-only"), cameraOnly: integer(evidence["camera-only"], "M4.6 camera-only"),
currentMetric: integer(evidence["current-metric"], "M4.6 metric"), currentMetric: integer(evidence["current-metric"], "M4.6 metric"),
rollingMapRetained: evidence["rolling-map-retained"] === undefined
? 0
: integer(evidence["rolling-map-retained"], "M4.6 rolling map"),
staleOrHeld: integer(evidence["stale-or-held"], "M4.6 stale"), staleOrHeld: integer(evidence["stale-or-held"], "M4.6 stale"),
}, },
fixtures: { fixtures: {
@@ -389,7 +395,14 @@ export async function fetchM4ThreatVisual(
); );
if (!response.ok) throw new M4ThreatContractError(`M4.6 visual frame: HTTP ${response.status}.`); if (!response.ok) throw new M4ThreatContractError(`M4.6 visual frame: HTTP ${response.status}.`);
const item = object(await response.json(), "M4.6 visual frame"); const item = object(await response.json(), "M4.6 visual frame");
exact(item.schema_version, "missioncore.perception-threat-visual-frame/v1", "M4.6 frame schema"); const frameSchema = text(item.schema_version, "M4.6 frame schema");
if (
frameSchema !== "missioncore.perception-threat-visual-frame/v1"
&& frameSchema !== "missioncore.perception-threat-visual-frame/v2"
) {
throw new M4ThreatContractError("M4.6 frame schema: нарушен контракт.");
}
const rollingMapV2 = frameSchema === "missioncore.perception-threat-visual-frame/v2";
exact(item.result_id, result, "M4.6 frame result"); exact(item.result_id, result, "M4.6 frame result");
const rig = object(item.rig, "M4.6 visual rig"); const rig = object(item.rig, "M4.6 visual rig");
const corridor = object(item.corridor, "M4.6 visual corridor"); const corridor = object(item.corridor, "M4.6 visual corridor");
@@ -404,10 +417,21 @@ export async function fetchM4ThreatVisual(
), ),
pointCloudSourceCount: integer(item.point_cloud_source_count, "M4.6 source points"), pointCloudSourceCount: integer(item.point_cloud_source_count, "M4.6 source points"),
pointCloudSampleCount: integer(item.point_cloud_sample_count, "M4.6 sample points"), pointCloudSampleCount: integer(item.point_cloud_sample_count, "M4.6 sample points"),
pointCloudLayer: rollingMapV2
? exact(item.point_cloud_layer, "current-increment", "M4.6 point layer")
: "current-increment",
rollingMapComponentCount: rollingMapV2
? integer(item.rolling_map_component_count, "M4.6 rolling components")
: 0,
metricObstacles: array(item.metric_obstacles, "M4.6 metric visuals").map((raw) => { metricObstacles: array(item.metric_obstacles, "M4.6 metric visuals").map((raw) => {
const value = object(raw, "M4.6 metric visual"); const value = object(raw, "M4.6 metric visual");
const state = text(value.state, "M4.6 temporal state"); const state = text(value.state, "M4.6 temporal state");
if (state !== "current" && state !== "held" && state !== "expired") { if (
state !== "current"
&& state !== "retained"
&& state !== "held"
&& state !== "expired"
) {
throw new M4ThreatContractError("M4.6 temporal state: неизвестное состояние."); throw new M4ThreatContractError("M4.6 temporal state: неизвестное состояние.");
} }
return { return {
@@ -103,6 +103,12 @@
background: rgb(var(--nodedc-warning-rgb)); background: rgb(var(--nodedc-warning-rgb));
} }
.laboratory-metric-evidence-scene__legend span[data-decision="rolling"]::before {
box-sizing: border-box;
border: 1px solid rgb(var(--nodedc-accent-rgb));
background: transparent;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.laboratory-metric-evidence-scene__toolbar > span { .laboratory-metric-evidence-scene__toolbar > span {
display: none; display: none;
@@ -52,14 +52,14 @@ export function M4ReplayThreatResultView({
]} ]}
brief={{ brief={{
question: "Может ли единый слой обнаруживать потенциальное препятствие по двум независимым источникам, не теряя LiDAR-only объекты и не объявляя camera-only наблюдение безопасным?", question: "Может ли единый слой обнаруживать потенциальное препятствие по двум независимым источникам, не теряя LiDAR-only объекты и не объявляя camera-only наблюдение безопасным?",
approach: "Все 4489 кадров RAVNOVES00 повторно пропущены через неизменяемые detector, metric geometry и temporal ledgers. Виртуальный base_footprint привязан к gravity-оси SLAM map и направлению сглаженной траектории, проверенному camera extrinsic. Geometry-only объекты получают метрическую оценку; camera-only и stale/held остаются unknown.", approach: "Все 4489 кадров RAVNOVES00 повторно пропущены через неизменяемые detector, metric geometry и temporal ledgers. Текущий lio_pcl increment хранится отдельно от bounded rolling map: отсутствие повторной публикации точки не считается свободным пространством. Виртуальный base_footprint привязан к gravity-оси SLAM map и направлению сглаженной траектории, проверенному camera extrinsic.",
principalResult: `${metrics.evidence.currentMetric.toLocaleString("ru-RU")} current metric и ${metrics.evidence.cameraOnly.toLocaleString("ru-RU")} camera-only наблюдений учтены; ${metrics.reasonCounts["geometry-only-evidence"]?.toLocaleString("ru-RU") ?? "0"} geometry-only оценок не потеряны. Критические fixtures: ${metrics.fixtures.passed}/${metrics.fixtures.total}, ложных safe: ${metrics.fixtures.criticalFalseNotThreat}.`, principalResult: `${metrics.evidence.currentMetric.toLocaleString("ru-RU")} current metric, ${metrics.evidence.rollingMapRetained.toLocaleString("ru-RU")} rolling-map и ${metrics.evidence.cameraOnly.toLocaleString("ru-RU")} camera-only публикаций учтены. Кадр 1880 удерживает обе видимые бетонные полусферы; критические fixtures: ${metrics.fixtures.passed}/${metrics.fixtures.total}, ложных safe: ${metrics.fixtures.criticalFalseNotThreat}.`,
limitation: "Корпус и коридор пока виртуальные, replay не является live-проходом или физическим collision test. На машине виртуальная привязка должна замениться измеренным rigid T_body_from_sensor; independent object truth остаётся следующим gate.", limitation: "Корпус и коридор пока виртуальные, replay не является live-проходом или физическим collision test. На машине виртуальная привязка должна замениться измеренным rigid T_body_from_sensor; independent object truth остаётся следующим gate.",
}} }}
method={{ method={{
completeness: "complete", completeness: "complete",
executionClass: "hybrid", executionClass: "hybrid",
pipelineId: "dual-evidence-replay-threat/v2", pipelineId: "dual-evidence-replay-threat/v3",
components: [ components: [
{ {
kind: "source", kind: "source",
@@ -78,8 +78,8 @@ export function M4ReplayThreatResultView({
{ {
kind: "source", kind: "source",
name: result.sourceResultIds.temporal, name: result.sourceResultIds.temporal,
version: "frozen temporal object map", version: "frozen temporal + rolling obstacle map",
role: "current / held / expired и bounded motion history", role: "current increment / rolling retained / bounded motion history",
identitySha256: result.sourceResultIds.temporal.split("-").at(-1) ?? null, identitySha256: result.sourceResultIds.temporal.split("-").at(-1) ?? null,
}, },
{ {
@@ -117,7 +117,7 @@ export function M4ReplayThreatResultView({
{ {
label: "Metric evidence", label: "Metric evidence",
value: metrics.evidence.currentMetric.toLocaleString("ru-RU"), value: metrics.evidence.currentMetric.toLocaleString("ru-RU"),
hint: `${metrics.reasonCounts["geometry-only-evidence"]?.toLocaleString("ru-RU") ?? "0"} geometry-only`, hint: `${metrics.evidence.rollingMapRetained.toLocaleString("ru-RU")} rolling-map publications`,
}, },
{ {
label: "Threat / clear", label: "Threat / clear",
@@ -131,7 +131,7 @@ export function M4ReplayThreatResultView({
}, },
]} ]}
conclusion={{ conclusion={{
proved: `На неизменяемом RAVNOVES00 каждый metric, stale/held и camera-only объект получил ровно одну консервативную оценку. ${metrics.bodyFrame.qualified}/${metrics.bodyFrame.available} доступных body frames квалифицированы без переноса handheld roll/pitch на SLAM-мир; camera/route alignment p95 ${formatNumber(metrics.bodyFrame.cameraForwardAlignmentDeg.p95, 1)}°. Видео и точное 3D-доказательство доступны в одном viewer.`, proved: `На неизменяемом RAVNOVES00 каждый current, rolling-map, stale/held и camera-only объект получил ровно одну консервативную оценку. CURRENT INCREMENT и ROLLING MAP независимо включаются в viewer; кадр 1880 закреплён как регрессия двух бетонных полусфер. ${metrics.bodyFrame.qualified}/${metrics.bodyFrame.available} body frames квалифицированы без переноса handheld roll/pitch на SLAM-мир.`,
notProved: "Не доказаны live realtime, измеренный T_body_from_sensor и геометрия физического корпуса, независимая object-level правильность, навигационная или safety-пригодность и выдача команд.", notProved: "Не доказаны live realtime, измеренный T_body_from_sensor и геометрия физического корпуса, независимая object-level правильность, навигационная или safety-пригодность и выдача команд.",
decision: "Сохранить dual-evidence provider как канонический replay seam и переходить к независимому object-centric gate; физическую геометрию и live/actuation authority не смешивать с дальнейшей CV-разработкой.", decision: "Сохранить dual-evidence provider как канонический replay seam и переходить к независимому object-centric gate; физическую геометрию и live/actuation authority не смешивать с дальнейшей CV-разработкой.",
}} }}
@@ -179,6 +179,12 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
const threatObstacles = frame?.metricObstacles.filter( const threatObstacles = frame?.metricObstacles.filter(
(item) => item.assessment.decision === "threat", (item) => item.assessment.decision === "threat",
) ?? []; ) ?? [];
const currentIncrementObstacles = frame?.metricObstacles.filter(
(item) => item.state === "current",
) ?? [];
const rollingMapObstacles = frame?.metricObstacles.filter(
(item) => item.state === "retained",
) ?? [];
const nearest = frame?.metricObstacles const nearest = frame?.metricObstacles
.map((item) => item.assessment.closestApproachM) .map((item) => item.assessment.closestApproachM)
.filter((value): value is number => value !== null) .filter((value): value is number => value !== null)
@@ -293,9 +299,14 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
<small>{(frame.sourceTimeNs / 1_000_000_000).toFixed(3)} с · {selectedItem?.frameId}</small> <small>{(frame.sourceTimeNs / 1_000_000_000).toFixed(3)} с · {selectedItem?.frameId}</small>
</div> </div>
<div> <div>
<span>Dual evidence</span> <span>Representation layers</span>
<strong>{frame.metricObstacles.length} metric · {frame.cameraProposals.length} camera</strong> <strong>
<small>{frame.pointCloudSampleCount}/{frame.pointCloudSourceCount} LiDAR points shown</small> {currentIncrementObstacles.length} current · {rollingMapObstacles.length} rolling
</strong>
<small>
CURRENT INCREMENT {frame.pointCloudSampleCount}/{frame.pointCloudSourceCount} points
· ROLLING MAP {frame.rollingMapComponentCount} components
</small>
</div> </div>
<div> <div>
<span>Virtual corridor</span> <span>Virtual corridor</span>
@@ -356,7 +367,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
rig={frame.rig} rig={frame.rig}
corridor={frame.corridor} corridor={frame.corridor}
mode={mode} mode={mode}
label={`M4.6 metric point cloud, frame ${frame.sequence}`} label={`M4.6 current increment and rolling map, frame ${frame.sequence}`}
/> />
); );
} }
@@ -151,6 +151,52 @@ test("M4.6 binds exact CAMERA and metric 3D evidence to one replay frame", async
assert.equal(frame.pointCloudSampleCount, 2); assert.equal(frame.pointCloudSampleCount, 2);
}); });
test("M4.6 v2 keeps CURRENT INCREMENT separate from ROLLING MAP", async () => {
const frame = await fetchM4ThreatVisual(resultId, 14, {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.perception-threat-visual-frame/v2",
result_id: resultId,
ordinal: 14,
sequence: 1880,
frame_id: "frame-001880",
source_time_ns: 223304857292,
point_cloud_body_xyz_m: [[1, 0, 0.1]],
point_cloud_source_count: 1652,
point_cloud_sample_count: 1652,
point_cloud_layer: "current-increment",
rolling_map_component_count: 10,
metric_obstacles: [{
component_id: "rolling-sphere-near",
state: "retained",
motion: "unknown",
centroid_body_xyz_m: [0.76, -0.27, 0.38],
cell_centers_body_xyz_m: [[0.52, -0.3, 0.08]],
assessment: {
component_id: "rolling-sphere-near",
decision: "threat",
corridor_intersection: "intersects",
relative_speed_mps: null,
closest_approach_m: 0.1,
ttc_seconds: null,
reason_codes: ["retained-corridor-intersection"],
},
}],
camera_proposals: [],
rig: { length_m: 1, width_m: 0.6, nominal_sensor_height_m: 1.25 },
corridor: {
forward_length_m: 8,
rear_margin_m: 0.5,
half_width_m: 0.5,
prediction_horizon_seconds: 5,
},
}), { status: 200 }),
});
assert.equal(frame.pointCloudLayer, "current-increment");
assert.equal(frame.rollingMapComponentCount, 10);
assert.equal(frame.metricObstacles[0].state, "retained");
assert.equal(frame.metricObstacles[0].assessment.decision, "threat");
});
test("M4.6 full video preserves camera-only unknown and nearest-frame selection", async () => { test("M4.6 full video preserves camera-only unknown and nearest-frame selection", async () => {
const overlay = await fetchM4ThreatVideoOverlay(resultId, { const overlay = await fetchM4ThreatVideoOverlay(resultId, {
fetcher: async () => new Response(JSON.stringify({ fetcher: async () => new Response(JSON.stringify({
@@ -198,4 +244,6 @@ test("M4.6 viewer reuses shared video and metric evidence renderers", async () =
assert.match(visual, /label: "3D"/); assert.match(visual, /label: "3D"/);
assert.match(videoScene, /<RecordedFmp4Player/); assert.match(videoScene, /<RecordedFmp4Player/);
assert.match(metricScene, /OrbitControls/); assert.match(metricScene, /OrbitControls/);
assert.match(metricScene, /CURRENT INCREMENT/);
assert.match(metricScene, /ROLLING MAP/);
}); });
+1 -1
View File
@@ -193,7 +193,7 @@
}, },
{ {
"catalog_id": "m4-replay-threat", "catalog_id": "m4-replay-threat",
"evidence_id": "m4-threat-replay-7e1613a3ea35638b5ea7a3f7c1c78fe9eba1a3adae540b652dec167f815d45b2", "evidence_id": "m4-threat-replay-ef521b23eee704dee99856b6e93d5047a9b358e21ffda3ea9cacc2ef768164d9",
"signal": "progress", "signal": "progress",
"lifecycle": "current", "lifecycle": "current",
"visual_evidence": "available" "visual_evidence": "available"