feat(lidar): add point-aligned ground review

This commit is contained in:
DCCONSTRUCTIONS
2026-07-25 09:44:38 +03:00
parent 75a3e669d9
commit 2dfb34ef21
15 changed files with 1548 additions and 140 deletions
@@ -77,6 +77,11 @@ export interface LidarGroundBenchmark {
physicalSensorHeightKnown: boolean;
sensorScanGeometryKnown: boolean;
reason: string;
normalization: {
sensorHeightM: number;
mapVerticalOriginOffsetM: number;
heightEvidence: "missing" | "operator-estimated" | "runtime-calibrated";
} | null;
};
labels: {
status: "missing-independent-review";
@@ -115,6 +120,33 @@ export interface LidarGroundBenchmarkCatalog {
items: LidarGroundBenchmark[];
}
export interface LidarGroundFrame {
benchmarkId: string;
replayPackId: string;
sessionId: string;
frameIndex: number;
frameCount: number;
captureSequence: number;
pointCount: number;
coordinateFrame: "map";
distanceUnit: "m";
pointsXyzM: Array<[number, number, number]>;
intensity0To255: number[];
masks: {
currentGround: number[];
currentAssigned: number[];
candidateGround: number[];
candidateAssigned: number[];
disagreement: number[];
};
counts: {
currentGround: number;
candidateGround: number;
disagreement: number;
};
groundTruth: false;
}
export class LidarReplayContractError extends Error {}
export class LidarReplayApiError extends Error {
@@ -178,6 +210,20 @@ function boolean(value: unknown, label: string): boolean {
return value;
}
function groundMask(value: unknown, label: string, count: number): number[] {
const values = array(value, label);
if (values.length !== count) {
throw new LidarReplayContractError(`${label}: длина маски не совпадает`);
}
return values.map((item, index) => {
const parsed = integer(item, `${label}[${index}]`);
if (parsed !== 0 && parsed !== 1) {
throw new LidarReplayContractError(`${label}: ожидалась бинарная маска`);
}
return parsed;
});
}
function distribution(value: unknown, label: string): LidarDistribution {
const source = record(value, label);
return {
@@ -355,6 +401,9 @@ function groundBenchmark(value: unknown): LidarGroundBenchmark {
throw new LidarReplayContractError("Ground benchmark status несовместим");
}
const inputDomain = record(source.input_domain, "input_domain");
const normalization = inputDomain.normalization === undefined
? null
: record(inputDomain.normalization, "input_domain.normalization");
const labels = record(source.labels, "labels");
const comparison = record(source.comparison, "comparison");
const decision = record(source.decision, "decision");
@@ -397,6 +446,33 @@ function groundBenchmark(value: unknown): LidarGroundBenchmark {
"input_domain.sensor_scan_geometry_known",
),
reason: string(inputDomain.reason, "input_domain.reason"),
normalization: normalization
? {
sensorHeightM:
number(
normalization.sensor_height_m,
"normalization.sensor_height_m",
) ?? 0,
mapVerticalOriginOffsetM:
number(
normalization.map_vertical_origin_offset_m,
"normalization.map_vertical_origin_offset_m",
) ?? 0,
heightEvidence: (() => {
const value = normalization.height_evidence;
if (
value !== "missing"
&& value !== "operator-estimated"
&& value !== "runtime-calibrated"
) {
throw new LidarReplayContractError(
"normalization.height_evidence: неизвестное значение",
);
}
return value;
})(),
}
: null,
},
labels: {
status: "missing-independent-review",
@@ -449,6 +525,122 @@ export function parseLidarGroundBenchmarkCatalog(
};
}
export function parseLidarGroundFrame(value: unknown): LidarGroundFrame {
const source = record(value, "LiDAR ground frame");
if (
source.schema_version !== "missioncore.lidar-ground-frame/v1"
|| source.access !== "read-only"
|| source.ground_truth !== false
|| source.coordinate_frame !== "map"
|| source.distance_unit !== "m"
) {
throw new LidarReplayContractError("LiDAR ground frame contract несовместим");
}
const pointCount = integer(source.point_count, "point_count");
if (pointCount < 1 || pointCount > 200_000) {
throw new LidarReplayContractError("LiDAR ground frame слишком большой");
}
const points = array(source.points_xyz_m, "points_xyz_m");
if (points.length !== pointCount) {
throw new LidarReplayContractError("Количество LiDAR points не совпадает");
}
const pointsXyzM = points.map((value, index): [number, number, number] => {
const tuple = array(value, `points_xyz_m[${index}]`);
if (tuple.length !== 3) {
throw new LidarReplayContractError("LiDAR point должен содержать XYZ");
}
return [
number(tuple[0], `points_xyz_m[${index}].x`) ?? 0,
number(tuple[1], `points_xyz_m[${index}].y`) ?? 0,
number(tuple[2], `points_xyz_m[${index}].z`) ?? 0,
];
});
const intensity = array(source.intensity_0_255, "intensity_0_255");
if (intensity.length !== pointCount) {
throw new LidarReplayContractError("Количество intensity не совпадает");
}
const intensity0To255 = intensity.map((value, index) => {
const parsed = integer(value, `intensity_0_255[${index}]`);
if (parsed > 255) {
throw new LidarReplayContractError("LiDAR intensity вне диапазона");
}
return parsed;
});
const masks = record(source.masks, "masks");
const counts = record(source.counts, "counts");
const currentGround = groundMask(
masks.current_ground,
"masks.current_ground",
pointCount,
);
const candidateGround = groundMask(
masks.candidate_ground,
"masks.candidate_ground",
pointCount,
);
const disagreement = groundMask(
masks.disagreement,
"masks.disagreement",
pointCount,
);
const parsedCounts = {
currentGround: integer(counts.current_ground, "counts.current_ground"),
candidateGround: integer(
counts.candidate_ground,
"counts.candidate_ground",
),
disagreement: integer(counts.disagreement, "counts.disagreement"),
};
const frameIndex = integer(source.frame_index, "frame_index");
const frameCount = integer(source.frame_count, "frame_count");
if (
frameCount < 1
|| frameIndex >= frameCount
|| parsedCounts.currentGround !== currentGround.reduce((sum, item) => sum + item, 0)
|| parsedCounts.candidateGround !== candidateGround.reduce((sum, item) => sum + item, 0)
|| parsedCounts.disagreement !== disagreement.reduce((sum, item) => sum + item, 0)
|| disagreement.some(
(item, index) => item !== Number(currentGround[index] !== candidateGround[index]),
)
) {
throw new LidarReplayContractError("LiDAR ground frame несовместим");
}
return {
benchmarkId: string(
source.benchmark_id,
"benchmark_id",
SAFE_GROUND_BENCHMARK_ID,
),
replayPackId: string(source.replay_pack_id, "replay_pack_id", SAFE_PACK_ID),
sessionId: string(source.session_id, "session_id", SAFE_ID),
frameIndex,
frameCount,
captureSequence: integer(source.capture_sequence, "capture_sequence"),
pointCount,
coordinateFrame: "map",
distanceUnit: "m",
pointsXyzM,
intensity0To255,
masks: {
currentGround,
currentAssigned: groundMask(
masks.current_assigned,
"masks.current_assigned",
pointCount,
),
candidateGround,
candidateAssigned: groundMask(
masks.candidate_assigned,
"masks.candidate_assigned",
pointCount,
),
disagreement,
},
counts: parsedCounts,
groundTruth: false,
};
}
async function responseJson(
response: Response,
fallback: string,
@@ -521,3 +713,29 @@ export async function fetchLidarGroundBenchmarks(
await responseJson(response, "Не удалось получить LiDAR ground benchmark."),
);
}
export async function fetchLidarGroundFrame(
benchmarkId: string,
frameIndex: number,
options: { signal?: AbortSignal; fetcher?: LidarFetch } = {},
): Promise<LidarGroundFrame> {
if (
!SAFE_GROUND_BENCHMARK_ID.test(benchmarkId)
|| !Number.isInteger(frameIndex)
|| frameIndex < 0
) {
throw new LidarReplayContractError("Некорректный LiDAR ground frame");
}
const fetcher = options.fetcher ?? fetch;
const response = await fetcher(
`/api/v1/lidar/ground-benchmarks/${benchmarkId}/frames/${frameIndex}`,
{
method: "GET",
headers: { Accept: "application/json" },
signal: options.signal,
},
);
return parseLidarGroundFrame(
await responseJson(response, "Не удалось получить LiDAR ground frame."),
);
}