From 3333e9ac0f80a2a9684fdfd29dd8ed7e253559c4 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 25 Jul 2026 10:26:07 +0300 Subject: [PATCH] feat(lidar): add RAVNOVES field review --- .../src/core/lidar/replayQuality.ts | 500 ++++++++++ .../control-station/src/styles/responsive.css | 32 + .../control-station/src/styles/workspaces.css | 212 +++++ .../src/workspaces/LidarGroundPointCloud.tsx | 36 +- .../src/workspaces/LidarQualityWorkspace.tsx | 275 +++++- .../test/lidarReplayQuality.test.mjs | 213 +++++ docs/10_EXTERNAL_PERCEPTION_WORKER.md | 11 + docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md | 22 + .../perception/run_lidar_field_review.py | 182 ++++ src/k1link/compute/__init__.py | 30 + src/k1link/compute/lidar_field_review.py | 891 ++++++++++++++++++ src/k1link/web/lidar_api.py | 147 ++- tests/test_lidar_field_review.py | 238 +++++ 13 files changed, 2775 insertions(+), 14 deletions(-) create mode 100644 experiments/perception/run_lidar_field_review.py create mode 100644 src/k1link/compute/lidar_field_review.py create mode 100644 tests/test_lidar_field_review.py diff --git a/apps/control-station/src/core/lidar/replayQuality.ts b/apps/control-station/src/core/lidar/replayQuality.ts index 583aa92..cf4130a 100644 --- a/apps/control-station/src/core/lidar/replayQuality.ts +++ b/apps/control-station/src/core/lidar/replayQuality.ts @@ -147,6 +147,106 @@ export interface LidarGroundFrame { groundTruth: false; } +export interface LidarFieldReviewWindowSummary { + index: number; + key: string; + label: string; + startSeconds: number; + endSeconds: number; + midpointSeconds: number; + sourceLidarSamples: number; + sourcePointCount: number; + displayPointCount: number; + sourceFrameStart: number; + sourceFrameEnd: number; + previewSourceFrameIndex: number; + previewSessionSeconds: number; +} + +export interface LidarFieldReview { + reviewId: string; + displayName: string; + sessionId: string; + sourcePackId: string; + status: "diagnostic-only"; + source: { + timelineStartSeconds: number; + timelineEndSeconds: number; + availableLidarFrames: number; + pointCount: number; + representation: "legacy-e10-vendor-map-with-pose"; + intensityAvailable: false; + rawScanAccepted: false; + }; + selection: { + purpose: "operator-readable-central-urban-field-review"; + defaultWindowIndex: number; + accumulation: "per-source-frame masks accumulated in map frame"; + maximumPointsPerWindow: number; + }; + windows: LidarFieldReviewWindowSummary[]; + metrics: { + sourceSamples: number; + current: { + provider: LidarGroundProviderSummary; + groundFraction: LidarDistribution; + latencyMs: LidarDistribution; + }; + candidate: { + provider: LidarGroundProviderSummary; + groundFraction: LidarDistribution; + latencyMs: LidarDistribution; + }; + comparison: { + algorithmGroundIou: LidarDistribution; + groundDisagreementFraction: LidarDistribution; + isAccuracyMetric: false; + }; + }; + decision: { + status: "visual-review-only"; + productionPromotion: false; + reasons: string[]; + }; + createdAtUtc: string | null; + groundTruth: false; + authority: { + commandsEnabled: false; + navigationOrSafetyAccepted: false; + }; +} + +export interface LidarFieldReviewCatalog { + configured: boolean; + validTotal: number; + invalidTotal: number; + items: LidarFieldReview[]; +} + +export interface LidarFieldReviewWindow { + reviewId: string; + displayName: string; + sessionId: string; + sourcePackId: string; + windowIndex: number; + windowCount: number; + window: LidarFieldReviewWindowSummary; + pointCount: number; + coordinateFrame: "map"; + distanceUnit: "m"; + pointsXyzM: Array<[number, number, number]>; + intensity0To255: null; + intensity: { + available: false; + reason: string; + }; + masks: LidarGroundFrame["masks"]; + counts: LidarGroundFrame["counts"]; + previewUrl: string; + groundTruth: false; + authority: LidarFieldReview["authority"]; +} + export class LidarReplayContractError extends Error {} export class LidarReplayApiError extends Error { @@ -162,7 +262,10 @@ type LidarFetch = ( const SAFE_PACK_ID = /^lidar-replay-pack-[a-f0-9]{64}$/; const SAFE_GROUND_BENCHMARK_ID = /^ground-benchmark-[a-f0-9]{64}$/; +const SAFE_FIELD_REVIEW_ID = /^lidar-field-review-[a-f0-9]{64}$/; +const SAFE_E10_PACK_ID = /^e10-lidar-pack-[a-f0-9]{64}$/; const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/; +const SAFE_FIELD_KEY = /^[a-z0-9][a-z0-9-]{0,63}$/; const SHA256 = /^[a-f0-9]{64}$/; const GIT_SHA1 = /^[a-f0-9]{40}$/; @@ -641,6 +744,358 @@ export function parseLidarGroundFrame(value: unknown): LidarGroundFrame { }; } +function fieldReviewWindowSummary( + value: unknown, + expectedIndex: number, +): LidarFieldReviewWindowSummary { + const source = record(value, `field-review window ${expectedIndex}`); + const index = integer(source.index, "window.index"); + const startSeconds = number(source.start_seconds, "window.start_seconds") ?? 0; + const endSeconds = number(source.end_seconds, "window.end_seconds") ?? 0; + if (index !== expectedIndex || startSeconds >= endSeconds) { + throw new LidarReplayContractError("LiDAR field-review window несовместим"); + } + return { + index, + key: string(source.key, "window.key", SAFE_FIELD_KEY), + label: string(source.label, "window.label"), + startSeconds, + endSeconds, + midpointSeconds: + number(source.midpoint_seconds, "window.midpoint_seconds") ?? 0, + sourceLidarSamples: integer( + source.source_lidar_samples, + "window.source_lidar_samples", + ), + sourcePointCount: integer( + source.source_point_count, + "window.source_point_count", + ), + displayPointCount: integer( + source.display_point_count, + "window.display_point_count", + ), + sourceFrameStart: integer( + source.source_frame_start, + "window.source_frame_start", + ), + sourceFrameEnd: integer( + source.source_frame_end, + "window.source_frame_end", + ), + previewSourceFrameIndex: integer( + source.preview_source_frame_index, + "window.preview_source_frame_index", + ), + previewSessionSeconds: + number( + source.preview_session_seconds, + "window.preview_session_seconds", + ) ?? 0, + }; +} + +function fieldReviewBranch( + value: unknown, + label: string, +): LidarFieldReview["metrics"]["current"] { + const source = record(value, label); + return { + provider: groundProvider(source.provider, `${label}.provider`), + groundFraction: distribution( + source.ground_fraction, + `${label}.ground_fraction`, + ), + latencyMs: distribution(source.latency_ms, `${label}.latency_ms`), + }; +} + +function fieldReview(value: unknown): LidarFieldReview { + const source = record(value, "LiDAR field review"); + const sourceEvidence = record(source.source, "field-review source"); + const selection = record(source.selection, "field-review selection"); + const sampling = record(selection.sampling, "field-review sampling"); + const metrics = record(source.metrics, "field-review metrics"); + const comparison = record(metrics.comparison, "field-review comparison"); + const decision = record(source.decision, "field-review decision"); + const authority = record(source.authority, "field-review authority"); + if ( + source.status !== "diagnostic-only" + || source.ground_truth !== false + || sourceEvidence.representation !== "legacy-e10-vendor-map-with-pose" + || sourceEvidence.intensity_available !== false + || sourceEvidence.raw_scan_accepted !== false + || selection.purpose !== "operator-readable-central-urban-field-review" + || selection.accumulation !== "per-source-frame masks accumulated in map frame" + || sampling.method !== "uniform-point-index-per-window" + || comparison.is_accuracy_metric !== false + || decision.status !== "visual-review-only" + || decision.production_promotion !== false + || authority.commands_enabled !== false + || authority.navigation_or_safety_accepted !== false + ) { + throw new LidarReplayContractError( + "LiDAR field review завышает readiness или меняет evidence", + ); + } + const windows = array(source.windows, "field-review windows").map( + fieldReviewWindowSummary, + ); + const defaultWindowIndex = integer( + selection.default_window_index, + "selection.default_window_index", + ); + const maximumPointsPerWindow = integer( + sampling.maximum_points_per_window, + "sampling.maximum_points_per_window", + ); + if ( + windows.length < 1 + || defaultWindowIndex >= windows.length + || maximumPointsPerWindow < 1 + || maximumPointsPerWindow > 80_000 + || windows.some( + (window) => + window.sourceLidarSamples < 1 + || window.sourcePointCount < window.displayPointCount + || window.displayPointCount < 1 + || window.displayPointCount > maximumPointsPerWindow, + ) + ) { + throw new LidarReplayContractError("LiDAR field-review selection несовместим"); + } + return { + reviewId: string(source.review_id, "review_id", SAFE_FIELD_REVIEW_ID), + displayName: string(source.display_name, "display_name"), + sessionId: string(source.session_id, "session_id", SAFE_ID), + sourcePackId: string( + source.source_pack_id, + "source_pack_id", + SAFE_E10_PACK_ID, + ), + status: "diagnostic-only", + source: { + timelineStartSeconds: + number( + sourceEvidence.timeline_start_seconds, + "source.timeline_start_seconds", + ) ?? 0, + timelineEndSeconds: + number( + sourceEvidence.timeline_end_seconds, + "source.timeline_end_seconds", + ) ?? 0, + availableLidarFrames: integer( + sourceEvidence.available_lidar_frames, + "source.available_lidar_frames", + ), + pointCount: integer(sourceEvidence.point_count, "source.point_count"), + representation: "legacy-e10-vendor-map-with-pose", + intensityAvailable: false, + rawScanAccepted: false, + }, + selection: { + purpose: "operator-readable-central-urban-field-review", + defaultWindowIndex, + accumulation: "per-source-frame masks accumulated in map frame", + maximumPointsPerWindow, + }, + windows, + metrics: { + sourceSamples: integer(metrics.source_samples, "metrics.source_samples"), + current: fieldReviewBranch(metrics.current, "metrics.current"), + candidate: fieldReviewBranch(metrics.candidate, "metrics.candidate"), + comparison: { + algorithmGroundIou: distribution( + comparison.algorithm_to_algorithm_ground_iou, + "metrics.comparison.algorithm_ground_iou", + ), + groundDisagreementFraction: distribution( + comparison.ground_disagreement_fraction, + "metrics.comparison.ground_disagreement_fraction", + ), + isAccuracyMetric: false, + }, + }, + decision: { + status: "visual-review-only", + productionPromotion: false, + reasons: array(decision.reasons, "decision.reasons").map((reason) => + string(reason, "decision.reason") + ), + }, + createdAtUtc: + source.created_at_utc === null || source.created_at_utc === undefined + ? null + : string(source.created_at_utc, "created_at_utc"), + groundTruth: false, + authority: { + commandsEnabled: false, + navigationOrSafetyAccepted: false, + }, + }; +} + +export function parseLidarFieldReviewCatalog( + value: unknown, +): LidarFieldReviewCatalog { + const source = record(value, "LiDAR field-review catalog"); + if ( + source.schema_version !== "missioncore.lidar-field-review-catalog/v1" + || source.access !== "read-only" + ) { + throw new LidarReplayContractError("LiDAR field-review catalog несовместим"); + } + return { + configured: boolean(source.configured, "configured"), + validTotal: integer(source.valid_total, "valid_total"), + invalidTotal: integer(source.invalid_total, "invalid_total"), + items: array(source.items, "items").map(fieldReview), + }; +} + +export function parseLidarFieldReviewWindow( + value: unknown, +): LidarFieldReviewWindow { + const source = record(value, "LiDAR field-review window"); + const reviewId = string( + source.review_id, + "review_id", + SAFE_FIELD_REVIEW_ID, + ); + if ( + source.schema_version !== "missioncore.lidar-field-review-window/v1" + || source.access !== "read-only" + || source.ground_truth !== false + || source.coordinate_frame !== "map" + || source.distance_unit !== "m" + ) { + throw new LidarReplayContractError("LiDAR field-review window несовместим"); + } + const authority = record(source.authority, "authority"); + const intensity = record(source.intensity, "intensity"); + if ( + authority.commands_enabled !== false + || authority.navigation_or_safety_accepted !== false + || intensity.available !== false + ) { + throw new LidarReplayContractError("LiDAR field-review authority несовместим"); + } + const pointCount = integer(source.point_count, "point_count"); + if (pointCount < 1 || pointCount > 80_000) { + throw new LidarReplayContractError("LiDAR field-review window слишком большой"); + } + const points = array(source.points_xyz_m, "points_xyz_m"); + if (points.length !== pointCount) { + throw new LidarReplayContractError("Количество field-review 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 masks = record(source.masks, "masks"); + const currentGround = groundMask( + masks.current_ground, + "masks.current_ground", + pointCount, + ); + const currentAssigned = groundMask( + masks.current_assigned, + "masks.current_assigned", + pointCount, + ); + const candidateGround = groundMask( + masks.candidate_ground, + "masks.candidate_ground", + pointCount, + ); + const candidateAssigned = groundMask( + masks.candidate_assigned, + "masks.candidate_assigned", + pointCount, + ); + const disagreement = groundMask( + masks.disagreement, + "masks.disagreement", + pointCount, + ); + const counts = record(source.counts, "counts"); + 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 windowIndex = integer(source.window_index, "window_index"); + const windowCount = integer(source.window_count, "window_count"); + const window = fieldReviewWindowSummary(source.window, windowIndex); + const expectedPreviewUrl = + `/api/v1/lidar/field-reviews/${reviewId}/windows/${windowIndex}/preview`; + if ( + windowCount < 1 + || windowIndex >= windowCount + || window.displayPointCount !== pointCount + || 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]), + ) + || source.preview_url !== expectedPreviewUrl + ) { + throw new LidarReplayContractError("LiDAR field-review content несовместим"); + } + return { + reviewId, + displayName: string(source.display_name, "display_name"), + sessionId: string(source.session_id, "session_id", SAFE_ID), + sourcePackId: string( + source.source_pack_id, + "source_pack_id", + SAFE_E10_PACK_ID, + ), + windowIndex, + windowCount, + window, + pointCount, + coordinateFrame: "map", + distanceUnit: "m", + pointsXyzM, + intensity0To255: null, + intensity: { + available: false, + reason: string(intensity.reason, "intensity.reason"), + }, + masks: { + currentGround, + currentAssigned, + candidateGround, + candidateAssigned, + disagreement, + }, + counts: parsedCounts, + previewUrl: expectedPreviewUrl, + groundTruth: false, + authority: { + commandsEnabled: false, + navigationOrSafetyAccepted: false, + }, + }; +} + async function responseJson( response: Response, fallback: string, @@ -739,3 +1194,48 @@ export async function fetchLidarGroundFrame( await responseJson(response, "Не удалось получить LiDAR ground frame."), ); } + +export async function fetchLidarFieldReviews( + options: { signal?: AbortSignal; fetcher?: LidarFetch } = {}, +): Promise { + const fetcher = options.fetcher ?? fetch; + const response = await fetcher("/api/v1/lidar/field-reviews?limit=10", { + method: "GET", + headers: { Accept: "application/json" }, + signal: options.signal, + }); + return parseLidarFieldReviewCatalog( + await responseJson(response, "Не удалось получить полевой LiDAR review."), + ); +} + +export async function fetchLidarFieldReviewWindow( + reviewId: string, + windowIndex: number, + options: { signal?: AbortSignal; fetcher?: LidarFetch } = {}, +): Promise { + if ( + !SAFE_FIELD_REVIEW_ID.test(reviewId) + || !Number.isInteger(windowIndex) + || windowIndex < 0 + ) { + throw new LidarReplayContractError( + "Некорректное окно полевого LiDAR review", + ); + } + const fetcher = options.fetcher ?? fetch; + const response = await fetcher( + `/api/v1/lidar/field-reviews/${reviewId}/windows/${windowIndex}`, + { + method: "GET", + headers: { Accept: "application/json" }, + signal: options.signal, + }, + ); + return parseLidarFieldReviewWindow( + await responseJson( + response, + "Не удалось получить окно полевого LiDAR review.", + ), + ); +} diff --git a/apps/control-station/src/styles/responsive.css b/apps/control-station/src/styles/responsive.css index 46273f8..459c1b3 100644 --- a/apps/control-station/src/styles/responsive.css +++ b/apps/control-station/src/styles/responsive.css @@ -18,6 +18,23 @@ grid-template-columns: 1fr; } + .lidar-field-review__source { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .lidar-field-window-list { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .lidar-field-stage { + grid-template-columns: 1fr; + } + + .lidar-field-camera img { + min-height: 0; + aspect-ratio: 4 / 3; + } + .polygon-run-providers { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -202,6 +219,21 @@ display: none; } + .lidar-field-review__source, + .lidar-field-window-list { + grid-template-columns: 1fr; + } + + .lidar-field-cloud > header, + .lidar-field-review__explanation { + align-items: stretch; + grid-template-columns: 1fr; + } + + .lidar-field-cloud > header { + flex-direction: column; + } + .polygon-run-identity dl { grid-template-columns: 1fr; } diff --git a/apps/control-station/src/styles/workspaces.css b/apps/control-station/src/styles/workspaces.css index 6e59507..550e19c 100644 --- a/apps/control-station/src/styles/workspaces.css +++ b/apps/control-station/src/styles/workspaces.css @@ -954,6 +954,218 @@ margin-top: 0.8rem; } +.lidar-field-review { + min-width: 0; + border-color: rgb(74 215 255 / 0.22); + background: + radial-gradient(circle at 8% 0%, rgb(56 124 255 / 0.13), transparent 30rem), + var(--station-panel); +} + +.lidar-field-review__heading p, +.lidar-field-review__explanation p, +.lidar-field-review__empty p { + margin: 0.28rem 0 0; + max-width: 49rem; + color: var(--nodedc-text-muted); + font-size: 0.64rem; + line-height: 1.5; +} + +.lidar-field-review__source { + display: grid; + grid-template-columns: minmax(13rem, 1.4fr) repeat(3, minmax(0, 0.72fr)); + gap: 0.55rem; + margin-top: 1rem; +} + +.lidar-field-review__source > div { + display: grid; + min-width: 0; + gap: 0.28rem; + border: 1px solid var(--station-hairline); + border-radius: 0.72rem; + background: rgb(255 255 255 / 0.025); + padding: 0.64rem 0.7rem; +} + +.lidar-field-review__source span, +.lidar-field-window-list small, +.lidar-field-stage__label span, +.lidar-field-camera footer, +.lidar-field-stage__pending { + color: var(--nodedc-text-muted); + font-size: 0.59rem; + line-height: 1.4; +} + +.lidar-field-review__source strong { + overflow: hidden; + color: var(--nodedc-text-primary); + font-size: 0.68rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lidar-field-window-list { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 0.48rem; + margin-top: 0.65rem; +} + +.lidar-field-window-list > button { + display: flex; + min-width: 0; + align-items: flex-start; + gap: 0.5rem; + border: 1px solid var(--station-hairline); + border-radius: 0.78rem; + background: rgb(255 255 255 / 0.02); + padding: 0.62rem; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.lidar-field-window-list > button:hover, +.lidar-field-window-list > button[data-selected="true"] { + border-color: rgb(74 215 255 / 0.48); + background: rgb(74 215 255 / 0.08); +} + +.lidar-field-window-list > button > span { + display: grid; + flex: 0 0 auto; + width: 1.35rem; + height: 1.35rem; + place-items: center; + border-radius: 50%; + background: rgb(74 215 255 / 0.12); + color: #78e3ff; + font-size: 0.59rem; + font-weight: 700; +} + +.lidar-field-window-list > button > div { + display: grid; + min-width: 0; + gap: 0.18rem; +} + +.lidar-field-window-list strong { + color: var(--nodedc-text-primary); + font-size: 0.61rem; + line-height: 1.35; +} + +.lidar-field-stage { + display: grid; + grid-template-columns: minmax(18rem, 0.68fr) minmax(0, 1.32fr); + gap: 0.65rem; + margin-top: 0.7rem; +} + +.lidar-field-camera, +.lidar-field-cloud { + overflow: hidden; + min-width: 0; + border: 1px solid rgb(255 255 255 / 0.09); + border-radius: 0.9rem; + background: #071018; +} + +.lidar-field-camera { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.lidar-field-camera > .lidar-field-stage__label, +.lidar-field-cloud > header { + min-height: 3.45rem; + border-bottom: 1px solid rgb(255 255 255 / 0.08); + padding: 0.66rem 0.72rem; +} + +.lidar-field-stage__label { + display: grid; + gap: 0.18rem; +} + +.lidar-field-stage__label strong { + color: var(--nodedc-text-primary); + font-size: 0.68rem; +} + +.lidar-field-camera img { + display: block; + width: 100%; + height: 100%; + min-height: 25rem; + object-fit: contain; +} + +.lidar-field-camera footer { + display: flex; + justify-content: space-between; + gap: 0.5rem; + border-top: 1px solid rgb(255 255 255 / 0.08); + padding: 0.52rem 0.7rem; +} + +.lidar-field-stage__pending { + display: grid; + min-height: 25rem; + place-items: center; + padding: 1rem; + text-align: center; +} + +.lidar-field-cloud > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.65rem; +} + +.lidar-field-cloud .lidar-ground-scene { + min-height: 28rem; + border: 0; + border-radius: 0; +} + +.lidar-field-cloud .lidar-ground-scene-placeholder { + min-height: 28rem; + border: 0; + border-radius: 0; +} + +.lidar-field-review__explanation { + display: grid; + grid-template-columns: auto minmax(15rem, 1fr) auto; + align-items: center; + gap: 0.65rem; + margin-top: 0.68rem; + border-top: 1px solid var(--station-hairline); + padding-top: 0.68rem; +} + +.lidar-field-review__explanation p { + margin: 0; +} + +.lidar-field-review__empty { + display: flex; + align-items: center; + gap: 0.65rem; + margin-top: 0.8rem; +} + +.lidar-field-review__empty p { + margin: 0; +} + .lidar-ground-benchmark { min-width: 0; } diff --git a/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx b/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx index e89aa15..090eb6d 100644 --- a/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx +++ b/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx @@ -2,16 +2,27 @@ 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"; +export interface LidarGroundPointCloudFrame { + pointCount: number; + pointsXyzM: Array<[number, number, number]>; + intensity0To255: number[] | null; + masks: { + currentGround: number[]; + currentAssigned: number[]; + candidateGround: number[]; + candidateAssigned: number[]; + disagreement: number[]; + }; +} + interface LidarGroundPointCloudProps { - frame: LidarGroundFrame; + frame: LidarGroundPointCloudFrame; mode: LidarGroundViewMode; } @@ -28,7 +39,7 @@ function setRgb( } function frameColors( - frame: LidarGroundFrame, + frame: LidarGroundPointCloudFrame, mode: LidarGroundViewMode, ): Float32Array { const colors = new Float32Array(frame.pointCount * 3); @@ -38,7 +49,7 @@ function frameColors( const candidate = frame.masks.candidateGround[index] === 1; const candidateAssigned = frame.masks.candidateAssigned[index] === 1; if (mode === "intensity") { - const intensity = frame.intensity0To255[index] / 255; + const intensity = (frame.intensity0To255?.[index] ?? 96) / 255; setRgb( colors, offset, @@ -88,6 +99,8 @@ export function LidarGroundPointCloud({ const materialRef = useRef(null); const cameraRef = useRef(null); const controlsRef = useRef(null); + const fogRef = useRef(null); + const gridRef = useRef(null); const [renderError, setRenderError] = useState(null); useEffect(() => { @@ -115,7 +128,9 @@ export function LidarGroundPointCloud({ host.prepend(renderer.domElement); const scene = new THREE.Scene(); - scene.fog = new THREE.FogExp2(0x071018, 0.035); + const fog = new THREE.FogExp2(0x071018, 0.035); + scene.fog = fog; + fogRef.current = fog; const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 1_000); camera.position.set(6, 4.5, 6); cameraRef.current = camera; @@ -147,6 +162,7 @@ export function LidarGroundPointCloud({ scene.add(new THREE.Points(geometry, material)); const grid = new THREE.GridHelper(24, 48, 0x3c7cff, 0x233747); + gridRef.current = grid; const gridMaterials = Array.isArray(grid.material) ? grid.material : [grid.material]; @@ -198,6 +214,8 @@ export function LidarGroundPointCloud({ materialRef.current = null; cameraRef.current = null; controlsRef.current = null; + fogRef.current = null; + gridRef.current = null; }; }, []); @@ -206,7 +224,9 @@ export function LidarGroundPointCloud({ const material = materialRef.current; const camera = cameraRef.current; const controls = controlsRef.current; - if (!geometry || !material || !camera || !controls) return; + const fog = fogRef.current; + const grid = gridRef.current; + if (!geometry || !material || !camera || !controls || !fog || !grid) return; const positions = new Float32Array(frame.pointCount * 3); let minimumX = Number.POSITIVE_INFINITY; @@ -235,6 +255,8 @@ export function LidarGroundPointCloud({ geometry.computeBoundingSphere(); const radius = Math.max(geometry.boundingSphere?.radius ?? 1, 0.2); material.size = THREE.MathUtils.clamp(radius / 155, 0.014, 0.075); + fog.density = THREE.MathUtils.clamp(0.18 / radius, 0.0008, 0.035); + grid.scale.setScalar(Math.max(radius / 12, 1)); const targetHeight = Math.max((maximumZ - minimumZ) * 0.35, 0.15); const distance = Math.max(radius * 1.8, 1.2); diff --git a/apps/control-station/src/workspaces/LidarQualityWorkspace.tsx b/apps/control-station/src/workspaces/LidarQualityWorkspace.tsx index 3b84aee..c5047f3 100644 --- a/apps/control-station/src/workspaces/LidarQualityWorkspace.tsx +++ b/apps/control-station/src/workspaces/LidarQualityWorkspace.tsx @@ -6,10 +6,14 @@ import { } from "@nodedc/ui-react"; import { + fetchLidarFieldReviews, + fetchLidarFieldReviewWindow, fetchLidarGroundFrame, fetchLidarGroundBenchmarks, fetchLidarReplayCatalog, fetchLidarReplayDetail, + type LidarFieldReview, + type LidarFieldReviewWindow, type LidarGroundBenchmark, type LidarGroundFrame, type LidarReplayCatalog, @@ -69,6 +73,14 @@ export function LidarQualityWorkspace({ const [groundFrameError, setGroundFrameError] = useState(null); const [groundViewMode, setGroundViewMode] = useState("disagreement"); + const [fieldReview, setFieldReview] = useState(null); + const [fieldWindow, setFieldWindow] = + useState(null); + const [fieldWindowIndex, setFieldWindowIndex] = useState(0); + const [fieldLoading, setFieldLoading] = useState(true); + const [fieldError, setFieldError] = useState(null); + const [fieldViewMode, setFieldViewMode] = + useState("disagreement"); const [selectedPackId, setSelectedPackId] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -146,7 +158,56 @@ export function LidarQualityWorkspace({ return () => controller.abort(); }, [groundBenchmark, groundFrameIndex]); + useEffect(() => { + const controller = new AbortController(); + setFieldLoading(true); + setFieldError(null); + void fetchLidarFieldReviews({ signal: controller.signal }) + .then((nextCatalog) => { + if (controller.signal.aborted) return; + const nextReview = nextCatalog.items[0] ?? null; + setFieldReview(nextReview); + setFieldWindow(null); + setFieldWindowIndex(nextReview?.selection.defaultWindowIndex ?? 0); + }) + .catch((loadError) => { + if (controller.signal.aborted) return; + setFieldReview(null); + setFieldWindow(null); + setFieldError(errorMessage(loadError)); + }) + .finally(() => { + if (!controller.signal.aborted) setFieldLoading(false); + }); + return () => controller.abort(); + }, [reloadGeneration]); + + useEffect(() => { + if (!fieldReview) return; + const controller = new AbortController(); + setFieldLoading(true); + setFieldError(null); + void fetchLidarFieldReviewWindow( + fieldReview.reviewId, + fieldWindowIndex, + { signal: controller.signal }, + ) + .then((window) => { + if (!controller.signal.aborted) setFieldWindow(window); + }) + .catch((loadError) => { + if (controller.signal.aborted) return; + setFieldWindow(null); + setFieldError(errorMessage(loadError)); + }) + .finally(() => { + if (!controller.signal.aborted) setFieldLoading(false); + }); + return () => controller.abort(); + }, [fieldReview, fieldWindowIndex]); + const groundNormalization = groundBenchmark?.inputDomain.normalization ?? null; + const selectedFieldWindow = fieldReview?.windows[fieldWindowIndex] ?? null; return (
@@ -191,12 +252,213 @@ export function LidarQualityWorkspace({ ) : ( <> + +
+
+ ПОЛЕВОЙ REVIEW · RAVNOVES00 +

+ {fieldReview?.displayName + ?? "Центральный городской интервал"} +

+

+ Дорога, дома, автомобили и растительность. Облако накоплено + по исходным LiDAR-сэмплам выбранного окна, а кадр камеры + фиксирует контекст сцены. +

+
+ + {fieldError ? "Review недоступен" : "Visual review only"} + +
+ + {fieldReview ? ( + <> +
+
+ Запись + {fieldReview.sessionId} +
+
+ Интервал записи + + {formatNumber(fieldReview.source.timelineStartSeconds, 2)} + {"–"} + {formatNumber(fieldReview.source.timelineEndSeconds, 2)} с + +
+
+ LiDAR-сэмплов + + {fieldReview.source.availableLidarFrames.toLocaleString("ru-RU")} + +
+
+ Исходных точек + + {fieldReview.source.pointCount.toLocaleString("ru-RU")} + +
+
+ +
+ {fieldReview.windows.map((window) => ( + + ))} +
+ +
+
+
+ КОНТЕКСТ КАМЕРЫ + + {selectedFieldWindow?.label ?? "Выбранная сцена"} + +
+ {fieldWindow ? ( + {`Кадр + ) : ( +
+ {fieldError ?? "Загружаем кадр выбранной сцены…"} +
+ )} +
+ + Кадр {selectedFieldWindow?.previewSourceFrameIndex ?? "—"} + + + t = {formatNumber( + selectedFieldWindow?.previewSessionSeconds ?? null, + 2, + )}{" "} + с + +
+
+ +
+
+
+ НАКОПЛЕННОЕ MAP-ОБЛАКО + + {selectedFieldWindow + ? `${selectedFieldWindow.sourcePointCount.toLocaleString( + "ru-RU", + )} исходных · ${selectedFieldWindow.displayPointCount.toLocaleString( + "ru-RU", + )} показано` + : "Ожидание данных"} + +
+
+ {([ + ["current", "Current"], + ["candidate", "Patchwork++"], + ["disagreement", "Расхождения"], + ] as const).map(([mode, label]) => ( + + ))} +
+
+ {fieldWindow ? ( + + ) : ( +
+ + {fieldError ? "Ошибка" : "Загрузка"} + + + {fieldError ?? "Готовим накопленное облако…"} + +
+ )} +
+
+ +
+ Не accuracy +

+ Маски вычислены отдельно на каждом исходном скане и только + затем сведены в map frame. Это legacy E10 vendor-map + derivative без intensity и независимой ground-разметки, + поэтому результат предназначен для визуального разбора, а + не для production-gate. +

+
+ Ground у обоих + Только current + Только Patchwork++ + Оба non-ground +
+
+ + ) : ( +
+ + {fieldError ? "Ошибка загрузки" : "Проверка evidence"} + +

+ {fieldError + ?? (fieldLoading + ? "Читаем RAVNOVES00 field-review артефакт…" + : "Field-review артефакт ещё не опубликован.")} +

+
+ )} +
+
- POINT-ALIGNED REVIEW + ТЕХНИЧЕСКИЙ CONTRACT SLICE · VIEWER_LIVE -

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

+

Indoor-проверка point-aligned контракта

- Один и тот же map-frame XYZ, разные диагностические - раскраски. Маски не изменяют replay. + Этот короткий indoor-срез подтверждает выравнивание XYZ + и масок, но не является полевой оценкой качества. Для + улицы используйте RAVNOVES00 выше.

diff --git a/apps/control-station/test/lidarReplayQuality.test.mjs b/apps/control-station/test/lidarReplayQuality.test.mjs index 171219a..01a8ded 100644 --- a/apps/control-station/test/lidarReplayQuality.test.mjs +++ b/apps/control-station/test/lidarReplayQuality.test.mjs @@ -8,10 +8,14 @@ let parseLidarReplayCatalog; let parseLidarReplayDetail; let parseLidarGroundBenchmarkCatalog; let parseLidarGroundFrame; +let parseLidarFieldReviewCatalog; +let parseLidarFieldReviewWindow; let fetchLidarReplayCatalog; let fetchLidarReplayDetail; let fetchLidarGroundBenchmarks; let fetchLidarGroundFrame; +let fetchLidarFieldReviews; +let fetchLidarFieldReviewWindow; let LidarReplayContractError; let workspaceById; @@ -26,10 +30,14 @@ before(async () => { parseLidarReplayDetail, parseLidarGroundBenchmarkCatalog, parseLidarGroundFrame, + parseLidarFieldReviewCatalog, + parseLidarFieldReviewWindow, fetchLidarReplayCatalog, fetchLidarReplayDetail, fetchLidarGroundBenchmarks, fetchLidarGroundFrame, + fetchLidarFieldReviews, + fetchLidarFieldReviewWindow, LidarReplayContractError, } = await server.ssrLoadModule("/src/core/lidar/replayQuality.ts")); ({ workspaceById } = await server.ssrLoadModule("/src/productModel.ts")); @@ -40,6 +48,8 @@ after(async () => { }); const packId = `lidar-replay-pack-${"a".repeat(64)}`; +const fieldReviewId = `lidar-field-review-${"d".repeat(64)}`; +const e10PackId = `e10-lidar-pack-${"e".repeat(64)}`; function summary(overrides = {}) { return { @@ -267,6 +277,145 @@ function groundFrame(overrides = {}) { }; } +function fieldReviewWindowSummary(overrides = {}) { + return { + index: 0, + key: "intersection-facades", + label: "Перекрёсток, дорога и фасады", + start_seconds: 145, + end_seconds: 151, + midpoint_seconds: 148, + source_lidar_samples: 56, + source_point_count: 98538, + display_point_count: 3, + source_frame_start: 1097, + source_frame_end: 1156, + preview_source_frame_index: 1120, + preview_session_seconds: 147.451857292, + ...overrides, + }; +} + +function fieldReviewCatalog(overrides = {}) { + const provider = (providerId, sourceCommit = undefined) => ({ + provider_id: providerId, + source_commit: sourceCommit, + }); + const branch = (providerValue) => ({ + provider: providerValue, + ground_fraction: distribution(), + latency_ms: distribution(), + }); + return { + schema_version: "missioncore.lidar-field-review-catalog/v1", + configured: true, + valid_total: 1, + invalid_total: 0, + access: "read-only", + items: [{ + review_id: fieldReviewId, + display_name: "RAVNOVES00 · центральный городской интервал", + session_id: "20260720T065719Z_viewer_live", + source_pack_id: e10PackId, + status: "diagnostic-only", + source: { + timeline_start_seconds: 135.365857292, + timeline_end_seconds: 195.334857292, + available_lidar_frames: 526, + point_count: 1182292, + representation: "legacy-e10-vendor-map-with-pose", + intensity_available: false, + raw_scan_accepted: false, + }, + selection: { + purpose: "operator-readable-central-urban-field-review", + default_window_index: 0, + accumulation: "per-source-frame masks accumulated in map frame", + sampling: { + method: "uniform-point-index-per-window", + maximum_points_per_window: 80000, + }, + }, + windows: [fieldReviewWindowSummary()], + metrics: { + source_samples: 56, + current: branch( + provider("missioncore-local-percentile-ground/v1"), + ), + candidate: branch( + provider( + "patchworkpp/v1.4.1", + "3e6903a1d5537a4cc2ace897b0bbb98a92d6014c", + ), + ), + comparison: { + algorithm_to_algorithm_ground_iou: distribution(), + ground_disagreement_fraction: distribution(), + is_accuracy_metric: false, + }, + }, + decision: { + status: "visual-review-only", + production_promotion: false, + reasons: ["legacy derivative does not retain intensity"], + }, + created_at_utc: "2026-07-25T02:00:00Z", + ground_truth: false, + authority: { + commands_enabled: false, + navigation_or_safety_accepted: false, + }, + }], + ...overrides, + }; +} + +function fieldReviewWindow(overrides = {}) { + return { + schema_version: "missioncore.lidar-field-review-window/v1", + review_id: fieldReviewId, + display_name: "RAVNOVES00 · центральный городской интервал", + session_id: "20260720T065719Z_viewer_live", + source_pack_id: e10PackId, + window_index: 0, + window_count: 1, + window: fieldReviewWindowSummary(), + point_count: 3, + coordinate_frame: "map", + distance_unit: "m", + intensity: { + available: false, + reason: "E10 derivative did not retain rgbi/intensity", + }, + points_xyz_m: [ + [0, 0, 0], + [1, 0, 0.1], + [0, 1, 0.5], + ], + 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, + }, + preview_url: + `/api/v1/lidar/field-reviews/${fieldReviewId}/windows/0/preview`, + access: "read-only", + ground_truth: false, + authority: { + commands_enabled: false, + navigation_or_safety_accepted: false, + }, + ...overrides, + }; +} + function jsonResponse(payload, status = 200) { return new Response(JSON.stringify(payload), { status, @@ -359,6 +508,49 @@ test("ground frame stays point-aligned, bounded and path-free", () => { ); }); +test("field review identifies RAVNOVES00 source and stays visual-only", () => { + const parsedCatalog = parseLidarFieldReviewCatalog(fieldReviewCatalog()); + const parsedWindow = parseLidarFieldReviewWindow(fieldReviewWindow()); + + assert.equal(parsedCatalog.items[0].sourcePackId, e10PackId); + assert.equal(parsedCatalog.items[0].source.availableLidarFrames, 526); + assert.equal(parsedCatalog.items[0].selection.defaultWindowIndex, 0); + assert.equal(parsedWindow.window.label, "Перекрёсток, дорога и фасады"); + assert.equal(parsedWindow.intensity0To255, null); + assert.equal(parsedWindow.counts.disagreement, 1); + assert.equal("path" in parsedWindow, false); + + const promoted = fieldReviewCatalog(); + promoted.items[0].decision.production_promotion = true; + assert.throws( + () => parseLidarFieldReviewCatalog(promoted), + LidarReplayContractError, + ); +}); + +test("field-review window refuses forged preview and point masks", () => { + assert.throws( + () => parseLidarFieldReviewWindow(fieldReviewWindow({ + preview_url: "https://example.invalid/frame.jpg", + })), + LidarReplayContractError, + ); + assert.throws( + () => parseLidarFieldReviewWindow(fieldReviewWindow({ + masks: { + ...fieldReviewWindow().masks, + disagreement: [0, 0, 0], + }, + counts: { + current_ground: 2, + candidate_ground: 1, + disagreement: 0, + }, + })), + LidarReplayContractError, + ); +}); + test("LiDAR fetchers use read-only endpoints and workspace is registered", async () => { const calls = []; const fetcher = async (input, init) => { @@ -368,6 +560,11 @@ test("LiDAR fetchers use read-only endpoints and workspace is registered", async ? jsonResponse(groundFrame()) : jsonResponse(groundCatalog()); } + if (String(input).includes("field-reviews")) { + return String(input).includes("/windows/") + ? jsonResponse(fieldReviewWindow()) + : jsonResponse(fieldReviewCatalog()); + } return String(input).includes(packId) ? jsonResponse(detail()) : jsonResponse(catalog()); @@ -380,11 +577,19 @@ test("LiDAR fetchers use read-only endpoints and workspace is registered", async 0, { fetcher }, ); + const fieldCatalog = await fetchLidarFieldReviews({ fetcher }); + const fieldWindow = await fetchLidarFieldReviewWindow( + fieldReviewId, + 0, + { fetcher }, + ); assert.equal(parsedCatalog.validTotal, 1); assert.equal(parsedDetail.pack.packId, packId); assert.equal(ground.validTotal, 1); assert.equal(frame.pointCount, 3); + assert.equal(fieldCatalog.items[0].windows[0].sourceLidarSamples, 56); + assert.equal(fieldWindow.previewUrl.endsWith("/preview"), true); assert.deepEqual(calls, [ { input: "/api/v1/lidar/replay-packs?limit=50", method: "GET" }, { input: `/api/v1/lidar/replay-packs/${packId}`, method: "GET" }, @@ -396,6 +601,14 @@ test("LiDAR fetchers use read-only endpoints and workspace is registered", async input: `/api/v1/lidar/ground-benchmarks/ground-benchmark-${"c".repeat(64)}/frames/0`, method: "GET", }, + { + input: "/api/v1/lidar/field-reviews?limit=10", + method: "GET", + }, + { + input: `/api/v1/lidar/field-reviews/${fieldReviewId}/windows/0`, + method: "GET", + }, ]); assert.equal(workspaceById("lidar-quality").root, "data"); assert.equal(workspaceById("lidar-quality").kind, "lidar-quality"); diff --git a/docs/10_EXTERNAL_PERCEPTION_WORKER.md b/docs/10_EXTERNAL_PERCEPTION_WORKER.md index ea70fcc..f6e8d91 100644 --- a/docs/10_EXTERNAL_PERCEPTION_WORKER.md +++ b/docs/10_EXTERNAL_PERCEPTION_WORKER.md @@ -31,6 +31,17 @@ 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. +`missioncore.lidar-field-review/v1` is the separate operator-readable field +surface. The read-only `/api/v1/lidar/field-reviews` catalog names the exact +RAVNOVES00 source, timeline and five central urban windows. The bounded +`/api/v1/lidar/field-reviews/{review_id}/windows/{window_index}` response +contains a deterministic accumulated map cloud plus masks that were computed +per original LiDAR sample before accumulation. Its sibling `/preview` endpoint +returns the content-bound camera JPEG for that window. No source path, +Patchwork++ binary, worker control or command authority crosses this boundary. +The legacy E10 source has no intensity, so the API declares it unavailable +instead of synthesizing a value. + 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 diff --git a/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md b/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md index 578da76..b11b829 100644 --- a/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md +++ b/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md @@ -226,6 +226,8 @@ quality line. It is not repaired or hidden by replay. 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] Separate the 66-frame indoor contract slice from a named RAVNOVES00 + field review with five camera-bound urban windows and accumulated map clouds. - [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 @@ -255,6 +257,26 @@ 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 operator-facing field generation is +`lidar-field-review-57f359dae336f06962e3a29e69e2da1bb8365f9af46d5d3173609f92d43db1ef`. +It uses the immutable RAVNOVES00 E10 derivative +`e10-lidar-pack-5da0396d32a27f9d1ca537cc2e8a371d386078d6f0dc71737b78620992af9625` +over `135.365857292–195.334857292` session seconds. The source has 526 +available LiDAR samples and 1,182,292 points. Five central urban windows cover +roads, facades, sidewalks, parked vehicles and vegetation. Each window: + +- retains the exact source interval and camera preview identity; +- computes both ground masks independently on every original LiDAR sample; +- accumulates those point-aligned results only afterwards in the map frame; +- reports 53–68 source samples and 98,538–172,027 source points; +- publishes a deterministic uniform display sample of at most 80,000 points. + +This field review fixes the operator-context problem of the indoor technical +slice; it does not upgrade evidence. The E10 derivative remains intensity-free, +vendor-mapped and without independent ground labels. Its contract is therefore +`missioncore.lidar-field-review/v1`, `status=diagnostic-only`, +`decision.status=visual-review-only` and `production_promotion=false`. + The result is an evidence-backed **do-not-promote** decision for the current K1 feed. Patchwork++ is fast, but its input model assumes a sensor-centric scan and physical sensor height. K1 `lio_pcl` is a vendor-mapped increment, its diff --git a/experiments/perception/run_lidar_field_review.py b/experiments/perception/run_lidar_field_review.py new file mode 100644 index 0000000..24c92e7 --- /dev/null +++ b/experiments/perception/run_lidar_field_review.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +import subprocess +import tempfile +from pathlib import Path + +from k1link.compute import ( + PATCHWORKPP_SOURCE_COMMIT, + PATCHWORKPP_SOURCE_TAG, + RAVNOVES00_CENTRAL_WINDOWS, + E10LidarFieldSource, + GroundBenchmarkProfile, + LidarFieldReviewV1, + PatchworkPPGroundSegmenter, + build_lidar_field_review, +) + + +def _arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Build accumulated RAVNOVES00 central-urban LiDAR ground review " + "with content-bound camera context previews." + ) + ) + parser.add_argument("source_pack", type=Path) + parser.add_argument("camera_epoch", type=Path) + parser.add_argument("output_root", type=Path) + parser.add_argument("--ffmpeg", default="ffmpeg") + parser.add_argument("--patchwork-module", default="pypatchworkpp") + parser.add_argument("--patchwork-source-tag", default=PATCHWORKPP_SOURCE_TAG) + parser.add_argument( + "--patchwork-source-commit", + default=PATCHWORKPP_SOURCE_COMMIT, + ) + parser.add_argument("--sensor-height-m", type=float, default=1.27) + parser.add_argument("--map-vertical-origin-offset-m", type=float, default=1.27) + return parser.parse_args() + + +def _preview_segments(epoch: Path) -> dict[str, Path]: + resolved = epoch.expanduser().resolve(strict=True) + init = resolved / "init.mp4" + index_path = resolved / "index.jsonl" + if not init.is_file() or init.is_symlink() or not index_path.is_file(): + raise RuntimeError("Camera epoch is incomplete") + required = { + window.preview_source_frame_index + 1: window.key for window in RAVNOVES00_CENTRAL_WINDOWS + } + found: dict[str, Path] = {} + with index_path.open(encoding="utf-8") as stream: + for line in stream: + value = json.loads(line) + sequence = value.get("sequence") if isinstance(value, dict) else None + if sequence not in required: + continue + relative = value.get("path") + expected_sha256 = value.get("sha256") + expected_length = value.get("length") + if ( + value.get("schema_version") != "missioncore.camera-recording-index/v1" + or relative != f"segments/{sequence}.m4s" + or not isinstance(expected_sha256, str) + or not isinstance(expected_length, int) + ): + raise RuntimeError("Camera preview segment index is invalid") + segment = resolved / relative + if ( + segment.is_symlink() + or not segment.is_file() + or segment.stat().st_size != expected_length + or _sha256(segment) != expected_sha256 + ): + raise RuntimeError("Camera preview segment failed integrity") + found[required[sequence]] = segment + if set(found) != set(required.values()): + raise RuntimeError("Camera preview segments are incomplete") + return found + + +def _extract_previews( + ffmpeg: str, + epoch: Path, + output: Path, +) -> dict[str, Path]: + init = epoch.expanduser().resolve(strict=True) / "init.mp4" + segments = _preview_segments(epoch) + previews: dict[str, Path] = {} + for key, segment in segments.items(): + target = output / f"{key}.jpg" + subprocess.run( + [ + ffmpeg, + "-hide_banner", + "-loglevel", + "error", + "-i", + f"concat:{init}|{segment}", + "-frames:v", + "1", + "-vf", + "scale=640:480", + "-q:v", + "3", + str(target), + ], + check=True, + ) + if not target.is_file() or target.stat().st_size < 1_000: + raise RuntimeError("Camera preview extraction failed") + previews[key] = target + return previews + + +def main() -> int: + arguments = _arguments() + profile = GroundBenchmarkProfile( + profile_id="ravnoves00-central-urban-operator-height-ground-review/v1", + patchwork_sensor_height_proxy_m=arguments.sensor_height_m, + patchwork_map_vertical_origin_offset_m=(arguments.map_vertical_origin_offset_m), + patchwork_height_evidence="operator-estimated", + ) + source = E10LidarFieldSource(arguments.source_pack) + try: + patchwork = PatchworkPPGroundSegmenter.load( + profile=profile, + module_name=arguments.patchwork_module, + source_tag=arguments.patchwork_source_tag, + source_commit=arguments.patchwork_source_commit, + ) + with tempfile.TemporaryDirectory(prefix="missioncore-lidar-field-review-") as value: + previews = _extract_previews( + arguments.ffmpeg, + arguments.camera_epoch, + Path(value), + ) + output = build_lidar_field_review( + source, + arguments.output_root, + patchwork=patchwork, + profile=profile, + preview_paths=previews, + ) + finally: + source.close() + review = LidarFieldReviewV1(output) + try: + print( + json.dumps( + { + "review_id": review.review_id, + "display_name": review.report["display_name"], + "session_id": review.report["session_id"], + "source": review.report["source"], + "selection": review.report["selection"], + "windows": review.report["windows"], + "metrics": review.report["metrics"], + "decision": review.report["decision"], + }, + ensure_ascii=False, + indent=2, + ) + ) + finally: + review.close() + return 0 + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/k1link/compute/__init__.py b/src/k1link/compute/__init__.py index 4430145..4a74317 100644 --- a/src/k1link/compute/__init__.py +++ b/src/k1link/compute/__init__.py @@ -69,6 +69,22 @@ from .lidar_contract import ( lidar_readiness_document, sensor_frame_xyzi, ) +from .lidar_field_review import ( + E10_LIDAR_PACK_SCHEMA, + FIELD_REVIEW_ARRAYS_NAME, + FIELD_REVIEW_MANIFEST_NAME, + FIELD_REVIEW_REPORT_NAME, + LIDAR_FIELD_REVIEW_REPORT_SCHEMA, + LIDAR_FIELD_REVIEW_SCHEMA, + LIDAR_FIELD_REVIEW_WINDOW_SCHEMA, + MAX_FIELD_REVIEW_WINDOW_POINTS, + RAVNOVES00_CENTRAL_WINDOWS, + E10LidarFieldSource, + FieldReviewWindowSpec, + LidarFieldReviewV1, + build_lidar_field_review, + lidar_field_review_catalog_item, +) from .lidar_ground import ( DEFAULT_GROUND_BENCHMARK_PROFILE, LIDAR_GROUND_ANNOTATION_TEMPLATE_SCHEMA, @@ -179,6 +195,9 @@ __all__ = [ "LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA", "LIDAR_GROUND_BENCHMARK_SCHEMA", "LIDAR_GROUND_FRAME_SCHEMA", + "LIDAR_FIELD_REVIEW_REPORT_SCHEMA", + "LIDAR_FIELD_REVIEW_SCHEMA", + "LIDAR_FIELD_REVIEW_WINDOW_SCHEMA", "LIDAR_EVIDENCE_PROFILE_SCHEMA", "LIDAR_EQUIVALENCE_REPORT_SCHEMA", "LIDAR_QUALITY_REPORT_SCHEMA", @@ -262,6 +281,17 @@ __all__ = [ "build_lidar_ground_annotation_template", "build_lidar_ground_benchmark", "lidar_ground_frame_detail", + "E10_LIDAR_PACK_SCHEMA", + "FIELD_REVIEW_ARRAYS_NAME", + "FIELD_REVIEW_MANIFEST_NAME", + "FIELD_REVIEW_REPORT_NAME", + "MAX_FIELD_REVIEW_WINDOW_POINTS", + "RAVNOVES00_CENTRAL_WINDOWS", + "E10LidarFieldSource", + "FieldReviewWindowSpec", + "LidarFieldReviewV1", + "build_lidar_field_review", + "lidar_field_review_catalog_item", "DetectionFrame", "ObjectDetection", "RecordedPerceptionOverlayError", diff --git a/src/k1link/compute/lidar_field_review.py b/src/k1link/compute/lidar_field_review.py new file mode 100644 index 0000000..76698c0 --- /dev/null +++ b/src/k1link/compute/lidar_field_review.py @@ -0,0 +1,891 @@ +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Final + +import numpy as np +import numpy.typing as npt + +from k1link.device_plugins.xgrids_k1.analyze import ( + CalibratedProjectionError, + map_points_to_lidar, +) + +from .lidar_ground import ( + GroundBenchmarkProfile, + GroundSegmenter, + LidarGroundError, + LocalPercentileGroundSegmenter, +) + +LIDAR_FIELD_REVIEW_SCHEMA: Final = "missioncore.lidar-field-review/v1" +LIDAR_FIELD_REVIEW_REPORT_SCHEMA: Final = "missioncore.lidar-field-review-report/v1" +LIDAR_FIELD_REVIEW_WINDOW_SCHEMA: Final = "missioncore.lidar-field-review-window/v1" +E10_LIDAR_PACK_SCHEMA: Final = "missioncore.e10-lidar-replay-pack/v1" +FIELD_REVIEW_ARRAYS_NAME: Final = "field-review.npz" +FIELD_REVIEW_REPORT_NAME: Final = "field-review.json" +FIELD_REVIEW_MANIFEST_NAME: Final = "manifest.json" +MAX_FIELD_REVIEW_WINDOW_POINTS: Final = 80_000 + +_E10_PACK_ID = re.compile(r"^e10-lidar-pack-[a-f0-9]{64}$") +_FIELD_REVIEW_ID = re.compile(r"^lidar-field-review-[a-f0-9]{64}$") +_SHA256 = re.compile(r"^[a-f0-9]{64}$") +_SAFE_KEY = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$") + + +@dataclass(frozen=True, slots=True) +class FieldReviewWindowSpec: + key: str + label: str + start_seconds: float + end_seconds: float + preview_source_frame_index: int + + def __post_init__(self) -> None: + if ( + _SAFE_KEY.fullmatch(self.key) is None + or not self.label.strip() + or len(self.label) > 120 + or not np.isfinite((self.start_seconds, self.end_seconds)).all() + or not 0 <= self.start_seconds < self.end_seconds + or self.preview_source_frame_index < 0 + ): + raise LidarGroundError("LiDAR field-review window is invalid") + + def to_dict(self) -> dict[str, object]: + return { + "key": self.key, + "label": self.label, + "start_seconds": self.start_seconds, + "end_seconds": self.end_seconds, + "preview_source_frame_index": self.preview_source_frame_index, + } + + +RAVNOVES00_CENTRAL_WINDOWS: Final = ( + FieldReviewWindowSpec( + key="intersection-facades", + label="Перекрёсток, дорога и фасады", + start_seconds=145.0, + end_seconds=151.0, + preview_source_frame_index=1120, + ), + FieldReviewWindowSpec( + key="crossing-parked-vehicles", + label="Переход и припаркованные машины", + start_seconds=153.0, + end_seconds=159.0, + preview_source_frame_index=1200, + ), + FieldReviewWindowSpec( + key="long-street", + label="Длинный фасад, тротуар и улица", + start_seconds=167.0, + end_seconds=175.0, + preview_source_frame_index=1350, + ), + FieldReviewWindowSpec( + key="sidewalk-vehicles", + label="Тротуар, дома и автомобили", + start_seconds=180.0, + end_seconds=187.0, + preview_source_frame_index=1480, + ), + FieldReviewWindowSpec( + key="street-vegetation", + label="Продолжение улицы и растительность", + start_seconds=188.0, + end_seconds=194.0, + preview_source_frame_index=1550, + ), +) + + +class E10LidarFieldSource: + """Strict reader for the immutable, intensity-free RAVNOVES00 E10 pack.""" + + def __init__(self, root: Path) -> None: + candidate = root.expanduser().absolute() + if candidate.is_symlink(): + raise LidarGroundError("E10 LiDAR source cannot be a symlink") + self.root = candidate.resolve(strict=True) + if not self.root.is_dir() or _E10_PACK_ID.fullmatch(self.root.name) is None: + raise LidarGroundError("E10 LiDAR source id is invalid") + self.manifest = _read_json(self.root / "manifest.json") + self.identity = _object(self.manifest.get("identity"), "E10 LiDAR identity") + identity_sha256 = self.manifest.get("identity_sha256") + artifact = _object(self.manifest.get("artifact"), "E10 LiDAR artifact") + artifact_path = artifact.get("path") + if ( + self.manifest.get("schema_version") != E10_LIDAR_PACK_SCHEMA + or self.identity.get("schema_version") != E10_LIDAR_PACK_SCHEMA + or not isinstance(identity_sha256, str) + or _SHA256.fullmatch(identity_sha256) is None + or hashlib.sha256(_canonical_json(self.identity)).hexdigest() != identity_sha256 + or self.root.name != f"e10-lidar-pack-{identity_sha256}" + or self.manifest.get("pack_id") != self.root.name + or artifact_path != "lidar-pack.npz" + ): + raise LidarGroundError("E10 LiDAR source identity is invalid") + arrays_path = self.root / artifact_path + if ( + arrays_path.is_symlink() + or not arrays_path.is_file() + or arrays_path.stat().st_size != artifact.get("byte_length") + or _sha256(arrays_path) != artifact.get("sha256") + ): + raise LidarGroundError("E10 LiDAR source artifact is invalid") + self.arrays = np.load(arrays_path, allow_pickle=False) + try: + self._validate_arrays() + except BaseException: + self.close() + raise + self.pack_id = self.root.name + + @property + def frame_count(self) -> int: + return int(self.identity["frame_count"]) + + @property + def point_count(self) -> int: + return int(self.identity["point_count"]) + + def close(self) -> None: + self.arrays.close() + + def _validate_arrays(self) -> None: + required = { + "frame_indices", + "source_frame_indices", + "session_seconds", + "sample_available", + "cloud_offsets", + "cloud_points_map", + "pose_positions_map", + "pose_quaternions_map_from_lidar", + "lidar_camera_delta_ms", + "pose_point_delta_ms", + "intrinsic_fx_fy_cx_cy", + "distortion_kb4", + "t_camera_from_lidar", + } + frame_count = _nonnegative_int(self.identity.get("frame_count"), "E10 frame count") + point_count = _nonnegative_int(self.identity.get("point_count"), "E10 point count") + available = self.arrays["sample_available"] + offsets = self.arrays["cloud_offsets"] + points = self.arrays["cloud_points_map"] + positions = self.arrays["pose_positions_map"] + quaternions = self.arrays["pose_quaternions_map_from_lidar"] + if ( + set(self.arrays.files) != required + or self.arrays["frame_indices"].shape != (frame_count,) + or self.arrays["source_frame_indices"].shape != (frame_count,) + or self.arrays["session_seconds"].shape != (frame_count,) + or available.shape != (frame_count,) + or offsets.shape != (frame_count + 1,) + or points.shape != (point_count, 3) + or positions.shape != (frame_count, 3) + or quaternions.shape != (frame_count, 4) + or self.arrays["frame_indices"].dtype != np.dtype(" 0) + or not np.isfinite(self.arrays["session_seconds"]).all() + or not np.all(np.diff(self.arrays["session_seconds"]) > 0) + or offsets[0] != 0 + or offsets[-1] != point_count + or np.any(np.diff(offsets) < 0) + or not np.isfinite(points).all() + or int(np.count_nonzero(available)) != self.identity.get("available_lidar_frames") + ): + raise LidarGroundError("E10 LiDAR source arrays are invalid") + counts = np.diff(offsets) + if ( + np.any(counts[available] <= 0) + or np.any(counts[~available] != 0) + or not np.isfinite(positions[available]).all() + or not np.isfinite(quaternions[available]).all() + ): + raise LidarGroundError("E10 LiDAR source availability is invalid") + + +class LidarFieldReviewV1: + """Strict reader for accumulated, path-free LiDAR field-review evidence.""" + + def __init__(self, root: Path) -> None: + candidate = root.expanduser().absolute() + if candidate.is_symlink(): + raise LidarGroundError("LiDAR field review cannot be a symlink") + self.root = candidate.resolve(strict=True) + if not self.root.is_dir() or _FIELD_REVIEW_ID.fullmatch(self.root.name) is None: + raise LidarGroundError("LiDAR field-review id is invalid") + self.manifest = _read_json(self.root / FIELD_REVIEW_MANIFEST_NAME) + self.identity = _object(self.manifest.get("identity"), "field-review identity") + identity_sha256 = self.manifest.get("identity_sha256") + if ( + self.manifest.get("schema_version") != LIDAR_FIELD_REVIEW_SCHEMA + or self.identity.get("schema_version") != LIDAR_FIELD_REVIEW_SCHEMA + or not isinstance(identity_sha256, str) + or _SHA256.fullmatch(identity_sha256) is None + or hashlib.sha256(_canonical_json(self.identity)).hexdigest() != identity_sha256 + or self.root.name != f"lidar-field-review-{identity_sha256}" + or self.manifest.get("review_id") != self.root.name + ): + raise LidarGroundError("LiDAR field-review identity is invalid") + artifacts = _validate_artifacts(self.root, self.manifest.get("artifacts")) + self.arrays = np.load(artifacts["field-review"], allow_pickle=False) + self.report = _read_json(artifacts["field-review-report"]) + self.preview_paths = { + role.removeprefix("preview-"): path + for role, path in artifacts.items() + if role.startswith("preview-") + } + try: + self._validate() + except BaseException: + self.close() + raise + self.review_id = self.root.name + + def close(self) -> None: + self.arrays.close() + + def _validate(self) -> None: + windows = _list(self.report.get("windows"), "field-review windows") + parsed_windows = [ + _object(item, f"field-review window {index}") for index, item in enumerate(windows) + ] + source = _object(self.report.get("source"), "field-review source") + decision = _object(self.report.get("decision"), "field-review decision") + authority = _object(self.report.get("authority"), "field-review authority") + offsets = self.arrays["window_offsets"] + points = self.arrays["points_xyz_map"] + point_count = _nonnegative_int( + self.identity.get("display_point_count"), + "field-review point count", + ) + required = { + "window_offsets", + "points_xyz_map", + "current_ground", + "current_assigned", + "candidate_ground", + "candidate_assigned", + } + if ( + self.report.get("schema_version") != LIDAR_FIELD_REVIEW_REPORT_SCHEMA + or self.report.get("review_id") != self.root.name + or self.report.get("status") != "diagnostic-only" + or self.report.get("ground_truth") is not False + or source.get("representation") != "legacy-e10-vendor-map-with-pose" + or source.get("intensity_available") is not False + or source.get("raw_scan_accepted") is not False + or decision.get("status") != "visual-review-only" + or decision.get("production_promotion") is not False + or authority.get("commands_enabled") is not False + or authority.get("navigation_or_safety_accepted") is not False + or len(windows) != self.identity.get("window_count") + or set(self.preview_paths) != {str(item.get("key")) for item in parsed_windows} + or set(self.arrays.files) != required + or offsets.shape != (len(windows) + 1,) + or offsets.dtype != np.dtype(" 1): + raise LidarGroundError("LiDAR field-review mask is invalid") + for index, window in enumerate(parsed_windows): + start = int(offsets[index]) + end = int(offsets[index + 1]) + if ( + window.get("index") != index + or _SAFE_KEY.fullmatch(str(window.get("key"))) is None + or window.get("display_point_count") != end - start + or not 0 < end - start <= MAX_FIELD_REVIEW_WINDOW_POINTS + or _nonnegative_int( + window.get("source_lidar_samples"), + "field-review source samples", + ) + < 1 + or _nonnegative_int( + window.get("source_point_count"), + "field-review source points", + ) + < end - start + or not isinstance(window.get("label"), str) + or not window["label"].strip() + ): + raise LidarGroundError("LiDAR field-review window is invalid") + + def window_detail(self, window_index: int) -> dict[str, object]: + windows = _list(self.report["windows"], "field-review windows") + if not 0 <= window_index < len(windows): + raise IndexError(window_index) + window = _object(windows[window_index], "field-review window") + offsets = self.arrays["window_offsets"] + start = int(offsets[window_index]) + end = int(offsets[window_index + 1]) + current_ground = self.arrays["current_ground"][start:end] + candidate_ground = self.arrays["candidate_ground"][start:end] + disagreement = (current_ground != candidate_ground).astype(np.uint8) + return { + "schema_version": LIDAR_FIELD_REVIEW_WINDOW_SCHEMA, + "review_id": self.review_id, + "display_name": self.report["display_name"], + "session_id": self.report["session_id"], + "source_pack_id": self.report["source_pack_id"], + "window_index": window_index, + "window_count": len(windows), + "window": window, + "point_count": end - start, + "coordinate_frame": "map", + "distance_unit": "m", + "intensity": { + "available": False, + "reason": "E10 derivative did not retain rgbi/intensity", + }, + "points_xyz_m": self.arrays["points_xyz_map"][start:end].astype(np.float64).tolist(), + "masks": { + "current_ground": current_ground.astype(np.int64).tolist(), + "current_assigned": self.arrays["current_assigned"][start:end] + .astype(np.int64) + .tolist(), + "candidate_ground": candidate_ground.astype(np.int64).tolist(), + "candidate_assigned": self.arrays["candidate_assigned"][start:end] + .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)), + }, + "preview_url": ( + f"/api/v1/lidar/field-reviews/{self.review_id}/windows/{window_index}/preview" + ), + "access": "read-only", + "ground_truth": False, + "authority": { + "commands_enabled": False, + "navigation_or_safety_accepted": False, + }, + } + + +def build_lidar_field_review( + source: E10LidarFieldSource, + output_root: Path, + *, + patchwork: GroundSegmenter, + profile: GroundBenchmarkProfile, + preview_paths: Mapping[str, Path], + windows: Sequence[FieldReviewWindowSpec] = RAVNOVES00_CENTRAL_WINDOWS, + display_name: str = "RAVNOVES00 · центральный городской интервал", + default_window_index: int = 2, + maximum_display_points: int = MAX_FIELD_REVIEW_WINDOW_POINTS, +) -> Path: + """Build accumulated field windows without upgrading legacy input evidence.""" + + if ( + not windows + or not 0 <= default_window_index < len(windows) + or not 1 <= maximum_display_points <= MAX_FIELD_REVIEW_WINDOW_POINTS + or set(preview_paths) != {window.key for window in windows} + ): + raise LidarGroundError("LiDAR field-review build configuration is invalid") + times = source.arrays["session_seconds"] + available = source.arrays["sample_available"] + source_offsets = source.arrays["cloud_offsets"] + points_map = source.arrays["cloud_points_map"] + positions = source.arrays["pose_positions_map"] + quaternions = source.arrays["pose_quaternions_map_from_lidar"] + source_frame_indices = source.arrays["source_frame_indices"] + current = LocalPercentileGroundSegmenter(profile) + window_arrays: list[dict[str, npt.NDArray[Any]]] = [] + window_reports: list[dict[str, object]] = [] + current_latency: list[float] = [] + candidate_latency: list[float] = [] + current_fraction: list[float] = [] + candidate_fraction: list[float] = [] + disagreement_fraction: list[float] = [] + algorithm_iou: list[float] = [] + selected_source_point_count = 0 + + for window_index, spec in enumerate(windows): + source_rows = np.flatnonzero( + available & (times >= spec.start_seconds) & (times <= spec.end_seconds) + ) + if source_rows.size == 0: + raise LidarGroundError("LiDAR field-review window has no source samples") + collected_points: list[npt.NDArray[np.float32]] = [] + collected_current: list[npt.NDArray[np.uint8]] = [] + collected_current_assigned: list[npt.NDArray[np.uint8]] = [] + collected_candidate: list[npt.NDArray[np.uint8]] = [] + collected_candidate_assigned: list[npt.NDArray[np.uint8]] = [] + source_point_count = 0 + for source_row in source_rows: + start = int(source_offsets[source_row]) + end = int(source_offsets[source_row + 1]) + cloud = np.asarray(points_map[start:end], dtype=np.float32) + source_point_count += cloud.shape[0] + current_input = np.zeros((cloud.shape[0], 4), dtype=np.float32) + current_input[:, :3] = cloud + current_result = current.segment(current_input) + try: + position = positions[source_row] + orientation = quaternions[source_row] + points_sensor = map_points_to_lidar( + cloud, + position_map_xyz=( + float(position[0]), + float(position[1]), + float(position[2]), + ), + orientation_map_from_lidar_xyzw=( + float(orientation[0]), + float(orientation[1]), + float(orientation[2]), + float(orientation[3]), + ), + ) + except CalibratedProjectionError as exc: + raise LidarGroundError("LiDAR field-review pose conversion failed") from exc + candidate_input = np.zeros((cloud.shape[0], 4), dtype=np.float32) + candidate_input[:, :3] = points_sensor.astype(np.float32) + if profile.patchwork_map_vertical_origin_offset_m: + candidate_input[:, 2] -= profile.patchwork_map_vertical_origin_offset_m + candidate_result = patchwork.segment(candidate_input) + _segmentation( + current_result.ground_mask, + current_result.assigned_mask, + cloud.shape[0], + "current", + ) + _segmentation( + candidate_result.ground_mask, + candidate_result.assigned_mask, + cloud.shape[0], + "candidate", + ) + collected_points.append(cloud) + collected_current.append(current_result.ground_mask.astype(np.uint8)) + collected_current_assigned.append(current_result.assigned_mask.astype(np.uint8)) + collected_candidate.append(candidate_result.ground_mask.astype(np.uint8)) + collected_candidate_assigned.append(candidate_result.assigned_mask.astype(np.uint8)) + current_latency.append(current_result.latency_ms) + candidate_latency.append(candidate_result.latency_ms) + current_fraction.append(float(np.mean(current_result.ground_mask))) + candidate_fraction.append(float(np.mean(candidate_result.ground_mask))) + disagreement_fraction.append( + float(np.mean(current_result.ground_mask != candidate_result.ground_mask)) + ) + intersection = int( + np.count_nonzero(current_result.ground_mask & candidate_result.ground_mask) + ) + union = int(np.count_nonzero(current_result.ground_mask | candidate_result.ground_mask)) + algorithm_iou.append(float(intersection / union) if union else 1.0) + + combined_points = np.concatenate(collected_points) + combined_current = np.concatenate(collected_current) + combined_current_assigned = np.concatenate(collected_current_assigned) + combined_candidate = np.concatenate(collected_candidate) + combined_candidate_assigned = np.concatenate(collected_candidate_assigned) + selected_source_point_count += source_point_count + selected = _uniform_indices(combined_points.shape[0], maximum_display_points) + displayed = { + "points_xyz_map": combined_points[selected].astype(" dict[str, object]: + report = review.report + metrics = _object(report["metrics"], "field-review metrics") + return { + "review_id": review.review_id, + "display_name": report["display_name"], + "session_id": report["session_id"], + "source_pack_id": report["source_pack_id"], + "status": report["status"], + "source": report["source"], + "selection": report["selection"], + "windows": report["windows"], + "metrics": metrics, + "decision": report["decision"], + "created_at_utc": review.manifest.get("created_at_utc"), + "ground_truth": False, + "authority": report["authority"], + } + + +def _uniform_indices(count: int, maximum: int) -> npt.NDArray[np.int64]: + if count <= maximum: + return np.arange(count, dtype=np.int64) + return np.linspace(0, count - 1, maximum, dtype=np.int64) + + +def _segmentation( + ground_mask: npt.NDArray[np.bool_], + assigned_mask: npt.NDArray[np.bool_], + point_count: int, + label: str, +) -> None: + if ( + ground_mask.shape != (point_count,) + or ground_mask.dtype != np.dtype("?") + or assigned_mask.shape != (point_count,) + or assigned_mask.dtype != np.dtype("?") + or np.any(ground_mask & ~assigned_mask) + ): + raise LidarGroundError(f"LiDAR field-review {label} mask is invalid") + + +def _distribution(values: Sequence[float]) -> dict[str, float | int]: + array = np.asarray(values, dtype=np.float64) + if array.size == 0 or not np.isfinite(array).all(): + raise LidarGroundError("LiDAR field-review distribution is invalid") + return { + "sample_count": int(array.shape[0]), + "minimum": float(np.min(array)), + "mean": float(np.mean(array)), + "p50": float(np.percentile(array, 50)), + "p95": float(np.percentile(array, 95)), + "maximum": float(np.max(array)), + } + + +def _logical_sha256(arrays: Mapping[str, npt.NDArray[Any]]) -> str: + digest = hashlib.sha256() + for name in sorted(arrays): + array = np.ascontiguousarray(arrays[name]) + digest.update(name.encode()) + digest.update(array.dtype.str.encode()) + digest.update(_canonical_json(list(array.shape))) + digest.update(memoryview(array).cast("B")) + return digest.hexdigest() + + +def _validate_artifacts(root: Path, value: object) -> dict[str, Path]: + artifacts = _list(value, "field-review artifacts") + resolved: dict[str, Path] = {} + for value in artifacts: + item = _object(value, "field-review artifact") + role = item.get("role") + relative = item.get("path") + if ( + not isinstance(role, str) + or role in resolved + or not isinstance(relative, str) + or Path(relative).name != relative + or not isinstance(item.get("byte_length"), int) + or isinstance(item.get("byte_length"), bool) + or item["byte_length"] < 1 + or _SHA256.fullmatch(str(item.get("sha256"))) is None + ): + raise LidarGroundError("LiDAR field-review artifact descriptor is invalid") + path = root / relative + if ( + path.is_symlink() + or not path.is_file() + or path.stat().st_size != item["byte_length"] + or _sha256(path) != item["sha256"] + ): + raise LidarGroundError("LiDAR field-review artifact is invalid") + resolved[role] = path + if "field-review" not in resolved or "field-review-report" not in resolved: + raise LidarGroundError("LiDAR field-review artifacts are incomplete") + return resolved + + +def _artifact(role: str, path: Path, media_type: str) -> dict[str, object]: + return { + "role": role, + "path": path.name, + "media_type": media_type, + "byte_length": path.stat().st_size, + "sha256": _sha256(path), + } + + +def _read_json(path: Path) -> dict[str, Any]: + if path.is_symlink() or not path.is_file() or path.stat().st_size > 4_000_000: + raise LidarGroundError("LiDAR field-review JSON artifact is invalid") + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise LidarGroundError("LiDAR field-review JSON artifact is unreadable") from exc + return _object(value, "LiDAR field-review JSON") + + +def _write_json(path: Path, value: Mapping[str, object]) -> None: + path.write_bytes( + json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + indent=2, + allow_nan=False, + ).encode() + + b"\n" + ) + + +def _canonical_json(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode() + + +def _object(value: object, label: str) -> dict[str, Any]: + if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): + raise LidarGroundError(f"{label} must be an object") + return value + + +def _list(value: object, label: str) -> list[Any]: + if not isinstance(value, list): + raise LidarGroundError(f"{label} must be a list") + return value + + +def _nonnegative_int(value: object, label: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise LidarGroundError(f"{label} must be a non-negative integer") + return value + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def _utc_now() -> str: + return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z") diff --git a/src/k1link/web/lidar_api.py b/src/k1link/web/lidar_api.py index 3d5b923..7d91522 100644 --- a/src/k1link/web/lidar_api.py +++ b/src/k1link/web/lidar_api.py @@ -6,13 +6,15 @@ from collections.abc import Callable from pathlib import Path from typing import Any, Final -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, HTTPException, Query, Response from k1link.compute import ( + LidarFieldReviewV1, LidarGroundBenchmarkV1, LidarGroundError, LidarReplayError, LidarReplayPackV2, + lidar_field_review_catalog_item, lidar_ground_benchmark_catalog_item, lidar_ground_frame_detail, lidar_pack_catalog_item, @@ -21,8 +23,10 @@ from k1link.compute import ( LIDAR_CATALOG_SCHEMA: Final = "missioncore.lidar-replay-pack-catalog/v1" LIDAR_GROUND_CATALOG_SCHEMA: Final = "missioncore.lidar-ground-benchmark-catalog/v1" +LIDAR_FIELD_REVIEW_CATALOG_SCHEMA: Final = "missioncore.lidar-field-review-catalog/v1" _PACK_ID = re.compile(r"^lidar-replay-pack-[a-f0-9]{64}$") _BENCHMARK_ID = re.compile(r"^ground-benchmark-[a-f0-9]{64}$") +_FIELD_REVIEW_ID = re.compile(r"^lidar-field-review-[a-f0-9]{64}$") RootProvider = Callable[[], Path | None] @@ -36,10 +40,16 @@ def configured_lidar_ground_root() -> Path | None: return Path(value).expanduser().absolute() if value else None +def configured_lidar_field_review_root() -> Path | None: + value = os.environ.get("MISSIONCORE_LIDAR_FIELD_REVIEW_ROOT", "").strip() + return Path(value).expanduser().absolute() if value else None + + def build_lidar_router( *, root_provider: RootProvider = configured_lidar_replay_root, ground_root_provider: RootProvider = configured_lidar_ground_root, + field_review_root_provider: RootProvider = configured_lidar_field_review_root, ) -> APIRouter: router = APIRouter(prefix="/api/v1/lidar", tags=["lidar"]) @@ -269,4 +279,139 @@ def build_lidar_router( detail="LiDAR ground frame не прошёл проверку целостности", ) from exc + @router.get("/field-reviews") + def list_lidar_field_reviews( + limit: int = Query(default=10, ge=1, le=50), + ) -> dict[str, Any]: + root = field_review_root_provider() + if root is None or not root.is_dir(): + return { + "schema_version": LIDAR_FIELD_REVIEW_CATALOG_SCHEMA, + "configured": root is not None, + "items": [], + "valid_total": 0, + "invalid_total": 0, + "access": "read-only", + } + items: list[dict[str, object]] = [] + invalid_total = 0 + candidates = sorted( + ( + candidate + for candidate in root.iterdir() + if candidate.is_dir() and _FIELD_REVIEW_ID.fullmatch(candidate.name) is not None + ), + key=lambda candidate: candidate.stat().st_mtime_ns, + reverse=True, + ) + for candidate in candidates: + try: + review = LidarFieldReviewV1(candidate) + try: + items.append(lidar_field_review_catalog_item(review)) + finally: + review.close() + except (LidarGroundError, OSError): + invalid_total += 1 + return { + "schema_version": LIDAR_FIELD_REVIEW_CATALOG_SCHEMA, + "configured": True, + "items": items[:limit], + "valid_total": len(items), + "invalid_total": invalid_total, + "access": "read-only", + } + + @router.get("/field-reviews/{review_id}/windows/{window_index}") + def get_lidar_field_review_window( + review_id: str, + window_index: int, + ) -> dict[str, object]: + if _FIELD_REVIEW_ID.fullmatch(review_id) is None or window_index < 0: + raise HTTPException( + status_code=404, + detail="LiDAR field-review window не найден", + ) + root = field_review_root_provider() + if root is None or not root.is_dir(): + raise HTTPException( + status_code=503, + detail="LiDAR field-review storage не настроен", + ) + candidate = root / review_id + if not candidate.is_dir(): + raise HTTPException( + status_code=404, + detail="LiDAR field review не найден", + ) + try: + review = LidarFieldReviewV1(candidate) + try: + return review.window_detail(window_index) + finally: + review.close() + except IndexError as exc: + raise HTTPException( + status_code=404, + detail="LiDAR field-review window не найден", + ) from exc + except (LidarGroundError, OSError) as exc: + raise HTTPException( + status_code=409, + detail="LiDAR field review не прошёл проверку целостности", + ) from exc + + @router.get("/field-reviews/{review_id}/windows/{window_index}/preview") + def get_lidar_field_review_preview( + review_id: str, + window_index: int, + ) -> Response: + if _FIELD_REVIEW_ID.fullmatch(review_id) is None or window_index < 0: + raise HTTPException( + status_code=404, + detail="LiDAR field-review preview не найден", + ) + root = field_review_root_provider() + if root is None or not root.is_dir(): + raise HTTPException( + status_code=503, + detail="LiDAR field-review storage не настроен", + ) + candidate = root / review_id + if not candidate.is_dir(): + raise HTTPException( + status_code=404, + detail="LiDAR field review не найден", + ) + try: + review = LidarFieldReviewV1(candidate) + try: + windows = review.report.get("windows") + if not isinstance(windows, list) or not 0 <= window_index < len(windows): + raise IndexError(window_index) + window = windows[window_index] + if not isinstance(window, dict) or not isinstance(window.get("key"), str): + raise LidarGroundError("LiDAR field-review preview key is invalid") + preview = review.preview_paths.get(window["key"]) + if preview is None: + raise LidarGroundError("LiDAR field-review preview is missing") + content = preview.read_bytes() + finally: + review.close() + except IndexError as exc: + raise HTTPException( + status_code=404, + detail="LiDAR field-review preview не найден", + ) from exc + except (LidarGroundError, OSError) as exc: + raise HTTPException( + status_code=409, + detail="LiDAR field-review preview не прошёл проверку", + ) from exc + return Response( + content=content, + media_type="image/jpeg", + headers={"Cache-Control": "private, max-age=31536000, immutable"}, + ) + return router diff --git a/tests/test_lidar_field_review.py b/tests/test_lidar_field_review.py new file mode 100644 index 0000000..fc710e6 --- /dev/null +++ b/tests/test_lidar_field_review.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import numpy as np +import pytest +from fastapi import APIRouter +from fastapi.routing import APIRoute + +from k1link.compute import ( + E10_LIDAR_PACK_SCHEMA, + E10LidarFieldSource, + FieldReviewWindowSpec, + GroundBenchmarkProfile, + GroundSegmentation, + LidarFieldReviewV1, + LidarGroundError, + build_lidar_field_review, +) +from k1link.web.lidar_api import build_lidar_router + + +class _Candidate: + @property + def identity(self) -> dict[str, object]: + return { + "provider_id": "test-patchwork/v1", + "source_commit": "c" * 40, + } + + def segment(self, xyzi: np.ndarray) -> GroundSegmentation: + ground = xyzi[:, 0] <= 0.25 + assigned = np.ones(xyzi.shape[0], dtype=np.bool_) + return GroundSegmentation(ground, assigned, 0.25) + + +def _canonical_json(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode() + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _source_pack(root: Path) -> Path: + base_points = np.asarray( + [ + [-1.0, -1.0, 0.00], + [-0.5, -1.0, 0.02], + [0.0, -1.0, 0.01], + [0.5, -1.0, 0.03], + [1.0, -1.0, 0.00], + [-1.0, 0.0, 0.01], + [-0.5, 0.0, 0.02], + [0.0, 0.0, 0.04], + [0.5, 0.0, 0.35], + [1.0, 0.0, 0.70], + ], + dtype=np.float32, + ) + points = np.concatenate( + [base_points + np.asarray([index * 0.1, index, 0], dtype=np.float32) for index in range(4)] + ).astype(" object: + for route in router.routes: + if ( + isinstance(route, APIRoute) + and route.path == path + and route.methods is not None + and "GET" in route.methods + ): + return route.endpoint + raise AssertionError(f"GET {path} route is missing") + + +def test_field_review_is_accumulated_path_free_visual_evidence( + tmp_path: Path, +) -> None: + source = E10LidarFieldSource(_source_pack(tmp_path / "source")) + windows = ( + FieldReviewWindowSpec("street-a", "Улица A", 0.5, 2.1, 100), + FieldReviewWindowSpec("street-b", "Улица B", 2.5, 4.1, 120), + ) + previews = {} + for window in windows: + preview = tmp_path / f"{window.key}.jpg" + preview.write_bytes(b"\xff\xd8synthetic-jpeg\xff\xd9") + previews[window.key] = preview + profile = GroundBenchmarkProfile( + profile_id="synthetic-ravnoves00-field-review/v1", + patchwork_sensor_height_proxy_m=1.27, + patchwork_map_vertical_origin_offset_m=1.27, + patchwork_height_evidence="operator-estimated", + ) + try: + output = build_lidar_field_review( + source, + tmp_path / "reviews", + patchwork=_Candidate(), + profile=profile, + preview_paths=previews, + windows=windows, + default_window_index=1, + maximum_display_points=12, + ) + finally: + source.close() + + review = LidarFieldReviewV1(output) + try: + detail = review.window_detail(1) + assert review.report["status"] == "diagnostic-only" + assert review.report["source"]["intensity_available"] is False + assert review.report["selection"]["default_window_index"] == 1 + assert len(review.report["windows"]) == 2 + assert review.report["windows"][0]["source_lidar_samples"] == 2 + assert detail["point_count"] == 12 + intensity = detail["intensity"] + authority = detail["authority"] + assert isinstance(intensity, dict) + assert isinstance(authority, dict) + assert intensity["available"] is False + assert detail["ground_truth"] is False + assert authority["commands_enabled"] is False + assert str(tmp_path) not in repr(detail) + finally: + review.close() + + router = build_lidar_router( + root_provider=lambda: None, + ground_root_provider=lambda: None, + field_review_root_provider=lambda: output.parent, + ) + catalog_route = _endpoint(router, "/api/v1/lidar/field-reviews") + window_route = _endpoint( + router, + "/api/v1/lidar/field-reviews/{review_id}/windows/{window_index}", + ) + preview_route = _endpoint( + router, + "/api/v1/lidar/field-reviews/{review_id}/windows/{window_index}/preview", + ) + catalog = catalog_route(limit=10) # type: ignore[operator] + window = window_route(review_id=output.name, window_index=0) # type: ignore[operator] + preview = preview_route(review_id=output.name, window_index=0) # type: ignore[operator] + + assert catalog["valid_total"] == 1 + assert catalog["items"][0]["display_name"] + assert window["preview_url"].endswith("/windows/0/preview") + assert preview.media_type == "image/jpeg" + assert preview.body.startswith(b"\xff\xd8") + assert str(tmp_path) not in repr({"catalog": catalog, "window": window}) + + +def test_field_review_reader_rejects_tampered_identity(tmp_path: Path) -> None: + source = E10LidarFieldSource(_source_pack(tmp_path / "source")) + window = FieldReviewWindowSpec("street-a", "Улица A", 0.5, 4.1, 100) + preview = tmp_path / "street-a.jpg" + preview.write_bytes(b"\xff\xd8synthetic-jpeg\xff\xd9") + try: + output = build_lidar_field_review( + source, + tmp_path / "reviews", + patchwork=_Candidate(), + profile=GroundBenchmarkProfile(), + preview_paths={window.key: preview}, + windows=(window,), + default_window_index=0, + maximum_display_points=12, + ) + finally: + source.close() + + manifest_path = output / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["identity"]["display_name"] = "tampered" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + with pytest.raises(LidarGroundError, match="identity"): + LidarFieldReviewV1(output)