perf(lab): stream sealed spatial playback tracks

This commit is contained in:
DCCONSTRUCTIONS
2026-08-27 15:18:45 +03:00
parent b70dc4298a
commit bf76304e00
10 changed files with 911 additions and 50 deletions
@@ -84,6 +84,48 @@ export interface M49TgsFullShadowSpatialChunk {
frames: readonly M49TgsFullShadowSpatial[]; frames: readonly M49TgsFullShadowSpatial[];
} }
export type M49TgsFullShadowPlaybackTrackId =
| "frames"
| "centers"
| "states"
| "z-bounds";
export interface M49TgsFullShadowPlaybackProgress {
phase: "manifest" | "download" | "verify" | "ready";
trackId: M49TgsFullShadowPlaybackTrackId | null;
loadedBytes: number;
totalBytes: number;
}
interface M49TgsFullShadowPlaybackTrack {
id: M49TgsFullShadowPlaybackTrackId;
url: string;
mediaType: string;
dtype: "ndjson" | "<f4" | "|u1";
shape: readonly number[];
byteLength: number;
sha256: string;
}
export interface M49TgsFullShadowPlaybackFrame {
sourceSequence: number;
sourceFrameIndex: number;
sessionSeconds: number;
sampleAvailable: boolean;
metrics: M49TgsFullShadowSpatial["metrics"];
}
export interface M49TgsFullShadowPlaybackPack {
resultId: string;
frameCount: 4489;
cellCount: 2244;
totalByteLength: number;
centersXyM: readonly (readonly [number, number])[];
states: Uint8Array;
zBoundsM: Float32Array;
frames: readonly M49TgsFullShadowPlaybackFrame[];
}
export class M49TgsFullShadowContractError extends Error {} export class M49TgsFullShadowContractError extends Error {}
function objectValue(value: unknown, label: string): Record<string, unknown> { function objectValue(value: unknown, label: string): Record<string, unknown> {
@@ -335,3 +377,285 @@ export async function fetchM49TgsFullShadowSpatialChunk(
)), )),
}; };
} }
function playbackTrackId(value: unknown, label: string): M49TgsFullShadowPlaybackTrackId {
const parsed = text(value, label);
if (parsed !== "frames" && parsed !== "centers" && parsed !== "states" && parsed !== "z-bounds") {
throw new M49TgsFullShadowContractError(`${label}: неизвестная playback-дорожка.`);
}
return parsed;
}
function playbackDtype(value: unknown, label: string): M49TgsFullShadowPlaybackTrack["dtype"] {
const parsed = text(value, label);
if (parsed !== "ndjson" && parsed !== "<f4" && parsed !== "|u1") {
throw new M49TgsFullShadowContractError(`${label}: неизвестный dtype.`);
}
return parsed;
}
function playbackShape(value: unknown, label: string): readonly number[] {
if (!Array.isArray(value) || !value.length) {
throw new M49TgsFullShadowContractError(`${label}: ожидалась размерность.`);
}
return value.map((item, index) => integer(item, `${label}.${index}`));
}
async function fetchPlaybackManifest(
id: string,
fetcher: LaboratoryFetch,
signal: AbortSignal | undefined,
): Promise<{
frameCount: 4489;
cellCount: 2244;
totalByteLength: number;
tracks: ReadonlyMap<M49TgsFullShadowPlaybackTrackId, M49TgsFullShadowPlaybackTrack>;
}> {
const response = await fetcher(
`/api/v1/laboratory/m49/tgs-full-shadow/${encodeURIComponent(id)}/playback/manifest`,
{ method: "GET", headers: { Accept: "application/json" }, signal },
);
if (!response.ok) {
throw new M49TgsFullShadowContractError(`M49 playback manifest недоступен: HTTP ${response.status}.`);
}
const payload = objectValue(await response.json(), "M49 playback manifest");
exact(payload.schema_version, "missioncore.m49-tgs-full-shadow-playback/v1", "M49 playback schema");
exact(payload.result_id, id, "M49 playback result");
exact(payload.coordinate_frame, "map-gravity-local", "M49 playback frame");
exact(payload.access, "read-only", "M49 playback access");
const frameCount = exact(integer(payload.frame_count, "M49 playback frames"), 4489, "M49 playback frames");
const cellCount = exact(integer(payload.cell_count, "M49 playback cells"), 2244, "M49 playback cells");
const totalByteLength = integer(payload.total_byte_length, "M49 playback bytes");
if (!Array.isArray(payload.tracks) || payload.tracks.length !== 4) {
throw new M49TgsFullShadowContractError("M49 playback tracks: неверная размерность.");
}
const tracks = new Map<M49TgsFullShadowPlaybackTrackId, M49TgsFullShadowPlaybackTrack>();
for (const [index, value] of payload.tracks.entries()) {
const row = objectValue(value, `M49 playback track ${index}`);
const trackId = playbackTrackId(row.id, `M49 playback track ${index}.id`);
const sha256 = text(row.sha256, `M49 playback track ${trackId}.sha256`);
if (!/^[a-f0-9]{64}$/.test(sha256) || tracks.has(trackId)) {
throw new M49TgsFullShadowContractError(`M49 playback track ${trackId}: нарушена идентичность.`);
}
tracks.set(trackId, {
id: trackId,
url: text(row.url, `M49 playback track ${trackId}.url`),
mediaType: text(row.media_type, `M49 playback track ${trackId}.media_type`),
dtype: playbackDtype(row.dtype, `M49 playback track ${trackId}.dtype`),
shape: playbackShape(row.shape, `M49 playback track ${trackId}.shape`),
byteLength: integer(row.byte_length, `M49 playback track ${trackId}.byte_length`),
sha256,
});
}
if ([...tracks.values()].reduce((sum, track) => sum + track.byteLength, 0) !== totalByteLength) {
throw new M49TgsFullShadowContractError("M49 playback bytes: нарушен accounting.");
}
return { frameCount, cellCount, totalByteLength, tracks };
}
function sha256Hex(buffer: ArrayBuffer): Promise<string> {
if (!globalThis.crypto?.subtle) {
throw new M49TgsFullShadowContractError("Браузер не поддерживает проверку playback SHA-256.");
}
return globalThis.crypto.subtle.digest("SHA-256", buffer).then((digest) => (
[...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("")
));
}
async function fetchPlaybackTrack(
track: M49TgsFullShadowPlaybackTrack,
fetcher: LaboratoryFetch,
signal: AbortSignal | undefined,
completedBytes: number,
totalBytes: number,
onProgress: ((progress: M49TgsFullShadowPlaybackProgress) => void) | undefined,
): Promise<ArrayBuffer> {
const response = await fetcher(track.url, {
method: "GET",
headers: { Accept: track.mediaType },
signal,
});
if (!response.ok) {
throw new M49TgsFullShadowContractError(`M49 playback ${track.id} недоступен: HTTP ${response.status}.`);
}
const bytes = new Uint8Array(track.byteLength);
if (response.body) {
const reader = response.body.getReader();
let offset = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (offset + value.byteLength > bytes.byteLength) {
throw new M49TgsFullShadowContractError(`M49 playback ${track.id}: превышен объявленный размер.`);
}
bytes.set(value, offset);
offset += value.byteLength;
onProgress?.({
phase: "download",
trackId: track.id,
loadedBytes: completedBytes + offset,
totalBytes,
});
}
if (offset !== bytes.byteLength) {
throw new M49TgsFullShadowContractError(`M49 playback ${track.id}: получен неполный файл.`);
}
} else {
const fallback = new Uint8Array(await response.arrayBuffer());
if (fallback.byteLength !== bytes.byteLength) {
throw new M49TgsFullShadowContractError(`M49 playback ${track.id}: получен неверный размер.`);
}
bytes.set(fallback);
}
onProgress?.({
phase: "verify",
trackId: track.id,
loadedBytes: completedBytes + bytes.byteLength,
totalBytes,
});
if (await sha256Hex(bytes.buffer) !== track.sha256) {
throw new M49TgsFullShadowContractError(`M49 playback ${track.id}: SHA-256 не совпал.`);
}
return bytes.buffer;
}
function equalShape(actual: readonly number[], expected: readonly number[]): boolean {
return actual.length === expected.length && actual.every((value, index) => value === expected[index]);
}
export function parseM49TgsNpyTrack(
buffer: ArrayBuffer,
expectedDtype: "<f4" | "|u1",
expectedShape: readonly number[],
): Float32Array | Uint8Array {
const bytes = new Uint8Array(buffer);
if (bytes.byteLength < 12
|| bytes[0] !== 0x93
|| String.fromCharCode(...bytes.subarray(1, 6)) !== "NUMPY") {
throw new M49TgsFullShadowContractError("M49 playback NPY: неверная сигнатура.");
}
const major = bytes[6];
const headerLength = major === 1
? new DataView(buffer).getUint16(8, true)
: major === 2 || major === 3
? new DataView(buffer).getUint32(8, true)
: -1;
const headerOffset = major === 1 ? 10 : 12;
if (headerLength < 0 || headerOffset + headerLength > bytes.byteLength) {
throw new M49TgsFullShadowContractError("M49 playback NPY: неверный заголовок.");
}
const header = new TextDecoder("latin1").decode(bytes.subarray(headerOffset, headerOffset + headerLength));
const dtype = header.match(/["']descr["']\s*:\s*["']([^"']+)["']/)?.[1];
const fortran = header.match(/["']fortran_order["']\s*:\s*(True|False)/)?.[1];
const rawShape = header.match(/["']shape["']\s*:\s*\(([^)]*)\)/)?.[1];
const shape = rawShape?.split(",").map((value) => value.trim()).filter(Boolean).map(Number) ?? [];
if (dtype !== expectedDtype || fortran !== "False" || !equalShape(shape, expectedShape)) {
throw new M49TgsFullShadowContractError("M49 playback NPY: dtype или shape изменились.");
}
const count = expectedShape.reduce((product, value) => product * value, 1);
const dataOffset = headerOffset + headerLength;
const bytesPerValue = expectedDtype === "<f4" ? 4 : 1;
if (dataOffset + count * bytesPerValue !== buffer.byteLength) {
throw new M49TgsFullShadowContractError("M49 playback NPY: длина payload изменилась.");
}
return expectedDtype === "<f4"
? new Float32Array(buffer, dataOffset, count)
: new Uint8Array(buffer, dataOffset, count);
}
function parsePlaybackFrames(buffer: ArrayBuffer): readonly M49TgsFullShadowPlaybackFrame[] {
const lines = new TextDecoder().decode(buffer).trim().split("\n");
if (lines.length !== 4489) {
throw new M49TgsFullShadowContractError("M49 playback frames: неверная размерность.");
}
return lines.map((line, sourceSequence) => {
const row = objectValue(JSON.parse(line), `M49 playback frame ${sourceSequence}`);
return {
sourceSequence,
sourceFrameIndex: integer(row.source_frame_index, `M49 playback frame ${sourceSequence}.source`),
sessionSeconds: numberValue(row.session_seconds, `M49 playback frame ${sourceSequence}.time`),
sampleAvailable: booleanValue(row.sample_available, `M49 playback frame ${sourceSequence}.available`),
metrics: {
eligiblePointCount: integer(row.eligible_point_count, `M49 playback frame ${sourceSequence}.eligible`),
groundPointCount: integer(row.ground_point_count, `M49 playback frame ${sourceSequence}.ground`),
nongroundPointCount: integer(row.nonground_point_count, `M49 playback frame ${sourceSequence}.nonground`),
rejectedPointCount: integer(row.rejected_point_count, `M49 playback frame ${sourceSequence}.rejected`),
occupiedCellCount: integer(row.occupied_cell_count, `M49 playback frame ${sourceSequence}.occupied`),
},
};
});
}
export async function fetchM49TgsFullShadowPlaybackPack(
id: string,
{
fetcher = fetch,
signal,
onProgress,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
onProgress?: (progress: M49TgsFullShadowPlaybackProgress) => void;
} = {},
): Promise<M49TgsFullShadowPlaybackPack> {
if (!RESULT_ID.test(id)) {
throw new M49TgsFullShadowContractError("M49 playback identity недопустима.");
}
onProgress?.({ phase: "manifest", trackId: null, loadedBytes: 0, totalBytes: 0 });
const manifest = await fetchPlaybackManifest(id, fetcher, signal);
const buffers = new Map<M49TgsFullShadowPlaybackTrackId, ArrayBuffer>();
let completedBytes = 0;
const order: readonly M49TgsFullShadowPlaybackTrackId[] = ["frames", "centers", "states", "z-bounds"];
for (const trackId of order) {
const track = manifest.tracks.get(trackId);
if (!track) throw new M49TgsFullShadowContractError(`M49 playback ${trackId}: дорожка отсутствует.`);
const buffer = await fetchPlaybackTrack(
track,
fetcher,
signal,
completedBytes,
manifest.totalByteLength,
onProgress,
);
buffers.set(trackId, buffer);
completedBytes += track.byteLength;
}
const centersTrack = manifest.tracks.get("centers")!;
const statesTrack = manifest.tracks.get("states")!;
const zBoundsTrack = manifest.tracks.get("z-bounds")!;
const centersRaw = parseM49TgsNpyTrack(
buffers.get("centers")!,
"<f4",
centersTrack.shape,
) as Float32Array;
const centersXyM = Array.from({ length: manifest.cellCount }, (_, index) => (
[centersRaw[index * 2]!, centersRaw[index * 2 + 1]!] as const
));
const states = parseM49TgsNpyTrack(
buffers.get("states")!,
"|u1",
statesTrack.shape,
) as Uint8Array;
const zBoundsM = parseM49TgsNpyTrack(
buffers.get("z-bounds")!,
"<f4",
zBoundsTrack.shape,
) as Float32Array;
const frames = parsePlaybackFrames(buffers.get("frames")!);
onProgress?.({
phase: "ready",
trackId: null,
loadedBytes: manifest.totalByteLength,
totalBytes: manifest.totalByteLength,
});
return {
resultId: id,
frameCount: manifest.frameCount,
cellCount: manifest.cellCount,
totalByteLength: manifest.totalByteLength,
centersXyM,
states,
zBoundsM,
frames,
};
}
@@ -226,6 +226,20 @@ export interface M4ThreatTimelineChunk {
frames: readonly M4ThreatTimelineFrame[]; frames: readonly M4ThreatTimelineFrame[];
} }
export interface M4ThreatPlaybackProgress {
phase: "manifest" | "download" | "verify" | "ready";
loadedBytes: number;
totalBytes: number;
}
export interface M4ThreatPlaybackPointPack {
resultId: string;
frameCount: 4489;
pointCount: number;
pointOffsets: Uint32Array;
pointsMapXyzM: Float32Array;
}
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>; type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
export const M4_THREAT_TIMELINE_ENDPOINT_ROOT = "/api/v1/laboratory/m4-threat/results"; export const M4_THREAT_TIMELINE_ENDPOINT_ROOT = "/api/v1/laboratory/m4-threat/results";
class M4ThreatContractError extends Error {} class M4ThreatContractError extends Error {}
@@ -803,11 +817,13 @@ export async function fetchM4ThreatTimelineChunk(
signal, signal,
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT, endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
cameraObstacleProjectionDelivery = null, cameraObstacleProjectionDelivery = null,
playbackPointPack,
}: { }: {
fetcher?: LaboratoryFetch; fetcher?: LaboratoryFetch;
signal?: AbortSignal; signal?: AbortSignal;
endpointRoot?: string; endpointRoot?: string;
cameraObstacleProjectionDelivery?: M4ThreatTimeline["cameraObstacleProjectionDelivery"]; cameraObstacleProjectionDelivery?: M4ThreatTimeline["cameraObstacleProjectionDelivery"];
playbackPointPack?: M4ThreatPlaybackPointPack;
} = {}, } = {},
): Promise<M4ThreatTimelineChunk> { ): Promise<M4ThreatTimelineChunk> {
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -817,6 +833,7 @@ export async function fetchM4ThreatTimelineChunk(
if (cameraObstacleProjectionDelivery !== null) { if (cameraObstacleProjectionDelivery !== null) {
params.set("obstacle_projection", cameraObstacleProjectionDelivery); params.set("obstacle_projection", cameraObstacleProjectionDelivery);
} }
if (playbackPointPack) params.set("include_points", "false");
const response = await fetcher( const response = await fetcher(
`${endpointRoot}/${result}/timeline/chunk?${params}`, `${endpointRoot}/${result}/timeline/chunk?${params}`,
{ headers: { Accept: "application/json" }, signal }, { headers: { Accept: "application/json" }, signal },
@@ -834,8 +851,12 @@ export async function fetchM4ThreatTimelineChunk(
if (parsedStart !== startSequence) { if (parsedStart !== startSequence) {
throw new M4ThreatContractError("M4.6 timeline chunk start: нарушен контракт."); throw new M4ThreatContractError("M4.6 timeline chunk start: нарушен контракт.");
} }
const frames = array(payload.frames, "M4.6 timeline frames").map((raw, offset) => const frames = array(payload.frames, "M4.6 timeline frames").map((raw, offset) => {
parseTimelineFrame(raw, result, parsedStart + offset, endpointRoot)); const parsed = parseTimelineFrame(raw, result, parsedStart + offset, endpointRoot);
return playbackPointPack
? hydrateM4ThreatTimelineFrame(parsed, playbackPointPack)
: parsed;
});
const parsedCount = integer(payload.frame_count, "M4.6 timeline chunk count"); const parsedCount = integer(payload.frame_count, "M4.6 timeline chunk count");
if (parsedCount !== frames.length || parsedCount > frameCount) { if (parsedCount !== frames.length || parsedCount > frameCount) {
throw new M4ThreatContractError("M4.6 timeline chunk count: нарушен контракт."); throw new M4ThreatContractError("M4.6 timeline chunk count: нарушен контракт.");
@@ -851,6 +872,159 @@ export async function fetchM4ThreatTimelineChunk(
}; };
} }
function playbackSha256Hex(buffer: ArrayBuffer): Promise<string> {
if (!globalThis.crypto?.subtle) {
throw new M4ThreatContractError("Браузер не поддерживает проверку playback SHA-256.");
}
return globalThis.crypto.subtle.digest("SHA-256", buffer).then((digest) => (
[...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("")
));
}
function roundedBodyCoordinate(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000;
}
export function hydrateM4ThreatTimelineFrame(
frame: M4ThreatTimelineFrame,
pack: M4ThreatPlaybackPointPack,
): M4ThreatTimelineFrame {
if (frame.sequence >= pack.frameCount || pack.resultId === "") {
throw new M4ThreatContractError("M4.6 binary playback frame: нарушена идентичность.");
}
if (!frame.sourceAvailable || !frame.spatialAvailable || !frame.bodyFrame) {
if (frame.pointCloudSourceCount !== 0) {
throw new M4ThreatContractError("M4.6 binary playback unavailable frame содержит точки.");
}
return { ...frame, pointCloudBodyXyzM: [], pointCloudSampleCount: 0 };
}
const start = pack.pointOffsets[frame.sequence];
const stop = pack.pointOffsets[frame.sequence + 1];
if (start === undefined || stop === undefined || stop < start || stop > pack.pointCount) {
throw new M4ThreatContractError("M4.6 binary playback offsets: нарушена размерность.");
}
const count = stop - start;
if (frame.pointCloudSourceCount !== count || frame.pointCloudSampleCount !== count) {
throw new M4ThreatContractError("M4.6 binary playback point accounting: нарушен контракт.");
}
const origin = frame.bodyFrame.originMapXyzM;
const basis = frame.bodyFrame.basisMapFromBody;
const points = new Array<M4Point3>(count);
for (let index = 0; index < count; index += 1) {
const sourceOffset = (start + index) * 3;
const dx = pack.pointsMapXyzM[sourceOffset]! - origin[0];
const dy = pack.pointsMapXyzM[sourceOffset + 1]! - origin[1];
const dz = pack.pointsMapXyzM[sourceOffset + 2]! - origin[2];
points[index] = [
roundedBodyCoordinate(dx * basis[0][0] + dy * basis[1][0] + dz * basis[2][0]),
roundedBodyCoordinate(dx * basis[0][1] + dy * basis[1][1] + dz * basis[2][1]),
roundedBodyCoordinate(dx * basis[0][2] + dy * basis[1][2] + dz * basis[2][2]),
];
}
return { ...frame, pointCloudBodyXyzM: points, pointCloudSampleCount: count };
}
export async function fetchM4ThreatPlaybackPointPack(
result: string,
{
fetcher = fetch,
signal,
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
onProgress,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
endpointRoot?: string;
onProgress?: (progress: M4ThreatPlaybackProgress) => void;
} = {},
): Promise<M4ThreatPlaybackPointPack> {
resultId(result);
onProgress?.({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
const manifestResponse = await fetcher(`${endpointRoot}/${result}/timeline/playback`, {
headers: { Accept: "application/json" },
signal,
});
if (!manifestResponse.ok) {
throw new M4ThreatContractError(`M4.6 playback manifest: HTTP ${manifestResponse.status}.`);
}
const manifest = object(await manifestResponse.json(), "M4.6 playback manifest");
exact(manifest.schema_version, "missioncore.recorded-spatial-playback/v1", "M4.6 playback schema");
exact(manifest.result_id, result, "M4.6 playback result");
exact(manifest.coordinate_frame, "map", "M4.6 playback coordinate frame");
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 pointCount = integer(manifest.point_count, "M4.6 playback points");
const offsetsRaw = array(manifest.point_offsets, "M4.6 playback offsets");
if (offsetsRaw.length !== frameCount + 1) {
throw new M4ThreatContractError("M4.6 playback offsets: неверная размерность.");
}
const pointOffsets = Uint32Array.from(offsetsRaw, (value) => integer(value, "M4.6 playback offset"));
if (pointOffsets[0] !== 0 || pointOffsets[pointOffsets.length - 1] !== pointCount) {
throw new M4ThreatContractError("M4.6 playback offsets: нарушено замыкание.");
}
for (let index = 1; index < pointOffsets.length; index += 1) {
if (pointOffsets[index]! < pointOffsets[index - 1]!) {
throw new M4ThreatContractError("M4.6 playback offsets: нарушена монотонность.");
}
}
const track = object(manifest.track, "M4.6 playback track");
exact(track.id, "points-map-f32", "M4.6 playback track id");
exact(track.dtype, "<f4", "M4.6 playback dtype");
const shape = array(track.shape, "M4.6 playback shape").map((value) => integer(value, "M4.6 playback shape"));
if (shape.length !== 2 || shape[0] !== pointCount || shape[1] !== 3) {
throw new M4ThreatContractError("M4.6 playback shape: нарушена размерность.");
}
const byteLength = integer(track.bytes, "M4.6 playback bytes");
if (byteLength !== pointCount * 3 * Float32Array.BYTES_PER_ELEMENT) {
throw new M4ThreatContractError("M4.6 playback bytes: нарушен accounting.");
}
const sha256 = text(track.sha256, "M4.6 playback SHA-256");
if (!/^[a-f0-9]{64}$/.test(sha256)) {
throw new M4ThreatContractError("M4.6 playback SHA-256: нарушен контракт.");
}
const response = await fetcher(text(track.url, "M4.6 playback URL"), {
headers: { Accept: "application/octet-stream" },
signal,
});
if (!response.ok) throw new M4ThreatContractError(`M4.6 playback points: HTTP ${response.status}.`);
const bytes = new Uint8Array(byteLength);
if (response.body) {
const reader = response.body.getReader();
let offset = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (offset + value.byteLength > bytes.byteLength) {
throw new M4ThreatContractError("M4.6 playback points превысил объявленный размер.");
}
bytes.set(value, offset);
offset += value.byteLength;
onProgress?.({ phase: "download", loadedBytes: offset, totalBytes: byteLength });
}
if (offset !== byteLength) {
throw new M4ThreatContractError("M4.6 playback points получен не полностью.");
}
} else {
const fallback = new Uint8Array(await response.arrayBuffer());
if (fallback.byteLength !== byteLength) {
throw new M4ThreatContractError("M4.6 playback points: неверный размер.");
}
bytes.set(fallback);
}
onProgress?.({ phase: "verify", loadedBytes: byteLength, totalBytes: byteLength });
if (await playbackSha256Hex(bytes.buffer) !== sha256) {
throw new M4ThreatContractError("M4.6 playback points: SHA-256 не совпал.");
}
onProgress?.({ phase: "ready", loadedBytes: byteLength, totalBytes: byteLength });
return {
resultId: result,
frameCount,
pointCount,
pointOffsets,
pointsMapXyzM: new Float32Array(bytes.buffer),
};
}
export async function fetchM4ThreatCameraPointOverlay( export async function fetchM4ThreatCameraPointOverlay(
result: string, result: string,
sequence: number, sequence: number,
@@ -6,6 +6,7 @@ import { createServer } from "vite";
let server; let server;
let fetchM49TgsFullShadowSpatialChunk; let fetchM49TgsFullShadowSpatialChunk;
let parseM49TgsNpyTrack;
const resultId = `m49-tgs-full-shadow-${"a".repeat(64)}`; const resultId = `m49-tgs-full-shadow-${"a".repeat(64)}`;
@@ -15,7 +16,7 @@ before(async () => {
logLevel: "silent", logLevel: "silent",
server: { middlewareMode: true }, server: { middlewareMode: true },
}); });
({ fetchM49TgsFullShadowSpatialChunk } = await server.ssrLoadModule( ({ fetchM49TgsFullShadowSpatialChunk, parseM49TgsNpyTrack } = await server.ssrLoadModule(
"/src/core/laboratory/m49TgsFullShadow.ts", "/src/core/laboratory/m49TgsFullShadow.ts",
)); ));
}); });
@@ -73,7 +74,7 @@ test("M4.9T5 chunk keeps every missing-LiDAR cell explicitly UNOBSERVED", async
assert.deepEqual(new Set(chunk.frames[0].costmap.states), new Set([0])); assert.deepEqual(new Set(chunk.frames[0].costmap.states), new Set([0]));
}); });
test("M4.9T5 viewer prefetches 24-frame immutable chunks", async () => { test("M4.9T5 viewer preloads one immutable binary playback pack", async () => {
const [source, contract] = await Promise.all([ const [source, contract] = await Promise.all([
readFile( readFile(
new URL("../src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx", import.meta.url), new URL("../src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx", import.meta.url),
@@ -84,12 +85,37 @@ test("M4.9T5 viewer prefetches 24-frame immutable chunks", async () => {
"utf8", "utf8",
), ),
]); ]);
assert.match(source, /const CHUNK_FRAMES = 24/); assert.match(source, /fetchM49TgsFullShadowPlaybackPack/);
assert.match(source, /activeChunkStart \+ CHUNK_FRAMES/); assert.match(source, /playbackProgress/);
assert.match(source, /fetchM49TgsFullShadowSpatialChunk/); assert.doesNotMatch(source, /fetchM49TgsFullShadowSpatialChunk/);
assert.match(source, /sampleAvailable: spatial\.sampleAvailable/); assert.match(source, /sampleAvailable: spatial\.sampleAvailable/);
assert.match(source, /fetchE47SemanticSlamResult/); assert.match(source, /fetchE47SemanticSlamResult/);
assert.match(source, /next\.baseM4ResultId !== result\.source\.linkedVisualResultId/); assert.match(source, /next\.baseM4ResultId !== result\.source\.linkedVisualResultId/);
assert.match(source, /semantic=\{semantic \? \{/); assert.match(source, /semantic=\{semantic \? \{/);
assert.match(contract, /linked_semantic_result_id/); assert.match(contract, /linked_semantic_result_id/);
assert.match(contract, /missioncore\.m49-tgs-full-shadow-playback\/v1/);
});
function npyFloat32(values, shape) {
const shapeText = shape.length === 1 ? `${shape[0]},` : shape.join(", ");
const prefixLength = 10;
let header = `{'descr': '<f4', 'fortran_order': False, 'shape': (${shapeText}), }`;
const padding = (16 - ((prefixLength + header.length + 1) % 16)) % 16;
header += " ".repeat(padding) + "\n";
const buffer = new ArrayBuffer(prefixLength + header.length + values.length * 4);
const bytes = new Uint8Array(buffer);
bytes.set([0x93, 0x4e, 0x55, 0x4d, 0x50, 0x59, 1, 0]);
new DataView(buffer).setUint16(8, header.length, true);
bytes.set(new TextEncoder().encode(header), prefixLength);
new Float32Array(buffer, prefixLength + header.length, values.length).set(values);
return buffer;
}
test("M4.9T5 parses sealed NPY tracks without JSON point arrays", () => {
const parsed = parseM49TgsNpyTrack(
npyFloat32([1.25, -2.5, 3.75, 4.5], [2, 2]),
"<f4",
[2, 2],
);
assert.deepEqual([...parsed], [1.25, -2.5, 3.75, 4.5]);
}); });
@@ -10,6 +10,7 @@ let fetchM4ThreatVisual;
let fetchM4ThreatTimeline; let fetchM4ThreatTimeline;
let fetchM4ThreatTimelineChunk; let fetchM4ThreatTimelineChunk;
let fetchM4ThreatCameraPointOverlay; let fetchM4ThreatCameraPointOverlay;
let hydrateM4ThreatTimelineFrame;
let selectM4ThreatTimelineFrame; let selectM4ThreatTimelineFrame;
let selectM4ThreatTimelineSequence; let selectM4ThreatTimelineSequence;
let advanceRecordedEvidencePlayback; let advanceRecordedEvidencePlayback;
@@ -33,6 +34,7 @@ before(async () => {
fetchM4ThreatTimeline, fetchM4ThreatTimeline,
fetchM4ThreatTimelineChunk, fetchM4ThreatTimelineChunk,
fetchM4ThreatCameraPointOverlay, fetchM4ThreatCameraPointOverlay,
hydrateM4ThreatTimelineFrame,
selectM4ThreatTimelineFrame, selectM4ThreatTimelineFrame,
selectM4ThreatTimelineSequence, selectM4ThreatTimelineSequence,
} = await server.ssrLoadModule("/src/core/laboratory/m4ReplayThreat.ts")); } = await server.ssrLoadModule("/src/core/laboratory/m4ReplayThreat.ts"));
@@ -332,6 +334,45 @@ 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 hydrates a lightweight timeline frame from one retained binary point track", async () => {
const raw = timelineFrame(0, 35.421857292, {
body_frame: {
origin_map_xyz_m: [10, 20, 30],
basis_map_from_body: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
},
point_cloud_body_xyz_m: [],
point_cloud_source_count: 2,
point_cloud_sample_count: 2,
});
const parsed = await fetchM4ThreatTimelineChunk(resultId, 0, 1, {
playbackPointPack: {
resultId,
frameCount: 4489,
pointCount: 2,
pointOffsets: new Uint32Array([0, 2, ...Array(4488).fill(2)]),
pointsMapXyzM: new Float32Array([11, 20, 30.25, 12, 19.5, 30]),
},
fetcher: async (input) => {
assert.match(String(input), /include_points=false/);
return new Response(JSON.stringify({
schema_version: "missioncore.recorded-spatial-evidence-chunk/v1",
result_id: resultId,
start_sequence: 0,
frame_count: 1,
next_sequence: 1,
frames: [raw],
authority: "replay-simulated",
}), { status: 200 });
},
});
assert.deepEqual(parsed.frames[0].pointCloudBodyXyzM, [
[1, 0, 0.25],
[2, -0.5, 0],
]);
assert.equal(parsed.frames[0].pointCloudSampleCount, 2);
assert.equal(typeof hydrateM4ThreatTimelineFrame, "function");
});
test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint", async () => { test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint", async () => {
const replayResultId = `m48s-fixed-class-detector-lab-${"b".repeat(64)}`; const replayResultId = `m48s-fixed-class-detector-lab-${"b".repeat(64)}`;
const endpointRoot = "/api/v1/laboratory/m48s/fixed-class-detector"; const endpointRoot = "/api/v1/laboratory/m48s/fixed-class-detector";
+21
View File
@@ -344,6 +344,27 @@ class RecordedGeometryStore:
points.setflags(write=False) points.setflags(write=False)
return points return points
def playback_points_map(self) -> FloatArray:
"""Expose the sealed contiguous map-point track for binary LAB playback.
The returned array is the exact source-pack point index space. It is
read-only and deliberately excludes any UI projection or resampling so
the browser can retain it once and derive the current increment by the
verified offsets below.
"""
points = np.asarray(self._source["cloud_points_map"], dtype=np.dtype("<f4"))
if not points.flags.c_contiguous:
raise GeometryProviderError("source playback point track is not contiguous")
points.setflags(write=False)
return points
def playback_point_offsets(self) -> tuple[int, ...]:
"""Return immutable offsets into :meth:`playback_points_map`."""
offsets = np.asarray(self._source["cloud_offsets"], dtype=np.int64)
return tuple(int(value) for value in offsets)
def point_step_candidates_for_frame(self, frame_index: int) -> UInt8Array | None: def point_step_candidates_for_frame(self, frame_index: int) -> UInt8Array | None:
"""Expose the sealed low-step diagnostic in the source point index space. """Expose the sealed low-step diagnostic in the source point index space.
+23 -7
View File
@@ -150,14 +150,23 @@ class RecordedThreatTimeline:
"access": "read-only-bounded-recorded-replay", "access": "read-only-bounded-recorded-replay",
} }
def chunk(self, *, start_sequence: int, frame_count: int) -> dict[str, object]: def chunk(
self,
*,
start_sequence: int,
frame_count: int,
include_points: bool = True,
) -> dict[str, object]:
if not 0 <= start_sequence < len(self.index.offsets): if not 0 <= start_sequence < len(self.index.offsets):
raise RecordedThreatTimelineError("recorded timeline chunk start is invalid") raise RecordedThreatTimelineError("recorded timeline chunk start is invalid")
if not 1 <= frame_count <= RECORDED_SPATIAL_MAX_CHUNK_FRAMES: if not 1 <= frame_count <= RECORDED_SPATIAL_MAX_CHUNK_FRAMES:
raise RecordedThreatTimelineError("recorded timeline chunk size is invalid") raise RecordedThreatTimelineError("recorded timeline chunk size is invalid")
stop = min(len(self.index.offsets), start_sequence + frame_count) stop = min(len(self.index.offsets), start_sequence + frame_count)
with self._lock: with self._lock:
frames = [self._project_frame(sequence) for sequence in range(start_sequence, stop)] frames = [
self._project_frame(sequence, include_points=include_points)
for sequence in range(start_sequence, stop)
]
return { return {
"schema_version": RECORDED_SPATIAL_CHUNK_SCHEMA, "schema_version": RECORDED_SPATIAL_CHUNK_SCHEMA,
"result_id": self.result.result_id, "result_id": self.result.result_id,
@@ -170,7 +179,12 @@ class RecordedThreatTimeline:
"access": "read-only-bounded-recorded-replay", "access": "read-only-bounded-recorded-replay",
} }
def _project_frame(self, sequence: int) -> dict[str, object]: def _project_frame(
self,
sequence: int,
*,
include_points: bool,
) -> dict[str, object]:
row = _read_frame_at(self.frames_path, self.index, sequence) row = _read_frame_at(self.frames_path, self.index, sequence)
frame_id = row.get("frame_id") frame_id = row.get("frame_id")
if not isinstance(frame_id, str) or not frame_id: if not isinstance(frame_id, str) or not frame_id:
@@ -193,6 +207,8 @@ class RecordedThreatTimeline:
raise RecordedThreatTimelineError( raise RecordedThreatTimelineError(
"recorded timeline current increment binding changed" "recorded timeline current increment binding changed"
) )
point_source_count = int(points.shape[0])
if include_points:
point_cloud, point_source_count = sample_points_in_body_frame( point_cloud, point_source_count = sample_points_in_body_frame(
points, points,
body_frame, body_frame,
@@ -219,13 +235,13 @@ class RecordedThreatTimeline:
if body_frame is None if body_frame is None
else { else {
"origin_map_xyz_m": list(body_frame.origin_map_xyz_m), "origin_map_xyz_m": list(body_frame.origin_map_xyz_m),
"basis_map_from_body": [ "basis_map_from_body": [list(row) for row in body_frame.basis_map_from_body],
list(row) for row in body_frame.basis_map_from_body
],
}, },
"point_cloud_body_xyz_m": point_cloud, "point_cloud_body_xyz_m": point_cloud,
"point_cloud_source_count": point_source_count, "point_cloud_source_count": point_source_count,
"point_cloud_sample_count": len(point_cloud), "point_cloud_sample_count": point_source_count
if not include_points
else len(point_cloud),
"point_cloud_layer": "current-increment", "point_cloud_layer": "current-increment",
"rolling_map_component_count": sum( "rolling_map_component_count": sum(
item.get("state") == "retained" for item in metric_visuals item.get("state") == "retained" for item in metric_visuals
+125 -10
View File
@@ -13,17 +13,44 @@ from typing import Final
import numpy as np import numpy as np
from fastapi import APIRouter, HTTPException, Query, Response from fastapi import APIRouter, HTTPException, Query, Response
from fastapi.responses import FileResponse
from k1link.laboratory.m49_tgs_full_shadow import ( from k1link.laboratory.m49_tgs_full_shadow import (
PREFIX,
M49TgsFullShadowError, M49TgsFullShadowError,
M49TgsFullShadowResult, M49TgsFullShadowResult,
PREFIX,
read_m49_tgs_full_shadow, read_m49_tgs_full_shadow,
) )
RootProvider = Callable[[], Path | None] RootProvider = Callable[[], Path | None]
RESULT_ID: Final = re.compile(rf"^{re.escape(PREFIX)}[a-f0-9]{{64}}$") RESULT_ID: Final = re.compile(rf"^{re.escape(PREFIX)}[a-f0-9]{{64}}$")
ENDPOINT_ROOT: Final = "/api/v1/laboratory/m49/tgs-full-shadow" ENDPOINT_ROOT: Final = "/api/v1/laboratory/m49/tgs-full-shadow"
PLAYBACK_TRACKS: Final = {
"centers": (
"costmap-cell-centers-xy-m.npy",
"application/x-npy",
"<f4",
[2244, 2],
),
"states": (
"costmap-states.npy",
"application/x-npy",
"|u1",
[4489, 2244],
),
"z-bounds": (
"costmap-z-bounds-m.npy",
"application/x-npy",
"<f4",
[4489, 2244, 2],
),
"frames": (
"frames.ndjson",
"application/x-ndjson",
"ndjson",
[4489],
),
}
def build_m49_tgs_full_shadow_router(*, root_provider: RootProvider = lambda: None) -> APIRouter: def build_m49_tgs_full_shadow_router(*, root_provider: RootProvider = lambda: None) -> APIRouter:
@@ -55,16 +82,46 @@ def build_m49_tgs_full_shadow_router(*, root_provider: RootProvider = lambda: No
if not candidate.is_dir() or RESULT_ID.fullmatch(candidate.name) is None: if not candidate.is_dir() or RESULT_ID.fullmatch(candidate.name) is None:
continue continue
try: try:
results.append(_project(_read_cached(str(candidate.resolve()), _signature(candidate)))) results.append(
_project(_read_cached(str(candidate.resolve()), _signature(candidate)))
)
except (M49TgsFullShadowError, OSError, ValueError): except (M49TgsFullShadowError, OSError, ValueError):
invalid += 1 invalid += 1
results.sort(key=lambda row: (str(row["created_at_utc"]), str(row["result_id"])), reverse=True) results.sort(
key=lambda row: (str(row["created_at_utc"]), str(row["result_id"])), reverse=True
)
return _catalog(results[:limit], configured=True, invalid_total=invalid) return _catalog(results[:limit], configured=True, invalid_total=invalid)
@router.get("/{result_id}") @router.get("/{result_id}")
def get_result(result_id: str) -> dict[str, object]: def get_result(result_id: str) -> dict[str, object]:
return _project(sealed(result_id)) return _project(sealed(result_id))
@router.get("/{result_id}/playback/manifest")
def get_playback_manifest(result_id: str) -> dict[str, object]:
return _playback_manifest(sealed(result_id))
@router.get("/{result_id}/playback/tracks/{track_id}")
def get_playback_track(result_id: str, track_id: str) -> FileResponse:
result = sealed(result_id)
descriptor = PLAYBACK_TRACKS.get(track_id)
if descriptor is None:
raise HTTPException(status_code=404, detail="M49 playback track not found")
name, media_type, _dtype, _shape = descriptor
artifact = _artifact_descriptor(result, name)
return FileResponse(
result.root / name,
media_type=media_type,
headers={
"Cache-Control": "private, max-age=31536000, immutable",
# These playback artifacts are consumed on the local control
# station. Avoid spending multiple seconds recompressing
# already compact numeric tracks on every cold open.
"Content-Encoding": "identity",
"ETag": f'"{artifact["sha256"]}"',
"X-Content-Type-Options": "nosniff",
},
)
@router.get("/{result_id}/frames/{source_sequence}/spatial") @router.get("/{result_id}/frames/{source_sequence}/spatial")
def get_spatial(result_id: str, source_sequence: int) -> Response: def get_spatial(result_id: str, source_sequence: int) -> Response:
if source_sequence < 0 or source_sequence >= 4489: if source_sequence < 0 or source_sequence >= 4489:
@@ -75,11 +132,16 @@ def build_m49_tgs_full_shadow_router(*, root_provider: RootProvider = lambda: No
str(result.root), result_id, source_sequence, _evidence_signature(result.root) str(result.root), result_id, source_sequence, _evidence_signature(result.root)
) )
except (OSError, ValueError, KeyError, json.JSONDecodeError): except (OSError, ValueError, KeyError, json.JSONDecodeError):
raise HTTPException(status_code=503, detail="M49 TGS full-shadow spatial evidence failed verification") from None raise HTTPException(
status_code=503, detail="M49 TGS full-shadow spatial evidence failed verification"
) from None
return Response( return Response(
content=content, content=content,
media_type="application/json", media_type="application/json",
headers={"Cache-Control": "private, max-age=31536000, immutable", "X-Content-Type-Options": "nosniff"}, headers={
"Cache-Control": "private, max-age=31536000, immutable",
"X-Content-Type-Options": "nosniff",
},
) )
@router.get("/{result_id}/spatial/chunk") @router.get("/{result_id}/spatial/chunk")
@@ -134,7 +196,9 @@ def _frame_json_cached(
root_path = Path(root) root_path = Path(root)
frame_signature = (signature[-2], signature[-1]) frame_signature = (signature[-2], signature[-1])
frame = _frames(root, frame_signature)[source_sequence] frame = _frames(root, frame_signature)[source_sequence]
centers = np.load(root_path / "costmap-cell-centers-xy-m.npy", mmap_mode="r", allow_pickle=False) centers = np.load(
root_path / "costmap-cell-centers-xy-m.npy", mmap_mode="r", allow_pickle=False
)
states_all = np.load(root_path / "costmap-states.npy", mmap_mode="r", allow_pickle=False) states_all = np.load(root_path / "costmap-states.npy", mmap_mode="r", allow_pickle=False)
z_all = np.load(root_path / "costmap-z-bounds-m.npy", mmap_mode="r", allow_pickle=False) z_all = np.load(root_path / "costmap-z-bounds-m.npy", mmap_mode="r", allow_pickle=False)
states = states_all[source_sequence] states = states_all[source_sequence]
@@ -169,13 +233,20 @@ def _frame_json_cached(
], ],
}, },
"metrics": copy.deepcopy(frame), "metrics": copy.deepcopy(frame),
"state_codes": {"UNOBSERVED": 0, "GROUND_SUPPORT": 1, "NONGROUND_OCCUPIED": 2, "UNKNOWN_REJECTED": 3}, "state_codes": {
"UNOBSERVED": 0,
"GROUND_SUPPORT": 1,
"NONGROUND_OCCUPIED": 2,
"UNKNOWN_REJECTED": 3,
},
"aos_used": False, "aos_used": False,
"gpu_used": False, "gpu_used": False,
"authority": {"navigation_or_safety_accepted": False, "visual_quality_accepted": False}, "authority": {"navigation_or_safety_accepted": False, "visual_quality_accepted": False},
"access": "read-only", "access": "read-only",
} }
return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") return json.dumps(
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
).encode("utf-8")
@lru_cache(maxsize=16) @lru_cache(maxsize=16)
@@ -189,7 +260,9 @@ def _chunk_json_cached(
root_path = Path(root) root_path = Path(root)
frame_signature = (signature[-2], signature[-1]) frame_signature = (signature[-2], signature[-1])
frames = _frames(root, frame_signature) frames = _frames(root, frame_signature)
centers = np.load(root_path / "costmap-cell-centers-xy-m.npy", mmap_mode="r", allow_pickle=False) centers = np.load(
root_path / "costmap-cell-centers-xy-m.npy", mmap_mode="r", allow_pickle=False
)
states_all = np.load(root_path / "costmap-states.npy", mmap_mode="r", allow_pickle=False) states_all = np.load(root_path / "costmap-states.npy", mmap_mode="r", allow_pickle=False)
z_all = np.load(root_path / "costmap-z-bounds-m.npy", mmap_mode="r", allow_pickle=False) z_all = np.load(root_path / "costmap-z-bounds-m.npy", mmap_mode="r", allow_pickle=False)
if ( if (
@@ -258,6 +331,46 @@ def _chunk_json_cached(
).encode("utf-8") ).encode("utf-8")
def _artifact_descriptor(
result: M49TgsFullShadowResult,
name: str,
) -> dict[str, object]:
for artifact in result.manifest["artifacts"]:
if artifact.get("path") == name:
return artifact
raise ValueError(f"full-shadow artifact is not declared: {name}")
def _playback_manifest(result: M49TgsFullShadowResult) -> dict[str, object]:
tracks: list[dict[str, object]] = []
total_bytes = 0
for track_id, (name, media_type, dtype, shape) in PLAYBACK_TRACKS.items():
artifact = _artifact_descriptor(result, name)
byte_length = int(artifact["byte_length"])
total_bytes += byte_length
tracks.append(
{
"id": track_id,
"url": (f"{ENDPOINT_ROOT}/{result.result_id}/playback/tracks/{track_id}"),
"media_type": media_type,
"dtype": dtype,
"shape": shape,
"byte_length": byte_length,
"sha256": artifact["sha256"],
}
)
return {
"schema_version": "missioncore.m49-tgs-full-shadow-playback/v1",
"result_id": result.result_id,
"coordinate_frame": "map-gravity-local",
"frame_count": 4489,
"cell_count": 2244,
"total_byte_length": total_bytes,
"tracks": tracks,
"access": "read-only",
}
def _project(result: M49TgsFullShadowResult) -> dict[str, object]: def _project(result: M49TgsFullShadowResult) -> dict[str, object]:
return { return {
**copy.deepcopy(result.report), **copy.deepcopy(result.report),
@@ -269,7 +382,9 @@ def _project(result: M49TgsFullShadowResult) -> dict[str, object]:
} }
def _catalog(items: list[dict[str, object]], *, configured: bool, invalid_total: int) -> dict[str, object]: def _catalog(
items: list[dict[str, object]], *, configured: bool, invalid_total: int
) -> dict[str, object]:
return { return {
"schema_version": "missioncore.m49-tgs-full-shadow-catalog/v1", "schema_version": "missioncore.m49-tgs-full-shadow-catalog/v1",
"configured": configured, "configured": configured,
+83 -3
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import copy import copy
import hashlib
import json import json
import re import re
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator
@@ -11,6 +12,7 @@ from pathlib import Path
from typing import Final from typing import Final
from fastapi import APIRouter, HTTPException, Query, Response from fastapi import APIRouter, HTTPException, Query, Response
from fastapi.responses import StreamingResponse
from k1link.perception.threat_replay import ( from k1link.perception.threat_replay import (
THREAT_REPLAY_FRAME_SCHEMA, THREAT_REPLAY_FRAME_SCHEMA,
@@ -166,7 +168,7 @@ def build_m4_threat_replay_router(
def get_timeline(result_id: str) -> dict[str, object]: def get_timeline(result_id: str) -> dict[str, object]:
return copy.deepcopy(timeline(result_id).metadata()) return copy.deepcopy(timeline(result_id).metadata())
@router.get("/results/{result_id}/timeline/chunk") @router.get("/results/{result_id}/timeline/chunk", response_model=None)
def get_timeline_chunk( def get_timeline_chunk(
result_id: str, result_id: str,
start: int = Query(default=0, ge=0), start: int = Query(default=0, ge=0),
@@ -175,14 +177,87 @@ def build_m4_threat_replay_router(
ge=1, ge=1,
le=RECORDED_SPATIAL_MAX_CHUNK_FRAMES, le=RECORDED_SPATIAL_MAX_CHUNK_FRAMES,
), ),
) -> dict[str, object]: include_points: bool = Query(default=True),
) -> dict[str, object] | Response:
try: try:
return timeline(result_id).chunk(start_sequence=start, frame_count=count) payload = timeline(result_id).chunk(
start_sequence=start,
frame_count=count,
include_points=include_points,
)
except RecordedThreatTimelineError: except RecordedThreatTimelineError:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail="M4.6 timeline chunk не найден", detail="M4.6 timeline chunk не найден",
) from None ) from None
if include_points:
return payload
return Response(
content=json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
allow_nan=False,
),
media_type="application/json",
headers={
"Cache-Control": "private, max-age=31536000, immutable",
"Content-Encoding": "identity",
"X-Content-Type-Options": "nosniff",
},
)
@router.get("/results/{result_id}/timeline/playback")
def get_timeline_playback(result_id: str) -> dict[str, object]:
projected = timeline(result_id)
points = projected.store.playback_points_map()
offsets = projected.store.playback_point_offsets()
content_sha256 = hashlib.sha256(memoryview(points).cast("B")).hexdigest()
return {
"schema_version": "missioncore.recorded-spatial-playback/v1",
"result_id": result_id,
"frame_count": len(offsets) - 1,
"point_count": int(points.shape[0]),
"point_offsets": list(offsets),
"track": {
"id": "points-map-f32",
"url": (
f"/api/v1/laboratory/m4-threat/results/{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": content_sha256,
},
"source_pack_sha256": projected.profile.source_pack_sha256,
"coordinate_frame": "map",
"ground_truth": False,
"authority": "replay-simulated",
"access": "read-only-sealed-binary-playback",
}
@router.get(
"/results/{result_id}/timeline/playback/tracks/points-map-f32",
response_class=StreamingResponse,
)
def get_timeline_playback_points(result_id: str) -> StreamingResponse:
projected = timeline(result_id)
points = projected.store.playback_points_map()
source_digest = projected.profile.source_pack_sha256
return StreamingResponse(
_binary_chunks(memoryview(points).cast("B")),
media_type="application/octet-stream",
headers={
"Cache-Control": "private, max-age=31536000, immutable",
"Content-Encoding": "identity",
"Content-Length": str(points.nbytes),
"ETag": f'"{source_digest}-points-map-f32"',
"X-Content-Type-Options": "nosniff",
"X-Uncompressed-Content-Length": str(points.nbytes),
},
)
@router.get("/results/{result_id}/timeline/frames/{sequence}/camera") @router.get("/results/{result_id}/timeline/frames/{sequence}/camera")
def get_timeline_camera(result_id: str, sequence: int) -> Response: def get_timeline_camera(result_id: str, sequence: int) -> Response:
@@ -196,6 +271,11 @@ def build_m4_threat_replay_router(
return router return router
def _binary_chunks(view: memoryview, chunk_size: int = 1024 * 1024) -> Iterator[bytes]:
for start in range(0, view.nbytes, chunk_size):
yield bytes(view[start : start + chunk_size])
@lru_cache(maxsize=4) @lru_cache(maxsize=4)
def _read_threat_result_cached( def _read_threat_result_cached(
root_value: str, root_value: str,
+50 -5
View File
@@ -8,7 +8,10 @@ from pathlib import Path
import numpy as np import numpy as np
from k1link.laboratory.m49_tgs_full_shadow import seal_m49_tgs_full_shadow from k1link.laboratory.m49_tgs_full_shadow import (
M49TgsFullShadowResult,
seal_m49_tgs_full_shadow,
)
from k1link.web import m49_tgs_full_shadow_api as full_shadow_api from k1link.web import m49_tgs_full_shadow_api as full_shadow_api
REPOSITORY_ROOT = Path(__file__).resolve().parents[1] REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
@@ -96,7 +99,9 @@ def test_full_shadow_seal_binds_visual_and_semantic_timelines(tmp_path: Path) ->
"files": files, "files": files,
} }
(source / "result.json").write_text(json.dumps(worker), encoding="utf-8") (source / "result.json").write_text(json.dumps(worker), encoding="utf-8")
(source / "worker-summary.json").write_text(json.dumps({ (source / "worker-summary.json").write_text(
json.dumps(
{
"schema_version": "missioncore.m49-tgs-full-shadow-worker-summary/v1", "schema_version": "missioncore.m49-tgs-full-shadow-worker-summary/v1",
"gpu_requested": False, "gpu_requested": False,
"aos_used": False, "aos_used": False,
@@ -105,15 +110,23 @@ def test_full_shadow_seal_binds_visual_and_semantic_timelines(tmp_path: Path) ->
"canonical_triton_health": "healthy", "canonical_triton_health": "healthy",
"canonical_triton_id": "triton", "canonical_triton_id": "triton",
"wall_seconds": 1.0, "wall_seconds": 1.0,
}), encoding="utf-8") }
),
encoding="utf-8",
)
profile = tmp_path / "profile.json" profile = tmp_path / "profile.json"
profile.write_text(json.dumps({ profile.write_text(
json.dumps(
{
"schema_version": "missioncore.m49-tgs-full-shadow-profile/v1", "schema_version": "missioncore.m49-tgs-full-shadow-profile/v1",
"profile_id": "test", "profile_id": "test",
"profile": {"history_seconds": 1.0}, "profile": {"history_seconds": 1.0},
"costmap": {"state_priority": ["NONGROUND_OCCUPIED"]}, "costmap": {"state_priority": ["NONGROUND_OCCUPIED"]},
"state_codes": {"UNOBSERVED": 0}, "state_codes": {"UNOBSERVED": 0},
}), encoding="utf-8") }
),
encoding="utf-8",
)
visual = "m4-threat-replay-" + "d" * 64 visual = "m4-threat-replay-" + "d" * 64
semantic = "e47-semantic-slam-" + "e" * 64 semantic = "e47-semantic-slam-" + "e" * 64
@@ -176,3 +189,35 @@ def test_full_shadow_chunk_contract_keeps_missing_lidar_unobserved(
assert len(payload["costmap"]["centers_xy_m"]) == 2244 assert len(payload["costmap"]["centers_xy_m"]) == 2244
assert payload["frames"][1]["sample_available"] is False assert payload["frames"][1]["sample_available"] is False
assert set(payload["frames"][1]["states"]) == {0} assert set(payload["frames"][1]["states"]) == {0}
def test_full_shadow_playback_manifest_exposes_sealed_binary_tracks(tmp_path: Path) -> None:
names = {descriptor[0] for descriptor in full_shadow_api.PLAYBACK_TRACKS.values()}
artifacts = [
{
"path": name,
"byte_length": index + 10,
"sha256": f"{index + 1:064x}",
}
for index, name in enumerate(sorted(names))
]
result = M49TgsFullShadowResult(
"m49-tgs-full-shadow-" + "a" * 64,
tmp_path,
{"artifacts": artifacts},
{},
)
payload = full_shadow_api._playback_manifest(result)
assert payload["schema_version"] == "missioncore.m49-tgs-full-shadow-playback/v1"
assert payload["frame_count"] == 4489
assert payload["cell_count"] == 2244
assert payload["total_byte_length"] == sum(item["byte_length"] for item in artifacts)
assert {item["id"] for item in payload["tracks"]} == {
"frames",
"centers",
"states",
"z-bounds",
}
assert all("/playback/tracks/" in item["url"] for item in payload["tracks"])
+19
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path from pathlib import Path
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
@@ -126,6 +127,7 @@ def test_m4_6_lab_api_projects_report_and_exact_visual_frame() -> None:
def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None: def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None:
get_timeline = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline") get_timeline = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline")
get_chunk = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline/chunk") get_chunk = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline/chunk")
get_playback = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline/playback")
timeline = get_timeline(RESULT_ID) timeline = get_timeline(RESULT_ID)
assert timeline["schema_version"] == "missioncore.recorded-spatial-evidence-timeline/v1" assert timeline["schema_version"] == "missioncore.recorded-spatial-evidence-timeline/v1"
@@ -160,6 +162,23 @@ def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None:
assert 0 < first["point_cloud_sample_count"] <= 4096 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")
lightweight_response = get_chunk(RESULT_ID, start=1880, count=1, include_points=False)
lightweight = json.loads(lightweight_response.body)
assert lightweight["frames"][0]["point_cloud_body_xyz_m"] == []
assert lightweight["frames"][0]["point_cloud_source_count"] == first["point_cloud_source_count"]
assert lightweight["frames"][0]["point_cloud_sample_count"] == first["point_cloud_sample_count"]
playback = get_playback(RESULT_ID)
assert playback["schema_version"] == "missioncore.recorded-spatial-playback/v1"
assert playback["frame_count"] == 4489
assert playback["point_count"] == 9_207_270
assert len(playback["point_offsets"]) == 4490
assert playback["point_offsets"][-1] == playback["point_count"]
assert playback["track"]["dtype"] == "<f4"
assert playback["track"]["shape"] == [9_207_270, 3]
assert playback["track"]["bytes"] == 110_487_240
assert len(playback["track"]["sha256"]) == 64
def test_m4_6_exact_camera_endpoint_is_bound_to_selected_visual_sequence() -> None: def test_m4_6_exact_camera_endpoint_is_bound_to_selected_visual_sequence() -> None:
calls: list[tuple[str, int]] = [] calls: list[tuple[str, int]] = []