fix(lab): stabilize recorded spatial playback
This commit is contained in:
@@ -96,6 +96,7 @@ export function LaboratoryMetricEvidenceScene({
|
|||||||
obstacles,
|
obstacles,
|
||||||
rig,
|
rig,
|
||||||
corridor,
|
corridor,
|
||||||
|
occupiedVoxelSizeM,
|
||||||
mode,
|
mode,
|
||||||
label,
|
label,
|
||||||
}: {
|
}: {
|
||||||
@@ -103,6 +104,7 @@ export function LaboratoryMetricEvidenceScene({
|
|||||||
obstacles: readonly LaboratoryMetricObstacleVisual[];
|
obstacles: readonly LaboratoryMetricObstacleVisual[];
|
||||||
rig: LaboratoryMetricRigVisual;
|
rig: LaboratoryMetricRigVisual;
|
||||||
corridor: LaboratoryMetricCorridorVisual;
|
corridor: LaboratoryMetricCorridorVisual;
|
||||||
|
occupiedVoxelSizeM: number;
|
||||||
mode: LaboratoryMetricSceneMode;
|
mode: LaboratoryMetricSceneMode;
|
||||||
label: string;
|
label: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -205,10 +207,10 @@ export function LaboratoryMetricEvidenceScene({
|
|||||||
contextGeometry,
|
contextGeometry,
|
||||||
new THREE.PointsMaterial({
|
new THREE.PointsMaterial({
|
||||||
color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
||||||
size: 1.7,
|
size: 1.55,
|
||||||
sizeAttenuation: false,
|
sizeAttenuation: false,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.34,
|
opacity: 0.58,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
@@ -224,6 +226,33 @@ export function LaboratoryMetricEvidenceScene({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const color = decisionColor(host, obstacle.decision);
|
const color = decisionColor(host, obstacle.decision);
|
||||||
|
if (obstacle.state === "retained") {
|
||||||
|
const geometry = new THREE.BoxGeometry(
|
||||||
|
occupiedVoxelSizeM * 0.82,
|
||||||
|
occupiedVoxelSizeM * 0.82,
|
||||||
|
occupiedVoxelSizeM * 0.82,
|
||||||
|
);
|
||||||
|
const material = new THREE.MeshBasicMaterial({
|
||||||
|
color,
|
||||||
|
wireframe: true,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.34,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const voxels = new THREE.InstancedMesh(
|
||||||
|
geometry,
|
||||||
|
material,
|
||||||
|
obstacle.cellCentersBodyXyzM.length,
|
||||||
|
);
|
||||||
|
const matrix = new THREE.Matrix4();
|
||||||
|
obstacle.cellCentersBodyXyzM.forEach((point, index) => {
|
||||||
|
matrix.makeTranslation(...scenePoint(point));
|
||||||
|
voxels.setMatrixAt(index, matrix);
|
||||||
|
});
|
||||||
|
voxels.instanceMatrix.needsUpdate = true;
|
||||||
|
voxels.userData.evidenceId = obstacle.id;
|
||||||
|
content.add(voxels);
|
||||||
|
} else {
|
||||||
const cellsGeometry = new THREE.BufferGeometry();
|
const cellsGeometry = new THREE.BufferGeometry();
|
||||||
cellsGeometry.setAttribute(
|
cellsGeometry.setAttribute(
|
||||||
"position",
|
"position",
|
||||||
@@ -233,27 +262,26 @@ export function LaboratoryMetricEvidenceScene({
|
|||||||
cellsGeometry,
|
cellsGeometry,
|
||||||
new THREE.PointsMaterial({
|
new THREE.PointsMaterial({
|
||||||
color,
|
color,
|
||||||
size: obstacle.state === "current" ? 4.8 : 5.2,
|
size: 4.4,
|
||||||
sizeAttenuation: false,
|
sizeAttenuation: false,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: obstacle.state === "current" ? 0.94 : 0.78,
|
opacity: 0.96,
|
||||||
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.065, 12, 8),
|
||||||
new THREE.MeshBasicMaterial({
|
new THREE.MeshBasicMaterial({ color }),
|
||||||
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;
|
||||||
content.add(centroid);
|
content.add(centroid);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}, [
|
}, [
|
||||||
obstacles,
|
obstacles,
|
||||||
|
occupiedVoxelSizeM,
|
||||||
pointCloudBodyXyzM,
|
pointCloudBodyXyzM,
|
||||||
showCurrentIncrement,
|
showCurrentIncrement,
|
||||||
showRollingMap,
|
showRollingMap,
|
||||||
|
|||||||
@@ -156,6 +156,10 @@ export interface M4ThreatTimeline {
|
|||||||
nominalRateHz: number;
|
nominalRateHz: number;
|
||||||
maxChunkFrames: number;
|
maxChunkFrames: number;
|
||||||
pointSampleLimit: number;
|
pointSampleLimit: number;
|
||||||
|
maximumSourcePointsPerFrame: number;
|
||||||
|
pointDelivery: "exact-current-increment";
|
||||||
|
sourceRepresentationId: "registered-map-increment-v1";
|
||||||
|
occupiedVoxelSizeM: number;
|
||||||
rig: M4ThreatVisualFrame["rig"];
|
rig: M4ThreatVisualFrame["rig"];
|
||||||
corridor: M4ThreatVisualFrame["corridor"];
|
corridor: M4ThreatVisualFrame["corridor"];
|
||||||
}
|
}
|
||||||
@@ -513,6 +517,11 @@ export async function fetchM4ThreatTimeline(
|
|||||||
"M4.6 recorded session",
|
"M4.6 recorded session",
|
||||||
);
|
);
|
||||||
exact(recorded.source_id, "RAVNOVES00", "M4.6 recorded source id");
|
exact(recorded.source_id, "RAVNOVES00", "M4.6 recorded source id");
|
||||||
|
exact(
|
||||||
|
recorded.representation_id,
|
||||||
|
"registered-map-increment-v1",
|
||||||
|
"M4.6 recorded representation",
|
||||||
|
);
|
||||||
exact(
|
exact(
|
||||||
recorded.synchronization,
|
recorded.synchronization,
|
||||||
"host-arrival-best-effort",
|
"host-arrival-best-effort",
|
||||||
@@ -546,6 +555,20 @@ export async function fetchM4ThreatTimeline(
|
|||||||
nominalRateHz: number(payload.nominal_rate_hz, "M4.6 timeline rate"),
|
nominalRateHz: number(payload.nominal_rate_hz, "M4.6 timeline rate"),
|
||||||
maxChunkFrames: integer(payload.max_chunk_frames, "M4.6 max chunk"),
|
maxChunkFrames: integer(payload.max_chunk_frames, "M4.6 max chunk"),
|
||||||
pointSampleLimit: integer(payload.point_sample_limit, "M4.6 point limit"),
|
pointSampleLimit: integer(payload.point_sample_limit, "M4.6 point limit"),
|
||||||
|
maximumSourcePointsPerFrame: integer(
|
||||||
|
payload.maximum_source_points_per_frame,
|
||||||
|
"M4.6 maximum source points",
|
||||||
|
),
|
||||||
|
pointDelivery: exact(
|
||||||
|
payload.point_delivery,
|
||||||
|
"exact-current-increment",
|
||||||
|
"M4.6 point delivery",
|
||||||
|
),
|
||||||
|
sourceRepresentationId: "registered-map-increment-v1",
|
||||||
|
occupiedVoxelSizeM: number(
|
||||||
|
corridor.occupied_voxel_size_m,
|
||||||
|
"M4.6 occupied voxel size",
|
||||||
|
),
|
||||||
rig: {
|
rig: {
|
||||||
lengthM: number(rig.length_m, "M4.6 rig length"),
|
lengthM: number(rig.length_m, "M4.6 rig length"),
|
||||||
widthM: number(rig.width_m, "M4.6 rig width"),
|
widthM: number(rig.width_m, "M4.6 rig width"),
|
||||||
|
|||||||
@@ -12,6 +12,48 @@
|
|||||||
background: var(--nodedc-canvas);
|
background: var(--nodedc-canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.m4-replay-threat-visual__deck,
|
||||||
|
.m4-replay-threat-visual__layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m4-replay-threat-visual__layer {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m4-replay-threat-visual__layer[data-active="true"] {
|
||||||
|
z-index: 1;
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m4-replay-threat-visual__buffering {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 5;
|
||||||
|
top: 4.9rem;
|
||||||
|
left: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
border: 1px solid var(--nodedc-glass-outline);
|
||||||
|
border-radius: var(--nodedc-radius-control-compact);
|
||||||
|
background: var(--nodedc-floating-surface);
|
||||||
|
padding: 0.42rem 0.58rem;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.54rem;
|
||||||
|
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
.laboratory-metric-evidence-scene__viewport {
|
.laboratory-metric-evidence-scene__viewport {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Icon, IconButton } from "@nodedc/ui-react";
|
import { Icon, IconButton } from "@nodedc/ui-react";
|
||||||
|
|
||||||
import { ObservationTimeline } from "../../components/ObservationTimeline";
|
import { ObservationTimeline } from "../../components/ObservationTimeline";
|
||||||
@@ -13,7 +13,10 @@ import {
|
|||||||
type RecordedEvidenceBox,
|
type RecordedEvidenceBox,
|
||||||
} from "../../components/laboratory/RecordedEvidenceVideoScene";
|
} from "../../components/laboratory/RecordedEvidenceVideoScene";
|
||||||
import { useRecordedEvidencePlayback } from "../../components/laboratory/useRecordedEvidencePlayback";
|
import { useRecordedEvidencePlayback } from "../../components/laboratory/useRecordedEvidencePlayback";
|
||||||
import type { M4ThreatCameraProposal } from "../../core/laboratory/m4ReplayThreat";
|
import type {
|
||||||
|
M4ThreatCameraProposal,
|
||||||
|
M4ThreatTimelineFrame,
|
||||||
|
} from "../../core/laboratory/m4ReplayThreat";
|
||||||
import { recordedObservationSources } from "../../core/observation/recordedObservationSources";
|
import { recordedObservationSources } from "../../core/observation/recordedObservationSources";
|
||||||
import { replayObservationSession } from "../../core/observation/sessionArchive";
|
import { replayObservationSession } from "../../core/observation/sessionArchive";
|
||||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||||
@@ -62,6 +65,7 @@ function SpatialState({ message: text }: { message: string }) {
|
|||||||
|
|
||||||
export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
||||||
const [mode, setMode] = useState<M4ThreatViewMode>("video");
|
const [mode, setMode] = useState<M4ThreatViewMode>("video");
|
||||||
|
const [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode>("3d");
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const metadata = useM4ThreatTimelineMetadata(resultId);
|
const metadata = useM4ThreatTimelineMetadata(resultId);
|
||||||
const playbackRange = useMemo(() => metadata.timeline ? ({
|
const playbackRange = useMemo(() => metadata.timeline ? ({
|
||||||
@@ -85,7 +89,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeline = metadata.timeline;
|
const timeline = metadata.timeline;
|
||||||
if (mode !== "video" || !timeline || videoSource) return;
|
if (!timeline || videoSource) return;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setVideoLoading(true);
|
setVideoLoading(true);
|
||||||
setVideoError(null);
|
setVideoError(null);
|
||||||
@@ -122,9 +126,19 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
|||||||
if (!controller.signal.aborted) setVideoLoading(false);
|
if (!controller.signal.aborted) setVideoLoading(false);
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [metadata.timeline, mode, videoSource]);
|
}, [metadata.timeline, videoSource]);
|
||||||
|
|
||||||
const frame = timelineFrame.activeFrame;
|
const lastFrameRef = useRef<M4ThreatTimelineFrame | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
lastFrameRef.current = null;
|
||||||
|
}, [resultId]);
|
||||||
|
if (timelineFrame.activeFrame) lastFrameRef.current = timelineFrame.activeFrame;
|
||||||
|
const frame = timelineFrame.activeFrame ?? lastFrameRef.current;
|
||||||
|
const displayingBufferedFrame = Boolean(
|
||||||
|
frame
|
||||||
|
&& timelineFrame.activeSequence !== null
|
||||||
|
&& frame.sequence !== timelineFrame.activeSequence,
|
||||||
|
);
|
||||||
const activeBoxes = useMemo(() => boxes(frame?.cameraProposals ?? []), [frame]);
|
const activeBoxes = useMemo(() => boxes(frame?.cameraProposals ?? []), [frame]);
|
||||||
const sceneObstacles = useMemo(() => frame?.metricObstacles.map((obstacle) => ({
|
const sceneObstacles = useMemo(() => frame?.metricObstacles.map((obstacle) => ({
|
||||||
id: obstacle.componentId,
|
id: obstacle.componentId,
|
||||||
@@ -147,9 +161,16 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
|||||||
const seek = (seconds: number) => playbackController.seek(seconds);
|
const seek = (seconds: number) => playbackController.seek(seconds);
|
||||||
const handleModeChange = (next: M4ThreatViewMode) => {
|
const handleModeChange = (next: M4ThreatViewMode) => {
|
||||||
if (next === "camera") playbackController.setPlaying(false);
|
if (next === "camera") playbackController.setPlaying(false);
|
||||||
|
if (next === "3d" || next === "plan") setSpatialMode(next);
|
||||||
setMode(next);
|
setMode(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playbackController.playback.playing || !frame) return;
|
||||||
|
const image = new Image();
|
||||||
|
image.src = frame.cameraUrl;
|
||||||
|
}, [frame?.cameraUrl, playbackController.playback.playing]);
|
||||||
|
|
||||||
const actions = (
|
const actions = (
|
||||||
<div className="l3-visual-audit__actions">
|
<div className="l3-visual-audit__actions">
|
||||||
<div className="l3-visual-audit__pagination">
|
<div className="l3-visual-audit__pagination">
|
||||||
@@ -178,7 +199,9 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
|||||||
<strong>frame {frame.sequence + 1}/{metadata.timeline.frameCount}</strong>
|
<strong>frame {frame.sequence + 1}/{metadata.timeline.frameCount}</strong>
|
||||||
<small>
|
<small>
|
||||||
+{(frame.sessionSeconds - metadata.timeline.timelineStartSeconds).toFixed(3)} с
|
+{(frame.sessionSeconds - metadata.timeline.timelineStartSeconds).toFixed(3)} с
|
||||||
· {playbackController.playback.playing ? "воспроизведение" : "пауза / seek"}
|
· {displayingBufferedFrame
|
||||||
|
? "держим последний кадр, следующий в буфере"
|
||||||
|
: playbackController.playback.playing ? "воспроизведение" : "пауза / seek"}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -188,7 +211,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
|||||||
</strong>
|
</strong>
|
||||||
<small>
|
<small>
|
||||||
{frame.spatialAvailable
|
{frame.spatialAvailable
|
||||||
? `${frame.pointCloudSampleCount}/${frame.pointCloudSourceCount} LiDAR points`
|
? `${frame.pointCloudSampleCount}/${frame.pointCloudSourceCount} exact lio_pcl increment`
|
||||||
: "body frame / current increment unavailable"}
|
: "body frame / current increment unavailable"}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,65 +227,97 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
|
const timeline = metadata.timeline;
|
||||||
let content;
|
let content;
|
||||||
if (metadata.error) {
|
if (metadata.error) {
|
||||||
content = <SpatialState message={metadata.error} />;
|
content = <SpatialState message={metadata.error} />;
|
||||||
} else if (mode === "video") {
|
} else if (!timeline) {
|
||||||
content = videoLoading
|
content = (
|
||||||
|| metadata.loading
|
|
||||||
|| Boolean(metadata.timeline && !videoSource && !videoError) ? (
|
|
||||||
<div className="l3-visual-audit__state" role="status">
|
<div className="l3-visual-audit__state" role="status">
|
||||||
<span className="busy-indicator" aria-hidden="true" />
|
<span className="busy-indicator" aria-hidden="true" />
|
||||||
<span>Открываем синхронное RIGHT-видео RAVNOVES00</span>
|
<span>Открываем recorded-realtime timeline M4.6</span>
|
||||||
</div>
|
</div>
|
||||||
) : videoError || !metadata.timeline || !videoSource ? (
|
);
|
||||||
<SpatialState message={videoError ?? "Видео-доказательство M4.6 недоступно."} />
|
} else {
|
||||||
) : (
|
content = (
|
||||||
|
<div className="m4-replay-threat-visual__deck">
|
||||||
|
<div
|
||||||
|
className="m4-replay-threat-visual__layer"
|
||||||
|
data-active={mode === "video" ? "true" : undefined}
|
||||||
|
aria-hidden={mode !== "video"}
|
||||||
|
>
|
||||||
|
{videoSource ? (
|
||||||
<RecordedEvidenceVideoScene
|
<RecordedEvidenceVideoScene
|
||||||
source={videoSource}
|
source={videoSource}
|
||||||
playback={playbackController.playback}
|
playback={playbackController.playback}
|
||||||
imageWidth={metadata.timeline.imageWidth}
|
imageWidth={timeline.imageWidth}
|
||||||
imageHeight={metadata.timeline.imageHeight}
|
imageHeight={timeline.imageHeight}
|
||||||
boxes={activeBoxes}
|
boxes={activeBoxes}
|
||||||
ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
|
ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
|
||||||
interactive={false}
|
interactive={false}
|
||||||
/>
|
/>
|
||||||
);
|
) : videoError ? (
|
||||||
} else if (timelineFrame.error) {
|
<SpatialState message={videoError} />
|
||||||
content = <SpatialState message={timelineFrame.error} />;
|
) : (
|
||||||
} else if (timelineFrame.loading || !metadata.timeline || !frame) {
|
|
||||||
content = (
|
|
||||||
<div className="l3-visual-audit__state" role="status">
|
<div className="l3-visual-audit__state" role="status">
|
||||||
<span className="busy-indicator" aria-hidden="true" />
|
<span className="busy-indicator" aria-hidden="true" />
|
||||||
<span>Буферизуем bounded spatial chunk M4.6</span>
|
<span>{videoLoading ? "Подготавливаем локальный видеобуфер" : "Открываем RIGHT-видео RAVNOVES00"}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
} else if (mode === "camera") {
|
</div>
|
||||||
content = (
|
<div
|
||||||
|
className="m4-replay-threat-visual__layer"
|
||||||
|
data-active={mode === "camera" ? "true" : undefined}
|
||||||
|
aria-hidden={mode !== "camera"}
|
||||||
|
>
|
||||||
|
{mode === "camera" && frame ? (
|
||||||
<RecordedEvidenceImageScene
|
<RecordedEvidenceImageScene
|
||||||
src={frame.cameraUrl}
|
src={frame.cameraUrl}
|
||||||
imageWidth={metadata.timeline.imageWidth}
|
imageWidth={timeline.imageWidth}
|
||||||
imageHeight={metadata.timeline.imageHeight}
|
imageHeight={timeline.imageHeight}
|
||||||
boxes={activeBoxes}
|
boxes={activeBoxes}
|
||||||
ariaLabel={`M4.6 exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
|
ariaLabel={`M4.6 exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
|
||||||
/>
|
/>
|
||||||
);
|
) : null}
|
||||||
} else if (!frame.spatialAvailable) {
|
</div>
|
||||||
content = <SpatialState message="На этом recorded-кадре нет квалифицированного body frame и current LiDAR increment." />;
|
<div
|
||||||
} else {
|
className="m4-replay-threat-visual__layer"
|
||||||
content = (
|
data-active={mode === "3d" || mode === "plan" ? "true" : undefined}
|
||||||
|
aria-hidden={mode !== "3d" && mode !== "plan"}
|
||||||
|
>
|
||||||
|
{frame ? (
|
||||||
<LaboratoryMetricEvidenceScene
|
<LaboratoryMetricEvidenceScene
|
||||||
pointCloudBodyXyzM={frame.pointCloudBodyXyzM}
|
pointCloudBodyXyzM={frame.pointCloudBodyXyzM}
|
||||||
obstacles={sceneObstacles}
|
obstacles={sceneObstacles}
|
||||||
rig={metadata.timeline.rig}
|
rig={timeline.rig}
|
||||||
corridor={metadata.timeline.corridor}
|
corridor={timeline.corridor}
|
||||||
mode={mode}
|
occupiedVoxelSizeM={timeline.occupiedVoxelSizeM}
|
||||||
|
mode={spatialMode}
|
||||||
label="M4.6 recorded-realtime current increment and rolling occupancy"
|
label="M4.6 recorded-realtime current increment and rolling occupancy"
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{timelineFrame.loading || displayingBufferedFrame ? (
|
||||||
|
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||||
|
<span className="busy-indicator" aria-hidden="true" />
|
||||||
|
<span>Догружаем следующий spatial-буфер без сброса сцены</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{timelineFrame.error ? (
|
||||||
|
<div className="m4-replay-threat-visual__buffering" role="alert">
|
||||||
|
<Icon name="alert" size={16} />
|
||||||
|
<span>{timelineFrame.error}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{frame && !frame.spatialAvailable && (mode === "3d" || mode === "plan") ? (
|
||||||
|
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||||
|
На этом кадре нет квалифицированного body frame; сцена сохранена.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeline = metadata.timeline;
|
|
||||||
const transport = timeline ? (
|
const transport = timeline ? (
|
||||||
<ObservationTimeline
|
<ObservationTimeline
|
||||||
className="m4-replay-threat-visual__timeline"
|
className="m4-replay-threat-visual__timeline"
|
||||||
|
|||||||
@@ -9,13 +9,26 @@ import {
|
|||||||
type M4ThreatTimelineFrame,
|
type M4ThreatTimelineFrame,
|
||||||
} from "../../core/laboratory/m4ReplayThreat";
|
} from "../../core/laboratory/m4ReplayThreat";
|
||||||
|
|
||||||
const REQUESTED_CHUNK_FRAMES = 12;
|
const REQUESTED_CHUNK_FRAMES = 24;
|
||||||
const RETAINED_CHUNK_COUNT = 4;
|
const RETAINED_CHUNK_COUNT = 8;
|
||||||
|
const PREFETCH_CHUNKS_AHEAD = 2;
|
||||||
|
|
||||||
function errorMessage(error: unknown, fallback: string): string {
|
function errorMessage(error: unknown, fallback: string): string {
|
||||||
return error instanceof Error && error.message.trim() ? error.message : fallback;
|
return error instanceof Error && error.message.trim() ? error.message : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function m4ThreatChunkWindowStarts(
|
||||||
|
activeChunkStart: number,
|
||||||
|
chunkSize: number,
|
||||||
|
frameCount: number,
|
||||||
|
): readonly number[] {
|
||||||
|
if (chunkSize < 1 || frameCount < 1) return [];
|
||||||
|
return Array.from(
|
||||||
|
{ length: PREFETCH_CHUNKS_AHEAD + 2 },
|
||||||
|
(_, index) => activeChunkStart + (index - 1) * chunkSize,
|
||||||
|
).filter((start) => start >= 0 && start < frameCount);
|
||||||
|
}
|
||||||
|
|
||||||
export function useM4ThreatTimelineMetadata(resultId: string) {
|
export function useM4ThreatTimelineMetadata(resultId: string) {
|
||||||
const [timeline, setTimeline] = useState<M4ThreatTimeline | null>(null);
|
const [timeline, setTimeline] = useState<M4ThreatTimeline | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -52,14 +65,22 @@ export function useM4ThreatTimelineFrame({
|
|||||||
() => new Map(),
|
() => new Map(),
|
||||||
);
|
);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const inFlight = useRef(new Set<number>());
|
const inFlight = useRef(new Map<number, AbortController>());
|
||||||
const chunksRef = useRef(chunks);
|
const chunksRef = useRef(chunks);
|
||||||
|
const activeChunkStartRef = useRef<number | null>(null);
|
||||||
chunksRef.current = chunks;
|
chunksRef.current = chunks;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setChunks(new Map());
|
for (const controller of inFlight.current.values()) controller.abort();
|
||||||
setError(null);
|
|
||||||
inFlight.current.clear();
|
inFlight.current.clear();
|
||||||
|
const empty = new Map<number, M4ThreatTimelineChunk>();
|
||||||
|
chunksRef.current = empty;
|
||||||
|
setChunks(empty);
|
||||||
|
setError(null);
|
||||||
|
return () => {
|
||||||
|
for (const controller of inFlight.current.values()) controller.abort();
|
||||||
|
inFlight.current.clear();
|
||||||
|
};
|
||||||
}, [resultId, timeline]);
|
}, [resultId, timeline]);
|
||||||
|
|
||||||
const activeSequence = useMemo(
|
const activeSequence = useMemo(
|
||||||
@@ -75,18 +96,19 @@ export function useM4ThreatTimelineFrame({
|
|||||||
const activeChunkStart = activeSequence === null
|
const activeChunkStart = activeSequence === null
|
||||||
? null
|
? null
|
||||||
: Math.floor(activeSequence / chunkSize) * chunkSize;
|
: Math.floor(activeSequence / chunkSize) * chunkSize;
|
||||||
|
activeChunkStartRef.current = activeChunkStart;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!timeline || activeChunkStart === null) return;
|
if (!timeline || activeChunkStart === null) return;
|
||||||
const starts = [activeChunkStart, activeChunkStart + chunkSize].filter(
|
const starts = m4ThreatChunkWindowStarts(
|
||||||
(start) => start < timeline.frameCount,
|
activeChunkStart,
|
||||||
|
chunkSize,
|
||||||
|
timeline.frameCount,
|
||||||
);
|
);
|
||||||
const controllers: AbortController[] = [];
|
|
||||||
for (const start of starts) {
|
for (const start of starts) {
|
||||||
if (chunksRef.current.has(start) || inFlight.current.has(start)) continue;
|
if (chunksRef.current.has(start) || inFlight.current.has(start)) continue;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
controllers.push(controller);
|
inFlight.current.set(start, controller);
|
||||||
inFlight.current.add(start);
|
|
||||||
void fetchM4ThreatTimelineChunk(resultId, start, chunkSize, {
|
void fetchM4ThreatTimelineChunk(resultId, start, chunkSize, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
@@ -95,23 +117,27 @@ export function useM4ThreatTimelineFrame({
|
|||||||
setChunks((current) => {
|
setChunks((current) => {
|
||||||
const next = new Map(current);
|
const next = new Map(current);
|
||||||
next.set(start, chunk);
|
next.set(start, chunk);
|
||||||
|
const center = activeChunkStartRef.current ?? start;
|
||||||
const retained = [...next.keys()]
|
const retained = [...next.keys()]
|
||||||
.sort((left, right) => (
|
.sort((left, right) => (
|
||||||
Math.abs(left - activeChunkStart) - Math.abs(right - activeChunkStart)
|
Math.abs(left - center) - Math.abs(right - center)
|
||||||
))
|
))
|
||||||
.slice(0, RETAINED_CHUNK_COUNT);
|
.slice(0, RETAINED_CHUNK_COUNT);
|
||||||
return new Map(retained.map((key) => [key, next.get(key)!]));
|
const bounded = new Map(retained.map((key) => [key, next.get(key)!]));
|
||||||
|
chunksRef.current = bounded;
|
||||||
|
return bounded;
|
||||||
});
|
});
|
||||||
if (start === activeChunkStart) setError(null);
|
if (start === activeChunkStartRef.current) setError(null);
|
||||||
})
|
})
|
||||||
.catch((caught: unknown) => {
|
.catch((caught: unknown) => {
|
||||||
if (!controller.signal.aborted && start === activeChunkStart) {
|
if (!controller.signal.aborted && start === activeChunkStartRef.current) {
|
||||||
setError(errorMessage(caught, "3D chunk M4.6 недоступен."));
|
setError(errorMessage(caught, "3D chunk M4.6 недоступен."));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => inFlight.current.delete(start));
|
.finally(() => {
|
||||||
|
if (inFlight.current.get(start) === controller) inFlight.current.delete(start);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return () => controllers.forEach((controller) => controller.abort());
|
|
||||||
}, [activeChunkStart, chunkSize, resultId, timeline]);
|
}, [activeChunkStart, chunkSize, resultId, timeline]);
|
||||||
|
|
||||||
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
|
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ let fetchM4ThreatTimelineChunk;
|
|||||||
let selectM4ThreatTimelineFrame;
|
let selectM4ThreatTimelineFrame;
|
||||||
let selectM4ThreatTimelineSequence;
|
let selectM4ThreatTimelineSequence;
|
||||||
let advanceRecordedEvidencePlayback;
|
let advanceRecordedEvidencePlayback;
|
||||||
|
let m4ThreatChunkWindowStarts;
|
||||||
|
|
||||||
const resultId = `m4-threat-replay-${"a".repeat(64)}`;
|
const resultId = `m4-threat-replay-${"a".repeat(64)}`;
|
||||||
|
|
||||||
@@ -32,6 +33,9 @@ before(async () => {
|
|||||||
({ advanceRecordedEvidencePlayback } = await server.ssrLoadModule(
|
({ advanceRecordedEvidencePlayback } = await server.ssrLoadModule(
|
||||||
"/src/components/laboratory/useRecordedEvidencePlayback.ts",
|
"/src/components/laboratory/useRecordedEvidencePlayback.ts",
|
||||||
));
|
));
|
||||||
|
({ m4ThreatChunkWindowStarts } = await server.ssrLoadModule(
|
||||||
|
"/src/workspaces/laboratory/useM4ThreatTimeline.ts",
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
after(async () => {
|
after(async () => {
|
||||||
@@ -243,6 +247,7 @@ test("M4.6 timeline keeps only a compact index and decodes bounded spatial chunk
|
|||||||
recorded_source: {
|
recorded_source: {
|
||||||
session_id: "20260720T065719Z_viewer_live",
|
session_id: "20260720T065719Z_viewer_live",
|
||||||
source_id: "RAVNOVES00",
|
source_id: "RAVNOVES00",
|
||||||
|
representation_id: "registered-map-increment-v1",
|
||||||
synchronization: "host-arrival-best-effort",
|
synchronization: "host-arrival-best-effort",
|
||||||
},
|
},
|
||||||
image_width: 800,
|
image_width: 800,
|
||||||
@@ -254,18 +259,24 @@ test("M4.6 timeline keeps only a compact index and decodes bounded spatial chunk
|
|||||||
nominal_frame_interval_seconds: 0.1,
|
nominal_frame_interval_seconds: 0.1,
|
||||||
nominal_rate_hz: 10,
|
nominal_rate_hz: 10,
|
||||||
max_chunk_frames: 24,
|
max_chunk_frames: 24,
|
||||||
point_sample_limit: 2000,
|
point_sample_limit: 4096,
|
||||||
|
maximum_source_points_per_frame: 3092,
|
||||||
|
point_delivery: "exact-current-increment",
|
||||||
rig: { length_m: 1, width_m: 0.6, nominal_sensor_height_m: 1.25 },
|
rig: { length_m: 1, width_m: 0.6, nominal_sensor_height_m: 1.25 },
|
||||||
corridor: {
|
corridor: {
|
||||||
forward_length_m: 8,
|
forward_length_m: 8,
|
||||||
rear_margin_m: 0.5,
|
rear_margin_m: 0.5,
|
||||||
half_width_m: 0.5,
|
half_width_m: 0.5,
|
||||||
|
occupied_voxel_size_m: 0.45,
|
||||||
prediction_horizon_seconds: 5,
|
prediction_horizon_seconds: 5,
|
||||||
},
|
},
|
||||||
authority: "replay-simulated",
|
authority: "replay-simulated",
|
||||||
}), { status: 200 }),
|
}), { status: 200 }),
|
||||||
});
|
});
|
||||||
assert.equal(timeline.frameTimesNs.length, 4489);
|
assert.equal(timeline.frameTimesNs.length, 4489);
|
||||||
|
assert.equal(timeline.pointDelivery, "exact-current-increment");
|
||||||
|
assert.equal(timeline.maximumSourcePointsPerFrame, 3092);
|
||||||
|
assert.equal(timeline.occupiedVoxelSizeM, 0.45);
|
||||||
assert.equal(selectM4ThreatTimelineSequence(timeline.frameTimesNs, 35.50), 1);
|
assert.equal(selectM4ThreatTimelineSequence(timeline.frameTimesNs, 35.50), 1);
|
||||||
|
|
||||||
const chunk = await fetchM4ThreatTimelineChunk(resultId, 0, 2, {
|
const chunk = await fetchM4ThreatTimelineChunk(resultId, 0, 2, {
|
||||||
@@ -289,6 +300,11 @@ test("M4.6 timeline keeps only a compact index and decodes bounded spatial chunk
|
|||||||
assert.equal(selectM4ThreatTimelineFrame(chunk.frames, 35.50).sequence, 1);
|
assert.equal(selectM4ThreatTimelineFrame(chunk.frames, 35.50).sequence, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
|
||||||
test("recorded evidence clock advances by selected rate and stops at the sealed end", () => {
|
test("recorded evidence clock advances by selected rate and stops at the sealed end", () => {
|
||||||
const range = { startSeconds: 10, endSeconds: 20 };
|
const range = { startSeconds: 10, endSeconds: 20 };
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -319,6 +335,8 @@ test("M4.6 viewer reuses shared camera, video and metric evidence renderers", as
|
|||||||
assert.match(visual, /<RecordedEvidenceVideoScene/);
|
assert.match(visual, /<RecordedEvidenceVideoScene/);
|
||||||
assert.match(visual, /<RecordedEvidenceImageScene/);
|
assert.match(visual, /<RecordedEvidenceImageScene/);
|
||||||
assert.match(visual, /<LaboratoryMetricEvidenceScene/);
|
assert.match(visual, /<LaboratoryMetricEvidenceScene/);
|
||||||
|
assert.match(visual, /m4-replay-threat-visual__deck/);
|
||||||
|
assert.match(visual, /lastFrameRef/);
|
||||||
assert.match(visual, /<ObservationTimeline/);
|
assert.match(visual, /<ObservationTimeline/);
|
||||||
assert.match(visual, /useM4ThreatTimelineFrame/);
|
assert.match(visual, /useM4ThreatTimelineFrame/);
|
||||||
assert.match(visual, /label: "VIDEO"/);
|
assert.match(visual, /label: "VIDEO"/);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ WHEEL_NAME = "nodedc_mission_core-0.1.0-py3-none-any.whl"
|
|||||||
RUNNER_NAME = RUNNER.name
|
RUNNER_NAME = RUNNER.name
|
||||||
PATCH_ID = re.compile(r"^[A-Za-z0-9._-]{1,96}$")
|
PATCH_ID = re.compile(r"^[A-Za-z0-9._-]{1,96}$")
|
||||||
EXPECTED_BASELINE_SHA256 = "ea10359339e6cce31b5780a2710299771cab7cc0c1c2a2b56a1621f786b31fa8"
|
EXPECTED_BASELINE_SHA256 = "ea10359339e6cce31b5780a2710299771cab7cc0c1c2a2b56a1621f786b31fa8"
|
||||||
EXPECTED_WHEEL_SHA256 = "215411719c6af4b042d0019f7f73b1885e2c2db47f87eb3617c653d7e751f19e"
|
EXPECTED_WHEEL_SHA256 = "9d203362cebb383fd5394e60dbcaa4cea6d332de9c0aa411cbc23825a4e53868"
|
||||||
PAYLOAD_FILES = (
|
PAYLOAD_FILES = (
|
||||||
RUNNER_NAME,
|
RUNNER_NAME,
|
||||||
WHEEL_NAME,
|
WHEEL_NAME,
|
||||||
|
|||||||
@@ -276,6 +276,13 @@ class RecordedGeometryStore:
|
|||||||
points.setflags(write=False)
|
points.setflags(write=False)
|
||||||
return points
|
return points
|
||||||
|
|
||||||
|
@property
|
||||||
|
def maximum_current_point_count(self) -> int:
|
||||||
|
"""Return the immutable source-pack upper bound for one recorded increment."""
|
||||||
|
|
||||||
|
counts = np.diff(np.asarray(self._source["cloud_offsets"], dtype=np.int64))
|
||||||
|
return int(counts.max(initial=0))
|
||||||
|
|
||||||
def pose_values_for_frame(
|
def pose_values_for_frame(
|
||||||
self,
|
self,
|
||||||
frame_id: str,
|
frame_id: str,
|
||||||
@@ -946,7 +953,10 @@ def _camera_unavailable_observation(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _load_npz(path: Path, label: str) -> dict[str, npt.NDArray[np.generic]]:
|
def _load_npz(
|
||||||
|
path: Path,
|
||||||
|
label: str,
|
||||||
|
) -> dict[str, npt.NDArray[np.generic]]:
|
||||||
try:
|
try:
|
||||||
with np.load(path, allow_pickle=False) as archive:
|
with np.load(path, allow_pickle=False) as archive:
|
||||||
return {name: np.asarray(archive[name]) for name in archive.files}
|
return {name: np.asarray(archive[name]) for name in archive.files}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from threading import RLock
|
|||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
from .geometry import RecordedGeometryStore
|
from .geometry import RecordedGeometryStore
|
||||||
|
from .recorded_source import RECORDED_REPRESENTATION_ID
|
||||||
from .spatial_evidence import (
|
from .spatial_evidence import (
|
||||||
project_metric_obstacles_to_body,
|
project_metric_obstacles_to_body,
|
||||||
sample_points_in_body_frame,
|
sample_points_in_body_frame,
|
||||||
@@ -32,7 +33,7 @@ from .threat_replay import (
|
|||||||
RECORDED_SPATIAL_TIMELINE_SCHEMA: Final = "missioncore.recorded-spatial-evidence-timeline/v1"
|
RECORDED_SPATIAL_TIMELINE_SCHEMA: Final = "missioncore.recorded-spatial-evidence-timeline/v1"
|
||||||
RECORDED_SPATIAL_CHUNK_SCHEMA: Final = "missioncore.recorded-spatial-evidence-chunk/v1"
|
RECORDED_SPATIAL_CHUNK_SCHEMA: Final = "missioncore.recorded-spatial-evidence-chunk/v1"
|
||||||
RECORDED_SPATIAL_FRAME_SCHEMA: Final = "missioncore.recorded-spatial-evidence-frame/v1"
|
RECORDED_SPATIAL_FRAME_SCHEMA: Final = "missioncore.recorded-spatial-evidence-frame/v1"
|
||||||
RECORDED_SPATIAL_POINT_LIMIT: Final = 2_000
|
RECORDED_SPATIAL_POINT_LIMIT: Final = 4_096
|
||||||
RECORDED_SPATIAL_MAX_CHUNK_FRAMES: Final = 24
|
RECORDED_SPATIAL_MAX_CHUNK_FRAMES: Final = 24
|
||||||
_EXPECTED_FRAME_COUNT: Final = 4_489
|
_EXPECTED_FRAME_COUNT: Final = 4_489
|
||||||
_SOURCE_TIME = re.compile(rb'"source_time_ns":([0-9]+)')
|
_SOURCE_TIME = re.compile(rb'"source_time_ns":([0-9]+)')
|
||||||
@@ -80,6 +81,10 @@ class RecordedThreatTimeline:
|
|||||||
or self.store.profile.frame_count != _EXPECTED_FRAME_COUNT
|
or self.store.profile.frame_count != _EXPECTED_FRAME_COUNT
|
||||||
):
|
):
|
||||||
raise RecordedThreatTimelineError("recorded timeline geometry identity changed")
|
raise RecordedThreatTimelineError("recorded timeline geometry identity changed")
|
||||||
|
if self.store.maximum_current_point_count > RECORDED_SPATIAL_POINT_LIMIT:
|
||||||
|
raise RecordedThreatTimelineError(
|
||||||
|
"recorded timeline exact point delivery exceeds its declared bound"
|
||||||
|
)
|
||||||
self.body_frames = RecordedReplayBodyFrameResolver(
|
self.body_frames = RecordedReplayBodyFrameResolver(
|
||||||
self.store,
|
self.store,
|
||||||
profile=self.profile.body_frame,
|
profile=self.profile.body_frame,
|
||||||
@@ -99,6 +104,7 @@ class RecordedThreatTimeline:
|
|||||||
"recorded_source": {
|
"recorded_source": {
|
||||||
"session_id": self.profile.session_id,
|
"session_id": self.profile.session_id,
|
||||||
"source_id": self.profile.source_id,
|
"source_id": self.profile.source_id,
|
||||||
|
"representation_id": RECORDED_REPRESENTATION_ID,
|
||||||
"synchronization": "host-arrival-best-effort",
|
"synchronization": "host-arrival-best-effort",
|
||||||
},
|
},
|
||||||
"frame_count": len(times),
|
"frame_count": len(times),
|
||||||
@@ -109,6 +115,8 @@ class RecordedThreatTimeline:
|
|||||||
"nominal_rate_hz": 1 / nominal_interval,
|
"nominal_rate_hz": 1 / nominal_interval,
|
||||||
"max_chunk_frames": RECORDED_SPATIAL_MAX_CHUNK_FRAMES,
|
"max_chunk_frames": RECORDED_SPATIAL_MAX_CHUNK_FRAMES,
|
||||||
"point_sample_limit": RECORDED_SPATIAL_POINT_LIMIT,
|
"point_sample_limit": RECORDED_SPATIAL_POINT_LIMIT,
|
||||||
|
"point_delivery": "exact-current-increment",
|
||||||
|
"maximum_source_points_per_frame": self.store.maximum_current_point_count,
|
||||||
"image_width": 800,
|
"image_width": 800,
|
||||||
"image_height": 600,
|
"image_height": 600,
|
||||||
"rig": {
|
"rig": {
|
||||||
@@ -119,6 +127,7 @@ class RecordedThreatTimeline:
|
|||||||
"corridor": {
|
"corridor": {
|
||||||
"forward_length_m": self.profile.corridor.forward_length_m,
|
"forward_length_m": self.profile.corridor.forward_length_m,
|
||||||
"rear_margin_m": self.profile.corridor.rear_margin_m,
|
"rear_margin_m": self.profile.corridor.rear_margin_m,
|
||||||
|
"occupied_voxel_size_m": self.profile.corridor.occupied_voxel_size_m,
|
||||||
"half_width_m": (
|
"half_width_m": (
|
||||||
self.profile.rig.body_width_m / 2 + self.profile.corridor.lateral_clearance_m
|
self.profile.rig.body_width_m / 2 + self.profile.corridor.lateral_clearance_m
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from fastapi.exceptions import RequestValidationError
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
from starlette.middleware.gzip import GZipMiddleware
|
||||||
|
|
||||||
from k1link import __version__
|
from k1link import __version__
|
||||||
from k1link.artifact_gateway import configured_artifact_gateway
|
from k1link.artifact_gateway import configured_artifact_gateway
|
||||||
@@ -414,6 +415,7 @@ app = FastAPI(
|
|||||||
openapi_url="/api/openapi.json",
|
openapi_url="/api/openapi.json",
|
||||||
lifespan=app_lifespan,
|
lifespan=app_lifespan,
|
||||||
)
|
)
|
||||||
|
app.add_middleware(GZipMiddleware, minimum_size=1_024, compresslevel=5)
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
@app.exception_handler(RequestValidationError)
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None:
|
|||||||
assert timeline["frame_count"] == 4489
|
assert timeline["frame_count"] == 4489
|
||||||
assert len(timeline["frame_times_ns"]) == 4489
|
assert len(timeline["frame_times_ns"]) == 4489
|
||||||
assert timeline["recorded_source"]["session_id"] == "20260720T065719Z_viewer_live"
|
assert timeline["recorded_source"]["session_id"] == "20260720T065719Z_viewer_live"
|
||||||
|
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 "frames" not in timeline
|
assert "frames" not in timeline
|
||||||
|
|
||||||
chunk = get_chunk(RESULT_ID, start=1880, count=12)
|
chunk = get_chunk(RESULT_ID, start=1880, count=12)
|
||||||
@@ -141,7 +144,8 @@ def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None:
|
|||||||
assert first["schema_version"] == "missioncore.recorded-spatial-evidence-frame/v1"
|
assert first["schema_version"] == "missioncore.recorded-spatial-evidence-frame/v1"
|
||||||
assert first["spatial_available"] is True
|
assert first["spatial_available"] is True
|
||||||
assert first["point_cloud_layer"] == "current-increment"
|
assert first["point_cloud_layer"] == "current-increment"
|
||||||
assert 0 < first["point_cloud_sample_count"] <= 2000
|
assert first["point_cloud_sample_count"] == first["point_cloud_source_count"]
|
||||||
|
assert 0 < first["point_cloud_sample_count"] <= 4096
|
||||||
assert first["camera_url"].endswith(f"/{RESULT_ID}/timeline/frames/1880/camera")
|
assert first["camera_url"].endswith(f"/{RESULT_ID}/timeline/frames/1880/camera")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user