feat(perception): add lossless lidar replay v2
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
export interface LidarDistribution {
|
||||
sampleCount: number;
|
||||
minimum: number | null;
|
||||
mean: number | null;
|
||||
p50: number | null;
|
||||
p95: number | null;
|
||||
maximum: number | null;
|
||||
}
|
||||
|
||||
export interface LidarPackSummary {
|
||||
packId: string;
|
||||
sessionId: string;
|
||||
profileId: string;
|
||||
pointFrames: number;
|
||||
poseFrames: number;
|
||||
points: number;
|
||||
meanPointsPerFrame: number | null;
|
||||
p95FrameIntervalMs: number | null;
|
||||
poseCoverageFraction: number | null;
|
||||
equivalenceStatus: "passed";
|
||||
logicalContentSha256: string;
|
||||
createdAtUtc: string | null;
|
||||
}
|
||||
|
||||
export interface LidarReplayCatalog {
|
||||
configured: boolean;
|
||||
validTotal: number;
|
||||
invalidTotal: number;
|
||||
duplicateTotal: number;
|
||||
items: LidarPackSummary[];
|
||||
}
|
||||
|
||||
export interface LidarStageReadiness {
|
||||
stage: string;
|
||||
readiness: "ready" | "degraded" | "blocked";
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface LidarReplayDetail {
|
||||
pack: LidarPackSummary;
|
||||
quality: {
|
||||
fieldRetention: Record<string, boolean>;
|
||||
pointCountPerFrame: LidarDistribution;
|
||||
pointFrameIntervalMs: LidarDistribution;
|
||||
intensity: LidarDistribution;
|
||||
poseBinding: {
|
||||
thresholdMs: number;
|
||||
coveredPointFrames: number;
|
||||
coverageFraction: number;
|
||||
nearestDeltaMs: LidarDistribution;
|
||||
};
|
||||
limitations: string[];
|
||||
};
|
||||
equivalence: {
|
||||
status: "passed";
|
||||
arraysCompared: number;
|
||||
arrayMismatches: number;
|
||||
};
|
||||
stages: LidarStageReadiness[];
|
||||
}
|
||||
|
||||
export class LidarReplayContractError extends Error {}
|
||||
|
||||
export class LidarReplayApiError extends Error {
|
||||
constructor(message: string, readonly status: number | null = null) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
type LidarFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
const SAFE_PACK_ID = /^lidar-replay-pack-[a-f0-9]{64}$/;
|
||||
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new LidarReplayContractError(`${label}: ожидался объект`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new LidarReplayContractError(`${label}: ожидался массив`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string, pattern?: RegExp): string {
|
||||
if (typeof value !== "string" || !value || (pattern && !pattern.test(value))) {
|
||||
throw new LidarReplayContractError(`${label}: некорректная строка`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string, nullable = false): number | null {
|
||||
if (nullable && value === null) return null;
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new LidarReplayContractError(`${label}: некорректное число`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (parsed === null || !Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new LidarReplayContractError(`${label}: ожидалось неотрицательное целое`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new LidarReplayContractError(`${label}: ожидался boolean`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function distribution(value: unknown, label: string): LidarDistribution {
|
||||
const source = record(value, label);
|
||||
return {
|
||||
sampleCount: integer(source.sample_count, `${label}.sample_count`),
|
||||
minimum: number(source.minimum, `${label}.minimum`, true),
|
||||
mean: number(source.mean, `${label}.mean`, true),
|
||||
p50: number(source.p50, `${label}.p50`, true),
|
||||
p95: number(source.p95, `${label}.p95`, true),
|
||||
maximum: number(source.maximum, `${label}.maximum`, true),
|
||||
};
|
||||
}
|
||||
|
||||
function packSummary(value: unknown): LidarPackSummary {
|
||||
const source = record(value, "LiDAR pack");
|
||||
if (source.equivalence_status !== "passed") {
|
||||
throw new LidarReplayContractError("LiDAR pack не прошёл equivalence gate");
|
||||
}
|
||||
return {
|
||||
packId: string(source.pack_id, "pack_id", SAFE_PACK_ID),
|
||||
sessionId: string(source.session_id, "session_id", SAFE_ID),
|
||||
profileId: string(source.profile_id, "profile_id", SAFE_ID),
|
||||
pointFrames: integer(source.point_frames, "point_frames"),
|
||||
poseFrames: integer(source.pose_frames, "pose_frames"),
|
||||
points: integer(source.points, "points"),
|
||||
meanPointsPerFrame: number(source.mean_points_per_frame, "mean_points_per_frame", true),
|
||||
p95FrameIntervalMs: number(source.p95_frame_interval_ms, "p95_frame_interval_ms", true),
|
||||
poseCoverageFraction: number(
|
||||
source.pose_coverage_fraction,
|
||||
"pose_coverage_fraction",
|
||||
true,
|
||||
),
|
||||
equivalenceStatus: "passed",
|
||||
logicalContentSha256: string(
|
||||
source.logical_content_sha256,
|
||||
"logical_content_sha256",
|
||||
SHA256,
|
||||
),
|
||||
createdAtUtc:
|
||||
source.created_at_utc === null || source.created_at_utc === undefined
|
||||
? null
|
||||
: string(source.created_at_utc, "created_at_utc"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseLidarReplayCatalog(value: unknown): LidarReplayCatalog {
|
||||
const source = record(value, "LiDAR catalog");
|
||||
if (source.schema_version !== "missioncore.lidar-replay-pack-catalog/v1") {
|
||||
throw new LidarReplayContractError("LiDAR catalog schema несовместима");
|
||||
}
|
||||
return {
|
||||
configured: boolean(source.configured, "configured"),
|
||||
validTotal: integer(source.valid_total, "valid_total"),
|
||||
invalidTotal: integer(source.invalid_total, "invalid_total"),
|
||||
duplicateTotal: integer(source.duplicate_total, "duplicate_total"),
|
||||
items: array(source.items, "items").map(packSummary),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseLidarReplayDetail(value: unknown): LidarReplayDetail {
|
||||
const source = record(value, "LiDAR detail");
|
||||
if (
|
||||
source.schema_version !== "missioncore.lidar-replay-pack-detail/v1"
|
||||
|| source.access !== "read-only"
|
||||
) {
|
||||
throw new LidarReplayContractError("LiDAR detail contract несовместим");
|
||||
}
|
||||
const quality = record(source.quality, "quality");
|
||||
const retention = record(quality.field_retention, "field_retention");
|
||||
const fieldRetention: Record<string, boolean> = {};
|
||||
for (const [key, retained] of Object.entries(retention)) {
|
||||
fieldRetention[key] = boolean(retained, `field_retention.${key}`);
|
||||
}
|
||||
const poseBinding = record(quality.pose_binding, "pose_binding");
|
||||
const equivalence = record(source.equivalence, "equivalence");
|
||||
if (equivalence.status !== "passed") {
|
||||
throw new LidarReplayContractError("LiDAR equivalence gate не пройден");
|
||||
}
|
||||
const readiness = record(source.readiness, "readiness");
|
||||
const stages = array(readiness.stages, "readiness.stages").map((value) => {
|
||||
const stage = record(value, "readiness stage");
|
||||
const readinessValue = stage.readiness;
|
||||
if (
|
||||
readinessValue !== "ready"
|
||||
&& readinessValue !== "degraded"
|
||||
&& readinessValue !== "blocked"
|
||||
) {
|
||||
throw new LidarReplayContractError("Неизвестный LiDAR readiness status");
|
||||
}
|
||||
const normalizedReadiness: LidarStageReadiness["readiness"] = readinessValue;
|
||||
return {
|
||||
stage: string(stage.stage, "stage", SAFE_ID),
|
||||
readiness: normalizedReadiness,
|
||||
reasons: array(stage.reasons, "stage.reasons").map((reason) =>
|
||||
string(reason, "stage reason", SAFE_ID)
|
||||
),
|
||||
};
|
||||
});
|
||||
return {
|
||||
pack: packSummary(source.pack),
|
||||
quality: {
|
||||
fieldRetention,
|
||||
pointCountPerFrame: distribution(quality.point_count_per_frame, "point_count"),
|
||||
pointFrameIntervalMs: distribution(
|
||||
quality.point_frame_interval_ms,
|
||||
"point_interval",
|
||||
),
|
||||
intensity: distribution(quality.intensity_0_255, "intensity"),
|
||||
poseBinding: {
|
||||
thresholdMs: number(poseBinding.threshold_ms, "pose threshold") ?? 0,
|
||||
coveredPointFrames: integer(
|
||||
poseBinding.covered_point_frames,
|
||||
"covered point frames",
|
||||
),
|
||||
coverageFraction: number(
|
||||
poseBinding.coverage_fraction,
|
||||
"pose coverage",
|
||||
) ?? 0,
|
||||
nearestDeltaMs: distribution(
|
||||
poseBinding.nearest_delta_ms,
|
||||
"nearest pose delta",
|
||||
),
|
||||
},
|
||||
limitations: array(quality.limitations, "limitations").map((item) =>
|
||||
string(item, "limitation")
|
||||
),
|
||||
},
|
||||
equivalence: {
|
||||
status: "passed",
|
||||
arraysCompared: integer(equivalence.arrays_compared, "arrays_compared"),
|
||||
arrayMismatches: integer(
|
||||
equivalence.array_mismatches,
|
||||
"array_mismatches",
|
||||
),
|
||||
},
|
||||
stages,
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
): Promise<unknown> {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = await response.json();
|
||||
} catch {
|
||||
throw new LidarReplayApiError(fallback, response.status);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = record(value, "API error").detail;
|
||||
throw new LidarReplayApiError(
|
||||
typeof detail === "string" && detail.trim() ? detail : fallback,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function fetchLidarReplayCatalog(
|
||||
options: { signal?: AbortSignal; fetcher?: LidarFetch } = {},
|
||||
): Promise<LidarReplayCatalog> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher("/api/v1/lidar/replay-packs?limit=50", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
});
|
||||
return parseLidarReplayCatalog(
|
||||
await responseJson(response, "Не удалось получить каталог LiDAR replay."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchLidarReplayDetail(
|
||||
packId: string,
|
||||
options: { signal?: AbortSignal; fetcher?: LidarFetch } = {},
|
||||
): Promise<LidarReplayDetail> {
|
||||
if (!SAFE_PACK_ID.test(packId)) {
|
||||
throw new LidarReplayContractError("Некорректный LiDAR pack id");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(`/api/v1/lidar/replay-packs/${packId}`, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
});
|
||||
return parseLidarReplayDetail(
|
||||
await responseJson(response, "Не удалось получить LiDAR quality report."),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user