perf(lab): stream sealed spatial playback tracks
This commit is contained in:
@@ -84,6 +84,48 @@ export interface M49TgsFullShadowSpatialChunk {
|
||||
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 {}
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
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>;
|
||||
export const M4_THREAT_TIMELINE_ENDPOINT_ROOT = "/api/v1/laboratory/m4-threat/results";
|
||||
class M4ThreatContractError extends Error {}
|
||||
@@ -803,11 +817,13 @@ export async function fetchM4ThreatTimelineChunk(
|
||||
signal,
|
||||
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
||||
cameraObstacleProjectionDelivery = null,
|
||||
playbackPointPack,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
endpointRoot?: string;
|
||||
cameraObstacleProjectionDelivery?: M4ThreatTimeline["cameraObstacleProjectionDelivery"];
|
||||
playbackPointPack?: M4ThreatPlaybackPointPack;
|
||||
} = {},
|
||||
): Promise<M4ThreatTimelineChunk> {
|
||||
const params = new URLSearchParams({
|
||||
@@ -817,6 +833,7 @@ export async function fetchM4ThreatTimelineChunk(
|
||||
if (cameraObstacleProjectionDelivery !== null) {
|
||||
params.set("obstacle_projection", cameraObstacleProjectionDelivery);
|
||||
}
|
||||
if (playbackPointPack) params.set("include_points", "false");
|
||||
const response = await fetcher(
|
||||
`${endpointRoot}/${result}/timeline/chunk?${params}`,
|
||||
{ headers: { Accept: "application/json" }, signal },
|
||||
@@ -834,8 +851,12 @@ export async function fetchM4ThreatTimelineChunk(
|
||||
if (parsedStart !== startSequence) {
|
||||
throw new M4ThreatContractError("M4.6 timeline chunk start: нарушен контракт.");
|
||||
}
|
||||
const frames = array(payload.frames, "M4.6 timeline frames").map((raw, offset) =>
|
||||
parseTimelineFrame(raw, result, parsedStart + offset, endpointRoot));
|
||||
const frames = array(payload.frames, "M4.6 timeline frames").map((raw, offset) => {
|
||||
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");
|
||||
if (parsedCount !== frames.length || parsedCount > frameCount) {
|
||||
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(
|
||||
result: string,
|
||||
sequence: number,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let fetchM49TgsFullShadowSpatialChunk;
|
||||
let parseM49TgsNpyTrack;
|
||||
|
||||
const resultId = `m49-tgs-full-shadow-${"a".repeat(64)}`;
|
||||
|
||||
@@ -15,7 +16,7 @@ before(async () => {
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({ fetchM49TgsFullShadowSpatialChunk } = await server.ssrLoadModule(
|
||||
({ fetchM49TgsFullShadowSpatialChunk, parseM49TgsNpyTrack } = await server.ssrLoadModule(
|
||||
"/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]));
|
||||
});
|
||||
|
||||
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([
|
||||
readFile(
|
||||
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",
|
||||
),
|
||||
]);
|
||||
assert.match(source, /const CHUNK_FRAMES = 24/);
|
||||
assert.match(source, /activeChunkStart \+ CHUNK_FRAMES/);
|
||||
assert.match(source, /fetchM49TgsFullShadowSpatialChunk/);
|
||||
assert.match(source, /fetchM49TgsFullShadowPlaybackPack/);
|
||||
assert.match(source, /playbackProgress/);
|
||||
assert.doesNotMatch(source, /fetchM49TgsFullShadowSpatialChunk/);
|
||||
assert.match(source, /sampleAvailable: spatial\.sampleAvailable/);
|
||||
assert.match(source, /fetchE47SemanticSlamResult/);
|
||||
assert.match(source, /next\.baseM4ResultId !== result\.source\.linkedVisualResultId/);
|
||||
assert.match(source, /semantic=\{semantic \? \{/);
|
||||
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 fetchM4ThreatTimelineChunk;
|
||||
let fetchM4ThreatCameraPointOverlay;
|
||||
let hydrateM4ThreatTimelineFrame;
|
||||
let selectM4ThreatTimelineFrame;
|
||||
let selectM4ThreatTimelineSequence;
|
||||
let advanceRecordedEvidencePlayback;
|
||||
@@ -33,6 +34,7 @@ before(async () => {
|
||||
fetchM4ThreatTimeline,
|
||||
fetchM4ThreatTimelineChunk,
|
||||
fetchM4ThreatCameraPointOverlay,
|
||||
hydrateM4ThreatTimelineFrame,
|
||||
selectM4ThreatTimelineFrame,
|
||||
selectM4ThreatTimelineSequence,
|
||||
} = 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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const replayResultId = `m48s-fixed-class-detector-lab-${"b".repeat(64)}`;
|
||||
const endpointRoot = "/api/v1/laboratory/m48s/fixed-class-detector";
|
||||
|
||||
@@ -344,6 +344,27 @@ class RecordedGeometryStore:
|
||||
points.setflags(write=False)
|
||||
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:
|
||||
"""Expose the sealed low-step diagnostic in the source point index space.
|
||||
|
||||
|
||||
@@ -150,14 +150,23 @@ class RecordedThreatTimeline:
|
||||
"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):
|
||||
raise RecordedThreatTimelineError("recorded timeline chunk start is invalid")
|
||||
if not 1 <= frame_count <= RECORDED_SPATIAL_MAX_CHUNK_FRAMES:
|
||||
raise RecordedThreatTimelineError("recorded timeline chunk size is invalid")
|
||||
stop = min(len(self.index.offsets), start_sequence + frame_count)
|
||||
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 {
|
||||
"schema_version": RECORDED_SPATIAL_CHUNK_SCHEMA,
|
||||
"result_id": self.result.result_id,
|
||||
@@ -170,7 +179,12 @@ class RecordedThreatTimeline:
|
||||
"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)
|
||||
frame_id = row.get("frame_id")
|
||||
if not isinstance(frame_id, str) or not frame_id:
|
||||
@@ -193,11 +207,13 @@ class RecordedThreatTimeline:
|
||||
raise RecordedThreatTimelineError(
|
||||
"recorded timeline current increment binding changed"
|
||||
)
|
||||
point_cloud, point_source_count = sample_points_in_body_frame(
|
||||
points,
|
||||
body_frame,
|
||||
point_limit=RECORDED_SPATIAL_POINT_LIMIT,
|
||||
)
|
||||
point_source_count = int(points.shape[0])
|
||||
if include_points:
|
||||
point_cloud, point_source_count = sample_points_in_body_frame(
|
||||
points,
|
||||
body_frame,
|
||||
point_limit=RECORDED_SPATIAL_POINT_LIMIT,
|
||||
)
|
||||
metric_visuals = project_metric_obstacles_to_body(
|
||||
_mapping_array(row.get("metric_obstacles"), "metric obstacles"),
|
||||
body_frame,
|
||||
@@ -219,13 +235,13 @@ class RecordedThreatTimeline:
|
||||
if body_frame is None
|
||||
else {
|
||||
"origin_map_xyz_m": list(body_frame.origin_map_xyz_m),
|
||||
"basis_map_from_body": [
|
||||
list(row) for row in body_frame.basis_map_from_body
|
||||
],
|
||||
"basis_map_from_body": [list(row) for row in body_frame.basis_map_from_body],
|
||||
},
|
||||
"point_cloud_body_xyz_m": point_cloud,
|
||||
"point_cloud_source_count": point_source_count,
|
||||
"point_cloud_sample_count": len(point_cloud),
|
||||
"point_cloud_sample_count": point_source_count
|
||||
if not include_points
|
||||
else len(point_cloud),
|
||||
"point_cloud_layer": "current-increment",
|
||||
"rolling_map_component_count": sum(
|
||||
item.get("state") == "retained" for item in metric_visuals
|
||||
|
||||
@@ -13,17 +13,44 @@ from typing import Final
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from k1link.laboratory.m49_tgs_full_shadow import (
|
||||
PREFIX,
|
||||
M49TgsFullShadowError,
|
||||
M49TgsFullShadowResult,
|
||||
PREFIX,
|
||||
read_m49_tgs_full_shadow,
|
||||
)
|
||||
|
||||
RootProvider = Callable[[], Path | None]
|
||||
RESULT_ID: Final = re.compile(rf"^{re.escape(PREFIX)}[a-f0-9]{{64}}$")
|
||||
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:
|
||||
@@ -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:
|
||||
continue
|
||||
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):
|
||||
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)
|
||||
|
||||
@router.get("/{result_id}")
|
||||
def get_result(result_id: str) -> dict[str, object]:
|
||||
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")
|
||||
def get_spatial(result_id: str, source_sequence: int) -> Response:
|
||||
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)
|
||||
)
|
||||
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(
|
||||
content=content,
|
||||
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")
|
||||
@@ -134,7 +196,9 @@ def _frame_json_cached(
|
||||
root_path = Path(root)
|
||||
frame_signature = (signature[-2], signature[-1])
|
||||
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)
|
||||
z_all = np.load(root_path / "costmap-z-bounds-m.npy", mmap_mode="r", allow_pickle=False)
|
||||
states = states_all[source_sequence]
|
||||
@@ -169,13 +233,20 @@ def _frame_json_cached(
|
||||
],
|
||||
},
|
||||
"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,
|
||||
"gpu_used": False,
|
||||
"authority": {"navigation_or_safety_accepted": False, "visual_quality_accepted": False},
|
||||
"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)
|
||||
@@ -189,7 +260,9 @@ def _chunk_json_cached(
|
||||
root_path = Path(root)
|
||||
frame_signature = (signature[-2], signature[-1])
|
||||
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)
|
||||
z_all = np.load(root_path / "costmap-z-bounds-m.npy", mmap_mode="r", allow_pickle=False)
|
||||
if (
|
||||
@@ -258,6 +331,46 @@ def _chunk_json_cached(
|
||||
).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]:
|
||||
return {
|
||||
**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 {
|
||||
"schema_version": "missioncore.m49-tgs-full-shadow-catalog/v1",
|
||||
"configured": configured,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable, Iterator
|
||||
@@ -11,6 +12,7 @@ from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from k1link.perception.threat_replay import (
|
||||
THREAT_REPLAY_FRAME_SCHEMA,
|
||||
@@ -166,7 +168,7 @@ def build_m4_threat_replay_router(
|
||||
def get_timeline(result_id: str) -> dict[str, object]:
|
||||
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(
|
||||
result_id: str,
|
||||
start: int = Query(default=0, ge=0),
|
||||
@@ -175,14 +177,87 @@ def build_m4_threat_replay_router(
|
||||
ge=1,
|
||||
le=RECORDED_SPATIAL_MAX_CHUNK_FRAMES,
|
||||
),
|
||||
) -> dict[str, object]:
|
||||
include_points: bool = Query(default=True),
|
||||
) -> dict[str, object] | Response:
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="M4.6 timeline chunk не найден",
|
||||
) 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")
|
||||
def get_timeline_camera(result_id: str, sequence: int) -> Response:
|
||||
@@ -196,6 +271,11 @@ def build_m4_threat_replay_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)
|
||||
def _read_threat_result_cached(
|
||||
root_value: str,
|
||||
|
||||
@@ -8,7 +8,10 @@ from pathlib import Path
|
||||
|
||||
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
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -96,24 +99,34 @@ def test_full_shadow_seal_binds_visual_and_semantic_timelines(tmp_path: Path) ->
|
||||
"files": files,
|
||||
}
|
||||
(source / "result.json").write_text(json.dumps(worker), encoding="utf-8")
|
||||
(source / "worker-summary.json").write_text(json.dumps({
|
||||
"schema_version": "missioncore.m49-tgs-full-shadow-worker-summary/v1",
|
||||
"gpu_requested": False,
|
||||
"aos_used": False,
|
||||
"all_timeline_frames_accounted": True,
|
||||
"all_eligible_points_accounted": True,
|
||||
"canonical_triton_health": "healthy",
|
||||
"canonical_triton_id": "triton",
|
||||
"wall_seconds": 1.0,
|
||||
}), encoding="utf-8")
|
||||
(source / "worker-summary.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "missioncore.m49-tgs-full-shadow-worker-summary/v1",
|
||||
"gpu_requested": False,
|
||||
"aos_used": False,
|
||||
"all_timeline_frames_accounted": True,
|
||||
"all_eligible_points_accounted": True,
|
||||
"canonical_triton_health": "healthy",
|
||||
"canonical_triton_id": "triton",
|
||||
"wall_seconds": 1.0,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
profile = tmp_path / "profile.json"
|
||||
profile.write_text(json.dumps({
|
||||
"schema_version": "missioncore.m49-tgs-full-shadow-profile/v1",
|
||||
"profile_id": "test",
|
||||
"profile": {"history_seconds": 1.0},
|
||||
"costmap": {"state_priority": ["NONGROUND_OCCUPIED"]},
|
||||
"state_codes": {"UNOBSERVED": 0},
|
||||
}), encoding="utf-8")
|
||||
profile.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "missioncore.m49-tgs-full-shadow-profile/v1",
|
||||
"profile_id": "test",
|
||||
"profile": {"history_seconds": 1.0},
|
||||
"costmap": {"state_priority": ["NONGROUND_OCCUPIED"]},
|
||||
"state_codes": {"UNOBSERVED": 0},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
visual = "m4-threat-replay-" + "d" * 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 payload["frames"][1]["sample_available"] is False
|
||||
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"])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
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:
|
||||
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_playback = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline/playback")
|
||||
|
||||
timeline = get_timeline(RESULT_ID)
|
||||
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 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:
|
||||
calls: list[tuple[str, int]] = []
|
||||
|
||||
Reference in New Issue
Block a user