feat(lidar): add point-aligned ground review
This commit is contained in:
@@ -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."),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user