From 2dfb34ef21477cb87c76f39736da046444499448 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 25 Jul 2026 09:44:38 +0300 Subject: [PATCH] feat(lidar): add point-aligned ground review --- .../src/core/lidar/replayQuality.ts | 218 +++++++++++++ .../control-station/src/styles/responsive.css | 31 ++ .../control-station/src/styles/workspaces.css | 203 +++++++++++++ .../src/workspaces/LidarGroundPointCloud.tsx | 286 ++++++++++++++++++ .../src/workspaces/LidarQualityWorkspace.tsx | 183 ++++++++++- .../test/lidarReplayQuality.test.mjs | 111 ++++++- docs/10_EXTERNAL_PERCEPTION_WORKER.md | 15 + docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md | 50 ++- .../0020-patchworkpp-vendor-map-boundary.md | 43 ++- ..._FW302_LIDAR_PIPELINE_20260725.redacted.md | 61 ++++ .../perception/run_lidar_ground_benchmark.py | 33 +- src/k1link/compute/__init__.py | 4 + src/k1link/compute/lidar_ground.py | 282 +++++++++++------ src/k1link/web/lidar_api.py | 88 +++++- tests/test_lidar_ground.py | 80 ++++- 15 files changed, 1548 insertions(+), 140 deletions(-) create mode 100644 apps/control-station/src/workspaces/LidarGroundPointCloud.tsx create mode 100644 docs/lab/005_K1_FW302_LIDAR_PIPELINE_20260725.redacted.md diff --git a/apps/control-station/src/core/lidar/replayQuality.ts b/apps/control-station/src/core/lidar/replayQuality.ts index 620ea8d..583aa92 100644 --- a/apps/control-station/src/core/lidar/replayQuality.ts +++ b/apps/control-station/src/core/lidar/replayQuality.ts @@ -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 { + 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."), + ); +} diff --git a/apps/control-station/src/styles/responsive.css b/apps/control-station/src/styles/responsive.css index ff34d0d..46273f8 100644 --- a/apps/control-station/src/styles/responsive.css +++ b/apps/control-station/src/styles/responsive.css @@ -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; } diff --git a/apps/control-station/src/styles/workspaces.css b/apps/control-station/src/styles/workspaces.css index eb927b2..6e59507 100644 --- a/apps/control-station/src/styles/workspaces.css +++ b/apps/control-station/src/styles/workspaces.css @@ -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; } diff --git a/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx b/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx new file mode 100644 index 0000000..e89aa15 --- /dev/null +++ b/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx @@ -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(null); + const geometryRef = useRef(null); + const materialRef = useRef(null); + const cameraRef = useRef(null); + const controlsRef = useRef(null); + const [renderError, setRenderError] = useState(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 ( +
+
+ {renderError ? ( +

{renderError}

+ ) : null} +
+
+ + ЛКМ · вращение + Колесо · масштаб + ПКМ · панорама +
+
+ ); +} diff --git a/apps/control-station/src/workspaces/LidarQualityWorkspace.tsx b/apps/control-station/src/workspaces/LidarQualityWorkspace.tsx index d23e35a..3b84aee 100644 --- a/apps/control-station/src/workspaces/LidarQualityWorkspace.tsx +++ b/apps/control-station/src/workspaces/LidarQualityWorkspace.tsx @@ -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(null); const [groundBenchmark, setGroundBenchmark] = useState(null); + const [groundFrame, setGroundFrame] = useState(null); + const [groundFrameIndex, setGroundFrameIndex] = useState(0); + const [groundFrameLoading, setGroundFrameLoading] = useState(false); + const [groundFrameError, setGroundFrameError] = useState(null); + const [groundViewMode, setGroundViewMode] = + useState("disagreement"); const [selectedPackId, setSelectedPackId] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 (
@@ -306,28 +351,144 @@ export function LidarQualityWorkspace({
+
+
+
+ + POINT-ALIGNED REVIEW + +

Покадровое облако и маски

+

+ Один и тот же map-frame XYZ, разные диагностические + раскраски. Маски не изменяют replay. +

+
+
+ + Кадр {groundFrameIndex + 1} / {groundBenchmark.frames} + + + {groundFrame + ? `${groundFrame.pointCount.toLocaleString("ru-RU")} точек` + : groundFrameLoading + ? "Загрузка…" + : "Нет данных"} + +
+
+
+
+ {([ + ["intensity", "Интенсивность"], + ["current", "Current"], + ["candidate", "Patchwork++"], + ["disagreement", "Расхождения"], + ] as const).map(([mode, label]) => ( + + ))} +
+
+ + + setGroundFrameIndex(Number(event.currentTarget.value)) + } + /> + +
+
+ {groundFrame ? ( + + ) : ( +
+ + {groundFrameError ? "Frame недоступен" : "Читаем frame"} + +

{groundFrameError ?? "Проверяем point alignment и masks."}

+
+ )} +
+ Оба считают ground + Только current + Только Patchwork++ + Оба non-ground +
+
Входной контракт не принят

- Patchwork++ ожидает sensor-centric scan и физическую высоту - сенсора; текущий point feed является vendor-mapped increment. + Firmware 3.0.2 подтверждает внутренний MID-360 raw feed, + но текущий MQTT остаётся прореженным LIO/map-продуктом. +

+
+
+ + {groundNormalization?.heightEvidence === "operator-estimated" + ? "Высота применена диагностически" + : "Высота не принята"} + +

+ {groundNormalization?.heightEvidence === "operator-estimated" + ? `Ручной замер ${formatNumber( + groundNormalization.sensorHeightM, + 2, + )} м сдвигает optical origin, но не заменяет runtime calibration.` + : "Нужна привязка optical origin к map и штатной установке."}

Разметка не принята

- IoU, curb recall, low-obstacle recall и reflection-noise - rejection появятся только после независимого human review. -

-
-
- Не продвигать -

- Следующий gate: human-reviewed annotation subset или - принятый raw sensor scan с физической высотой сенсора. + Ground IoU и recall появятся только после human review; + визуальное расхождение само по себе не является accuracy.

diff --git a/apps/control-station/test/lidarReplayQuality.test.mjs b/apps/control-station/test/lidarReplayQuality.test.mjs index fe88bd8..171219a 100644 --- a/apps/control-station/test/lidarReplayQuality.test.mjs +++ b/apps/control-station/test/lidarReplayQuality.test.mjs @@ -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"); diff --git a/docs/10_EXTERNAL_PERCEPTION_WORKER.md b/docs/10_EXTERNAL_PERCEPTION_WORKER.md index ffae798..ea70fcc 100644 --- a/docs/10_EXTERNAL_PERCEPTION_WORKER.md +++ b/docs/10_EXTERNAL_PERCEPTION_WORKER.md @@ -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 diff --git a/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md b/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md index b256a7c..578da76 100644 --- a/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md +++ b/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md @@ -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 diff --git a/docs/adr/0020-patchworkpp-vendor-map-boundary.md b/docs/adr/0020-patchworkpp-vendor-map-boundary.md index 516a480..5e755ad 100644 --- a/docs/adr/0020-patchworkpp-vendor-map-boundary.md +++ b/docs/adr/0020-patchworkpp-vendor-map-boundary.md @@ -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` diff --git a/docs/lab/005_K1_FW302_LIDAR_PIPELINE_20260725.redacted.md b/docs/lab/005_K1_FW302_LIDAR_PIPELINE_20260725.redacted.md new file mode 100644 index 0000000..646ab7c --- /dev/null +++ b/docs/lab/005_K1_FW302_LIDAR_PIPELINE_20260725.redacted.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` | diff --git a/experiments/perception/run_lidar_ground_benchmark.py b/experiments/perception/run_lidar_ground_benchmark.py index 5529247..f8873f9 100644 --- a/experiments/perception/run_lidar_ground_benchmark.py +++ b/experiments/perception/run_lidar_ground_benchmark.py @@ -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, diff --git a/src/k1link/compute/__init__.py b/src/k1link/compute/__init__.py index 83e957d..4430145 100644 --- a/src/k1link/compute/__init__.py +++ b/src/k1link/compute/__init__.py @@ -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", diff --git a/src/k1link/compute/lidar_ground.py b/src/k1link/compute/lidar_ground.py index fc7febc..9dbcd0e 100644 --- a/src/k1link/compute/lidar_ground.py +++ b/src/k1link/compute/lidar_ground.py @@ -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=" 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") diff --git a/src/k1link/web/lidar_api.py b/src/k1link/web/lidar_api.py index 453bfff..3d5b923 100644 --- a/src/k1link/web/lidar_api.py +++ b/src/k1link/web/lidar_api.py @@ -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 diff --git a/tests/test_lidar_ground.py b/tests/test_lidar_ground.py index f13e467..6435286 100644 --- a/tests/test_lidar_ground.py +++ b/tests/test_lidar_ground.py @@ -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