feat(lab): separate current increment from rolling map
This commit is contained in:
@@ -10,7 +10,7 @@ export type LaboratoryMetricSceneMode = "3d" | "plan";
|
||||
export interface LaboratoryMetricObstacleVisual {
|
||||
id: string;
|
||||
decision: LaboratoryMetricDecision;
|
||||
state: "current" | "held" | "expired";
|
||||
state: "current" | "retained" | "held" | "expired";
|
||||
centroidBodyXyzM: LaboratoryMetricPoint3;
|
||||
cellCentersBodyXyzM: readonly LaboratoryMetricPoint3[];
|
||||
}
|
||||
@@ -103,6 +103,8 @@ export function LaboratoryMetricEvidenceScene({
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const contentRef = useRef<THREE.Group | null>(null);
|
||||
const [renderError, setRenderError] = useState<string | null>(null);
|
||||
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
||||
const [showRollingMap, setShowRollingMap] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
@@ -185,24 +187,34 @@ export function LaboratoryMetricEvidenceScene({
|
||||
child.traverse(disposeRenderable);
|
||||
}
|
||||
|
||||
const contextGeometry = new THREE.BufferGeometry();
|
||||
contextGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions(pointCloudBodyXyzM), 3),
|
||||
);
|
||||
content.add(new THREE.Points(
|
||||
contextGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
||||
size: 1.7,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.34,
|
||||
depthWrite: false,
|
||||
}),
|
||||
));
|
||||
if (showCurrentIncrement) {
|
||||
const contextGeometry = new THREE.BufferGeometry();
|
||||
contextGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions(pointCloudBodyXyzM), 3),
|
||||
);
|
||||
content.add(new THREE.Points(
|
||||
contextGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
||||
size: 1.7,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.34,
|
||||
depthWrite: false,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
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 cellsGeometry = new THREE.BufferGeometry();
|
||||
cellsGeometry.setAttribute(
|
||||
@@ -213,16 +225,19 @@ export function LaboratoryMetricEvidenceScene({
|
||||
cellsGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color,
|
||||
size: obstacle.state === "current" ? 4.8 : 3.8,
|
||||
size: obstacle.state === "current" ? 4.8 : 5.2,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: obstacle.state === "current" ? 0.94 : 0.45,
|
||||
opacity: obstacle.state === "current" ? 0.94 : 0.78,
|
||||
depthWrite: false,
|
||||
}),
|
||||
));
|
||||
const centroid = new THREE.Mesh(
|
||||
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.userData.evidenceId = obstacle.id;
|
||||
@@ -287,7 +302,14 @@ export function LaboratoryMetricEvidenceScene({
|
||||
material.depthWrite = false;
|
||||
});
|
||||
content.add(grid);
|
||||
}, [corridor, obstacles, pointCloudBodyXyzM, rig]);
|
||||
}, [
|
||||
corridor,
|
||||
obstacles,
|
||||
pointCloudBodyXyzM,
|
||||
rig,
|
||||
showCurrentIncrement,
|
||||
showRollingMap,
|
||||
]);
|
||||
|
||||
const resetView = () => {
|
||||
const camera = cameraRef.current;
|
||||
@@ -321,13 +343,30 @@ export function LaboratoryMetricEvidenceScene({
|
||||
>
|
||||
Сбросить ракурс
|
||||
</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>
|
||||
</div>
|
||||
<div className="laboratory-metric-evidence-scene__legend">
|
||||
<span data-decision="threat">Угроза</span>
|
||||
<span data-decision="not-threat">Вне коридора</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>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface M4ThreatReplayResult {
|
||||
evidence: {
|
||||
cameraOnly: number;
|
||||
currentMetric: number;
|
||||
rollingMapRetained: number;
|
||||
staleOrHeld: number;
|
||||
};
|
||||
fixtures: {
|
||||
@@ -69,7 +70,7 @@ export interface M4ThreatAssessment {
|
||||
|
||||
export interface M4ThreatMetricVisual {
|
||||
componentId: string;
|
||||
state: "current" | "held" | "expired";
|
||||
state: "current" | "retained" | "held" | "expired";
|
||||
motion: M4ThreatMotion;
|
||||
centroidBodyXyzM: M4Point3;
|
||||
cellCentersBodyXyzM: readonly M4Point3[];
|
||||
@@ -96,6 +97,8 @@ export interface M4ThreatVisualFrame {
|
||||
pointCloudBodyXyzM: readonly M4Point3[];
|
||||
pointCloudSourceCount: number;
|
||||
pointCloudSampleCount: number;
|
||||
pointCloudLayer: "current-increment";
|
||||
rollingMapComponentCount: number;
|
||||
metricObstacles: readonly M4ThreatMetricVisual[];
|
||||
cameraProposals: readonly M4ThreatCameraProposal[];
|
||||
rig: {
|
||||
@@ -296,6 +299,9 @@ export async function fetchM4ThreatReplayResult({
|
||||
evidence: {
|
||||
cameraOnly: integer(evidence["camera-only"], "M4.6 camera-only"),
|
||||
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"),
|
||||
},
|
||||
fixtures: {
|
||||
@@ -389,7 +395,14 @@ export async function fetchM4ThreatVisual(
|
||||
);
|
||||
if (!response.ok) throw new M4ThreatContractError(`M4.6 visual frame: HTTP ${response.status}.`);
|
||||
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");
|
||||
const rig = object(item.rig, "M4.6 visual rig");
|
||||
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"),
|
||||
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) => {
|
||||
const value = object(raw, "M4.6 metric visual");
|
||||
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: неизвестное состояние.");
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -103,6 +103,12 @@
|
||||
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) {
|
||||
.laboratory-metric-evidence-scene__toolbar > span {
|
||||
display: none;
|
||||
|
||||
@@ -52,14 +52,14 @@ export function M4ReplayThreatResultView({
|
||||
]}
|
||||
brief={{
|
||||
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.",
|
||||
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}.`,
|
||||
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.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.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: "dual-evidence-replay-threat/v2",
|
||||
pipelineId: "dual-evidence-replay-threat/v3",
|
||||
components: [
|
||||
{
|
||||
kind: "source",
|
||||
@@ -78,8 +78,8 @@ export function M4ReplayThreatResultView({
|
||||
{
|
||||
kind: "source",
|
||||
name: result.sourceResultIds.temporal,
|
||||
version: "frozen temporal object map",
|
||||
role: "current / held / expired и bounded motion history",
|
||||
version: "frozen temporal + rolling obstacle map",
|
||||
role: "current increment / rolling retained / bounded motion history",
|
||||
identitySha256: result.sourceResultIds.temporal.split("-").at(-1) ?? null,
|
||||
},
|
||||
{
|
||||
@@ -117,7 +117,7 @@ export function M4ReplayThreatResultView({
|
||||
{
|
||||
label: "Metric evidence",
|
||||
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",
|
||||
@@ -131,7 +131,7 @@ export function M4ReplayThreatResultView({
|
||||
},
|
||||
]}
|
||||
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-пригодность и выдача команд.",
|
||||
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(
|
||||
(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
|
||||
.map((item) => item.assessment.closestApproachM)
|
||||
.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>
|
||||
</div>
|
||||
<div>
|
||||
<span>Dual evidence</span>
|
||||
<strong>{frame.metricObstacles.length} metric · {frame.cameraProposals.length} camera</strong>
|
||||
<small>{frame.pointCloudSampleCount}/{frame.pointCloudSourceCount} LiDAR points shown</small>
|
||||
<span>Representation layers</span>
|
||||
<strong>
|
||||
{currentIncrementObstacles.length} current · {rollingMapObstacles.length} rolling
|
||||
</strong>
|
||||
<small>
|
||||
CURRENT INCREMENT {frame.pointCloudSampleCount}/{frame.pointCloudSourceCount} points
|
||||
· ROLLING MAP {frame.rollingMapComponentCount} components
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Virtual corridor</span>
|
||||
@@ -356,7 +367,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
||||
rig={frame.rig}
|
||||
corridor={frame.corridor}
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const overlay = await fetchM4ThreatVideoOverlay(resultId, {
|
||||
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(videoScene, /<RecordedFmp4Player/);
|
||||
assert.match(metricScene, /OrbitControls/);
|
||||
assert.match(metricScene, /CURRENT INCREMENT/);
|
||||
assert.match(metricScene, /ROLLING MAP/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user