feat(lidar): add point-aligned ground review
This commit is contained in:
parent
75a3e669d9
commit
2dfb34ef21
|
|
@ -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."),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,6 +171,37 @@
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lidar-ground-review > header,
|
||||
.lidar-ground-review__controls {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.lidar-ground-frame-status {
|
||||
justify-items: start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.lidar-ground-modes {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lidar-ground-frame-control input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lidar-ground-frame-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lidar-ground-scene {
|
||||
min-height: 22rem;
|
||||
}
|
||||
|
||||
.lidar-ground-scene__toolbar span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.polygon-run-identity dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1020,6 +1020,209 @@
|
|||
padding-top: 0.7rem;
|
||||
}
|
||||
|
||||
.lidar-ground-review {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.85rem;
|
||||
border: 1px solid rgb(74 215 255 / 0.18);
|
||||
border-radius: 1rem;
|
||||
background:
|
||||
radial-gradient(circle at 18% 0%, rgb(56 124 255 / 0.12), transparent 34%),
|
||||
rgb(4 12 19 / 0.72);
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.lidar-ground-review > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.lidar-ground-review h3,
|
||||
.lidar-ground-review p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lidar-ground-review h3 {
|
||||
margin-top: 0.2rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.lidar-ground-review p,
|
||||
.lidar-ground-frame-status span,
|
||||
.lidar-ground-scene__toolbar,
|
||||
.lidar-ground-legend {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.61rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.lidar-ground-review > header p {
|
||||
max-width: 35rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.lidar-ground-frame-status {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
gap: 0.18rem;
|
||||
justify-items: end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.lidar-ground-frame-status strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.lidar-ground-review__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.lidar-ground-modes,
|
||||
.lidar-ground-frame-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.lidar-ground-modes button,
|
||||
.lidar-ground-frame-control button,
|
||||
.lidar-ground-scene__toolbar button {
|
||||
border: 1px solid var(--station-hairline);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.4rem 0.62rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.61rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lidar-ground-modes button:hover,
|
||||
.lidar-ground-modes button[data-active="true"],
|
||||
.lidar-ground-frame-control button:hover:not(:disabled),
|
||||
.lidar-ground-scene__toolbar button:hover {
|
||||
border-color: rgb(74 215 255 / 0.48);
|
||||
background: rgb(74 215 255 / 0.1);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.lidar-ground-frame-control button {
|
||||
display: grid;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.lidar-ground-frame-control button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.lidar-ground-frame-control input {
|
||||
width: min(16rem, 24vw);
|
||||
accent-color: #4ad7ff;
|
||||
}
|
||||
|
||||
.lidar-ground-scene {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 30rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.09);
|
||||
border-radius: 0.9rem;
|
||||
background: #071018;
|
||||
}
|
||||
|
||||
.lidar-ground-scene__viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.lidar-ground-scene__viewport canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.lidar-ground-scene__toolbar {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 0.6rem;
|
||||
bottom: 0.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 999px;
|
||||
background: rgb(5 13 21 / 0.82);
|
||||
padding: 0.28rem;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.lidar-ground-scene__toolbar button {
|
||||
background: rgb(74 215 255 / 0.08);
|
||||
}
|
||||
|
||||
.lidar-ground-scene__error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0;
|
||||
color: var(--nodedc-danger);
|
||||
}
|
||||
|
||||
.lidar-ground-scene-placeholder {
|
||||
display: grid;
|
||||
min-height: 18rem;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 0.55rem;
|
||||
border: 1px solid var(--station-hairline);
|
||||
border-radius: 0.9rem;
|
||||
background: #071018;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lidar-ground-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem 0.85rem;
|
||||
}
|
||||
|
||||
.lidar-ground-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.lidar-ground-legend i {
|
||||
width: 0.46rem;
|
||||
height: 0.46rem;
|
||||
border-radius: 50%;
|
||||
background: #3d4a58;
|
||||
}
|
||||
|
||||
.lidar-ground-legend i[data-color="shared"] {
|
||||
background: #b9ff4a;
|
||||
}
|
||||
|
||||
.lidar-ground-legend i[data-color="current"] {
|
||||
background: #ffa32e;
|
||||
}
|
||||
|
||||
.lidar-ground-legend i[data-color="candidate"] {
|
||||
background: #3dd7ff;
|
||||
}
|
||||
|
||||
.lidar-ground-empty {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,286 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
|
||||
import type { LidarGroundFrame } from "../core/lidar/replayQuality";
|
||||
|
||||
export type LidarGroundViewMode =
|
||||
| "intensity"
|
||||
| "current"
|
||||
| "candidate"
|
||||
| "disagreement";
|
||||
|
||||
interface LidarGroundPointCloudProps {
|
||||
frame: LidarGroundFrame;
|
||||
mode: LidarGroundViewMode;
|
||||
}
|
||||
|
||||
function setRgb(
|
||||
target: Float32Array,
|
||||
offset: number,
|
||||
red: number,
|
||||
green: number,
|
||||
blue: number,
|
||||
) {
|
||||
target[offset] = red;
|
||||
target[offset + 1] = green;
|
||||
target[offset + 2] = blue;
|
||||
}
|
||||
|
||||
function frameColors(
|
||||
frame: LidarGroundFrame,
|
||||
mode: LidarGroundViewMode,
|
||||
): Float32Array {
|
||||
const colors = new Float32Array(frame.pointCount * 3);
|
||||
for (let index = 0; index < frame.pointCount; index += 1) {
|
||||
const offset = index * 3;
|
||||
const current = frame.masks.currentGround[index] === 1;
|
||||
const candidate = frame.masks.candidateGround[index] === 1;
|
||||
const candidateAssigned = frame.masks.candidateAssigned[index] === 1;
|
||||
if (mode === "intensity") {
|
||||
const intensity = frame.intensity0To255[index] / 255;
|
||||
setRgb(
|
||||
colors,
|
||||
offset,
|
||||
0.12 + intensity * 0.74,
|
||||
0.24 + intensity * 0.68,
|
||||
0.34 + intensity * 0.6,
|
||||
);
|
||||
} else if (mode === "current") {
|
||||
setRgb(
|
||||
colors,
|
||||
offset,
|
||||
current ? 0.73 : 0.29,
|
||||
current ? 1 : 0.36,
|
||||
current ? 0.29 : 0.43,
|
||||
);
|
||||
} else if (mode === "candidate") {
|
||||
if (!candidateAssigned) {
|
||||
setRgb(colors, offset, 1, 0.24, 0.32);
|
||||
} else {
|
||||
setRgb(
|
||||
colors,
|
||||
offset,
|
||||
candidate ? 0.24 : 0.29,
|
||||
candidate ? 0.84 : 0.36,
|
||||
candidate ? 1 : 0.43,
|
||||
);
|
||||
}
|
||||
} else if (current && candidate) {
|
||||
setRgb(colors, offset, 0.73, 1, 0.29);
|
||||
} else if (current) {
|
||||
setRgb(colors, offset, 1, 0.64, 0.18);
|
||||
} else if (candidate) {
|
||||
setRgb(colors, offset, 0.24, 0.84, 1);
|
||||
} else {
|
||||
setRgb(colors, offset, 0.24, 0.29, 0.35);
|
||||
}
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
export function LidarGroundPointCloud({
|
||||
frame,
|
||||
mode,
|
||||
}: LidarGroundPointCloudProps) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const geometryRef = useRef<THREE.BufferGeometry | null>(null);
|
||||
const materialRef = useRef<THREE.PointsMaterial | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const [renderError, setRenderError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
} catch {
|
||||
setRenderError("Браузер не смог создать WebGL-сцену LiDAR.");
|
||||
return;
|
||||
}
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setClearColor(0x071018, 0.96);
|
||||
renderer.domElement.setAttribute(
|
||||
"aria-label",
|
||||
"Интерактивное облако ground segmentation",
|
||||
);
|
||||
host.prepend(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.fog = new THREE.FogExp2(0x071018, 0.035);
|
||||
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 1_000);
|
||||
camera.position.set(6, 4.5, 6);
|
||||
cameraRef.current = camera;
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
controls.enablePan = true;
|
||||
controls.enableZoom = true;
|
||||
controls.minDistance = 0.15;
|
||||
controls.maxDistance = 200;
|
||||
controls.minPolarAngle = 0;
|
||||
controls.maxPolarAngle = Math.PI;
|
||||
controls.target.set(0, 0.5, 0);
|
||||
controls.update();
|
||||
controlsRef.current = controls;
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometryRef.current = geometry;
|
||||
const material = new THREE.PointsMaterial({
|
||||
size: 0.035,
|
||||
sizeAttenuation: true,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.96,
|
||||
depthWrite: true,
|
||||
});
|
||||
materialRef.current = material;
|
||||
scene.add(new THREE.Points(geometry, material));
|
||||
|
||||
const grid = new THREE.GridHelper(24, 48, 0x3c7cff, 0x233747);
|
||||
const gridMaterials = Array.isArray(grid.material)
|
||||
? grid.material
|
||||
: [grid.material];
|
||||
gridMaterials.forEach((gridMaterial) => {
|
||||
gridMaterial.transparent = true;
|
||||
gridMaterial.opacity = 0.3;
|
||||
});
|
||||
scene.add(grid);
|
||||
|
||||
const axes = new THREE.AxesHelper(0.8);
|
||||
axes.position.set(-0.05, 0.02, -0.05);
|
||||
scene.add(axes);
|
||||
|
||||
const resize = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height, false);
|
||||
};
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(host);
|
||||
resize();
|
||||
|
||||
let animationFrame = 0;
|
||||
const render = () => {
|
||||
animationFrame = window.requestAnimationFrame(render);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
render();
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
observer.disconnect();
|
||||
controls.dispose();
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
grid.geometry.dispose();
|
||||
gridMaterials.forEach((gridMaterial) => gridMaterial.dispose());
|
||||
axes.geometry.dispose();
|
||||
const axesMaterials = Array.isArray(axes.material)
|
||||
? axes.material
|
||||
: [axes.material];
|
||||
axesMaterials.forEach((axesMaterial) => axesMaterial.dispose());
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
geometryRef.current = null;
|
||||
materialRef.current = null;
|
||||
cameraRef.current = null;
|
||||
controlsRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const geometry = geometryRef.current;
|
||||
const material = materialRef.current;
|
||||
const camera = cameraRef.current;
|
||||
const controls = controlsRef.current;
|
||||
if (!geometry || !material || !camera || !controls) return;
|
||||
|
||||
const positions = new Float32Array(frame.pointCount * 3);
|
||||
let minimumX = Number.POSITIVE_INFINITY;
|
||||
let maximumX = Number.NEGATIVE_INFINITY;
|
||||
let minimumY = Number.POSITIVE_INFINITY;
|
||||
let maximumY = Number.NEGATIVE_INFINITY;
|
||||
let minimumZ = Number.POSITIVE_INFINITY;
|
||||
let maximumZ = Number.NEGATIVE_INFINITY;
|
||||
frame.pointsXyzM.forEach(([x, y, z]) => {
|
||||
minimumX = Math.min(minimumX, x);
|
||||
maximumX = Math.max(maximumX, x);
|
||||
minimumY = Math.min(minimumY, y);
|
||||
maximumY = Math.max(maximumY, y);
|
||||
minimumZ = Math.min(minimumZ, z);
|
||||
maximumZ = Math.max(maximumZ, z);
|
||||
});
|
||||
const centerX = (minimumX + maximumX) / 2;
|
||||
const centerY = (minimumY + maximumY) / 2;
|
||||
frame.pointsXyzM.forEach(([x, y, z], index) => {
|
||||
const offset = index * 3;
|
||||
positions[offset] = x - centerX;
|
||||
positions[offset + 1] = z - minimumZ;
|
||||
positions[offset + 2] = -(y - centerY);
|
||||
});
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.computeBoundingSphere();
|
||||
const radius = Math.max(geometry.boundingSphere?.radius ?? 1, 0.2);
|
||||
material.size = THREE.MathUtils.clamp(radius / 155, 0.014, 0.075);
|
||||
|
||||
const targetHeight = Math.max((maximumZ - minimumZ) * 0.35, 0.15);
|
||||
const distance = Math.max(radius * 1.8, 1.2);
|
||||
controls.target.set(0, targetHeight, 0);
|
||||
camera.position.set(distance, distance * 0.72, distance);
|
||||
camera.near = Math.max(distance / 1_000, 0.005);
|
||||
camera.far = Math.max(distance * 100, 100);
|
||||
camera.updateProjectionMatrix();
|
||||
controls.update();
|
||||
}, [frame]);
|
||||
|
||||
useEffect(() => {
|
||||
const geometry = geometryRef.current;
|
||||
if (!geometry) return;
|
||||
geometry.setAttribute(
|
||||
"color",
|
||||
new THREE.BufferAttribute(frameColors(frame, mode), 3),
|
||||
);
|
||||
geometry.attributes.color.needsUpdate = true;
|
||||
}, [frame, mode]);
|
||||
|
||||
const resetCamera = () => {
|
||||
const geometry = geometryRef.current;
|
||||
const camera = cameraRef.current;
|
||||
const controls = controlsRef.current;
|
||||
if (!geometry || !camera || !controls) return;
|
||||
const radius = Math.max(geometry.boundingSphere?.radius ?? 1, 0.2);
|
||||
const distance = Math.max(radius * 1.8, 1.2);
|
||||
camera.position.set(distance, distance * 0.72, distance);
|
||||
controls.target.set(0, radius * 0.18, 0);
|
||||
controls.update();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lidar-ground-scene" data-testid="lidar-ground-scene">
|
||||
<div ref={hostRef} className="lidar-ground-scene__viewport">
|
||||
{renderError ? (
|
||||
<p className="lidar-ground-scene__error">{renderError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="lidar-ground-scene__toolbar">
|
||||
<button type="button" onClick={resetCamera}>Сбросить ракурс</button>
|
||||
<span>ЛКМ · вращение</span>
|
||||
<span>Колесо · масштаб</span>
|
||||
<span>ПКМ · панорама</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,16 +6,22 @@ import {
|
|||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
fetchLidarGroundFrame,
|
||||
fetchLidarGroundBenchmarks,
|
||||
fetchLidarReplayCatalog,
|
||||
fetchLidarReplayDetail,
|
||||
type LidarGroundBenchmark,
|
||||
type LidarGroundFrame,
|
||||
type LidarReplayCatalog,
|
||||
type LidarReplayDetail,
|
||||
type LidarStageReadiness,
|
||||
} from "../core/lidar/replayQuality";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import type { WorkspaceDefinition } from "../productModel";
|
||||
import {
|
||||
LidarGroundPointCloud,
|
||||
type LidarGroundViewMode,
|
||||
} from "./LidarGroundPointCloud";
|
||||
|
||||
function formatNumber(value: number | null, digits = 1): string {
|
||||
if (value === null) return "—";
|
||||
|
|
@ -57,6 +63,12 @@ export function LidarQualityWorkspace({
|
|||
const [detail, setDetail] = useState<LidarReplayDetail | null>(null);
|
||||
const [groundBenchmark, setGroundBenchmark] =
|
||||
useState<LidarGroundBenchmark | null>(null);
|
||||
const [groundFrame, setGroundFrame] = useState<LidarGroundFrame | null>(null);
|
||||
const [groundFrameIndex, setGroundFrameIndex] = useState(0);
|
||||
const [groundFrameLoading, setGroundFrameLoading] = useState(false);
|
||||
const [groundFrameError, setGroundFrameError] = useState<string | null>(null);
|
||||
const [groundViewMode, setGroundViewMode] =
|
||||
useState<LidarGroundViewMode>("disagreement");
|
||||
const [selectedPackId, setSelectedPackId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -77,6 +89,7 @@ export function LidarQualityWorkspace({
|
|||
if (!target) {
|
||||
setDetail(null);
|
||||
setGroundBenchmark(null);
|
||||
setGroundFrame(null);
|
||||
return;
|
||||
}
|
||||
const [nextDetail, groundCatalog] = await Promise.all([
|
||||
|
|
@ -91,10 +104,12 @@ export function LidarQualityWorkspace({
|
|||
setSelectedPackId(target);
|
||||
setDetail(nextDetail);
|
||||
setGroundBenchmark(groundCatalog.items[0] ?? null);
|
||||
setGroundFrameIndex(0);
|
||||
} catch (loadError) {
|
||||
if (controller.signal.aborted) return;
|
||||
setDetail(null);
|
||||
setGroundBenchmark(null);
|
||||
setGroundFrame(null);
|
||||
setError(errorMessage(loadError));
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
|
|
@ -103,6 +118,36 @@ export function LidarQualityWorkspace({
|
|||
return () => controller.abort();
|
||||
}, [reloadGeneration, selectedPackId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!groundBenchmark) {
|
||||
setGroundFrame(null);
|
||||
setGroundFrameError(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setGroundFrameLoading(true);
|
||||
setGroundFrameError(null);
|
||||
void fetchLidarGroundFrame(
|
||||
groundBenchmark.benchmarkId,
|
||||
groundFrameIndex,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((frame) => {
|
||||
if (!controller.signal.aborted) setGroundFrame(frame);
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setGroundFrame(null);
|
||||
setGroundFrameError(errorMessage(loadError));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setGroundFrameLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [groundBenchmark, groundFrameIndex]);
|
||||
|
||||
const groundNormalization = groundBenchmark?.inputDomain.normalization ?? null;
|
||||
|
||||
return (
|
||||
<div className="standard-workspace lidar-quality-workspace">
|
||||
<section className="workspace-lead workspace-lead--compact">
|
||||
|
|
@ -306,28 +351,144 @@ export function LidarQualityWorkspace({
|
|||
</small>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
className="lidar-ground-review"
|
||||
aria-label="Визуальное сравнение ground segmentation"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">
|
||||
POINT-ALIGNED REVIEW
|
||||
</span>
|
||||
<h3>Покадровое облако и маски</h3>
|
||||
<p>
|
||||
Один и тот же map-frame XYZ, разные диагностические
|
||||
раскраски. Маски не изменяют replay.
|
||||
</p>
|
||||
</div>
|
||||
<div className="lidar-ground-frame-status">
|
||||
<strong>
|
||||
Кадр {groundFrameIndex + 1} / {groundBenchmark.frames}
|
||||
</strong>
|
||||
<span>
|
||||
{groundFrame
|
||||
? `${groundFrame.pointCount.toLocaleString("ru-RU")} точек`
|
||||
: groundFrameLoading
|
||||
? "Загрузка…"
|
||||
: "Нет данных"}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<div className="lidar-ground-review__controls">
|
||||
<div
|
||||
className="lidar-ground-modes"
|
||||
role="group"
|
||||
aria-label="Режим окраски LiDAR"
|
||||
>
|
||||
{([
|
||||
["intensity", "Интенсивность"],
|
||||
["current", "Current"],
|
||||
["candidate", "Patchwork++"],
|
||||
["disagreement", "Расхождения"],
|
||||
] as const).map(([mode, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={mode}
|
||||
data-active={groundViewMode === mode ? "true" : undefined}
|
||||
onClick={() => setGroundViewMode(mode)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="lidar-ground-frame-control">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Предыдущий LiDAR кадр"
|
||||
disabled={groundFrameIndex === 0}
|
||||
onClick={() =>
|
||||
setGroundFrameIndex((value) => Math.max(0, value - 1))
|
||||
}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
aria-label="Номер LiDAR кадра"
|
||||
min={0}
|
||||
max={Math.max(groundBenchmark.frames - 1, 0)}
|
||||
step={1}
|
||||
value={groundFrameIndex}
|
||||
onChange={(event) =>
|
||||
setGroundFrameIndex(Number(event.currentTarget.value))
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Следующий LiDAR кадр"
|
||||
disabled={
|
||||
groundFrameIndex >= groundBenchmark.frames - 1
|
||||
}
|
||||
onClick={() =>
|
||||
setGroundFrameIndex((value) =>
|
||||
Math.min(groundBenchmark.frames - 1, value + 1)
|
||||
)
|
||||
}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{groundFrame ? (
|
||||
<LidarGroundPointCloud
|
||||
frame={groundFrame}
|
||||
mode={groundViewMode}
|
||||
/>
|
||||
) : (
|
||||
<div className="lidar-ground-scene-placeholder">
|
||||
<StatusBadge tone={groundFrameError ? "danger" : "accent"}>
|
||||
{groundFrameError ? "Frame недоступен" : "Читаем frame"}
|
||||
</StatusBadge>
|
||||
<p>{groundFrameError ?? "Проверяем point alignment и masks."}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="lidar-ground-legend">
|
||||
<span><i data-color="shared" />Оба считают ground</span>
|
||||
<span><i data-color="current" />Только current</span>
|
||||
<span><i data-color="candidate" />Только Patchwork++</span>
|
||||
<span><i data-color="non-ground" />Оба non-ground</span>
|
||||
</div>
|
||||
</section>
|
||||
<div className="lidar-ground-gates">
|
||||
<div>
|
||||
<StatusBadge tone="danger">
|
||||
Входной контракт не принят
|
||||
</StatusBadge>
|
||||
<p>
|
||||
Patchwork++ ожидает sensor-centric scan и физическую высоту
|
||||
сенсора; текущий point feed является vendor-mapped increment.
|
||||
Firmware 3.0.2 подтверждает внутренний MID-360 raw feed,
|
||||
но текущий MQTT остаётся прореженным LIO/map-продуктом.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<StatusBadge tone="warning">
|
||||
{groundNormalization?.heightEvidence === "operator-estimated"
|
||||
? "Высота применена диагностически"
|
||||
: "Высота не принята"}
|
||||
</StatusBadge>
|
||||
<p>
|
||||
{groundNormalization?.heightEvidence === "operator-estimated"
|
||||
? `Ручной замер ${formatNumber(
|
||||
groundNormalization.sensorHeightM,
|
||||
2,
|
||||
)} м сдвигает optical origin, но не заменяет runtime calibration.`
|
||||
: "Нужна привязка optical origin к map и штатной установке."}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<StatusBadge tone="warning">Разметка не принята</StatusBadge>
|
||||
<p>
|
||||
IoU, curb recall, low-obstacle recall и reflection-noise
|
||||
rejection появятся только после независимого human review.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<StatusBadge tone="danger">Не продвигать</StatusBadge>
|
||||
<p>
|
||||
Следующий gate: human-reviewed annotation subset или
|
||||
принятый raw sensor scan с физической высотой сенсора.
|
||||
Ground IoU и recall появятся только после human review;
|
||||
визуальное расхождение само по себе не является accuracy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ let server;
|
|||
let parseLidarReplayCatalog;
|
||||
let parseLidarReplayDetail;
|
||||
let parseLidarGroundBenchmarkCatalog;
|
||||
let parseLidarGroundFrame;
|
||||
let fetchLidarReplayCatalog;
|
||||
let fetchLidarReplayDetail;
|
||||
let fetchLidarGroundBenchmarks;
|
||||
let fetchLidarGroundFrame;
|
||||
let LidarReplayContractError;
|
||||
let workspaceById;
|
||||
|
||||
|
|
@ -23,9 +25,11 @@ before(async () => {
|
|||
parseLidarReplayCatalog,
|
||||
parseLidarReplayDetail,
|
||||
parseLidarGroundBenchmarkCatalog,
|
||||
parseLidarGroundFrame,
|
||||
fetchLidarReplayCatalog,
|
||||
fetchLidarReplayDetail,
|
||||
fetchLidarGroundBenchmarks,
|
||||
fetchLidarGroundFrame,
|
||||
LidarReplayContractError,
|
||||
} = await server.ssrLoadModule("/src/core/lidar/replayQuality.ts"));
|
||||
({ workspaceById } = await server.ssrLoadModule("/src/productModel.ts"));
|
||||
|
|
@ -176,6 +180,11 @@ function groundCatalog(overrides = {}) {
|
|||
representation: "vendor-map-increment",
|
||||
physical_sensor_height_known: false,
|
||||
sensor_scan_geometry_known: false,
|
||||
normalization: {
|
||||
sensor_height_m: 1.27,
|
||||
map_vertical_origin_offset_m: 1.27,
|
||||
height_evidence: "operator-estimated",
|
||||
},
|
||||
reason: "Patchwork++ expects sensor-centric scans.",
|
||||
},
|
||||
labels: {
|
||||
|
|
@ -218,6 +227,46 @@ function groundCatalog(overrides = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
function groundFrame(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.lidar-ground-frame/v1",
|
||||
benchmark_id: `ground-benchmark-${"c".repeat(64)}`,
|
||||
replay_pack_id: packId,
|
||||
session_id: "20260719T220917Z_viewer_live",
|
||||
frame_index: 0,
|
||||
frame_count: 2,
|
||||
capture_sequence: 42,
|
||||
point_count: 3,
|
||||
coordinate_frame: "map",
|
||||
distance_unit: "m",
|
||||
points_xyz_m: [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0.1],
|
||||
[0, 1, 0.5],
|
||||
],
|
||||
intensity_0_255: [10, 120, 255],
|
||||
masks: {
|
||||
current_ground: [1, 1, 0],
|
||||
current_assigned: [1, 1, 1],
|
||||
candidate_ground: [1, 0, 0],
|
||||
candidate_assigned: [1, 1, 1],
|
||||
disagreement: [0, 1, 0],
|
||||
},
|
||||
counts: {
|
||||
current_ground: 2,
|
||||
candidate_ground: 1,
|
||||
disagreement: 1,
|
||||
},
|
||||
access: "read-only",
|
||||
ground_truth: false,
|
||||
authority: {
|
||||
commands_enabled: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(payload, status = 200) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
|
|
@ -252,6 +301,10 @@ test("ground benchmark stays diagnostic until input and labels are accepted", ()
|
|||
assert.equal(parsed.items[0].inputDomain.accepted, false);
|
||||
assert.equal(parsed.items[0].labels.metricsAvailable, false);
|
||||
assert.equal(parsed.items[0].candidate.groundFraction.p50, 0.005);
|
||||
assert.equal(
|
||||
parsed.items[0].inputDomain.normalization.heightEvidence,
|
||||
"operator-estimated",
|
||||
);
|
||||
assert.equal(parsed.items[0].decision.productionPromotion, false);
|
||||
|
||||
const promoted = groundCatalog();
|
||||
|
|
@ -262,12 +315,58 @@ test("ground benchmark stays diagnostic until input and labels are accepted", ()
|
|||
);
|
||||
});
|
||||
|
||||
test("ground frame stays point-aligned, bounded and path-free", () => {
|
||||
const parsed = parseLidarGroundFrame(groundFrame());
|
||||
|
||||
assert.equal(parsed.pointCount, 3);
|
||||
assert.deepEqual(parsed.pointsXyzM[1], [1, 0, 0.1]);
|
||||
assert.deepEqual(parsed.masks.disagreement, [0, 1, 0]);
|
||||
assert.equal(parsed.counts.currentGround, 2);
|
||||
assert.equal("path" in parsed, false);
|
||||
|
||||
assert.throws(
|
||||
() => parseLidarGroundFrame(groundFrame({
|
||||
masks: {
|
||||
...groundFrame().masks,
|
||||
disagreement: [0, 1],
|
||||
},
|
||||
})),
|
||||
LidarReplayContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseLidarGroundFrame(groundFrame({
|
||||
counts: {
|
||||
current_ground: 1,
|
||||
candidate_ground: 1,
|
||||
disagreement: 1,
|
||||
},
|
||||
})),
|
||||
LidarReplayContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseLidarGroundFrame(groundFrame({
|
||||
masks: {
|
||||
...groundFrame().masks,
|
||||
disagreement: [0, 0, 0],
|
||||
},
|
||||
counts: {
|
||||
current_ground: 2,
|
||||
candidate_ground: 1,
|
||||
disagreement: 0,
|
||||
},
|
||||
})),
|
||||
LidarReplayContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("LiDAR fetchers use read-only endpoints and workspace is registered", async () => {
|
||||
const calls = [];
|
||||
const fetcher = async (input, init) => {
|
||||
calls.push({ input: String(input), method: init?.method });
|
||||
if (String(input).includes("ground-benchmarks")) {
|
||||
return jsonResponse(groundCatalog());
|
||||
return String(input).includes("/frames/")
|
||||
? jsonResponse(groundFrame())
|
||||
: jsonResponse(groundCatalog());
|
||||
}
|
||||
return String(input).includes(packId)
|
||||
? jsonResponse(detail())
|
||||
|
|
@ -276,10 +375,16 @@ test("LiDAR fetchers use read-only endpoints and workspace is registered", async
|
|||
const parsedCatalog = await fetchLidarReplayCatalog({ fetcher });
|
||||
const parsedDetail = await fetchLidarReplayDetail(packId, { fetcher });
|
||||
const ground = await fetchLidarGroundBenchmarks(packId, { fetcher });
|
||||
const frame = await fetchLidarGroundFrame(
|
||||
`ground-benchmark-${"c".repeat(64)}`,
|
||||
0,
|
||||
{ fetcher },
|
||||
);
|
||||
|
||||
assert.equal(parsedCatalog.validTotal, 1);
|
||||
assert.equal(parsedDetail.pack.packId, packId);
|
||||
assert.equal(ground.validTotal, 1);
|
||||
assert.equal(frame.pointCount, 3);
|
||||
assert.deepEqual(calls, [
|
||||
{ input: "/api/v1/lidar/replay-packs?limit=50", method: "GET" },
|
||||
{ input: `/api/v1/lidar/replay-packs/${packId}`, method: "GET" },
|
||||
|
|
@ -287,6 +392,10 @@ test("LiDAR fetchers use read-only endpoints and workspace is registered", async
|
|||
input: `/api/v1/lidar/ground-benchmarks?pack_id=${packId}&limit=20`,
|
||||
method: "GET",
|
||||
},
|
||||
{
|
||||
input: `/api/v1/lidar/ground-benchmarks/ground-benchmark-${"c".repeat(64)}/frames/0`,
|
||||
method: "GET",
|
||||
},
|
||||
]);
|
||||
assert.equal(workspaceById("lidar-quality").root, "data");
|
||||
assert.equal(workspaceById("lidar-quality").kind, "lidar-quality");
|
||||
|
|
|
|||
|
|
@ -24,6 +24,21 @@ read-only `/api/v1/lidar/ground-benchmarks` surface therefore publishes
|
|||
`production_promotion=false` until an independent annotation generation or an
|
||||
admitted raw scan closes the input gate.
|
||||
|
||||
The companion read-only
|
||||
`/api/v1/lidar/ground-benchmarks/{benchmark_id}/frames/{frame_index}` endpoint
|
||||
returns one bounded, path-free point-aligned frame. The Control Station renders
|
||||
that evidence in Three.js with unrestricted orbit, pan and zoom plus
|
||||
intensity/current/candidate/disagreement color modes. The browser never runs
|
||||
Patchwork++, parses private firmware or receives a filesystem path.
|
||||
|
||||
Static K1 3.0.2 firmware evidence confirms that the appliance internally uses
|
||||
a Livox MID-360 point/IMU path with richer timestamp/ring semantics and a
|
||||
configured MQTT point-cloud downsample factor of four. This creates a concrete
|
||||
next integration target—an admitted onboard export or bag contract—but does
|
||||
not change the authority or input acceptance of existing `lio_pcl` recordings.
|
||||
The 1.27 m operator-height profile is likewise explicit and reproducible, but
|
||||
remains `operator-estimated` rather than runtime-calibrated.
|
||||
|
||||
## Boundary
|
||||
|
||||
```text
|
||||
|
|
|
|||
|
|
@ -34,7 +34,28 @@ The firmware-3 `lio_pcl` stream currently exposes:
|
|||
- a frame header with sequence, stamp and scaler;
|
||||
- a separate `T_map_from_lidar` pose stream.
|
||||
|
||||
It does not currently expose an admitted:
|
||||
The recorded XYZ values are metric. With the independent pose they can be
|
||||
expressed relative to the reported LiDAR pose and produce useful local
|
||||
distance diagnostics. What is not proven by that inverse transform is that the
|
||||
result is the original unregistered sweep with preserved beam origin,
|
||||
acquisition order and motion timing.
|
||||
|
||||
Static, read-only analysis of the K1 3.0.2 deployment artifacts additionally
|
||||
shows that the appliance internally:
|
||||
|
||||
- selects a Livox MID-360 adapter;
|
||||
- consumes `/livox/lidar` and `/imu`;
|
||||
- carries XYZ, intensity, timestamp and ring in its internal point type;
|
||||
- supports raw-packet and point/IMU callbacks in its MID-360 library;
|
||||
- publishes the external cloud after the LIO/modeling path;
|
||||
- configures the external MQTT point-cloud sample factor to four.
|
||||
|
||||
This proves a richer onboard data path exists. It does not prove that the
|
||||
current external MQTT contract preserves those fields, and private firmware
|
||||
artifacts remain outside Git. The redacted evidence is recorded in
|
||||
`docs/lab/005_K1_FW302_LIDAR_PIPELINE_20260725.redacted.md`.
|
||||
|
||||
The external contract does not currently expose an admitted:
|
||||
|
||||
- raw sensor-frame sweep;
|
||||
- per-point firing time;
|
||||
|
|
@ -170,8 +191,10 @@ memory, zero-copy or batching optimizations are accepted.
|
|||
host capture/receive times.
|
||||
- [x] Record explicitly absent ring, per-point time and IMU fields.
|
||||
- [x] Add immutable reports for sequence gaps, arrival jitter, points/frame,
|
||||
intensity distribution and pose coverage. Sensor-frame range remains
|
||||
explicitly unavailable because the source is a vendor map increment.
|
||||
intensity distribution and pose coverage. The sealed source report keeps
|
||||
native sensor-frame range explicitly unavailable because the source is a
|
||||
vendor map increment; later diagnostics may separately report pose-derived
|
||||
local distances without relabeling them as raw beam ranges.
|
||||
- [x] Compare source native capture vs persisted replay fields byte-for-byte on
|
||||
a frozen real slice.
|
||||
- [x] Publish the report to the React observation view without bundling a
|
||||
|
|
@ -201,6 +224,12 @@ quality line. It is not repaired or hidden by replay.
|
|||
E19 local-percentile ground proposal.
|
||||
- [x] Retain separate current/candidate ground and assigned masks for every
|
||||
source point; raw replay remains unchanged.
|
||||
- [x] Add a bounded point-aligned frame API and browser 3D review for
|
||||
intensity, current ground, candidate ground and disagreement.
|
||||
- [x] Record the K1 3.0.2 internal MID-360/LIO field path from static,
|
||||
redacted firmware evidence without changing device state.
|
||||
- [x] Bind physical height, applied vertical-origin offset and evidence class
|
||||
into every new benchmark identity.
|
||||
- [x] Seal latency, ground-fraction, algorithm-IoU and disagreement
|
||||
distributions in `missioncore.lidar-ground-benchmark/v1`.
|
||||
- [x] Create an immutable eight-frame annotation template in which every point
|
||||
|
|
@ -218,12 +247,21 @@ Patchwork++ classified only 0.53% ground at p50 with 0.26 ms p95 host latency.
|
|||
Their point-aligned ground IoU was 2.90% p50 and disagreement was 17.92% p50.
|
||||
These last two values compare algorithms; they are not accuracy metrics.
|
||||
|
||||
An additional operator-height diagnostic,
|
||||
`ground-benchmark-87cb3150701f7e21756f46ef5b6ce110df1e07720ef6dc2303c95520932cf43f`,
|
||||
binds the measured 1.27 m handheld height and applies the same explicit map-Z
|
||||
offset. Patchwork++ ground fraction rises from 0.53% to 2.47% p50; its p95
|
||||
latency remains 0.42 ms. Algorithm IoU is 4.07% p50 and disagreement is 19.13%
|
||||
p50. The run is useful for visual review, but its evidence class is
|
||||
`operator-estimated`, so input acceptance and production promotion stay false.
|
||||
|
||||
The result is an evidence-backed **do-not-promote** decision for the current K1
|
||||
feed. Patchwork++ is fast, but its input model assumes a sensor-centric scan
|
||||
and physical sensor height. K1 `lio_pcl` is a vendor-mapped increment, its
|
||||
physical sensor height is not encoded by the best-effort pose, and scan
|
||||
geometry remains unknown. Translating the cloud until Patchwork++ looks
|
||||
plausible would tune against the candidate and invalidate the comparison.
|
||||
physical sensor height is not encoded by the best-effort pose, and raw scan
|
||||
geometry is not exported. The measured 1.27 m estimate can be reproduced as a
|
||||
named diagnostic profile; tuning that offset until Patchwork++ merely looks
|
||||
plausible would still invalidate the comparison.
|
||||
|
||||
The independent-label exit remains open. It can be closed by reviewing the
|
||||
content-bound template
|
||||
|
|
|
|||
|
|
@ -13,6 +13,20 @@ independent best-effort pose and no admitted raw sweep or scan geometry. The
|
|||
pose describes vendor odometry; it does not prove the physical height of the
|
||||
LiDAR above terrain.
|
||||
|
||||
Static, read-only analysis of the K1 3.0.2 deployment artifacts narrows this
|
||||
boundary. K1 selects a Livox MID-360 adapter internally, consumes
|
||||
`/livox/lidar` plus `/imu`, and its point type carries XYZ, intensity,
|
||||
timestamp and ring. The configured external MQTT point-cloud sample factor is
|
||||
four, while the externally recorded `lio_pcl` is emitted after the LIO/modeling
|
||||
path. The scanner therefore measures real metric ranges and the appliance has a
|
||||
richer internal source; Mission Core has not yet admitted that internal source
|
||||
through a versioned export or bag contract.
|
||||
|
||||
For the handheld capture, the operator measured approximately 1.27 m from the
|
||||
LiDAR head to the ground. This is useful evidence, but it is neither a
|
||||
runtime-attested extrinsic nor proof that the vendor map Z origin coincides with
|
||||
terrain. It may be used only in an explicitly marked diagnostic normalization.
|
||||
|
||||
Treating a successful Patchwork++ call as a valid baseline would conflate API
|
||||
compatibility with input-domain compatibility. Choosing a synthetic Z
|
||||
translation until the output looks plausible would use the candidate itself to
|
||||
|
|
@ -34,7 +48,13 @@ define the normalization.
|
|||
8. Create an all-ignore, content-bound annotation template; only a separate
|
||||
human-reviewed generation may unlock ground IoU, curb/low-obstacle recall
|
||||
and reflection-noise rejection.
|
||||
9. Keep command, navigation and safety authority false.
|
||||
9. Bind sensor height, applied map vertical-origin offset and evidence class
|
||||
(`missing`, `operator-estimated` or `runtime-calibrated`) into the immutable
|
||||
benchmark identity.
|
||||
10. Publish one bounded, path-free point-aligned frame endpoint and render it
|
||||
in React/Three.js with current, candidate and disagreement masks. Heavy
|
||||
processing remains in the worker.
|
||||
11. Keep command, navigation and safety authority false.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
|
@ -45,6 +65,11 @@ define the normalization.
|
|||
threshold tuning around a mis-specified sensor model.
|
||||
- The same immutable benchmark/API/React surface can compare a future raw scan,
|
||||
replay or simulation provider without moving C++ processing into React.
|
||||
- Firmware evidence justifies implementing an admitted onboard raw
|
||||
point/IMU exporter or bag reader; it does not retroactively upgrade existing
|
||||
MQTT recordings to raw scans.
|
||||
- The 1.27 m run makes the effect of the operator estimate visible and
|
||||
repeatable, but remains diagnostic-only.
|
||||
|
||||
## Real diagnostic evidence
|
||||
|
||||
|
|
@ -60,10 +85,26 @@ processed 66 frames and 226,963 points:
|
|||
Algorithm ground IoU was 2.90% p50 and point disagreement was 17.92% p50.
|
||||
Neither is an accuracy metric. Independent labels are still missing.
|
||||
|
||||
The explicit operator-height variant
|
||||
`ground-benchmark-87cb3150701f7e21756f46ef5b6ce110df1e07720ef6dc2303c95520932cf43f`
|
||||
applied a 1.27 m map-Z offset and processed the same 66 frames and 226,963
|
||||
points:
|
||||
|
||||
| Measurement | Current local percentile | Patchwork++ |
|
||||
| --- | ---: | ---: |
|
||||
| Ground fraction p50 | 18.31% | 2.47% |
|
||||
| Host latency p95 | 11.85 ms | 0.42 ms |
|
||||
|
||||
Algorithm ground IoU was 4.07% p50 and point disagreement was 19.13% p50.
|
||||
This is evidence that height/origin normalization materially changes the
|
||||
candidate result, not evidence that either algorithm is accurate.
|
||||
|
||||
## References
|
||||
|
||||
- `src/k1link/compute/lidar_ground.py`
|
||||
- `experiments/perception/run_lidar_ground_benchmark.py`
|
||||
- `src/k1link/web/lidar_api.py`
|
||||
- `apps/control-station/src/workspaces/LidarQualityWorkspace.tsx`
|
||||
- `apps/control-station/src/workspaces/LidarGroundPointCloud.tsx`
|
||||
- `docs/lab/005_K1_FW302_LIDAR_PIPELINE_20260725.redacted.md`
|
||||
- `docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
# K1 firmware 3.0.2 LiDAR pipeline — redacted static analysis
|
||||
|
||||
Date: 2026-07-25
|
||||
Scope: owner-controlled LixelKity K1 firmware 3.0.2 full archive
|
||||
Method: offline configuration, ELF symbol and string inspection only
|
||||
Device writes, firmware upload, runtime process changes and network probes: none
|
||||
|
||||
## Evidence boundary
|
||||
|
||||
The retained firmware package contains deployable ARM64 binaries, libraries and
|
||||
configuration files, not the original C++ source tree. Non-stripped symbols,
|
||||
embedded build paths and protobuf descriptors still provide stronger pipeline
|
||||
evidence than the external MQTT contract alone. The private firmware archive
|
||||
and extracted filesystem remain outside Git; this note contains only sanitized
|
||||
derived facts and content hashes.
|
||||
|
||||
## Proven static facts
|
||||
|
||||
1. K1 selects `livox_mid360` as its LiDAR driver.
|
||||
2. The driver configuration exposes separate point and IMU inputs. The K1 LIO
|
||||
configuration consumes `/livox/lidar` and `/imu`.
|
||||
3. The internal converter uses a `PointIRT` representation containing XYZ,
|
||||
intensity, per-point timestamp and ring fields. The MID-360 library exposes
|
||||
raw packet processing, `PointXyzlt`, `PointFrame` and IMU callbacks.
|
||||
4. The K1 LIO preprocess contract admits `0.3–200 m`, a `0.1 s` sweep and
|
||||
`use_single_pcl: false`.
|
||||
5. The external MID-360 MQTT point-cloud branch declares
|
||||
`mqtt_pcl_samples: 4`; it is deliberately downsampled before transport.
|
||||
6. The modeling application receives raw `PointCloudMsg`, feeds `SlamCore`,
|
||||
and later sends point clouds from `LioResultMsg`. This supports classifying
|
||||
external `lio_pcl` as an LIO product rather than the original MID-360 scan.
|
||||
7. The firmware contains a K1 calibration candidate
|
||||
`lidar_imu_trans: [0.01176, -0.01865, 0.075]` with zero initial rotation.
|
||||
Static presence does not prove that this exact file was active in the
|
||||
recorded session, and it is not the sensor-to-ground installation height.
|
||||
|
||||
## Consequences for Mission Core
|
||||
|
||||
- Metric range is present in the scanner and internal point path.
|
||||
- The current MQTT `lio_pcl` evidence is useful for visualization, derived
|
||||
ground proposals and detector experiments, but must not be relabeled as raw.
|
||||
- A future raw-scan gate should capture the existing internal point and IMU
|
||||
products through an admitted onboard exporter or recorded bag boundary. It
|
||||
does not require replacing the physical scanner.
|
||||
- The operator-estimated `1.27 m` height belongs only to the handheld recording
|
||||
and remains diagnostic until a runtime calibration binds the optical origin,
|
||||
axes and installation.
|
||||
- Patchwork++ comparison must remain diagnostic on MQTT evidence even after
|
||||
applying the height estimate.
|
||||
|
||||
## Evidence hashes
|
||||
|
||||
| Evidence role | SHA-256 |
|
||||
| --- | --- |
|
||||
| LiDAR configuration | `cafca05b230dececfde45917f21eaad973491de4cbc2a9668b2fc01a6f79d0b2` |
|
||||
| K1 LIO configuration | `2b8f06bf95e429862a5559b1aefb39591e5a97cd41dbda89016faf8d05ab7471` |
|
||||
| K1 calibration configuration | `52db698b80d870b018461501a8582a84dee1aa0f269a9d96cdf96c4169dca95c` |
|
||||
| MID-360 driver library | `0a9266a337c8112f84728c9c7ae6a4a282e0c92e8ffefc1d43f2d2d54ef55c25` |
|
||||
| K1 LiDAR adapter library | `e1b8756474098d3a51e08e0690abc5397ea3e7422c16f0ef7f22d81509a96d85` |
|
||||
| ROS/internal message converter | `225d83fb2468f686a0382324aba595982e34d1dd1e5dc8d0959e1c15d3678c67` |
|
||||
| Modeling application | `780c9194a749b84b2f8e4c0ea7b297be24f5d8b5946f0315c6c9b74861425ec0` |
|
||||
|
|
@ -6,8 +6,10 @@ import json
|
|||
from pathlib import Path
|
||||
|
||||
from k1link.compute import (
|
||||
DEFAULT_GROUND_BENCHMARK_PROFILE,
|
||||
PATCHWORKPP_SOURCE_COMMIT,
|
||||
PATCHWORKPP_SOURCE_TAG,
|
||||
GroundBenchmarkProfile,
|
||||
LidarGroundBenchmarkV1,
|
||||
LidarReplayPackV2,
|
||||
PatchworkPPGroundSegmenter,
|
||||
|
|
@ -47,14 +49,40 @@ def _arguments() -> argparse.Namespace:
|
|||
"--patchwork-source-commit",
|
||||
default=PATCHWORKPP_SOURCE_COMMIT,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--profile-id",
|
||||
default=DEFAULT_GROUND_BENCHMARK_PROFILE.profile_id,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sensor-height-m",
|
||||
type=float,
|
||||
default=DEFAULT_GROUND_BENCHMARK_PROFILE.patchwork_sensor_height_proxy_m,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--map-vertical-origin-offset-m",
|
||||
type=float,
|
||||
default=(DEFAULT_GROUND_BENCHMARK_PROFILE.patchwork_map_vertical_origin_offset_m),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--height-evidence",
|
||||
choices=("missing", "operator-estimated", "runtime-calibrated"),
|
||||
default=DEFAULT_GROUND_BENCHMARK_PROFILE.patchwork_height_evidence,
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = _arguments()
|
||||
profile = GroundBenchmarkProfile(
|
||||
profile_id=arguments.profile_id,
|
||||
patchwork_sensor_height_proxy_m=arguments.sensor_height_m,
|
||||
patchwork_map_vertical_origin_offset_m=(arguments.map_vertical_origin_offset_m),
|
||||
patchwork_height_evidence=arguments.height_evidence,
|
||||
)
|
||||
replay = LidarReplayPackV2(arguments.replay_pack)
|
||||
try:
|
||||
patchwork = PatchworkPPGroundSegmenter.load(
|
||||
profile=profile,
|
||||
module_name=arguments.patchwork_module,
|
||||
source_tag=arguments.patchwork_source_tag,
|
||||
source_commit=arguments.patchwork_source_commit,
|
||||
|
|
@ -63,6 +91,7 @@ def main() -> int:
|
|||
replay,
|
||||
arguments.output_root,
|
||||
patchwork=patchwork,
|
||||
profile=profile,
|
||||
)
|
||||
annotation = (
|
||||
build_lidar_ground_annotation_template(
|
||||
|
|
@ -87,9 +116,7 @@ def main() -> int:
|
|||
"current": result.report["current"],
|
||||
"candidate": result.report["candidate"],
|
||||
"comparison": result.report["comparison"],
|
||||
"annotation_template_id": (
|
||||
annotation.name if annotation is not None else None
|
||||
),
|
||||
"annotation_template_id": (annotation.name if annotation is not None else None),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ from .lidar_ground import (
|
|||
LIDAR_GROUND_ANNOTATION_TEMPLATE_SCHEMA,
|
||||
LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA,
|
||||
LIDAR_GROUND_BENCHMARK_SCHEMA,
|
||||
LIDAR_GROUND_FRAME_SCHEMA,
|
||||
PATCHWORKPP_SOURCE_COMMIT,
|
||||
PATCHWORKPP_SOURCE_TAG,
|
||||
PATCHWORKPP_SOURCE_URL,
|
||||
|
|
@ -86,6 +87,7 @@ from .lidar_ground import (
|
|||
build_lidar_ground_annotation_template,
|
||||
build_lidar_ground_benchmark,
|
||||
lidar_ground_benchmark_catalog_item,
|
||||
lidar_ground_frame_detail,
|
||||
score_ground_labels,
|
||||
)
|
||||
from .lidar_replay import (
|
||||
|
|
@ -176,6 +178,7 @@ __all__ = [
|
|||
"LIDAR_GROUND_ANNOTATION_TEMPLATE_SCHEMA",
|
||||
"LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA",
|
||||
"LIDAR_GROUND_BENCHMARK_SCHEMA",
|
||||
"LIDAR_GROUND_FRAME_SCHEMA",
|
||||
"LIDAR_EVIDENCE_PROFILE_SCHEMA",
|
||||
"LIDAR_EQUIVALENCE_REPORT_SCHEMA",
|
||||
"LIDAR_QUALITY_REPORT_SCHEMA",
|
||||
|
|
@ -258,6 +261,7 @@ __all__ = [
|
|||
"build_lidar_replay_pack_v2",
|
||||
"build_lidar_ground_annotation_template",
|
||||
"build_lidar_ground_benchmark",
|
||||
"lidar_ground_frame_detail",
|
||||
"DetectionFrame",
|
||||
"ObjectDetection",
|
||||
"RecordedPerceptionOverlayError",
|
||||
|
|
|
|||
|
|
@ -23,25 +23,21 @@ from .lidar_contract import LidarContractError, sensor_frame_xyzi
|
|||
from .lidar_replay import LidarReplayPackV2
|
||||
|
||||
LIDAR_GROUND_BENCHMARK_SCHEMA: Final = "missioncore.lidar-ground-benchmark/v1"
|
||||
LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA: Final = (
|
||||
"missioncore.lidar-ground-benchmark-report/v1"
|
||||
)
|
||||
LIDAR_GROUND_ANNOTATION_TEMPLATE_SCHEMA: Final = (
|
||||
"missioncore.lidar-ground-annotation-template/v1"
|
||||
)
|
||||
LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA: Final = "missioncore.lidar-ground-benchmark-report/v1"
|
||||
LIDAR_GROUND_ANNOTATION_TEMPLATE_SCHEMA: Final = "missioncore.lidar-ground-annotation-template/v1"
|
||||
LIDAR_GROUND_FRAME_SCHEMA: Final = "missioncore.lidar-ground-frame/v1"
|
||||
LIDAR_GROUND_RESULTS_NAME: Final = "ground-results.npz"
|
||||
LIDAR_GROUND_REPORT_NAME: Final = "ground-report.json"
|
||||
LIDAR_GROUND_MANIFEST_NAME: Final = "manifest.json"
|
||||
LIDAR_GROUND_ANNOTATION_LABELS_NAME: Final = "labels-template.npz"
|
||||
MAX_GROUND_FRAME_POINTS: Final = 200_000
|
||||
|
||||
PATCHWORKPP_SOURCE_URL: Final = "https://github.com/url-kaist/patchwork-plusplus"
|
||||
PATCHWORKPP_SOURCE_TAG: Final = "v1.4.1"
|
||||
PATCHWORKPP_SOURCE_COMMIT: Final = "3e6903a1d5537a4cc2ace897b0bbb98a92d6014c"
|
||||
|
||||
_BENCHMARK_ID = re.compile(r"^ground-benchmark-[a-f0-9]{64}$")
|
||||
_ANNOTATION_TEMPLATE_ID = re.compile(
|
||||
r"^ground-annotation-template-[a-f0-9]{64}$"
|
||||
)
|
||||
_ANNOTATION_TEMPLATE_ID = re.compile(r"^ground-annotation-template-[a-f0-9]{64}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
_GIT_SHA1 = re.compile(r"^[a-f0-9]{40}$")
|
||||
|
||||
|
|
@ -64,9 +60,54 @@ class GroundBenchmarkProfile:
|
|||
current_maximum_above_ground_m: float = 0.12
|
||||
current_minimum_local_points: int = 8
|
||||
patchwork_sensor_height_proxy_m: float = 0.0
|
||||
patchwork_map_vertical_origin_offset_m: float = 0.0
|
||||
patchwork_height_evidence: str = "missing"
|
||||
patchwork_minimum_range_m: float = 0.1
|
||||
patchwork_maximum_range_m: float = 20.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
finite_values = (
|
||||
self.pose_binding_threshold_ms,
|
||||
self.current_cell_size_m,
|
||||
self.current_local_radius_m,
|
||||
self.current_lower_percentile,
|
||||
self.current_maximum_below_ground_m,
|
||||
self.current_maximum_above_ground_m,
|
||||
self.patchwork_sensor_height_proxy_m,
|
||||
self.patchwork_map_vertical_origin_offset_m,
|
||||
self.patchwork_minimum_range_m,
|
||||
self.patchwork_maximum_range_m,
|
||||
)
|
||||
if not all(math.isfinite(value) for value in finite_values):
|
||||
raise LidarGroundError("Ground benchmark profile must be finite")
|
||||
if (
|
||||
not 0 < self.pose_binding_threshold_ms <= 10_000
|
||||
or not 0 < self.current_cell_size_m <= 100
|
||||
or not 0 < self.current_local_radius_m <= 1_000
|
||||
or not 0 <= self.current_lower_percentile <= 100
|
||||
or not 0 <= self.current_maximum_below_ground_m <= 100
|
||||
or not 0 <= self.current_maximum_above_ground_m <= 100
|
||||
or not 1 <= self.current_minimum_local_points <= 1_000_000
|
||||
or not 0 <= self.patchwork_sensor_height_proxy_m <= 10
|
||||
or not -10 <= self.patchwork_map_vertical_origin_offset_m <= 10
|
||||
or not 0 <= self.patchwork_minimum_range_m < self.patchwork_maximum_range_m <= 1_000
|
||||
or self.patchwork_height_evidence
|
||||
not in {"missing", "operator-estimated", "runtime-calibrated"}
|
||||
):
|
||||
raise LidarGroundError("Ground benchmark profile is invalid")
|
||||
if self.patchwork_height_evidence == "missing" and (
|
||||
self.patchwork_sensor_height_proxy_m != 0
|
||||
or self.patchwork_map_vertical_origin_offset_m != 0
|
||||
):
|
||||
raise LidarGroundError("Ground benchmark cannot apply height without height evidence")
|
||||
if (
|
||||
self.patchwork_height_evidence != "missing"
|
||||
and self.patchwork_sensor_height_proxy_m <= 0
|
||||
):
|
||||
raise LidarGroundError(
|
||||
"Ground benchmark height evidence requires a positive sensor height"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": "missioncore.lidar-ground-benchmark-profile/v1",
|
||||
|
|
@ -88,6 +129,8 @@ class GroundBenchmarkProfile:
|
|||
"candidate": {
|
||||
"provider_id": "patchworkpp/v1.4.1",
|
||||
"sensor_height_proxy_m": self.patchwork_sensor_height_proxy_m,
|
||||
"map_vertical_origin_offset_m": (self.patchwork_map_vertical_origin_offset_m),
|
||||
"height_evidence": self.patchwork_height_evidence,
|
||||
"minimum_range_m": self.patchwork_minimum_range_m,
|
||||
"maximum_range_m": self.patchwork_maximum_range_m,
|
||||
"enable_rnr": True,
|
||||
|
|
@ -97,7 +140,9 @@ class GroundBenchmarkProfile:
|
|||
"input_normalization": {
|
||||
"current": "vendor-map-xyz",
|
||||
"candidate": "best-effort-map-to-lidar-pose-inversion",
|
||||
"physical_sensor_height_known": False,
|
||||
"physical_sensor_height_known": (
|
||||
self.patchwork_height_evidence == "runtime-calibrated"
|
||||
),
|
||||
"sensor_scan_geometry_known": False,
|
||||
},
|
||||
"authority": {
|
||||
|
|
@ -146,9 +191,7 @@ class LocalPercentileGroundSegmenter:
|
|||
cell_keys = np.floor(xyz[:, :2] / cell_size).astype(np.int64)
|
||||
unique_cells, inverse = np.unique(cell_keys, axis=0, return_inverse=True)
|
||||
ground = np.zeros(points.shape[0], dtype=np.bool_)
|
||||
global_ground_z = float(
|
||||
np.percentile(xyz[:, 2], self.profile.current_lower_percentile)
|
||||
)
|
||||
global_ground_z = float(np.percentile(xyz[:, 2], self.profile.current_lower_percentile))
|
||||
radius_squared = self.profile.current_local_radius_m**2
|
||||
for cell_index in range(unique_cells.shape[0]):
|
||||
point_indices = np.flatnonzero(inverse == cell_index)
|
||||
|
|
@ -172,9 +215,8 @@ class LocalPercentileGroundSegmenter:
|
|||
)
|
||||
z = xyz[point_indices, 2]
|
||||
ground[point_indices] = (
|
||||
(z >= ground_z - self.profile.current_maximum_below_ground_m)
|
||||
& (z <= ground_z + self.profile.current_maximum_above_ground_m)
|
||||
)
|
||||
z >= ground_z - self.profile.current_maximum_below_ground_m
|
||||
) & (z <= ground_z + self.profile.current_maximum_above_ground_m)
|
||||
latency_ms = (time.perf_counter_ns() - started) / 1_000_000
|
||||
return GroundSegmentation(
|
||||
ground_mask=ground,
|
||||
|
|
@ -235,9 +277,7 @@ class PatchworkPPGroundSegmenter:
|
|||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except ImportError as exc:
|
||||
raise LidarGroundError(
|
||||
"Pinned Patchwork++ Python binding is unavailable"
|
||||
) from exc
|
||||
raise LidarGroundError("Pinned Patchwork++ Python binding is unavailable") from exc
|
||||
return cls(
|
||||
module,
|
||||
profile,
|
||||
|
|
@ -295,8 +335,7 @@ class LidarGroundBenchmarkV1:
|
|||
self.manifest.get("schema_version") != LIDAR_GROUND_BENCHMARK_SCHEMA
|
||||
or self.identity.get("schema_version") != LIDAR_GROUND_BENCHMARK_SCHEMA
|
||||
or not isinstance(identity_sha256, str)
|
||||
or hashlib.sha256(_canonical_json(self.identity)).hexdigest()
|
||||
!= identity_sha256
|
||||
or hashlib.sha256(_canonical_json(self.identity)).hexdigest() != identity_sha256
|
||||
or self.root.name != f"ground-benchmark-{identity_sha256}"
|
||||
or self.manifest.get("benchmark_id") != self.root.name
|
||||
):
|
||||
|
|
@ -313,19 +352,15 @@ class LidarGroundBenchmarkV1:
|
|||
labels = _object(self.report.get("labels"), "ground labels")
|
||||
decision = _object(self.report.get("decision"), "ground decision")
|
||||
if (
|
||||
_ground_logical_sha256(self.arrays)
|
||||
!= self.identity.get("logical_results_sha256")
|
||||
or self.report.get("schema_version")
|
||||
!= LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA
|
||||
_ground_logical_sha256(self.arrays) != self.identity.get("logical_results_sha256")
|
||||
or self.report.get("schema_version") != LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA
|
||||
or self.report.get("benchmark_id") != self.root.name
|
||||
or self.report.get("replay_pack_id")
|
||||
!= self.identity.get("replay_pack_id")
|
||||
or self.report.get("replay_pack_id") != self.identity.get("replay_pack_id")
|
||||
or self.report.get("status") != "diagnostic-only"
|
||||
or input_domain.get("accepted") is not False
|
||||
or labels.get("status") != "missing-independent-review"
|
||||
or labels.get("metrics_available") is not False
|
||||
or decision.get("status")
|
||||
!= "do-not-promote-on-current-vendor-map"
|
||||
or decision.get("status") != "do-not-promote-on-current-vendor-map"
|
||||
or decision.get("production_promotion") is not False
|
||||
):
|
||||
raise LidarGroundError("Ground benchmark report is incompatible")
|
||||
|
|
@ -370,14 +405,9 @@ def build_lidar_ground_benchmark(
|
|||
)
|
||||
for frame_index in range(replay.point_frame_count):
|
||||
point = replay.point_frame(frame_index)
|
||||
nearest_pose_index = int(
|
||||
np.argmin(np.abs(pose_times - point.received_monotonic_ns))
|
||||
)
|
||||
nearest_pose_index = int(np.argmin(np.abs(pose_times - point.received_monotonic_ns)))
|
||||
pose = replay.pose_frame(nearest_pose_index)
|
||||
delta_ms = (
|
||||
abs(pose.received_monotonic_ns - point.received_monotonic_ns)
|
||||
/ 1_000_000
|
||||
)
|
||||
delta_ms = abs(pose.received_monotonic_ns - point.received_monotonic_ns) / 1_000_000
|
||||
if delta_ms > profile.pose_binding_threshold_ms:
|
||||
raise LidarGroundError("Ground A/B point frame has no admitted pose")
|
||||
try:
|
||||
|
|
@ -387,6 +417,9 @@ def build_lidar_ground_benchmark(
|
|||
)
|
||||
except LidarContractError as exc:
|
||||
raise LidarGroundError("Ground A/B sensor conversion failed") from exc
|
||||
if profile.patchwork_map_vertical_origin_offset_m:
|
||||
candidate_input = candidate_input.copy()
|
||||
candidate_input[:, 2] -= profile.patchwork_map_vertical_origin_offset_m
|
||||
current_input = np.empty((point.xyz_map.shape[0], 4), dtype=np.float32)
|
||||
current_input[:, :3] = point.xyz_map.astype(np.float32)
|
||||
current_input[:, 3] = point.intensity.astype(np.float32) / 255.0
|
||||
|
|
@ -404,26 +437,14 @@ def build_lidar_ground_benchmark(
|
|||
pose_delta_ms.append(delta_ms)
|
||||
current_fraction.append(float(np.mean(current_result.ground_mask)))
|
||||
candidate_fraction.append(float(np.mean(candidate_result.ground_mask)))
|
||||
candidate_assigned_fraction.append(
|
||||
float(np.mean(candidate_result.assigned_mask))
|
||||
)
|
||||
candidate_assigned_fraction.append(float(np.mean(candidate_result.assigned_mask)))
|
||||
intersection = int(
|
||||
np.count_nonzero(
|
||||
current_result.ground_mask & candidate_result.ground_mask
|
||||
)
|
||||
)
|
||||
union = int(
|
||||
np.count_nonzero(
|
||||
current_result.ground_mask | candidate_result.ground_mask
|
||||
)
|
||||
np.count_nonzero(current_result.ground_mask & candidate_result.ground_mask)
|
||||
)
|
||||
union = int(np.count_nonzero(current_result.ground_mask | candidate_result.ground_mask))
|
||||
inter_provider_iou.append(float(intersection / union) if union else 1.0)
|
||||
disagreement_fraction.append(
|
||||
float(
|
||||
np.mean(
|
||||
current_result.ground_mask != candidate_result.ground_mask
|
||||
)
|
||||
)
|
||||
float(np.mean(current_result.ground_mask != candidate_result.ground_mask))
|
||||
)
|
||||
|
||||
arrays: dict[str, npt.NDArray[Any]] = {
|
||||
|
|
@ -477,11 +498,19 @@ def build_lidar_ground_benchmark(
|
|||
"input_domain": {
|
||||
"accepted": False,
|
||||
"representation": replay.profile.representation.value,
|
||||
"physical_sensor_height_known": False,
|
||||
"physical_sensor_height_known": (
|
||||
profile.patchwork_height_evidence == "runtime-calibrated"
|
||||
),
|
||||
"sensor_scan_geometry_known": replay.profile.scan_geometry_known,
|
||||
"normalization": {
|
||||
"sensor_height_m": profile.patchwork_sensor_height_proxy_m,
|
||||
"map_vertical_origin_offset_m": (profile.patchwork_map_vertical_origin_offset_m),
|
||||
"height_evidence": profile.patchwork_height_evidence,
|
||||
},
|
||||
"reason": (
|
||||
"Patchwork++ expects a sensor-centric scan and physical sensor "
|
||||
"height; K1 lio_pcl is a vendor-mapped increment."
|
||||
"Patchwork++ expects a sensor-centric scan; K1 lio_pcl is an "
|
||||
"externally downsampled LIO/map product. Height correction does "
|
||||
"not admit the external feed as a raw MID-360 scan."
|
||||
),
|
||||
},
|
||||
"labels": {
|
||||
|
|
@ -498,9 +527,7 @@ def build_lidar_ground_benchmark(
|
|||
"current": {
|
||||
"provider": dict(current.identity),
|
||||
"ground_fraction": _distribution(current_fraction),
|
||||
"assigned_fraction": _distribution(
|
||||
[1.0] * replay.point_frame_count
|
||||
),
|
||||
"assigned_fraction": _distribution([1.0] * replay.point_frame_count),
|
||||
"latency_ms": _distribution(current_latency),
|
||||
},
|
||||
"candidate": {
|
||||
|
|
@ -510,12 +537,8 @@ def build_lidar_ground_benchmark(
|
|||
"latency_ms": _distribution(candidate_latency),
|
||||
},
|
||||
"comparison": {
|
||||
"algorithm_to_algorithm_ground_iou": _distribution(
|
||||
inter_provider_iou
|
||||
),
|
||||
"ground_disagreement_fraction": _distribution(
|
||||
disagreement_fraction
|
||||
),
|
||||
"algorithm_to_algorithm_ground_iou": _distribution(inter_provider_iou),
|
||||
"ground_disagreement_fraction": _distribution(disagreement_fraction),
|
||||
"is_accuracy_metric": False,
|
||||
},
|
||||
"decision": {
|
||||
|
|
@ -596,10 +619,7 @@ def build_lidar_ground_annotation_template(
|
|||
)
|
||||
source_offsets = np.asarray(replay.arrays["point_offsets"], dtype=np.int64)
|
||||
selected_counts = np.asarray(
|
||||
[
|
||||
int(source_offsets[index + 1] - source_offsets[index])
|
||||
for index in selected
|
||||
],
|
||||
[int(source_offsets[index + 1] - source_offsets[index]) for index in selected],
|
||||
dtype="<i8",
|
||||
)
|
||||
selected_offsets = np.concatenate(
|
||||
|
|
@ -739,6 +759,101 @@ def lidar_ground_benchmark_catalog_item(
|
|||
}
|
||||
|
||||
|
||||
def lidar_ground_frame_detail(
|
||||
benchmark: LidarGroundBenchmarkV1,
|
||||
replay: LidarReplayPackV2,
|
||||
frame_index: int,
|
||||
) -> dict[str, object]:
|
||||
"""Return one bounded, point-aligned frame for browser diagnostic review."""
|
||||
|
||||
if (
|
||||
benchmark.identity.get("replay_pack_id") != replay.pack_id
|
||||
or benchmark.identity.get("replay_logical_content_sha256")
|
||||
!= replay.identity.get("logical_content_sha256")
|
||||
or benchmark.identity.get("point_frames") != replay.point_frame_count
|
||||
or benchmark.identity.get("points") != replay.point_count
|
||||
):
|
||||
raise LidarGroundError("Ground benchmark is not bound to this replay pack")
|
||||
if not 0 <= frame_index < replay.point_frame_count:
|
||||
raise IndexError(frame_index)
|
||||
|
||||
benchmark_offsets = np.asarray(
|
||||
benchmark.arrays["point_offsets"],
|
||||
dtype=np.int64,
|
||||
)
|
||||
replay_offsets = np.asarray(replay.arrays["point_offsets"], dtype=np.int64)
|
||||
if not np.array_equal(benchmark_offsets, replay_offsets):
|
||||
raise LidarGroundError("Ground benchmark point offsets changed")
|
||||
start = int(benchmark_offsets[frame_index])
|
||||
end = int(benchmark_offsets[frame_index + 1])
|
||||
point_count = end - start
|
||||
if not 0 < point_count <= MAX_GROUND_FRAME_POINTS:
|
||||
raise LidarGroundError("Ground frame point count exceeds viewer limit")
|
||||
|
||||
frame = replay.point_frame(frame_index)
|
||||
capture_sequence = int(benchmark.arrays["point_capture_sequence"][frame_index])
|
||||
if capture_sequence != frame.capture_sequence:
|
||||
raise LidarGroundError("Ground frame capture sequence changed")
|
||||
xyz = np.asarray(frame.xyz_map, dtype=np.float64)
|
||||
intensity = np.asarray(frame.intensity, dtype=np.uint8)
|
||||
current_ground = np.asarray(
|
||||
benchmark.arrays["current_ground"][start:end],
|
||||
dtype=np.uint8,
|
||||
)
|
||||
current_assigned = np.asarray(
|
||||
benchmark.arrays["current_assigned"][start:end],
|
||||
dtype=np.uint8,
|
||||
)
|
||||
candidate_ground = np.asarray(
|
||||
benchmark.arrays["candidate_ground"][start:end],
|
||||
dtype=np.uint8,
|
||||
)
|
||||
candidate_assigned = np.asarray(
|
||||
benchmark.arrays["candidate_assigned"][start:end],
|
||||
dtype=np.uint8,
|
||||
)
|
||||
disagreement = (current_ground != candidate_ground).astype(np.uint8)
|
||||
if (
|
||||
xyz.shape != (point_count, 3)
|
||||
or intensity.shape != (point_count,)
|
||||
or not np.isfinite(xyz).all()
|
||||
):
|
||||
raise LidarGroundError("Ground frame replay content is incompatible")
|
||||
|
||||
return {
|
||||
"schema_version": LIDAR_GROUND_FRAME_SCHEMA,
|
||||
"benchmark_id": benchmark.benchmark_id,
|
||||
"replay_pack_id": replay.pack_id,
|
||||
"session_id": replay.identity["session_id"],
|
||||
"frame_index": frame_index,
|
||||
"frame_count": replay.point_frame_count,
|
||||
"capture_sequence": capture_sequence,
|
||||
"point_count": point_count,
|
||||
"coordinate_frame": "map",
|
||||
"distance_unit": "m",
|
||||
"points_xyz_m": xyz.tolist(),
|
||||
"intensity_0_255": intensity.astype(np.int64).tolist(),
|
||||
"masks": {
|
||||
"current_ground": current_ground.astype(np.int64).tolist(),
|
||||
"current_assigned": current_assigned.astype(np.int64).tolist(),
|
||||
"candidate_ground": candidate_ground.astype(np.int64).tolist(),
|
||||
"candidate_assigned": candidate_assigned.astype(np.int64).tolist(),
|
||||
"disagreement": disagreement.astype(np.int64).tolist(),
|
||||
},
|
||||
"counts": {
|
||||
"current_ground": int(np.count_nonzero(current_ground)),
|
||||
"candidate_ground": int(np.count_nonzero(candidate_ground)),
|
||||
"disagreement": int(np.count_nonzero(disagreement)),
|
||||
},
|
||||
"access": "read-only",
|
||||
"ground_truth": False,
|
||||
"authority": {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _validate_annotation_template(root: Path) -> None:
|
||||
resolved = root.resolve(strict=True)
|
||||
if (
|
||||
|
|
@ -752,8 +867,7 @@ def _validate_annotation_template(root: Path) -> None:
|
|||
identity_sha256 = manifest.get("identity_sha256")
|
||||
if (
|
||||
manifest.get("schema_version") != LIDAR_GROUND_ANNOTATION_TEMPLATE_SCHEMA
|
||||
or identity.get("schema_version")
|
||||
!= LIDAR_GROUND_ANNOTATION_TEMPLATE_SCHEMA
|
||||
or identity.get("schema_version") != LIDAR_GROUND_ANNOTATION_TEMPLATE_SCHEMA
|
||||
or not isinstance(identity_sha256, str)
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or resolved.name != f"ground-annotation-template-{identity_sha256}"
|
||||
|
|
@ -774,8 +888,7 @@ def _validate_annotation_template(root: Path) -> None:
|
|||
set(arrays.files) != required
|
||||
or arrays["labels"].dtype != np.dtype("u1")
|
||||
or np.any(arrays["labels"] != 0)
|
||||
or _ground_logical_sha256(arrays)
|
||||
!= identity.get("logical_content_sha256")
|
||||
or _ground_logical_sha256(arrays) != identity.get("logical_content_sha256")
|
||||
):
|
||||
raise LidarGroundError("Ground annotation template content is invalid")
|
||||
finally:
|
||||
|
|
@ -836,13 +949,8 @@ def _xyzi(value: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]:
|
|||
|
||||
|
||||
def _indices(value: npt.NDArray[np.int64], count: int, label: str) -> None:
|
||||
if (
|
||||
value.size
|
||||
and (
|
||||
np.any(value < 0)
|
||||
or np.any(value >= count)
|
||||
or np.unique(value).shape[0] != value.shape[0]
|
||||
)
|
||||
if value.size and (
|
||||
np.any(value < 0) or np.any(value >= count) or np.unique(value).shape[0] != value.shape[0]
|
||||
):
|
||||
raise LidarGroundError(f"{label} indices are invalid")
|
||||
|
||||
|
|
@ -889,11 +997,7 @@ def _ratio(numerator: int, denominator: int) -> float | None:
|
|||
|
||||
|
||||
def _nonnegative_int(value: object, label: str) -> int:
|
||||
if (
|
||||
not isinstance(value, int)
|
||||
or isinstance(value, bool)
|
||||
or value < 0
|
||||
):
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise LidarGroundError(f"{label} must be a non-negative integer")
|
||||
return value
|
||||
|
||||
|
|
@ -983,9 +1087,7 @@ def _validate_artifacts(
|
|||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or not all(
|
||||
isinstance(key, str) for key in value
|
||||
):
|
||||
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
||||
raise LidarGroundError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
|
@ -1021,8 +1123,4 @@ def _sha256(path: Path) -> str:
|
|||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return (
|
||||
datetime.now(UTC)
|
||||
.isoformat(timespec="milliseconds")
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
|
|
|||
|
|
@ -14,14 +14,13 @@ from k1link.compute import (
|
|||
LidarReplayError,
|
||||
LidarReplayPackV2,
|
||||
lidar_ground_benchmark_catalog_item,
|
||||
lidar_ground_frame_detail,
|
||||
lidar_pack_catalog_item,
|
||||
lidar_pack_detail,
|
||||
)
|
||||
|
||||
LIDAR_CATALOG_SCHEMA: Final = "missioncore.lidar-replay-pack-catalog/v1"
|
||||
LIDAR_GROUND_CATALOG_SCHEMA: Final = (
|
||||
"missioncore.lidar-ground-benchmark-catalog/v1"
|
||||
)
|
||||
LIDAR_GROUND_CATALOG_SCHEMA: Final = "missioncore.lidar-ground-benchmark-catalog/v1"
|
||||
_PACK_ID = re.compile(r"^lidar-replay-pack-[a-f0-9]{64}$")
|
||||
_BENCHMARK_ID = re.compile(r"^ground-benchmark-[a-f0-9]{64}$")
|
||||
RootProvider = Callable[[], Path | None]
|
||||
|
|
@ -143,8 +142,7 @@ def build_lidar_router(
|
|||
(
|
||||
candidate
|
||||
for candidate in root.iterdir()
|
||||
if candidate.is_dir()
|
||||
and _BENCHMARK_ID.fullmatch(candidate.name) is not None
|
||||
if candidate.is_dir() and _BENCHMARK_ID.fullmatch(candidate.name) is not None
|
||||
),
|
||||
key=lambda candidate: candidate.stat().st_mtime_ns,
|
||||
reverse=True,
|
||||
|
|
@ -153,13 +151,8 @@ def build_lidar_router(
|
|||
try:
|
||||
benchmark = LidarGroundBenchmarkV1(candidate)
|
||||
try:
|
||||
if (
|
||||
pack_id is None
|
||||
or benchmark.identity.get("replay_pack_id") == pack_id
|
||||
):
|
||||
items.append(
|
||||
lidar_ground_benchmark_catalog_item(benchmark)
|
||||
)
|
||||
if pack_id is None or benchmark.identity.get("replay_pack_id") == pack_id:
|
||||
items.append(lidar_ground_benchmark_catalog_item(benchmark))
|
||||
finally:
|
||||
benchmark.close()
|
||||
except (LidarGroundError, OSError):
|
||||
|
|
@ -196,9 +189,7 @@ def build_lidar_router(
|
|||
benchmark = LidarGroundBenchmarkV1(candidate)
|
||||
try:
|
||||
return {
|
||||
"schema_version": (
|
||||
"missioncore.lidar-ground-benchmark-detail/v1"
|
||||
),
|
||||
"schema_version": ("missioncore.lidar-ground-benchmark-detail/v1"),
|
||||
"benchmark": lidar_ground_benchmark_catalog_item(benchmark),
|
||||
"report": benchmark.report,
|
||||
"access": "read-only",
|
||||
|
|
@ -211,4 +202,71 @@ def build_lidar_router(
|
|||
detail="LiDAR ground benchmark не прошёл проверку целостности",
|
||||
) from exc
|
||||
|
||||
@router.get("/ground-benchmarks/{benchmark_id}/frames/{frame_index}")
|
||||
def get_lidar_ground_frame(
|
||||
benchmark_id: str,
|
||||
frame_index: int,
|
||||
) -> dict[str, object]:
|
||||
if _BENCHMARK_ID.fullmatch(benchmark_id) is None or frame_index < 0:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="LiDAR ground frame не найден",
|
||||
)
|
||||
ground_root = ground_root_provider()
|
||||
replay_root = root_provider()
|
||||
if ground_root is None or not ground_root.is_dir():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="LiDAR ground storage не настроен",
|
||||
)
|
||||
if replay_root is None or not replay_root.is_dir():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="LiDAR replay storage не настроен",
|
||||
)
|
||||
benchmark_path = ground_root / benchmark_id
|
||||
if not benchmark_path.is_dir():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="LiDAR ground benchmark не найден",
|
||||
)
|
||||
try:
|
||||
benchmark = LidarGroundBenchmarkV1(benchmark_path)
|
||||
try:
|
||||
replay_pack_id = benchmark.identity.get("replay_pack_id")
|
||||
if (
|
||||
not isinstance(replay_pack_id, str)
|
||||
or _PACK_ID.fullmatch(replay_pack_id) is None
|
||||
):
|
||||
raise LidarGroundError("Ground benchmark replay identity is invalid")
|
||||
replay_path = replay_root / replay_pack_id
|
||||
if not replay_path.is_dir():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Связанный LiDAR replay pack не найден",
|
||||
)
|
||||
replay = LidarReplayPackV2(replay_path)
|
||||
try:
|
||||
return lidar_ground_frame_detail(
|
||||
benchmark,
|
||||
replay,
|
||||
frame_index,
|
||||
)
|
||||
finally:
|
||||
replay.close()
|
||||
finally:
|
||||
benchmark.close()
|
||||
except IndexError as exc:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="LiDAR ground frame не найден",
|
||||
) from exc
|
||||
except HTTPException:
|
||||
raise
|
||||
except (LidarGroundError, LidarReplayError, OSError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="LiDAR ground frame не прошёл проверку целостности",
|
||||
) from exc
|
||||
|
||||
return router
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from fastapi.routing import APIRoute
|
|||
from k1link.compute import (
|
||||
DEFAULT_GROUND_BENCHMARK_PROFILE,
|
||||
K1_LIDAR_PACK_V2_PROFILE,
|
||||
GroundBenchmarkProfile,
|
||||
GroundSegmentation,
|
||||
LidarGroundBenchmarkV1,
|
||||
LidarGroundError,
|
||||
|
|
@ -21,6 +22,7 @@ from k1link.compute import (
|
|||
PatchworkPPGroundSegmenter,
|
||||
build_lidar_ground_annotation_template,
|
||||
build_lidar_ground_benchmark,
|
||||
lidar_ground_frame_detail,
|
||||
score_ground_labels,
|
||||
)
|
||||
from k1link.web.lidar_api import build_lidar_router
|
||||
|
|
@ -147,9 +149,7 @@ def test_local_percentile_ground_is_point_aligned_and_non_mutating() -> None:
|
|||
xyzi = np.column_stack((xyz, np.ones(xyz.shape[0]))).astype(np.float32)
|
||||
unchanged = xyzi.copy()
|
||||
|
||||
result = LocalPercentileGroundSegmenter(
|
||||
profile=DEFAULT_GROUND_BENCHMARK_PROFILE
|
||||
).segment(xyzi)
|
||||
result = LocalPercentileGroundSegmenter(profile=DEFAULT_GROUND_BENCHMARK_PROFILE).segment(xyzi)
|
||||
|
||||
assert result.ground_mask.shape == (10,)
|
||||
assert result.assigned_mask.all()
|
||||
|
|
@ -169,10 +169,7 @@ def test_ground_benchmark_is_immutable_diagnostic_evidence(tmp_path: Path) -> No
|
|||
assert result.report["status"] == "diagnostic-only"
|
||||
assert result.report["input_domain"]["accepted"] is False
|
||||
assert result.report["labels"]["metrics_available"] is False
|
||||
assert (
|
||||
result.report["decision"]["status"]
|
||||
== "do-not-promote-on-current-vendor-map"
|
||||
)
|
||||
assert result.report["decision"]["status"] == "do-not-promote-on-current-vendor-map"
|
||||
assert result.arrays["current_ground"].shape == (20,)
|
||||
assert result.arrays["candidate_ground"].shape == (20,)
|
||||
finally:
|
||||
|
|
@ -195,6 +192,66 @@ def test_ground_benchmark_is_immutable_diagnostic_evidence(tmp_path: Path) -> No
|
|||
LidarGroundBenchmarkV1(output)
|
||||
|
||||
|
||||
def test_operator_height_correction_is_explicit_and_stays_diagnostic(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
replay = _Replay()
|
||||
observed_z: list[np.ndarray] = []
|
||||
|
||||
class RecordingCandidate(_Candidate):
|
||||
def segment(self, xyzi: np.ndarray) -> GroundSegmentation:
|
||||
observed_z.append(xyzi[:, 2].copy())
|
||||
return super().segment(xyzi)
|
||||
|
||||
profile = GroundBenchmarkProfile(
|
||||
profile_id="k1-handheld-operator-height-ground-ab/v1",
|
||||
patchwork_sensor_height_proxy_m=1.27,
|
||||
patchwork_map_vertical_origin_offset_m=1.27,
|
||||
patchwork_height_evidence="operator-estimated",
|
||||
)
|
||||
output = build_lidar_ground_benchmark(
|
||||
replay, # type: ignore[arg-type]
|
||||
tmp_path / "benchmarks",
|
||||
patchwork=RecordingCandidate(),
|
||||
profile=profile,
|
||||
)
|
||||
result = LidarGroundBenchmarkV1(output)
|
||||
try:
|
||||
normalization = result.report["input_domain"]["normalization"]
|
||||
assert normalization == {
|
||||
"height_evidence": "operator-estimated",
|
||||
"map_vertical_origin_offset_m": 1.27,
|
||||
"sensor_height_m": 1.27,
|
||||
}
|
||||
assert result.report["input_domain"]["physical_sensor_height_known"] is False
|
||||
frame = lidar_ground_frame_detail(
|
||||
result,
|
||||
replay, # type: ignore[arg-type]
|
||||
0,
|
||||
)
|
||||
finally:
|
||||
result.close()
|
||||
|
||||
np.testing.assert_allclose(
|
||||
observed_z[0],
|
||||
replay._points[0][:, 2] - 1.27,
|
||||
atol=1e-6,
|
||||
)
|
||||
assert frame["schema_version"] == "missioncore.lidar-ground-frame/v1"
|
||||
assert frame["point_count"] == 10
|
||||
assert frame["coordinate_frame"] == "map"
|
||||
assert frame["ground_truth"] is False
|
||||
assert frame["masks"]["disagreement"]
|
||||
assert str(tmp_path) not in repr(frame)
|
||||
|
||||
|
||||
def test_ground_profile_rejects_unattested_height_configuration() -> None:
|
||||
with pytest.raises(LidarGroundError, match="without height evidence"):
|
||||
GroundBenchmarkProfile(patchwork_sensor_height_proxy_m=1.27)
|
||||
with pytest.raises(LidarGroundError, match="positive sensor height"):
|
||||
GroundBenchmarkProfile(patchwork_height_evidence="operator-estimated")
|
||||
|
||||
|
||||
def test_annotation_template_starts_all_ignore_and_never_ground_truth(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
|
@ -234,6 +291,10 @@ def test_ground_api_is_read_only_path_free_and_pack_filtered(
|
|||
router,
|
||||
"/api/v1/lidar/ground-benchmarks/{benchmark_id}",
|
||||
)
|
||||
_endpoint(
|
||||
router,
|
||||
"/api/v1/lidar/ground-benchmarks/{benchmark_id}/frames/{frame_index}",
|
||||
)
|
||||
|
||||
catalog = catalog_route( # type: ignore[operator]
|
||||
pack_id=replay.pack_id,
|
||||
|
|
@ -241,10 +302,7 @@ def test_ground_api_is_read_only_path_free_and_pack_filtered(
|
|||
)
|
||||
detail = detail_route(benchmark_id=output.name) # type: ignore[operator]
|
||||
|
||||
assert (
|
||||
catalog["schema_version"]
|
||||
== "missioncore.lidar-ground-benchmark-catalog/v1"
|
||||
)
|
||||
assert catalog["schema_version"] == "missioncore.lidar-ground-benchmark-catalog/v1"
|
||||
assert catalog["valid_total"] == 1
|
||||
assert catalog["items"][0]["decision"]["production_promotion"] is False
|
||||
assert detail["benchmark"]["benchmark_id"] == output.name
|
||||
|
|
|
|||
Loading…
Reference in New Issue