feat(lidar): add point-aligned ground review

This commit is contained in:
DCCONSTRUCTIONS 2026-07-25 09:44:38 +03:00
parent 75a3e669d9
commit 2dfb34ef21
15 changed files with 1548 additions and 140 deletions

View File

@ -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."),
);
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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");

View File

@ -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

View File

@ -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

View File

@ -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`

View File

@ -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.3200 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` |

View File

@ -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,

View File

@ -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",

View File

@ -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")

View File

@ -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

View File

@ -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