feat(lab): add bounded local SLAM surface
This commit is contained in:
@@ -104,6 +104,7 @@ export const LaboratoryMetricEvidenceScene = forwardRef<
|
||||
LaboratoryMetricEvidenceSceneHandle,
|
||||
{
|
||||
pointCloudBodyXyzM: readonly LaboratoryMetricPoint3[];
|
||||
localSurfaceBodyXyzM: readonly LaboratoryMetricPoint3[];
|
||||
obstacles: readonly LaboratoryMetricObstacleVisual[];
|
||||
rig: LaboratoryMetricRigVisual;
|
||||
corridor: LaboratoryMetricCorridorVisual;
|
||||
@@ -111,10 +112,12 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
mode: LaboratoryMetricSceneMode;
|
||||
label: string;
|
||||
showCurrentIncrement: boolean;
|
||||
showLocalSurface: boolean;
|
||||
showRollingMap: boolean;
|
||||
}
|
||||
>(function LaboratoryMetricEvidenceScene({
|
||||
pointCloudBodyXyzM,
|
||||
localSurfaceBodyXyzM,
|
||||
obstacles,
|
||||
rig,
|
||||
corridor,
|
||||
@@ -122,6 +125,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
mode,
|
||||
label,
|
||||
showCurrentIncrement,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
}, ref) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -211,6 +215,25 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
if (!host || !content) return;
|
||||
clearGroup(content);
|
||||
|
||||
if (showLocalSurface) {
|
||||
const localSurfaceGeometry = new THREE.BufferGeometry();
|
||||
localSurfaceGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions(localSurfaceBodyXyzM), 3),
|
||||
);
|
||||
content.add(new THREE.Points(
|
||||
localSurfaceGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
|
||||
size: 1.3,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.42,
|
||||
depthWrite: false,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if (showCurrentIncrement) {
|
||||
const contextGeometry = new THREE.BufferGeometry();
|
||||
contextGeometry.setAttribute(
|
||||
@@ -296,8 +319,10 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
}, [
|
||||
obstacles,
|
||||
occupiedVoxelSizeM,
|
||||
localSurfaceBodyXyzM,
|
||||
pointCloudBodyXyzM,
|
||||
showCurrentIncrement,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
]);
|
||||
|
||||
@@ -396,6 +421,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
<span data-decision="not-threat">Вне коридора</span>
|
||||
<span data-decision="unknown">Неизвестно</span>
|
||||
<span data-decision="context">Current increment</span>
|
||||
<span data-decision="local-surface">Local SLAM surface</span>
|
||||
<span data-decision="rolling">Rolling-map occupied</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import type {
|
||||
M4Matrix3,
|
||||
M4Point3,
|
||||
M4ThreatTimelineFrame,
|
||||
} from "./m4ReplayThreat";
|
||||
|
||||
export interface M4LocalSurfaceProfile {
|
||||
windowSeconds: number;
|
||||
voxelSizeM: number;
|
||||
radiusM: number;
|
||||
pointLimit: number;
|
||||
}
|
||||
|
||||
export interface M4LocalSurface {
|
||||
pointsBodyXyzM: readonly M4Point3[];
|
||||
sourceFrameCount: number;
|
||||
sourcePointCount: number;
|
||||
voxelCount: number;
|
||||
}
|
||||
|
||||
function bodyPointToMap(
|
||||
point: M4Point3,
|
||||
origin: M4Point3,
|
||||
basis: M4Matrix3,
|
||||
): M4Point3 {
|
||||
return [
|
||||
origin[0] + point[0] * basis[0][0] + point[1] * basis[0][1] + point[2] * basis[0][2],
|
||||
origin[1] + point[0] * basis[1][0] + point[1] * basis[1][1] + point[2] * basis[1][2],
|
||||
origin[2] + point[0] * basis[2][0] + point[1] * basis[2][1] + point[2] * basis[2][2],
|
||||
];
|
||||
}
|
||||
|
||||
function mapPointToBody(
|
||||
point: M4Point3,
|
||||
origin: M4Point3,
|
||||
basis: M4Matrix3,
|
||||
): M4Point3 {
|
||||
const delta: M4Point3 = [
|
||||
point[0] - origin[0],
|
||||
point[1] - origin[1],
|
||||
point[2] - origin[2],
|
||||
];
|
||||
return [
|
||||
delta[0] * basis[0][0] + delta[1] * basis[1][0] + delta[2] * basis[2][0],
|
||||
delta[0] * basis[0][1] + delta[1] * basis[1][1] + delta[2] * basis[2][1],
|
||||
delta[0] * basis[0][2] + delta[1] * basis[1][2] + delta[2] * basis[2][2],
|
||||
];
|
||||
}
|
||||
|
||||
function emptySurface(): M4LocalSurface {
|
||||
return {
|
||||
pointsBodyXyzM: [],
|
||||
sourceFrameCount: 0,
|
||||
sourcePointCount: 0,
|
||||
voxelCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildM4LocalSurface(
|
||||
availableFrames: readonly M4ThreatTimelineFrame[],
|
||||
activeFrame: M4ThreatTimelineFrame | null,
|
||||
profile: M4LocalSurfaceProfile,
|
||||
): M4LocalSurface {
|
||||
const activeBody = activeFrame?.bodyFrame;
|
||||
if (
|
||||
!activeFrame
|
||||
|| !activeBody
|
||||
|| profile.windowSeconds <= 0
|
||||
|| profile.voxelSizeM <= 0
|
||||
|| profile.radiusM <= 0
|
||||
|| profile.pointLimit < 1
|
||||
) {
|
||||
return emptySurface();
|
||||
}
|
||||
|
||||
const startTimeNs = activeFrame.sourceTimeNs - profile.windowSeconds * 1_000_000_000;
|
||||
const frames = availableFrames
|
||||
.filter((frame) => (
|
||||
frame.bodyFrame
|
||||
&& frame.sourceTimeNs >= startTimeNs
|
||||
&& frame.sourceTimeNs <= activeFrame.sourceTimeNs
|
||||
))
|
||||
.sort((left, right) => left.sequence - right.sequence);
|
||||
if (!frames.length) return emptySurface();
|
||||
|
||||
const radiusSquared = profile.radiusM * profile.radiusM;
|
||||
const voxels = new Map<string, M4Point3>();
|
||||
let sourcePointCount = 0;
|
||||
for (const frame of frames) {
|
||||
const sourceBody = frame.bodyFrame;
|
||||
if (!sourceBody) continue;
|
||||
sourcePointCount += frame.pointCloudBodyXyzM.length;
|
||||
for (const sourcePoint of frame.pointCloudBodyXyzM) {
|
||||
const mapPoint = bodyPointToMap(
|
||||
sourcePoint,
|
||||
sourceBody.originMapXyzM,
|
||||
sourceBody.basisMapFromBody,
|
||||
);
|
||||
const activePoint = mapPointToBody(
|
||||
mapPoint,
|
||||
activeBody.originMapXyzM,
|
||||
activeBody.basisMapFromBody,
|
||||
);
|
||||
if (
|
||||
activePoint[0] * activePoint[0]
|
||||
+ activePoint[1] * activePoint[1]
|
||||
+ activePoint[2] * activePoint[2]
|
||||
> radiusSquared
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const key = [
|
||||
Math.floor(mapPoint[0] / profile.voxelSizeM),
|
||||
Math.floor(mapPoint[1] / profile.voxelSizeM),
|
||||
Math.floor(mapPoint[2] / profile.voxelSizeM),
|
||||
].join(":");
|
||||
if (!voxels.has(key)) voxels.set(key, activePoint);
|
||||
}
|
||||
}
|
||||
|
||||
const retained = [...voxels.values()];
|
||||
const stride = Math.max(1, Math.ceil(retained.length / profile.pointLimit));
|
||||
return {
|
||||
pointsBodyXyzM: retained
|
||||
.filter((_, index) => index % stride === 0)
|
||||
.slice(0, profile.pointLimit),
|
||||
sourceFrameCount: frames.length,
|
||||
sourcePointCount,
|
||||
voxelCount: voxels.size,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export type M4ThreatDecision = "threat" | "not-threat" | "unknown";
|
||||
export type M4ThreatMotion = "moving" | "stationary" | "unknown";
|
||||
export type M4Point3 = readonly [number, number, number];
|
||||
export type M4Matrix3 = readonly [M4Point3, M4Point3, M4Point3];
|
||||
|
||||
export interface M4ThreatReplayResult {
|
||||
resultId: string;
|
||||
@@ -132,6 +133,10 @@ export interface M4ThreatTimelineFrame {
|
||||
sessionSeconds: number;
|
||||
sourceAvailable: boolean;
|
||||
spatialAvailable: boolean;
|
||||
bodyFrame: {
|
||||
originMapXyzM: M4Point3;
|
||||
basisMapFromBody: M4Matrix3;
|
||||
} | null;
|
||||
pointCloudBodyXyzM: readonly M4Point3[];
|
||||
pointCloudSourceCount: number;
|
||||
pointCloudSampleCount: number;
|
||||
@@ -159,6 +164,14 @@ export interface M4ThreatTimeline {
|
||||
maximumSourcePointsPerFrame: number;
|
||||
pointDelivery: "exact-current-increment";
|
||||
sourceRepresentationId: "registered-map-increment-v1";
|
||||
localSurfaceVisualization: {
|
||||
derivation: "bounded-registered-increment-accumulation";
|
||||
windowSeconds: number;
|
||||
voxelSizeM: number;
|
||||
radiusM: number;
|
||||
pointLimit: number;
|
||||
authority: "visual-derived";
|
||||
};
|
||||
occupiedVoxelSizeM: number;
|
||||
rig: M4ThreatVisualFrame["rig"];
|
||||
corridor: M4ThreatVisualFrame["corridor"];
|
||||
@@ -219,6 +232,10 @@ const vector = (value: unknown, size: number, label: string): number[] => {
|
||||
if (parsed.length !== size) throw new M4ThreatContractError(`${label}: неверная размерность.`);
|
||||
return parsed;
|
||||
};
|
||||
const point3 = (value: unknown, label: string): M4Point3 => {
|
||||
const parsed = vector(value, 3, label);
|
||||
return [parsed[0]!, parsed[1]!, parsed[2]!];
|
||||
};
|
||||
const decision = (value: unknown, label: string): M4ThreatDecision => {
|
||||
if (value !== "threat" && value !== "not-threat" && value !== "unknown") {
|
||||
throw new M4ThreatContractError(`${label}: неизвестное решение.`);
|
||||
@@ -539,6 +556,10 @@ export async function fetchM4ThreatTimeline(
|
||||
}
|
||||
const rig = object(payload.rig, "M4.6 timeline rig");
|
||||
const corridor = object(payload.corridor, "M4.6 timeline corridor");
|
||||
const localSurface = object(
|
||||
payload.local_surface_visualization,
|
||||
"M4.6 local surface profile",
|
||||
);
|
||||
return {
|
||||
resultId: result,
|
||||
recordedSourceSessionId: "20260720T065719Z_viewer_live",
|
||||
@@ -565,6 +586,22 @@ export async function fetchM4ThreatTimeline(
|
||||
"M4.6 point delivery",
|
||||
),
|
||||
sourceRepresentationId: "registered-map-increment-v1",
|
||||
localSurfaceVisualization: {
|
||||
derivation: exact(
|
||||
localSurface.derivation,
|
||||
"bounded-registered-increment-accumulation",
|
||||
"M4.6 local surface derivation",
|
||||
),
|
||||
windowSeconds: number(localSurface.window_seconds, "M4.6 local surface window"),
|
||||
voxelSizeM: number(localSurface.voxel_size_m, "M4.6 local surface voxel"),
|
||||
radiusM: number(localSurface.radius_m, "M4.6 local surface radius"),
|
||||
pointLimit: integer(localSurface.point_limit, "M4.6 local surface point limit"),
|
||||
authority: exact(
|
||||
localSurface.authority,
|
||||
"visual-derived",
|
||||
"M4.6 local surface authority",
|
||||
),
|
||||
},
|
||||
occupiedVoxelSizeM: number(
|
||||
corridor.occupied_voxel_size_m,
|
||||
"M4.6 occupied voxel size",
|
||||
@@ -647,6 +684,22 @@ function parseTimelineFrame(
|
||||
throw new M4ThreatContractError("M4.6 timeline frame order: нарушен контракт.");
|
||||
}
|
||||
const counts = object(item.decision_counts, "M4.6 timeline decisions");
|
||||
const spatialAvailable = typeof item.spatial_available === "boolean"
|
||||
&& item.spatial_available;
|
||||
const bodyFrame = item.body_frame === null
|
||||
? null
|
||||
: object(item.body_frame, "M4.6 timeline body frame");
|
||||
if (spatialAvailable !== (bodyFrame !== null)) {
|
||||
throw new M4ThreatContractError("M4.6 timeline body frame: нарушена доступность.");
|
||||
}
|
||||
const basis = bodyFrame === null
|
||||
? null
|
||||
: array(bodyFrame.basis_map_from_body, "M4.6 timeline body basis").map(
|
||||
(row) => point3(row, "M4.6 timeline body basis row"),
|
||||
);
|
||||
if (basis !== null && basis.length !== 3) {
|
||||
throw new M4ThreatContractError("M4.6 timeline body basis: нарушен размер.");
|
||||
}
|
||||
const cameraUrl = text(item.camera_url, "M4.6 timeline camera URL");
|
||||
if (!cameraUrl.includes(`/results/${result}/timeline/frames/${sequence}/camera`)) {
|
||||
throw new M4ThreatContractError("M4.6 timeline camera URL: нарушена идентичность.");
|
||||
@@ -657,7 +710,16 @@ function parseTimelineFrame(
|
||||
sourceTimeNs: integer(item.source_time_ns, "M4.6 timeline source time"),
|
||||
sessionSeconds: number(item.session_seconds, "M4.6 timeline time"),
|
||||
sourceAvailable: typeof item.source_available === "boolean" && item.source_available,
|
||||
spatialAvailable: typeof item.spatial_available === "boolean" && item.spatial_available,
|
||||
spatialAvailable,
|
||||
bodyFrame: bodyFrame === null || basis === null
|
||||
? null
|
||||
: {
|
||||
originMapXyzM: point3(
|
||||
bodyFrame.origin_map_xyz_m,
|
||||
"M4.6 timeline body origin",
|
||||
),
|
||||
basisMapFromBody: [basis[0]!, basis[1]!, basis[2]!],
|
||||
},
|
||||
pointCloudBodyXyzM: array(item.point_cloud_body_xyz_m, "M4.6 timeline points").map(
|
||||
(point) => vector(point, 3, "M4.6 timeline point") as [number, number, number],
|
||||
),
|
||||
|
||||
@@ -66,6 +66,10 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__layer-controls .nodedc-segmented__item {
|
||||
padding-inline: 0.72rem;
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer .laboratory-evidence-viewer__transport {
|
||||
bottom: 0.3rem;
|
||||
}
|
||||
@@ -155,3 +159,8 @@
|
||||
border: 1px solid rgb(var(--nodedc-accent-rgb));
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend span[data-decision="local-surface"]::before {
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
M4ThreatCameraProposal,
|
||||
M4ThreatTimelineFrame,
|
||||
} from "../../core/laboratory/m4ReplayThreat";
|
||||
import { buildM4LocalSurface } from "../../core/laboratory/m4LocalSurface";
|
||||
import { recordedObservationSources } from "../../core/observation/recordedObservationSources";
|
||||
import { replayObservationSession } from "../../core/observation/sessionArchive";
|
||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||
@@ -68,6 +69,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
||||
const [mode, setMode] = useState<M4ThreatViewMode>("video");
|
||||
const [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode>("3d");
|
||||
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
||||
const [showLocalSurface, setShowLocalSurface] = useState(true);
|
||||
const [showRollingMap, setShowRollingMap] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null);
|
||||
@@ -161,6 +163,16 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
||||
.map((item) => item.assessment.closestApproachM)
|
||||
.filter((value): value is number => value !== null)
|
||||
.sort((left, right) => left - right)[0] ?? null;
|
||||
const localSurface = useMemo(() => buildM4LocalSurface(
|
||||
timelineFrame.availableFrames,
|
||||
frame,
|
||||
metadata.timeline?.localSurfaceVisualization ?? {
|
||||
windowSeconds: 2,
|
||||
voxelSizeM: 0.1,
|
||||
radiusM: 12,
|
||||
pointLimit: 20_000,
|
||||
},
|
||||
), [frame, metadata.timeline, timelineFrame.availableFrames]);
|
||||
|
||||
const seek = (seconds: number) => playbackController.seek(seconds);
|
||||
const handleModeChange = (next: M4ThreatViewMode) => {
|
||||
@@ -206,7 +218,17 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
||||
aria-pressed={showCurrentIncrement}
|
||||
onClick={() => setShowCurrentIncrement((visible) => !visible)}
|
||||
>
|
||||
CURRENT INCREMENT
|
||||
CURRENT
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-segmented__item"
|
||||
data-active={showLocalSurface ? "true" : undefined}
|
||||
aria-pressed={showLocalSurface}
|
||||
title="Bounded local SLAM surface · visual-derived"
|
||||
onClick={() => setShowLocalSurface((visible) => !visible)}
|
||||
>
|
||||
LOCAL SLAM
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -215,7 +237,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
||||
aria-pressed={showRollingMap}
|
||||
onClick={() => setShowRollingMap((visible) => !visible)}
|
||||
>
|
||||
ROLLING MAP
|
||||
ROLLING
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -250,7 +272,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
||||
</strong>
|
||||
<small>
|
||||
{frame.spatialAvailable
|
||||
? `${frame.pointCloudSampleCount}/${frame.pointCloudSourceCount} exact lio_pcl increment`
|
||||
? `${frame.pointCloudSampleCount}/${frame.pointCloudSourceCount} exact · ${localSurface.pointsBodyXyzM.length} local SLAM / ${localSurface.sourceFrameCount} frames`
|
||||
: "body frame / current increment unavailable"}
|
||||
</small>
|
||||
</div>
|
||||
@@ -328,13 +350,15 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
||||
<LaboratoryMetricEvidenceScene
|
||||
ref={metricSceneRef}
|
||||
pointCloudBodyXyzM={frame.pointCloudBodyXyzM}
|
||||
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
|
||||
obstacles={sceneObstacles}
|
||||
rig={timeline.rig}
|
||||
corridor={timeline.corridor}
|
||||
occupiedVoxelSizeM={timeline.occupiedVoxelSizeM}
|
||||
mode={spatialMode}
|
||||
label="M4.6 recorded-realtime current increment and rolling occupancy"
|
||||
label="M4.6 exact current increment, bounded local SLAM surface and rolling occupancy"
|
||||
showCurrentIncrement={showCurrentIncrement}
|
||||
showLocalSurface={showLocalSurface}
|
||||
showRollingMap={showRollingMap}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -146,10 +146,18 @@ export function useM4ThreatTimelineFrame({
|
||||
(frame) => frame.sequence === activeSequence,
|
||||
) ?? null;
|
||||
}, [activeChunkStart, activeSequence, chunks]);
|
||||
const availableFrames = useMemo(() => {
|
||||
const unique = new Map<number, M4ThreatTimelineFrame>();
|
||||
for (const chunk of chunks.values()) {
|
||||
for (const frame of chunk.frames) unique.set(frame.sequence, frame);
|
||||
}
|
||||
return [...unique.values()].sort((left, right) => left.sequence - right.sequence);
|
||||
}, [chunks]);
|
||||
|
||||
return {
|
||||
activeSequence,
|
||||
activeFrame,
|
||||
availableFrames,
|
||||
loading: error === null && Boolean(timeline) && !activeFrame,
|
||||
error,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ let selectM4ThreatTimelineFrame;
|
||||
let selectM4ThreatTimelineSequence;
|
||||
let advanceRecordedEvidencePlayback;
|
||||
let m4ThreatChunkWindowStarts;
|
||||
let buildM4LocalSurface;
|
||||
|
||||
const resultId = `m4-threat-replay-${"a".repeat(64)}`;
|
||||
|
||||
@@ -36,6 +37,9 @@ before(async () => {
|
||||
({ m4ThreatChunkWindowStarts } = await server.ssrLoadModule(
|
||||
"/src/workspaces/laboratory/useM4ThreatTimeline.ts",
|
||||
));
|
||||
({ buildM4LocalSurface } = await server.ssrLoadModule(
|
||||
"/src/core/laboratory/m4LocalSurface.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
@@ -65,6 +69,10 @@ function timelineFrame(sequence, sessionSeconds, overrides = {}) {
|
||||
session_seconds: sessionSeconds,
|
||||
source_available: true,
|
||||
spatial_available: true,
|
||||
body_frame: {
|
||||
origin_map_xyz_m: [sequence, 0, 0],
|
||||
basis_map_from_body: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
|
||||
},
|
||||
point_cloud_body_xyz_m: [[1, 0, 0.1]],
|
||||
point_cloud_source_count: 1,
|
||||
point_cloud_sample_count: 1,
|
||||
@@ -262,6 +270,14 @@ test("M4.6 timeline keeps only a compact index and decodes bounded spatial chunk
|
||||
point_sample_limit: 4096,
|
||||
maximum_source_points_per_frame: 3092,
|
||||
point_delivery: "exact-current-increment",
|
||||
local_surface_visualization: {
|
||||
derivation: "bounded-registered-increment-accumulation",
|
||||
window_seconds: 2,
|
||||
voxel_size_m: 0.1,
|
||||
radius_m: 12,
|
||||
point_limit: 20000,
|
||||
authority: "visual-derived",
|
||||
},
|
||||
rig: { length_m: 1, width_m: 0.6, nominal_sensor_height_m: 1.25 },
|
||||
corridor: {
|
||||
forward_length_m: 8,
|
||||
@@ -277,6 +293,8 @@ test("M4.6 timeline keeps only a compact index and decodes bounded spatial chunk
|
||||
assert.equal(timeline.pointDelivery, "exact-current-increment");
|
||||
assert.equal(timeline.maximumSourcePointsPerFrame, 3092);
|
||||
assert.equal(timeline.occupiedVoxelSizeM, 0.45);
|
||||
assert.equal(timeline.localSurfaceVisualization.windowSeconds, 2);
|
||||
assert.equal(timeline.localSurfaceVisualization.authority, "visual-derived");
|
||||
assert.equal(selectM4ThreatTimelineSequence(timeline.frameTimesNs, 35.50), 1);
|
||||
|
||||
const chunk = await fetchM4ThreatTimelineChunk(resultId, 0, 2, {
|
||||
@@ -300,6 +318,54 @@ test("M4.6 timeline keeps only a compact index and decodes bounded spatial chunk
|
||||
assert.equal(selectM4ThreatTimelineFrame(chunk.frames, 35.50).sequence, 1);
|
||||
});
|
||||
|
||||
test("M4.6 local SLAM surface reprojects registered increments into the active body frame", () => {
|
||||
const frames = [
|
||||
timelineFrame(0, 10, {
|
||||
body_frame: {
|
||||
origin_map_xyz_m: [0, 0, 0],
|
||||
basis_map_from_body: [[0, -1, 0], [1, 0, 0], [0, 0, 1]],
|
||||
},
|
||||
point_cloud_body_xyz_m: [[1, 0, 0]],
|
||||
}),
|
||||
timelineFrame(1, 10.1, {
|
||||
body_frame: {
|
||||
origin_map_xyz_m: [0, 0, 0],
|
||||
basis_map_from_body: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
|
||||
},
|
||||
point_cloud_body_xyz_m: [[1, 0, 0]],
|
||||
}),
|
||||
].map((raw, index) => ({
|
||||
sequence: raw.sequence,
|
||||
frameId: raw.frame_id,
|
||||
sourceTimeNs: raw.source_time_ns,
|
||||
sessionSeconds: raw.session_seconds,
|
||||
sourceAvailable: true,
|
||||
spatialAvailable: true,
|
||||
bodyFrame: {
|
||||
originMapXyzM: raw.body_frame.origin_map_xyz_m,
|
||||
basisMapFromBody: raw.body_frame.basis_map_from_body,
|
||||
},
|
||||
pointCloudBodyXyzM: raw.point_cloud_body_xyz_m,
|
||||
pointCloudSourceCount: 1,
|
||||
pointCloudSampleCount: 1,
|
||||
pointCloudLayer: "current-increment",
|
||||
rollingMapComponentCount: 0,
|
||||
metricObstacles: [],
|
||||
cameraProposals: [],
|
||||
decisionCounts: { threat: 0, "not-threat": 0, unknown: 0 },
|
||||
cameraUrl: raw.camera_url,
|
||||
}));
|
||||
const surface = buildM4LocalSurface(frames, frames[1], {
|
||||
windowSeconds: 2,
|
||||
voxelSizeM: 0.1,
|
||||
radiusM: 12,
|
||||
pointLimit: 20_000,
|
||||
});
|
||||
assert.equal(surface.sourceFrameCount, 2);
|
||||
assert.equal(surface.sourcePointCount, 2);
|
||||
assert.deepEqual(surface.pointsBodyXyzM, [[0, 1, 0], [1, 0, 0]]);
|
||||
});
|
||||
|
||||
test("M4.6 spatial buffering keeps previous, active and two future chunks", () => {
|
||||
assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [24, 48, 72, 96]);
|
||||
assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24, 48]);
|
||||
@@ -345,8 +411,9 @@ test("M4.6 viewer reuses shared camera, video and metric evidence renderers", as
|
||||
assert.match(videoScene, /<RecordedFmp4Player/);
|
||||
assert.match(imageScene, /<RecordedEvidenceBoxOverlay/);
|
||||
assert.match(metricScene, /OrbitControls/);
|
||||
assert.match(visual, /CURRENT INCREMENT/);
|
||||
assert.match(visual, /ROLLING MAP/);
|
||||
assert.match(visual, /LOCAL SLAM/);
|
||||
assert.match(visual, /showLocalSurface/);
|
||||
assert.match(metricScene, /Local SLAM surface/);
|
||||
assert.match(visual, /showJumpToEnd=\{false\}/);
|
||||
assert.doesNotMatch(metricScene, /ЛКМ · вращение/);
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ WHEEL_NAME = "nodedc_mission_core-0.1.0-py3-none-any.whl"
|
||||
RUNNER_NAME = RUNNER.name
|
||||
PATCH_ID = re.compile(r"^[A-Za-z0-9._-]{1,96}$")
|
||||
EXPECTED_BASELINE_SHA256 = "ea10359339e6cce31b5780a2710299771cab7cc0c1c2a2b56a1621f786b31fa8"
|
||||
EXPECTED_WHEEL_SHA256 = "9d203362cebb383fd5394e60dbcaa4cea6d332de9c0aa411cbc23825a4e53868"
|
||||
EXPECTED_WHEEL_SHA256 = "2fc53bf3c2cd82a33e62b158a455d813bca64b844707e758792b4bac263b2543"
|
||||
PAYLOAD_FILES = (
|
||||
RUNNER_NAME,
|
||||
WHEEL_NAME,
|
||||
|
||||
@@ -35,6 +35,10 @@ RECORDED_SPATIAL_CHUNK_SCHEMA: Final = "missioncore.recorded-spatial-evidence-ch
|
||||
RECORDED_SPATIAL_FRAME_SCHEMA: Final = "missioncore.recorded-spatial-evidence-frame/v1"
|
||||
RECORDED_SPATIAL_POINT_LIMIT: Final = 4_096
|
||||
RECORDED_SPATIAL_MAX_CHUNK_FRAMES: Final = 24
|
||||
RECORDED_LOCAL_SURFACE_WINDOW_SECONDS: Final = 2.0
|
||||
RECORDED_LOCAL_SURFACE_VOXEL_SIZE_M: Final = 0.10
|
||||
RECORDED_LOCAL_SURFACE_RADIUS_M: Final = 12.0
|
||||
RECORDED_LOCAL_SURFACE_POINT_LIMIT: Final = 20_000
|
||||
_EXPECTED_FRAME_COUNT: Final = 4_489
|
||||
_SOURCE_TIME = re.compile(rb'"source_time_ns":([0-9]+)')
|
||||
|
||||
@@ -117,6 +121,14 @@ class RecordedThreatTimeline:
|
||||
"point_sample_limit": RECORDED_SPATIAL_POINT_LIMIT,
|
||||
"point_delivery": "exact-current-increment",
|
||||
"maximum_source_points_per_frame": self.store.maximum_current_point_count,
|
||||
"local_surface_visualization": {
|
||||
"derivation": "bounded-registered-increment-accumulation",
|
||||
"window_seconds": RECORDED_LOCAL_SURFACE_WINDOW_SECONDS,
|
||||
"voxel_size_m": RECORDED_LOCAL_SURFACE_VOXEL_SIZE_M,
|
||||
"radius_m": RECORDED_LOCAL_SURFACE_RADIUS_M,
|
||||
"point_limit": RECORDED_LOCAL_SURFACE_POINT_LIMIT,
|
||||
"authority": "visual-derived",
|
||||
},
|
||||
"image_width": 800,
|
||||
"image_height": 600,
|
||||
"rig": {
|
||||
@@ -203,6 +215,14 @@ class RecordedThreatTimeline:
|
||||
"session_seconds": self.index.source_times_ns[sequence] / 1_000_000_000,
|
||||
"source_available": source_available,
|
||||
"spatial_available": body_frame is not None,
|
||||
"body_frame": None
|
||||
if body_frame is None
|
||||
else {
|
||||
"origin_map_xyz_m": list(body_frame.origin_map_xyz_m),
|
||||
"basis_map_from_body": [
|
||||
list(row) for row in body_frame.basis_map_from_body
|
||||
],
|
||||
},
|
||||
"point_cloud_body_xyz_m": point_cloud,
|
||||
"point_cloud_source_count": point_source_count,
|
||||
"point_cloud_sample_count": len(point_cloud),
|
||||
@@ -285,6 +305,10 @@ def _decision_counts(assessments: list[dict[str, object]]) -> dict[str, int]:
|
||||
__all__ = [
|
||||
"RECORDED_SPATIAL_CHUNK_SCHEMA",
|
||||
"RECORDED_SPATIAL_FRAME_SCHEMA",
|
||||
"RECORDED_LOCAL_SURFACE_POINT_LIMIT",
|
||||
"RECORDED_LOCAL_SURFACE_RADIUS_M",
|
||||
"RECORDED_LOCAL_SURFACE_VOXEL_SIZE_M",
|
||||
"RECORDED_LOCAL_SURFACE_WINDOW_SECONDS",
|
||||
"RECORDED_SPATIAL_MAX_CHUNK_FRAMES",
|
||||
"RECORDED_SPATIAL_POINT_LIMIT",
|
||||
"RECORDED_SPATIAL_TIMELINE_SCHEMA",
|
||||
|
||||
@@ -133,6 +133,14 @@ def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None:
|
||||
assert timeline["recorded_source"]["representation_id"] == "registered-map-increment-v1"
|
||||
assert timeline["point_delivery"] == "exact-current-increment"
|
||||
assert timeline["maximum_source_points_per_frame"] == 3092
|
||||
assert timeline["local_surface_visualization"] == {
|
||||
"authority": "visual-derived",
|
||||
"derivation": "bounded-registered-increment-accumulation",
|
||||
"point_limit": 20000,
|
||||
"radius_m": 12.0,
|
||||
"voxel_size_m": 0.1,
|
||||
"window_seconds": 2.0,
|
||||
}
|
||||
assert "frames" not in timeline
|
||||
|
||||
chunk = get_chunk(RESULT_ID, start=1880, count=12)
|
||||
@@ -143,6 +151,8 @@ def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None:
|
||||
first = chunk["frames"][0]
|
||||
assert first["schema_version"] == "missioncore.recorded-spatial-evidence-frame/v1"
|
||||
assert first["spatial_available"] is True
|
||||
assert len(first["body_frame"]["origin_map_xyz_m"]) == 3
|
||||
assert len(first["body_frame"]["basis_map_from_body"]) == 3
|
||||
assert first["point_cloud_layer"] == "current-increment"
|
||||
assert first["point_cloud_sample_count"] == first["point_cloud_source_count"]
|
||||
assert 0 < first["point_cloud_sample_count"] <= 4096
|
||||
|
||||
Reference in New Issue
Block a user