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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user