From f2f044080f35256aad9b7222696c791e0e769614 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 25 Jul 2026 21:12:37 +0300 Subject: [PATCH] feat: qualify complete RELLIS ground dataset --- .../src/core/lidar/datasetGateway.ts | 4 +- .../src/core/polygon/groundQualification.ts | 54 +- .../workspaces/DatasetGatewayWorkspace.tsx | 44 +- .../src/workspaces/GooseDatasetReview.tsx | 3 + .../src/workspaces/RellisDatasetReview.tsx | 339 +++++- .../test/groundQualification.test.mjs | 34 + src/k1link/datasets/__init__.py | 26 + src/k1link/datasets/cli.py | 90 ++ src/k1link/datasets/gateway.py | 41 +- src/k1link/datasets/rellis_admission.py | 417 +++++++ src/k1link/datasets/rellis_profile.py | 104 ++ src/k1link/datasets/rellis_qualification.py | 1050 +++++++++++++++++ src/k1link/web/app.py | 3 + src/k1link/web/lidar_api.py | 2 + src/k1link/web/polygon_api.py | 57 +- tests/test_polygon_api.py | 38 + tests/test_rellis_qualification.py | 78 ++ 17 files changed, 2349 insertions(+), 35 deletions(-) create mode 100644 src/k1link/datasets/rellis_admission.py create mode 100644 src/k1link/datasets/rellis_profile.py create mode 100644 src/k1link/datasets/rellis_qualification.py create mode 100644 tests/test_rellis_qualification.py diff --git a/apps/control-station/src/core/lidar/datasetGateway.ts b/apps/control-station/src/core/lidar/datasetGateway.ts index ffdef73..65653d8 100644 --- a/apps/control-station/src/core/lidar/datasetGateway.ts +++ b/apps/control-station/src/core/lidar/datasetGateway.ts @@ -12,7 +12,8 @@ export type DatasetSourceAdmissionStatus = | "verifying" | "verified" | "frame-ready" - | "smoke-ready"; + | "smoke-ready" + | "dataset-ready"; export interface DatasetSource { sourceId: string; @@ -308,6 +309,7 @@ export function parseDatasetGatewayCatalog( && admissionStatus !== "verified" && admissionStatus !== "frame-ready" && admissionStatus !== "smoke-ready" + && admissionStatus !== "dataset-ready" ) { throw new DatasetGatewayContractError("source admission status неизвестен"); } diff --git a/apps/control-station/src/core/polygon/groundQualification.ts b/apps/control-station/src/core/polygon/groundQualification.ts index 7cec1b6..9f5d753 100644 --- a/apps/control-station/src/core/polygon/groundQualification.ts +++ b/apps/control-station/src/core/polygon/groundQualification.ts @@ -42,7 +42,10 @@ export interface GroundQualification { checks: QualificationCheck[]; worstFrames: QualificationWorstFrame[]; decision: { - status: "shadow-candidate" | "qualification-rejected"; + status: + | "shadow-candidate" + | "cross-dataset-qualified" + | "qualification-rejected"; passed: boolean; promotedToNavigationOrSafety: false; reason: string; @@ -75,7 +78,7 @@ export interface GroundReviewFrameSummary { frameId: string; datasetSequenceId: string; datasetFrameNumber: number; - sensorTimestampNs: string; + sensorTimestampNs: string | null; sourcePointCount: number; pointCount: number; current: GroundReviewFrameMetrics; @@ -264,7 +267,11 @@ export function decodeGroundQualification(payload: unknown): GroundQualification ); const decision = record(source.decision, "decision"); const status = text(decision.status, "decision.status", 64); - if (status !== "shadow-candidate" && status !== "qualification-rejected") { + if ( + status !== "shadow-candidate" + && status !== "cross-dataset-qualified" + && status !== "qualification-rejected" + ) { throw new GroundQualificationContractError("Решение квалификации неизвестно."); } if (decision.promoted_to_navigation_or_safety !== false) { @@ -384,7 +391,11 @@ export function decodeGroundReview(payload: unknown): GroundReview { throw new GroundQualificationContractError("Неизвестная схема покадрового просмотра."); } const frameCount = integer(source.frame_count, "frame_count", 1); - const frames = array(source.frames, "frames", 2_000).map( + const sourceId = text(source.source_id, "source_id"); + if (sourceId !== "goose-3d/v2025-08-22" && sourceId !== "rellis-3d/v1.1") { + throw new GroundQualificationContractError("Источник review неизвестен."); + } + const frames = array(source.frames, "frames", 3_000).map( (value, index): GroundReviewFrameSummary => { const frame = record(value, `frames[${index}]`); const sequence = integer(frame.sequence, `frames[${index}].sequence`); @@ -392,7 +403,6 @@ export function decodeGroundReview(payload: unknown): GroundReview { throw new GroundQualificationContractError("Кадры просмотра идут не по порядку."); } const frameId = id(frame.frame_id, `frames[${index}].frame_id`); - const frameIdentity = parseGooseFrameId(frameId); const datasetSequenceId = id( frame.dataset_sequence_id, `frames[${index}].dataset_sequence_id`, @@ -401,18 +411,30 @@ export function decodeGroundReview(payload: unknown): GroundReview { frame.dataset_frame_number, `frames[${index}].dataset_frame_number`, ); - const sensorTimestampNs = text( - frame.sensor_timestamp_ns, - `frames[${index}].sensor_timestamp_ns`, - 20, - ); - if ( - datasetSequenceId !== frameIdentity.datasetSequenceId - || datasetFrameNumber !== frameIdentity.datasetFrameNumber - || sensorTimestampNs !== frameIdentity.sensorTimestampNs + let sensorTimestampNs: string | null = null; + if (sourceId === "goose-3d/v2025-08-22") { + const frameIdentity = parseGooseFrameId(frameId); + sensorTimestampNs = text( + frame.sensor_timestamp_ns, + `frames[${index}].sensor_timestamp_ns`, + 20, + ); + if ( + datasetSequenceId !== frameIdentity.datasetSequenceId + || datasetFrameNumber !== frameIdentity.datasetFrameNumber + || sensorTimestampNs !== frameIdentity.sensorTimestampNs + ) { + throw new GroundQualificationContractError( + `frames[${index}] противоречит идентичности GOOSE-кадра.`, + ); + } + } else if ( + frame.sensor_timestamp_ns !== null + || !/^rellis-0000[0-4]-[0-9]{6}$/.test(frameId) + || frameId !== `rellis-${datasetSequenceId}-${String(datasetFrameNumber).padStart(6, "0")}` ) { throw new GroundQualificationContractError( - `frames[${index}] противоречит идентичности GOOSE-кадра.`, + `frames[${index}] противоречит идентичности RELLIS-кадра.`, ); } return { @@ -457,7 +479,7 @@ export function decodeGroundReview(payload: unknown): GroundReview { return { runId: id(source.run_id, "run_id"), identitySha256, - sourceId: text(source.source_id, "source_id"), + sourceId, frameCount, previewPoints: integer(source.preview_points, "preview_points", 1), frames, diff --git a/apps/control-station/src/workspaces/DatasetGatewayWorkspace.tsx b/apps/control-station/src/workspaces/DatasetGatewayWorkspace.tsx index 529840d..8790bf2 100644 --- a/apps/control-station/src/workspaces/DatasetGatewayWorkspace.tsx +++ b/apps/control-station/src/workspaces/DatasetGatewayWorkspace.tsx @@ -41,6 +41,7 @@ function admissionLabel(status: DatasetSourceAdmissionStatus): string { verified: "Проверен", "frame-ready": "Dataset готов", "smoke-ready": "Smoke готов", + "dataset-ready": "Dataset готов", }; return labels[status]; } @@ -60,9 +61,17 @@ function isGooseQualificationRun( && item.scenarioGeneration.startsWith("goose-3d"); } +function isRellisQualificationRun( + item: PolygonRunCatalog["items"][number], +): boolean { + return item.kind === "replay_shadow" + && item.scenarioGeneration.startsWith("rellis-3d"); +} + function sourceReady(source: DatasetSource): boolean { return source.admissionStatus === "frame-ready" - || source.admissionStatus === "smoke-ready"; + || source.admissionStatus === "smoke-ready" + || source.admissionStatus === "dataset-ready"; } function sourceDescription(source: DatasetSource): string { @@ -76,6 +85,9 @@ function sourceFooter(source: DatasetSource, storageReady: boolean): string { if (source.admissionStatus === "frame-ready") { return "Native scans и разметка приняты. Можно открыть фрагменты и результаты алгоритмов."; } + if (source.admissionStatus === "dataset-ready") { + return "Все официальные scans, labels, poses и split-индексы выровнены на worker D."; + } if (source.admissionStatus === "smoke-ready") { return "Официальный пример совместим. Можно проверить scan, классы и ground mapping."; } @@ -98,6 +110,7 @@ export function DatasetGatewayWorkspace() { const [openSourceId, setOpenSourceId] = useState(null); const [runCatalog, setRunCatalog] = useState(null); const [selectedRunId, setSelectedRunId] = useState(null); + const [selectedRellisRunId, setSelectedRellisRunId] = useState(null); const [runError, setRunError] = useState(null); useEffect(() => { @@ -143,6 +156,11 @@ export function DatasetGatewayWorkspace() { ?? gooseRuns[0] ?? null; setSelectedRunId(selected?.runId ?? null); + const rellisRuns = value.items.filter(isRellisQualificationRun); + const selectedRellis = rellisRuns.find((item) => item.runId === requested) + ?? rellisRuns[0] + ?? null; + setSelectedRellisRunId(selectedRellis?.runId ?? null); }) .catch((loadError: unknown) => { if (!controller.signal.aborted) setRunError(errorMessage(loadError)); @@ -151,6 +169,7 @@ export function DatasetGatewayWorkspace() { }, []); const gooseRuns = runCatalog?.items.filter(isGooseQualificationRun) ?? []; + const rellisRuns = runCatalog?.items.filter(isRellisQualificationRun) ?? []; const openSource = catalog?.sources.find( (source) => source.sourceId === openSourceId, ) ?? null; @@ -322,7 +341,28 @@ export function DatasetGatewayWorkspace() {

{runError ?? "Нет совместимой версии покадрового анализа."}

) : openSource?.sourceKind === "rellis" ? ( - + <> + {rellisRuns.length > 1 && selectedRellisRunId ? ( +
+
+ РЕЗУЛЬТАТЫ ОБРАБОТКИ + Версия анализа +
+ +
+ ) : null} + + ) : null}
diff --git a/apps/control-station/src/workspaces/GooseDatasetReview.tsx b/apps/control-station/src/workspaces/GooseDatasetReview.tsx index b48a6f0..bf6be32 100644 --- a/apps/control-station/src/workspaces/GooseDatasetReview.tsx +++ b/apps/control-station/src/workspaces/GooseDatasetReview.tsx @@ -68,6 +68,9 @@ function secondsBetween( left: GroundReviewFrameSummary, right: GroundReviewFrameSummary, ): number { + if (left.sensorTimestampNs === null || right.sensorTimestampNs === null) { + throw new Error("GOOSE frame timestamp отсутствует."); + } const delta = BigInt(right.sensorTimestampNs) - BigInt(left.sensorTimestampNs); return Number(delta) / 1_000_000_000; } diff --git a/apps/control-station/src/workspaces/RellisDatasetReview.tsx b/apps/control-station/src/workspaces/RellisDatasetReview.tsx index 59aee0d..bf1a45d 100644 --- a/apps/control-station/src/workspaces/RellisDatasetReview.tsx +++ b/apps/control-station/src/workspaces/RellisDatasetReview.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { StatusBadge } from "@nodedc/ui-react"; import { @@ -9,6 +9,13 @@ import { LidarGroundPointCloud, type LidarGroundViewMode, } from "./LidarGroundPointCloud"; +import { + fetchGroundReview, + fetchGroundReviewFrame, + type GroundReview, + type GroundReviewFrame, + type GroundReviewFrameSummary, +} from "../core/polygon/groundQualification"; const modes: ReadonlyArray<[LidarGroundViewMode, string]> = [ ["intensity", "Исходный скан"], @@ -39,7 +46,25 @@ function modeExplanation(mode: LidarGroundViewMode): string { return "Один исходный оборот Ouster OS1 64; цвет — нормализованная remission."; } -export function RellisDatasetReview() { +interface RellisDatasetReviewProps { + runId?: string | null; +} + +const qualifiedModes: ReadonlyArray<[LidarGroundViewMode, string]> = [ + ["intensity", "Исходный скан"], + ["ground-truth", "Ground truth"], + ["current", "Текущий алгоритм"], + ["candidate", "Patchwork++"], + ["disagreement", "Ошибки текущего"], + ["candidate-disagreement", "Ошибки Patchwork++"], +]; + +export function RellisDatasetReview({ runId = null }: RellisDatasetReviewProps) { + if (runId) return ; + return ; +} + +function RellisSmokeReview() { const [preview, setPreview] = useState(null); const [error, setError] = useState(null); const [mode, setMode] = useState("semantic"); @@ -237,3 +262,313 @@ export function RellisDatasetReview() { ); } + +function reviewScene(frame: GroundReviewFrame | null) { + if (!frame) return null; + return { + pointCount: frame.pointCount, + pointsXyzM: frame.pointsXyzM, + intensity0To255: frame.intensity0To255, + masks: { + currentGround: frame.currentGround, + currentAssigned: frame.evaluated, + candidateGround: frame.patchworkGround, + candidateAssigned: frame.evaluated, + disagreement: frame.currentDisagreement, + candidateDisagreement: frame.patchworkDisagreement, + groundTruthGround: frame.groundTruthGround, + }, + }; +} + +function groupRellisFrames(review: GroundReview) { + const groups = new Map(); + review.frames.forEach((frame) => { + const frames = groups.get(frame.datasetSequenceId) ?? []; + frames.push(frame); + groups.set(frame.datasetSequenceId, frames); + }); + return [...groups.entries()].map(([sequenceId, frames]) => ({ + sequenceId, + frames, + })); +} + +function averageMetric( + frames: GroundReviewFrameSummary[], + value: (frame: GroundReviewFrameSummary) => number, +) { + return frames.reduce((total, frame) => total + value(frame), 0) / frames.length; +} + +function qualifiedModeExplanation(mode: LidarGroundViewMode) { + if (mode === "intensity") return "Один исходный Ouster OS1 scan; цвет — remission."; + if (mode === "ground-truth") return "Point-aligned ground target из RELLIS labels."; + if (mode === "current") return "Результат текущего Mission Core baseline."; + if (mode === "candidate") return "Результат закреплённого Patchwork++ v1.4.1."; + if (mode === "disagreement") return "Красным — ошибки текущего baseline."; + return "Красным — ошибки Patchwork++ относительно labels."; +} + +function RellisQualifiedReview({ runId }: { runId: string }) { + const [review, setReview] = useState(null); + const [frame, setFrame] = useState(null); + const [selectedSequence, setSelectedSequence] = useState(null); + const [selectedFrame, setSelectedFrame] = useState(null); + const [mode, setMode] = useState("intensity"); + const [playing, setPlaying] = useState(false); + const [error, setError] = useState(null); + const cache = useRef(new Map()); + + useEffect(() => { + const controller = new AbortController(); + setError(null); + setReview(null); + setFrame(null); + cache.current.clear(); + void fetchGroundReview(runId, { signal: controller.signal }) + .then((value) => { + if (controller.signal.aborted || !value) return; + setReview(value); + setSelectedSequence(value.frames[0]?.datasetSequenceId ?? null); + setSelectedFrame(value.frames[0]?.frameId ?? null); + }) + .catch((loadError: unknown) => { + if (!controller.signal.aborted) setError(errorMessage(loadError)); + }); + return () => controller.abort(); + }, [runId]); + + const sequences = useMemo( + () => review ? groupRellisFrames(review) : [], + [review], + ); + const sequence = sequences.find((item) => item.sequenceId === selectedSequence) + ?? sequences[0] + ?? null; + const position = Math.max( + 0, + sequence?.frames.findIndex((item) => item.frameId === selectedFrame) ?? 0, + ); + const summary = sequence?.frames[position] ?? null; + + useEffect(() => { + if (!selectedFrame) return; + const cached = cache.current.get(selectedFrame); + if (cached) { + setFrame(cached); + return; + } + const controller = new AbortController(); + void fetchGroundReviewFrame(runId, selectedFrame, { signal: controller.signal }) + .then((value) => { + if (controller.signal.aborted) return; + cache.current.set(value.frameId, value); + while (cache.current.size > 32) { + const first = cache.current.keys().next().value as string | undefined; + if (first) cache.current.delete(first); + else break; + } + setFrame(value); + }) + .catch((loadError: unknown) => { + if (!controller.signal.aborted) setError(errorMessage(loadError)); + }); + return () => controller.abort(); + }, [runId, selectedFrame]); + + useEffect(() => { + if (!playing || !sequence || frame?.frameId !== selectedFrame) return; + const timer = window.setTimeout(() => { + const next = sequence.frames[position + 1]; + if (!next) { + setPlaying(false); + return; + } + setSelectedFrame(next.frameId); + }, 250); + return () => window.clearTimeout(timer); + }, [frame?.frameId, playing, position, selectedFrame, sequence]); + + if (error) { + return ( +
+ Review недоступен +

RELLIS-3D не открыт

+

{error}

+
+ ); + } + if (!review || !sequence) { + return ( +
+ Читаем индекс +

Открываем RELLIS validation

+

Исходные архивы остаются на worker D; в браузер идёт bounded preview.

+
+ ); + } + + const selectPosition = (value: number) => { + const bounded = Math.max(0, Math.min(value, sequence.frames.length - 1)); + setPlaying(false); + setSelectedFrame(sequence.frames[bounded]?.frameId ?? null); + }; + const patchworkBetter = sequence.frames.filter( + (item) => item.groundIouDelta > 0, + ).length; + const scene = reviewScene(frame); + + return ( +
+
+
+ RELLIS · FULL VALIDATION +

Пять последовательностей Ouster OS1 с покадровым сравнением

+

+ Все {review.frameCount.toLocaleString("ru-RU")} validation-сканов + выровнены с labels и pose index. Высота Patchwork++ определена только + на train split; validation labels использованы исключительно для оценки. +

+
+
+
Последовательностей
{sequences.length}
+
Validation scans
{review.frameCount}
+
Preview
{review.previewPoints.toLocaleString("ru-RU")} точек
+
+
+ +
+ + + + Индексы {sequence.frames[0]?.datasetFrameNumber ?? 0}–{ + sequence.frames[sequence.frames.length - 1]?.datasetFrameNumber ?? 0 + } + +
+ +
+
+
+ + SCAN {position + 1} / {sequence.frames.length} + {" · SOURCE FRAME "}{summary?.datasetFrameNumber ?? "—"} + + {summary?.frameId ?? "Загружаем scan"} +
+
+ {qualifiedModes.map(([viewMode, label]) => ( + + ))} +
+
+
+ + + + selectPosition(Number(event.target.value))} + /> + {position + 1} / {sequence.frames.length} +
+
+ {scene ? ( + + ) : ( +
Загружаем scan…
+ )} +
+ + {qualifiedModes.find(([viewMode]) => viewMode === mode)?.[1]} + + {qualifiedModeExplanation(mode)} +
+
+
+ +
+
+ Ground IoU · Current + {formatPercent(averageMetric(sequence.frames, (item) => item.current.groundIou))} + среднее по выбранной sequence +
+
+ Ground IoU · Patchwork++ + + {formatPercent(averageMetric(sequence.frames, (item) => item.patchwork.groundIou))} + + лучше Current на {patchworkBetter} scans +
+
+ Obstacle recall · Patchwork++ + + {formatPercent( + averageMetric( + sequence.frames, + (item) => item.patchwork.obstacleNonGroundRecall, + ), + )} + + non-ground сохранён как препятствие +
+
+ +

+ Это независимая исследовательская проверка переносимости. Она не + включает управление машиной и не принимает алгоритм для navigation/safety. + Лицензия исходника — CC-BY-NC-SA-3.0. +

+
+ ); +} diff --git a/apps/control-station/test/groundQualification.test.mjs b/apps/control-station/test/groundQualification.test.mjs index 39f679d..74cad0b 100644 --- a/apps/control-station/test/groundQualification.test.mjs +++ b/apps/control-station/test/groundQualification.test.mjs @@ -184,6 +184,40 @@ test("decodes a complete ordered frame review", () => { assert.equal(decoded.frames[0].patchwork.groundIou, 0.7); }); +test("decodes RELLIS sequences without inventing sensor timestamps", () => { + const metric = { + ground_iou: 0.6, + natural_ground_recall: 0.7, + obstacle_non_ground_recall: 0.93, + latency_ms: 18, + }; + const decoded = decodeGroundReview({ + schema_version: "missioncore.polygon-ground-review/v2", + access: "read-only", + run_id: "rellis-ground-abc", + identity_sha256: "c".repeat(64), + source_id: "rellis-3d/v1.1", + frame_count: 1, + preview_points: 12_000, + frames: [{ + sequence: 0, + frame_id: "rellis-00000-000307", + dataset_sequence_id: "00000", + dataset_frame_number: 307, + sensor_timestamp_ns: null, + source_point_count: 131_072, + point_count: 12_000, + current: metric, + patchworkpp: metric, + ground_iou_delta: 0, + }], + }); + assert.equal(decoded.sourceId, "rellis-3d/v1.1"); + assert.equal(decoded.frames[0].datasetSequenceId, "00000"); + assert.equal(decoded.frames[0].datasetFrameNumber, 307); + assert.equal(decoded.frames[0].sensorTimestampNs, null); +}); + test("parses GOOSE sequence, source frame and nanosecond timestamp", () => { assert.deepEqual( parseGooseFrameId("2023-05-17_neubiberg_sunny__0459_1684330012686564560"), diff --git a/src/k1link/datasets/__init__.py b/src/k1link/datasets/__init__.py index aff21c6..d3aeb3f 100644 --- a/src/k1link/datasets/__init__.py +++ b/src/k1link/datasets/__init__.py @@ -38,6 +38,22 @@ from k1link.datasets.goose_qualification import ( GroundAcceptancePolicy, qualify_goose_ground, ) +from k1link.datasets.rellis_admission import ( + RELLIS_ADMISSION_SCHEMA, + RellisAdmissionError, + admit_rellis_release, +) +from k1link.datasets.rellis_profile import ( + RELLIS_PATCHWORK_PROFILE_SCHEMA, + RellisPatchworkProfile, +) +from k1link.datasets.rellis_qualification import ( + RELLIS_QUALIFICATION_REPORT_SCHEMA, + RELLIS_REVIEW_PACK_SCHEMA, + RellisAcceptancePolicy, + calibrate_rellis_sensor_height, + qualify_rellis_ground, +) from k1link.datasets.rellis_smoke import ( RELLIS_CLASSES, RELLIS_GROUND_POLICY_SCHEMA, @@ -64,9 +80,16 @@ __all__ = [ "GOOSE_QUALIFICATION_PROFILE_SCHEMA", "GOOSE_QUALIFICATION_REPORT_SCHEMA", "RELLIS_CLASSES", + "RELLIS_ADMISSION_SCHEMA", "RELLIS_GROUND_POLICY_SCHEMA", + "RELLIS_PATCHWORK_PROFILE_SCHEMA", "RELLIS_PREVIEW_SCHEMA", + "RELLIS_QUALIFICATION_REPORT_SCHEMA", + "RELLIS_REVIEW_PACK_SCHEMA", "RELLIS_SOURCE_ID", + "RellisAcceptancePolicy", + "RellisAdmissionError", + "RellisPatchworkProfile", "RellisSmokeError", "GoosePatchworkProfile", "GroundAcceptancePolicy", @@ -74,7 +97,9 @@ __all__ = [ "DEFAULT_DEGRADATIONS", "benchmark_goose_current_ground", "benchmark_goose_patchwork_ground", + "admit_rellis_release", "build_rellis_official_smoke_preview", + "calibrate_rellis_sensor_height", "configured_dataset_admission_manifest", "configured_dataset_ground_preview", "configured_dataset_preview", @@ -85,5 +110,6 @@ __all__ = [ "read_dataset_native_scan_preview", "read_semantic_kitti_frame", "rellis_native_scan_preview", + "qualify_rellis_ground", "qualify_goose_ground", ] diff --git a/src/k1link/datasets/cli.py b/src/k1link/datasets/cli.py index 248f5b1..8157991 100644 --- a/src/k1link/datasets/cli.py +++ b/src/k1link/datasets/cli.py @@ -15,6 +15,8 @@ from k1link.datasets.goose_benchmark import ( ) from k1link.datasets.goose_qualification import qualify_goose_ground from k1link.datasets.goose_review import build_goose_ground_review_pack +from k1link.datasets.rellis_admission import RellisAdmissionError, admit_rellis_release +from k1link.datasets.rellis_qualification import qualify_rellis_ground from k1link.datasets.rellis_smoke import ( RellisSmokeError, build_rellis_official_smoke_preview, @@ -207,5 +209,93 @@ def build_rellis_smoke_preview_command( ) +@app.command("admit-rellis") +def admit_rellis_command( + dataset_root: Annotated[ + Path, + typer.Option("--dataset-root", exists=True, file_okay=False, resolve_path=True), + ], + scan_archive: Annotated[ + Path | None, + typer.Option("--scan-archive", exists=True, dir_okay=False, resolve_path=True), + ] = None, + label_archive: Annotated[ + Path | None, + typer.Option("--label-archive", exists=True, dir_okay=False, resolve_path=True), + ] = None, + pose_archive: Annotated[ + Path | None, + typer.Option("--pose-archive", exists=True, dir_okay=False, resolve_path=True), + ] = None, + split_archive: Annotated[ + Path | None, + typer.Option("--split-archive", exists=True, dir_okay=False, resolve_path=True), + ] = None, + preview_points: Annotated[ + int, + typer.Option("--preview-points", min=1, max=20_000), + ] = 20_000, +) -> None: + """Verify the complete official RELLIS release on worker D.""" + + try: + manifest = admit_rellis_release( + dataset_root, + scan_archive=scan_archive, + label_archive=label_archive, + pose_archive=pose_archive, + split_archive=split_archive, + preview_points=preview_points, + ) + except RellisAdmissionError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=2) from exc + typer.echo(json.dumps(manifest, ensure_ascii=False, sort_keys=True)) + + +@app.command("qualify-rellis-ground") +def qualify_rellis_ground_command( + dataset_root: Annotated[ + Path, + typer.Option("--dataset-root", exists=True, file_okay=False, resolve_path=True), + ], + runs_root: Annotated[ + Path, + typer.Option("--runs-root", file_okay=False, resolve_path=True), + ], + mission_core_commit: Annotated[ + str, + typer.Option("--mission-core-commit", min=7, max=64), + ], + workers: Annotated[ + int, + typer.Option("--workers", min=1, max=32), + ] = 8, + calibration_frames: Annotated[ + int, + typer.Option("--calibration-frames", min=5, max=256), + ] = 64, + preview_points: Annotated[ + int, + typer.Option("--preview-points", min=1, max=20_000), + ] = 12_000, +) -> None: + """Calibrate on RELLIS train and qualify Current/Patchwork++ on validation.""" + + try: + report = qualify_rellis_ground( + dataset_root, + runs_root, + mission_core_commit=mission_core_commit, + parallel_workers=workers, + calibration_frame_count=calibration_frames, + preview_points=preview_points, + ) + except (RellisAdmissionError, ValueError) as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=2) from exc + typer.echo(json.dumps(report, ensure_ascii=False, sort_keys=True)) + + if __name__ == "__main__": app() diff --git a/src/k1link/datasets/gateway.py b/src/k1link/datasets/gateway.py index 4f46544..08a48e9 100644 --- a/src/k1link/datasets/gateway.py +++ b/src/k1link/datasets/gateway.py @@ -178,6 +178,7 @@ def dataset_gateway_catalog( dataset_root: Path | None = None, admission_manifest_path: Path | None = None, rellis_preview_path: Path | None = None, + rellis_admission_path: Path | None = None, ) -> dict[str, object]: """Return the path-free, read-only ingress plan and current admission state.""" @@ -205,6 +206,34 @@ def dataset_gateway_catalog( rellis_preview = candidate except DatasetAdmissionError: pass + rellis_admission: dict[str, Any] | None = None + if rellis_admission_path is not None and rellis_admission_path.is_file(): + try: + candidate = _bounded_json_object( + rellis_admission_path, + MAX_STATE_BYTES, + "RELLIS admission manifest", + ) + storage = _object(candidate.get("storage"), "RELLIS storage") + alignment = _object(candidate.get("alignment"), "RELLIS alignment") + release = _object(candidate.get("release"), "RELLIS release") + if ( + candidate.get("schema_version") != "missioncore.rellis-admission/v1" + or candidate.get("source_id") != "rellis-3d/v1.1" + or candidate.get("status") != "dataset-ready" + or storage.get("policy") != "worker-d-only" + or storage.get("admitted") is not True + or storage.get("path_exposed") is not False + or alignment.get("annotated_scan_count") != 13_556 + or alignment.get("point_label_alignment") != "complete" + or alignment.get("pose_index_coverage") != "complete" + or not isinstance(release.get("identity_sha256"), str) + or len(release["identity_sha256"]) != 64 + ): + raise DatasetAdmissionError("RELLIS admission manifest is incompatible") + rellis_admission = candidate + except DatasetAdmissionError: + pass source_status = ( str(admission["status"]) if admission is not None @@ -222,14 +251,18 @@ def dataset_gateway_catalog( else "configure-dataset-root-on-worker-d" ) rellis_status = ( - "smoke-ready" + "dataset-ready" + if rellis_admission is not None and rellis_preview is not None + else "smoke-ready" if rellis_preview is not None else "ready-for-smoke" if storage_admitted else "blocked-storage-policy" ) catalog_next_action = ( - "admit-rellis-ouster-semantickitti-to-worker-d" + "qualify-rellis-current-vs-patchworkpp" + if rellis_admission is not None + else "admit-rellis-ouster-semantickitti-to-worker-d" if rellis_preview is not None and source_status == "frame-ready" else "build-rellis-official-example-smoke" if source_status == "frame-ready" @@ -342,7 +375,9 @@ def dataset_gateway_catalog( "admission": { "status": rellis_status, "native_scan": ( - "official-example-compatible" + "complete-release-split-aligned" + if rellis_admission is not None + else "official-example-compatible" if rellis_preview is not None else "requires-official-example-smoke" ), diff --git a/src/k1link/datasets/rellis_admission.py b/src/k1link/datasets/rellis_admission.py new file mode 100644 index 0000000..2fd83f2 --- /dev/null +++ b/src/k1link/datasets/rellis_admission.py @@ -0,0 +1,417 @@ +"""Fail-closed admission of the complete official RELLIS-3D release. + +All four source archives remain on the Simulation Worker D drive. Admission +indexes the ZIP central directories, proves split/scan/label/pose alignment and +publishes only a path-free manifest plus one bounded validation-frame preview. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import tempfile +import zipfile +from collections import Counter +from datetime import UTC, datetime +from pathlib import Path, PurePosixPath +from typing import Any, Final + +from k1link.datasets.gateway import DatasetFrameError, decode_semantic_kitti_frame +from k1link.datasets.rellis_smoke import ( + RELLIS_LABEL_CONFIG_SHA256, + RELLIS_LICENSE, + RELLIS_REPOSITORY_COMMIT, + RELLIS_REPOSITORY_URL, + RELLIS_SOURCE_ID, + RellisSmokeError, + rellis_native_scan_preview, +) + +RELLIS_ADMISSION_SCHEMA: Final = "missioncore.rellis-admission/v1" +RELLIS_RELEASE_ID: Final = "v1.1" +RELLIS_ARCHIVE_ROOT: Final = "rellis-3d/v1.1/archives" +RELLIS_SCAN_ARCHIVE: Final = "Rellis_3D_os1_cloud_node_kitti_bin.zip" +RELLIS_LABEL_ARCHIVE: Final = "Rellis_3D_os1_labels_20210614.zip" +RELLIS_POSE_ARCHIVE: Final = "Rellis_3D_lidar_poses_20210614.zip" +RELLIS_SPLIT_ARCHIVE: Final = "Rellis_3D_lidar_split.zip" +RELLIS_ARCHIVE_URLS: Final[dict[str, str]] = { + RELLIS_SCAN_ARCHIVE: ( + "https://drive.google.com/uc?id=1lDSVRf_kZrD0zHHMsKJ0V1GN9QATR4wH" + ), + RELLIS_LABEL_ARCHIVE: ( + "https://drive.google.com/uc?id=12bsblHXtob60KrjV7lGXUQTdC5PhV8Er" + ), + RELLIS_POSE_ARCHIVE: ( + "https://drive.google.com/uc?id=1V3PT_NJhA41N7TBLp5AbW31d0ztQDQOX" + ), + RELLIS_SPLIT_ARCHIVE: ( + "https://drive.google.com/uc?id=1raQJPySyqDaHpc53KPnJVl3Bln6HlcVS" + ), +} +RELLIS_OBSERVED_SHA256: Final[dict[str, str]] = { + RELLIS_LABEL_ARCHIVE: ( + "03297c30b9f2182c74be93f8564d6ce1349237fcc4f89fc96479fe71bb40d905" + ), + RELLIS_POSE_ARCHIVE: ( + "6deaf38a9cac3a480cfdb70fa9a1d9278f25c36f5efd2487a3c4a6a400250f67" + ), + RELLIS_SPLIT_ARCHIVE: ( + "de639728e0058d7d477188028d9b811313a601884ef00d267ce13736be7cf54f" + ), +} +RELLIS_SPLIT_COUNTS: Final[dict[str, int]] = { + "train": 7_800, + "validation": 2_413, + "test": 3_343, +} +RELLIS_SEQUENCE_IDS: Final = ("00000", "00001", "00002", "00003", "00004") +RELLIS_ANNOTATED_SCAN_COUNT: Final = 13_556 +MAX_ARCHIVE_ENTRIES: Final = 40_000 +MAX_UNCOMPRESSED_BYTES: Final = 256 * 1024**3 +MAX_METADATA_MEMBER_BYTES: Final = 32 * 1024**2 +MAX_PREVIEW_POINTS: Final = 20_000 +_FRAME_PATH = re.compile( + r"^(?P0000[0-4])/os1_cloud_node_kitti_bin/" + r"(?P[0-9]{6})\.bin$" +) +_LABEL_PATH = re.compile( + r"^(?P0000[0-4])/os1_cloud_node_semantickitti_label_id/" + r"(?P[0-9]{6})\.label$" +) + + +class RellisAdmissionError(RuntimeError): + """The official RELLIS release cannot satisfy its pinned admission contract.""" + + +def admit_rellis_release( + dataset_root: Path, + *, + scan_archive: Path | None = None, + label_archive: Path | None = None, + pose_archive: Path | None = None, + split_archive: Path | None = None, + preview_points: int = MAX_PREVIEW_POINTS, +) -> dict[str, Any]: + """Verify the full official release without extracting its source archives.""" + + root = dataset_root.expanduser().absolute() + if not _is_worker_dataset_root(root): + raise RellisAdmissionError("RELLIS admission requires the canonical worker D root") + if not 1 <= preview_points <= MAX_PREVIEW_POINTS: + raise RellisAdmissionError("RELLIS preview point limit is outside the admitted range") + archive_root = root / RELLIS_ARCHIVE_ROOT + paths = { + RELLIS_SCAN_ARCHIVE: _archive_path(archive_root, scan_archive, RELLIS_SCAN_ARCHIVE), + RELLIS_LABEL_ARCHIVE: _archive_path(archive_root, label_archive, RELLIS_LABEL_ARCHIVE), + RELLIS_POSE_ARCHIVE: _archive_path(archive_root, pose_archive, RELLIS_POSE_ARCHIVE), + RELLIS_SPLIT_ARCHIVE: _archive_path(archive_root, split_archive, RELLIS_SPLIT_ARCHIVE), + } + if any(not path.is_file() for path in paths.values()): + raise RellisAdmissionError("one or more official RELLIS archives are unavailable") + + descriptors: dict[str, dict[str, Any]] = {} + for filename, path in paths.items(): + digest = _sha256_file(path) + observed = RELLIS_OBSERVED_SHA256.get(filename) + if observed is not None and digest != observed: + raise RellisAdmissionError(f"{filename} differs from the pinned observed release") + descriptors[filename] = { + "filename": filename, + "source_url": RELLIS_ARCHIVE_URLS[filename], + "size_bytes": path.stat().st_size, + "sha256": digest, + "vendor_checksum_available": False, + } + + try: + with ( + zipfile.ZipFile(paths[RELLIS_SCAN_ARCHIVE]) as scans_zip, + zipfile.ZipFile(paths[RELLIS_LABEL_ARCHIVE]) as labels_zip, + zipfile.ZipFile(paths[RELLIS_POSE_ARCHIVE]) as poses_zip, + zipfile.ZipFile(paths[RELLIS_SPLIT_ARCHIVE]) as splits_zip, + ): + scan_members = _member_index(scans_zip) + label_members = _member_index(labels_zip) + pose_members = _member_index(poses_zip) + split_members = _member_index(splits_zip) + splits = _read_splits(splits_zip, split_members) + split_pairs = { + pair for split in splits.values() for pair in split + } + if len(split_pairs) != RELLIS_ANNOTATED_SCAN_COUNT: + raise RellisAdmissionError("RELLIS split union is incomplete or overlapping") + + indexed_scans = { + path: member + for path, member in scan_members.items() + if _FRAME_PATH.fullmatch(path) + } + indexed_labels = { + path: member + for path, member in label_members.items() + if _LABEL_PATH.fullmatch(path) + } + expected_scans = {point_path for point_path, _ in split_pairs} + expected_labels = {label_path for _, label_path in split_pairs} + if set(indexed_scans) != expected_scans: + raise RellisAdmissionError("RELLIS scan archive does not match the official splits") + if set(indexed_labels) != expected_labels: + raise RellisAdmissionError( + "RELLIS label archive does not match the official splits" + ) + _validate_member_alignment(indexed_scans, indexed_labels, split_pairs) + pose_counts = _validate_poses(poses_zip, pose_members, split_pairs) + + validation_point, validation_label = splits["validation"][0] + point_bytes = scans_zip.read(indexed_scans[validation_point]) + label_bytes = labels_zip.read(indexed_labels[validation_label]) + frame = decode_semantic_kitti_frame(point_bytes, label_bytes) + frame_id = _qualified_frame_id(validation_point) + preview = rellis_native_scan_preview( + frame, + frame_id=frame_id, + maximum_points=preview_points, + source_evidence={ + "repository_url": RELLIS_REPOSITORY_URL, + "repository_commit": RELLIS_REPOSITORY_COMMIT, + "label_config_sha256": RELLIS_LABEL_CONFIG_SHA256, + "point_sha256": hashlib.sha256(point_bytes).hexdigest(), + "label_sha256": hashlib.sha256(label_bytes).hexdigest(), + "license": RELLIS_LICENSE, + }, + ) + except (OSError, KeyError, zipfile.BadZipFile, DatasetFrameError, RellisSmokeError) as exc: + raise RellisAdmissionError("RELLIS archives could not be verified") from exc + + sequence_counts = Counter( + _FRAME_PATH.fullmatch(point_path).group("sequence") # type: ignore[union-attr] + for point_path, _ in split_pairs + ) + install_identity = hashlib.sha256( + json.dumps(descriptors, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + install_root = root / "rellis-3d/v1.1/installs" / install_identity + preview_path = install_root / "previews" / f"{frame_id}.json" + _atomic_json(preview_path, preview) + manifest = { + "schema_version": RELLIS_ADMISSION_SCHEMA, + "source_id": RELLIS_SOURCE_ID, + "observed_at_utc": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + "status": "dataset-ready", + "storage": { + "policy": "worker-d-only", + "admitted": True, + "canonical_root": True, + "path_exposed": False, + }, + "release": { + "repository_url": RELLIS_REPOSITORY_URL, + "repository_commit": RELLIS_REPOSITORY_COMMIT, + "license": RELLIS_LICENSE, + "archives": descriptors, + "identity_sha256": install_identity, + }, + "alignment": { + "annotated_scan_count": len(split_pairs), + "split_counts": RELLIS_SPLIT_COUNTS, + "sequence_counts": dict(sorted(sequence_counts.items())), + "pose_counts": pose_counts, + "point_label_alignment": "complete", + "pose_index_coverage": "complete", + }, + "frame": { + "frame_id": frame_id, + "split": "validation", + "source_point_count": frame.point_count, + "preview_point_count": int(preview["point_count"]), + "preview_sha256": _sha256_file(preview_path), + }, + "coordinate_frame": { + "frame_id": "sensor/lidar/os1", + "x": "forward", + "y": "left", + "z": "up", + "transform_applied": False, + }, + "next_action": "calibrate-on-train-then-qualify-validation", + } + _atomic_json(root / "state/rellis-3d-v1.1.json", manifest) + return manifest + + +def read_rellis_splits(dataset_root: Path) -> dict[str, tuple[tuple[str, str], ...]]: + """Read the already admitted immutable split index.""" + + root = dataset_root.expanduser().absolute() + if not _is_worker_dataset_root(root): + raise RellisAdmissionError("RELLIS split access requires the canonical worker D root") + split_path = root / RELLIS_ARCHIVE_ROOT / RELLIS_SPLIT_ARCHIVE + try: + with zipfile.ZipFile(split_path) as source: + return _read_splits(source, _member_index(source)) + except (OSError, zipfile.BadZipFile) as exc: + raise RellisAdmissionError("RELLIS split archive cannot be indexed") from exc + + +def _archive_path(root: Path, value: Path | None, filename: str) -> Path: + return value.expanduser().absolute() if value is not None else root / filename + + +def _member_index(source: zipfile.ZipFile) -> dict[str, zipfile.ZipInfo]: + members = source.infolist() + if not members or len(members) > MAX_ARCHIVE_ENTRIES: + raise RellisAdmissionError("RELLIS archive entry count is outside the admitted range") + total = 0 + result: dict[str, zipfile.ZipInfo] = {} + for member in members: + path = PurePosixPath(member.filename.replace("\\", "/")) + if path.is_absolute() or ".." in path.parts: + raise RellisAdmissionError("RELLIS archive contains an unsafe member path") + if (member.external_attr >> 16) & 0o170000 == 0o120000: + raise RellisAdmissionError("RELLIS archive contains a symbolic link") + total += member.file_size + if total > MAX_UNCOMPRESSED_BYTES: + raise RellisAdmissionError("RELLIS archive exceeds the uncompressed safety limit") + if member.is_dir(): + continue + normalized = "/".join(part for part in path.parts if part not in {".", "Rellis-3D"}) + if not normalized or normalized in result: + raise RellisAdmissionError("RELLIS archive member identity is ambiguous") + result[normalized] = member + return result + + +def _read_splits( + source: zipfile.ZipFile, + members: dict[str, zipfile.ZipInfo], +) -> dict[str, tuple[tuple[str, str], ...]]: + result: dict[str, tuple[tuple[str, str], ...]] = {} + for split, expected in RELLIS_SPLIT_COUNTS.items(): + filename = "pt_val.lst" if split == "validation" else f"pt_{split}.lst" + matches = [member for path, member in members.items() if path.endswith(filename)] + if len(matches) != 1 or matches[0].file_size > MAX_METADATA_MEMBER_BYTES: + raise RellisAdmissionError(f"RELLIS {split} split index is unavailable") + try: + lines = source.read(matches[0]).decode("utf-8").splitlines() + except UnicodeDecodeError as exc: + raise RellisAdmissionError("RELLIS split index is not UTF-8") from exc + pairs: list[tuple[str, str]] = [] + for raw in lines: + parts = raw.strip().replace("\\", "/").split() + if len(parts) != 2: + raise RellisAdmissionError("RELLIS split row is malformed") + point_path, label_path = parts + point_match = _FRAME_PATH.fullmatch(point_path) + label_match = _LABEL_PATH.fullmatch(label_path) + if ( + point_match is None + or label_match is None + or point_match.group("sequence") != label_match.group("sequence") + or point_match.group("frame") != label_match.group("frame") + ): + raise RellisAdmissionError("RELLIS split point and label identities differ") + pairs.append((point_path, label_path)) + if len(pairs) != expected or len(set(pairs)) != expected: + raise RellisAdmissionError(f"RELLIS {split} split count differs") + result[split] = tuple(pairs) + if sum(map(len, result.values())) != len( + {pair for values in result.values() for pair in values} + ): + raise RellisAdmissionError("RELLIS split indexes overlap") + return result + + +def _validate_member_alignment( + scans: dict[str, zipfile.ZipInfo], + labels: dict[str, zipfile.ZipInfo], + pairs: set[tuple[str, str]], +) -> None: + for point_path, label_path in pairs: + point = scans[point_path] + label = labels[label_path] + if ( + point.file_size <= 0 + or point.file_size % 16 + or label.file_size % 4 + or point.file_size // 16 != label.file_size // 4 + ): + raise RellisAdmissionError("RELLIS point and label member sizes are not aligned") + + +def _validate_poses( + source: zipfile.ZipFile, + members: dict[str, zipfile.ZipInfo], + pairs: set[tuple[str, str]], +) -> dict[str, int]: + result: dict[str, int] = {} + required_by_sequence: dict[str, set[int]] = { + sequence: set() for sequence in RELLIS_SEQUENCE_IDS + } + for point_path, _ in pairs: + match = _FRAME_PATH.fullmatch(point_path) + assert match is not None + required_by_sequence[match.group("sequence")].add(int(match.group("frame"))) + for sequence in RELLIS_SEQUENCE_IDS: + path = f"{sequence}/poses.txt" + matches = [member for name, member in members.items() if name.endswith(path)] + if len(matches) != 1 or matches[0].file_size > MAX_METADATA_MEMBER_BYTES: + raise RellisAdmissionError(f"RELLIS poses are unavailable for sequence {sequence}") + try: + rows = source.read(matches[0]).decode("utf-8").splitlines() + except UnicodeDecodeError as exc: + raise RellisAdmissionError("RELLIS pose file is not UTF-8") from exc + for row in rows: + values = row.split() + if len(values) != 12: + raise RellisAdmissionError("RELLIS pose row is not a 3x4 transform") + try: + tuple(float(value) for value in values) + except ValueError as exc: + raise RellisAdmissionError("RELLIS pose row is not numeric") from exc + if not rows or max(required_by_sequence[sequence], default=-1) >= len(rows): + raise RellisAdmissionError("RELLIS pose index does not cover every split frame") + result[sequence] = len(rows) + return result + + +def _qualified_frame_id(point_path: str) -> str: + match = _FRAME_PATH.fullmatch(point_path) + if match is None: + raise RellisAdmissionError("RELLIS frame path is incompatible") + return f"{match.group('sequence')}-{match.group('frame')}" + + +def _is_worker_dataset_root(root: Path) -> bool: + return str(root).replace("\\", "/").rstrip("/").lower() == ( + "/mnt/d/ndc_missioncore/datasets" + ) + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + try: + with path.open("rb") as source: + for chunk in iter(lambda: source.read(8 * 1024**2), b""): + digest.update(chunk) + except OSError as exc: + raise RellisAdmissionError("RELLIS artifact cannot be hashed") from exc + return digest.hexdigest() + + +def _atomic_json(path: Path, document: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", + dir=path.parent, + encoding="utf-8", + delete=False, + ) as temporary: + temporary_path = Path(temporary.name) + json.dump(document, temporary, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + temporary.write("\n") + temporary.flush() + os.fsync(temporary.fileno()) + os.replace(temporary_path, path) diff --git a/src/k1link/datasets/rellis_profile.py b/src/k1link/datasets/rellis_profile.py new file mode 100644 index 0000000..2f9603f --- /dev/null +++ b/src/k1link/datasets/rellis_profile.py @@ -0,0 +1,104 @@ +"""Train-calibrated, algorithm-scoped RELLIS OS1 Patchwork++ profile.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, Final + +RELLIS_PATCHWORK_PROFILE_SCHEMA: Final = "missioncore.rellis-patchwork-profile/v1" +RELLIS_URDF_URL: Final = ( + "https://raw.githubusercontent.com/unmannedlab/RELLIS-3D/" + "c17a118fcaed1559f03cc32cc3a91dedc557f8b8/" + "catkin_ws/src/platform_description/urdf/warthog.urdf.xacro" +) +RELLIS_URDF_SHA256: Final = ( + "bcb66a05b8818f06479bb4a17ce93e9d66cb8fdaab726c437596807288116ac3" +) + + +@dataclass(frozen=True, slots=True) +class RellisPatchworkProfile: + """Frozen provider profile derived without looking at validation labels.""" + + patchwork_sensor_height_proxy_m: float + calibration_identity_sha256: str + calibration_frame_count: int + calibration_median_absolute_deviation_m: float + patchwork_minimum_range_m: float = 2.7 + patchwork_maximum_range_m: float = 80.0 + profile_id: str = "rellis-warthog-os1-patchwork/v1" + + def __post_init__(self) -> None: + values = ( + self.patchwork_sensor_height_proxy_m, + self.calibration_median_absolute_deviation_m, + self.patchwork_minimum_range_m, + self.patchwork_maximum_range_m, + ) + if ( + not self.profile_id + or not all(math.isfinite(value) for value in values) + or not 0.5 <= self.patchwork_sensor_height_proxy_m <= 3.0 + or not 5 <= self.calibration_frame_count <= 256 + or self.calibration_median_absolute_deviation_m < 0 + or not 0 < self.patchwork_minimum_range_m < self.patchwork_maximum_range_m + or len(self.calibration_identity_sha256) != 64 + or any( + character not in "0123456789abcdef" + for character in self.calibration_identity_sha256 + ) + ): + raise ValueError("RELLIS Patchwork++ profile is invalid") + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": RELLIS_PATCHWORK_PROFILE_SCHEMA, + "profile_id": self.profile_id, + "source_id": "rellis-3d/v1.1", + "representation": "native-scan", + "sensor_frame": { + "frame_id": "sensor/lidar/os1", + "handedness": "right", + "x": "forward", + "y": "left", + "z": "up", + "one_revolution": True, + }, + "height": { + "sensor_above_ground_m": self.patchwork_sensor_height_proxy_m, + "evidence": "train-split-ground-label-calibration", + "calibration_frame_count": self.calibration_frame_count, + "median_absolute_deviation_m": ( + self.calibration_median_absolute_deviation_m + ), + "calibration_identity_sha256": self.calibration_identity_sha256, + "validation_labels_used": False, + }, + "patchworkpp": { + "provider_id": "patchworkpp/v1.4.1", + "minimum_range_m": self.patchwork_minimum_range_m, + "maximum_range_m": self.patchwork_maximum_range_m, + "enable_rnr": True, + "enable_rvpf": True, + "enable_tgr": True, + }, + "evidence": { + "vehicle_urdf": { + "url": RELLIS_URDF_URL, + "sha256": RELLIS_URDF_SHA256, + "use": "topology-cross-check-only", + "exact_physical_height_claimed": False, + } + }, + "scope": { + "patchworkpp_eligible": True, + "normalized_scan_produced": False, + "complete_vehicle_transform_known": False, + "deskew_claimed": False, + }, + "authority": { + "qualification_only": True, + "navigation_or_safety_accepted": False, + }, + } diff --git a/src/k1link/datasets/rellis_qualification.py b/src/k1link/datasets/rellis_qualification.py new file mode 100644 index 0000000..b7bffea --- /dev/null +++ b/src/k1link/datasets/rellis_qualification.py @@ -0,0 +1,1050 @@ +"""Full RELLIS validation qualification and bounded operator review export.""" + +from __future__ import annotations + +import hashlib +import json +import os +import tempfile +import time +import zipfile +from concurrent.futures import ProcessPoolExecutor, as_completed +from dataclasses import asdict, dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Final + +import numpy as np + +from k1link.datasets.gateway import DatasetPointFrame, decode_semantic_kitti_frame +from k1link.datasets.goose_qualification import ( + _aggregate, + _check, + _provider_frame_result, + _validate_result, +) +from k1link.datasets.rellis_admission import ( + RELLIS_ADMISSION_SCHEMA, + RELLIS_ARCHIVE_ROOT, + RELLIS_LABEL_ARCHIVE, + RELLIS_SCAN_ARCHIVE, + RellisAdmissionError, + _is_worker_dataset_root, + _member_index, + read_rellis_splits, +) +from k1link.datasets.rellis_profile import RellisPatchworkProfile +from k1link.datasets.rellis_smoke import RELLIS_SOURCE_ID +from k1link.ground_segmentation import ( + DEFAULT_GROUND_BENCHMARK_PROFILE, + PATCHWORKPP_SOURCE_COMMIT, + GroundBenchmarkProfile, + GroundSegmentation, + GroundSegmenter, + LocalPercentileGroundSegmenter, + PatchworkPPGroundSegmenter, +) +from k1link.simulation.contracts import ( + AuthorityProfile, + ProviderPin, + QualificationArtifact, + QualificationRun, + ReproducibilityTier, + RunKind, + RunState, +) +from k1link.simulation.run_store import ( + QualificationRunConflictError, + QualificationRunStore, +) + +RELLIS_QUALIFICATION_REPORT_SCHEMA: Final = ( + "missioncore.rellis-ground-qualification-report/v1" +) +RELLIS_QUALIFICATION_FRAME_SCHEMA: Final = ( + "missioncore.rellis-ground-qualification-frame/v1" +) +RELLIS_REVIEW_PACK_SCHEMA: Final = "missioncore.rellis-ground-review-pack/v1" +RELLIS_REVIEW_FRAME_SCHEMA: Final = "missioncore.rellis-ground-review-frame/v1" +RELLIS_CALIBRATION_SCHEMA: Final = "missioncore.rellis-sensor-height-calibration/v1" +RELLIS_REPORT_ARTIFACT_KIND: Final = "rellis-ground-qualification-report" +EXPECTED_VALIDATION_FRAMES: Final = 2_413 +DEFAULT_CALIBRATION_FRAMES: Final = 64 +DEFAULT_REVIEW_POINTS: Final = 12_000 +MAX_REVIEW_POINTS: Final = 20_000 +GROUND_IDS: Final = frozenset({1, 3, 10, 23, 33}) +ARTIFICIAL_GROUND_IDS: Final = frozenset({10, 23}) +NATURAL_GROUND_IDS: Final = frozenset({1, 3, 33}) +NON_GROUND_IDS: Final = frozenset({4, 5, 8, 12, 15, 17, 18, 19, 27, 34}) +IGNORE_IDS: Final = frozenset({0, 6, 7, 9, 31}) +_WORKER_SCANS: zipfile.ZipFile | None = None +_WORKER_LABELS: zipfile.ZipFile | None = None +_WORKER_SCAN_INDEX: dict[str, zipfile.ZipInfo] | None = None +_WORKER_LABEL_INDEX: dict[str, zipfile.ZipInfo] | None = None +_WORKER_CURRENT: GroundSegmenter | None = None +_WORKER_PATCHWORK: GroundSegmenter | None = None + + +@dataclass(frozen=True, slots=True) +class RellisAcceptancePolicy: + """Predeclared portability gates; never an actuator/safety promotion.""" + + minimum_ground_iou_gain: float = 0.02 + minimum_obstacle_non_ground_recall: float = 0.90 + maximum_obstacle_recall_regression: float = 0.01 + minimum_assigned_fraction: float = 0.999 + maximum_patchwork_latency_p95_ms: float = 50.0 + maximum_per_frame_iou_regression: float = 0.20 + + def to_dict(self) -> dict[str, float]: + return asdict(self) + + +DEFAULT_RELLIS_ACCEPTANCE: Final = RellisAcceptancePolicy() + + +def qualify_rellis_ground( + dataset_root: Path, + runs_root: Path, + *, + mission_core_commit: str, + parallel_workers: int = 8, + expected_frame_count: int = EXPECTED_VALIDATION_FRAMES, + calibration_frame_count: int = DEFAULT_CALIBRATION_FRAMES, + preview_points: int = DEFAULT_REVIEW_POINTS, + current_profile: GroundBenchmarkProfile = DEFAULT_GROUND_BENCHMARK_PROFILE, + acceptance: RellisAcceptancePolicy = DEFAULT_RELLIS_ACCEPTANCE, + patchwork_module_name: str = "pypatchworkpp", + current_segmenter: GroundSegmenter | None = None, + patchwork_segmenter: GroundSegmenter | None = None, +) -> dict[str, Any]: + """Calibrate on train labels, freeze the profile, then score validation.""" + + root = dataset_root.expanduser().absolute() + runs = runs_root.expanduser().absolute() + if not _is_worker_dataset_root(root): + raise RellisAdmissionError("RELLIS qualification requires the canonical worker D root") + if not 1 <= parallel_workers <= 32: + raise RellisAdmissionError("parallel worker count is outside the admitted range") + if not 5 <= calibration_frame_count <= 256: + raise RellisAdmissionError("calibration frame count is outside the admitted range") + if not 1 <= preview_points <= MAX_REVIEW_POINTS: + raise RellisAdmissionError("review point count is outside the admitted range") + if (current_segmenter is not None or patchwork_segmenter is not None) and parallel_workers != 1: + raise RellisAdmissionError("custom providers require one qualification worker") + + admission = _read_admission(root) + splits = read_rellis_splits(root) + validation = splits["validation"] + if len(validation) != expected_frame_count: + raise RellisAdmissionError( + f"RELLIS validation contains {len(validation)} frames, not {expected_frame_count}" + ) + scan_path = root / RELLIS_ARCHIVE_ROOT / RELLIS_SCAN_ARCHIVE + label_path = root / RELLIS_ARCHIVE_ROOT / RELLIS_LABEL_ARCHIVE + calibration = calibrate_rellis_sensor_height( + scan_path, + label_path, + splits["train"], + archive_identity=str(admission["release"]["identity_sha256"]), + frame_count=calibration_frame_count, + ) + patchwork_profile = RellisPatchworkProfile( + patchwork_sensor_height_proxy_m=calibration["sensor_height_m"], + calibration_identity_sha256=calibration["identity_sha256"], + calibration_frame_count=calibration["frame_count"], + calibration_median_absolute_deviation_m=calibration[ + "median_absolute_deviation_m" + ], + ) + local = current_segmenter or LocalPercentileGroundSegmenter(current_profile) + candidate = patchwork_segmenter or PatchworkPPGroundSegmenter.load( + patchwork_profile, + module_name=patchwork_module_name, + ) + providers = { + "current": dict(local.identity), + "patchworkpp": dict(candidate.identity), + } + profile = { + "schema_version": "missioncore.rellis-ground-qualification-profile/v1", + "source_id": RELLIS_SOURCE_ID, + "split": "validation", + "expected_frame_count": expected_frame_count, + "archive_identity_sha256": admission["release"]["identity_sha256"], + "current_profile": current_profile.to_dict(), + "patchwork_profile": patchwork_profile.to_dict(), + "calibration": calibration, + "acceptance": acceptance.to_dict(), + "reproducibility_tier": "R1", + "authority": { + "qualification_only": True, + "navigation_or_safety_accepted": False, + }, + } + identity = _canonical_sha256( + { + "profile": profile, + "mission_core_commit": mission_core_commit, + "providers": providers, + } + ) + run_id = f"rellis-ground-{identity[:20]}" + store = QualificationRunStore(runs) + run = _admit_or_resume_run( + store, + run_id=run_id, + identity=identity, + profile=profile, + mission_core_commit=mission_core_commit, + providers=providers, + ) + if run.state is RunState.COMPLETED: + return _read_completed_report(store, run) + run = _ensure_running(store, run) + store.append_event( + run_id, + event_type="qualification.profile-admitted", + observed_at_utc=_utc_now(), + host_monotonic_ns=time.monotonic_ns(), + payload={ + "frames_total": len(validation), + "calibration_split": "train", + "calibration_frames": calibration["frame_count"], + "validation_labels_used_for_height": False, + }, + ) + + install_root = ( + root + / "rellis-3d/v1.1/installs" + / str(admission["release"]["identity_sha256"]) + ) + work_root = install_root / "qualifications" / identity / "working" + cache_root = work_root / "frames" + review_root = work_root / "review-frames" + cache_root.mkdir(parents=True, exist_ok=True) + review_root.mkdir(parents=True, exist_ok=True) + records: dict[str, dict[str, Any]] = {} + pending: list[tuple[int, str, str, Path]] = [] + for sequence, (point_member, label_member) in enumerate(validation): + frame_id = _frame_id(point_member) + cache_path = cache_root / f"{frame_id}.json" + review_path = review_root / f"{frame_id}.npz" + cached = _read_cached(cache_path, identity) + if cached is None or not review_path.is_file(): + pending.append((sequence, point_member, label_member, review_path)) + else: + records[frame_id] = cached + + if pending and parallel_workers == 1: + for sequence, point_member, label_member, review_path in pending: + frame = _read_frame(scan_path, label_path, point_member, label_member) + record = _qualify_frame( + frame, + sequence=sequence, + point_member=point_member, + identity=identity, + current=local, + patchwork=candidate, + review_path=review_path, + preview_points=preview_points, + ) + _write_json_once(cache_root / f"{record['frame_id']}.json", record) + records[record["frame_id"]] = record + elif pending: + with ProcessPoolExecutor( + max_workers=parallel_workers, + initializer=_initialize_worker, + initargs=( + str(scan_path), + str(label_path), + current_profile, + patchwork_profile, + patchwork_module_name, + ), + ) as executor: + futures = { + executor.submit( + _qualify_frame_worker, + sequence, + point_member, + label_member, + identity, + str(review_path), + preview_points, + ): point_member + for sequence, point_member, label_member, review_path in pending + } + completed_since_event = 0 + for future in as_completed(futures): + record = future.result() + _write_json_once(cache_root / f"{record['frame_id']}.json", record) + records[record["frame_id"]] = record + completed_since_event += 1 + if completed_since_event >= 50 or len(records) == len(validation): + store.append_event( + run_id, + event_type="qualification.frames-progress", + observed_at_utc=_utc_now(), + host_monotonic_ns=time.monotonic_ns(), + payload={ + "completed": len(records), + "total": len(validation), + "fraction": len(records) / len(validation), + }, + ) + completed_since_event = 0 + + ordered = [records[_frame_id(point)] for point, _ in validation] + report = _build_report( + identity=identity, + run_id=run_id, + profile=profile, + providers=providers, + frames=ordered, + acceptance=acceptance, + ) + run_root = runs / run_id + report_path = run_root / "evidence/qualification.json" + _write_json_once(report_path, report) + _register_artifact( + store, + run_id, + report_path, + run_root, + artifact_id="qualification-report", + kind=RELLIS_REPORT_ARTIFACT_KIND, + ) + _publish_review_pack( + runs, + run_id=run_id, + identity=identity, + archive_identity=str(admission["release"]["identity_sha256"]), + preview_points=preview_points, + records=ordered, + work_review_root=review_root, + ) + store.append_event( + run_id, + event_type="qualification.decision-recorded", + observed_at_utc=_utc_now(), + host_monotonic_ns=time.monotonic_ns(), + payload={ + "status": report["decision"]["status"], + "passed": report["decision"]["passed"], + "frames_completed": len(ordered), + }, + ) + current_run = store.load(run_id) + current_run = store.transition( + run_id, + RunState.STOPPING, + expected_revision=current_run.revision, + observed_at_utc=_utc_now(), + host_monotonic_ns=time.monotonic_ns(), + ) + store.transition( + run_id, + RunState.COMPLETED, + expected_revision=current_run.revision, + observed_at_utc=_utc_now(), + host_monotonic_ns=time.monotonic_ns(), + reason="rellis-cross-dataset-evidence-sealed", + ) + return report + + +def calibrate_rellis_sensor_height( + scan_archive: Path, + label_archive: Path, + train_pairs: tuple[tuple[str, str], ...], + *, + archive_identity: str, + frame_count: int = DEFAULT_CALIBRATION_FRAMES, +) -> dict[str, Any]: + """Estimate one frozen height from deterministic training frames only.""" + + if not 5 <= frame_count <= 256: + raise RellisAdmissionError("RELLIS calibration frame count is outside range") + selected = _stratified_pairs(train_pairs, frame_count) + estimates: list[dict[str, Any]] = [] + try: + with zipfile.ZipFile(scan_archive) as scans, zipfile.ZipFile(label_archive) as labels: + scan_index = _member_index(scans) + label_index = _member_index(labels) + for point_member, label_member in selected: + frame = decode_semantic_kitti_frame( + scans.read(scan_index[point_member]), + labels.read(label_index[label_member]), + ) + radial = np.linalg.norm(frame.points_xyz_m[:, :2], axis=1) + ground = np.isin(frame.semantic_labels, tuple(GROUND_IDS)) + z = frame.points_xyz_m[:, 2] + admitted = ( + ground + & (radial >= 2.7) + & (radial <= 15.0) + & (z <= -0.30) + & (z >= -3.0) + ) + heights = -z[admitted] + if heights.size < 256: + raise RellisAdmissionError( + "RELLIS calibration frame has insufficient near-field ground" + ) + estimates.append( + { + "frame_id": _frame_id(point_member), + "ground_point_count": int(heights.size), + "height_m": float(np.median(heights)), + } + ) + except (OSError, KeyError, zipfile.BadZipFile) as exc: + raise RellisAdmissionError("RELLIS train calibration cannot read its frames") from exc + values = np.asarray([item["height_m"] for item in estimates], dtype=np.float64) + median = float(np.median(values)) + mad = float(np.median(np.abs(values - median))) + if not 0.5 <= median <= 3.0 or mad > 0.40: + raise RellisAdmissionError("RELLIS train-derived height is physically inconsistent") + identity_document = { + "schema_version": RELLIS_CALIBRATION_SCHEMA, + "source_id": RELLIS_SOURCE_ID, + "archive_identity_sha256": archive_identity, + "split": "train", + "selection": "sequence-stratified-even-index", + "range_m": [2.7, 15.0], + "ground_label_ids": sorted(GROUND_IDS), + "frame_ids": [item["frame_id"] for item in estimates], + } + return { + **identity_document, + "identity_sha256": _canonical_sha256(identity_document), + "frame_count": len(estimates), + "sensor_height_m": median, + "median_absolute_deviation_m": mad, + "minimum_frame_estimate_m": float(np.min(values)), + "maximum_frame_estimate_m": float(np.max(values)), + "frame_estimates": estimates, + "validation_labels_used": False, + } + + +def _stratified_pairs( + pairs: tuple[tuple[str, str], ...], + frame_count: int, +) -> tuple[tuple[str, str], ...]: + groups: dict[str, list[tuple[str, str]]] = {} + for pair in pairs: + groups.setdefault(pair[0].split("/", 1)[0], []).append(pair) + if sorted(groups) != ["00000", "00001", "00002", "00003", "00004"]: + raise RellisAdmissionError("RELLIS train split does not cover every sequence") + selected: list[tuple[str, str]] = [] + remaining = frame_count + sequence_ids = sorted(groups) + for index, sequence in enumerate(sequence_ids): + count = remaining // (len(sequence_ids) - index) + group = sorted(groups[sequence]) + indices = np.linspace(0, len(group) - 1, count, dtype=np.int64) + selected.extend(group[int(position)] for position in indices) + remaining -= count + if len(selected) != frame_count or len(set(selected)) != frame_count: + raise RellisAdmissionError("RELLIS calibration selection is not unique") + return tuple(selected) + + +def _initialize_worker( + scan_path: str, + label_path: str, + current_profile: GroundBenchmarkProfile, + patchwork_profile: RellisPatchworkProfile, + module_name: str, +) -> None: + global _WORKER_CURRENT, _WORKER_LABEL_INDEX, _WORKER_LABELS + global _WORKER_PATCHWORK, _WORKER_SCAN_INDEX, _WORKER_SCANS + _WORKER_SCANS = zipfile.ZipFile(scan_path) + _WORKER_LABELS = zipfile.ZipFile(label_path) + _WORKER_SCAN_INDEX = _member_index(_WORKER_SCANS) + _WORKER_LABEL_INDEX = _member_index(_WORKER_LABELS) + _WORKER_CURRENT = LocalPercentileGroundSegmenter(current_profile) + _WORKER_PATCHWORK = PatchworkPPGroundSegmenter.load( + patchwork_profile, + module_name=module_name, + ) + + +def _qualify_frame_worker( + sequence: int, + point_member: str, + label_member: str, + identity: str, + review_path: str, + preview_points: int, +) -> dict[str, Any]: + if ( + _WORKER_SCANS is None + or _WORKER_LABELS is None + or _WORKER_SCAN_INDEX is None + or _WORKER_LABEL_INDEX is None + or _WORKER_CURRENT is None + or _WORKER_PATCHWORK is None + ): + raise RellisAdmissionError("RELLIS qualification worker is not initialized") + frame = decode_semantic_kitti_frame( + _WORKER_SCANS.read(_WORKER_SCAN_INDEX[point_member]), + _WORKER_LABELS.read(_WORKER_LABEL_INDEX[label_member]), + ) + return _qualify_frame( + frame, + sequence=sequence, + point_member=point_member, + identity=identity, + current=_WORKER_CURRENT, + patchwork=_WORKER_PATCHWORK, + review_path=Path(review_path), + preview_points=preview_points, + ) + + +def _read_frame( + scan_path: Path, + label_path: Path, + point_member: str, + label_member: str, +) -> DatasetPointFrame: + try: + with zipfile.ZipFile(scan_path) as scans, zipfile.ZipFile(label_path) as labels: + scan_index = _member_index(scans) + label_index = _member_index(labels) + return decode_semantic_kitti_frame( + scans.read(scan_index[point_member]), + labels.read(label_index[label_member]), + ) + except (OSError, KeyError, zipfile.BadZipFile) as exc: + raise RellisAdmissionError("RELLIS validation frame cannot be read") from exc + + +def _qualify_frame( + frame: DatasetPointFrame, + *, + sequence: int, + point_member: str, + identity: str, + current: GroundSegmenter, + patchwork: GroundSegmenter, + review_path: Path, + preview_points: int, +) -> dict[str, Any]: + semantic = frame.semantic_labels + categories = np.zeros(frame.point_count, dtype=np.uint8) + categories[np.isin(semantic, tuple(ARTIFICIAL_GROUND_IDS))] = 2 + categories[np.isin(semantic, tuple(NATURAL_GROUND_IDS))] = 3 + categories[np.isin(semantic, tuple(NON_GROUND_IDS))] = 4 + known = np.isin(semantic, tuple(GROUND_IDS | NON_GROUND_IDS | IGNORE_IDS)) + if not bool(np.all(known)): + raise RellisAdmissionError("RELLIS validation frame contains an unknown label") + evaluated = categories != 0 + ground_truth = np.isin(semantic, tuple(GROUND_IDS)) + xyzi = np.column_stack((frame.points_xyz_m, frame.remission)).astype( + np.float32, + copy=False, + ) + current_result = current.segment(xyzi) + patchwork_result = patchwork.segment(xyzi) + _validate_result(current_result, frame.point_count, "current") + _validate_result(patchwork_result, frame.point_count, "patchworkpp") + current_metrics = _provider_frame_result( + current_result, + ground_truth, + evaluated, + categories, + frame.point_count, + ) + patchwork_metrics = _provider_frame_result( + patchwork_result, + ground_truth, + evaluated, + categories, + frame.point_count, + ) + review = _write_review_frame( + review_path, + frame, + ground_truth, + evaluated, + current_result, + patchwork_result, + frame_id=_frame_id(point_member), + maximum_points=preview_points, + ) + sequence_id, frame_number = _frame_identity(point_member) + return { + "schema_version": RELLIS_QUALIFICATION_FRAME_SCHEMA, + "identity_sha256": identity, + "sequence": sequence, + "frame_id": _frame_id(point_member), + "dataset_sequence_id": sequence_id, + "dataset_frame_number": frame_number, + "source_point_count": frame.point_count, + "nominal": { + "current": current_metrics, + "patchworkpp": patchwork_metrics, + }, + "review": review, + } + + +def _write_review_frame( + path: Path, + frame: DatasetPointFrame, + ground_truth: np.ndarray[Any, Any], + evaluated: np.ndarray[Any, Any], + current: GroundSegmentation, + patchwork: GroundSegmentation, + *, + frame_id: str, + maximum_points: int, +) -> dict[str, Any]: + sample_count = min(frame.point_count, maximum_points) + indices = np.linspace(0, frame.point_count - 1, sample_count, dtype=np.int64) + points_cm = np.rint(frame.points_xyz_m[indices] * 100.0) + if not np.all(np.isfinite(points_cm)) or np.any(np.abs(points_cm) > 32_767): + raise RellisAdmissionError("RELLIS review point escaped signed-centimetre range") + flags = ( + evaluated[indices].astype(np.uint8) + | (ground_truth[indices].astype(np.uint8) << 1) + | (current.ground_mask[indices].astype(np.uint8) << 2) + | (patchwork.ground_mask[indices].astype(np.uint8) << 3) + ) + remission = np.asarray(frame.remission[indices], dtype=np.float32) + low = float(np.min(remission)) + high = float(np.max(remission)) + intensity = ( + np.zeros(sample_count, dtype=np.uint8) + if high <= low + else np.clip(np.rint((remission - low) / (high - low) * 255), 0, 255).astype( + np.uint8 + ) + ) + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as temporary: + temporary_path = Path(temporary.name) + np.savez_compressed( + temporary, + schema=np.asarray([RELLIS_REVIEW_FRAME_SCHEMA]), + frame_id=np.asarray([frame_id]), + source_point_count=np.asarray([frame.point_count], dtype=np.int32), + xyz_cm=np.ascontiguousarray(points_cm, dtype=" dict[str, Any]: + current = _aggregate([frame["nominal"]["current"] for frame in frames]) + patchwork = _aggregate([frame["nominal"]["patchworkpp"] for frame in frames]) + regressions = [ + { + "frame_id": frame["frame_id"], + "current_ground_iou": frame["nominal"]["current"]["metrics"]["ground_iou"], + "patchwork_ground_iou": frame["nominal"]["patchworkpp"]["metrics"]["ground_iou"], + "ground_iou_delta": ( + frame["nominal"]["patchworkpp"]["metrics"]["ground_iou"] + - frame["nominal"]["current"]["metrics"]["ground_iou"] + ), + "patchwork_natural_ground_recall": frame["nominal"]["patchworkpp"]["metrics"][ + "natural_ground_recall" + ], + } + for frame in frames + ] + checks = [ + _check( + "ground-iou-gain", + patchwork["micro"]["ground_iou"] - current["micro"]["ground_iou"], + acceptance.minimum_ground_iou_gain, + ">=", + ), + _check( + "obstacle-non-ground-recall", + patchwork["micro"]["obstacle_non_ground_recall"], + acceptance.minimum_obstacle_non_ground_recall, + ">=", + ), + _check( + "obstacle-recall-regression", + current["micro"]["obstacle_non_ground_recall"] + - patchwork["micro"]["obstacle_non_ground_recall"], + acceptance.maximum_obstacle_recall_regression, + "<=", + ), + _check( + "assigned-fraction", + patchwork["assigned_fraction"], + acceptance.minimum_assigned_fraction, + ">=", + ), + _check( + "latency-p95-ms", + patchwork["latency_ms"]["p95"], + acceptance.maximum_patchwork_latency_p95_ms, + "<=", + ), + _check( + "catastrophic-regression-count", + sum( + item["ground_iou_delta"] < -acceptance.maximum_per_frame_iou_regression + for item in regressions + ), + 0, + "<=", + ), + ] + passed = all(bool(check["passed"]) for check in checks) + return { + "schema_version": RELLIS_QUALIFICATION_REPORT_SCHEMA, + "identity_sha256": identity, + "run_id": run_id, + "source_id": RELLIS_SOURCE_ID, + "split": "validation", + "frame_count": len(frames), + "profile": profile, + "providers": providers, + "aggregates": {"current": current, "patchworkpp": patchwork}, + "degradations": {}, + "checks": checks, + "worst_frames": sorted( + regressions, + key=lambda item: (item["ground_iou_delta"], item["patchwork_ground_iou"]), + )[:20], + "frames": frames, + "decision": { + "status": "cross-dataset-qualified" if passed else "qualification-rejected", + "passed": passed, + "promoted_to_navigation_or_safety": False, + "reason": ( + "all predeclared independent-dataset gates passed" + if passed + else "one or more independent-dataset gates failed" + ), + }, + "safety": { + "qualification_only": True, + "actuator_authority": False, + "navigation_or_safety_accepted": False, + }, + } + + +def _publish_review_pack( + runs_root: Path, + *, + run_id: str, + identity: str, + archive_identity: str, + preview_points: int, + records: list[dict[str, Any]], + work_review_root: Path, +) -> None: + pack_identity = _canonical_sha256( + { + "schema_version": RELLIS_REVIEW_PACK_SCHEMA, + "source_run_id": run_id, + "qualification_identity_sha256": identity, + "archive_identity_sha256": archive_identity, + "preview_points": preview_points, + "sampling": "deterministic-even-index", + } + ) + pack_root = ( + runs_root.parent + / "polygon-review-packs" + / run_id + / f"review-{pack_identity}" + ) + frame_root = pack_root / "frames" + frame_root.mkdir(parents=True, exist_ok=True) + frames: list[dict[str, Any]] = [] + for sequence, record in enumerate(records): + frame_id = str(record["frame_id"]) + source = work_review_root / f"{frame_id}.npz" + target = frame_root / f"{frame_id}.npz" + if not target.exists(): + try: + os.link(source, target) + except OSError: + target.write_bytes(source.read_bytes()) + review = record["review"] + if ( + target.stat().st_size != review["byte_length"] + or _sha256_file(target) != review["sha256"] + ): + raise RellisAdmissionError("RELLIS review frame changed during publication") + nominal = record["nominal"] + frames.append( + { + "sequence": sequence, + "frame_id": frame_id, + "dataset_sequence_id": record["dataset_sequence_id"], + "dataset_frame_number": record["dataset_frame_number"], + "source_point_count": record["source_point_count"], + "point_count": review["point_count"], + "relative_path": f"frames/{frame_id}.npz", + "sha256": review["sha256"], + "byte_length": review["byte_length"], + "current": _review_metrics(nominal["current"]), + "patchworkpp": _review_metrics(nominal["patchworkpp"]), + "ground_iou_delta": ( + nominal["patchworkpp"]["metrics"]["ground_iou"] + - nominal["current"]["metrics"]["ground_iou"] + ), + } + ) + manifest = { + "schema_version": RELLIS_REVIEW_PACK_SCHEMA, + "source_run_id": run_id, + "identity_sha256": pack_identity, + "source_id": RELLIS_SOURCE_ID, + "preview_points": preview_points, + "frame_count": len(frames), + "frames": frames, + "safety": { + "visualization_only": True, + "actuator_authority": False, + "navigation_or_safety_accepted": False, + }, + } + _write_json_once(pack_root / "manifest.json", manifest) + + +def _review_metrics(value: dict[str, Any]) -> dict[str, float]: + metrics = value["metrics"] + return { + "ground_iou": float(metrics["ground_iou"]), + "natural_ground_recall": float(metrics["natural_ground_recall"]), + "obstacle_non_ground_recall": float(metrics["obstacle_non_ground_recall"]), + "latency_ms": float(value["latency_ms"]), + } + + +def _read_admission(root: Path) -> dict[str, Any]: + try: + value = json.loads((root / "state/rellis-3d-v1.1.json").read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RellisAdmissionError("RELLIS admission manifest is unavailable") from exc + if ( + not isinstance(value, dict) + or value.get("schema_version") != RELLIS_ADMISSION_SCHEMA + or value.get("source_id") != RELLIS_SOURCE_ID + or value.get("status") != "dataset-ready" + or not isinstance(value.get("release"), dict) + or not isinstance(value["release"].get("identity_sha256"), str) + ): + raise RellisAdmissionError("RELLIS admission manifest is incompatible") + return value + + +def _admit_or_resume_run( + store: QualificationRunStore, + *, + run_id: str, + identity: str, + profile: dict[str, Any], + mission_core_commit: str, + providers: dict[str, dict[str, Any]], +) -> QualificationRun: + scenario = {"source_id": RELLIS_SOURCE_ID, "split": "validation"} + patchwork_digest = providers["patchworkpp"].get("binary_sha256") + run = QualificationRun( + run_id=run_id, + episode_id=f"episode-{identity[:20]}", + kind=RunKind.REPLAY_SHADOW, + state=RunState.ADMITTED, + scenario_generation="rellis-3d-validation-v1.1", + scenario_sha256=_canonical_sha256(scenario), + profile_generation="rellis-ground-current-vs-patchworkpp-v1", + profile_sha256=_canonical_sha256(profile), + mission_core_commit=mission_core_commit, + providers=( + ProviderPin( + identifier="missioncore-local-percentile-ground", + version="v1", + revision=mission_core_commit, + ), + ProviderPin( + identifier="patchworkpp", + version="v1.4.1", + revision=PATCHWORKPP_SOURCE_COMMIT, + digest=str(patchwork_digest) if patchwork_digest is not None else None, + ), + ), + host_profile_id="simulation-worker-rellis-ground-v1", + host_profile_sha256=_canonical_sha256( + {"role": "simulation-worker", "storage": "worker-d-only"} + ), + seed=42, + reproducibility_tier=ReproducibilityTier.R1, + authority=AuthorityProfile( + generation=1, + command_ttl_max_ns=1, + heartbeat_timeout_monotonic_ns=1, + ), + clock_domain="dataset:frame-index", + created_at_utc=_utc_now(), + ) + try: + return store.create(run) + except QualificationRunConflictError as exc: + existing = store.load(run_id) + if ( + existing.profile_sha256 != run.profile_sha256 + or existing.scenario_sha256 != run.scenario_sha256 + or existing.mission_core_commit != mission_core_commit + ): + raise RellisAdmissionError("existing RELLIS run has another identity") from exc + return existing + + +def _ensure_running(store: QualificationRunStore, run: QualificationRun) -> QualificationRun: + if run.state is RunState.ADMITTED: + run = store.transition( + run.run_id, + RunState.STARTING, + expected_revision=run.revision, + observed_at_utc=_utc_now(), + host_monotonic_ns=time.monotonic_ns(), + ) + if run.state is RunState.STARTING: + run = store.transition( + run.run_id, + RunState.RUNNING, + expected_revision=run.revision, + observed_at_utc=_utc_now(), + host_monotonic_ns=time.monotonic_ns(), + ) + if run.state is not RunState.RUNNING: + raise RellisAdmissionError("RELLIS qualification cannot resume from this state") + return run + + +def _register_artifact( + store: QualificationRunStore, + run_id: str, + path: Path, + run_root: Path, + *, + artifact_id: str, + kind: str, +) -> None: + relative = path.relative_to(run_root).as_posix() + store.register_artifact( + run_id, + QualificationArtifact( + artifact_id=artifact_id, + kind=kind, + relative_path=relative, + sha256=_sha256_file(path), + byte_length=path.stat().st_size, + source_of_record=True, + ), + ) + + +def _read_completed_report( + store: QualificationRunStore, + run: QualificationRun, +) -> dict[str, Any]: + artifacts = [ + item for item in run.artifacts if item.kind == RELLIS_REPORT_ARTIFACT_KIND + ] + if len(artifacts) != 1: + raise RellisAdmissionError("completed RELLIS run has no report") + path = store.root / run.run_id / artifacts[0].relative_path + if _sha256_file(path) != artifacts[0].sha256: + raise RellisAdmissionError("completed RELLIS report digest differs") + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise RellisAdmissionError("completed RELLIS report is invalid") + return value + + +def _read_cached(path: Path, identity: str) -> dict[str, Any] | None: + if not path.is_file(): + return None + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return None + if ( + not isinstance(value, dict) + or value.get("schema_version") != RELLIS_QUALIFICATION_FRAME_SCHEMA + or value.get("identity_sha256") != identity + ): + return None + return value + + +def _frame_identity(point_member: str) -> tuple[str, int]: + parts = point_member.split("/") + if ( + len(parts) != 3 + or parts[0] not in {"00000", "00001", "00002", "00003", "00004"} + or not parts[2].endswith(".bin") + or not parts[2][:-4].isdigit() + ): + raise RellisAdmissionError("RELLIS frame identity is incompatible") + return parts[0], int(parts[2][:-4]) + + +def _frame_id(point_member: str) -> str: + sequence, frame = _frame_identity(point_member) + return f"rellis-{sequence}-{frame:06d}" + + +def _canonical_sha256(value: dict[str, Any]) -> str: + return hashlib.sha256( + json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024**2), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _write_json_once(path: Path, value: dict[str, Any]) -> None: + encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + b"\n" + if path.exists(): + if path.is_file() and path.read_bytes() == encoded: + return + raise RellisAdmissionError("immutable RELLIS artifact already exists") + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as temporary: + temporary_path = Path(temporary.name) + temporary.write(encoded) + temporary.flush() + os.fsync(temporary.fileno()) + os.replace(temporary_path, path) + + +def _utc_now() -> str: + return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z") diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index 1e18a18..03ff75b 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -429,6 +429,9 @@ app.include_router( dataset_rellis_preview_provider=lambda: ( REPOSITORY_ROOT / ".runtime" / "dataset-gateway" / "rellis-preview.json" ), + dataset_rellis_admission_provider=lambda: ( + REPOSITORY_ROOT / ".runtime" / "dataset-gateway" / "rellis-admission.json" + ), dataset_ground_preview_provider=lambda: ( REPOSITORY_ROOT / ".runtime" / "dataset-gateway" / "ground-comparison.json" ), diff --git a/src/k1link/web/lidar_api.py b/src/k1link/web/lidar_api.py index c7a071a..e276392 100644 --- a/src/k1link/web/lidar_api.py +++ b/src/k1link/web/lidar_api.py @@ -64,6 +64,7 @@ def build_lidar_router( dataset_admission_provider: DatasetArtifactProvider = configured_dataset_admission_manifest, dataset_preview_provider: DatasetArtifactProvider = configured_dataset_preview, dataset_rellis_preview_provider: DatasetArtifactProvider = lambda: None, + dataset_rellis_admission_provider: DatasetArtifactProvider = lambda: None, dataset_ground_preview_provider: DatasetArtifactProvider = configured_dataset_ground_preview, ) -> APIRouter: router = APIRouter(prefix="/api/v1/lidar", tags=["lidar"]) @@ -73,6 +74,7 @@ def build_lidar_router( return dataset_gateway_catalog( admission_manifest_path=dataset_admission_provider(), rellis_preview_path=dataset_rellis_preview_provider(), + rellis_admission_path=dataset_rellis_admission_provider(), ) @router.get("/dataset-gateway/preview") diff --git a/src/k1link/web/polygon_api.py b/src/k1link/web/polygon_api.py index 9da82c8..c731ace 100644 --- a/src/k1link/web/polygon_api.py +++ b/src/k1link/web/polygon_api.py @@ -41,17 +41,21 @@ DETAIL_SCHEMA: Final = "missioncore.polygon-run-detail/v1" GROUND_QUALIFICATION_SCHEMA: Final = "missioncore.polygon-ground-qualification/v1" GROUND_FAILURE_PREVIEW_SCHEMA: Final = "missioncore.polygon-ground-failure-preview/v1" GROUND_REPORT_ARTIFACT_KIND: Final = "goose-ground-qualification-report" +RELLIS_REPORT_ARTIFACT_KIND: Final = "rellis-ground-qualification-report" GROUND_FAILURE_ARTIFACT_KIND: Final = "goose-ground-qualification-failure-preview" GROUND_REPORT_SCHEMA: Final = "missioncore.goose-ground-qualification-report/v1" +RELLIS_REPORT_SCHEMA: Final = "missioncore.rellis-ground-qualification-report/v1" GROUND_FAILURE_SCHEMA: Final = "missioncore.goose-ground-qualification-failure-preview/v1" GROUND_REVIEW_SCHEMA: Final = "missioncore.polygon-ground-review/v2" GROUND_REVIEW_FRAME_SCHEMA: Final = "missioncore.polygon-ground-review-frame/v1" GOOSE_REVIEW_PACK_SCHEMA: Final = "missioncore.goose-ground-review-pack/v1" GOOSE_REVIEW_FRAME_SCHEMA: Final = "missioncore.goose-ground-review-frame/v1" +RELLIS_REVIEW_PACK_SCHEMA: Final = "missioncore.rellis-ground-review-pack/v1" +RELLIS_REVIEW_FRAME_SCHEMA: Final = "missioncore.rellis-ground-review-frame/v1" MAX_QUALIFICATION_ARTIFACT_BYTES: Final = 32 * 1024**2 MAX_REVIEW_MANIFEST_BYTES: Final = 8 * 1024**2 MAX_REVIEW_FRAME_BYTES: Final = 2 * 1024**2 -MAX_REVIEW_FRAMES: Final = 2_000 +MAX_REVIEW_FRAMES: Final = 3_000 MAX_REVIEW_POINTS: Final = 20_000 COMMIT_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$") GOOSE_FRAME_ID_PATTERN: Final = re.compile( @@ -201,8 +205,8 @@ def build_polygon_router( report = _read_registered_json( store, run, - artifact_kind=GROUND_REPORT_ARTIFACT_KIND, - expected_schema=GROUND_REPORT_SCHEMA, + artifact_kind=(GROUND_REPORT_ARTIFACT_KIND, RELLIS_REPORT_ARTIFACT_KIND), + expected_schema=(GROUND_REPORT_SCHEMA, RELLIS_REPORT_SCHEMA), ) required = ( "identity_sha256", @@ -305,7 +309,7 @@ def build_polygon_router( "ground_iou_delta", ) }, - **_goose_frame_identity(frame["frame_id"]), + **_dataset_frame_identity(frame), } for frame in manifest["frames"] ], @@ -348,7 +352,11 @@ def build_polygon_router( ) from exc point_count = int(frame["point_count"]) if ( - schema != GOOSE_REVIEW_FRAME_SCHEMA + schema + not in { + GOOSE_REVIEW_FRAME_SCHEMA, + RELLIS_REVIEW_FRAME_SCHEMA, + } or stored_frame_id != frame_id or source_point_count != frame["source_point_count"] or xyz_cm.dtype != np.dtype(" dict[str, Any]: - candidates = [artifact for artifact in run.artifacts if artifact.kind == artifact_kind] + artifact_kinds = (artifact_kind,) if isinstance(artifact_kind, str) else artifact_kind + expected_schemas = ( + (expected_schema,) if isinstance(expected_schema, str) else expected_schema + ) + candidates = [ + artifact for artifact in run.artifacts if artifact.kind in artifact_kinds + ] if frame_id is not None: candidates = [ artifact for artifact in candidates if Path(artifact.relative_path).stem == frame_id @@ -611,7 +625,7 @@ def _read_registered_json( status_code=500, detail="Квалификационный артефакт не является допустимым JSON.", ) from exc - if not isinstance(value, dict) or value.get("schema_version") != expected_schema: + if not isinstance(value, dict) or value.get("schema_version") not in expected_schemas: raise HTTPException( status_code=500, detail="Квалификационный артефакт имеет неизвестную схему.", @@ -676,7 +690,8 @@ def _read_ground_review_manifest( safety = manifest.get("safety") if isinstance(manifest, dict) else None if ( not isinstance(manifest, dict) - or manifest.get("schema_version") != GOOSE_REVIEW_PACK_SCHEMA + or manifest.get("schema_version") + not in {GOOSE_REVIEW_PACK_SCHEMA, RELLIS_REVIEW_PACK_SCHEMA} or manifest.get("source_run_id") != run_id or not isinstance(manifest.get("identity_sha256"), str) or not re.fullmatch(r"[a-f0-9]{64}", manifest["identity_sha256"]) @@ -725,7 +740,27 @@ def _read_ground_review_manifest( return pack_root, manifest -def _goose_frame_identity(frame_id: str) -> dict[str, str | int]: +def _dataset_frame_identity(frame: dict[str, Any]) -> dict[str, str | int | None]: + frame_id = frame["frame_id"] + explicit_sequence = frame.get("dataset_sequence_id") + explicit_number = frame.get("dataset_frame_number") + if explicit_sequence is not None or explicit_number is not None: + if ( + not isinstance(explicit_sequence, str) + or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,95}", explicit_sequence) + is None + or not isinstance(explicit_number, int) + or explicit_number < 0 + ): + raise HTTPException( + status_code=500, + detail="Идентичность dataset-кадра нарушает контракт.", + ) + return { + "dataset_sequence_id": explicit_sequence, + "dataset_frame_number": explicit_number, + "sensor_timestamp_ns": None, + } match = GOOSE_FRAME_ID_PATTERN.fullmatch(frame_id) if match is None: raise HTTPException( diff --git a/tests/test_polygon_api.py b/tests/test_polygon_api.py index 16fd214..5a4f5ff 100644 --- a/tests/test_polygon_api.py +++ b/tests/test_polygon_api.py @@ -435,6 +435,44 @@ def test_polygon_api_rejects_tampered_review_frame(tmp_path: Path) -> None: assert "SHA-256" in failure.value.detail or "файл" in failure.value.detail.lower() +def test_polygon_api_publishes_rellis_sequence_identity(tmp_path: Path) -> None: + root = tmp_path / "runs" + run, _ = _qualification_run(root) + frame_id = "rellis-00000-000307" + frame_path = _review_pack(root, run.run_id, frame_id) + with np.load(frame_path, allow_pickle=False) as source: + payload = {key: source[key] for key in source.files} + payload["schema"] = np.asarray(["missioncore.rellis-ground-review-frame/v1"]) + np.savez_compressed(frame_path, **payload) + manifest_path = frame_path.parent.parent / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["schema_version"] = "missioncore.rellis-ground-review-pack/v1" + manifest["source_id"] = "rellis-3d/v1.1" + manifest["frames"][0]["dataset_sequence_id"] = "00000" + manifest["frames"][0]["dataset_frame_number"] = 307 + manifest["frames"][0]["sha256"] = hashlib.sha256(frame_path.read_bytes()).hexdigest() + manifest["frames"][0]["byte_length"] = frame_path.stat().st_size + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + router = build_polygon_router(root_provider=lambda: root) + + review = _endpoint( + router, + "/api/v1/polygon/runs/{run_id}/qualification/review", + "GET", + )(run_id=run.run_id) + frame = _endpoint( + router, + "/api/v1/polygon/runs/{run_id}/qualification/review/frames/{frame_id}", + "GET", + )(run_id=run.run_id, frame_id=frame_id) + + assert review["source_id"] == "rellis-3d/v1.1" + assert review["frames"][0]["dataset_sequence_id"] == "00000" + assert review["frames"][0]["dataset_frame_number"] == 307 + assert review["frames"][0]["sensor_timestamp_ns"] is None + assert frame["frame_id"] == frame_id + + class _FakeWorkerGateway: def __init__(self) -> None: self.calls: list[tuple[str, str]] = [] diff --git a/tests/test_rellis_qualification.py b/tests/test_rellis_qualification.py new file mode 100644 index 0000000..5e51e0b --- /dev/null +++ b/tests/test_rellis_qualification.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import zipfile +from pathlib import Path + +import numpy as np +import pytest + +from k1link.datasets.rellis_admission import RellisAdmissionError, _member_index +from k1link.datasets.rellis_profile import RellisPatchworkProfile +from k1link.datasets.rellis_qualification import calibrate_rellis_sensor_height + + +def _frame(height_m: float) -> tuple[bytes, bytes]: + point_count = 512 + points = np.zeros((point_count, 4), dtype=" None: + scan_path = tmp_path / "scans.zip" + label_path = tmp_path / "labels.zip" + pairs: list[tuple[str, str]] = [] + with ( + zipfile.ZipFile(scan_path, "w") as scans, + zipfile.ZipFile(label_path, "w") as labels, + ): + for sequence, height in enumerate((1.20, 1.25, 1.30, 1.35, 1.40)): + sequence_id = f"{sequence:05d}" + point_member = f"{sequence_id}/os1_cloud_node_kitti_bin/000000.bin" + label_member = ( + f"{sequence_id}/os1_cloud_node_semantickitti_label_id/000000.label" + ) + point_bytes, label_bytes = _frame(height) + scans.writestr(f"Rellis-3D/{point_member}", point_bytes) + labels.writestr(f"Rellis-3D/{label_member}", label_bytes) + pairs.append((point_member, label_member)) + + result = calibrate_rellis_sensor_height( + scan_path, + label_path, + tuple(pairs), + archive_identity="a" * 64, + frame_count=5, + ) + + assert result["split"] == "train" + assert result["validation_labels_used"] is False + assert result["frame_count"] == 5 + assert result["sensor_height_m"] == pytest.approx(1.30) + assert len(result["identity_sha256"]) == 64 + + +def test_rellis_archive_index_rejects_path_traversal(tmp_path: Path) -> None: + archive = tmp_path / "unsafe.zip" + with zipfile.ZipFile(archive, "w") as target: + target.writestr("../outside.bin", b"data") + with ( + zipfile.ZipFile(archive) as source, + pytest.raises(RellisAdmissionError, match="unsafe"), + ): + _member_index(source) + + +def test_rellis_patchwork_profile_rejects_unphysical_height() -> None: + with pytest.raises(ValueError): + RellisPatchworkProfile( + patchwork_sensor_height_proxy_m=0.1, + calibration_identity_sha256="a" * 64, + calibration_frame_count=64, + calibration_median_absolute_deviation_m=0.01, + )