feat(perception): add PointPillars visual audit
This commit is contained in:
@@ -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<Record<AdvancedLaboratoryWorkId, string>> = {
|
||||
"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<AdvancedLaboratoryResults> {
|
||||
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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<L3PointPillarsVisualAuditResult | null> {
|
||||
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<L3VisualFrame> {
|
||||
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)),
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<AdvancedLaboratoryWorkId>[] {
|
||||
const available = new Set(index.map(({ workId }) => workId));
|
||||
const options: readonly LaboratoryOption<AdvancedLaboratoryWorkId>[] = [
|
||||
{
|
||||
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 <L3PointPillarsResult result={results.l3} />;
|
||||
}
|
||||
if (workId === "e40-perception-product-gate" && results.e40) {
|
||||
return <E40Result rigLabel={rigLabel} result={results.e40} />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="L3 · визуальный аудит PointPillars"
|
||||
description="Визуальная производная полного KITTI transfer-прогона: исходные LiDAR-точки, независимые truth-боксы и предсказания модели сопоставлены тем же глобальным 3D IoU-контрактом. Производная не меняет метрики и не выдает результат за K1 accuracy."
|
||||
status="Требуется визуальная проверка"
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{
|
||||
label: "Источник",
|
||||
value: `${result.datasetSourceId} · ${metrics.frameCount.toLocaleString("ru-RU")} кадров`,
|
||||
},
|
||||
{
|
||||
label: "Визуальная выборка",
|
||||
value: `${result.frames.length} доказательных кадров · lazy-load`,
|
||||
},
|
||||
{
|
||||
label: "Исполнение",
|
||||
value: "Worker 006 · последовательная производная",
|
||||
},
|
||||
{
|
||||
label: "Полномочия",
|
||||
value: "Read-only · без navigation/safety acceptance",
|
||||
},
|
||||
]}
|
||||
brief={{
|
||||
question: "Соответствуют ли измеренные провал переноса и почти сплошная ложная занятость фактической геометрии исходных LiDAR-кадров?",
|
||||
approach: "Полный sealed run проверен по hash identity. Global score-order matching повторён с исходными порогами IoU, после чего детерминированно выбраны TP-, FP-, FN- и class-coverage кадры. В браузер поступает только выбранный кадр.",
|
||||
principalResult: `Полный прогон: BEV mAP40 ${percent(metrics.bevMap40)}, 3D mAP40 ${percent(metrics.threeDMap40, 6)}, false occupied ${percent(metrics.falseOccupiedRate)}. Визуальный аудит теперь доступен в 3D и BEV.`,
|
||||
limitation: "Это cross-domain KITTI probe модели, обученной на proprietary solid-state LiDAR. Он проверяет перенос и корректность измерителя, но не доказывает точность K1, camera-first детектор, навигацию или safety.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "ai-inference",
|
||||
pipelineId: "l3-pointpillars-kitti-transfer/visual-audit-v1",
|
||||
components: [
|
||||
{
|
||||
kind: "source",
|
||||
name: result.sourceRunId,
|
||||
version: "sealed 3769-frame transfer run",
|
||||
role: "неизменяемые предсказания и latency",
|
||||
identitySha256: result.sourceFrameResultsIdentitySha256,
|
||||
},
|
||||
{
|
||||
kind: "source",
|
||||
name: result.datasetSourceId,
|
||||
version: "admitted public release",
|
||||
role: "LiDAR и независимые ориентированные 3D truth-боксы",
|
||||
identitySha256: result.datasetReleaseIdentitySha256,
|
||||
},
|
||||
{
|
||||
kind: "algorithm",
|
||||
name: "Global score-order oriented 3D IoU matching",
|
||||
version: "Car 0.7 · Pedestrian/Cyclist 0.5",
|
||||
role: "единая классификация TP, FP и FN",
|
||||
identitySha256: null,
|
||||
},
|
||||
{
|
||||
kind: "runtime",
|
||||
name: "Worker 006 → Mission Core lazy evidence",
|
||||
version: result.resultId,
|
||||
role: "append-only visual derivative без повторного inference",
|
||||
identitySha256: result.resultId.split("-").at(-1) ?? null,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="SEALED RUN → ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО"
|
||||
title="LiDAR, truth и предсказания PointPillars"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<L3PointPillarsVisualAudit result={result} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Численный transfer gate не пройден; визуальная ревизия открыта"
|
||||
status="Не допущено"
|
||||
statusTone="danger"
|
||||
metrics={[
|
||||
{
|
||||
label: "BEV mAP40",
|
||||
value: percent(metrics.bevMap40),
|
||||
hint: "полный denominator · public cross-domain",
|
||||
},
|
||||
{
|
||||
label: "3D mAP40",
|
||||
value: percent(metrics.threeDMap40, 6),
|
||||
hint: `${metrics.evaluatedBoxCount.toLocaleString("ru-RU")} оценённых боксов`,
|
||||
},
|
||||
{
|
||||
label: "False occupied",
|
||||
value: percent(metrics.falseOccupiedRate),
|
||||
hint: `${metrics.modelOutputBoxCount.toLocaleString("ru-RU")} post-NMS · ${metrics.outsideSharedRangeCount.toLocaleString("ru-RU")} вне range`,
|
||||
},
|
||||
{
|
||||
label: "Inference p95",
|
||||
value: `${metrics.inferenceP95Ms.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 2,
|
||||
})} мс`,
|
||||
hint: "Worker 006 · последовательное исполнение",
|
||||
},
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "Полный cross-domain прогон воспроизводим, его численные артефакты связаны с исходными LiDAR-кадрами, а TP/FP/FN можно проверить в 3D и BEV без повторного inference.",
|
||||
notProved: "Не доказаны пригодность этой модели для K1, точность camera-first семантики, метрическая геометрия K1 в других условиях, навигация, команды или safety.",
|
||||
decision: "Не переносить этот публичный PointPillars-кандидат в operational pipeline. Использовать визуальный аудит для проверки природы провала и сохранить архитектуру camera-first semantics + LiDAR metric geometry как основной продуктовый путь.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<L3VisualBox["status"], THREE.Color>>,
|
||||
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<HTMLDivElement | null>(null);
|
||||
const [renderError, setRenderError] = useState<string | null>(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<Record<L3VisualBox["status"], THREE.Color>> = {
|
||||
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 (
|
||||
<div className="l3-visual-audit__scene" ref={hostRef}>
|
||||
{renderError ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
{renderError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<L3VisualFrame | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<L3VisualMode>("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 = (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton
|
||||
label="Предыдущий кадр L3"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="Следующий кадр L3"
|
||||
onClick={() => navigate(1)}
|
||||
>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Select
|
||||
label="Выбрать кадр визуального аудита L3"
|
||||
value={selectedFrameId}
|
||||
options={result.frames.map((item) => ({
|
||||
value: item.frameId,
|
||||
label: frameLabel(item),
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={setSelectedFrameId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const overlay = frame ? (
|
||||
<div className="l3-visual-audit__overlay">
|
||||
<div>
|
||||
<span>Кадр</span>
|
||||
<strong>{frame.frameId}</strong>
|
||||
<small>
|
||||
{frame.sampledPointCount.toLocaleString("ru-RU")} из{" "}
|
||||
{frame.sharedRangePointCount.toLocaleString("ru-RU")} точек
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Сопоставление 3D</span>
|
||||
<strong>
|
||||
TP {frame.summary.truePositiveCount}
|
||||
{" · "}FP {frame.summary.falsePositiveCount}
|
||||
{" · "}FN {frame.summary.falseNegativeCount}
|
||||
</strong>
|
||||
<small>
|
||||
{frame.summary.inferenceMs.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 2,
|
||||
})} мс · {frame.summary.outsideSharedRangeCount} вне общего range
|
||||
</small>
|
||||
</div>
|
||||
<div className="l3-visual-audit__legend" aria-label="Легенда L3">
|
||||
<span data-tone="truth">Truth · совпало</span>
|
||||
<span data-tone="tp">TP · предсказание</span>
|
||||
<span data-tone="fp">FP · ложный бокс</span>
|
||||
<span data-tone="fn">FN · пропущенный truth</span>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className="l3-visual-audit">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="визуальный аудит PointPillars"
|
||||
mode={mode}
|
||||
modes={[
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "bev", label: "BEV" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={controls}
|
||||
overlay={overlay}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Проверяем и открываем выбранный кадр L3</span>
|
||||
</div>
|
||||
) : error || !frame ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{error ?? "Визуальный кадр L3 недоступен."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<L3PointPillarsScene frame={frame} mode={mode} />
|
||||
)}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ function mergeResults(
|
||||
next: AdvancedLaboratoryResults,
|
||||
): AdvancedLaboratoryResults {
|
||||
return {
|
||||
l3: next.l3 ?? current.l3,
|
||||
e31: next.e31 ?? current.e31,
|
||||
e32: next.e32 ?? current.e32,
|
||||
e33: next.e33 ?? current.e33,
|
||||
|
||||
Reference in New Issue
Block a user