fix(lab): seal RAV004 spatial replay transport
This commit is contained in:
@@ -28,6 +28,7 @@ export function RecordedEvidenceVideoScene({
|
|||||||
imageWidth,
|
imageWidth,
|
||||||
imageHeight,
|
imageHeight,
|
||||||
boxes,
|
boxes,
|
||||||
|
overlaySeconds,
|
||||||
semanticOverlay,
|
semanticOverlay,
|
||||||
pointCloudOverlay,
|
pointCloudOverlay,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
@@ -46,6 +47,7 @@ export function RecordedEvidenceVideoScene({
|
|||||||
imageWidth: number;
|
imageWidth: number;
|
||||||
imageHeight: number;
|
imageHeight: number;
|
||||||
boxes: readonly RecordedEvidenceBox[];
|
boxes: readonly RecordedEvidenceBox[];
|
||||||
|
overlaySeconds?: number;
|
||||||
semanticOverlay?: RecordedEvidenceSemanticOverlay;
|
semanticOverlay?: RecordedEvidenceSemanticOverlay;
|
||||||
pointCloudOverlay?: RecordedEvidencePointCloudOverlayData;
|
pointCloudOverlay?: RecordedEvidencePointCloudOverlayData;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
@@ -77,7 +79,8 @@ export function RecordedEvidenceVideoScene({
|
|||||||
const sourceReady = admissionPhase === "ready";
|
const sourceReady = admissionPhase === "ready";
|
||||||
const overlaysPresented = sourceReady
|
const overlaysPresented = sourceReady
|
||||||
&& presentedSeconds !== null
|
&& presentedSeconds !== null
|
||||||
&& Math.abs(presentedSeconds - playback.currentSeconds) <= 0.25;
|
&& Math.abs(presentedSeconds - playback.currentSeconds) <= 0.25
|
||||||
|
&& (overlaySeconds === undefined || Math.abs(presentedSeconds - overlaySeconds) <= 0.25);
|
||||||
const handlePlaybackChange = (next: RecordedObservationPlayback) => {
|
const handlePlaybackChange = (next: RecordedObservationPlayback) => {
|
||||||
setPresentedSeconds(next.currentSeconds);
|
setPresentedSeconds(next.currentSeconds);
|
||||||
// During a paused operator seek the existing media element can emit its old
|
// During a paused operator seek the existing media element can emit its old
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ export interface M4ThreatPlaybackProgress {
|
|||||||
|
|
||||||
export interface M4ThreatPlaybackPointPack {
|
export interface M4ThreatPlaybackPointPack {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
frameCount: 4489;
|
frameCount: number;
|
||||||
pointCount: number;
|
pointCount: number;
|
||||||
pointOffsets: Uint32Array;
|
pointOffsets: Uint32Array;
|
||||||
pointsMapXyzM: Float32Array;
|
pointsMapXyzM: Float32Array;
|
||||||
@@ -261,7 +261,7 @@ export interface M4ThreatPlaybackChunkDescriptor {
|
|||||||
|
|
||||||
export interface M4ThreatPlaybackManifest {
|
export interface M4ThreatPlaybackManifest {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
frameCount: 4489;
|
frameCount: number;
|
||||||
pointCount: number;
|
pointCount: number;
|
||||||
pointOffsets: Uint32Array;
|
pointOffsets: Uint32Array;
|
||||||
chunkFrameCount: 24;
|
chunkFrameCount: 24;
|
||||||
@@ -341,7 +341,7 @@ const motion = (value: unknown): M4ThreatMotion => {
|
|||||||
};
|
};
|
||||||
const resultId = (value: unknown): string => {
|
const resultId = (value: unknown): string => {
|
||||||
const parsed = text(value, "M4.6 result id");
|
const parsed = text(value, "M4.6 result id");
|
||||||
if (!/^m4-threat-replay-[a-f0-9]{64}$/.test(parsed)) {
|
if (!/^[a-z0-9][a-z0-9-]{0,127}-[a-f0-9]{64}$/.test(parsed)) {
|
||||||
throw new M4ThreatContractError("M4.6 result id: нарушена идентичность.");
|
throw new M4ThreatContractError("M4.6 result id: нарушена идентичность.");
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
@@ -1001,7 +1001,10 @@ export async function fetchM4ThreatPlaybackManifest(
|
|||||||
exact(manifest.result_id, result, "M4.6 playback result");
|
exact(manifest.result_id, result, "M4.6 playback result");
|
||||||
exact(manifest.coordinate_frame, "map", "M4.6 playback coordinate frame");
|
exact(manifest.coordinate_frame, "map", "M4.6 playback coordinate frame");
|
||||||
exact(manifest.access, "read-only-sealed-binary-playback", "M4.6 playback access");
|
exact(manifest.access, "read-only-sealed-binary-playback", "M4.6 playback access");
|
||||||
const frameCount = exact(integer(manifest.frame_count, "M4.6 playback frames"), 4489, "M4.6 playback frames");
|
const frameCount = integer(manifest.frame_count, "M4.6 playback frames");
|
||||||
|
if (frameCount < 1) {
|
||||||
|
throw new M4ThreatContractError("M4.6 playback frames: пустой playback недопустим.");
|
||||||
|
}
|
||||||
const pointCount = integer(manifest.point_count, "M4.6 playback points");
|
const pointCount = integer(manifest.point_count, "M4.6 playback points");
|
||||||
const offsetsRaw = array(manifest.point_offsets, "M4.6 playback offsets");
|
const offsetsRaw = array(manifest.point_offsets, "M4.6 playback offsets");
|
||||||
if (offsetsRaw.length !== frameCount + 1) {
|
if (offsetsRaw.length !== frameCount + 1) {
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ export function M4ReplayThreatVisual({
|
|||||||
showReferenceMediaLayers = true,
|
showReferenceMediaLayers = true,
|
||||||
showSpatialOverlaySummary = true,
|
showSpatialOverlaySummary = true,
|
||||||
playbackTransport = "epoch-stream",
|
playbackTransport = "epoch-stream",
|
||||||
|
spatialPlaybackTransport = "auto",
|
||||||
recoverTimestampStalls = false,
|
recoverTimestampStalls = false,
|
||||||
onActiveSequenceChange,
|
onActiveSequenceChange,
|
||||||
}: {
|
}: {
|
||||||
@@ -198,6 +199,7 @@ export function M4ReplayThreatVisual({
|
|||||||
showReferenceMediaLayers?: boolean;
|
showReferenceMediaLayers?: boolean;
|
||||||
showSpatialOverlaySummary?: boolean;
|
showSpatialOverlaySummary?: boolean;
|
||||||
playbackTransport?: "segmented" | "epoch-stream";
|
playbackTransport?: "segmented" | "epoch-stream";
|
||||||
|
spatialPlaybackTransport?: "auto" | "sealed-binary" | "json";
|
||||||
recoverTimestampStalls?: boolean;
|
recoverTimestampStalls?: boolean;
|
||||||
onActiveSequenceChange?: (sequence: number | null) => void;
|
onActiveSequenceChange?: (sequence: number | null) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -293,6 +295,7 @@ export function M4ReplayThreatVisual({
|
|||||||
currentSeconds: playbackController.playback.currentSeconds,
|
currentSeconds: playbackController.playback.currentSeconds,
|
||||||
includeSpatialPoints: evidenceDemand.sourceSpatialPoints,
|
includeSpatialPoints: evidenceDemand.sourceSpatialPoints,
|
||||||
endpointRoot: timelineEndpointRoot,
|
endpointRoot: timelineEndpointRoot,
|
||||||
|
spatialPlaybackTransport,
|
||||||
});
|
});
|
||||||
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
|
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
|
||||||
const [videoLoading, setVideoLoading] = useState(false);
|
const [videoLoading, setVideoLoading] = useState(false);
|
||||||
@@ -1143,6 +1146,7 @@ export function M4ReplayThreatVisual({
|
|||||||
imageWidth={timeline.imageWidth}
|
imageWidth={timeline.imageWidth}
|
||||||
imageHeight={timeline.imageHeight}
|
imageHeight={timeline.imageHeight}
|
||||||
boxes={activeBoxes}
|
boxes={activeBoxes}
|
||||||
|
overlaySeconds={frame?.sessionSeconds}
|
||||||
semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined}
|
semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined}
|
||||||
pointCloudOverlay={mediaMode === "video" ? pointCloudOverlay : undefined}
|
pointCloudOverlay={mediaMode === "video" ? pointCloudOverlay : undefined}
|
||||||
ariaLabel={`${evidenceLabel} recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
|
ariaLabel={`${evidenceLabel} recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ function FullRouteReviewEvidence({
|
|||||||
classifiedSpatialLayer={sealedSpatialGap}
|
classifiedSpatialLayer={sealedSpatialGap}
|
||||||
evidenceLabel="RAVNOVES004TREE"
|
evidenceLabel="RAVNOVES004TREE"
|
||||||
playbackTransport="segmented"
|
playbackTransport="segmented"
|
||||||
|
spatialPlaybackTransport="sealed-binary"
|
||||||
recoverTimestampStalls
|
recoverTimestampStalls
|
||||||
showReferenceMediaLayers
|
showReferenceMediaLayers
|
||||||
showSpatialOverlaySummary
|
showSpatialOverlaySummary
|
||||||
|
|||||||
@@ -34,14 +34,14 @@ export function m4ThreatChunkWindowStarts(
|
|||||||
if (chunkSize < 1 || frameCount < 1) return [];
|
if (chunkSize < 1 || frameCount < 1) return [];
|
||||||
return [
|
return [
|
||||||
activeChunkStart,
|
activeChunkStart,
|
||||||
...Array.from(
|
|
||||||
{ length: RETAINED_CHUNKS_BEHIND },
|
|
||||||
(_, index) => activeChunkStart - (index + 1) * chunkSize,
|
|
||||||
),
|
|
||||||
...Array.from(
|
...Array.from(
|
||||||
{ length: PREFETCH_CHUNKS_AHEAD },
|
{ length: PREFETCH_CHUNKS_AHEAD },
|
||||||
(_, index) => activeChunkStart + (index + 1) * chunkSize,
|
(_, index) => activeChunkStart + (index + 1) * chunkSize,
|
||||||
),
|
),
|
||||||
|
...Array.from(
|
||||||
|
{ length: RETAINED_CHUNKS_BEHIND },
|
||||||
|
(_, index) => activeChunkStart - (index + 1) * chunkSize,
|
||||||
|
),
|
||||||
].filter((start) => start >= 0 && start < frameCount);
|
].filter((start) => start >= 0 && start < frameCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,12 +86,14 @@ export function useM4ThreatTimelineFrame({
|
|||||||
currentSeconds,
|
currentSeconds,
|
||||||
includeSpatialPoints = true,
|
includeSpatialPoints = true,
|
||||||
endpointRoot,
|
endpointRoot,
|
||||||
|
spatialPlaybackTransport = "auto",
|
||||||
}: {
|
}: {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
timeline: M4ThreatTimeline | null;
|
timeline: M4ThreatTimeline | null;
|
||||||
currentSeconds: number;
|
currentSeconds: number;
|
||||||
includeSpatialPoints?: boolean;
|
includeSpatialPoints?: boolean;
|
||||||
endpointRoot?: string;
|
endpointRoot?: string;
|
||||||
|
spatialPlaybackTransport?: "auto" | "sealed-binary" | "json";
|
||||||
}) {
|
}) {
|
||||||
const [chunks, setChunks] = useState<ReadonlyMap<number, M4ThreatTimelineChunk>>(
|
const [chunks, setChunks] = useState<ReadonlyMap<number, M4ThreatTimelineChunk>>(
|
||||||
() => new Map(),
|
() => new Map(),
|
||||||
@@ -104,8 +106,10 @@ export function useM4ThreatTimelineFrame({
|
|||||||
totalBytes: 0,
|
totalBytes: 0,
|
||||||
});
|
});
|
||||||
const [playbackError, setPlaybackError] = useState<string | null>(null);
|
const [playbackError, setPlaybackError] = useState<string | null>(null);
|
||||||
const binaryPlayback = endpointRoot === undefined
|
const binaryPlayback = spatialPlaybackTransport === "sealed-binary"
|
||||||
|| endpointRoot === M4_THREAT_TIMELINE_ENDPOINT_ROOT;
|
|| (spatialPlaybackTransport === "auto" && (
|
||||||
|
endpointRoot === undefined || endpointRoot === M4_THREAT_TIMELINE_ENDPOINT_ROOT
|
||||||
|
));
|
||||||
const inFlight = useRef(new Map<number, AbortController>());
|
const inFlight = useRef(new Map<number, AbortController>());
|
||||||
const chunksRef = useRef(chunks);
|
const chunksRef = useRef(chunks);
|
||||||
const activeChunkStartRef = useRef<number | null>(null);
|
const activeChunkStartRef = useRef<number | null>(null);
|
||||||
|
|||||||
@@ -379,14 +379,16 @@ test("M4.6 hydrates a lightweight timeline frame from one retained binary point
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("M4.6 source cloud opens from one verified bounded chunk instead of the 105 MiB track", async () => {
|
test("M4.6 source cloud opens from one verified bounded chunk instead of the 105 MiB track", async () => {
|
||||||
const pointOffsets = [0, ...Array(4489).fill(2)];
|
const playbackResultId = `lab-v1-vegetation-shadow-${"b".repeat(64)}`;
|
||||||
|
const frameCount = 48;
|
||||||
|
const pointOffsets = [0, ...Array(frameCount).fill(2)];
|
||||||
const points = new Float32Array([11, 20, 30.25, 12, 19.5, 30]);
|
const points = new Float32Array([11, 20, 30.25, 12, 19.5, 30]);
|
||||||
const pointBytes = points.buffer;
|
const pointBytes = points.buffer;
|
||||||
const pointSha256 = createHash("sha256").update(Buffer.from(pointBytes)).digest("hex");
|
const pointSha256 = createHash("sha256").update(Buffer.from(pointBytes)).digest("hex");
|
||||||
const endpointRoot = "/api/v1/laboratory/m4-threat/results";
|
const endpointRoot = "/api/v1/laboratory/m4-threat/results";
|
||||||
const chunks = Array.from({ length: 188 }, (_, index) => {
|
const chunks = Array.from({ length: Math.ceil(frameCount / 24) }, (_, index) => {
|
||||||
const start = index * 24;
|
const start = index * 24;
|
||||||
const count = Math.min(24, 4489 - start);
|
const count = Math.min(24, frameCount - start);
|
||||||
const pointStart = pointOffsets[start];
|
const pointStart = pointOffsets[start];
|
||||||
const pointStop = pointOffsets[start + count];
|
const pointStop = pointOffsets[start + count];
|
||||||
const pointCount = pointStop - pointStart;
|
const pointCount = pointStop - pointStart;
|
||||||
@@ -396,7 +398,7 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
|
|||||||
count,
|
count,
|
||||||
point_start: pointStart,
|
point_start: pointStart,
|
||||||
point_count: pointCount,
|
point_count: pointCount,
|
||||||
url: `${endpointRoot}/${resultId}/timeline/playback/chunks/${index}`,
|
url: `${endpointRoot}/${playbackResultId}/timeline/playback/chunks/${index}`,
|
||||||
media_type: "application/octet-stream",
|
media_type: "application/octet-stream",
|
||||||
dtype: "<f4",
|
dtype: "<f4",
|
||||||
shape: [pointCount, 3],
|
shape: [pointCount, 3],
|
||||||
@@ -406,8 +408,8 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
|
|||||||
});
|
});
|
||||||
const manifestPayload = {
|
const manifestPayload = {
|
||||||
schema_version: "missioncore.recorded-spatial-playback/v1",
|
schema_version: "missioncore.recorded-spatial-playback/v1",
|
||||||
result_id: resultId,
|
result_id: playbackResultId,
|
||||||
frame_count: 4489,
|
frame_count: frameCount,
|
||||||
point_count: 2,
|
point_count: 2,
|
||||||
point_offsets: pointOffsets,
|
point_offsets: pointOffsets,
|
||||||
chunk_frame_count: 24,
|
chunk_frame_count: 24,
|
||||||
@@ -416,7 +418,7 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
|
|||||||
chunks,
|
chunks,
|
||||||
track: {
|
track: {
|
||||||
id: "points-map-f32",
|
id: "points-map-f32",
|
||||||
url: `${endpointRoot}/${resultId}/timeline/playback/tracks/points-map-f32`,
|
url: `${endpointRoot}/${playbackResultId}/timeline/playback/tracks/points-map-f32`,
|
||||||
media_type: "application/octet-stream",
|
media_type: "application/octet-stream",
|
||||||
dtype: "<f4",
|
dtype: "<f4",
|
||||||
shape: [2, 3],
|
shape: [2, 3],
|
||||||
@@ -434,12 +436,12 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
|
|||||||
if (url.endsWith("/timeline/playback/chunks/0")) return new Response(pointBytes.slice(0));
|
if (url.endsWith("/timeline/playback/chunks/0")) return new Response(pointBytes.slice(0));
|
||||||
return new Response(null, { status: 404 });
|
return new Response(null, { status: 404 });
|
||||||
};
|
};
|
||||||
const manifest = await fetchM4ThreatPlaybackManifest(resultId, { fetcher });
|
const manifest = await fetchM4ThreatPlaybackManifest(playbackResultId, { fetcher });
|
||||||
const chunk = await fetchM4ThreatPlaybackPointChunk(manifest, 0, { fetcher });
|
const chunk = await fetchM4ThreatPlaybackPointChunk(manifest, 0, { fetcher });
|
||||||
|
|
||||||
assert.deepEqual(requested, [
|
assert.deepEqual(requested, [
|
||||||
`${endpointRoot}/${resultId}/timeline/playback`,
|
`${endpointRoot}/${playbackResultId}/timeline/playback`,
|
||||||
`${endpointRoot}/${resultId}/timeline/playback/chunks/0`,
|
`${endpointRoot}/${playbackResultId}/timeline/playback/chunks/0`,
|
||||||
]);
|
]);
|
||||||
assert.equal(chunk.pointCount, 2);
|
assert.equal(chunk.pointCount, 2);
|
||||||
assert.equal(chunk.pointStart, 0);
|
assert.equal(chunk.pointStart, 0);
|
||||||
@@ -769,7 +771,7 @@ test("M4.6 local SLAM surface reprojects registered increments into the active b
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("M4.6 spatial buffering keeps the active and one future chunk", () => {
|
test("M4.6 spatial buffering keeps the active and one future chunk", () => {
|
||||||
assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 24, 72]);
|
assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 72, 24]);
|
||||||
assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24]);
|
assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -787,7 +789,7 @@ test("M4.6 spatial buffering drops stale in-flight windows across rapid jumps",
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert.deepEqual([...inFlight.keys()], [4488, 4464]);
|
assert.deepEqual([...inFlight.keys()], [4488, 4464]);
|
||||||
assert.deepEqual(aborted, [0, 24, 1488, 1464, 1512]);
|
assert.deepEqual(aborted, [0, 24, 1488, 1512, 1464]);
|
||||||
});
|
});
|
||||||
|
|
||||||
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", () => {
|
||||||
|
|||||||
@@ -22,6 +22,29 @@ Window structure, switching, seek, buffering and spatial scene code are shared.
|
|||||||
|
|
||||||
## Why the previous LAB failed
|
## Why the previous LAB failed
|
||||||
|
|
||||||
|
### RAV004 did not use the accepted replay data plane
|
||||||
|
|
||||||
|
The working M4/Hologravity LAB uses sealed binary numeric tracks, retained scene
|
||||||
|
state and bounded JSON metadata. RAV004 reused the visual component but silently
|
||||||
|
left its custom timeline endpoint on the JSON fallback. Each eight-frame spatial
|
||||||
|
window therefore transferred approximately 1.2-2.9 MB and took 0.87-1.37 s to
|
||||||
|
produce while representing only about 0.84 s of playback. The next request
|
||||||
|
aborted and replaced the previous one before spatial state could catch up.
|
||||||
|
|
||||||
|
The user screenshot captured the failure exactly: camera frame 366 was active
|
||||||
|
while the last delivered spatial evidence was frame 230, a 136-frame gap. The
|
||||||
|
backend also reopened and indexed the 6830-entry semantic ZIP for every requested
|
||||||
|
mask and proposal frame. This explains why the same canonical viewer was smooth
|
||||||
|
for Hologravity but stalled for RAV004: the window and interaction code were
|
||||||
|
shared, but the data-plane contract was not.
|
||||||
|
|
||||||
|
RAV004 now publishes the same `missioncore.recorded-spatial-playback/v1`
|
||||||
|
contract as the accepted LAB: an immutable Float32 map-point track, camera-frame
|
||||||
|
offsets and 24-frame binary chunks. JSON chunks contain bounded frame metadata
|
||||||
|
only; retained Local SLAM is reconstructed from exact source increments in the
|
||||||
|
shared client. The semantic ZIP handle and member index are cached per immutable
|
||||||
|
artifact instead of being reparsed per frame.
|
||||||
|
|
||||||
### Video and spatial state had different clocks
|
### Video and spatial state had different clocks
|
||||||
|
|
||||||
The removed RAV004 viewer advanced an animation/host clock even when the browser
|
The removed RAV004 viewer advanced an animation/host clock even when the browser
|
||||||
@@ -77,8 +100,11 @@ dozens of seconds old. The buffer now loads the active chunk first, the precedin
|
|||||||
chunk second and the next chunk as prefetch. The scene selects the latest proven
|
chunk second and the next chunk as prefetch. The scene selects the latest proven
|
||||||
source increment whose sequence is not later than the active camera frame.
|
source increment whose sequence is not later than the active camera frame.
|
||||||
|
|
||||||
At the final UI check, camera frame 189 causally held spatial frame 184. Before
|
At the final UI check, playback restarted at frame 1, then ran continuously past
|
||||||
the fix the same point could hold frame 16.
|
frame 462. At frame 188 the DDRNet mask was frame 188, proposals were frame 187
|
||||||
|
and spatial state was delivered without buffering; the one-frame proposal delay
|
||||||
|
is the recorded causal overlay, not stale UI state. Before the transport fix the
|
||||||
|
user's run had already fallen 136 frames behind by camera frame 366.
|
||||||
|
|
||||||
## Capability ledger
|
## Capability ledger
|
||||||
|
|
||||||
@@ -104,12 +130,17 @@ full-TGS result is also not reused because it has a different source identity an
|
|||||||
Measured on the canonical local service and current immutable artifacts:
|
Measured on the canonical local service and current immutable artifacts:
|
||||||
|
|
||||||
- replay launch POST: 3.55 s on first opening;
|
- replay launch POST: 3.55 s on first opening;
|
||||||
- timeline metadata: 0.02 s warm;
|
- first binary playback-manifest build after a service restart: 32.33 s while
|
||||||
- active spatial chunk, eight camera frames: 35.17 s first process-local RRD
|
the process-local RRD point track is materialized; warm manifest: 0.18-0.20 s;
|
||||||
index build, 0.67 s warm, approximately 3.81 MB;
|
- full retained point track: 3,893,445 Float32 map points, 46,721,340 bytes,
|
||||||
- UI replay: passed the previously deterministic 11.422 s decoder stop, then
|
divided into 285 immutable 24-frame chunks;
|
||||||
continued to 59 s with media and timeline advancing together;
|
- representative active binary chunks: 155-224 KB at 9-10 ms;
|
||||||
- operator reset seek: 16.4 s to 0 s, one mounted media worker, successful;
|
- representative 24-frame metadata chunks: 24-32 KB at 0.78-1.0 s, covering
|
||||||
|
about 2.4 s of playback;
|
||||||
|
- cached semantic-mask reads: 5.6-7.8 ms instead of approximately 80 ms;
|
||||||
|
- UI replay: reset to frame 1 and ran continuously beyond frame 462 with camera,
|
||||||
|
semantic overlay and retained spatial state advancing together;
|
||||||
|
- operator reset seek: successful, one mounted media worker;
|
||||||
- browser console after the acceptance run: no warnings or errors.
|
- browser console after the acceptance run: no warnings or errors.
|
||||||
|
|
||||||
The first RRD index is still process-local rather than a persistent disk cache.
|
The first RRD index is still process-local rather than a persistent disk cache.
|
||||||
@@ -147,13 +178,14 @@ deploy it.
|
|||||||
|
|
||||||
## Acceptance performed
|
## Acceptance performed
|
||||||
|
|
||||||
- 44 focused backend tests passed;
|
- 12 focused backend spatial/API tests passed;
|
||||||
- 37 frontend replay, buffering and LAB contract tests passed;
|
- 16 focused frontend replay transport/manifest tests passed;
|
||||||
- TypeScript project typecheck passed;
|
- TypeScript project typecheck passed;
|
||||||
- production Vite build passed (only existing large-chunk warnings);
|
- production Vite build passed (only existing large-chunk warnings);
|
||||||
- `git diff --check` passed;
|
- `git diff --check` passed;
|
||||||
- live browser run verified the shared controls, disabled unsealed TGS/3D
|
- live browser run verified reset seek, the shared controls, disabled unsealed
|
||||||
semantics, continuous media recovery, causal spatial hold and clean console.
|
TGS/3D semantics, continuous playback through the former failing interval,
|
||||||
|
causal spatial hold and a clean console.
|
||||||
|
|
||||||
Visual QA: `docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_QA.jpg`.
|
Visual QA: `docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_QA.jpg`.
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,13 @@ def _session_times(batch: Any) -> Any | None:
|
|||||||
return batch.column("session_time")
|
return batch.column("session_time")
|
||||||
|
|
||||||
|
|
||||||
def _point_rows(chunks: list[Any], entity: str, component: str, *, nested: bool = False) -> _TimedPoints:
|
def _point_rows(
|
||||||
|
chunks: list[Any],
|
||||||
|
entity: str,
|
||||||
|
component: str,
|
||||||
|
*,
|
||||||
|
nested: bool = False,
|
||||||
|
) -> _TimedPoints:
|
||||||
rows: list[tuple[int, np.ndarray]] = []
|
rows: list[tuple[int, np.ndarray]] = []
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
if chunk.entity_path != entity:
|
if chunk.entity_path != entity:
|
||||||
@@ -436,6 +442,8 @@ def _bounded_local_slam(
|
|||||||
def _canonical_lab_spatial_frame_from_index(
|
def _canonical_lab_spatial_frame_from_index(
|
||||||
index: _CanonicalSpatialIndex,
|
index: _CanonicalSpatialIndex,
|
||||||
target_time_ns: int,
|
target_time_ns: int,
|
||||||
|
*,
|
||||||
|
include_local_slam: bool = True,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
point_index = _latest_index(index.points.times_ns, target_time_ns)
|
point_index = _latest_index(index.points.times_ns, target_time_ns)
|
||||||
pose_index = _latest_index(index.poses.times_ns, index.points.times_ns[point_index])
|
pose_index = _latest_index(index.poses.times_ns, index.points.times_ns[point_index])
|
||||||
@@ -462,12 +470,17 @@ def _canonical_lab_spatial_frame_from_index(
|
|||||||
ground_origin,
|
ground_origin,
|
||||||
basis_map_from_body,
|
basis_map_from_body,
|
||||||
)
|
)
|
||||||
local_slam, local_slam_source_frames, local_slam_source_points = _bounded_local_slam(
|
if include_local_slam:
|
||||||
index.points,
|
local_slam, local_slam_source_frames, local_slam_source_points = _bounded_local_slam(
|
||||||
index.points.times_ns[point_index],
|
index.points,
|
||||||
ground_origin,
|
index.points.times_ns[point_index],
|
||||||
basis_map_from_body,
|
ground_origin,
|
||||||
)
|
basis_map_from_body,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
local_slam = np.empty((0, 3), dtype=np.float32)
|
||||||
|
local_slam_source_frames = 0
|
||||||
|
local_slam_source_points = 0
|
||||||
return {
|
return {
|
||||||
"schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v3",
|
"schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v3",
|
||||||
"target_time_ns": target_time_ns,
|
"target_time_ns": target_time_ns,
|
||||||
@@ -531,6 +544,8 @@ def canonical_lab_spatial_timeline_samples(
|
|||||||
frame_times_ns: tuple[int, ...],
|
frame_times_ns: tuple[int, ...],
|
||||||
start_sequence: int,
|
start_sequence: int,
|
||||||
frame_count: int,
|
frame_count: int,
|
||||||
|
*,
|
||||||
|
include_local_slam: bool = True,
|
||||||
) -> tuple[dict[str, object] | None, ...]:
|
) -> tuple[dict[str, object] | None, ...]:
|
||||||
"""Project only new source increments onto a denser camera timeline.
|
"""Project only new source increments onto a denser camera timeline.
|
||||||
|
|
||||||
@@ -545,7 +560,10 @@ def canonical_lab_spatial_timeline_samples(
|
|||||||
start_sequence < 0
|
start_sequence < 0
|
||||||
or frame_count < 1
|
or frame_count < 1
|
||||||
or start_sequence >= len(frame_times_ns)
|
or start_sequence >= len(frame_times_ns)
|
||||||
or any(current <= previous for previous, current in zip(frame_times_ns, frame_times_ns[1:]))
|
or any(
|
||||||
|
current <= previous
|
||||||
|
for previous, current in zip(frame_times_ns, frame_times_ns[1:], strict=False)
|
||||||
|
)
|
||||||
):
|
):
|
||||||
raise ValueError("Recorded LAB timeline sample request is invalid")
|
raise ValueError("Recorded LAB timeline sample request is invalid")
|
||||||
stat = recording_path.stat()
|
stat = recording_path.stat()
|
||||||
@@ -566,8 +584,75 @@ def canonical_lab_spatial_timeline_samples(
|
|||||||
else _latest_index(index.points.times_ns, frame_times_ns[sequence - 1])
|
else _latest_index(index.points.times_ns, frame_times_ns[sequence - 1])
|
||||||
)
|
)
|
||||||
samples.append(
|
samples.append(
|
||||||
_canonical_lab_spatial_frame_from_index(index, target_time_ns)
|
_canonical_lab_spatial_frame_from_index(
|
||||||
|
index,
|
||||||
|
target_time_ns,
|
||||||
|
include_local_slam=include_local_slam,
|
||||||
|
)
|
||||||
if point_index != previous_point_index
|
if point_index != previous_point_index
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
return tuple(samples)
|
return tuple(samples)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=2)
|
||||||
|
def _canonical_lab_spatial_playback_points_cached(
|
||||||
|
recording_path_text: str,
|
||||||
|
recording_size: int,
|
||||||
|
recording_mtime_ns: int,
|
||||||
|
generation_sha256: str,
|
||||||
|
frame_times_ns: tuple[int, ...],
|
||||||
|
) -> tuple[np.ndarray, tuple[int, ...]]:
|
||||||
|
del recording_size, recording_mtime_ns
|
||||||
|
recording_path = Path(recording_path_text)
|
||||||
|
stat = recording_path.stat()
|
||||||
|
index = _load_index(
|
||||||
|
str(recording_path),
|
||||||
|
stat.st_size,
|
||||||
|
stat.st_mtime_ns,
|
||||||
|
generation_sha256,
|
||||||
|
)
|
||||||
|
increments: list[np.ndarray] = []
|
||||||
|
offsets = [0]
|
||||||
|
point_count = 0
|
||||||
|
previous_point_index = -1
|
||||||
|
for target_time_ns in frame_times_ns:
|
||||||
|
point_index = _latest_index(index.points.times_ns, target_time_ns)
|
||||||
|
if point_index != previous_point_index:
|
||||||
|
increment = np.ascontiguousarray(index.points.values[point_index], dtype="<f4")
|
||||||
|
increments.append(increment)
|
||||||
|
point_count += int(increment.shape[0])
|
||||||
|
offsets.append(point_count)
|
||||||
|
previous_point_index = point_index
|
||||||
|
points = (
|
||||||
|
np.ascontiguousarray(np.concatenate(increments, axis=0), dtype="<f4")
|
||||||
|
if increments
|
||||||
|
else np.empty((0, 3), dtype="<f4")
|
||||||
|
)
|
||||||
|
points.setflags(write=False)
|
||||||
|
return points, tuple(offsets)
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_lab_spatial_playback_points(
|
||||||
|
recording_path: Path,
|
||||||
|
generation_sha256: str,
|
||||||
|
frame_times_ns: tuple[int, ...],
|
||||||
|
) -> tuple[np.ndarray, tuple[int, ...]]:
|
||||||
|
"""Return one retained map-coordinate point track for a camera timeline."""
|
||||||
|
|
||||||
|
if (
|
||||||
|
not frame_times_ns
|
||||||
|
or any(
|
||||||
|
current <= previous
|
||||||
|
for previous, current in zip(frame_times_ns, frame_times_ns[1:], strict=False)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError("Recorded LAB playback timeline is invalid")
|
||||||
|
stat = recording_path.stat()
|
||||||
|
return _canonical_lab_spatial_playback_points_cached(
|
||||||
|
str(recording_path),
|
||||||
|
stat.st_size,
|
||||||
|
stat.st_mtime_ns,
|
||||||
|
generation_sha256,
|
||||||
|
frame_times_ns,
|
||||||
|
)
|
||||||
|
|||||||
@@ -26,13 +26,16 @@ from k1link.laboratory.evidence_report import (
|
|||||||
)
|
)
|
||||||
from k1link.laboratory.vegetation_shadow_lab import LAB_SCHEMA
|
from k1link.laboratory.vegetation_shadow_lab import LAB_SCHEMA
|
||||||
from k1link.sessions import RecordedCameraFrame, SessionIntegrityError
|
from k1link.sessions import RecordedCameraFrame, SessionIntegrityError
|
||||||
from k1link.sessions.canonical_lab_spatial import canonical_lab_spatial_timeline_samples
|
from k1link.sessions.canonical_lab_spatial import (
|
||||||
|
canonical_lab_spatial_playback_points,
|
||||||
|
canonical_lab_spatial_timeline_samples,
|
||||||
|
)
|
||||||
|
|
||||||
RootProvider = Callable[[], Path | None]
|
RootProvider = Callable[[], Path | None]
|
||||||
CanonicalRecordingProvider = Callable[[str], tuple[Path, str] | None]
|
CanonicalRecordingProvider = Callable[[str], tuple[Path, str] | None]
|
||||||
CameraFrameProvider = Callable[[str, int], RecordedCameraFrame]
|
CameraFrameProvider = Callable[[str, int], RecordedCameraFrame]
|
||||||
_MAX_DOCUMENT_BYTES: Final = 1024 * 1024
|
_MAX_DOCUMENT_BYTES: Final = 1024 * 1024
|
||||||
_CANONICAL_ROUTE_CHUNK_FRAMES: Final = 8
|
_CANONICAL_ROUTE_CHUNK_FRAMES: Final = 24
|
||||||
_DEFINITION: Final = LaboratoryEvidenceDefinition(
|
_DEFINITION: Final = LaboratoryEvidenceDefinition(
|
||||||
work_id="lab-v1-vegetation-shadow",
|
work_id="lab-v1-vegetation-shadow",
|
||||||
runtime_relative_root=PurePosixPath("lab-v1-vegetation/results"),
|
runtime_relative_root=PurePosixPath("lab-v1-vegetation/results"),
|
||||||
@@ -167,19 +170,7 @@ def _build_vegetation_lab_router(
|
|||||||
archive_path = candidate.joinpath(*relative.parts)
|
archive_path = candidate.joinpath(*relative.parts)
|
||||||
member = f"masks/frame-{sequence + 1:06d}.png"
|
member = f"masks/frame-{sequence + 1:06d}.png"
|
||||||
try:
|
try:
|
||||||
before = archive_path.stat()
|
payload = _read_cached_mask_member(archive_path, member)
|
||||||
with zipfile.ZipFile(archive_path) as frozen:
|
|
||||||
info = frozen.getinfo(member)
|
|
||||||
if info.is_dir() or info.file_size < 8 or info.file_size > 1024 * 1024:
|
|
||||||
raise ValueError("Vegetation video mask member is invalid")
|
|
||||||
payload = frozen.read(info)
|
|
||||||
after = archive_path.stat()
|
|
||||||
if (
|
|
||||||
before.st_size != after.st_size
|
|
||||||
or before.st_mtime_ns != after.st_mtime_ns
|
|
||||||
or len(payload) != info.file_size
|
|
||||||
):
|
|
||||||
raise ValueError("Vegetation video mask archive changed during read")
|
|
||||||
except (KeyError, OSError, ValueError, zipfile.BadZipFile):
|
except (KeyError, OSError, ValueError, zipfile.BadZipFile):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
@@ -286,7 +277,7 @@ def _build_vegetation_lab_router(
|
|||||||
route, frame_times_ns = _full_route_context(candidate, manifest)
|
route, frame_times_ns = _full_route_context(candidate, manifest)
|
||||||
intervals = [
|
intervals = [
|
||||||
(current - previous) / 1_000_000_000
|
(current - previous) / 1_000_000_000
|
||||||
for previous, current in zip(frame_times_ns, frame_times_ns[1:])
|
for previous, current in zip(frame_times_ns, frame_times_ns[1:], strict=False)
|
||||||
]
|
]
|
||||||
nominal_interval = statistics.median(intervals)
|
nominal_interval = statistics.median(intervals)
|
||||||
if not math.isfinite(nominal_interval) or nominal_interval <= 0:
|
if not math.isfinite(nominal_interval) or nominal_interval <= 0:
|
||||||
@@ -350,7 +341,10 @@ def _build_vegetation_lab_router(
|
|||||||
if start >= len(frame_times_ns):
|
if start >= len(frame_times_ns):
|
||||||
raise HTTPException(status_code=404, detail="Full-route timeline chunk not found")
|
raise HTTPException(status_code=404, detail="Full-route timeline chunk not found")
|
||||||
if canonical_recording_provider is None:
|
if canonical_recording_provider is None:
|
||||||
raise HTTPException(status_code=503, detail="Canonical spatial recording is unavailable")
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Canonical spatial recording is unavailable",
|
||||||
|
)
|
||||||
recording = canonical_recording_provider(str(route["session_id"]))
|
recording = canonical_recording_provider(str(route["session_id"]))
|
||||||
if recording is None:
|
if recording is None:
|
||||||
raise HTTPException(status_code=409, detail="Canonical spatial recording is not ready")
|
raise HTTPException(status_code=409, detail="Canonical spatial recording is not ready")
|
||||||
@@ -362,6 +356,7 @@ def _build_vegetation_lab_router(
|
|||||||
frame_times_ns,
|
frame_times_ns,
|
||||||
start,
|
start,
|
||||||
count,
|
count,
|
||||||
|
include_local_slam=False,
|
||||||
)
|
)
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
raise HTTPException(status_code=503, detail="Canonical spatial chunk failed") from None
|
raise HTTPException(status_code=503, detail="Canonical spatial chunk failed") from None
|
||||||
@@ -391,6 +386,98 @@ def _build_vegetation_lab_router(
|
|||||||
"access": "read-only-bounded-recorded-replay",
|
"access": "read-only-bounded-recorded-replay",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@router.get("/{result_id}/timeline/playback")
|
||||||
|
def get_canonical_route_timeline_playback(result_id: str) -> dict[str, object]:
|
||||||
|
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||||
|
manifest = _read_verified(candidate, definition)
|
||||||
|
route, frame_times_ns = _full_route_context(candidate, manifest)
|
||||||
|
points, offsets = _canonical_route_playback(
|
||||||
|
canonical_recording_provider,
|
||||||
|
route,
|
||||||
|
frame_times_ns,
|
||||||
|
)
|
||||||
|
points_view = memoryview(points).cast("B")
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.recorded-spatial-playback/v1",
|
||||||
|
"result_id": result_id,
|
||||||
|
"frame_count": len(frame_times_ns),
|
||||||
|
"point_count": int(points.shape[0]),
|
||||||
|
"point_offsets": list(offsets),
|
||||||
|
"chunk_frame_count": _CANONICAL_ROUTE_CHUNK_FRAMES,
|
||||||
|
"resident_chunk_count_max": 4,
|
||||||
|
"forward_prefetch_chunk_count": 1,
|
||||||
|
"chunks": _canonical_route_playback_chunk_catalog(
|
||||||
|
prefix,
|
||||||
|
result_id,
|
||||||
|
points_view,
|
||||||
|
offsets,
|
||||||
|
),
|
||||||
|
"track": {
|
||||||
|
"id": "points-map-f32",
|
||||||
|
"url": f"{prefix}/{result_id}/timeline/playback/tracks/points-map-f32",
|
||||||
|
"media_type": "application/octet-stream",
|
||||||
|
"dtype": "<f4",
|
||||||
|
"shape": [int(points.shape[0]), 3],
|
||||||
|
"bytes": int(points.nbytes),
|
||||||
|
"sha256": hashlib.sha256(points_view).hexdigest(),
|
||||||
|
},
|
||||||
|
"coordinate_frame": "map",
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": "replay-simulated",
|
||||||
|
"access": "read-only-sealed-binary-playback",
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/{result_id}/timeline/playback/chunks/{chunk_index}")
|
||||||
|
def get_canonical_route_timeline_playback_chunk(
|
||||||
|
result_id: str,
|
||||||
|
chunk_index: int,
|
||||||
|
) -> Response:
|
||||||
|
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||||
|
manifest = _read_verified(candidate, definition)
|
||||||
|
route, frame_times_ns = _full_route_context(candidate, manifest)
|
||||||
|
points, offsets = _canonical_route_playback(
|
||||||
|
canonical_recording_provider,
|
||||||
|
route,
|
||||||
|
frame_times_ns,
|
||||||
|
)
|
||||||
|
points_view = memoryview(points).cast("B")
|
||||||
|
descriptor = _canonical_route_playback_chunk_descriptor(
|
||||||
|
prefix,
|
||||||
|
result_id,
|
||||||
|
points_view,
|
||||||
|
offsets,
|
||||||
|
chunk_index,
|
||||||
|
)
|
||||||
|
if descriptor is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Full-route playback chunk not found")
|
||||||
|
point_start = int(descriptor["point_start"])
|
||||||
|
byte_length = int(descriptor["bytes"])
|
||||||
|
byte_start = point_start * 3 * 4
|
||||||
|
payload = bytes(points_view[byte_start : byte_start + byte_length])
|
||||||
|
return Response(
|
||||||
|
content=payload,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers=_immutable_binary_headers(byte_length, str(descriptor["sha256"])),
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/{result_id}/timeline/playback/tracks/points-map-f32")
|
||||||
|
def get_canonical_route_timeline_playback_track(result_id: str) -> Response:
|
||||||
|
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||||
|
manifest = _read_verified(candidate, definition)
|
||||||
|
route, frame_times_ns = _full_route_context(candidate, manifest)
|
||||||
|
points, _ = _canonical_route_playback(
|
||||||
|
canonical_recording_provider,
|
||||||
|
route,
|
||||||
|
frame_times_ns,
|
||||||
|
)
|
||||||
|
payload = memoryview(points).cast("B")
|
||||||
|
digest = hashlib.sha256(payload).hexdigest()
|
||||||
|
return Response(
|
||||||
|
content=bytes(payload),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers=_immutable_binary_headers(payload.nbytes, digest),
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/{result_id}/timeline/frames/{sequence}/camera")
|
@router.get("/{result_id}/timeline/frames/{sequence}/camera")
|
||||||
def get_canonical_route_camera(result_id: str, sequence: int) -> Response:
|
def get_canonical_route_camera(result_id: str, sequence: int) -> Response:
|
||||||
if camera_frame_provider is None:
|
if camera_frame_provider is None:
|
||||||
@@ -403,7 +490,10 @@ def _build_vegetation_lab_router(
|
|||||||
try:
|
try:
|
||||||
camera = camera_frame_provider(str(route["session_id"]), sequence)
|
camera = camera_frame_provider(str(route["session_id"]), sequence)
|
||||||
except (OSError, SessionIntegrityError, ValueError):
|
except (OSError, SessionIntegrityError, ValueError):
|
||||||
raise HTTPException(status_code=503, detail="Full-route camera frame unavailable") from None
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Full-route camera frame unavailable",
|
||||||
|
) from None
|
||||||
if camera.width != route["width"] or camera.height != route["height"]:
|
if camera.width != route["width"] or camera.height != route["height"]:
|
||||||
raise HTTPException(status_code=503, detail="Full-route camera dimensions changed")
|
raise HTTPException(status_code=503, detail="Full-route camera dimensions changed")
|
||||||
return Response(
|
return Response(
|
||||||
@@ -495,15 +585,111 @@ def _full_route_context(
|
|||||||
values = np.frombuffer(payload, dtype="<u8")
|
values = np.frombuffer(payload, dtype="<u8")
|
||||||
frame_times_ns = tuple(int(value) for value in values)
|
frame_times_ns = tuple(int(value) for value in values)
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
raise HTTPException(status_code=503, detail="Full-route timeline verification failed") from None
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Full-route timeline verification failed",
|
||||||
|
) from None
|
||||||
if (
|
if (
|
||||||
len(frame_times_ns) != route["frame_count"]
|
len(frame_times_ns) != route["frame_count"]
|
||||||
or any(current <= previous for previous, current in zip(frame_times_ns, frame_times_ns[1:]))
|
or any(
|
||||||
|
current <= previous
|
||||||
|
for previous, current in zip(frame_times_ns, frame_times_ns[1:], strict=False)
|
||||||
|
)
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=503, detail="Full-route timeline order changed")
|
raise HTTPException(status_code=503, detail="Full-route timeline order changed")
|
||||||
return route, frame_times_ns
|
return route, frame_times_ns
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_route_playback(
|
||||||
|
provider: CanonicalRecordingProvider | None,
|
||||||
|
route: dict[str, Any],
|
||||||
|
frame_times_ns: tuple[int, ...],
|
||||||
|
) -> tuple[np.ndarray, tuple[int, ...]]:
|
||||||
|
if provider is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Canonical spatial recording is unavailable")
|
||||||
|
recording = provider(str(route["session_id"]))
|
||||||
|
if recording is None:
|
||||||
|
raise HTTPException(status_code=409, detail="Canonical spatial recording is not ready")
|
||||||
|
recording_path, generation_sha256 = recording
|
||||||
|
try:
|
||||||
|
return canonical_lab_spatial_playback_points(
|
||||||
|
recording_path,
|
||||||
|
generation_sha256,
|
||||||
|
frame_times_ns,
|
||||||
|
)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
raise HTTPException(status_code=503, detail="Canonical spatial playback failed") from None
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_route_playback_chunk_descriptor(
|
||||||
|
endpoint_prefix: str,
|
||||||
|
result_id: str,
|
||||||
|
points_view: memoryview,
|
||||||
|
offsets: tuple[int, ...],
|
||||||
|
chunk_index: int,
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
frame_count = len(offsets) - 1
|
||||||
|
start = chunk_index * _CANONICAL_ROUTE_CHUNK_FRAMES
|
||||||
|
if chunk_index < 0 or start >= frame_count:
|
||||||
|
return None
|
||||||
|
count = min(_CANONICAL_ROUTE_CHUNK_FRAMES, frame_count - start)
|
||||||
|
point_start = offsets[start]
|
||||||
|
point_stop = offsets[start + count]
|
||||||
|
byte_start = point_start * 3 * 4
|
||||||
|
byte_stop = point_stop * 3 * 4
|
||||||
|
payload = points_view[byte_start:byte_stop]
|
||||||
|
return {
|
||||||
|
"index": chunk_index,
|
||||||
|
"start": start,
|
||||||
|
"count": count,
|
||||||
|
"point_start": point_start,
|
||||||
|
"point_count": point_stop - point_start,
|
||||||
|
"url": f"{endpoint_prefix}/{result_id}/timeline/playback/chunks/{chunk_index}",
|
||||||
|
"media_type": "application/octet-stream",
|
||||||
|
"dtype": "<f4",
|
||||||
|
"shape": [point_stop - point_start, 3],
|
||||||
|
"bytes": payload.nbytes,
|
||||||
|
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_route_playback_chunk_catalog(
|
||||||
|
endpoint_prefix: str,
|
||||||
|
result_id: str,
|
||||||
|
points_view: memoryview,
|
||||||
|
offsets: tuple[int, ...],
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
frame_count = len(offsets) - 1
|
||||||
|
chunk_count = (
|
||||||
|
frame_count + _CANONICAL_ROUTE_CHUNK_FRAMES - 1
|
||||||
|
) // _CANONICAL_ROUTE_CHUNK_FRAMES
|
||||||
|
return [
|
||||||
|
descriptor
|
||||||
|
for chunk_index in range(chunk_count)
|
||||||
|
if (
|
||||||
|
descriptor := _canonical_route_playback_chunk_descriptor(
|
||||||
|
endpoint_prefix,
|
||||||
|
result_id,
|
||||||
|
points_view,
|
||||||
|
offsets,
|
||||||
|
chunk_index,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _immutable_binary_headers(byte_length: int, sha256: str) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable",
|
||||||
|
"Content-Encoding": "identity",
|
||||||
|
"Content-Length": str(byte_length),
|
||||||
|
"ETag": f'"{sha256}"',
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Uncompressed-Content-Length": str(byte_length),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _canonical_timeline_frame(
|
def _canonical_timeline_frame(
|
||||||
*,
|
*,
|
||||||
result_id: str,
|
result_id: str,
|
||||||
@@ -518,7 +704,6 @@ def _canonical_timeline_frame(
|
|||||||
points = [] if spatial is None or not include_points else spatial["source_points_body_xyz_m"]
|
points = [] if spatial is None or not include_points else spatial["source_points_body_xyz_m"]
|
||||||
point_count = 0 if spatial is None else int(spatial["source_point_count"])
|
point_count = 0 if spatial is None else int(spatial["source_point_count"])
|
||||||
body_frame = None if spatial is None else spatial["body_frame"]
|
body_frame = None if spatial is None else spatial["body_frame"]
|
||||||
local_slam = [] if spatial is None else spatial["local_slam_body_xyz_m"]
|
|
||||||
return {
|
return {
|
||||||
"schema_version": "missioncore.recorded-spatial-evidence-frame/v1",
|
"schema_version": "missioncore.recorded-spatial-evidence-frame/v1",
|
||||||
"sequence": sequence,
|
"sequence": sequence,
|
||||||
@@ -534,11 +719,6 @@ def _canonical_timeline_frame(
|
|||||||
"point_cloud_source_count": point_count,
|
"point_cloud_source_count": point_count,
|
||||||
"point_cloud_sample_count": point_count if not include_points else len(points),
|
"point_cloud_sample_count": point_count if not include_points else len(points),
|
||||||
"point_cloud_layer": "current-increment",
|
"point_cloud_layer": "current-increment",
|
||||||
"local_slam_body_xyz_m": local_slam,
|
|
||||||
"local_slam_source_frame_count": 0
|
|
||||||
if spatial is None else spatial["local_slam_source_frame_count"],
|
|
||||||
"local_slam_source_point_count": 0
|
|
||||||
if spatial is None else spatial["local_slam_source_point_count"],
|
|
||||||
"rolling_map_component_count": 0,
|
"rolling_map_component_count": 0,
|
||||||
"metric_obstacles": [],
|
"metric_obstacles": [],
|
||||||
"camera_proposals": _semantic_component_proposals(candidate, route, sequence),
|
"camera_proposals": _semantic_component_proposals(candidate, route, sequence),
|
||||||
@@ -585,12 +765,14 @@ def _semantic_component_proposals_cached(
|
|||||||
archive_mtime_ns: int,
|
archive_mtime_ns: int,
|
||||||
sequence: int,
|
sequence: int,
|
||||||
) -> tuple[dict[str, object], ...]:
|
) -> tuple[dict[str, object], ...]:
|
||||||
del archive_size, archive_mtime_ns
|
|
||||||
archive_path = Path(archive_path_text)
|
|
||||||
member = f"masks/frame-{sequence + 1:06d}.png"
|
member = f"masks/frame-{sequence + 1:06d}.png"
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(archive_path) as frozen:
|
frozen = _cached_zip_archive(
|
||||||
payload = frozen.read(member)
|
archive_path_text,
|
||||||
|
archive_size,
|
||||||
|
archive_mtime_ns,
|
||||||
|
)
|
||||||
|
payload = frozen.read(member)
|
||||||
with Image.open(io.BytesIO(payload)) as image:
|
with Image.open(io.BytesIO(payload)) as image:
|
||||||
mask = np.asarray(image.convert("L"), dtype=np.uint8)
|
mask = np.asarray(image.convert("L"), dtype=np.uint8)
|
||||||
except (KeyError, OSError, ValueError, zipfile.BadZipFile):
|
except (KeyError, OSError, ValueError, zipfile.BadZipFile):
|
||||||
@@ -733,7 +915,10 @@ def _route_tgs_anchor_payload(path: Path, source_sequence: int) -> dict[str, obj
|
|||||||
selected_points = np.ascontiguousarray(points[start:end], dtype=np.float32)
|
selected_points = np.ascontiguousarray(points[start:end], dtype=np.float32)
|
||||||
selected_states = np.ascontiguousarray(states[slot], dtype=np.uint8)
|
selected_states = np.ascontiguousarray(states[slot], dtype=np.uint8)
|
||||||
selected_z_bounds = np.ascontiguousarray(z_bounds[slot], dtype=np.float32)
|
selected_z_bounds = np.ascontiguousarray(z_bounds[slot], dtype=np.float32)
|
||||||
if not np.isfinite(selected_points).all() or not np.isin(selected_states, [0, 1, 2, 3]).all():
|
if (
|
||||||
|
not np.isfinite(selected_points).all()
|
||||||
|
or not np.isin(selected_states, [0, 1, 2, 3]).all()
|
||||||
|
):
|
||||||
raise ValueError("Route TGS payload changed")
|
raise ValueError("Route TGS payload changed")
|
||||||
result = {
|
result = {
|
||||||
"schema_version": "missioncore.lab-v1-route-tgs-anchor/v1",
|
"schema_version": "missioncore.lab-v1-route-tgs-anchor/v1",
|
||||||
@@ -762,19 +947,7 @@ def _route_tgs_anchor_payload(path: Path, source_sequence: int) -> dict[str, obj
|
|||||||
def _zip_mask_response(archive_path: Path, sequence: int) -> Response:
|
def _zip_mask_response(archive_path: Path, sequence: int) -> Response:
|
||||||
member = f"masks/frame-{sequence + 1:06d}.png"
|
member = f"masks/frame-{sequence + 1:06d}.png"
|
||||||
try:
|
try:
|
||||||
before = archive_path.stat()
|
payload = _read_cached_mask_member(archive_path, member)
|
||||||
with zipfile.ZipFile(archive_path) as frozen:
|
|
||||||
info = frozen.getinfo(member)
|
|
||||||
if info.is_dir() or info.file_size < 8 or info.file_size > 1024 * 1024:
|
|
||||||
raise ValueError("Semantic mask member is invalid")
|
|
||||||
payload = frozen.read(info)
|
|
||||||
after = archive_path.stat()
|
|
||||||
if (
|
|
||||||
before.st_size != after.st_size
|
|
||||||
or before.st_mtime_ns != after.st_mtime_ns
|
|
||||||
or len(payload) != info.file_size
|
|
||||||
):
|
|
||||||
raise ValueError("Semantic mask archive changed during read")
|
|
||||||
except (KeyError, OSError, ValueError, zipfile.BadZipFile):
|
except (KeyError, OSError, ValueError, zipfile.BadZipFile):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
@@ -792,6 +965,37 @@ def _zip_mask_response(archive_path: Path, sequence: int) -> Response:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=8)
|
||||||
|
def _cached_zip_archive(
|
||||||
|
archive_path_text: str,
|
||||||
|
archive_size: int,
|
||||||
|
archive_mtime_ns: int,
|
||||||
|
) -> zipfile.ZipFile:
|
||||||
|
del archive_size, archive_mtime_ns
|
||||||
|
return zipfile.ZipFile(archive_path_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cached_mask_member(archive_path: Path, member: str) -> bytes:
|
||||||
|
before = archive_path.stat()
|
||||||
|
frozen = _cached_zip_archive(
|
||||||
|
str(archive_path),
|
||||||
|
before.st_size,
|
||||||
|
before.st_mtime_ns,
|
||||||
|
)
|
||||||
|
info = frozen.getinfo(member)
|
||||||
|
if info.is_dir() or info.file_size < 8 or info.file_size > 1024 * 1024:
|
||||||
|
raise ValueError("Semantic mask member is invalid")
|
||||||
|
payload = frozen.read(info)
|
||||||
|
after = archive_path.stat()
|
||||||
|
if (
|
||||||
|
before.st_size != after.st_size
|
||||||
|
or before.st_mtime_ns != after.st_mtime_ns
|
||||||
|
or len(payload) != info.file_size
|
||||||
|
):
|
||||||
|
raise ValueError("Semantic mask archive changed during read")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _configured_root(provider: RootProvider) -> Path | None:
|
def _configured_root(provider: RootProvider) -> Path | None:
|
||||||
candidate = provider()
|
candidate = provider()
|
||||||
if candidate is None:
|
if candidate is None:
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ from __future__ import annotations
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import k1link.sessions.canonical_lab_spatial as spatial_module
|
||||||
from k1link.sessions.canonical_lab_spatial import (
|
from k1link.sessions.canonical_lab_spatial import (
|
||||||
_TimedPoints,
|
|
||||||
_TimedPoses,
|
|
||||||
_bounded_local_slam,
|
_bounded_local_slam,
|
||||||
_estimate_sensor_height,
|
_CanonicalSpatialIndex,
|
||||||
_estimate_local_sensor_height,
|
_estimate_local_sensor_height,
|
||||||
|
_estimate_sensor_height,
|
||||||
_gravity_stable_basis_map_from_body,
|
_gravity_stable_basis_map_from_body,
|
||||||
_ground_origin_map,
|
_ground_origin_map,
|
||||||
|
_TimedPoints,
|
||||||
|
_TimedPoses,
|
||||||
|
canonical_lab_spatial_playback_points,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -127,3 +130,43 @@ def test_sensor_height_tracks_current_source_window_instead_of_fixed_mount() ->
|
|||||||
assert high == pytest.approx(1.05, abs=0.03)
|
assert high == pytest.approx(1.05, abs=0.03)
|
||||||
assert low_samples >= 3 and high_samples >= 3
|
assert low_samples >= 3 and high_samples >= 3
|
||||||
assert low_source == high_source == "local-source-cloud-ground-quantile-median"
|
assert low_source == high_source == "local-source-cloud-ground-quantile-median"
|
||||||
|
|
||||||
|
|
||||||
|
def test_playback_track_binds_sparse_map_increments_to_dense_camera_timeline(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
recording = tmp_path / "recording.rrd"
|
||||||
|
recording.write_bytes(b"sealed")
|
||||||
|
points = _TimedPoints(
|
||||||
|
times_ns=(10, 20),
|
||||||
|
values=(
|
||||||
|
np.asarray([[1.0, 2.0, 3.0]], dtype=np.float32),
|
||||||
|
np.asarray([[4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], dtype=np.float32),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
empty_poses = _TimedPoses(times_ns=(), translations=(), quaternions_xyzw=())
|
||||||
|
index = _CanonicalSpatialIndex(
|
||||||
|
points=points,
|
||||||
|
poses=empty_poses,
|
||||||
|
trajectories=_TimedPoints(times_ns=(), values=()),
|
||||||
|
sensor_height_m=0.4,
|
||||||
|
sensor_height_sample_count=0,
|
||||||
|
sensor_height_mad_m=0.0,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(spatial_module, "_load_index", lambda *_args: index)
|
||||||
|
|
||||||
|
track, offsets = canonical_lab_spatial_playback_points(
|
||||||
|
recording,
|
||||||
|
"a" * 64,
|
||||||
|
(10, 15, 20, 25),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert offsets == (0, 1, 1, 3, 3)
|
||||||
|
assert track.tolist() == [
|
||||||
|
[1.0, 2.0, 3.0],
|
||||||
|
[4.0, 5.0, 6.0],
|
||||||
|
[7.0, 8.0, 9.0],
|
||||||
|
]
|
||||||
|
assert track.dtype == np.dtype("<f4")
|
||||||
|
assert not track.flags.writeable
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from k1link.laboratory.evidence_report import verify_laboratory_evidence_result
|
|||||||
from k1link.laboratory.vegetation_policy_review import seal_vegetation_policy_review
|
from k1link.laboratory.vegetation_policy_review import seal_vegetation_policy_review
|
||||||
from k1link.laboratory.vegetation_shadow_lab import seal_vegetation_shadow_lab
|
from k1link.laboratory.vegetation_shadow_lab import seal_vegetation_shadow_lab
|
||||||
from k1link.web.vegetation_shadow_lab_api import (
|
from k1link.web.vegetation_shadow_lab_api import (
|
||||||
|
_canonical_route_playback_chunk_descriptor,
|
||||||
_mask_component_boxes,
|
_mask_component_boxes,
|
||||||
_route_tgs_anchor_payload,
|
_route_tgs_anchor_payload,
|
||||||
build_vegetation_shadow_lab_router,
|
build_vegetation_shadow_lab_router,
|
||||||
@@ -42,6 +43,25 @@ def test_semantic_component_boxes_keep_distinct_objects_separate() -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_playback_chunk_descriptor_seals_only_requested_binary_window() -> None:
|
||||||
|
points = np.arange(18, dtype="<f4").reshape(6, 3)
|
||||||
|
descriptor = _canonical_route_playback_chunk_descriptor(
|
||||||
|
"/api/v1/laboratory/vegetation-shadow",
|
||||||
|
f"lab-v1-vegetation-shadow-{'a' * 64}",
|
||||||
|
memoryview(points).cast("B"),
|
||||||
|
(0, 1, 1, 3, 6),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert descriptor is not None
|
||||||
|
assert descriptor["start"] == 0
|
||||||
|
assert descriptor["count"] == 4
|
||||||
|
assert descriptor["point_count"] == 6
|
||||||
|
assert descriptor["bytes"] == points.nbytes
|
||||||
|
assert descriptor["shape"] == [6, 3]
|
||||||
|
assert len(str(descriptor["sha256"])) == 64
|
||||||
|
|
||||||
|
|
||||||
def test_route_tgs_anchor_payload_preserves_metric_evidence(tmp_path: Path) -> None:
|
def test_route_tgs_anchor_payload_preserves_metric_evidence(tmp_path: Path) -> None:
|
||||||
path = tmp_path / "tgs-evidence.npz"
|
path = tmp_path / "tgs-evidence.npz"
|
||||||
point_counts = np.arange(1, 11, dtype=np.int64)
|
point_counts = np.arange(1, 11, dtype=np.int64)
|
||||||
|
|||||||
Reference in New Issue
Block a user