feat(lidar): add point-aligned ground review
This commit is contained in:
parent
75a3e669d9
commit
2dfb34ef21
|
|
@ -77,6 +77,11 @@ export interface LidarGroundBenchmark {
|
||||||
physicalSensorHeightKnown: boolean;
|
physicalSensorHeightKnown: boolean;
|
||||||
sensorScanGeometryKnown: boolean;
|
sensorScanGeometryKnown: boolean;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
normalization: {
|
||||||
|
sensorHeightM: number;
|
||||||
|
mapVerticalOriginOffsetM: number;
|
||||||
|
heightEvidence: "missing" | "operator-estimated" | "runtime-calibrated";
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
labels: {
|
labels: {
|
||||||
status: "missing-independent-review";
|
status: "missing-independent-review";
|
||||||
|
|
@ -115,6 +120,33 @@ export interface LidarGroundBenchmarkCatalog {
|
||||||
items: LidarGroundBenchmark[];
|
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 LidarReplayContractError extends Error {}
|
||||||
|
|
||||||
export class LidarReplayApiError extends Error {
|
export class LidarReplayApiError extends Error {
|
||||||
|
|
@ -178,6 +210,20 @@ function boolean(value: unknown, label: string): boolean {
|
||||||
return value;
|
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 {
|
function distribution(value: unknown, label: string): LidarDistribution {
|
||||||
const source = record(value, label);
|
const source = record(value, label);
|
||||||
return {
|
return {
|
||||||
|
|
@ -355,6 +401,9 @@ function groundBenchmark(value: unknown): LidarGroundBenchmark {
|
||||||
throw new LidarReplayContractError("Ground benchmark status несовместим");
|
throw new LidarReplayContractError("Ground benchmark status несовместим");
|
||||||
}
|
}
|
||||||
const inputDomain = record(source.input_domain, "input_domain");
|
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 labels = record(source.labels, "labels");
|
||||||
const comparison = record(source.comparison, "comparison");
|
const comparison = record(source.comparison, "comparison");
|
||||||
const decision = record(source.decision, "decision");
|
const decision = record(source.decision, "decision");
|
||||||
|
|
@ -397,6 +446,33 @@ function groundBenchmark(value: unknown): LidarGroundBenchmark {
|
||||||
"input_domain.sensor_scan_geometry_known",
|
"input_domain.sensor_scan_geometry_known",
|
||||||
),
|
),
|
||||||
reason: string(inputDomain.reason, "input_domain.reason"),
|
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: {
|
labels: {
|
||||||
status: "missing-independent-review",
|
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(
|
async function responseJson(
|
||||||
response: Response,
|
response: Response,
|
||||||
fallback: string,
|
fallback: string,
|
||||||
|
|
@ -521,3 +713,29 @@ export async function fetchLidarGroundBenchmarks(
|
||||||
await responseJson(response, "Не удалось получить LiDAR ground benchmark."),
|
await responseJson(response, "Не удалось получить LiDAR ground benchmark."),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchLidarGroundFrame(
|
||||||
|
benchmarkId: string,
|
||||||
|
frameIndex: number,
|
||||||
|
options: { signal?: AbortSignal; fetcher?: LidarFetch } = {},
|
||||||
|
): Promise<LidarGroundFrame> {
|
||||||
|
if (
|
||||||
|
!SAFE_GROUND_BENCHMARK_ID.test(benchmarkId)
|
||||||
|
|| !Number.isInteger(frameIndex)
|
||||||
|
|| frameIndex < 0
|
||||||
|
) {
|
||||||
|
throw new LidarReplayContractError("Некорректный LiDAR ground frame");
|
||||||
|
}
|
||||||
|
const fetcher = options.fetcher ?? fetch;
|
||||||
|
const response = await fetcher(
|
||||||
|
`/api/v1/lidar/ground-benchmarks/${benchmarkId}/frames/${frameIndex}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal: options.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return parseLidarGroundFrame(
|
||||||
|
await responseJson(response, "Не удалось получить LiDAR ground frame."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,37 @@
|
||||||
grid-template-columns: 1fr;
|
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 {
|
.polygon-run-identity dl {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1020,6 +1020,209 @@
|
||||||
padding-top: 0.7rem;
|
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 {
|
.lidar-ground-empty {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,286 @@
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||||
|
|
||||||
|
import type { LidarGroundFrame } from "../core/lidar/replayQuality";
|
||||||
|
|
||||||
|
export type LidarGroundViewMode =
|
||||||
|
| "intensity"
|
||||||
|
| "current"
|
||||||
|
| "candidate"
|
||||||
|
| "disagreement";
|
||||||
|
|
||||||
|
interface LidarGroundPointCloudProps {
|
||||||
|
frame: LidarGroundFrame;
|
||||||
|
mode: LidarGroundViewMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRgb(
|
||||||
|
target: Float32Array,
|
||||||
|
offset: number,
|
||||||
|
red: number,
|
||||||
|
green: number,
|
||||||
|
blue: number,
|
||||||
|
) {
|
||||||
|
target[offset] = red;
|
||||||
|
target[offset + 1] = green;
|
||||||
|
target[offset + 2] = blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameColors(
|
||||||
|
frame: LidarGroundFrame,
|
||||||
|
mode: LidarGroundViewMode,
|
||||||
|
): Float32Array {
|
||||||
|
const colors = new Float32Array(frame.pointCount * 3);
|
||||||
|
for (let index = 0; index < frame.pointCount; index += 1) {
|
||||||
|
const offset = index * 3;
|
||||||
|
const current = frame.masks.currentGround[index] === 1;
|
||||||
|
const candidate = frame.masks.candidateGround[index] === 1;
|
||||||
|
const candidateAssigned = frame.masks.candidateAssigned[index] === 1;
|
||||||
|
if (mode === "intensity") {
|
||||||
|
const intensity = frame.intensity0To255[index] / 255;
|
||||||
|
setRgb(
|
||||||
|
colors,
|
||||||
|
offset,
|
||||||
|
0.12 + intensity * 0.74,
|
||||||
|
0.24 + intensity * 0.68,
|
||||||
|
0.34 + intensity * 0.6,
|
||||||
|
);
|
||||||
|
} else if (mode === "current") {
|
||||||
|
setRgb(
|
||||||
|
colors,
|
||||||
|
offset,
|
||||||
|
current ? 0.73 : 0.29,
|
||||||
|
current ? 1 : 0.36,
|
||||||
|
current ? 0.29 : 0.43,
|
||||||
|
);
|
||||||
|
} else if (mode === "candidate") {
|
||||||
|
if (!candidateAssigned) {
|
||||||
|
setRgb(colors, offset, 1, 0.24, 0.32);
|
||||||
|
} else {
|
||||||
|
setRgb(
|
||||||
|
colors,
|
||||||
|
offset,
|
||||||
|
candidate ? 0.24 : 0.29,
|
||||||
|
candidate ? 0.84 : 0.36,
|
||||||
|
candidate ? 1 : 0.43,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (current && candidate) {
|
||||||
|
setRgb(colors, offset, 0.73, 1, 0.29);
|
||||||
|
} else if (current) {
|
||||||
|
setRgb(colors, offset, 1, 0.64, 0.18);
|
||||||
|
} else if (candidate) {
|
||||||
|
setRgb(colors, offset, 0.24, 0.84, 1);
|
||||||
|
} else {
|
||||||
|
setRgb(colors, offset, 0.24, 0.29, 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LidarGroundPointCloud({
|
||||||
|
frame,
|
||||||
|
mode,
|
||||||
|
}: LidarGroundPointCloudProps) {
|
||||||
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const geometryRef = useRef<THREE.BufferGeometry | null>(null);
|
||||||
|
const materialRef = useRef<THREE.PointsMaterial | null>(null);
|
||||||
|
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||||
|
const controlsRef = useRef<OrbitControls | null>(null);
|
||||||
|
const [renderError, setRenderError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const host = hostRef.current;
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
let renderer: THREE.WebGLRenderer;
|
||||||
|
try {
|
||||||
|
renderer = new THREE.WebGLRenderer({
|
||||||
|
antialias: true,
|
||||||
|
alpha: true,
|
||||||
|
powerPreference: "high-performance",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setRenderError("Браузер не смог создать WebGL-сцену LiDAR.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
renderer.setClearColor(0x071018, 0.96);
|
||||||
|
renderer.domElement.setAttribute(
|
||||||
|
"aria-label",
|
||||||
|
"Интерактивное облако ground segmentation",
|
||||||
|
);
|
||||||
|
host.prepend(renderer.domElement);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.fog = new THREE.FogExp2(0x071018, 0.035);
|
||||||
|
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 1_000);
|
||||||
|
camera.position.set(6, 4.5, 6);
|
||||||
|
cameraRef.current = camera;
|
||||||
|
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.08;
|
||||||
|
controls.enablePan = true;
|
||||||
|
controls.enableZoom = true;
|
||||||
|
controls.minDistance = 0.15;
|
||||||
|
controls.maxDistance = 200;
|
||||||
|
controls.minPolarAngle = 0;
|
||||||
|
controls.maxPolarAngle = Math.PI;
|
||||||
|
controls.target.set(0, 0.5, 0);
|
||||||
|
controls.update();
|
||||||
|
controlsRef.current = controls;
|
||||||
|
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometryRef.current = geometry;
|
||||||
|
const material = new THREE.PointsMaterial({
|
||||||
|
size: 0.035,
|
||||||
|
sizeAttenuation: true,
|
||||||
|
vertexColors: true,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.96,
|
||||||
|
depthWrite: true,
|
||||||
|
});
|
||||||
|
materialRef.current = material;
|
||||||
|
scene.add(new THREE.Points(geometry, material));
|
||||||
|
|
||||||
|
const grid = new THREE.GridHelper(24, 48, 0x3c7cff, 0x233747);
|
||||||
|
const gridMaterials = Array.isArray(grid.material)
|
||||||
|
? grid.material
|
||||||
|
: [grid.material];
|
||||||
|
gridMaterials.forEach((gridMaterial) => {
|
||||||
|
gridMaterial.transparent = true;
|
||||||
|
gridMaterial.opacity = 0.3;
|
||||||
|
});
|
||||||
|
scene.add(grid);
|
||||||
|
|
||||||
|
const axes = new THREE.AxesHelper(0.8);
|
||||||
|
axes.position.set(-0.05, 0.02, -0.05);
|
||||||
|
scene.add(axes);
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const width = Math.max(host.clientWidth, 1);
|
||||||
|
const height = Math.max(host.clientHeight, 1);
|
||||||
|
camera.aspect = width / height;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(width, height, false);
|
||||||
|
};
|
||||||
|
const observer = new ResizeObserver(resize);
|
||||||
|
observer.observe(host);
|
||||||
|
resize();
|
||||||
|
|
||||||
|
let animationFrame = 0;
|
||||||
|
const render = () => {
|
||||||
|
animationFrame = window.requestAnimationFrame(render);
|
||||||
|
controls.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(animationFrame);
|
||||||
|
observer.disconnect();
|
||||||
|
controls.dispose();
|
||||||
|
geometry.dispose();
|
||||||
|
material.dispose();
|
||||||
|
grid.geometry.dispose();
|
||||||
|
gridMaterials.forEach((gridMaterial) => gridMaterial.dispose());
|
||||||
|
axes.geometry.dispose();
|
||||||
|
const axesMaterials = Array.isArray(axes.material)
|
||||||
|
? axes.material
|
||||||
|
: [axes.material];
|
||||||
|
axesMaterials.forEach((axesMaterial) => axesMaterial.dispose());
|
||||||
|
renderer.dispose();
|
||||||
|
renderer.domElement.remove();
|
||||||
|
geometryRef.current = null;
|
||||||
|
materialRef.current = null;
|
||||||
|
cameraRef.current = null;
|
||||||
|
controlsRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const geometry = geometryRef.current;
|
||||||
|
const material = materialRef.current;
|
||||||
|
const camera = cameraRef.current;
|
||||||
|
const controls = controlsRef.current;
|
||||||
|
if (!geometry || !material || !camera || !controls) return;
|
||||||
|
|
||||||
|
const positions = new Float32Array(frame.pointCount * 3);
|
||||||
|
let minimumX = Number.POSITIVE_INFINITY;
|
||||||
|
let maximumX = Number.NEGATIVE_INFINITY;
|
||||||
|
let minimumY = Number.POSITIVE_INFINITY;
|
||||||
|
let maximumY = Number.NEGATIVE_INFINITY;
|
||||||
|
let minimumZ = Number.POSITIVE_INFINITY;
|
||||||
|
let maximumZ = Number.NEGATIVE_INFINITY;
|
||||||
|
frame.pointsXyzM.forEach(([x, y, z]) => {
|
||||||
|
minimumX = Math.min(minimumX, x);
|
||||||
|
maximumX = Math.max(maximumX, x);
|
||||||
|
minimumY = Math.min(minimumY, y);
|
||||||
|
maximumY = Math.max(maximumY, y);
|
||||||
|
minimumZ = Math.min(minimumZ, z);
|
||||||
|
maximumZ = Math.max(maximumZ, z);
|
||||||
|
});
|
||||||
|
const centerX = (minimumX + maximumX) / 2;
|
||||||
|
const centerY = (minimumY + maximumY) / 2;
|
||||||
|
frame.pointsXyzM.forEach(([x, y, z], index) => {
|
||||||
|
const offset = index * 3;
|
||||||
|
positions[offset] = x - centerX;
|
||||||
|
positions[offset + 1] = z - minimumZ;
|
||||||
|
positions[offset + 2] = -(y - centerY);
|
||||||
|
});
|
||||||
|
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||||
|
geometry.computeBoundingSphere();
|
||||||
|
const radius = Math.max(geometry.boundingSphere?.radius ?? 1, 0.2);
|
||||||
|
material.size = THREE.MathUtils.clamp(radius / 155, 0.014, 0.075);
|
||||||
|
|
||||||
|
const targetHeight = Math.max((maximumZ - minimumZ) * 0.35, 0.15);
|
||||||
|
const distance = Math.max(radius * 1.8, 1.2);
|
||||||
|
controls.target.set(0, targetHeight, 0);
|
||||||
|
camera.position.set(distance, distance * 0.72, distance);
|
||||||
|
camera.near = Math.max(distance / 1_000, 0.005);
|
||||||
|
camera.far = Math.max(distance * 100, 100);
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
controls.update();
|
||||||
|
}, [frame]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const geometry = geometryRef.current;
|
||||||
|
if (!geometry) return;
|
||||||
|
geometry.setAttribute(
|
||||||
|
"color",
|
||||||
|
new THREE.BufferAttribute(frameColors(frame, mode), 3),
|
||||||
|
);
|
||||||
|
geometry.attributes.color.needsUpdate = true;
|
||||||
|
}, [frame, mode]);
|
||||||
|
|
||||||
|
const resetCamera = () => {
|
||||||
|
const geometry = geometryRef.current;
|
||||||
|
const camera = cameraRef.current;
|
||||||
|
const controls = controlsRef.current;
|
||||||
|
if (!geometry || !camera || !controls) return;
|
||||||
|
const radius = Math.max(geometry.boundingSphere?.radius ?? 1, 0.2);
|
||||||
|
const distance = Math.max(radius * 1.8, 1.2);
|
||||||
|
camera.position.set(distance, distance * 0.72, distance);
|
||||||
|
controls.target.set(0, radius * 0.18, 0);
|
||||||
|
controls.update();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lidar-ground-scene" data-testid="lidar-ground-scene">
|
||||||
|
<div ref={hostRef} className="lidar-ground-scene__viewport">
|
||||||
|
{renderError ? (
|
||||||
|
<p className="lidar-ground-scene__error">{renderError}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="lidar-ground-scene__toolbar">
|
||||||
|
<button type="button" onClick={resetCamera}>Сбросить ракурс</button>
|
||||||
|
<span>ЛКМ · вращение</span>
|
||||||
|
<span>Колесо · масштаб</span>
|
||||||
|
<span>ПКМ · панорама</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,16 +6,22 @@ import {
|
||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
fetchLidarGroundFrame,
|
||||||
fetchLidarGroundBenchmarks,
|
fetchLidarGroundBenchmarks,
|
||||||
fetchLidarReplayCatalog,
|
fetchLidarReplayCatalog,
|
||||||
fetchLidarReplayDetail,
|
fetchLidarReplayDetail,
|
||||||
type LidarGroundBenchmark,
|
type LidarGroundBenchmark,
|
||||||
|
type LidarGroundFrame,
|
||||||
type LidarReplayCatalog,
|
type LidarReplayCatalog,
|
||||||
type LidarReplayDetail,
|
type LidarReplayDetail,
|
||||||
type LidarStageReadiness,
|
type LidarStageReadiness,
|
||||||
} from "../core/lidar/replayQuality";
|
} from "../core/lidar/replayQuality";
|
||||||
import { MetricCard } from "../components/MetricCard";
|
import { MetricCard } from "../components/MetricCard";
|
||||||
import type { WorkspaceDefinition } from "../productModel";
|
import type { WorkspaceDefinition } from "../productModel";
|
||||||
|
import {
|
||||||
|
LidarGroundPointCloud,
|
||||||
|
type LidarGroundViewMode,
|
||||||
|
} from "./LidarGroundPointCloud";
|
||||||
|
|
||||||
function formatNumber(value: number | null, digits = 1): string {
|
function formatNumber(value: number | null, digits = 1): string {
|
||||||
if (value === null) return "—";
|
if (value === null) return "—";
|
||||||
|
|
@ -57,6 +63,12 @@ export function LidarQualityWorkspace({
|
||||||
const [detail, setDetail] = useState<LidarReplayDetail | null>(null);
|
const [detail, setDetail] = useState<LidarReplayDetail | null>(null);
|
||||||
const [groundBenchmark, setGroundBenchmark] =
|
const [groundBenchmark, setGroundBenchmark] =
|
||||||
useState<LidarGroundBenchmark | null>(null);
|
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 [selectedPackId, setSelectedPackId] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -77,6 +89,7 @@ export function LidarQualityWorkspace({
|
||||||
if (!target) {
|
if (!target) {
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
setGroundBenchmark(null);
|
setGroundBenchmark(null);
|
||||||
|
setGroundFrame(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [nextDetail, groundCatalog] = await Promise.all([
|
const [nextDetail, groundCatalog] = await Promise.all([
|
||||||
|
|
@ -91,10 +104,12 @@ export function LidarQualityWorkspace({
|
||||||
setSelectedPackId(target);
|
setSelectedPackId(target);
|
||||||
setDetail(nextDetail);
|
setDetail(nextDetail);
|
||||||
setGroundBenchmark(groundCatalog.items[0] ?? null);
|
setGroundBenchmark(groundCatalog.items[0] ?? null);
|
||||||
|
setGroundFrameIndex(0);
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
setGroundBenchmark(null);
|
setGroundBenchmark(null);
|
||||||
|
setGroundFrame(null);
|
||||||
setError(errorMessage(loadError));
|
setError(errorMessage(loadError));
|
||||||
} finally {
|
} finally {
|
||||||
if (!controller.signal.aborted) setLoading(false);
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
|
|
@ -103,6 +118,36 @@ export function LidarQualityWorkspace({
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [reloadGeneration, selectedPackId]);
|
}, [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 (
|
return (
|
||||||
<div className="standard-workspace lidar-quality-workspace">
|
<div className="standard-workspace lidar-quality-workspace">
|
||||||
<section className="workspace-lead workspace-lead--compact">
|
<section className="workspace-lead workspace-lead--compact">
|
||||||
|
|
@ -306,28 +351,144 @@ export function LidarQualityWorkspace({
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 className="lidar-ground-gates">
|
||||||
<div>
|
<div>
|
||||||
<StatusBadge tone="danger">
|
<StatusBadge tone="danger">
|
||||||
Входной контракт не принят
|
Входной контракт не принят
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
<p>
|
<p>
|
||||||
Patchwork++ ожидает sensor-centric scan и физическую высоту
|
Firmware 3.0.2 подтверждает внутренний MID-360 raw feed,
|
||||||
сенсора; текущий point feed является vendor-mapped increment.
|
но текущий 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<StatusBadge tone="warning">Разметка не принята</StatusBadge>
|
<StatusBadge tone="warning">Разметка не принята</StatusBadge>
|
||||||
<p>
|
<p>
|
||||||
IoU, curb recall, low-obstacle recall и reflection-noise
|
Ground IoU и recall появятся только после human review;
|
||||||
rejection появятся только после независимого human review.
|
визуальное расхождение само по себе не является accuracy.
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<StatusBadge tone="danger">Не продвигать</StatusBadge>
|
|
||||||
<p>
|
|
||||||
Следующий gate: human-reviewed annotation subset или
|
|
||||||
принятый raw sensor scan с физической высотой сенсора.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@ let server;
|
||||||
let parseLidarReplayCatalog;
|
let parseLidarReplayCatalog;
|
||||||
let parseLidarReplayDetail;
|
let parseLidarReplayDetail;
|
||||||
let parseLidarGroundBenchmarkCatalog;
|
let parseLidarGroundBenchmarkCatalog;
|
||||||
|
let parseLidarGroundFrame;
|
||||||
let fetchLidarReplayCatalog;
|
let fetchLidarReplayCatalog;
|
||||||
let fetchLidarReplayDetail;
|
let fetchLidarReplayDetail;
|
||||||
let fetchLidarGroundBenchmarks;
|
let fetchLidarGroundBenchmarks;
|
||||||
|
let fetchLidarGroundFrame;
|
||||||
let LidarReplayContractError;
|
let LidarReplayContractError;
|
||||||
let workspaceById;
|
let workspaceById;
|
||||||
|
|
||||||
|
|
@ -23,9 +25,11 @@ before(async () => {
|
||||||
parseLidarReplayCatalog,
|
parseLidarReplayCatalog,
|
||||||
parseLidarReplayDetail,
|
parseLidarReplayDetail,
|
||||||
parseLidarGroundBenchmarkCatalog,
|
parseLidarGroundBenchmarkCatalog,
|
||||||
|
parseLidarGroundFrame,
|
||||||
fetchLidarReplayCatalog,
|
fetchLidarReplayCatalog,
|
||||||
fetchLidarReplayDetail,
|
fetchLidarReplayDetail,
|
||||||
fetchLidarGroundBenchmarks,
|
fetchLidarGroundBenchmarks,
|
||||||
|
fetchLidarGroundFrame,
|
||||||
LidarReplayContractError,
|
LidarReplayContractError,
|
||||||
} = await server.ssrLoadModule("/src/core/lidar/replayQuality.ts"));
|
} = await server.ssrLoadModule("/src/core/lidar/replayQuality.ts"));
|
||||||
({ workspaceById } = await server.ssrLoadModule("/src/productModel.ts"));
|
({ workspaceById } = await server.ssrLoadModule("/src/productModel.ts"));
|
||||||
|
|
@ -176,6 +180,11 @@ function groundCatalog(overrides = {}) {
|
||||||
representation: "vendor-map-increment",
|
representation: "vendor-map-increment",
|
||||||
physical_sensor_height_known: false,
|
physical_sensor_height_known: false,
|
||||||
sensor_scan_geometry_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.",
|
reason: "Patchwork++ expects sensor-centric scans.",
|
||||||
},
|
},
|
||||||
labels: {
|
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) {
|
function jsonResponse(payload, status = 200) {
|
||||||
return new Response(JSON.stringify(payload), {
|
return new Response(JSON.stringify(payload), {
|
||||||
status,
|
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].inputDomain.accepted, false);
|
||||||
assert.equal(parsed.items[0].labels.metricsAvailable, false);
|
assert.equal(parsed.items[0].labels.metricsAvailable, false);
|
||||||
assert.equal(parsed.items[0].candidate.groundFraction.p50, 0.005);
|
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);
|
assert.equal(parsed.items[0].decision.productionPromotion, false);
|
||||||
|
|
||||||
const promoted = groundCatalog();
|
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 () => {
|
test("LiDAR fetchers use read-only endpoints and workspace is registered", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const fetcher = async (input, init) => {
|
const fetcher = async (input, init) => {
|
||||||
calls.push({ input: String(input), method: init?.method });
|
calls.push({ input: String(input), method: init?.method });
|
||||||
if (String(input).includes("ground-benchmarks")) {
|
if (String(input).includes("ground-benchmarks")) {
|
||||||
return jsonResponse(groundCatalog());
|
return String(input).includes("/frames/")
|
||||||
|
? jsonResponse(groundFrame())
|
||||||
|
: jsonResponse(groundCatalog());
|
||||||
}
|
}
|
||||||
return String(input).includes(packId)
|
return String(input).includes(packId)
|
||||||
? jsonResponse(detail())
|
? jsonResponse(detail())
|
||||||
|
|
@ -276,10 +375,16 @@ test("LiDAR fetchers use read-only endpoints and workspace is registered", async
|
||||||
const parsedCatalog = await fetchLidarReplayCatalog({ fetcher });
|
const parsedCatalog = await fetchLidarReplayCatalog({ fetcher });
|
||||||
const parsedDetail = await fetchLidarReplayDetail(packId, { fetcher });
|
const parsedDetail = await fetchLidarReplayDetail(packId, { fetcher });
|
||||||
const ground = await fetchLidarGroundBenchmarks(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(parsedCatalog.validTotal, 1);
|
||||||
assert.equal(parsedDetail.pack.packId, packId);
|
assert.equal(parsedDetail.pack.packId, packId);
|
||||||
assert.equal(ground.validTotal, 1);
|
assert.equal(ground.validTotal, 1);
|
||||||
|
assert.equal(frame.pointCount, 3);
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
{ input: "/api/v1/lidar/replay-packs?limit=50", method: "GET" },
|
{ input: "/api/v1/lidar/replay-packs?limit=50", method: "GET" },
|
||||||
{ input: `/api/v1/lidar/replay-packs/${packId}`, 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`,
|
input: `/api/v1/lidar/ground-benchmarks?pack_id=${packId}&limit=20`,
|
||||||
method: "GET",
|
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").root, "data");
|
||||||
assert.equal(workspaceById("lidar-quality").kind, "lidar-quality");
|
assert.equal(workspaceById("lidar-quality").kind, "lidar-quality");
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,21 @@ read-only `/api/v1/lidar/ground-benchmarks` surface therefore publishes
|
||||||
`production_promotion=false` until an independent annotation generation or an
|
`production_promotion=false` until an independent annotation generation or an
|
||||||
admitted raw scan closes the input gate.
|
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
|
## Boundary
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,28 @@ The firmware-3 `lio_pcl` stream currently exposes:
|
||||||
- a frame header with sequence, stamp and scaler;
|
- a frame header with sequence, stamp and scaler;
|
||||||
- a separate `T_map_from_lidar` pose stream.
|
- 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;
|
- raw sensor-frame sweep;
|
||||||
- per-point firing time;
|
- per-point firing time;
|
||||||
|
|
@ -170,8 +191,10 @@ memory, zero-copy or batching optimizations are accepted.
|
||||||
host capture/receive times.
|
host capture/receive times.
|
||||||
- [x] Record explicitly absent ring, per-point time and IMU fields.
|
- [x] Record explicitly absent ring, per-point time and IMU fields.
|
||||||
- [x] Add immutable reports for sequence gaps, arrival jitter, points/frame,
|
- [x] Add immutable reports for sequence gaps, arrival jitter, points/frame,
|
||||||
intensity distribution and pose coverage. Sensor-frame range remains
|
intensity distribution and pose coverage. The sealed source report keeps
|
||||||
explicitly unavailable because the source is a vendor map increment.
|
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
|
- [x] Compare source native capture vs persisted replay fields byte-for-byte on
|
||||||
a frozen real slice.
|
a frozen real slice.
|
||||||
- [x] Publish the report to the React observation view without bundling a
|
- [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.
|
E19 local-percentile ground proposal.
|
||||||
- [x] Retain separate current/candidate ground and assigned masks for every
|
- [x] Retain separate current/candidate ground and assigned masks for every
|
||||||
source point; raw replay remains unchanged.
|
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
|
- [x] Seal latency, ground-fraction, algorithm-IoU and disagreement
|
||||||
distributions in `missioncore.lidar-ground-benchmark/v1`.
|
distributions in `missioncore.lidar-ground-benchmark/v1`.
|
||||||
- [x] Create an immutable eight-frame annotation template in which every point
|
- [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.
|
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.
|
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
|
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
|
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
|
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
|
physical sensor height is not encoded by the best-effort pose, and raw scan
|
||||||
geometry remains unknown. Translating the cloud until Patchwork++ looks
|
geometry is not exported. The measured 1.27 m estimate can be reproduced as a
|
||||||
plausible would tune against the candidate and invalidate the comparison.
|
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
|
The independent-label exit remains open. It can be closed by reviewing the
|
||||||
content-bound template
|
content-bound template
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,20 @@ independent best-effort pose and no admitted raw sweep or scan geometry. The
|
||||||
pose describes vendor odometry; it does not prove the physical height of the
|
pose describes vendor odometry; it does not prove the physical height of the
|
||||||
LiDAR above terrain.
|
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
|
Treating a successful Patchwork++ call as a valid baseline would conflate API
|
||||||
compatibility with input-domain compatibility. Choosing a synthetic Z
|
compatibility with input-domain compatibility. Choosing a synthetic Z
|
||||||
translation until the output looks plausible would use the candidate itself to
|
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
|
8. Create an all-ignore, content-bound annotation template; only a separate
|
||||||
human-reviewed generation may unlock ground IoU, curb/low-obstacle recall
|
human-reviewed generation may unlock ground IoU, curb/low-obstacle recall
|
||||||
and reflection-noise rejection.
|
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
|
## Consequences
|
||||||
|
|
||||||
|
|
@ -45,6 +65,11 @@ define the normalization.
|
||||||
threshold tuning around a mis-specified sensor model.
|
threshold tuning around a mis-specified sensor model.
|
||||||
- The same immutable benchmark/API/React surface can compare a future raw scan,
|
- The same immutable benchmark/API/React surface can compare a future raw scan,
|
||||||
replay or simulation provider without moving C++ processing into React.
|
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
|
## 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.
|
Algorithm ground IoU was 2.90% p50 and point disagreement was 17.92% p50.
|
||||||
Neither is an accuracy metric. Independent labels are still missing.
|
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
|
## References
|
||||||
|
|
||||||
- `src/k1link/compute/lidar_ground.py`
|
- `src/k1link/compute/lidar_ground.py`
|
||||||
- `experiments/perception/run_lidar_ground_benchmark.py`
|
- `experiments/perception/run_lidar_ground_benchmark.py`
|
||||||
- `src/k1link/web/lidar_api.py`
|
- `src/k1link/web/lidar_api.py`
|
||||||
- `apps/control-station/src/workspaces/LidarQualityWorkspace.tsx`
|
- `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`
|
- `docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md`
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
# K1 firmware 3.0.2 LiDAR pipeline — redacted static analysis
|
||||||
|
|
||||||
|
Date: 2026-07-25
|
||||||
|
Scope: owner-controlled LixelKity K1 firmware 3.0.2 full archive
|
||||||
|
Method: offline configuration, ELF symbol and string inspection only
|
||||||
|
Device writes, firmware upload, runtime process changes and network probes: none
|
||||||
|
|
||||||
|
## Evidence boundary
|
||||||
|
|
||||||
|
The retained firmware package contains deployable ARM64 binaries, libraries and
|
||||||
|
configuration files, not the original C++ source tree. Non-stripped symbols,
|
||||||
|
embedded build paths and protobuf descriptors still provide stronger pipeline
|
||||||
|
evidence than the external MQTT contract alone. The private firmware archive
|
||||||
|
and extracted filesystem remain outside Git; this note contains only sanitized
|
||||||
|
derived facts and content hashes.
|
||||||
|
|
||||||
|
## Proven static facts
|
||||||
|
|
||||||
|
1. K1 selects `livox_mid360` as its LiDAR driver.
|
||||||
|
2. The driver configuration exposes separate point and IMU inputs. The K1 LIO
|
||||||
|
configuration consumes `/livox/lidar` and `/imu`.
|
||||||
|
3. The internal converter uses a `PointIRT` representation containing XYZ,
|
||||||
|
intensity, per-point timestamp and ring fields. The MID-360 library exposes
|
||||||
|
raw packet processing, `PointXyzlt`, `PointFrame` and IMU callbacks.
|
||||||
|
4. The K1 LIO preprocess contract admits `0.3–200 m`, a `0.1 s` sweep and
|
||||||
|
`use_single_pcl: false`.
|
||||||
|
5. The external MID-360 MQTT point-cloud branch declares
|
||||||
|
`mqtt_pcl_samples: 4`; it is deliberately downsampled before transport.
|
||||||
|
6. The modeling application receives raw `PointCloudMsg`, feeds `SlamCore`,
|
||||||
|
and later sends point clouds from `LioResultMsg`. This supports classifying
|
||||||
|
external `lio_pcl` as an LIO product rather than the original MID-360 scan.
|
||||||
|
7. The firmware contains a K1 calibration candidate
|
||||||
|
`lidar_imu_trans: [0.01176, -0.01865, 0.075]` with zero initial rotation.
|
||||||
|
Static presence does not prove that this exact file was active in the
|
||||||
|
recorded session, and it is not the sensor-to-ground installation height.
|
||||||
|
|
||||||
|
## Consequences for Mission Core
|
||||||
|
|
||||||
|
- Metric range is present in the scanner and internal point path.
|
||||||
|
- The current MQTT `lio_pcl` evidence is useful for visualization, derived
|
||||||
|
ground proposals and detector experiments, but must not be relabeled as raw.
|
||||||
|
- A future raw-scan gate should capture the existing internal point and IMU
|
||||||
|
products through an admitted onboard exporter or recorded bag boundary. It
|
||||||
|
does not require replacing the physical scanner.
|
||||||
|
- The operator-estimated `1.27 m` height belongs only to the handheld recording
|
||||||
|
and remains diagnostic until a runtime calibration binds the optical origin,
|
||||||
|
axes and installation.
|
||||||
|
- Patchwork++ comparison must remain diagnostic on MQTT evidence even after
|
||||||
|
applying the height estimate.
|
||||||
|
|
||||||
|
## Evidence hashes
|
||||||
|
|
||||||
|
| Evidence role | SHA-256 |
|
||||||
|
| --- | --- |
|
||||||
|
| LiDAR configuration | `cafca05b230dececfde45917f21eaad973491de4cbc2a9668b2fc01a6f79d0b2` |
|
||||||
|
| K1 LIO configuration | `2b8f06bf95e429862a5559b1aefb39591e5a97cd41dbda89016faf8d05ab7471` |
|
||||||
|
| K1 calibration configuration | `52db698b80d870b018461501a8582a84dee1aa0f269a9d96cdf96c4169dca95c` |
|
||||||
|
| MID-360 driver library | `0a9266a337c8112f84728c9c7ae6a4a282e0c92e8ffefc1d43f2d2d54ef55c25` |
|
||||||
|
| K1 LiDAR adapter library | `e1b8756474098d3a51e08e0690abc5397ea3e7422c16f0ef7f22d81509a96d85` |
|
||||||
|
| ROS/internal message converter | `225d83fb2468f686a0382324aba595982e34d1dd1e5dc8d0959e1c15d3678c67` |
|
||||||
|
| Modeling application | `780c9194a749b84b2f8e4c0ea7b297be24f5d8b5946f0315c6c9b74861425ec0` |
|
||||||
|
|
@ -6,8 +6,10 @@ import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from k1link.compute import (
|
from k1link.compute import (
|
||||||
|
DEFAULT_GROUND_BENCHMARK_PROFILE,
|
||||||
PATCHWORKPP_SOURCE_COMMIT,
|
PATCHWORKPP_SOURCE_COMMIT,
|
||||||
PATCHWORKPP_SOURCE_TAG,
|
PATCHWORKPP_SOURCE_TAG,
|
||||||
|
GroundBenchmarkProfile,
|
||||||
LidarGroundBenchmarkV1,
|
LidarGroundBenchmarkV1,
|
||||||
LidarReplayPackV2,
|
LidarReplayPackV2,
|
||||||
PatchworkPPGroundSegmenter,
|
PatchworkPPGroundSegmenter,
|
||||||
|
|
@ -47,14 +49,40 @@ def _arguments() -> argparse.Namespace:
|
||||||
"--patchwork-source-commit",
|
"--patchwork-source-commit",
|
||||||
default=PATCHWORKPP_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()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
arguments = _arguments()
|
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)
|
replay = LidarReplayPackV2(arguments.replay_pack)
|
||||||
try:
|
try:
|
||||||
patchwork = PatchworkPPGroundSegmenter.load(
|
patchwork = PatchworkPPGroundSegmenter.load(
|
||||||
|
profile=profile,
|
||||||
module_name=arguments.patchwork_module,
|
module_name=arguments.patchwork_module,
|
||||||
source_tag=arguments.patchwork_source_tag,
|
source_tag=arguments.patchwork_source_tag,
|
||||||
source_commit=arguments.patchwork_source_commit,
|
source_commit=arguments.patchwork_source_commit,
|
||||||
|
|
@ -63,6 +91,7 @@ def main() -> int:
|
||||||
replay,
|
replay,
|
||||||
arguments.output_root,
|
arguments.output_root,
|
||||||
patchwork=patchwork,
|
patchwork=patchwork,
|
||||||
|
profile=profile,
|
||||||
)
|
)
|
||||||
annotation = (
|
annotation = (
|
||||||
build_lidar_ground_annotation_template(
|
build_lidar_ground_annotation_template(
|
||||||
|
|
@ -87,9 +116,7 @@ def main() -> int:
|
||||||
"current": result.report["current"],
|
"current": result.report["current"],
|
||||||
"candidate": result.report["candidate"],
|
"candidate": result.report["candidate"],
|
||||||
"comparison": result.report["comparison"],
|
"comparison": result.report["comparison"],
|
||||||
"annotation_template_id": (
|
"annotation_template_id": (annotation.name if annotation is not None else None),
|
||||||
annotation.name if annotation is not None else None
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
indent=2,
|
indent=2,
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ from .lidar_ground import (
|
||||||
LIDAR_GROUND_ANNOTATION_TEMPLATE_SCHEMA,
|
LIDAR_GROUND_ANNOTATION_TEMPLATE_SCHEMA,
|
||||||
LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA,
|
LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA,
|
||||||
LIDAR_GROUND_BENCHMARK_SCHEMA,
|
LIDAR_GROUND_BENCHMARK_SCHEMA,
|
||||||
|
LIDAR_GROUND_FRAME_SCHEMA,
|
||||||
PATCHWORKPP_SOURCE_COMMIT,
|
PATCHWORKPP_SOURCE_COMMIT,
|
||||||
PATCHWORKPP_SOURCE_TAG,
|
PATCHWORKPP_SOURCE_TAG,
|
||||||
PATCHWORKPP_SOURCE_URL,
|
PATCHWORKPP_SOURCE_URL,
|
||||||
|
|
@ -86,6 +87,7 @@ from .lidar_ground import (
|
||||||
build_lidar_ground_annotation_template,
|
build_lidar_ground_annotation_template,
|
||||||
build_lidar_ground_benchmark,
|
build_lidar_ground_benchmark,
|
||||||
lidar_ground_benchmark_catalog_item,
|
lidar_ground_benchmark_catalog_item,
|
||||||
|
lidar_ground_frame_detail,
|
||||||
score_ground_labels,
|
score_ground_labels,
|
||||||
)
|
)
|
||||||
from .lidar_replay import (
|
from .lidar_replay import (
|
||||||
|
|
@ -176,6 +178,7 @@ __all__ = [
|
||||||
"LIDAR_GROUND_ANNOTATION_TEMPLATE_SCHEMA",
|
"LIDAR_GROUND_ANNOTATION_TEMPLATE_SCHEMA",
|
||||||
"LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA",
|
"LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA",
|
||||||
"LIDAR_GROUND_BENCHMARK_SCHEMA",
|
"LIDAR_GROUND_BENCHMARK_SCHEMA",
|
||||||
|
"LIDAR_GROUND_FRAME_SCHEMA",
|
||||||
"LIDAR_EVIDENCE_PROFILE_SCHEMA",
|
"LIDAR_EVIDENCE_PROFILE_SCHEMA",
|
||||||
"LIDAR_EQUIVALENCE_REPORT_SCHEMA",
|
"LIDAR_EQUIVALENCE_REPORT_SCHEMA",
|
||||||
"LIDAR_QUALITY_REPORT_SCHEMA",
|
"LIDAR_QUALITY_REPORT_SCHEMA",
|
||||||
|
|
@ -258,6 +261,7 @@ __all__ = [
|
||||||
"build_lidar_replay_pack_v2",
|
"build_lidar_replay_pack_v2",
|
||||||
"build_lidar_ground_annotation_template",
|
"build_lidar_ground_annotation_template",
|
||||||
"build_lidar_ground_benchmark",
|
"build_lidar_ground_benchmark",
|
||||||
|
"lidar_ground_frame_detail",
|
||||||
"DetectionFrame",
|
"DetectionFrame",
|
||||||
"ObjectDetection",
|
"ObjectDetection",
|
||||||
"RecordedPerceptionOverlayError",
|
"RecordedPerceptionOverlayError",
|
||||||
|
|
|
||||||
|
|
@ -23,25 +23,21 @@ from .lidar_contract import LidarContractError, sensor_frame_xyzi
|
||||||
from .lidar_replay import LidarReplayPackV2
|
from .lidar_replay import LidarReplayPackV2
|
||||||
|
|
||||||
LIDAR_GROUND_BENCHMARK_SCHEMA: Final = "missioncore.lidar-ground-benchmark/v1"
|
LIDAR_GROUND_BENCHMARK_SCHEMA: Final = "missioncore.lidar-ground-benchmark/v1"
|
||||||
LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA: Final = (
|
LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA: Final = "missioncore.lidar-ground-benchmark-report/v1"
|
||||||
"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_ANNOTATION_TEMPLATE_SCHEMA: Final = (
|
|
||||||
"missioncore.lidar-ground-annotation-template/v1"
|
|
||||||
)
|
|
||||||
LIDAR_GROUND_RESULTS_NAME: Final = "ground-results.npz"
|
LIDAR_GROUND_RESULTS_NAME: Final = "ground-results.npz"
|
||||||
LIDAR_GROUND_REPORT_NAME: Final = "ground-report.json"
|
LIDAR_GROUND_REPORT_NAME: Final = "ground-report.json"
|
||||||
LIDAR_GROUND_MANIFEST_NAME: Final = "manifest.json"
|
LIDAR_GROUND_MANIFEST_NAME: Final = "manifest.json"
|
||||||
LIDAR_GROUND_ANNOTATION_LABELS_NAME: Final = "labels-template.npz"
|
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_URL: Final = "https://github.com/url-kaist/patchwork-plusplus"
|
||||||
PATCHWORKPP_SOURCE_TAG: Final = "v1.4.1"
|
PATCHWORKPP_SOURCE_TAG: Final = "v1.4.1"
|
||||||
PATCHWORKPP_SOURCE_COMMIT: Final = "3e6903a1d5537a4cc2ace897b0bbb98a92d6014c"
|
PATCHWORKPP_SOURCE_COMMIT: Final = "3e6903a1d5537a4cc2ace897b0bbb98a92d6014c"
|
||||||
|
|
||||||
_BENCHMARK_ID = re.compile(r"^ground-benchmark-[a-f0-9]{64}$")
|
_BENCHMARK_ID = re.compile(r"^ground-benchmark-[a-f0-9]{64}$")
|
||||||
_ANNOTATION_TEMPLATE_ID = re.compile(
|
_ANNOTATION_TEMPLATE_ID = re.compile(r"^ground-annotation-template-[a-f0-9]{64}$")
|
||||||
r"^ground-annotation-template-[a-f0-9]{64}$"
|
|
||||||
)
|
|
||||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||||
_GIT_SHA1 = re.compile(r"^[a-f0-9]{40}$")
|
_GIT_SHA1 = re.compile(r"^[a-f0-9]{40}$")
|
||||||
|
|
||||||
|
|
@ -64,9 +60,54 @@ class GroundBenchmarkProfile:
|
||||||
current_maximum_above_ground_m: float = 0.12
|
current_maximum_above_ground_m: float = 0.12
|
||||||
current_minimum_local_points: int = 8
|
current_minimum_local_points: int = 8
|
||||||
patchwork_sensor_height_proxy_m: float = 0.0
|
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_minimum_range_m: float = 0.1
|
||||||
patchwork_maximum_range_m: float = 20.0
|
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]:
|
def to_dict(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"schema_version": "missioncore.lidar-ground-benchmark-profile/v1",
|
"schema_version": "missioncore.lidar-ground-benchmark-profile/v1",
|
||||||
|
|
@ -88,6 +129,8 @@ class GroundBenchmarkProfile:
|
||||||
"candidate": {
|
"candidate": {
|
||||||
"provider_id": "patchworkpp/v1.4.1",
|
"provider_id": "patchworkpp/v1.4.1",
|
||||||
"sensor_height_proxy_m": self.patchwork_sensor_height_proxy_m,
|
"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,
|
"minimum_range_m": self.patchwork_minimum_range_m,
|
||||||
"maximum_range_m": self.patchwork_maximum_range_m,
|
"maximum_range_m": self.patchwork_maximum_range_m,
|
||||||
"enable_rnr": True,
|
"enable_rnr": True,
|
||||||
|
|
@ -97,7 +140,9 @@ class GroundBenchmarkProfile:
|
||||||
"input_normalization": {
|
"input_normalization": {
|
||||||
"current": "vendor-map-xyz",
|
"current": "vendor-map-xyz",
|
||||||
"candidate": "best-effort-map-to-lidar-pose-inversion",
|
"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,
|
"sensor_scan_geometry_known": False,
|
||||||
},
|
},
|
||||||
"authority": {
|
"authority": {
|
||||||
|
|
@ -146,9 +191,7 @@ class LocalPercentileGroundSegmenter:
|
||||||
cell_keys = np.floor(xyz[:, :2] / cell_size).astype(np.int64)
|
cell_keys = np.floor(xyz[:, :2] / cell_size).astype(np.int64)
|
||||||
unique_cells, inverse = np.unique(cell_keys, axis=0, return_inverse=True)
|
unique_cells, inverse = np.unique(cell_keys, axis=0, return_inverse=True)
|
||||||
ground = np.zeros(points.shape[0], dtype=np.bool_)
|
ground = np.zeros(points.shape[0], dtype=np.bool_)
|
||||||
global_ground_z = float(
|
global_ground_z = float(np.percentile(xyz[:, 2], self.profile.current_lower_percentile))
|
||||||
np.percentile(xyz[:, 2], self.profile.current_lower_percentile)
|
|
||||||
)
|
|
||||||
radius_squared = self.profile.current_local_radius_m**2
|
radius_squared = self.profile.current_local_radius_m**2
|
||||||
for cell_index in range(unique_cells.shape[0]):
|
for cell_index in range(unique_cells.shape[0]):
|
||||||
point_indices = np.flatnonzero(inverse == cell_index)
|
point_indices = np.flatnonzero(inverse == cell_index)
|
||||||
|
|
@ -172,9 +215,8 @@ class LocalPercentileGroundSegmenter:
|
||||||
)
|
)
|
||||||
z = xyz[point_indices, 2]
|
z = xyz[point_indices, 2]
|
||||||
ground[point_indices] = (
|
ground[point_indices] = (
|
||||||
(z >= ground_z - self.profile.current_maximum_below_ground_m)
|
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_above_ground_m)
|
||||||
)
|
|
||||||
latency_ms = (time.perf_counter_ns() - started) / 1_000_000
|
latency_ms = (time.perf_counter_ns() - started) / 1_000_000
|
||||||
return GroundSegmentation(
|
return GroundSegmentation(
|
||||||
ground_mask=ground,
|
ground_mask=ground,
|
||||||
|
|
@ -235,9 +277,7 @@ class PatchworkPPGroundSegmenter:
|
||||||
try:
|
try:
|
||||||
module = importlib.import_module(module_name)
|
module = importlib.import_module(module_name)
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise LidarGroundError(
|
raise LidarGroundError("Pinned Patchwork++ Python binding is unavailable") from exc
|
||||||
"Pinned Patchwork++ Python binding is unavailable"
|
|
||||||
) from exc
|
|
||||||
return cls(
|
return cls(
|
||||||
module,
|
module,
|
||||||
profile,
|
profile,
|
||||||
|
|
@ -295,8 +335,7 @@ class LidarGroundBenchmarkV1:
|
||||||
self.manifest.get("schema_version") != LIDAR_GROUND_BENCHMARK_SCHEMA
|
self.manifest.get("schema_version") != LIDAR_GROUND_BENCHMARK_SCHEMA
|
||||||
or self.identity.get("schema_version") != LIDAR_GROUND_BENCHMARK_SCHEMA
|
or self.identity.get("schema_version") != LIDAR_GROUND_BENCHMARK_SCHEMA
|
||||||
or not isinstance(identity_sha256, str)
|
or not isinstance(identity_sha256, str)
|
||||||
or hashlib.sha256(_canonical_json(self.identity)).hexdigest()
|
or hashlib.sha256(_canonical_json(self.identity)).hexdigest() != identity_sha256
|
||||||
!= identity_sha256
|
|
||||||
or self.root.name != f"ground-benchmark-{identity_sha256}"
|
or self.root.name != f"ground-benchmark-{identity_sha256}"
|
||||||
or self.manifest.get("benchmark_id") != self.root.name
|
or self.manifest.get("benchmark_id") != self.root.name
|
||||||
):
|
):
|
||||||
|
|
@ -313,19 +352,15 @@ class LidarGroundBenchmarkV1:
|
||||||
labels = _object(self.report.get("labels"), "ground labels")
|
labels = _object(self.report.get("labels"), "ground labels")
|
||||||
decision = _object(self.report.get("decision"), "ground decision")
|
decision = _object(self.report.get("decision"), "ground decision")
|
||||||
if (
|
if (
|
||||||
_ground_logical_sha256(self.arrays)
|
_ground_logical_sha256(self.arrays) != self.identity.get("logical_results_sha256")
|
||||||
!= self.identity.get("logical_results_sha256")
|
or self.report.get("schema_version") != LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA
|
||||||
or self.report.get("schema_version")
|
|
||||||
!= LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA
|
|
||||||
or self.report.get("benchmark_id") != self.root.name
|
or self.report.get("benchmark_id") != self.root.name
|
||||||
or self.report.get("replay_pack_id")
|
or self.report.get("replay_pack_id") != self.identity.get("replay_pack_id")
|
||||||
!= self.identity.get("replay_pack_id")
|
|
||||||
or self.report.get("status") != "diagnostic-only"
|
or self.report.get("status") != "diagnostic-only"
|
||||||
or input_domain.get("accepted") is not False
|
or input_domain.get("accepted") is not False
|
||||||
or labels.get("status") != "missing-independent-review"
|
or labels.get("status") != "missing-independent-review"
|
||||||
or labels.get("metrics_available") is not False
|
or labels.get("metrics_available") is not False
|
||||||
or decision.get("status")
|
or decision.get("status") != "do-not-promote-on-current-vendor-map"
|
||||||
!= "do-not-promote-on-current-vendor-map"
|
|
||||||
or decision.get("production_promotion") is not False
|
or decision.get("production_promotion") is not False
|
||||||
):
|
):
|
||||||
raise LidarGroundError("Ground benchmark report is incompatible")
|
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):
|
for frame_index in range(replay.point_frame_count):
|
||||||
point = replay.point_frame(frame_index)
|
point = replay.point_frame(frame_index)
|
||||||
nearest_pose_index = int(
|
nearest_pose_index = int(np.argmin(np.abs(pose_times - point.received_monotonic_ns)))
|
||||||
np.argmin(np.abs(pose_times - point.received_monotonic_ns))
|
|
||||||
)
|
|
||||||
pose = replay.pose_frame(nearest_pose_index)
|
pose = replay.pose_frame(nearest_pose_index)
|
||||||
delta_ms = (
|
delta_ms = abs(pose.received_monotonic_ns - point.received_monotonic_ns) / 1_000_000
|
||||||
abs(pose.received_monotonic_ns - point.received_monotonic_ns)
|
|
||||||
/ 1_000_000
|
|
||||||
)
|
|
||||||
if delta_ms > profile.pose_binding_threshold_ms:
|
if delta_ms > profile.pose_binding_threshold_ms:
|
||||||
raise LidarGroundError("Ground A/B point frame has no admitted pose")
|
raise LidarGroundError("Ground A/B point frame has no admitted pose")
|
||||||
try:
|
try:
|
||||||
|
|
@ -387,6 +417,9 @@ def build_lidar_ground_benchmark(
|
||||||
)
|
)
|
||||||
except LidarContractError as exc:
|
except LidarContractError as exc:
|
||||||
raise LidarGroundError("Ground A/B sensor conversion failed") from 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 = np.empty((point.xyz_map.shape[0], 4), dtype=np.float32)
|
||||||
current_input[:, :3] = point.xyz_map.astype(np.float32)
|
current_input[:, :3] = point.xyz_map.astype(np.float32)
|
||||||
current_input[:, 3] = point.intensity.astype(np.float32) / 255.0
|
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)
|
pose_delta_ms.append(delta_ms)
|
||||||
current_fraction.append(float(np.mean(current_result.ground_mask)))
|
current_fraction.append(float(np.mean(current_result.ground_mask)))
|
||||||
candidate_fraction.append(float(np.mean(candidate_result.ground_mask)))
|
candidate_fraction.append(float(np.mean(candidate_result.ground_mask)))
|
||||||
candidate_assigned_fraction.append(
|
candidate_assigned_fraction.append(float(np.mean(candidate_result.assigned_mask)))
|
||||||
float(np.mean(candidate_result.assigned_mask))
|
|
||||||
)
|
|
||||||
intersection = int(
|
intersection = int(
|
||||||
np.count_nonzero(
|
np.count_nonzero(current_result.ground_mask & candidate_result.ground_mask)
|
||||||
current_result.ground_mask & candidate_result.ground_mask
|
|
||||||
)
|
|
||||||
)
|
|
||||||
union = 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))
|
||||||
inter_provider_iou.append(float(intersection / union) if union else 1.0)
|
inter_provider_iou.append(float(intersection / union) if union else 1.0)
|
||||||
disagreement_fraction.append(
|
disagreement_fraction.append(
|
||||||
float(
|
float(np.mean(current_result.ground_mask != candidate_result.ground_mask))
|
||||||
np.mean(
|
|
||||||
current_result.ground_mask != candidate_result.ground_mask
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
arrays: dict[str, npt.NDArray[Any]] = {
|
arrays: dict[str, npt.NDArray[Any]] = {
|
||||||
|
|
@ -477,11 +498,19 @@ def build_lidar_ground_benchmark(
|
||||||
"input_domain": {
|
"input_domain": {
|
||||||
"accepted": False,
|
"accepted": False,
|
||||||
"representation": replay.profile.representation.value,
|
"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,
|
"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": (
|
"reason": (
|
||||||
"Patchwork++ expects a sensor-centric scan and physical sensor "
|
"Patchwork++ expects a sensor-centric scan; K1 lio_pcl is an "
|
||||||
"height; K1 lio_pcl is a vendor-mapped increment."
|
"externally downsampled LIO/map product. Height correction does "
|
||||||
|
"not admit the external feed as a raw MID-360 scan."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
|
|
@ -498,9 +527,7 @@ def build_lidar_ground_benchmark(
|
||||||
"current": {
|
"current": {
|
||||||
"provider": dict(current.identity),
|
"provider": dict(current.identity),
|
||||||
"ground_fraction": _distribution(current_fraction),
|
"ground_fraction": _distribution(current_fraction),
|
||||||
"assigned_fraction": _distribution(
|
"assigned_fraction": _distribution([1.0] * replay.point_frame_count),
|
||||||
[1.0] * replay.point_frame_count
|
|
||||||
),
|
|
||||||
"latency_ms": _distribution(current_latency),
|
"latency_ms": _distribution(current_latency),
|
||||||
},
|
},
|
||||||
"candidate": {
|
"candidate": {
|
||||||
|
|
@ -510,12 +537,8 @@ def build_lidar_ground_benchmark(
|
||||||
"latency_ms": _distribution(candidate_latency),
|
"latency_ms": _distribution(candidate_latency),
|
||||||
},
|
},
|
||||||
"comparison": {
|
"comparison": {
|
||||||
"algorithm_to_algorithm_ground_iou": _distribution(
|
"algorithm_to_algorithm_ground_iou": _distribution(inter_provider_iou),
|
||||||
inter_provider_iou
|
"ground_disagreement_fraction": _distribution(disagreement_fraction),
|
||||||
),
|
|
||||||
"ground_disagreement_fraction": _distribution(
|
|
||||||
disagreement_fraction
|
|
||||||
),
|
|
||||||
"is_accuracy_metric": False,
|
"is_accuracy_metric": False,
|
||||||
},
|
},
|
||||||
"decision": {
|
"decision": {
|
||||||
|
|
@ -596,10 +619,7 @@ def build_lidar_ground_annotation_template(
|
||||||
)
|
)
|
||||||
source_offsets = np.asarray(replay.arrays["point_offsets"], dtype=np.int64)
|
source_offsets = np.asarray(replay.arrays["point_offsets"], dtype=np.int64)
|
||||||
selected_counts = np.asarray(
|
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",
|
dtype="<i8",
|
||||||
)
|
)
|
||||||
selected_offsets = np.concatenate(
|
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:
|
def _validate_annotation_template(root: Path) -> None:
|
||||||
resolved = root.resolve(strict=True)
|
resolved = root.resolve(strict=True)
|
||||||
if (
|
if (
|
||||||
|
|
@ -752,8 +867,7 @@ def _validate_annotation_template(root: Path) -> None:
|
||||||
identity_sha256 = manifest.get("identity_sha256")
|
identity_sha256 = manifest.get("identity_sha256")
|
||||||
if (
|
if (
|
||||||
manifest.get("schema_version") != LIDAR_GROUND_ANNOTATION_TEMPLATE_SCHEMA
|
manifest.get("schema_version") != LIDAR_GROUND_ANNOTATION_TEMPLATE_SCHEMA
|
||||||
or identity.get("schema_version")
|
or identity.get("schema_version") != LIDAR_GROUND_ANNOTATION_TEMPLATE_SCHEMA
|
||||||
!= LIDAR_GROUND_ANNOTATION_TEMPLATE_SCHEMA
|
|
||||||
or not isinstance(identity_sha256, str)
|
or not isinstance(identity_sha256, str)
|
||||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||||
or resolved.name != f"ground-annotation-template-{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
|
set(arrays.files) != required
|
||||||
or arrays["labels"].dtype != np.dtype("u1")
|
or arrays["labels"].dtype != np.dtype("u1")
|
||||||
or np.any(arrays["labels"] != 0)
|
or np.any(arrays["labels"] != 0)
|
||||||
or _ground_logical_sha256(arrays)
|
or _ground_logical_sha256(arrays) != identity.get("logical_content_sha256")
|
||||||
!= identity.get("logical_content_sha256")
|
|
||||||
):
|
):
|
||||||
raise LidarGroundError("Ground annotation template content is invalid")
|
raise LidarGroundError("Ground annotation template content is invalid")
|
||||||
finally:
|
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:
|
def _indices(value: npt.NDArray[np.int64], count: int, label: str) -> None:
|
||||||
if (
|
if value.size and (
|
||||||
value.size
|
np.any(value < 0) or np.any(value >= count) or np.unique(value).shape[0] != value.shape[0]
|
||||||
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")
|
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:
|
def _nonnegative_int(value: object, label: str) -> int:
|
||||||
if (
|
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||||
not isinstance(value, int)
|
|
||||||
or isinstance(value, bool)
|
|
||||||
or value < 0
|
|
||||||
):
|
|
||||||
raise LidarGroundError(f"{label} must be a non-negative integer")
|
raise LidarGroundError(f"{label} must be a non-negative integer")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
@ -983,9 +1087,7 @@ def _validate_artifacts(
|
||||||
|
|
||||||
|
|
||||||
def _object(value: object, label: str) -> dict[str, Any]:
|
def _object(value: object, label: str) -> dict[str, Any]:
|
||||||
if not isinstance(value, dict) or not all(
|
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
||||||
isinstance(key, str) for key in value
|
|
||||||
):
|
|
||||||
raise LidarGroundError(f"{label} must be an object")
|
raise LidarGroundError(f"{label} must be an object")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
@ -1021,8 +1123,4 @@ def _sha256(path: Path) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _utc_now() -> str:
|
def _utc_now() -> str:
|
||||||
return (
|
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||||
datetime.now(UTC)
|
|
||||||
.isoformat(timespec="milliseconds")
|
|
||||||
.replace("+00:00", "Z")
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,13 @@ from k1link.compute import (
|
||||||
LidarReplayError,
|
LidarReplayError,
|
||||||
LidarReplayPackV2,
|
LidarReplayPackV2,
|
||||||
lidar_ground_benchmark_catalog_item,
|
lidar_ground_benchmark_catalog_item,
|
||||||
|
lidar_ground_frame_detail,
|
||||||
lidar_pack_catalog_item,
|
lidar_pack_catalog_item,
|
||||||
lidar_pack_detail,
|
lidar_pack_detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
LIDAR_CATALOG_SCHEMA: Final = "missioncore.lidar-replay-pack-catalog/v1"
|
LIDAR_CATALOG_SCHEMA: Final = "missioncore.lidar-replay-pack-catalog/v1"
|
||||||
LIDAR_GROUND_CATALOG_SCHEMA: Final = (
|
LIDAR_GROUND_CATALOG_SCHEMA: Final = "missioncore.lidar-ground-benchmark-catalog/v1"
|
||||||
"missioncore.lidar-ground-benchmark-catalog/v1"
|
|
||||||
)
|
|
||||||
_PACK_ID = re.compile(r"^lidar-replay-pack-[a-f0-9]{64}$")
|
_PACK_ID = re.compile(r"^lidar-replay-pack-[a-f0-9]{64}$")
|
||||||
_BENCHMARK_ID = re.compile(r"^ground-benchmark-[a-f0-9]{64}$")
|
_BENCHMARK_ID = re.compile(r"^ground-benchmark-[a-f0-9]{64}$")
|
||||||
RootProvider = Callable[[], Path | None]
|
RootProvider = Callable[[], Path | None]
|
||||||
|
|
@ -143,8 +142,7 @@ def build_lidar_router(
|
||||||
(
|
(
|
||||||
candidate
|
candidate
|
||||||
for candidate in root.iterdir()
|
for candidate in root.iterdir()
|
||||||
if candidate.is_dir()
|
if candidate.is_dir() and _BENCHMARK_ID.fullmatch(candidate.name) is not None
|
||||||
and _BENCHMARK_ID.fullmatch(candidate.name) is not None
|
|
||||||
),
|
),
|
||||||
key=lambda candidate: candidate.stat().st_mtime_ns,
|
key=lambda candidate: candidate.stat().st_mtime_ns,
|
||||||
reverse=True,
|
reverse=True,
|
||||||
|
|
@ -153,13 +151,8 @@ def build_lidar_router(
|
||||||
try:
|
try:
|
||||||
benchmark = LidarGroundBenchmarkV1(candidate)
|
benchmark = LidarGroundBenchmarkV1(candidate)
|
||||||
try:
|
try:
|
||||||
if (
|
if pack_id is None or benchmark.identity.get("replay_pack_id") == pack_id:
|
||||||
pack_id is None
|
items.append(lidar_ground_benchmark_catalog_item(benchmark))
|
||||||
or benchmark.identity.get("replay_pack_id") == pack_id
|
|
||||||
):
|
|
||||||
items.append(
|
|
||||||
lidar_ground_benchmark_catalog_item(benchmark)
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
benchmark.close()
|
benchmark.close()
|
||||||
except (LidarGroundError, OSError):
|
except (LidarGroundError, OSError):
|
||||||
|
|
@ -196,9 +189,7 @@ def build_lidar_router(
|
||||||
benchmark = LidarGroundBenchmarkV1(candidate)
|
benchmark = LidarGroundBenchmarkV1(candidate)
|
||||||
try:
|
try:
|
||||||
return {
|
return {
|
||||||
"schema_version": (
|
"schema_version": ("missioncore.lidar-ground-benchmark-detail/v1"),
|
||||||
"missioncore.lidar-ground-benchmark-detail/v1"
|
|
||||||
),
|
|
||||||
"benchmark": lidar_ground_benchmark_catalog_item(benchmark),
|
"benchmark": lidar_ground_benchmark_catalog_item(benchmark),
|
||||||
"report": benchmark.report,
|
"report": benchmark.report,
|
||||||
"access": "read-only",
|
"access": "read-only",
|
||||||
|
|
@ -211,4 +202,71 @@ def build_lidar_router(
|
||||||
detail="LiDAR ground benchmark не прошёл проверку целостности",
|
detail="LiDAR ground benchmark не прошёл проверку целостности",
|
||||||
) from exc
|
) 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
|
return router
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from fastapi.routing import APIRoute
|
||||||
from k1link.compute import (
|
from k1link.compute import (
|
||||||
DEFAULT_GROUND_BENCHMARK_PROFILE,
|
DEFAULT_GROUND_BENCHMARK_PROFILE,
|
||||||
K1_LIDAR_PACK_V2_PROFILE,
|
K1_LIDAR_PACK_V2_PROFILE,
|
||||||
|
GroundBenchmarkProfile,
|
||||||
GroundSegmentation,
|
GroundSegmentation,
|
||||||
LidarGroundBenchmarkV1,
|
LidarGroundBenchmarkV1,
|
||||||
LidarGroundError,
|
LidarGroundError,
|
||||||
|
|
@ -21,6 +22,7 @@ from k1link.compute import (
|
||||||
PatchworkPPGroundSegmenter,
|
PatchworkPPGroundSegmenter,
|
||||||
build_lidar_ground_annotation_template,
|
build_lidar_ground_annotation_template,
|
||||||
build_lidar_ground_benchmark,
|
build_lidar_ground_benchmark,
|
||||||
|
lidar_ground_frame_detail,
|
||||||
score_ground_labels,
|
score_ground_labels,
|
||||||
)
|
)
|
||||||
from k1link.web.lidar_api import build_lidar_router
|
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)
|
xyzi = np.column_stack((xyz, np.ones(xyz.shape[0]))).astype(np.float32)
|
||||||
unchanged = xyzi.copy()
|
unchanged = xyzi.copy()
|
||||||
|
|
||||||
result = LocalPercentileGroundSegmenter(
|
result = LocalPercentileGroundSegmenter(profile=DEFAULT_GROUND_BENCHMARK_PROFILE).segment(xyzi)
|
||||||
profile=DEFAULT_GROUND_BENCHMARK_PROFILE
|
|
||||||
).segment(xyzi)
|
|
||||||
|
|
||||||
assert result.ground_mask.shape == (10,)
|
assert result.ground_mask.shape == (10,)
|
||||||
assert result.assigned_mask.all()
|
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["status"] == "diagnostic-only"
|
||||||
assert result.report["input_domain"]["accepted"] is False
|
assert result.report["input_domain"]["accepted"] is False
|
||||||
assert result.report["labels"]["metrics_available"] is False
|
assert result.report["labels"]["metrics_available"] is False
|
||||||
assert (
|
assert result.report["decision"]["status"] == "do-not-promote-on-current-vendor-map"
|
||||||
result.report["decision"]["status"]
|
|
||||||
== "do-not-promote-on-current-vendor-map"
|
|
||||||
)
|
|
||||||
assert result.arrays["current_ground"].shape == (20,)
|
assert result.arrays["current_ground"].shape == (20,)
|
||||||
assert result.arrays["candidate_ground"].shape == (20,)
|
assert result.arrays["candidate_ground"].shape == (20,)
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -195,6 +192,66 @@ def test_ground_benchmark_is_immutable_diagnostic_evidence(tmp_path: Path) -> No
|
||||||
LidarGroundBenchmarkV1(output)
|
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(
|
def test_annotation_template_starts_all_ignore_and_never_ground_truth(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -234,6 +291,10 @@ def test_ground_api_is_read_only_path_free_and_pack_filtered(
|
||||||
router,
|
router,
|
||||||
"/api/v1/lidar/ground-benchmarks/{benchmark_id}",
|
"/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]
|
catalog = catalog_route( # type: ignore[operator]
|
||||||
pack_id=replay.pack_id,
|
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]
|
detail = detail_route(benchmark_id=output.name) # type: ignore[operator]
|
||||||
|
|
||||||
assert (
|
assert catalog["schema_version"] == "missioncore.lidar-ground-benchmark-catalog/v1"
|
||||||
catalog["schema_version"]
|
|
||||||
== "missioncore.lidar-ground-benchmark-catalog/v1"
|
|
||||||
)
|
|
||||||
assert catalog["valid_total"] == 1
|
assert catalog["valid_total"] == 1
|
||||||
assert catalog["items"][0]["decision"]["production_promotion"] is False
|
assert catalog["items"][0]["decision"]["production_promotion"] is False
|
||||||
assert detail["benchmark"]["benchmark_id"] == output.name
|
assert detail["benchmark"]["benchmark_id"] == output.name
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue