diff --git a/apps/control-station/src/core/laboratory/advancedIndex.ts b/apps/control-station/src/core/laboratory/advancedIndex.ts index 14f2de6..51cf2fe 100644 --- a/apps/control-station/src/core/laboratory/advancedIndex.ts +++ b/apps/control-station/src/core/laboratory/advancedIndex.ts @@ -13,8 +13,10 @@ import { import { fetchE34TemporalLayerResult } from "./e34TemporalLayer"; import { fetchE35DegradationRecoveryResult } from "./e35DegradationRecovery"; import { fetchE40ProductGateResult } from "./e40ProductGate"; +import { fetchL3PointPillarsVisualAudit } from "./l3PointPillarsVisualAudit"; export type AdvancedLaboratoryWorkId = + | "l3-pointpillars-visual-audit" | "e31-source-binding" | "e32-track-geometry" | "e33-worker-shadow" @@ -32,6 +34,7 @@ export interface AdvancedLaboratoryIndexItem { } const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [ + "l3-pointpillars-visual-audit", "e31-source-binding", "e32-track-geometry", "e33-worker-shadow", @@ -44,6 +47,7 @@ const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [ ]; const RESULT_PREFIX: Readonly> = { + "l3-pointpillars-visual-audit": "l3-pointpillars-visual-audit", "e31-source-binding": "e31-source-qualification", "e32-track-geometry": "e32-track-geometry", "e33-worker-shadow": "e33-worker-shadow", @@ -63,6 +67,7 @@ export function isAdvancedLaboratoryWorkId( export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults { return { + l3: null, e31: null, e32: null, e33: null, @@ -163,7 +168,8 @@ export function advancedLaboratoryResultAvailable( workId: AdvancedLaboratoryWorkId, results: AdvancedLaboratoryResults, ): boolean { - return workId === "e31-source-binding" ? results.e31 !== null + return workId === "l3-pointpillars-visual-audit" ? results.l3 !== null + : workId === "e31-source-binding" ? results.e31 !== null : workId === "e32-track-geometry" ? results.e32 !== null : workId === "e33-worker-shadow" ? results.e33 !== null : workId === "e34-temporal-layer" ? results.e34 !== null @@ -185,7 +191,9 @@ export async function fetchAdvancedLaboratoryResult( } = {}, ): Promise { const results = emptyAdvancedLaboratoryResults(); - if (workId === "e31-source-binding") { + if (workId === "l3-pointpillars-visual-audit") { + results.l3 = await fetchL3PointPillarsVisualAudit({ fetcher, signal }); + } else if (workId === "e31-source-binding") { results.e31 = await fetchOne( "/api/v1/laboratory/e31/results?limit=1", parseE31, diff --git a/apps/control-station/src/core/laboratory/advancedResults.ts b/apps/control-station/src/core/laboratory/advancedResults.ts index 84d60b7..785a87e 100644 --- a/apps/control-station/src/core/laboratory/advancedResults.ts +++ b/apps/control-station/src/core/laboratory/advancedResults.ts @@ -11,6 +11,7 @@ import { type E40PerceptionProductGateResult, } from "./e40ProductGate"; import { settledCatalogValue } from "./catalogTransport"; +import type { L3PointPillarsVisualAuditResult } from "./l3PointPillarsVisualAudit"; export interface E31LaboratoryResult { resultId: string; @@ -238,6 +239,7 @@ export interface E39PerceptionRefinementResult { } export interface AdvancedLaboratoryResults { + l3: L3PointPillarsVisualAuditResult | null; e31: E31LaboratoryResult | null; e32: E32LaboratoryResult | null; e33: E33LaboratoryResult | null; @@ -988,5 +990,5 @@ export async function fetchAdvancedLaboratoryResults({ const e38 = settledCatalogValue(settled[6]); const e39 = settledCatalogValue(settled[7]); const e40 = settledCatalogValue(settled[8]); - return { e31, e32, e33, e34, e35, e37, e38, e39, e40 }; + return { l3: null, e31, e32, e33, e34, e35, e37, e38, e39, e40 }; } diff --git a/apps/control-station/src/core/laboratory/l3PointPillarsVisualAudit.ts b/apps/control-station/src/core/laboratory/l3PointPillarsVisualAudit.ts new file mode 100644 index 0000000..3aaaea1 --- /dev/null +++ b/apps/control-station/src/core/laboratory/l3PointPillarsVisualAudit.ts @@ -0,0 +1,384 @@ +import { + AdvancedLaboratoryContractError, + type LaboratoryFetch, +} from "./advancedResults"; + +export interface L3VisualFrameSummary { + frameId: string; + inferenceMs: number; + predictionCount: number; + evaluatedPredictionCount: number; + outsideSharedRangeCount: number; + truthCount: number; + truePositiveCount: number; + falsePositiveCount: number; + falseNegativeCount: number; + truthClasses: readonly string[]; +} + +export interface L3VisualBox { + benchmarkClass: string; + centerXyzM: readonly [number, number, number]; + sizeLwhM: readonly [number, number, number]; + yawRad: number; + status: "matched" | "false-negative" | "true-positive" | "false-positive"; + score: number | null; +} + +export interface L3VisualFrame { + frameId: string; + summary: L3VisualFrameSummary; + sourcePointCount: number; + sharedRangePointCount: number; + sampledPointCount: number; + pointsXyzi: readonly number[]; + truthBoxes: readonly L3VisualBox[]; + predictionBoxes: readonly L3VisualBox[]; +} + +export interface L3PointPillarsVisualAuditResult { + resultId: string; + createdAtUtc: string; + status: "operator-visual-review-required"; + sourceRunId: string; + sourceFrameResultsIdentitySha256: string; + datasetSourceId: string; + datasetReleaseIdentitySha256: string; + metrics: { + frameCount: number; + bevMap40: number; + threeDMap40: number; + falseOccupiedRate: number; + inferenceP95Ms: number; + modelOutputBoxCount: number; + evaluatedBoxCount: number; + outsideSharedRangeCount: number; + }; + frames: readonly L3VisualFrameSummary[]; +} + +const RESULT_ID = /^l3-pointpillars-visual-audit-[a-f0-9]{64}$/; +const RUN_ID = /^l3-pointpillars-kitti-[a-f0-9]{64}$/; +const SHA256 = /^[a-f0-9]{64}$/; +const FRAME_ID = /^[0-9]{6}$/; + +function objectValue( + value: unknown, + label: string, +): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`); + } + return value as Record; +} + +function arrayValue(value: unknown, label: string): readonly unknown[] { + if (!Array.isArray(value)) { + throw new AdvancedLaboratoryContractError(`${label}: ожидался массив.`); + } + return value; +} + +function exact(value: unknown, expected: string, label: string): string { + if (value !== expected) { + throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`); + } + return expected; +} + +function stringValue(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new AdvancedLaboratoryContractError(`${label}: ожидалась строка.`); + } + return value; +} + +function numberValue(value: unknown, label: string, minimum = 0): number { + if ( + typeof value !== "number" + || !Number.isFinite(value) + || value < minimum + ) { + throw new AdvancedLaboratoryContractError(`${label}: неверное число.`); + } + return value; +} + +function integerValue(value: unknown, label: string): number { + const parsed = numberValue(value, label); + if (!Number.isInteger(parsed)) { + throw new AdvancedLaboratoryContractError(`${label}: ожидалось целое.`); + } + return parsed; +} + +function tuple3( + value: unknown, + label: string, + positive = false, +): readonly [number, number, number] { + const values = arrayValue(value, label); + if (values.length !== 3) { + throw new AdvancedLaboratoryContractError(`${label}: ожидалось 3 числа.`); + } + return [ + numberValue(values[0], `${label}[0]`, positive ? Number.MIN_VALUE : -Infinity), + numberValue(values[1], `${label}[1]`, positive ? Number.MIN_VALUE : -Infinity), + numberValue(values[2], `${label}[2]`, positive ? Number.MIN_VALUE : -Infinity), + ]; +} + +function parseSummary(value: unknown): L3VisualFrameSummary { + const item = objectValue(value, "L3 visual frame"); + const frameId = stringValue(item.frame_id, "L3 frame_id"); + if (!FRAME_ID.test(frameId)) { + throw new AdvancedLaboratoryContractError("L3 frame_id: неверный формат."); + } + const truthClasses = arrayValue(item.truth_classes, "L3 truth_classes") + .map((entry) => stringValue(entry, "L3 truth class")); + if ( + truthClasses.some( + (entry) => !["Car", "Pedestrian", "Cyclist"].includes(entry), + ) + ) { + throw new AdvancedLaboratoryContractError("L3 truth class: неизвестен."); + } + return { + frameId, + inferenceMs: numberValue(item.inference_ms, "L3 inference_ms", Number.MIN_VALUE), + predictionCount: integerValue(item.prediction_count, "L3 prediction_count"), + evaluatedPredictionCount: integerValue( + item.evaluated_prediction_count, + "L3 evaluated_prediction_count", + ), + outsideSharedRangeCount: integerValue( + item.outside_shared_range_count, + "L3 outside_shared_range_count", + ), + truthCount: integerValue(item.truth_count, "L3 truth_count"), + truePositiveCount: integerValue( + item.true_positive_count, + "L3 true_positive_count", + ), + falsePositiveCount: integerValue( + item.false_positive_count, + "L3 false_positive_count", + ), + falseNegativeCount: integerValue( + item.false_negative_count, + "L3 false_negative_count", + ), + truthClasses, + }; +} + +function parseBox(value: unknown, truth: boolean): L3VisualBox { + const box = objectValue(value, "L3 visual box"); + const benchmarkClass = stringValue( + box.benchmark_class, + "L3 benchmark_class", + ); + if (!["Car", "Pedestrian", "Cyclist"].includes(benchmarkClass)) { + throw new AdvancedLaboratoryContractError("L3 box class: неизвестен."); + } + const status = stringValue(box.status, "L3 box status"); + const allowed = truth + ? ["matched", "false-negative"] + : ["true-positive", "false-positive"]; + if (!allowed.includes(status)) { + throw new AdvancedLaboratoryContractError("L3 box status: неизвестен."); + } + return { + benchmarkClass, + centerXyzM: tuple3(box.center_xyz_m, "L3 center_xyz_m"), + sizeLwhM: tuple3(box.size_lwh_m, "L3 size_lwh_m", true), + yawRad: numberValue(box.yaw_rad, "L3 yaw_rad", -Infinity), + status: status as L3VisualBox["status"], + score: truth ? null : numberValue(box.score, "L3 score"), + }; +} + +function parseResult(value: unknown): L3PointPillarsVisualAuditResult { + const result = objectValue(value, "L3 visual result"); + exact( + result.schema_version, + "missioncore.l3-pointpillars-visual-audit-result/v1", + "L3 result.schema_version", + ); + exact(result.access, "read-only", "L3 result.access"); + const resultId = stringValue(result.result_id, "L3 result_id"); + const sourceRunId = stringValue(result.source_run_id, "L3 source_run_id"); + const frameIdentity = stringValue( + result.source_frame_results_identity_sha256, + "L3 frame identity", + ); + const datasetIdentity = stringValue( + result.dataset_release_identity_sha256, + "L3 dataset identity", + ); + if ( + !RESULT_ID.test(resultId) + || !RUN_ID.test(sourceRunId) + || !SHA256.test(frameIdentity) + || !SHA256.test(datasetIdentity) + ) { + throw new AdvancedLaboratoryContractError( + "L3 result: нарушена идентичность.", + ); + } + const metrics = objectValue(result.metrics, "L3 metrics"); + const aggregates = objectValue(metrics.aggregates, "L3 aggregates"); + const volume = objectValue( + aggregates.prediction_volume, + "L3 prediction_volume", + ); + const latency = objectValue( + aggregates.inference_latency_ms, + "L3 latency", + ); + const frames = arrayValue(result.frames, "L3 frames").map(parseSummary); + if ( + !frames.length + || frames.length > 24 + || new Set(frames.map(({ frameId }) => frameId)).size !== frames.length + ) { + throw new AdvancedLaboratoryContractError( + "L3 frames: нарушен ограниченный каталог.", + ); + } + return { + resultId, + createdAtUtc: stringValue(result.created_at_utc, "L3 created_at_utc"), + status: exact( + result.status, + "operator-visual-review-required", + "L3 status", + ) as "operator-visual-review-required", + sourceRunId, + sourceFrameResultsIdentitySha256: frameIdentity, + datasetSourceId: stringValue(result.dataset_source_id, "L3 dataset_source_id"), + datasetReleaseIdentitySha256: datasetIdentity, + metrics: { + frameCount: integerValue(metrics.frame_count, "L3 frame_count"), + bevMap40: numberValue(aggregates.bev_map40, "L3 bev_map40"), + threeDMap40: numberValue(aggregates["3d_map40"], "L3 3d_map40"), + falseOccupiedRate: numberValue( + aggregates.false_occupied_rate, + "L3 false_occupied_rate", + ), + inferenceP95Ms: numberValue(latency.p95, "L3 latency.p95"), + modelOutputBoxCount: integerValue( + volume.model_output_box_count, + "L3 model_output_box_count", + ), + evaluatedBoxCount: integerValue( + volume.evaluated_box_count, + "L3 evaluated_box_count", + ), + outsideSharedRangeCount: integerValue( + volume.outside_shared_range_count, + "L3 outside_shared_range_count", + ), + }, + frames, + }; +} + +export async function fetchL3PointPillarsVisualAudit({ + fetcher = fetch, + signal, +}: { + fetcher?: LaboratoryFetch; + signal?: AbortSignal; +} = {}): Promise { + const response = await fetcher( + "/api/v1/laboratory/l3/pointpillars-visual-audits/results?limit=1", + { method: "GET", headers: { Accept: "application/json" }, signal }, + ); + if (!response.ok) { + throw new AdvancedLaboratoryContractError( + `L3 visual audit недоступен: HTTP ${response.status}.`, + ); + } + const catalog = objectValue(await response.json(), "L3 result catalog"); + exact( + catalog.schema_version, + "missioncore.l3-pointpillars-visual-audit-catalog-results/v1", + "L3 catalog.schema_version", + ); + exact(catalog.access, "read-only", "L3 catalog.access"); + const items = arrayValue(catalog.items, "L3 catalog.items"); + if (items.length > 1) { + throw new AdvancedLaboratoryContractError("L3 catalog: лишние результаты."); + } + return items.length ? parseResult(items[0]) : null; +} + +export async function fetchL3PointPillarsVisualFrame( + resultId: string, + frameId: string, + { + fetcher = fetch, + signal, + }: { + fetcher?: LaboratoryFetch; + signal?: AbortSignal; + } = {}, +): Promise { + if (!RESULT_ID.test(resultId) || !FRAME_ID.test(frameId)) { + throw new AdvancedLaboratoryContractError( + "L3 visual frame: неверная идентичность.", + ); + } + const response = await fetcher( + `/api/v1/laboratory/l3/pointpillars-visual-audits/${resultId}/frames/${frameId}`, + { method: "GET", headers: { Accept: "application/json" }, signal }, + ); + if (!response.ok) { + throw new AdvancedLaboratoryContractError( + `L3 visual frame недоступен: HTTP ${response.status}.`, + ); + } + const payload = objectValue(await response.json(), "L3 visual frame"); + exact( + payload.schema_version, + "missioncore.l3-pointpillars-visual-frame/v1", + "L3 frame.schema_version", + ); + exact(payload.access, "read-only", "L3 frame.access"); + exact(payload.frame_id, frameId, "L3 frame.frame_id"); + const points = objectValue(payload.points, "L3 points"); + exact(points.layout, "flat-xyzi", "L3 points.layout"); + const pointValues = arrayValue(points.values, "L3 points.values").map( + (value, index) => numberValue(value, `L3 points[${index}]`, -Infinity), + ); + const sampledPointCount = integerValue( + points.sampled_point_count, + "L3 sampled_point_count", + ); + if (sampledPointCount > 12_000 || pointValues.length !== sampledPointCount * 4) { + throw new AdvancedLaboratoryContractError( + "L3 points: нарушен ограниченный массив.", + ); + } + return { + frameId, + summary: parseSummary(payload.summary), + sourcePointCount: integerValue( + points.source_point_count, + "L3 source_point_count", + ), + sharedRangePointCount: integerValue( + points.shared_range_point_count, + "L3 shared_range_point_count", + ), + sampledPointCount, + pointsXyzi: pointValues, + truthBoxes: arrayValue(payload.truth_boxes, "L3 truth_boxes") + .map((box) => parseBox(box, true)), + predictionBoxes: arrayValue( + payload.prediction_boxes, + "L3 prediction_boxes", + ).map((box) => parseBox(box, false)), + }; +} diff --git a/apps/control-station/src/styles.css b/apps/control-station/src/styles.css index 829da76..5371364 100644 --- a/apps/control-station/src/styles.css +++ b/apps/control-station/src/styles.css @@ -4,6 +4,7 @@ @import "./styles/workspaces.css"; @import "./styles/laboratory.css"; @import "./styles/e40-case-review.css"; +@import "./styles/l3-pointpillars-visual-audit.css"; @import "./styles/laboratory-reporting.css"; @import "./styles/e34-temporal-layer.css"; @import "./styles/e35-degradation-recovery.css"; diff --git a/apps/control-station/src/styles/l3-pointpillars-visual-audit.css b/apps/control-station/src/styles/l3-pointpillars-visual-audit.css new file mode 100644 index 0000000..0ebdf42 --- /dev/null +++ b/apps/control-station/src/styles/l3-pointpillars-visual-audit.css @@ -0,0 +1,142 @@ +.l3-visual-audit { + display: grid; + height: clamp(36rem, 68vh, 54rem); + min-height: 36rem; +} + +.l3-visual-audit > .laboratory-evidence-viewer { + min-height: 0; +} + +.l3-visual-audit__scene { + position: relative; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; + background: var(--nodedc-canvas); +} + +.l3-visual-audit__scene canvas { + display: block; + width: 100%; + height: 100%; +} + +.l3-visual-audit__state { + display: flex; + width: 100%; + height: 100%; + min-height: 22rem; + align-items: center; + justify-content: center; + gap: 0.55rem; + background: var(--nodedc-canvas); + color: var(--nodedc-text-muted); + font-size: 0.62rem; +} + +.l3-visual-audit__actions { + display: flex; + flex: 1; + min-width: 0; + align-items: center; + gap: 0.45rem; +} + +.l3-visual-audit__pagination { + display: flex; + flex: none; + gap: 0.35rem; +} + +.l3-visual-audit__actions .nodedc-select-anchor, +.l3-visual-audit__actions .nodedc-select { + width: clamp(17rem, 34vw, 31rem); +} + +.l3-visual-audit + .laboratory-evidence-viewer__controls:has(.l3-visual-audit__actions) { + right: 0.6rem; + left: 0.6rem; +} + +.l3-visual-audit__overlay { + position: absolute; + z-index: 3; + left: 0.6rem; + bottom: 0.6rem; + display: grid; + width: min(58rem, calc(100% - 1.2rem)); + grid-template-columns: minmax(8rem, 0.55fr) minmax(13rem, 1fr) minmax(19rem, 1.2fr); + align-items: end; + gap: 0.7rem; + border-radius: var(--nodedc-radius-control-compact); + background: var(--nodedc-floating-surface); + padding: 0.55rem 0.65rem; + color: var(--nodedc-text-secondary); + backdrop-filter: blur(var(--nodedc-blur-control)); + pointer-events: none; +} + +.l3-visual-audit__overlay > div { + display: grid; + min-width: 0; + gap: 0.12rem; +} + +.l3-visual-audit__overlay span, +.l3-visual-audit__overlay small { + color: var(--nodedc-text-muted); + font-size: 0.49rem; + line-height: 1.35; +} + +.l3-visual-audit__overlay strong { + color: var(--nodedc-text-primary); + font-size: 0.6rem; +} + +.l3-visual-audit__legend { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.35rem 0.6rem; +} + +.l3-visual-audit__legend span { + display: flex; + align-items: center; + gap: 0.35rem; + color: var(--nodedc-text-secondary); + white-space: nowrap; +} + +.l3-visual-audit__legend span::before { + width: 0.42rem; + height: 0.42rem; + flex: none; + border-radius: 50%; + background: var(--nodedc-text-primary); + content: ""; +} + +.l3-visual-audit__legend span[data-tone="tp"]::before { + background: rgb(var(--nodedc-success-rgb)); +} + +.l3-visual-audit__legend span[data-tone="fp"]::before { + background: rgb(var(--nodedc-danger-rgb)); +} + +.l3-visual-audit__legend span[data-tone="fn"]::before { + background: rgb(var(--nodedc-warning-rgb)); +} + +@media (max-width: 900px) { + .l3-visual-audit__overlay { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .l3-visual-audit__legend { + display: none; + } +} diff --git a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx index 0264e3e..23fe812 100644 --- a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx +++ b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx @@ -18,6 +18,7 @@ import { E37Result } from "./E37Result"; import { E38Result } from "./E38Result"; import { E39Result } from "./E39Result"; import { E40Result } from "./E40Result"; +import { L3PointPillarsResult } from "./L3PointPillarsResult"; import { RecordedReplayEvidence } from "./RecordedReplayEvidence"; export { isAdvancedLaboratoryWorkId }; @@ -32,6 +33,10 @@ export function advancedLaboratoryWorkOptions( ): readonly LaboratoryOption[] { const available = new Set(index.map(({ workId }) => workId)); const options: readonly LaboratoryOption[] = [ + { + id: "l3-pointpillars-visual-audit", + label: "L3 · визуальный аудит PointPillars", + }, { id: "e31-source-binding", label: "LAB E31 · source binding" }, { id: "e32-track-geometry", label: "LAB E32 · TrackGeometry v1" }, { id: "e33-worker-shadow", label: "LAB E33 · worker shadow 1×" }, @@ -82,6 +87,9 @@ export function AdvancedLaboratoryResult({ failedSessionId: string | null; replayError: string | null; }) { + if (workId === "l3-pointpillars-visual-audit" && results.l3) { + return ; + } if (workId === "e40-perception-product-gate" && results.e40) { return ; } diff --git a/apps/control-station/src/workspaces/laboratory/L3PointPillarsResult.tsx b/apps/control-station/src/workspaces/laboratory/L3PointPillarsResult.tsx new file mode 100644 index 0000000..7414be7 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/L3PointPillarsResult.tsx @@ -0,0 +1,141 @@ +import { + LaboratoryEvidence, + LaboratoryResultSummary, + LaboratorySummary, + LaboratoryWorkTemplate, +} from "../../components/laboratory/LaboratoryPresentation"; +import type { + L3PointPillarsVisualAuditResult, +} from "../../core/laboratory/l3PointPillarsVisualAudit"; +import { L3PointPillarsVisualAudit } from "./L3PointPillarsVisualAudit"; + +function percent(value: number, digits = 3): string { + return `${(value * 100).toLocaleString("ru-RU", { + maximumFractionDigits: digits, + })}%`; +} + +export function L3PointPillarsResult({ + result, +}: { + result: L3PointPillarsVisualAuditResult; +}) { + const metrics = result.metrics; + return ( + + )} + evidence={( + + + + )} + result={( + + )} + /> + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/L3PointPillarsScene.tsx b/apps/control-station/src/workspaces/laboratory/L3PointPillarsScene.tsx new file mode 100644 index 0000000..08729e2 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/L3PointPillarsScene.tsx @@ -0,0 +1,256 @@ +import { useEffect, useRef, useState } from "react"; +import * as THREE from "three"; +import { OrbitControls } from "three/addons/controls/OrbitControls.js"; + +import type { + L3VisualBox, + L3VisualFrame, +} from "../../core/laboratory/l3PointPillarsVisualAudit"; + +export type L3VisualMode = "3d" | "bev"; + +function tokenColor( + host: HTMLElement, + token: string, + fallback: readonly [number, number, number], +): THREE.Color { + const value = getComputedStyle(host).getPropertyValue(token).trim(); + if (value.startsWith("#")) return new THREE.Color(value); + const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number); + const [red, green, blue] = channels?.length === 3 ? channels : fallback; + return new THREE.Color(red / 255, green / 255, blue / 255); +} + +function pointPositions(values: readonly number[]): Float32Array { + const positions = new Float32Array((values.length / 4) * 3); + for ( + let source = 0, target = 0; + source < values.length; + source += 4, target += 3 + ) { + positions[target] = values[source]; + positions[target + 1] = values[source + 2]; + positions[target + 2] = -values[source + 1]; + } + return positions; +} + +function boxSegments(box: L3VisualBox): Float32Array { + const [centerX, centerY, centerZ] = box.centerXyzM; + const [length, width, height] = box.sizeLwhM; + const cosine = Math.cos(box.yawRad); + const sine = Math.sin(box.yawRad); + const corners: THREE.Vector3[] = []; + for (const zOffset of [-height / 2, height / 2]) { + for (const [xOffset, yOffset] of [ + [-length / 2, -width / 2], + [length / 2, -width / 2], + [length / 2, width / 2], + [-length / 2, width / 2], + ]) { + const x = centerX + xOffset * cosine - yOffset * sine; + const y = centerY + xOffset * sine + yOffset * cosine; + corners.push(new THREE.Vector3(x, centerZ + zOffset, -y)); + } + } + const edges = [ + [0, 1], [1, 2], [2, 3], [3, 0], + [4, 5], [5, 6], [6, 7], [7, 4], + [0, 4], [1, 5], [2, 6], [3, 7], + ]; + const positions = new Float32Array(edges.length * 6); + edges.forEach(([from, to], index) => { + corners[from].toArray(positions, index * 6); + corners[to].toArray(positions, index * 6 + 3); + }); + return positions; +} + +function addBoxes( + scene: THREE.Scene, + boxes: readonly L3VisualBox[], + colors: Readonly>, + opacity: number, +): THREE.LineSegments[] { + return boxes.map((box) => { + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute( + "position", + new THREE.BufferAttribute(boxSegments(box), 3), + ); + const material = new THREE.LineBasicMaterial({ + color: colors[box.status], + transparent: true, + opacity, + depthTest: true, + depthWrite: false, + }); + const lines = new THREE.LineSegments(geometry, material); + scene.add(lines); + return lines; + }); +} + +export function L3PointPillarsScene({ + frame, + mode, +}: { + frame: L3VisualFrame; + mode: L3VisualMode; +}) { + const hostRef = useRef(null); + const [renderError, setRenderError] = useState(null); + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + setRenderError(null); + let renderer: THREE.WebGLRenderer; + try { + renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: false, + powerPreference: "high-performance", + }); + } catch { + setRenderError("Браузер не смог открыть WebGL-сцену L3."); + return; + } + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.setClearColor( + tokenColor(host, "--nodedc-canvas", [5, 5, 6]), + 1, + ); + renderer.domElement.setAttribute("role", "img"); + renderer.domElement.setAttribute( + "aria-label", + `L3 PointPillars: кадр ${frame.frameId}, режим ${mode}`, + ); + host.append(renderer.domElement); + + const scene = new THREE.Scene(); + const positions = pointPositions(frame.pointsXyzi); + const pointsGeometry = new THREE.BufferGeometry(); + pointsGeometry.setAttribute( + "position", + new THREE.BufferAttribute(positions, 3), + ); + const pointsMaterial = new THREE.PointsMaterial({ + color: tokenColor(host, "--nodedc-text-secondary", [187, 190, 196]), + size: mode === "bev" ? 1.4 : 1.8, + sizeAttenuation: false, + transparent: true, + opacity: 0.52, + depthWrite: false, + }); + scene.add(new THREE.Points(pointsGeometry, pointsMaterial)); + + const colors: Readonly> = { + matched: tokenColor(host, "--nodedc-text-primary", [247, 248, 244]), + "false-negative": tokenColor( + host, + "--nodedc-warning-rgb", + [255, 209, 102], + ), + "true-positive": tokenColor( + host, + "--nodedc-success-rgb", + [143, 255, 93], + ), + "false-positive": tokenColor( + host, + "--nodedc-danger-rgb", + [255, 98, 112], + ), + }; + const truthLines = addBoxes(scene, frame.truthBoxes, colors, 0.9); + const predictionLines = addBoxes( + scene, + frame.predictionBoxes, + colors, + 0.72, + ); + const grid = new THREE.GridHelper( + 80, + 40, + tokenColor(host, "--nodedc-text-muted", [96, 99, 106]), + tokenColor(host, "--nodedc-glass-outline", [48, 50, 56]), + ); + const gridMaterials = Array.isArray(grid.material) + ? grid.material + : [grid.material]; + gridMaterials.forEach((material) => { + material.transparent = true; + material.opacity = 0.22; + material.depthWrite = false; + }); + scene.add(grid); + + const perspective = new THREE.PerspectiveCamera(52, 1, 0.1, 500); + perspective.position.set(-12, 18, 36); + const orthographic = new THREE.OrthographicCamera(-40, 40, 40, -40, 0.1, 500); + orthographic.position.set(35, 100, 0); + orthographic.up.set(1, 0, 0); + const camera = mode === "bev" ? orthographic : perspective; + camera.lookAt(30, 0, 0); + + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = false; + controls.enableRotate = mode === "3d"; + controls.enablePan = true; + controls.enableZoom = true; + controls.screenSpacePanning = true; + controls.target.set(30, 0, 0); + controls.update(); + + const render = () => renderer.render(scene, camera); + controls.addEventListener("change", render); + const resize = () => { + const width = Math.max(host.clientWidth, 1); + const height = Math.max(host.clientHeight, 1); + renderer.setSize(width, height, false); + if (camera instanceof THREE.PerspectiveCamera) { + camera.aspect = width / height; + camera.updateProjectionMatrix(); + } else { + const horizontal = 42; + camera.left = -horizontal; + camera.right = horizontal; + camera.top = horizontal / (width / height); + camera.bottom = -horizontal / (width / height); + camera.updateProjectionMatrix(); + } + render(); + }; + const observer = new ResizeObserver(resize); + observer.observe(host); + resize(); + + return () => { + observer.disconnect(); + controls.removeEventListener("change", render); + controls.dispose(); + pointsGeometry.dispose(); + pointsMaterial.dispose(); + [...truthLines, ...predictionLines].forEach((lines) => { + lines.geometry.dispose(); + (lines.material as THREE.Material).dispose(); + }); + grid.geometry.dispose(); + gridMaterials.forEach((material) => material.dispose()); + renderer.dispose(); + renderer.domElement.remove(); + }; + }, [frame, mode]); + + return ( +
+ {renderError ? ( +
+ {renderError} +
+ ) : null} +
+ ); +} diff --git a/apps/control-station/src/workspaces/laboratory/L3PointPillarsVisualAudit.tsx b/apps/control-station/src/workspaces/laboratory/L3PointPillarsVisualAudit.tsx new file mode 100644 index 0000000..f09f5c9 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/L3PointPillarsVisualAudit.tsx @@ -0,0 +1,171 @@ +import { useEffect, useState } from "react"; +import { + Icon, + IconButton, + Select, +} from "@nodedc/ui-react"; + +import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer"; +import { + fetchL3PointPillarsVisualFrame, + type L3PointPillarsVisualAuditResult, + type L3VisualFrame, +} from "../../core/laboratory/l3PointPillarsVisualAudit"; +import { + L3PointPillarsScene, + type L3VisualMode, +} from "./L3PointPillarsScene"; + +function frameLabel( + frame: L3PointPillarsVisualAuditResult["frames"][number], +): string { + return ( + `Кадр ${frame.frameId} · TP ${frame.truePositiveCount}` + + ` · FP ${frame.falsePositiveCount} · FN ${frame.falseNegativeCount}` + ); +} + +export function L3PointPillarsVisualAudit({ + result, +}: { + result: L3PointPillarsVisualAuditResult; +}) { + const [selectedFrameId, setSelectedFrameId] = useState( + result.frames[0]?.frameId ?? "", + ); + const [frame, setFrame] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [mode, setMode] = useState("3d"); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + if (!selectedFrameId) return; + const controller = new AbortController(); + setFrame(null); + setLoading(true); + setError(null); + void fetchL3PointPillarsVisualFrame( + result.resultId, + selectedFrameId, + { signal: controller.signal }, + ).then((next) => { + if (!controller.signal.aborted) setFrame(next); + }).catch((caught: unknown) => { + if (controller.signal.aborted) return; + setError( + caught instanceof Error + ? caught.message + : "Визуальный кадр L3 недоступен.", + ); + }).finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [result.resultId, selectedFrameId]); + + const selectedIndex = result.frames.findIndex( + ({ frameId }) => frameId === selectedFrameId, + ); + const navigate = (offset: -1 | 1) => { + if (!result.frames.length || selectedIndex < 0) return; + const index = ( + selectedIndex + offset + result.frames.length + ) % result.frames.length; + setSelectedFrameId(result.frames[index].frameId); + }; + + const controls = ( +
+
+ navigate(-1)} + > + + + navigate(1)} + > + + +
+