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,
|
||||
|
||||
@@ -46,6 +46,18 @@ const e40CaseReviewUrl = new URL(
|
||||
"../src/workspaces/laboratory/E40CaseReview.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const l3ResultUrl = new URL(
|
||||
"../src/workspaces/laboratory/L3PointPillarsResult.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const l3VisualAuditUrl = new URL(
|
||||
"../src/workspaces/laboratory/L3PointPillarsVisualAudit.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const l3SceneUrl = new URL(
|
||||
"../src/workspaces/laboratory/L3PointPillarsScene.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const e35StylesUrl = new URL(
|
||||
"../src/styles/e35-degradation-recovery.css",
|
||||
import.meta.url,
|
||||
@@ -154,6 +166,30 @@ test("bounded LAB result modules use the versioned shared report anatomy", async
|
||||
}
|
||||
});
|
||||
|
||||
test("L3 visual audit binds the sealed metrics to one lazy 3D/BEV viewer", async () => {
|
||||
const [result, audit, scene, advanced] = await Promise.all([
|
||||
readFile(l3ResultUrl, "utf8"),
|
||||
readFile(l3VisualAuditUrl, "utf8"),
|
||||
readFile(l3SceneUrl, "utf8"),
|
||||
readFile(advancedLaboratoryResultUrl, "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(result, /<LaboratoryWorkTemplate/);
|
||||
assert.match(result, /<LaboratoryEvidence/);
|
||||
assert.match(result, /<LaboratoryResultSummary/);
|
||||
assert.match(result, /Производная не меняет метрики/);
|
||||
assert.match(audit, /fetchL3PointPillarsVisualFrame/);
|
||||
assert.match(audit, /\{ value: "3d", label: "3D" \}/);
|
||||
assert.match(audit, /\{ value: "bev", label: "BEV" \}/);
|
||||
assert.match(audit, /Предыдущий кадр L3/);
|
||||
assert.match(audit, /Следующий кадр L3/);
|
||||
assert.match(scene, /new THREE\.OrthographicCamera/);
|
||||
assert.match(scene, /"false-positive"/);
|
||||
assert.match(scene, /"false-negative"/);
|
||||
assert.match(advanced, /id: "l3-pointpillars-visual-audit"/);
|
||||
assert.match(advanced, /<L3PointPillarsResult/);
|
||||
});
|
||||
|
||||
test("E33 explains the experiment, its method and its retained limits", async () => {
|
||||
const [presentationSource, e33Source] = await Promise.all([
|
||||
readFile(presentationSourceUrl, "utf8"),
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a bounded visual-audit derivative of a sealed L3 transfer run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
import numpy as np
|
||||
|
||||
from k1link.compute.kitti_pointpillars_benchmark import (
|
||||
CROSS_DOMAIN_EVALUATION_RANGE,
|
||||
KITTI_BENCHMARK_CLASSES,
|
||||
KITTI_IOU_THRESHOLDS,
|
||||
MODEL_TO_KITTI_CLASS,
|
||||
KittiLidarTruth,
|
||||
read_kitti_validation_truth,
|
||||
)
|
||||
from k1link.compute.pointpillars_postprocess import (
|
||||
PointPillarsBox,
|
||||
oriented_3d_iou,
|
||||
)
|
||||
from k1link.datasets.kitti_3d_admission import (
|
||||
KITTI_3D_RELEASE_ROOT,
|
||||
KITTI_CALIB_ARCHIVE,
|
||||
KITTI_LABEL_ARCHIVE,
|
||||
KITTI_VELODYNE_ARCHIVE,
|
||||
read_kitti_3d_admission,
|
||||
read_kitti_standard_splits,
|
||||
)
|
||||
|
||||
SOURCE_MANIFEST_SCHEMA: Final = (
|
||||
"missioncore.l3-pointpillars-kitti-transfer-result/v1"
|
||||
)
|
||||
SOURCE_FRAME_SCHEMA: Final = (
|
||||
"missioncore.l3-pointpillars-kitti-transfer-frame/v1"
|
||||
)
|
||||
VISUAL_AUDIT_SCHEMA: Final = "missioncore.l3-pointpillars-visual-audit/v1"
|
||||
VISUAL_CATALOG_SCHEMA: Final = (
|
||||
"missioncore.l3-pointpillars-visual-audit-catalog/v1"
|
||||
)
|
||||
VISUAL_FRAME_SCHEMA: Final = "missioncore.l3-pointpillars-visual-frame/v1"
|
||||
EXPECTED_SOURCE_RUN_ID: Final = (
|
||||
"l3-pointpillars-kitti-"
|
||||
"1a6b499e194a363644854dc324bd1b565c100b809f145c1324a25328e7ae0910"
|
||||
)
|
||||
EXPECTED_FRAME_RESULTS_IDENTITY: Final = (
|
||||
"30b1933d508a09025a7d3c3c460fc2d06128e4bbe96a753bec7ba8545fda3e9c"
|
||||
)
|
||||
MAX_SELECTED_FRAMES: Final = 18
|
||||
MAX_SAMPLED_POINTS: Final = 12_000
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--source-run", type=Path, required=True)
|
||||
parser.add_argument("--dataset-root", type=Path, required=True)
|
||||
parser.add_argument("--output-root", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
result = build_visual_audit(
|
||||
source_run=args.source_run,
|
||||
dataset_root=args.dataset_root,
|
||||
output_root=args.output_root,
|
||||
)
|
||||
print(json.dumps(result, sort_keys=True), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
def build_visual_audit(
|
||||
*,
|
||||
source_run: Path,
|
||||
dataset_root: Path,
|
||||
output_root: Path,
|
||||
) -> dict[str, object]:
|
||||
run_root = source_run.expanduser().resolve(strict=True)
|
||||
if not run_root.is_dir() or run_root.is_symlink():
|
||||
raise RuntimeError("sealed L3 source run is unavailable")
|
||||
source_manifest_path = run_root / "manifest.json"
|
||||
source_report_path = run_root / "report.json"
|
||||
source_manifest = _read_json(source_manifest_path)
|
||||
source_report = _read_json(source_report_path)
|
||||
_validate_source_manifest(
|
||||
source_manifest,
|
||||
source_report,
|
||||
source_manifest_path=source_manifest_path,
|
||||
source_report_path=source_report_path,
|
||||
run_root=run_root,
|
||||
)
|
||||
|
||||
dataset = read_kitti_3d_admission(dataset_root.expanduser().absolute())
|
||||
validation_ids = read_kitti_standard_splits(
|
||||
dataset_root.expanduser().absolute()
|
||||
)["validation"]
|
||||
if (
|
||||
dataset["release_identity_sha256"]
|
||||
!= source_manifest["identity"]["dataset_release_identity_sha256"]
|
||||
or len(validation_ids) != source_manifest["frame_result_count"]
|
||||
):
|
||||
raise RuntimeError("sealed run and admitted KITTI release diverge")
|
||||
|
||||
archive_root = (
|
||||
dataset_root.expanduser().absolute()
|
||||
/ KITTI_3D_RELEASE_ROOT
|
||||
/ "archives"
|
||||
)
|
||||
truths = read_kitti_validation_truth(
|
||||
labels_archive=archive_root / KITTI_LABEL_ARCHIVE,
|
||||
calibrations_archive=archive_root / KITTI_CALIB_ARCHIVE,
|
||||
validation_frame_ids=validation_ids,
|
||||
)
|
||||
predictions = _read_predictions(run_root / "frames", validation_ids)
|
||||
matched_predictions, matched_truth = _global_matches(predictions, truths)
|
||||
summaries = _frame_summaries(
|
||||
predictions,
|
||||
truths,
|
||||
matched_predictions,
|
||||
matched_truth,
|
||||
)
|
||||
selected_ids = _select_frames(summaries)
|
||||
|
||||
identity = {
|
||||
"source_run_id": source_manifest["run_id"],
|
||||
"source_manifest_sha256": _sha256(source_manifest_path),
|
||||
"source_frame_results_identity_sha256": source_manifest[
|
||||
"frame_results_identity_sha256"
|
||||
],
|
||||
"dataset_source_id": dataset["source_id"],
|
||||
"dataset_release_identity_sha256": dataset[
|
||||
"release_identity_sha256"
|
||||
],
|
||||
"matching": {
|
||||
"metric": "oriented-3d-iou",
|
||||
"ordering": "global-score-descending",
|
||||
"class_iou_thresholds": KITTI_IOU_THRESHOLDS,
|
||||
"shared_evaluation_range": list(CROSS_DOMAIN_EVALUATION_RANGE),
|
||||
},
|
||||
"selection": {
|
||||
"policy": "tp-first-then-fp-fn-class-coverage/v1",
|
||||
"maximum_frames": MAX_SELECTED_FRAMES,
|
||||
"selected_frame_ids": list(selected_ids),
|
||||
},
|
||||
"point_sampling": {
|
||||
"policy": "shared-range-even-index/v1",
|
||||
"maximum_points_per_frame": MAX_SAMPLED_POINTS,
|
||||
"fields": ["x_m", "y_m", "z_m", "intensity"],
|
||||
},
|
||||
"producer_sha256": _sha256(Path(__file__).resolve(strict=True)),
|
||||
"authority": {
|
||||
"read_only": True,
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
result_id = f"l3-pointpillars-visual-audit-{identity_sha256}"
|
||||
result_root = output_root.expanduser().absolute() / result_id
|
||||
if result_root.exists():
|
||||
raise RuntimeError("visual-audit derivative already exists")
|
||||
frames_root = result_root / "frames"
|
||||
frames_root.mkdir(mode=0o700, parents=True)
|
||||
|
||||
descriptors: list[dict[str, object]] = []
|
||||
points_path = archive_root / KITTI_VELODYNE_ARCHIVE
|
||||
try:
|
||||
with zipfile.ZipFile(points_path.resolve(strict=True)) as points_zip:
|
||||
for frame_id in selected_ids:
|
||||
raw = points_zip.read(f"training/velodyne/{frame_id}.bin")
|
||||
source_frame = predictions[frame_id]["payload"]
|
||||
if hashlib.sha256(raw).hexdigest() != source_frame["point_sha256"]:
|
||||
raise RuntimeError(
|
||||
f"KITTI points changed for selected frame {frame_id}"
|
||||
)
|
||||
detail = _frame_detail(
|
||||
frame_id=frame_id,
|
||||
point_bytes=raw,
|
||||
prediction=predictions[frame_id],
|
||||
truths=truths[frame_id],
|
||||
matched_predictions=matched_predictions,
|
||||
matched_truth=matched_truth,
|
||||
summary=summaries[frame_id],
|
||||
)
|
||||
path = frames_root / f"{frame_id}.json"
|
||||
_write_once(path, detail)
|
||||
descriptors.append(
|
||||
{
|
||||
**summaries[frame_id],
|
||||
"detail_path": f"frames/{frame_id}.json",
|
||||
"detail_sha256": _sha256(path),
|
||||
"detail_byte_length": path.stat().st_size,
|
||||
}
|
||||
)
|
||||
except (OSError, KeyError, zipfile.BadZipFile) as exc:
|
||||
raise RuntimeError("selected KITTI points could not be read") from exc
|
||||
|
||||
catalog = {
|
||||
"schema_version": VISUAL_CATALOG_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"source_run_id": source_manifest["run_id"],
|
||||
"frame_count": len(descriptors),
|
||||
"frames": descriptors,
|
||||
}
|
||||
catalog_path = result_root / "catalog.json"
|
||||
_write_once(catalog_path, catalog)
|
||||
manifest = {
|
||||
"schema_version": VISUAL_AUDIT_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"identity_sha256": identity_sha256,
|
||||
"identity": identity,
|
||||
"created_at_utc": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
||||
"status": "operator-visual-review-required",
|
||||
"source_metrics": source_report["metrics"],
|
||||
"catalog": _artifact(catalog_path, "visual-frame-catalog"),
|
||||
"authority": identity["authority"],
|
||||
}
|
||||
_write_once(result_root / "manifest.json", manifest)
|
||||
return {
|
||||
"result_id": result_id,
|
||||
"selected_frame_count": len(descriptors),
|
||||
"status": manifest["status"],
|
||||
}
|
||||
|
||||
|
||||
def _validate_source_manifest(
|
||||
manifest: dict[str, Any],
|
||||
report: dict[str, Any],
|
||||
*,
|
||||
source_manifest_path: Path,
|
||||
source_report_path: Path,
|
||||
run_root: Path,
|
||||
) -> None:
|
||||
artifacts = manifest.get("artifacts")
|
||||
if (
|
||||
manifest.get("schema_version") != SOURCE_MANIFEST_SCHEMA
|
||||
or manifest.get("run_id") != EXPECTED_SOURCE_RUN_ID
|
||||
or run_root.name != EXPECTED_SOURCE_RUN_ID
|
||||
or manifest.get("frame_result_count") != 3769
|
||||
or manifest.get("frame_results_identity_sha256")
|
||||
!= EXPECTED_FRAME_RESULTS_IDENTITY
|
||||
or manifest.get("status")
|
||||
!= "public-cross-domain-transfer-probe-measured"
|
||||
or report.get("run_id") != manifest.get("run_id")
|
||||
or report.get("status") != manifest.get("status")
|
||||
or not isinstance(artifacts, list)
|
||||
):
|
||||
raise RuntimeError("sealed L3 source manifest is invalid")
|
||||
observed = {
|
||||
descriptor.get("role"): descriptor
|
||||
for descriptor in artifacts
|
||||
if isinstance(descriptor, dict)
|
||||
}
|
||||
for role, path in (
|
||||
("run-identity", run_root / "identity.json"),
|
||||
("benchmark-report", source_report_path),
|
||||
):
|
||||
descriptor = observed.get(role)
|
||||
if (
|
||||
not isinstance(descriptor, dict)
|
||||
or descriptor.get("sha256") != _sha256(path)
|
||||
or descriptor.get("byte_length") != path.stat().st_size
|
||||
):
|
||||
raise RuntimeError(f"sealed L3 {role} changed")
|
||||
if _frame_results_identity(run_root / "frames") != EXPECTED_FRAME_RESULTS_IDENTITY:
|
||||
raise RuntimeError("sealed L3 frame set changed")
|
||||
if _sha256(source_manifest_path) != _sha256(run_root / "manifest.json"):
|
||||
raise RuntimeError("sealed L3 manifest path changed")
|
||||
|
||||
|
||||
def _read_predictions(
|
||||
frames_root: Path,
|
||||
validation_ids: tuple[str, ...],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
expected = {f"{frame_id}.json" for frame_id in validation_ids}
|
||||
actual = {path.name for path in frames_root.glob("*.json")}
|
||||
if actual != expected:
|
||||
raise RuntimeError("sealed L3 frame set does not match KITTI validation")
|
||||
predictions: dict[str, dict[str, Any]] = {}
|
||||
for frame_id in validation_ids:
|
||||
payload = _read_json(frames_root / f"{frame_id}.json")
|
||||
raw_boxes = payload.get("boxes")
|
||||
if (
|
||||
payload.get("schema_version") != SOURCE_FRAME_SCHEMA
|
||||
or payload.get("frame_id") != frame_id
|
||||
or not isinstance(payload.get("point_sha256"), str)
|
||||
or not isinstance(raw_boxes, list)
|
||||
):
|
||||
raise RuntimeError(f"sealed L3 frame {frame_id} is invalid")
|
||||
try:
|
||||
boxes = tuple(PointPillarsBox(**box) for box in raw_boxes)
|
||||
inference_ms = float(payload["inference_ms"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise RuntimeError(
|
||||
f"sealed L3 frame {frame_id} is invalid"
|
||||
) from exc
|
||||
if (
|
||||
not math.isfinite(inference_ms)
|
||||
or inference_ms <= 0
|
||||
or any(not _valid_box(box) for box in boxes)
|
||||
):
|
||||
raise RuntimeError(f"sealed L3 frame {frame_id} is invalid")
|
||||
predictions[frame_id] = {
|
||||
"payload": payload,
|
||||
"boxes": boxes,
|
||||
"inference_ms": inference_ms,
|
||||
}
|
||||
return predictions
|
||||
|
||||
|
||||
def _global_matches(
|
||||
predictions: dict[str, dict[str, Any]],
|
||||
truths: dict[str, tuple[KittiLidarTruth, ...]],
|
||||
) -> tuple[dict[tuple[str, int], tuple[int, float]], set[tuple[str, int]]]:
|
||||
matched_predictions: dict[tuple[str, int], tuple[int, float]] = {}
|
||||
matched_truth: set[tuple[str, int]] = set()
|
||||
for class_name in KITTI_BENCHMARK_CLASSES:
|
||||
ordered = sorted(
|
||||
(
|
||||
(box.score, frame_id, index, box)
|
||||
for frame_id, frame in predictions.items()
|
||||
for index, box in enumerate(frame["boxes"])
|
||||
if _inside_shared_range(box)
|
||||
and MODEL_TO_KITTI_CLASS[box.model_class] == class_name
|
||||
),
|
||||
key=lambda item: (-item[0], item[1], item[2]),
|
||||
)
|
||||
for _, frame_id, index, box in ordered:
|
||||
best_index = -1
|
||||
best_iou = -1.0
|
||||
for truth_index, truth in enumerate(truths[frame_id]):
|
||||
if (
|
||||
truth.benchmark_class != class_name
|
||||
or (frame_id, truth_index) in matched_truth
|
||||
):
|
||||
continue
|
||||
overlap = oriented_3d_iou(box, _truth_box(truth))
|
||||
if overlap > best_iou:
|
||||
best_index = truth_index
|
||||
best_iou = overlap
|
||||
if (
|
||||
best_index >= 0
|
||||
and best_iou >= KITTI_IOU_THRESHOLDS[class_name]
|
||||
):
|
||||
matched_truth.add((frame_id, best_index))
|
||||
matched_predictions[(frame_id, index)] = (
|
||||
best_index,
|
||||
best_iou,
|
||||
)
|
||||
return matched_predictions, matched_truth
|
||||
|
||||
|
||||
def _frame_summaries(
|
||||
predictions: dict[str, dict[str, Any]],
|
||||
truths: dict[str, tuple[KittiLidarTruth, ...]],
|
||||
matched_predictions: dict[tuple[str, int], tuple[int, float]],
|
||||
matched_truth: set[tuple[str, int]],
|
||||
) -> dict[str, dict[str, object]]:
|
||||
result: dict[str, dict[str, object]] = {}
|
||||
for frame_id, frame in predictions.items():
|
||||
evaluated = [
|
||||
(index, box)
|
||||
for index, box in enumerate(frame["boxes"])
|
||||
if _inside_shared_range(box)
|
||||
]
|
||||
true_positive_count = sum(
|
||||
(frame_id, index) in matched_predictions
|
||||
for index, _ in evaluated
|
||||
)
|
||||
false_negative_count = sum(
|
||||
(frame_id, index) not in matched_truth
|
||||
for index in range(len(truths[frame_id]))
|
||||
)
|
||||
result[frame_id] = {
|
||||
"frame_id": frame_id,
|
||||
"inference_ms": frame["inference_ms"],
|
||||
"prediction_count": len(frame["boxes"]),
|
||||
"evaluated_prediction_count": len(evaluated),
|
||||
"outside_shared_range_count": len(frame["boxes"]) - len(evaluated),
|
||||
"truth_count": len(truths[frame_id]),
|
||||
"true_positive_count": true_positive_count,
|
||||
"false_positive_count": len(evaluated) - true_positive_count,
|
||||
"false_negative_count": false_negative_count,
|
||||
"truth_classes": sorted(
|
||||
{truth.benchmark_class for truth in truths[frame_id]}
|
||||
),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _select_frames(
|
||||
summaries: dict[str, dict[str, object]],
|
||||
) -> tuple[str, ...]:
|
||||
selected: list[str] = []
|
||||
|
||||
def add(frame_id: str) -> None:
|
||||
if frame_id not in selected and len(selected) < MAX_SELECTED_FRAMES:
|
||||
selected.append(frame_id)
|
||||
|
||||
for frame_id in sorted(
|
||||
summaries,
|
||||
key=lambda item: (
|
||||
-int(summaries[item]["true_positive_count"]),
|
||||
item,
|
||||
),
|
||||
):
|
||||
if int(summaries[frame_id]["true_positive_count"]) > 0:
|
||||
add(frame_id)
|
||||
for metric in ("false_positive_count", "false_negative_count"):
|
||||
for frame_id in sorted(
|
||||
summaries,
|
||||
key=lambda item: (-int(summaries[item][metric]), item),
|
||||
)[:6]:
|
||||
add(frame_id)
|
||||
for class_name in KITTI_BENCHMARK_CLASSES:
|
||||
candidates = [
|
||||
frame_id
|
||||
for frame_id, summary in summaries.items()
|
||||
if class_name in summary["truth_classes"]
|
||||
]
|
||||
if candidates:
|
||||
add(
|
||||
max(
|
||||
candidates,
|
||||
key=lambda item: (
|
||||
int(summaries[item]["false_negative_count"]),
|
||||
int(summaries[item]["false_positive_count"]),
|
||||
item,
|
||||
),
|
||||
)
|
||||
)
|
||||
for frame_id in sorted(
|
||||
summaries,
|
||||
key=lambda item: (
|
||||
-int(summaries[item]["false_positive_count"])
|
||||
- int(summaries[item]["false_negative_count"]),
|
||||
item,
|
||||
),
|
||||
):
|
||||
add(frame_id)
|
||||
if not selected:
|
||||
raise RuntimeError("visual-audit selection is empty")
|
||||
return tuple(selected)
|
||||
|
||||
|
||||
def _frame_detail(
|
||||
*,
|
||||
frame_id: str,
|
||||
point_bytes: bytes,
|
||||
prediction: dict[str, Any],
|
||||
truths: tuple[KittiLidarTruth, ...],
|
||||
matched_predictions: dict[tuple[str, int], tuple[int, float]],
|
||||
matched_truth: set[tuple[str, int]],
|
||||
summary: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
points = np.frombuffer(point_bytes, dtype="<f4")
|
||||
if points.size % 4:
|
||||
raise RuntimeError(f"KITTI point frame {frame_id} is malformed")
|
||||
points = points.reshape(-1, 4)
|
||||
bounds = CROSS_DOMAIN_EVALUATION_RANGE
|
||||
mask = (
|
||||
(points[:, 0] >= bounds[0])
|
||||
& (points[:, 0] <= bounds[3])
|
||||
& (points[:, 1] >= bounds[1])
|
||||
& (points[:, 1] <= bounds[4])
|
||||
& (points[:, 2] >= bounds[2])
|
||||
& (points[:, 2] <= bounds[5])
|
||||
)
|
||||
bounded = points[mask]
|
||||
if len(bounded) > MAX_SAMPLED_POINTS:
|
||||
indices = np.linspace(
|
||||
0,
|
||||
len(bounded) - 1,
|
||||
MAX_SAMPLED_POINTS,
|
||||
dtype=np.int64,
|
||||
)
|
||||
sampled = bounded[indices]
|
||||
else:
|
||||
sampled = bounded
|
||||
flat_points = np.round(sampled, decimals=4).reshape(-1).tolist()
|
||||
|
||||
prediction_boxes: list[dict[str, object]] = []
|
||||
for index, box in enumerate(prediction["boxes"]):
|
||||
if not _inside_shared_range(box):
|
||||
continue
|
||||
match = matched_predictions.get((frame_id, index))
|
||||
prediction_boxes.append(
|
||||
{
|
||||
**_box_payload(box, MODEL_TO_KITTI_CLASS[box.model_class]),
|
||||
"score": box.score,
|
||||
"status": "true-positive" if match else "false-positive",
|
||||
"matched_truth_index": match[0] if match else None,
|
||||
"matched_iou_3d": match[1] if match else None,
|
||||
}
|
||||
)
|
||||
truth_boxes = [
|
||||
{
|
||||
**_truth_payload(truth),
|
||||
"truth_index": index,
|
||||
"status": (
|
||||
"matched" if (frame_id, index) in matched_truth
|
||||
else "false-negative"
|
||||
),
|
||||
}
|
||||
for index, truth in enumerate(truths)
|
||||
]
|
||||
return {
|
||||
"schema_version": VISUAL_FRAME_SCHEMA,
|
||||
"frame_id": frame_id,
|
||||
"summary": summary,
|
||||
"points": {
|
||||
"layout": "flat-xyzi",
|
||||
"source_point_count": len(points),
|
||||
"shared_range_point_count": len(bounded),
|
||||
"sampled_point_count": len(sampled),
|
||||
"values": flat_points,
|
||||
},
|
||||
"truth_boxes": truth_boxes,
|
||||
"prediction_boxes": prediction_boxes,
|
||||
}
|
||||
|
||||
|
||||
def _valid_box(box: PointPillarsBox) -> bool:
|
||||
values = (
|
||||
box.x_m,
|
||||
box.y_m,
|
||||
box.z_m,
|
||||
box.length_m,
|
||||
box.width_m,
|
||||
box.height_m,
|
||||
box.yaw_rad,
|
||||
box.score,
|
||||
)
|
||||
return (
|
||||
box.model_class in MODEL_TO_KITTI_CLASS
|
||||
and all(math.isfinite(value) for value in values)
|
||||
and min(box.length_m, box.width_m, box.height_m) > 0
|
||||
and 0 <= box.score <= 1
|
||||
)
|
||||
|
||||
|
||||
def _inside_shared_range(box: PointPillarsBox) -> bool:
|
||||
bounds = CROSS_DOMAIN_EVALUATION_RANGE
|
||||
return (
|
||||
bounds[0] <= box.x_m <= bounds[3]
|
||||
and bounds[1] <= box.y_m <= bounds[4]
|
||||
and bounds[2] <= box.z_m <= bounds[5]
|
||||
)
|
||||
|
||||
|
||||
def _truth_box(truth: KittiLidarTruth) -> PointPillarsBox:
|
||||
return PointPillarsBox(
|
||||
x_m=truth.x_m,
|
||||
y_m=truth.y_m,
|
||||
z_m=truth.z_m,
|
||||
length_m=truth.length_m,
|
||||
width_m=truth.width_m,
|
||||
height_m=truth.height_m,
|
||||
yaw_rad=truth.yaw_rad,
|
||||
class_id=-1,
|
||||
model_class=truth.benchmark_class,
|
||||
score=1.0,
|
||||
)
|
||||
|
||||
|
||||
def _box_payload(
|
||||
box: PointPillarsBox,
|
||||
benchmark_class: str,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"benchmark_class": benchmark_class,
|
||||
"center_xyz_m": [box.x_m, box.y_m, box.z_m],
|
||||
"size_lwh_m": [box.length_m, box.width_m, box.height_m],
|
||||
"yaw_rad": box.yaw_rad,
|
||||
}
|
||||
|
||||
|
||||
def _truth_payload(truth: KittiLidarTruth) -> dict[str, object]:
|
||||
return {
|
||||
"benchmark_class": truth.benchmark_class,
|
||||
"center_xyz_m": [truth.x_m, truth.y_m, truth.z_m],
|
||||
"size_lwh_m": [truth.length_m, truth.width_m, truth.height_m],
|
||||
"yaw_rad": truth.yaw_rad,
|
||||
}
|
||||
|
||||
|
||||
def _frame_results_identity(frames_root: Path) -> str:
|
||||
descriptors = [
|
||||
{
|
||||
"name": path.name,
|
||||
"sha256": _sha256(path),
|
||||
"byte_length": path.stat().st_size,
|
||||
}
|
||||
for path in sorted(frames_root.glob("*.json"))
|
||||
]
|
||||
return hashlib.sha256(_canonical_json(descriptors)).hexdigest()
|
||||
|
||||
|
||||
def _artifact(path: Path, role: str) -> dict[str, object]:
|
||||
return {
|
||||
"path": path.name,
|
||||
"role": role,
|
||||
"media_type": "application/json",
|
||||
"sha256": _sha256(path),
|
||||
"byte_length": path.stat().st_size,
|
||||
}
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError(f"{path.name} is invalid") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError(f"{path.name} is not an object")
|
||||
return payload
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _canonical_json(payload: Any) -> bytes:
|
||||
return json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _write_once(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
if path.exists():
|
||||
raise RuntimeError(f"{path.name} already exists")
|
||||
descriptor, temporary = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
dir=path.parent,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as target:
|
||||
target.write(_canonical_json(payload))
|
||||
target.flush()
|
||||
os.fsync(target.fileno())
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
with suppress(FileNotFoundError):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -10,6 +10,8 @@ from typing import Any, Final
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from k1link.web.l3_pointpillars_visual_api import latest_l3_visual_identity
|
||||
|
||||
from k1link.compute.e31_source_qualification import (
|
||||
E31SourceQualification,
|
||||
E31SourceQualificationError,
|
||||
@@ -825,12 +827,13 @@ def build_advanced_laboratory_router(
|
||||
e38_root_provider: RootProvider = lambda: None,
|
||||
e39_root_provider: RootProvider = lambda: None,
|
||||
e40_root_provider: RootProvider = lambda: None,
|
||||
l3_visual_root_provider: RootProvider = lambda: None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"])
|
||||
|
||||
@router.get("/advanced-index")
|
||||
def list_advanced_results() -> dict[str, object]:
|
||||
return _advanced_index(
|
||||
result = _advanced_index(
|
||||
(
|
||||
(
|
||||
"e31-source-binding",
|
||||
@@ -897,6 +900,16 @@ def build_advanced_laboratory_router(
|
||||
),
|
||||
)
|
||||
)
|
||||
l3_identity = latest_l3_visual_identity(l3_visual_root_provider)
|
||||
if l3_identity is not None:
|
||||
result["items"].append(
|
||||
{
|
||||
"work_id": "l3-pointpillars-visual-audit",
|
||||
**l3_identity,
|
||||
"access": "read-only",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
@router.get("/e31/results")
|
||||
def list_e31_results(
|
||||
|
||||
@@ -34,6 +34,9 @@ from k1link.sessions import (
|
||||
SessionStore,
|
||||
)
|
||||
from k1link.web.advanced_laboratory_api import build_advanced_laboratory_router
|
||||
from k1link.web.l3_pointpillars_visual_api import (
|
||||
build_l3_pointpillars_visual_router,
|
||||
)
|
||||
from k1link.web.artifact_health_api import build_artifact_health_router
|
||||
from k1link.web.compute_contour_api import build_compute_contour_router
|
||||
from k1link.web.device_plugin_composition import load_installed_device_plugins
|
||||
@@ -599,6 +602,24 @@ app.include_router(
|
||||
/ "e40"
|
||||
/ "results"
|
||||
),
|
||||
l3_visual_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "l3"
|
||||
/ "visual-audits"
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_l3_pointpillars_visual_router(
|
||||
root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "l3"
|
||||
/ "visual-audits"
|
||||
)
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Read-only projection of sealed L3 PointPillars visual-audit evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
RootProvider = Callable[[], Path | None]
|
||||
|
||||
VISUAL_AUDIT_SCHEMA: Final = "missioncore.l3-pointpillars-visual-audit/v1"
|
||||
VISUAL_CATALOG_SCHEMA: Final = (
|
||||
"missioncore.l3-pointpillars-visual-audit-catalog/v1"
|
||||
)
|
||||
VISUAL_FRAME_SCHEMA: Final = "missioncore.l3-pointpillars-visual-frame/v1"
|
||||
VISUAL_RESULT_SCHEMA: Final = (
|
||||
"missioncore.l3-pointpillars-visual-audit-result/v1"
|
||||
)
|
||||
VISUAL_RESULT_ID: Final = re.compile(
|
||||
r"^l3-pointpillars-visual-audit-[a-f0-9]{64}$"
|
||||
)
|
||||
FRAME_ID: Final = re.compile(r"^[0-9]{6}$")
|
||||
MAX_JSON_BYTES: Final = 16 * 1024 * 1024
|
||||
MAX_CANDIDATES: Final = 64
|
||||
MAX_FRAMES: Final = 24
|
||||
|
||||
|
||||
def build_l3_pointpillars_visual_router(
|
||||
*,
|
||||
root_provider: RootProvider = lambda: None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/laboratory/l3/pointpillars-visual-audits",
|
||||
tags=["laboratory"],
|
||||
)
|
||||
|
||||
@router.get("/results")
|
||||
def list_results(
|
||||
limit: int = Query(default=1, ge=1, le=10),
|
||||
) -> dict[str, object]:
|
||||
root = _configured_root(root_provider)
|
||||
if root is None:
|
||||
return _empty_catalog(False)
|
||||
candidates = _candidates(root)
|
||||
items: list[dict[str, object]] = []
|
||||
invalid_total = 0
|
||||
for candidate in candidates:
|
||||
try:
|
||||
items.append(_project_result(candidate))
|
||||
except RuntimeError:
|
||||
invalid_total += 1
|
||||
items.sort(
|
||||
key=lambda item: (
|
||||
str(item["created_at_utc"]),
|
||||
str(item["result_id"]),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return {
|
||||
"schema_version": (
|
||||
"missioncore.l3-pointpillars-visual-audit-catalog-results/v1"
|
||||
),
|
||||
"configured": True,
|
||||
"items": items[:limit],
|
||||
"candidate_total": len(candidates),
|
||||
"invalid_total": invalid_total,
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
@router.get("/{result_id}/frames/{frame_id}")
|
||||
def get_frame(result_id: str, frame_id: str) -> dict[str, object]:
|
||||
if not VISUAL_RESULT_ID.fullmatch(result_id):
|
||||
raise HTTPException(status_code=404, detail="visual audit not found")
|
||||
if not FRAME_ID.fullmatch(frame_id):
|
||||
raise HTTPException(status_code=404, detail="visual frame not found")
|
||||
root = _configured_root(root_provider)
|
||||
if root is None:
|
||||
raise HTTPException(status_code=404, detail="visual audit not found")
|
||||
candidate = root / result_id
|
||||
try:
|
||||
result = _load_result(candidate)
|
||||
descriptor = next(
|
||||
item
|
||||
for item in result["catalog"]["frames"]
|
||||
if item["frame_id"] == frame_id
|
||||
)
|
||||
relative = descriptor["detail_path"]
|
||||
if relative != f"frames/{frame_id}.json":
|
||||
raise RuntimeError("visual frame path changed")
|
||||
path = candidate / relative
|
||||
payload = _read_json(path)
|
||||
if (
|
||||
payload.get("schema_version") != VISUAL_FRAME_SCHEMA
|
||||
or payload.get("frame_id") != frame_id
|
||||
or descriptor["detail_sha256"] != _sha256(path)
|
||||
or descriptor["detail_byte_length"] != path.stat().st_size
|
||||
):
|
||||
raise RuntimeError("visual frame identity changed")
|
||||
except (RuntimeError, StopIteration):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="visual frame not found",
|
||||
) from None
|
||||
return {**copy.deepcopy(payload), "access": "read-only"}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def latest_l3_visual_identity(
|
||||
root_provider: RootProvider,
|
||||
) -> dict[str, str] | None:
|
||||
"""Return the newest verified identity for the shared LAB index."""
|
||||
|
||||
root = _configured_root(root_provider)
|
||||
if root is None:
|
||||
return None
|
||||
valid: list[dict[str, object]] = []
|
||||
for candidate in _candidates(root):
|
||||
try:
|
||||
valid.append(_project_result(candidate))
|
||||
except RuntimeError:
|
||||
continue
|
||||
if not valid:
|
||||
return None
|
||||
latest = max(
|
||||
valid,
|
||||
key=lambda item: (
|
||||
str(item["created_at_utc"]),
|
||||
str(item["result_id"]),
|
||||
),
|
||||
)
|
||||
return {
|
||||
"result_id": str(latest["result_id"]),
|
||||
"created_at_utc": str(latest["created_at_utc"]),
|
||||
}
|
||||
|
||||
|
||||
def _project_result(candidate: Path) -> dict[str, object]:
|
||||
result = _load_result(candidate)
|
||||
manifest = result["manifest"]
|
||||
catalog = result["catalog"]
|
||||
metrics = manifest["source_metrics"]
|
||||
return {
|
||||
"schema_version": VISUAL_RESULT_SCHEMA,
|
||||
"result_id": manifest["result_id"],
|
||||
"created_at_utc": manifest["created_at_utc"],
|
||||
"status": manifest["status"],
|
||||
"source_run_id": manifest["identity"]["source_run_id"],
|
||||
"source_frame_results_identity_sha256": manifest["identity"][
|
||||
"source_frame_results_identity_sha256"
|
||||
],
|
||||
"dataset_source_id": manifest["identity"]["dataset_source_id"],
|
||||
"dataset_release_identity_sha256": manifest["identity"][
|
||||
"dataset_release_identity_sha256"
|
||||
],
|
||||
"metrics": copy.deepcopy(metrics),
|
||||
"frames": copy.deepcopy(catalog["frames"]),
|
||||
"matching": copy.deepcopy(manifest["identity"]["matching"]),
|
||||
"point_sampling": copy.deepcopy(
|
||||
manifest["identity"]["point_sampling"]
|
||||
),
|
||||
"authority": copy.deepcopy(manifest["authority"]),
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
|
||||
def _load_result(candidate: Path) -> dict[str, Any]:
|
||||
if (
|
||||
not candidate.is_dir()
|
||||
or candidate.is_symlink()
|
||||
or not VISUAL_RESULT_ID.fullmatch(candidate.name)
|
||||
):
|
||||
raise RuntimeError("visual audit candidate is invalid")
|
||||
manifest_path = candidate / "manifest.json"
|
||||
manifest = _read_json(manifest_path)
|
||||
identity = manifest.get("identity")
|
||||
authority = manifest.get("authority")
|
||||
catalog_descriptor = manifest.get("catalog")
|
||||
if (
|
||||
manifest.get("schema_version") != VISUAL_AUDIT_SCHEMA
|
||||
or manifest.get("result_id") != candidate.name
|
||||
or manifest.get("status") != "operator-visual-review-required"
|
||||
or not isinstance(manifest.get("created_at_utc"), str)
|
||||
or not isinstance(identity, dict)
|
||||
or not isinstance(authority, dict)
|
||||
or authority.get("read_only") is not True
|
||||
or authority.get("commands_enabled") is not False
|
||||
or authority.get("navigation_or_safety_accepted") is not False
|
||||
or manifest.get("identity_sha256")
|
||||
!= hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
or candidate.name
|
||||
!= f"l3-pointpillars-visual-audit-{manifest.get('identity_sha256')}"
|
||||
or not isinstance(catalog_descriptor, dict)
|
||||
or catalog_descriptor.get("path") != "catalog.json"
|
||||
or catalog_descriptor.get("role") != "visual-frame-catalog"
|
||||
):
|
||||
raise RuntimeError("visual audit manifest is invalid")
|
||||
catalog_path = candidate / "catalog.json"
|
||||
if (
|
||||
catalog_descriptor.get("sha256") != _sha256(catalog_path)
|
||||
or catalog_descriptor.get("byte_length") != catalog_path.stat().st_size
|
||||
):
|
||||
raise RuntimeError("visual audit catalog changed")
|
||||
catalog = _read_json(catalog_path)
|
||||
frames = catalog.get("frames")
|
||||
if (
|
||||
catalog.get("schema_version") != VISUAL_CATALOG_SCHEMA
|
||||
or catalog.get("result_id") != candidate.name
|
||||
or catalog.get("source_run_id") != identity.get("source_run_id")
|
||||
or not isinstance(frames, list)
|
||||
or not 1 <= len(frames) <= MAX_FRAMES
|
||||
or catalog.get("frame_count") != len(frames)
|
||||
or len({item.get("frame_id") for item in frames if isinstance(item, dict)})
|
||||
!= len(frames)
|
||||
or any(not _valid_frame_descriptor(item) for item in frames)
|
||||
):
|
||||
raise RuntimeError("visual audit catalog is invalid")
|
||||
if not isinstance(manifest.get("source_metrics"), dict):
|
||||
raise RuntimeError("visual audit metrics are unavailable")
|
||||
return {"manifest": manifest, "catalog": catalog}
|
||||
|
||||
|
||||
def _valid_frame_descriptor(value: object) -> bool:
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
frame_id = value.get("frame_id")
|
||||
counts = (
|
||||
"prediction_count",
|
||||
"evaluated_prediction_count",
|
||||
"outside_shared_range_count",
|
||||
"truth_count",
|
||||
"true_positive_count",
|
||||
"false_positive_count",
|
||||
"false_negative_count",
|
||||
"detail_byte_length",
|
||||
)
|
||||
return (
|
||||
isinstance(frame_id, str)
|
||||
and FRAME_ID.fullmatch(frame_id) is not None
|
||||
and value.get("detail_path") == f"frames/{frame_id}.json"
|
||||
and isinstance(value.get("detail_sha256"), str)
|
||||
and re.fullmatch(r"[a-f0-9]{64}", value["detail_sha256"]) is not None
|
||||
and all(
|
||||
isinstance(value.get(key), int)
|
||||
and not isinstance(value.get(key), bool)
|
||||
and value[key] >= 0
|
||||
for key in counts
|
||||
)
|
||||
and 0 < value["detail_byte_length"] <= MAX_JSON_BYTES
|
||||
and isinstance(value.get("inference_ms"), (int, float))
|
||||
and not isinstance(value.get("inference_ms"), bool)
|
||||
and 0 < value["inference_ms"] < 60_000
|
||||
and isinstance(value.get("truth_classes"), list)
|
||||
)
|
||||
|
||||
|
||||
def _configured_root(provider: RootProvider) -> Path | None:
|
||||
value = provider()
|
||||
if value is None:
|
||||
return None
|
||||
root = value.expanduser().absolute()
|
||||
if not root.is_dir() or root.is_symlink():
|
||||
return None
|
||||
return root
|
||||
|
||||
|
||||
def _candidates(root: Path) -> list[Path]:
|
||||
candidates = [
|
||||
path
|
||||
for path in root.iterdir()
|
||||
if path.is_dir()
|
||||
and not path.is_symlink()
|
||||
and VISUAL_RESULT_ID.fullmatch(path.name)
|
||||
]
|
||||
if len(candidates) > MAX_CANDIDATES:
|
||||
raise RuntimeError("visual audit candidate bound exceeded")
|
||||
return candidates
|
||||
|
||||
|
||||
def _empty_catalog(configured: bool) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": (
|
||||
"missioncore.l3-pointpillars-visual-audit-catalog-results/v1"
|
||||
),
|
||||
"configured": configured,
|
||||
"items": [],
|
||||
"candidate_total": 0,
|
||||
"invalid_total": 0,
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
if (
|
||||
not path.is_file()
|
||||
or path.is_symlink()
|
||||
or not 0 < path.stat().st_size <= MAX_JSON_BYTES
|
||||
):
|
||||
raise RuntimeError(f"{path.name} is unavailable")
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError(f"{path.name} is invalid") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError(f"{path.name} is not an object")
|
||||
return payload
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _canonical_json(payload: object) -> bytes:
|
||||
return json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
@@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.routing import APIRoute
|
||||
from pytest import raises
|
||||
|
||||
from k1link.web.l3_pointpillars_visual_api import (
|
||||
build_l3_pointpillars_visual_router,
|
||||
)
|
||||
|
||||
|
||||
def _endpoint(router: APIRouter, path: str) -> 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 _canonical(payload: object) -> bytes:
|
||||
return json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _write(path: Path, payload: object) -> dict[str, object]:
|
||||
content = _canonical(payload)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
return {
|
||||
"sha256": hashlib.sha256(content).hexdigest(),
|
||||
"byte_length": len(content),
|
||||
}
|
||||
|
||||
|
||||
def _result(root: Path) -> tuple[str, str]:
|
||||
frame_id = "000001"
|
||||
identity = {
|
||||
"source_run_id": f"l3-pointpillars-kitti-{'1' * 64}",
|
||||
"source_frame_results_identity_sha256": "2" * 64,
|
||||
"dataset_source_id": "kitti-3d-object/v2017",
|
||||
"dataset_release_identity_sha256": "3" * 64,
|
||||
"matching": {"metric": "oriented-3d-iou"},
|
||||
"point_sampling": {"maximum_points_per_frame": 12000},
|
||||
"authority": {
|
||||
"read_only": True,
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
identity_sha = hashlib.sha256(_canonical(identity)).hexdigest()
|
||||
result_id = f"l3-pointpillars-visual-audit-{identity_sha}"
|
||||
candidate = root / result_id
|
||||
summary = {
|
||||
"frame_id": frame_id,
|
||||
"inference_ms": 12.5,
|
||||
"prediction_count": 2,
|
||||
"evaluated_prediction_count": 1,
|
||||
"outside_shared_range_count": 1,
|
||||
"truth_count": 1,
|
||||
"true_positive_count": 0,
|
||||
"false_positive_count": 1,
|
||||
"false_negative_count": 1,
|
||||
"truth_classes": ["Car"],
|
||||
}
|
||||
detail = {
|
||||
"schema_version": "missioncore.l3-pointpillars-visual-frame/v1",
|
||||
"frame_id": frame_id,
|
||||
"summary": summary,
|
||||
"points": {
|
||||
"layout": "flat-xyzi",
|
||||
"source_point_count": 1,
|
||||
"shared_range_point_count": 1,
|
||||
"sampled_point_count": 1,
|
||||
"values": [1, 2, 3, 0.5],
|
||||
},
|
||||
"truth_boxes": [],
|
||||
"prediction_boxes": [],
|
||||
}
|
||||
detail_descriptor = _write(candidate / "frames" / f"{frame_id}.json", detail)
|
||||
descriptor = {
|
||||
**summary,
|
||||
"detail_path": f"frames/{frame_id}.json",
|
||||
"detail_sha256": detail_descriptor["sha256"],
|
||||
"detail_byte_length": detail_descriptor["byte_length"],
|
||||
}
|
||||
catalog = {
|
||||
"schema_version": (
|
||||
"missioncore.l3-pointpillars-visual-audit-catalog/v1"
|
||||
),
|
||||
"result_id": result_id,
|
||||
"source_run_id": identity["source_run_id"],
|
||||
"frame_count": 1,
|
||||
"frames": [descriptor],
|
||||
}
|
||||
catalog_descriptor = _write(candidate / "catalog.json", catalog)
|
||||
manifest = {
|
||||
"schema_version": "missioncore.l3-pointpillars-visual-audit/v1",
|
||||
"result_id": result_id,
|
||||
"identity_sha256": identity_sha,
|
||||
"identity": identity,
|
||||
"created_at_utc": "2026-07-31T10:00:00Z",
|
||||
"status": "operator-visual-review-required",
|
||||
"source_metrics": {"frame_count": 3769, "aggregates": {}},
|
||||
"catalog": {
|
||||
"path": "catalog.json",
|
||||
"role": "visual-frame-catalog",
|
||||
**catalog_descriptor,
|
||||
},
|
||||
"authority": identity["authority"],
|
||||
}
|
||||
_write(candidate / "manifest.json", manifest)
|
||||
return result_id, frame_id
|
||||
|
||||
|
||||
def test_l3_visual_catalog_and_frame_are_read_only(tmp_path: Path) -> None:
|
||||
result_id, frame_id = _result(tmp_path)
|
||||
router = build_l3_pointpillars_visual_router(root_provider=lambda: tmp_path)
|
||||
catalog_route = _endpoint(
|
||||
router,
|
||||
"/api/v1/laboratory/l3/pointpillars-visual-audits/results",
|
||||
)
|
||||
frame_route = _endpoint(
|
||||
router,
|
||||
(
|
||||
"/api/v1/laboratory/l3/pointpillars-visual-audits/"
|
||||
"{result_id}/frames/{frame_id}"
|
||||
),
|
||||
)
|
||||
|
||||
catalog = catalog_route(limit=1) # type: ignore[operator]
|
||||
assert catalog["configured"] is True
|
||||
assert catalog["invalid_total"] == 0
|
||||
assert catalog["items"][0]["result_id"] == result_id
|
||||
assert catalog["items"][0]["frames"][0]["frame_id"] == frame_id
|
||||
|
||||
frame = frame_route(result_id=result_id, frame_id=frame_id) # type: ignore[operator]
|
||||
assert frame["schema_version"] == "missioncore.l3-pointpillars-visual-frame/v1"
|
||||
assert frame["access"] == "read-only"
|
||||
|
||||
|
||||
def test_l3_visual_frame_fails_closed_after_mutation(tmp_path: Path) -> None:
|
||||
result_id, frame_id = _result(tmp_path)
|
||||
router = build_l3_pointpillars_visual_router(root_provider=lambda: tmp_path)
|
||||
frame_route = _endpoint(
|
||||
router,
|
||||
(
|
||||
"/api/v1/laboratory/l3/pointpillars-visual-audits/"
|
||||
"{result_id}/frames/{frame_id}"
|
||||
),
|
||||
)
|
||||
(tmp_path / result_id / "frames" / f"{frame_id}.json").write_text(
|
||||
"{}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with raises(Exception) as caught:
|
||||
frame_route(result_id=result_id, frame_id=frame_id) # type: ignore[operator]
|
||||
assert getattr(caught.value, "status_code", None) == 404
|
||||
Reference in New Issue
Block a user