feat(lab): present chronological perception evidence
This commit is contained in:
@@ -23,6 +23,12 @@ export interface WorkspaceNavigation {
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
}
|
||||
|
||||
export interface LaboratoryAnnotationAction {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceRendererProps {
|
||||
definition: WorkspaceDefinition;
|
||||
state: MissionRuntimeState | null;
|
||||
@@ -56,4 +62,7 @@ export interface WorkspaceRendererProps {
|
||||
disabled: boolean;
|
||||
blockedReason: string | null;
|
||||
};
|
||||
onLaboratoryAnnotationActionChange: (
|
||||
action: LaboratoryAnnotationAction | null,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import type { LaboratoryOption } from "../../components/laboratory/LaboratoryPresentation";
|
||||
import {
|
||||
isAdvancedLaboratoryWorkId,
|
||||
type AdvancedLaboratoryIndexItem,
|
||||
type AdvancedLaboratoryWorkId,
|
||||
} from "../../core/laboratory/advancedIndex";
|
||||
import type { AdvancedLaboratoryResults } from "../../core/laboratory/advancedResults";
|
||||
@@ -20,7 +18,27 @@ import { E39Result } from "./E39Result";
|
||||
import { E40Result } from "./E40Result";
|
||||
import { L3PointPillarsResult } from "./L3PointPillarsResult";
|
||||
import { L31PointPillarsRavnovesResult } from "./L31PointPillarsRavnovesResult";
|
||||
import { L32PointPillarsCameraReviewResult } from "./L32PointPillarsCameraReviewResult";
|
||||
import { L33CameraFirstDetectorReviewResult } from "./L33CameraFirstDetectorReviewResult";
|
||||
import { L34RightYoloxTruthIslandResultView } from "./L34RightYoloxTruthIslandResult";
|
||||
import { L34AAssistedYoloxErrorResultView } from "./L34AAssistedYoloxErrorResult";
|
||||
import { L34BNestedBoxConsolidationResultView } from "./L34BNestedBoxConsolidationResult";
|
||||
import { L34CTileSeamStitchResultView } from "./L34CTileSeamStitchResult";
|
||||
import { L34DCumulativePostprocessingResultView } from "./L34DCumulativePostprocessingResult";
|
||||
import { L34ESelfReviewDiagnosticResultView } from "./L34ESelfReviewDiagnosticResult";
|
||||
import { L34FAdjudicatedReferenceResultView } from "./L34FAdjudicatedReferenceResult";
|
||||
import { RecordedReplayEvidence } from "./RecordedReplayEvidence";
|
||||
import { E46BlindReviewResultView } from "./E46BlindReviewResult";
|
||||
import { E46AAiEngineeringPreannotationResultView } from "./E46AAiEngineeringPreannotationResult";
|
||||
import { E46BTemporalMotionResultView } from "./E46BTemporalMotionResult";
|
||||
import { E46CFullReplayWorldTracksResultView } from "./E46CFullReplayWorldTracksResult";
|
||||
import { E46DTemporalFailureAuditResultView } from "./E46DTemporalFailureAuditResult";
|
||||
import { E46EReadyStackResultView } from "./E46EReadyStackResult";
|
||||
import { E46FDashCamBakeoffResultView } from "./E46FDashCamBakeoffResult";
|
||||
import { E46GRectifiedDetectorBakeoffResultView } from "./E46GRectifiedDetectorBakeoffResult";
|
||||
import { E46HFullRectifiedFrontReplayResultView } from "./E46HFullRectifiedFrontReplayResult";
|
||||
import { E46IGroundingDinoFullReplayResultView } from "./E46IGroundingDinoFullReplayResult";
|
||||
import { E46JRawFisheyeRealtimeResultView } from "./E46JRawFisheyeRealtimeResult";
|
||||
|
||||
export { isAdvancedLaboratoryWorkId };
|
||||
export type { AdvancedLaboratoryWorkId };
|
||||
@@ -29,35 +47,6 @@ type LaboratoryWorkspaceProps = WorkspaceRendererProps & {
|
||||
SpatialView: ComponentType<WorkspaceRendererProps>;
|
||||
};
|
||||
|
||||
export function advancedLaboratoryWorkOptions(
|
||||
index: readonly AdvancedLaboratoryIndexItem[],
|
||||
): readonly LaboratoryOption<AdvancedLaboratoryWorkId>[] {
|
||||
const available = new Set(index.map(({ workId }) => workId));
|
||||
const options: readonly LaboratoryOption<AdvancedLaboratoryWorkId>[] = [
|
||||
{
|
||||
id: "l31-pointpillars-ravnoves",
|
||||
label: "L3.1 · PointPillars на RAVNOVES00",
|
||||
},
|
||||
{
|
||||
id: "l3-pointpillars-visual-audit",
|
||||
label: "L3 · KITTI · внешний PointPillars benchmark",
|
||||
},
|
||||
{ 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×" },
|
||||
{ id: "e34-temporal-layer", label: "LAB E34 · temporal occupied/unknown" },
|
||||
{ id: "e35-degradation-recovery", label: "LAB E35 · degradation recovery" },
|
||||
{ id: "e37-ravnoves-acceptance", label: "LAB E37 · RAVNOVES00 acceptance R0" },
|
||||
{ id: "e38-perception-baseline", label: "LAB E38 · perception quality R1" },
|
||||
{ id: "e39-perception-refinement", label: "LAB E39 · perception refinement R1" },
|
||||
{
|
||||
id: "e40-perception-product-gate",
|
||||
label: "LAB E40 · historical visible engineering evaluation",
|
||||
},
|
||||
];
|
||||
return options.filter(({ id }) => available.has(id));
|
||||
}
|
||||
|
||||
export function advancedLaboratorySourceSession(
|
||||
workId: AdvancedLaboratoryWorkId,
|
||||
results: AdvancedLaboratoryResults,
|
||||
@@ -98,9 +87,69 @@ export function AdvancedLaboratoryResult({
|
||||
if (workId === "l31-pointpillars-ravnoves" && results.l31) {
|
||||
return <L31PointPillarsRavnovesResult result={results.l31} />;
|
||||
}
|
||||
if (workId === "l32-pointpillars-camera-review" && results.l32) {
|
||||
return <L32PointPillarsCameraReviewResult result={results.l32} />;
|
||||
}
|
||||
if (workId === "l33-camera-first-detector-review" && results.l33) {
|
||||
return <L33CameraFirstDetectorReviewResult result={results.l33} />;
|
||||
}
|
||||
if (workId === "e40-perception-product-gate" && results.e40) {
|
||||
return <E40Result rigLabel={rigLabel} result={results.e40} />;
|
||||
}
|
||||
if (workId === "e46-detector-truth-island" && results.e46) {
|
||||
return <E46BlindReviewResultView rigLabel={rigLabel} result={results.e46} />;
|
||||
}
|
||||
if (workId === "e46a-ai-engineering-preannotation" && results.e46a) {
|
||||
return <E46AAiEngineeringPreannotationResultView rigLabel={rigLabel} result={results.e46a} />;
|
||||
}
|
||||
if (workId === "e46b-temporal-motion" && results.e46b) {
|
||||
return <E46BTemporalMotionResultView rigLabel={rigLabel} result={results.e46b} />;
|
||||
}
|
||||
if (workId === "e46c-full-replay-world-tracks" && results.e46c) {
|
||||
return <E46CFullReplayWorldTracksResultView rigLabel={rigLabel} result={results.e46c} />;
|
||||
}
|
||||
if (workId === "e46d-temporal-failure-audit" && results.e46d) {
|
||||
return <E46DTemporalFailureAuditResultView rigLabel={rigLabel} result={results.e46d} />;
|
||||
}
|
||||
if (workId === "e46e-ready-stack" && results.e46e) {
|
||||
return <E46EReadyStackResultView rigLabel={rigLabel} result={results.e46e} />;
|
||||
}
|
||||
if (workId === "e46f-dashcam-bakeoff" && results.e46f) {
|
||||
return <E46FDashCamBakeoffResultView rigLabel={rigLabel} result={results.e46f} />;
|
||||
}
|
||||
if (workId === "e46g-rectified-detector-bakeoff" && results.e46g) {
|
||||
return <E46GRectifiedDetectorBakeoffResultView rigLabel={rigLabel} result={results.e46g} />;
|
||||
}
|
||||
if (workId === "e46h-full-rectified-front-replay" && results.e46h) {
|
||||
return <E46HFullRectifiedFrontReplayResultView rigLabel={rigLabel} result={results.e46h} />;
|
||||
}
|
||||
if (workId === "e46i-grounding-dino-full-replay" && results.e46i) {
|
||||
return <E46IGroundingDinoFullReplayResultView rigLabel={rigLabel} result={results.e46i} />;
|
||||
}
|
||||
if (workId === "e46j-raw-fisheye-realtime" && results.e46j) {
|
||||
return <E46JRawFisheyeRealtimeResultView rigLabel={rigLabel} result={results.e46j} />;
|
||||
}
|
||||
if (workId === "l34-right-yolox-truth-island-freeze" && results.l34) {
|
||||
return <L34RightYoloxTruthIslandResultView rigLabel={rigLabel} result={results.l34} />;
|
||||
}
|
||||
if (workId === "l34a-assisted-yolox-error-audit" && results.l34a) {
|
||||
return <L34AAssistedYoloxErrorResultView rigLabel={rigLabel} result={results.l34a} />;
|
||||
}
|
||||
if (workId === "l34b-nested-box-consolidation-shadow" && results.l34b) {
|
||||
return <L34BNestedBoxConsolidationResultView rigLabel={rigLabel} result={results.l34b} />;
|
||||
}
|
||||
if (workId === "l34c-tile-seam-stitch-shadow" && results.l34c) {
|
||||
return <L34CTileSeamStitchResultView rigLabel={rigLabel} result={results.l34c} />;
|
||||
}
|
||||
if (workId === "l34d-cumulative-postprocessing-candidate" && results.l34d) {
|
||||
return <L34DCumulativePostprocessingResultView rigLabel={rigLabel} result={results.l34d} />;
|
||||
}
|
||||
if (workId === "l34e-self-review-diagnostic" && results.l34e) {
|
||||
return <L34ESelfReviewDiagnosticResultView rigLabel={rigLabel} result={results.l34e} />;
|
||||
}
|
||||
if (workId === "l34f-adjudicated-reference" && results.l34f) {
|
||||
return <L34FAdjudicatedReferenceResultView rigLabel={rigLabel} result={results.l34f} />;
|
||||
}
|
||||
if (workId === "e39-perception-refinement" && results.e39) {
|
||||
return <E39Result rigLabel={rigLabel} result={results.e39} />;
|
||||
}
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { E46AAiEngineeringPreannotationResult } from "../../core/laboratory/e46aAiEngineeringPreannotation";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { E46AAiEngineeringPreannotationVisual } from "./E46AAiEngineeringPreannotationVisual";
|
||||
|
||||
export function E46AAiEngineeringPreannotationResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: E46AAiEngineeringPreannotationResult;
|
||||
}) {
|
||||
const counts = result.metrics.classCounts;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={<LaboratorySummary
|
||||
title="LAB E46A · object-level preannotation QA"
|
||||
description="Все 32 exact E46 RIGHT-camera кадра повторно проверены на уровне отдельных объектов. Ложные рамки удалены, а грубая геометрия привязана к замороженному детекторному кандидату и затем просмотрена на исходных кадрах."
|
||||
status={`32/32 object-level QA · −${result.metrics.deletedFalseBoxCount} false boxes · not truth`}
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · RIGHT camera · recorded replay` },
|
||||
{ label: "Разметка", value: `${result.metrics.frameCount}/32 кадров · ${formatNumber(result.metrics.objectCount, 0)} объектов` },
|
||||
{ label: "QA", value: `${formatNumber(result.metrics.geometrySnappedObjectCount, 0)} geometry snaps · ${formatNumber(result.metrics.deletedFalseBoxCount, 0)} удалено · ${formatNumber(result.metrics.categoryCorrectedObjectCount, 0)} класс исправлен` },
|
||||
{ label: "Полномочия", value: "Reviewer A/B не затронуты · E48 closed · L3.5 closed" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Можно ли убрать очевидную дрочню из ручной разметки и оставить человеку только спорные correction/approval?",
|
||||
approach: "Исходные 260 candidate-visible рамок сопоставлены с замороженной Mask R-CNN геометрией на тех же hash-bound кадрах. После NMS и one-to-one snap все 32 кадра просмотрены целиком; ложные фоновые рамки удалены, custom-классы сохранены.",
|
||||
principalResult: `${formatNumber(result.metrics.sourceObjectCount, 0)} исходных рамок → ${formatNumber(result.metrics.objectCount, 0)} после QA: ${formatNumber(counts.car ?? 0, 0)} авто, ${formatNumber(counts.heavy_vehicle ?? 0, 0)} тяжёлых ТС, ${formatNumber(counts.person ?? 0, 0)} людей, ${formatNumber(counts.stroller ?? 0, 0)} коляски, ${formatNumber(counts.laptop ?? 0, 0)} ноутбука и ${formatNumber(counts.bicycle ?? 0, 0)} велосипед.`,
|
||||
limitation: "Наличие объекта и финальное решение всё ещё candidate-visible и AI-assisted. Это инженерная предразметка, не independent truth и не основание считать AP/precision/recall.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: "e46a-object-level-preannotation-qa/v2",
|
||||
components: [
|
||||
{ kind: "source", name: result.sourceE46ResultId, version: "E46 immutable 32-frame source", role: "hash-bound sensor.camera.right frames", identitySha256: result.sourceE46ResultId.split("-").at(-1) ?? null },
|
||||
{ kind: "model", name: "Mask R-CNN geometry candidate", version: "valid-fov-fill frozen", role: "geometry-only snap; no truth authority", identitySha256: null },
|
||||
{ kind: "tool", name: "AI object-level visual audit", version: "all-32/v2", role: "remove false boxes, verify one instance per frame", identitySha256: null },
|
||||
],
|
||||
}}
|
||||
/>}
|
||||
evidence={<LaboratoryEvidence
|
||||
eyebrow="E46A VISUAL EVIDENCE · PREANNOTATION / SOURCE"
|
||||
title="Все 32 кадра открываются с рамками и без них"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<E46AAiEngineeringPreannotationVisual resultId={result.resultId} />
|
||||
</LaboratoryEvidence>}
|
||||
result={<LaboratoryResultSummary
|
||||
title="Очевидные ошибки предразметки сняты; ручная работа оставлена только для спорных случаев"
|
||||
status="Object-level QA complete · independent gate unchanged"
|
||||
statusTone="warning"
|
||||
metrics={[
|
||||
{ label: "Reviewed frames", value: "32/32", hint: "Exact E46 source" },
|
||||
{ label: "Geometry snapped", value: formatNumber(result.metrics.geometrySnappedObjectCount, 0), hint: "Frozen candidate · visually checked" },
|
||||
{ label: "False boxes removed", value: formatNumber(result.metrics.deletedFalseBoxCount, 0), hint: `${formatNumber(result.metrics.sourceObjectCount, 0)} → ${formatNumber(result.metrics.objectCount, 0)} objects` },
|
||||
{ label: "Independent truth", value: "0/2", hint: "E46 Reviewer A/B untouched" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: `На всех 32 кадрах выполнен object-level engineering QA: ${formatNumber(result.metrics.deletedFalseBoxCount, 0)} фоновых/дублирующих рамок удалены, ${formatNumber(result.metrics.geometrySnappedObjectCount, 0)} рамок геометрически уточнены, один SUV исправлен из heavy_vehicle в car.`,
|
||||
notProved: "Не доказаны независимость, correctness неоднозначных дальних объектов, detector AP/precision/recall, движение/статика, live/hardware или safety-пригодность. Количество рамок не является метрикой качества модели.",
|
||||
decision: "Использовать обновлённый E46A как assisted correction/approval. Следующий архитектурный шаг — temporal motion-state для размеченных объектов; независимый benchmark остаётся отдельным Reviewer A/B → adjudication → E48 seal.",
|
||||
}}
|
||||
/>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { E46ACase } from "../../core/laboratory/e46aAiEngineeringPreannotation";
|
||||
|
||||
export type E46ASceneMode = "preannotation" | "source";
|
||||
|
||||
function color(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
alpha = 1,
|
||||
): string {
|
||||
const value = getComputedStyle(host).getPropertyValue(token).trim();
|
||||
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
function objectColor(host: HTMLElement, category: string): string {
|
||||
if (category === "person" || category === "stroller" || category === "laptop") {
|
||||
return color(host, "--nodedc-accent-rgb", [232, 56, 126]);
|
||||
}
|
||||
if (category === "heavy_vehicle") {
|
||||
return color(host, "--nodedc-warning-rgb", [255, 197, 92]);
|
||||
}
|
||||
if (category === "bicycle" || category === "motorcycle") {
|
||||
return color(host, "--nodedc-success-rgb", [181, 255, 90]);
|
||||
}
|
||||
return color(host, "--nodedc-foreground-rgb", [245, 245, 245]);
|
||||
}
|
||||
|
||||
export function E46AAiEngineeringPreannotationScene({
|
||||
frame,
|
||||
mode,
|
||||
}: {
|
||||
frame: E46ACase;
|
||||
mode: E46ASceneMode;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const next = new Image();
|
||||
next.decoding = "async";
|
||||
next.onload = () => { setImage(next); setFailed(false); };
|
||||
next.onerror = () => { setImage(null); setFailed(true); };
|
||||
next.src = frame.cameraUrl;
|
||||
return () => { next.onload = null; next.onerror = null; };
|
||||
}, [frame.cameraUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!host || !canvas || !image) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * pixelRatio);
|
||||
canvas.height = Math.round(height * pixelRatio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6]);
|
||||
context.fillRect(0, 0, width, height);
|
||||
const scale = Math.min(width / 800, height / 600);
|
||||
const drawWidth = 800 * scale;
|
||||
const drawHeight = 600 * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
context.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
|
||||
if (mode === "source") return;
|
||||
frame.objects.forEach((item) => {
|
||||
const [left, top, right, bottom] = item.boxXyxy;
|
||||
const x = offsetX + left * scale;
|
||||
const y = offsetY + top * scale;
|
||||
const boxWidth = (right - left) * scale;
|
||||
const boxHeight = (bottom - top) * scale;
|
||||
const stroke = objectColor(host, item.category);
|
||||
context.strokeStyle = stroke;
|
||||
context.lineWidth = Math.max(1.5, 2 * scale);
|
||||
context.strokeRect(x, y, boxWidth, boxHeight);
|
||||
const label = item.displayCategory;
|
||||
const fontSize = Math.max(9, 10 * scale);
|
||||
context.font = `600 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
const labelWidth = context.measureText(label).width + 8;
|
||||
const labelHeight = fontSize + 6;
|
||||
const labelX = Math.min(offsetX + drawWidth - labelWidth, Math.max(offsetX, x));
|
||||
const labelY = Math.max(offsetY, y - labelHeight);
|
||||
context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9);
|
||||
context.fillRect(labelX, labelY, labelWidth, labelHeight);
|
||||
context.fillStyle = stroke;
|
||||
context.fillText(label, labelX + 4, labelY + fontSize + 1);
|
||||
});
|
||||
};
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [frame, image, mode]);
|
||||
|
||||
return (
|
||||
<div className="l32-camera-scene" ref={hostRef}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label={`E46A frame ${frame.frameIndex}: ${mode}, ${frame.objectCount} preannotations`}
|
||||
/>
|
||||
{failed ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
Точный source-кадр E46A недоступен.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon, IconButton, Select } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
fetchE46ACase,
|
||||
type E46ACase,
|
||||
} from "../../core/laboratory/e46aAiEngineeringPreannotation";
|
||||
import {
|
||||
E46AAiEngineeringPreannotationScene,
|
||||
type E46ASceneMode,
|
||||
} from "./E46AAiEngineeringPreannotationScene";
|
||||
|
||||
const SEQUENCES = Array.from({ length: 32 }, (_, index) => index + 1);
|
||||
|
||||
export function E46AAiEngineeringPreannotationVisual({
|
||||
resultId,
|
||||
}: {
|
||||
resultId: string;
|
||||
}) {
|
||||
const [selectedSequence, setSelectedSequence] = useState(1);
|
||||
const [frame, setFrame] = useState<E46ACase | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<E46ASceneMode>("preannotation");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setFrame(null);
|
||||
void fetchE46ACase(resultId, selectedSequence, { signal: controller.signal })
|
||||
.then((next) => { if (!controller.signal.aborted) setFrame(next); })
|
||||
.catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(caught instanceof Error ? caught.message : "E46A frame недоступен.");
|
||||
}
|
||||
})
|
||||
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
||||
return () => controller.abort();
|
||||
}, [resultId, selectedSequence]);
|
||||
|
||||
const navigate = (offset: -1 | 1) => {
|
||||
setSelectedSequence((current) => ((current - 1 + offset + 32) % 32) + 1);
|
||||
};
|
||||
const actions = (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton label="Предыдущий кадр E46A" onClick={() => navigate(-1)}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Следующий кадр E46A" onClick={() => navigate(1)}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Select
|
||||
label="Выбрать кадр E46A"
|
||||
value={String(selectedSequence)}
|
||||
options={SEQUENCES.map((sequence) => ({
|
||||
value: String(sequence),
|
||||
label: frame?.truthIslandSequence === sequence
|
||||
? `${sequence}/32 · frame ${frame.frameIndex} · ${frame.groupId}`
|
||||
: `${sequence}/32 · E46 source frame`,
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
searchable
|
||||
searchPlaceholder="Найти номер кадра"
|
||||
onChange={(value) => setSelectedSequence(Number(value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const overlay = frame ? (
|
||||
<div className="l3-visual-audit__overlay">
|
||||
<div>
|
||||
<span>RAVNOVES00 · sensor.camera.right</span>
|
||||
<strong>{frame.sessionSeconds.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} с · frame {frame.frameIndex}</strong>
|
||||
<small>Sequence {frame.truthIslandSequence}/32 · {frame.groupId}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>E46A · AI engineering preannotation</span>
|
||||
<strong>{frame.objectCount} объектов · визуально проверено</strong>
|
||||
<small>Not independent · not truth · image {frame.sourceImageSha256.slice(0, 12)}</small>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className="l3-visual-audit">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="E46A AI engineering preannotation and exact source"
|
||||
mode={mode}
|
||||
modes={[
|
||||
{ value: "preannotation", label: "PREANNOTATION" },
|
||||
{ value: "source", label: "SOURCE" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Открываем hash-bound E46A preannotation</span>
|
||||
</div>
|
||||
) : error || !frame ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{error ?? "E46A frame недоступен."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<E46AAiEngineeringPreannotationScene frame={frame} mode={mode} />
|
||||
)}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { LaboratoryEvidence, LaboratoryResultSummary, LaboratorySummary, LaboratoryWorkTemplate } from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { E46BTemporalMotionResult } from "../../core/laboratory/e46bTemporalMotion";
|
||||
import { E46BTemporalMotionVisual } from "./E46BTemporalMotionVisual";
|
||||
|
||||
export function E46BTemporalMotionResultView({ rigLabel, result }: { rigLabel: string; result: E46BTemporalMotionResult }) {
|
||||
const metrics = result.metrics;
|
||||
return <LaboratoryWorkTemplate
|
||||
summary={<LaboratorySummary title="LAB E46B · RIGHT temporal tracks + motion-state" description="Четыре записанных temporal-клипа E46A связаны в устойчивые source-scoped ID. Состояние движения публикуется только при решающем E26 ego-motion/LiDAR evidence; остальные объекты честно остаются unknown." status={`16/16 visual evidence · ${metrics.trackCount} tracks · recorded RIGHT`} statusTone="warning" facts={[{ label: "Конфигурация", value: `${rigLabel} · RIGHT camera · recorded replay` }, { label: "Temporal", value: `${metrics.temporalGroupCount} клипа · ${metrics.frameCount} кадров · ${metrics.objectObservationCount} наблюдений` }, { label: "Motion", value: `${metrics.dynamicTrackCount} dynamic · ${metrics.staticTrackCount} static · ${metrics.unknownTrackCount} unknown` }, { label: "Полномочия", value: "Engineering evidence · no metric velocity · no safety" }]} brief={{ question: "Можно ли превратить отдельные рамки в стабильные объекты и различить движение/статику на записи?", approach: "Внутри каждого фиксированного четырёхкадрового клипа объекты связаны по классу и пространственному порядку. Каждая рамка отдельно сопоставлена с hash-bound E26 KB4 ego-motion/LiDAR fusion на том же source frame; конфликт или отсутствие evidence даёт unknown.", principalResult: `${metrics.trackCount} устойчивых source-scoped ID: ${metrics.dynamicTrackCount} движется, ${metrics.staticTrackCount} стоит, ${metrics.unknownTrackCount} остаются неизвестными. Визуал показывает рамку, ID и накопленный след на всех 16 кадрах.`, limitation: "ID действуют только внутри четырёх коротких клипов. Это не route-global tracking, не метрическая скорость, не truth и не acceptance детектора." }} method={{ completeness: "complete", executionClass: "hybrid", pipelineId: "e46b-source-scoped-temporal-motion/v1", components: [{ kind: "source", name: result.sourceE46AResultId, version: "E46A immutable", role: "object geometry on exact RIGHT frames", identitySha256: result.sourceE46AResultId.split("-").at(-1) ?? null }, { kind: "algorithm", name: "Source-scoped temporal association", version: "category-x-rank/v1", role: "stable IDs within four-frame clips", identitySha256: null }, { kind: "algorithm", name: result.sourceE26ResultId, version: "KB4 ego-motion + LiDAR fusion", role: "conservative dynamic/static/unknown evidence", identitySha256: result.sourceE26ResultId.split("-").at(-1) ?? null }] }} />}
|
||||
evidence={<LaboratoryEvidence eyebrow="E46B VISUAL EVIDENCE · TRACKS / SOURCE" title="Устойчивый ID, след и motion-state на каждом temporal-кадре" kind="diagnostic-model" resizable><E46BTemporalMotionVisual resultId={result.resultId} /></LaboratoryEvidence>}
|
||||
result={<LaboratoryResultSummary title="Объектный слой получил temporal identity и консервативный motion-state" status="Source-scoped temporal layer complete · route-global tracking next" statusTone="warning" metrics={[{ label: "Temporal frames", value: "16/16", hint: "4 fixed clips" }, { label: "Stable IDs", value: String(metrics.trackCount), hint: `${metrics.objectObservationCount} observations` }, { label: "Decisive motion", value: String(metrics.dynamicTrackCount + metrics.staticTrackCount), hint: `${metrics.dynamicTrackCount} dynamic · ${metrics.staticTrackCount} static` }, { label: "Unknown", value: String(metrics.unknownTrackCount), hint: "no guess without evidence" }]} conclusion={{ proved: `На 16 recorded RIGHT кадрах опубликованы ${metrics.trackCount} стабильных ID с визуальными следами; ${metrics.dynamicTrackCount + metrics.staticTrackCount} треков имеют непротиворечивое E26 motion evidence.`, notProved: "Не доказаны route-global identity, метрическая скорость для camera-only объектов, detector AP/recall, live/hardware или safety-пригодность.", decision: "Принять E46B как recorded temporal object layer. Следующий архитектурный шаг — расширить ассоциацию с коротких truth-island клипов на полный recorded replay и затем связать confirmed tracks с temporal occupied/unknown world layer." }} />}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { E46BCase, E46BMotionState } from "../../core/laboratory/e46bTemporalMotion";
|
||||
|
||||
export type E46BSceneMode = "tracks" | "source";
|
||||
function color(host: HTMLElement, token: string, fallback: readonly [number, number, number], alpha = 1): string {
|
||||
const channels = getComputedStyle(host).getPropertyValue(token).trim().match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
function stateColor(host: HTMLElement, state: E46BMotionState): string {
|
||||
return state === "dynamic" ? color(host, "--nodedc-accent-rgb", [232, 56, 126])
|
||||
: state === "static" ? color(host, "--nodedc-success-rgb", [181, 255, 90])
|
||||
: color(host, "--nodedc-warning-rgb", [255, 197, 92]);
|
||||
}
|
||||
const stateLabel = (state: E46BMotionState) => state === "dynamic" ? "движется" : state === "static" ? "стоит" : "неизвестно";
|
||||
|
||||
export function E46BTemporalMotionScene({ frame, mode }: { frame: E46BCase; mode: E46BSceneMode }) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
const next = new Image(); next.decoding = "async";
|
||||
next.onload = () => { setImage(next); setFailed(false); };
|
||||
next.onerror = () => { setImage(null); setFailed(true); };
|
||||
next.src = frame.cameraUrl;
|
||||
return () => { next.onload = null; next.onerror = null; };
|
||||
}, [frame.cameraUrl]);
|
||||
useEffect(() => {
|
||||
const host = hostRef.current; const canvas = canvasRef.current;
|
||||
if (!host || !canvas || !image) return;
|
||||
const context = canvas.getContext("2d"); if (!context) return;
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1); const height = Math.max(host.clientHeight, 1);
|
||||
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * pixelRatio); canvas.height = Math.round(height * pixelRatio);
|
||||
canvas.style.width = `${width}px`; canvas.style.height = `${height}px`;
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6]); context.fillRect(0, 0, width, height);
|
||||
const scale = Math.min(width / 800, height / 600); const drawWidth = 800 * scale; const drawHeight = 600 * scale;
|
||||
const offsetX = (width - drawWidth) / 2; const offsetY = (height - drawHeight) / 2;
|
||||
context.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
|
||||
if (mode === "source") return;
|
||||
frame.objects.forEach((item) => {
|
||||
const stroke = stateColor(host, item.motionState);
|
||||
if (item.trailCentersXy.length > 1) {
|
||||
context.beginPath(); context.strokeStyle = stroke; context.lineWidth = Math.max(1.5, 2 * scale);
|
||||
item.trailCentersXy.forEach(([px, py], index) => index === 0 ? context.moveTo(offsetX + px * scale, offsetY + py * scale) : context.lineTo(offsetX + px * scale, offsetY + py * scale));
|
||||
context.stroke();
|
||||
item.trailCentersXy.forEach(([px, py]) => { context.beginPath(); context.fillStyle = stroke; context.arc(offsetX + px * scale, offsetY + py * scale, Math.max(2, 3 * scale), 0, Math.PI * 2); context.fill(); });
|
||||
}
|
||||
const [left, top, right, bottom] = item.boxXyxy; const x = offsetX + left * scale; const y = offsetY + top * scale;
|
||||
context.strokeStyle = stroke; context.lineWidth = Math.max(1.5, 2 * scale); context.strokeRect(x, y, (right - left) * scale, (bottom - top) * scale);
|
||||
const label = `#${String(item.trackIndex).padStart(2, "0")} · ${item.displayCategory} · ${stateLabel(item.motionState)}`;
|
||||
const fontSize = Math.max(9, 10 * scale); context.font = `600 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
const labelWidth = context.measureText(label).width + 8; const labelHeight = fontSize + 6;
|
||||
const labelX = Math.min(offsetX + drawWidth - labelWidth, Math.max(offsetX, x)); const labelY = Math.max(offsetY, y - labelHeight);
|
||||
context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9); context.fillRect(labelX, labelY, labelWidth, labelHeight);
|
||||
context.fillStyle = stroke; context.fillText(label, labelX + 4, labelY + fontSize + 1);
|
||||
});
|
||||
};
|
||||
const observer = new ResizeObserver(render); observer.observe(host); render(); return () => observer.disconnect();
|
||||
}, [frame, image, mode]);
|
||||
return <div className="l32-camera-scene" ref={hostRef}><canvas ref={canvasRef} role="img" aria-label={`E46B frame ${frame.frameIndex}: ${frame.objectCount} temporal tracks`} />{failed ? <div className="l3-visual-audit__state" role="status">Точный source-кадр E46B недоступен.</div> : null}</div>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon, IconButton, Select } from "@nodedc/ui-react";
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import { fetchE46BCase, type E46BCase } from "../../core/laboratory/e46bTemporalMotion";
|
||||
import { E46BTemporalMotionScene, type E46BSceneMode } from "./E46BTemporalMotionScene";
|
||||
|
||||
const SEQUENCES = [5, 6, 7, 8, 14, 15, 16, 17, 23, 24, 25, 26, 29, 30, 31, 32] as const;
|
||||
export function E46BTemporalMotionVisual({ resultId }: { resultId: string }) {
|
||||
const [selectedSequence, setSelectedSequence] = useState<number>(5); const [frame, setFrame] = useState<E46BCase | null>(null);
|
||||
const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<E46BSceneMode>("tracks"); const [expanded, setExpanded] = useState(false);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController(); setLoading(true); setError(null); setFrame(null);
|
||||
void fetchE46BCase(resultId, selectedSequence, { signal: controller.signal }).then((next) => { if (!controller.signal.aborted) setFrame(next); }).catch((caught: unknown) => { if (!controller.signal.aborted) setError(caught instanceof Error ? caught.message : "E46B frame недоступен."); }).finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
||||
return () => controller.abort();
|
||||
}, [resultId, selectedSequence]);
|
||||
const navigate = (offset: -1 | 1) => { const index = SEQUENCES.indexOf(selectedSequence as typeof SEQUENCES[number]); setSelectedSequence(SEQUENCES[(index + offset + SEQUENCES.length) % SEQUENCES.length]); };
|
||||
const actions = <div className="l3-visual-audit__actions"><div className="l3-visual-audit__pagination"><IconButton label="Предыдущий temporal кадр" onClick={() => navigate(-1)}><Icon name="chevron-left" size={16} /></IconButton><IconButton label="Следующий temporal кадр" onClick={() => navigate(1)}><Icon name="chevron-right" size={16} /></IconButton></div><Select label="Выбрать temporal кадр" value={String(selectedSequence)} options={SEQUENCES.map((sequence, index) => ({ value: String(sequence), label: `${index + 1}/16 · E46 seq ${sequence}${frame?.truthIslandSequence === sequence ? ` · frame ${frame.frameIndex}` : ""}` }))} variant="split" menuWidth="anchor" onChange={(value) => setSelectedSequence(Number(value))} /></div>;
|
||||
const overlay = frame ? <div className="l3-visual-audit__overlay"><div><span>RAVNOVES00 · sensor.camera.right</span><strong>{frame.groupId} · frame {frame.frameIndex}</strong><small>Sequence {frame.truthIslandSequence} · {frame.sessionSeconds.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} с</small></div><div><span>E46B · temporal tracks</span><strong>{frame.objectCount} ID · {frame.motionCounts.dynamic} движется · {frame.motionCounts.static} стоит</strong><small>{frame.motionCounts.unknown} неизвестно · image {frame.sourceImageSha256.slice(0, 12)}</small></div></div> : undefined;
|
||||
return <div className="l3-visual-audit"><LaboratoryEvidenceViewer label="E46B temporal tracks and exact source" mode={mode} modes={[{ value: "tracks", label: "TRACKS" }, { value: "source", label: "SOURCE" }]} expanded={expanded} onModeChange={setMode} onExpandedChange={setExpanded} actions={actions} overlay={overlay}>{loading ? <div className="l3-visual-audit__state" role="status"><span className="busy-indicator" aria-hidden="true" /><span>Открываем E46B temporal evidence</span></div> : error || !frame ? <div className="l3-visual-audit__state" role="status"><Icon name="alert" size={18} /><span>{error ?? "E46B frame недоступен."}</span></div> : <E46BTemporalMotionScene frame={frame} mode={mode} />}</LaboratoryEvidenceViewer></div>;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { E46BlindReviewResult } from "../../core/laboratory/e46BlindReview";
|
||||
import { E46BlindReviewVisual } from "./E46BlindReviewVisual";
|
||||
|
||||
export function E46BlindReviewResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: E46BlindReviewResult;
|
||||
}) {
|
||||
const metrics = result.metrics;
|
||||
const complete = result.decision.twoIndependentReviewsComplete;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={<LaboratorySummary
|
||||
title="LAB E46 · independent source-only detector review"
|
||||
description="Два раздельных reviewer-слота получают те же 32 immutable кадра RAVNOVES00 без identity YOLOX-кандидата, predictions, scores, prelabels и разметки другого рецензента."
|
||||
status={complete ? "2/2 frozen · нужна adjudication" : `${metrics.completedSubmissionCount}/2 independent reviews frozen`}
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · RIGHT camera · recorded replay` },
|
||||
{ label: "Источник", value: `${metrics.frameCount} кадров · ${metrics.anchorCount} anchors + ${metrics.temporalFrameCount} temporal` },
|
||||
{ label: "Reviewer slots", value: `${metrics.reviewSessionCount}/2 созданы · ${metrics.completedSubmissionCount}/2 frozen` },
|
||||
{ label: "Полномочия", value: "Not truth · E48 unsealed · L3.5 closed" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Как собрать две реально независимые разметки E46, не подсказывая людям результат модели или первого reviewer?",
|
||||
approach: "Каждый рецензент получает приватный capability-bound слот, фиксированную семиклассовую taxonomy и 32 exact source frame. Чужой слот сервер не раскрывает; freeze разрешён только после 32/32 и трёх явных attestations о независимости и model blindness.",
|
||||
principalResult: `Контур готов; заморожено ${metrics.completedSubmissionCount} из 2 обязательных review. До второго независимого freeze никаких AP/recall или выводов о качестве детектора не существует.`,
|
||||
limitation: "Интерфейс и криптографическая привязка источника контролируют данные, но утверждение о том, что reviewer физически не видел модель вне системы, остаётся человеческой аттестацией.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "deterministic",
|
||||
pipelineId: "e46-source-only-independent-review/v1",
|
||||
components: [
|
||||
{ kind: "source", name: result.resultId, version: "E46 immutable references", role: "32 hash-bound right-camera frames", identitySha256: result.resultId.split("-").at(-1) ?? null },
|
||||
{ kind: "algorithm", name: "Capability-separated reviewer slots", version: "2-slot/v1", role: "prevent cross-review label access", identitySha256: null },
|
||||
{ kind: "tool", name: "Fullscreen canonical box editor", version: "source-only", role: "manual move, resize, add, delete and classify", identitySha256: null },
|
||||
],
|
||||
}}
|
||||
/>}
|
||||
evidence={<LaboratoryEvidence
|
||||
eyebrow="E46 VISUAL SOURCE · CANDIDATE IDENTITY EXCLUDED"
|
||||
title="Все 32 кадра доступны как source-only evidence"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<E46BlindReviewVisual resultId={result.resultId} />
|
||||
</LaboratoryEvidence>}
|
||||
result={<LaboratoryResultSummary
|
||||
title={complete
|
||||
? "Два независимых входа собраны; seal всё ещё закрыт до adjudication"
|
||||
: "Контур готов; независимые человеческие проходы ещё не собраны"}
|
||||
status={complete ? "Adjudication required before E48" : "Reviewer A + Reviewer B required"}
|
||||
statusTone="warning"
|
||||
metrics={[
|
||||
{ label: "Source frames", value: "32/32", hint: "16 anchors · 4 temporal groups" },
|
||||
{ label: "Reviewer slots", value: `${metrics.reviewSessionCount}/2`, hint: "Capability separated" },
|
||||
{ label: "Frozen inputs", value: `${metrics.completedSubmissionCount}/2`, hint: "E48 inputs · not truth" },
|
||||
{ label: "Model material", value: "0", hint: "No candidate id, boxes, scores or prelabels" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "Источник для независимого контроля воспроизводим, визуально доступен и отделён от model material и чужих reviewer labels. Сервер не примет неполный, assisted или произвольный-class проход.",
|
||||
notProved: "Пока не доказаны correctness разметки, межэкспертное согласие, качество YOLOX, AP/recall, metric-grade truth или пригодность системы к live/hardware/safety.",
|
||||
decision: complete
|
||||
? "Выполнить отдельную adjudication двух immutable reviews; только затем разрешить E48 truth seal и один acceptance-прогон L3.5."
|
||||
: "Передать Reviewer A и Reviewer B их отдельные слоты. Не показывать им L3.4/L3.4D/L3.4F до freeze собственного прохода.",
|
||||
}}
|
||||
/>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon, IconButton, Select } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
fetchL34AnnotationSourceCatalog,
|
||||
fetchL34AnnotationSourceFrame,
|
||||
type L34AnnotationSourceCatalog,
|
||||
type L34AnnotationSourceFrame,
|
||||
} from "../../core/laboratory/l34Annotation";
|
||||
|
||||
function frameLabel(frame: L34AnnotationSourceCatalog["frames"][number]): string {
|
||||
return `${frame.truthIslandSequence}/32 · frame ${frame.frameIndex} · ${frame.groupId}`;
|
||||
}
|
||||
|
||||
export function E46BlindReviewVisual({ resultId }: { resultId: string }) {
|
||||
const [catalog, setCatalog] = useState<L34AnnotationSourceCatalog | null>(null);
|
||||
const [selectedSequence, setSelectedSequence] = useState(1);
|
||||
const [frame, setFrame] = useState<L34AnnotationSourceFrame | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchL34AnnotationSourceCatalog(resultId, {
|
||||
signal: controller.signal,
|
||||
}).then((next) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setCatalog(next);
|
||||
setSelectedSequence(next.frames[0]?.truthIslandSequence ?? 1);
|
||||
}).catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(caught instanceof Error ? caught.message : "E46 source недоступен.");
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!catalog) return;
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setFrame(null);
|
||||
void fetchL34AnnotationSourceFrame(resultId, selectedSequence, {
|
||||
signal: controller.signal,
|
||||
}).then((next) => {
|
||||
if (!controller.signal.aborted) setFrame(next);
|
||||
}).catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(caught instanceof Error ? caught.message : "E46 frame недоступен.");
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [catalog, resultId, selectedSequence]);
|
||||
|
||||
const selectedIndex = catalog?.frames.findIndex(
|
||||
(item) => item.truthIslandSequence === selectedSequence,
|
||||
) ?? -1;
|
||||
const navigate = (offset: -1 | 1) => {
|
||||
if (!catalog || selectedIndex < 0) return;
|
||||
const next = (
|
||||
selectedIndex + offset + catalog.frames.length
|
||||
) % catalog.frames.length;
|
||||
setSelectedSequence(catalog.frames[next].truthIslandSequence);
|
||||
};
|
||||
const actions = catalog ? (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton label="Предыдущий source-only кадр E46" onClick={() => navigate(-1)}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Следующий source-only кадр E46" onClick={() => navigate(1)}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Select
|
||||
label="Выбрать source-only кадр E46"
|
||||
value={String(selectedSequence)}
|
||||
options={catalog.frames.map((item) => ({
|
||||
value: String(item.truthIslandSequence),
|
||||
label: frameLabel(item),
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
searchable
|
||||
searchPlaceholder="Найти кадр или группу"
|
||||
onChange={(value) => setSelectedSequence(Number(value))}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
const overlay = frame ? (
|
||||
<div className="l3-visual-audit__overlay">
|
||||
<div>
|
||||
<span>RAVNOVES00 · sensor.camera.right</span>
|
||||
<strong>{frame.sessionSeconds.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} с · frame {frame.frameIndex}</strong>
|
||||
<small>Sequence {frame.truthIslandSequence}/32 · {frame.groupId}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>E46 independent review source</span>
|
||||
<strong>0 boxes · 0 labels · 0 scores</strong>
|
||||
<small>Candidate identity не включена · image {frame.cameraSha256.slice(0, 12)}</small>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className="l3-visual-audit">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="E46 source-only independent reviewer evidence"
|
||||
mode="source"
|
||||
modes={[{ value: "source", label: "SOURCE ONLY" }]}
|
||||
expanded={expanded}
|
||||
onModeChange={() => undefined}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Открываем hash-bound E46 source без model material</span>
|
||||
</div>
|
||||
) : error || !frame ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{error ?? "E46 source frame недоступен."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="l32-camera-scene">
|
||||
<img
|
||||
src={frame.cameraUrl}
|
||||
alt={`E46 source-only кадр ${frame.frameIndex} без model overlay`}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { E46CCase, E46CMotionState } from "../../core/laboratory/e46cFullReplayWorldTracks";
|
||||
|
||||
export type E46CSceneMode = "camera" | "world";
|
||||
function color(host: HTMLElement, token: string, fallback: readonly [number, number, number], alpha = 1): string { const channels = getComputedStyle(host).getPropertyValue(token).trim().match(/[\d.]+/g)?.slice(0, 3).map(Number); const [red, green, blue] = channels?.length === 3 ? channels : fallback; return `rgba(${red}, ${green}, ${blue}, ${alpha})`; }
|
||||
function stateColor(host: HTMLElement, state: E46CMotionState): string { return state === "dynamic" ? color(host, "--nodedc-accent-rgb", [232, 56, 126]) : state === "static" ? color(host, "--nodedc-success-rgb", [181, 255, 90]) : color(host, "--nodedc-warning-rgb", [255, 197, 92]); }
|
||||
const stateLabel = (state: E46CMotionState) => state === "dynamic" ? "движется" : state === "static" ? "стоит" : "unknown";
|
||||
|
||||
export function E46CFullReplayWorldTracksScene({ frame, mode }: { frame: E46CCase; mode: E46CSceneMode }) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null); const [image, setImage] = useState<HTMLImageElement | null>(null); const [failed, setFailed] = useState(false);
|
||||
useEffect(() => { const next = new Image(); next.decoding = "async"; next.onload = () => { setImage(next); setFailed(false); }; next.onerror = () => { setImage(null); setFailed(true); }; next.src = frame.cameraUrl; return () => { next.onload = null; next.onerror = null; }; }, [frame.cameraUrl]);
|
||||
useEffect(() => {
|
||||
const host = hostRef.current; const canvas = canvasRef.current; if (!host || !canvas || (mode === "camera" && !image)) return; const context = canvas.getContext("2d"); if (!context) return;
|
||||
const render = () => { const width = Math.max(host.clientWidth, 1); const height = Math.max(host.clientHeight, 1); const pixelRatio = Math.min(window.devicePixelRatio, 1.5); canvas.width = Math.round(width * pixelRatio); canvas.height = Math.round(height * pixelRatio); canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6]); context.fillRect(0, 0, width, height);
|
||||
if (mode === "camera" && image) { const scale = Math.min(width / 800, height / 600); const drawWidth = 800 * scale; const drawHeight = 600 * scale; const offsetX = (width - drawWidth) / 2; const offsetY = (height - drawHeight) / 2; context.drawImage(image, offsetX, offsetY, drawWidth, drawHeight); frame.objects.forEach((item) => { const [left, top, right, bottom] = item.boxXyxy; const x = offsetX + left * scale; const y = offsetY + top * scale; const stroke = stateColor(host, item.motionState); context.strokeStyle = stroke; context.lineWidth = Math.max(1.5, 2 * scale); context.strokeRect(x, y, (right - left) * scale, (bottom - top) * scale); const identity = item.routeTrackId === null ? "S—" : `S${item.routeTrackId}${item.worldTrackId === null ? "" : `→W${item.worldTrackId}`}`; const label = `${identity} · ${item.displayCategory} · ${stateLabel(item.motionState)}`; const fontSize = Math.max(9, 10 * scale); context.font = `600 ${fontSize}px Inter, system-ui, sans-serif`; const labelWidth = context.measureText(label).width + 8; const labelHeight = fontSize + 6; const labelX = Math.min(offsetX + drawWidth - labelWidth, Math.max(offsetX, x)); const labelY = Math.max(offsetY, y - labelHeight); context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9); context.fillRect(labelX, labelY, labelWidth, labelHeight); context.fillStyle = stroke; context.fillText(label, labelX + 4, labelY + fontSize + 1); }); return; }
|
||||
const footprints = frame.worldObjects.flatMap((item) => item.occupancyFootprintMapXy); if (!footprints.length) { context.fillStyle = color(host, "--nodedc-foreground-rgb", [245, 245, 245], 0.64); context.font = "600 14px Inter, system-ui, sans-serif"; context.textAlign = "center"; context.fillText("На этом sample-кадре world-state отсутствует", width / 2, height / 2); context.textAlign = "start"; return; }
|
||||
const xs = footprints.map((point) => point[0]); const ys = footprints.map((point) => point[1]); const minX = Math.min(...xs); const maxX = Math.max(...xs); const minY = Math.min(...ys); const maxY = Math.max(...ys); const padding = 56; const spanX = Math.max(maxX - minX, 8); const spanY = Math.max(maxY - minY, 8); const scale = Math.min((width - padding * 2) / spanX, (height - padding * 2) / spanY); const project = ([x, y]: readonly [number, number]) => [padding + (x - minX) * scale, height - padding - (y - minY) * scale] as const;
|
||||
context.strokeStyle = color(host, "--nodedc-foreground-rgb", [245, 245, 245], 0.1); context.lineWidth = 1; for (let gx = Math.floor(minX / 5) * 5; gx <= maxX; gx += 5) { const [x] = project([gx, minY]); context.beginPath(); context.moveTo(x, padding); context.lineTo(x, height - padding); context.stroke(); } for (let gy = Math.floor(minY / 5) * 5; gy <= maxY; gy += 5) { const [, y] = project([minX, gy]); context.beginPath(); context.moveTo(padding, y); context.lineTo(width - padding, y); context.stroke(); }
|
||||
frame.worldObjects.forEach((item) => { const stroke = stateColor(host, item.motionState); const projected = item.occupancyFootprintMapXy.map(project); context.beginPath(); projected.forEach(([x, y], index) => index === 0 ? context.moveTo(x, y) : context.lineTo(x, y)); context.closePath(); context.fillStyle = item.occupancyEvidenceCurrent ? stroke.replace(", 1)", ", 0.28)") : stroke.replace(", 1)", ", 0.12)"); context.fill(); context.strokeStyle = stroke; context.setLineDash(item.occupancyEvidenceCurrent ? [] : [5, 4]); context.lineWidth = 2; context.stroke(); context.setLineDash([]); const [labelX, labelY] = project([item.positionMapM[0], item.positionMapM[1]]); const label = `S${item.routeTrackId}→W${item.worldTrackId} · ${stateLabel(item.motionState)} · ${item.occupancyEvidenceCurrent ? "current" : "held"}`; context.font = "600 11px Inter, system-ui, sans-serif"; const labelWidth = context.measureText(label).width + 8; const safeLabelX = Math.min(width - labelWidth - 18, Math.max(18, labelX + 5)); const safeLabelY = Math.min(height - 42, Math.max(22, labelY - 16)); context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.88); context.fillRect(safeLabelX, safeLabelY, labelWidth, 18); context.fillStyle = stroke; context.fillText(label, safeLabelX + 4, safeLabelY + 13); });
|
||||
context.fillStyle = color(host, "--nodedc-foreground-rgb", [245, 245, 245], 0.58); context.font = "600 11px Inter, system-ui, sans-serif"; context.fillText("world map · occupied footprints · solid=current · dashed=held", 18, height - 18);
|
||||
}; const observer = new ResizeObserver(render); observer.observe(host); render(); return () => observer.disconnect();
|
||||
}, [frame, image, mode]);
|
||||
return <div className="l32-camera-scene" ref={hostRef}><canvas ref={canvasRef} role="img" aria-label={`E46C frame ${frame.frameIndex}: ${mode}, ${frame.objectCount} camera objects, ${frame.worldObjectCount} world objects`} />{failed && mode === "camera" ? <div className="l3-visual-audit__state" role="status">Точный source-кадр E46C недоступен.</div> : null}</div>;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon, IconButton, Select } from "@nodedc/ui-react";
|
||||
|
||||
import type { RecordedObservationPlayback } from "../../components/RecordedFmp4Player";
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
fetchE46CCase,
|
||||
fetchE46CVideoOverlay,
|
||||
selectE46CVideoFrame,
|
||||
type E46CCase,
|
||||
type E46CVideoOverlay,
|
||||
} from "../../core/laboratory/e46cFullReplayWorldTracks";
|
||||
import { recordedObservationSources } from "../../core/observation/recordedObservationSources";
|
||||
import { replayObservationSession } from "../../core/observation/sessionArchive";
|
||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||
import { E46CRecordedVideoScene } from "./E46CRecordedVideoScene";
|
||||
import {
|
||||
E46CFullReplayWorldTracksScene,
|
||||
type E46CSceneMode,
|
||||
} from "./E46CFullReplayWorldTracksScene";
|
||||
|
||||
const SEQUENCES = Array.from({ length: 32 }, (_, index) => index + 1);
|
||||
type E46CViewMode = "video" | E46CSceneMode;
|
||||
|
||||
export interface E46CTemporalAuditWindow {
|
||||
id: string;
|
||||
label: string;
|
||||
startSeconds: number;
|
||||
endSeconds: number;
|
||||
title: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
function reviewWindowLabel(
|
||||
window: E46CVideoOverlay["reviewWindows"][number],
|
||||
): string {
|
||||
const kind = window.kind === "static-control" ? "статика" : "динамика";
|
||||
const group = window.classGroup === "vehicle"
|
||||
? "транспорт"
|
||||
: window.classGroup === "person"
|
||||
? "люди"
|
||||
: window.classGroup === "vulnerable_road_user"
|
||||
? "люди / велосипеды"
|
||||
: window.classGroup;
|
||||
const tracks = window.targetSourceTrackIds.length
|
||||
? ` · S${window.targetSourceTrackIds.join(", S")}`
|
||||
: "";
|
||||
return `${window.startSeconds.toFixed(0)}–${window.endSeconds.toFixed(0)} с · ${kind} · ${group}${tracks}`;
|
||||
}
|
||||
|
||||
export function E46CFullReplayWorldTracksVisual({
|
||||
resultId,
|
||||
auditWindows,
|
||||
}: {
|
||||
resultId: string;
|
||||
auditWindows?: readonly E46CTemporalAuditWindow[];
|
||||
}) {
|
||||
const [selectedSequence, setSelectedSequence] = useState(1);
|
||||
const [frame, setFrame] = useState<E46CCase | null>(null);
|
||||
const [sampleLoading, setSampleLoading] = useState(true);
|
||||
const [sampleError, setSampleError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<E46CViewMode>("video");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [videoOverlay, setVideoOverlay] = useState<E46CVideoOverlay | null>(null);
|
||||
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
|
||||
const [videoLoading, setVideoLoading] = useState(false);
|
||||
const [videoError, setVideoError] = useState<string | null>(null);
|
||||
const [selectedWindow, setSelectedWindow] = useState(auditWindows?.[0]?.id ?? "");
|
||||
const [videoPlayback, setVideoPlayback] = useState<RecordedObservationPlayback>({
|
||||
currentSeconds: 0,
|
||||
playing: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setSampleLoading(true);
|
||||
setSampleError(null);
|
||||
setFrame(null);
|
||||
void fetchE46CCase(resultId, selectedSequence, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (!controller.signal.aborted) setFrame(next);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setSampleError(caught instanceof Error ? caught.message : "E46C frame недоступен.");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setSampleLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [resultId, selectedSequence]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "video" || (videoOverlay && videoSource)) return;
|
||||
const controller = new AbortController();
|
||||
setVideoLoading(true);
|
||||
setVideoError(null);
|
||||
void (async () => {
|
||||
const overlay = await fetchE46CVideoOverlay(resultId, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const replay = await replayObservationSession(overlay.recordedSourceSessionId, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (replay.kind !== "ready") {
|
||||
throw new Error("Физический RIGHT-архив ещё готовится к воспроизведению.");
|
||||
}
|
||||
const source = recordedObservationSources(replay.launch).find(
|
||||
(candidate) =>
|
||||
candidate.modality === "video" &&
|
||||
candidate.semanticChannelId === "camera.video.recorded",
|
||||
);
|
||||
const delivery = source?.delivery?.kind === "recorded-fmp4-manifest"
|
||||
? source.delivery
|
||||
: null;
|
||||
if (
|
||||
!source ||
|
||||
!delivery ||
|
||||
delivery.timelineStartSeconds !== overlay.timelineStartSeconds ||
|
||||
delivery.timelineEndSeconds < overlay.timelineEndSeconds
|
||||
) {
|
||||
throw new Error("RIGHT-видео не совпало с временным контрактом E46C.");
|
||||
}
|
||||
if (controller.signal.aborted) return;
|
||||
setVideoOverlay(overlay);
|
||||
setVideoSource(source);
|
||||
const initialWindow = auditWindows?.find((item) => item.id === selectedWindow);
|
||||
setVideoPlayback({
|
||||
currentSeconds: initialWindow?.startSeconds ?? overlay.timelineStartSeconds,
|
||||
playing: false,
|
||||
});
|
||||
})()
|
||||
.catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setVideoError(
|
||||
caught instanceof Error ? caught.message : "E46C video replay недоступен.",
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setVideoLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [auditWindows, mode, resultId, selectedWindow, videoOverlay, videoSource]);
|
||||
|
||||
const activeVideoFrame = useMemo(
|
||||
() =>
|
||||
videoOverlay
|
||||
? selectE46CVideoFrame(videoOverlay.frames, videoPlayback.currentSeconds)
|
||||
: null,
|
||||
[videoOverlay, videoPlayback.currentSeconds],
|
||||
);
|
||||
const activeAuditWindow = auditWindows?.find((item) => item.id === selectedWindow) ?? null;
|
||||
const navigate = (offset: -1 | 1) => {
|
||||
setSelectedSequence((current) => ((current - 1 + offset + 32) % 32) + 1);
|
||||
};
|
||||
const seekVideo = (seconds: number) => {
|
||||
if (!videoOverlay) return;
|
||||
setVideoPlayback({
|
||||
currentSeconds: Math.min(
|
||||
videoOverlay.timelineEndSeconds,
|
||||
Math.max(videoOverlay.timelineStartSeconds, seconds),
|
||||
),
|
||||
playing: false,
|
||||
});
|
||||
};
|
||||
const actions = mode === "video" ? (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton
|
||||
label="Назад на 5 секунд"
|
||||
onClick={() => seekVideo(videoPlayback.currentSeconds - 5)}
|
||||
>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="Вперёд на 5 секунд"
|
||||
onClick={() => seekVideo(videoPlayback.currentSeconds + 5)}
|
||||
>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{auditWindows?.length || videoOverlay?.reviewWindows.length ? (
|
||||
<Select
|
||||
label={auditWindows?.length
|
||||
? "Перейти к автоматически найденному эпизоду E46D"
|
||||
: "Перейти к проверочному окну E26"}
|
||||
value={selectedWindow || "full-route"}
|
||||
options={[
|
||||
{ value: "full-route", label: "Весь проход · с начала" },
|
||||
...(auditWindows?.map((window) => ({
|
||||
value: window.id,
|
||||
label: window.label,
|
||||
})) ?? videoOverlay?.reviewWindows.map((window) => ({
|
||||
value: window.id,
|
||||
label: reviewWindowLabel(window),
|
||||
})) ?? []),
|
||||
]}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
searchable
|
||||
searchPlaceholder="Найти окно"
|
||||
onChange={(value) => {
|
||||
if (!videoOverlay) return;
|
||||
setSelectedWindow(value);
|
||||
if (value === "full-route") {
|
||||
seekVideo(videoOverlay.timelineStartSeconds);
|
||||
return;
|
||||
}
|
||||
const window = auditWindows?.find((item) => item.id === value)
|
||||
?? videoOverlay.reviewWindows.find((item) => item.id === value);
|
||||
if (window) seekVideo(window.startSeconds);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton label="Предыдущий sample E46C" onClick={() => navigate(-1)}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Следующий sample E46C" onClick={() => navigate(1)}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Select
|
||||
label="Выбрать sample E46C"
|
||||
value={String(selectedSequence)}
|
||||
options={SEQUENCES.map((sequence) => ({
|
||||
value: String(sequence),
|
||||
label:
|
||||
frame?.truthIslandSequence === sequence
|
||||
? `${sequence}/32 · frame ${frame.frameIndex} · ${frame.groupId}`
|
||||
: `${sequence}/32 · exact E46 sample`,
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
searchable
|
||||
searchPlaceholder="Найти sample"
|
||||
onChange={(value) => setSelectedSequence(Number(value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const overlay = mode === "video" && videoOverlay ? (
|
||||
<div className="l3-visual-audit__overlay l3-visual-audit__overlay--video">
|
||||
<div>
|
||||
<span>RAVNOVES00 · recorded RIGHT video</span>
|
||||
<strong>
|
||||
+{Math.max(0, videoPlayback.currentSeconds - videoOverlay.timelineStartSeconds).toFixed(1)} с
|
||||
{activeVideoFrame ? ` · frame ${activeVideoFrame.frameIndex}` : ""}
|
||||
</strong>
|
||||
<small>{videoPlayback.playing ? "воспроизведение" : "пауза / seek"}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>E26 · temporal route tracks</span>
|
||||
<strong>
|
||||
{activeVideoFrame?.objects.length ?? 0} boxes
|
||||
{activeVideoFrame?.objects.length
|
||||
? ` · ${activeVideoFrame.objects.map((item) => `S${item.routeTrackId}`).join(", ")}`
|
||||
: " · объектов нет"}
|
||||
</strong>
|
||||
<small>{activeVideoFrame?.fusionState ?? "ожидаем frame binding"}</small>
|
||||
</div>
|
||||
{activeAuditWindow ? (
|
||||
<div>
|
||||
<span>E46D · temporal exception</span>
|
||||
<strong>{activeAuditWindow.title}</strong>
|
||||
<small>{activeAuditWindow.detail}</small>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span>Что проверяем</span>
|
||||
<strong>удержание рамки · смена S-ID · потеря / повторный захват</strong>
|
||||
<small>Цвет: dynamic / static / unknown · это diagnostic, не truth</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : frame ? (
|
||||
<div className="l3-visual-audit__overlay">
|
||||
<div>
|
||||
<span>RAVNOVES00 · full recorded RIGHT</span>
|
||||
<strong>frame {frame.frameIndex} · {frame.groupId}</strong>
|
||||
<small>
|
||||
Sample {frame.truthIslandSequence}/32 · {frame.sessionSeconds.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})} с
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>E46C · route / world binding</span>
|
||||
<strong>
|
||||
{frame.matchedRouteObjectCount}/{frame.objectCount} route-bound · {frame.worldObjectCount} world objects
|
||||
</strong>
|
||||
<small>{frame.fusionState} · image {frame.sourceImageSha256.slice(0, 12)}</small>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
let content;
|
||||
if (mode === "video") {
|
||||
content = videoLoading ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Связываем 4489 temporal frames с RIGHT-видео</span>
|
||||
</div>
|
||||
) : videoError || !videoOverlay || !videoSource ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{videoError ?? "E46C video replay недоступен."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<E46CRecordedVideoScene
|
||||
source={videoSource}
|
||||
overlay={videoOverlay}
|
||||
playback={videoPlayback}
|
||||
onPlaybackChange={setVideoPlayback}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = sampleLoading ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Открываем E46C route/world sample</span>
|
||||
</div>
|
||||
) : sampleError || !frame ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{sampleError ?? "E46C frame недоступен."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<E46CFullReplayWorldTracksScene frame={frame} mode={mode} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="l3-visual-audit">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="E46C full replay temporal video, route and world evidence"
|
||||
mode={mode}
|
||||
modes={[
|
||||
{ value: "video", label: "VIDEO" },
|
||||
{ value: "camera", label: "CAMERA" },
|
||||
{ value: "world", label: "WORLD" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
>
|
||||
{content}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
import {
|
||||
RecordedFmp4Player,
|
||||
type RecordedObservationPlayback,
|
||||
} from "../../components/RecordedFmp4Player";
|
||||
import {
|
||||
selectE46CVideoFrame,
|
||||
type E46CMotionState,
|
||||
type E46CVideoOverlay,
|
||||
} from "../../core/laboratory/e46cFullReplayWorldTracks";
|
||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||
|
||||
function color(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
alpha = 1,
|
||||
): string {
|
||||
const channels = getComputedStyle(host)
|
||||
.getPropertyValue(token)
|
||||
.trim()
|
||||
.match(/[\d.]+/g)
|
||||
?.slice(0, 3)
|
||||
.map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
function stateColor(host: HTMLElement, state: E46CMotionState): string {
|
||||
if (state === "dynamic") return color(host, "--nodedc-accent-rgb", [232, 56, 126]);
|
||||
if (state === "static") return color(host, "--nodedc-success-rgb", [181, 255, 90]);
|
||||
return color(host, "--nodedc-warning-rgb", [255, 197, 92]);
|
||||
}
|
||||
|
||||
function stateLabel(state: E46CMotionState): string {
|
||||
if (state === "dynamic") return "движется";
|
||||
if (state === "static") return "стоит";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function E46CRecordedVideoScene({
|
||||
source,
|
||||
overlay,
|
||||
playback,
|
||||
onPlaybackChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
overlay: E46CVideoOverlay;
|
||||
playback: RecordedObservationPlayback;
|
||||
onPlaybackChange: (playback: RecordedObservationPlayback) => void;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const frame = useMemo(
|
||||
() => selectE46CVideoFrame(overlay.frames, playback.currentSeconds),
|
||||
[overlay.frames, playback.currentSeconds],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!host || !canvas) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * pixelRatio);
|
||||
canvas.height = Math.round(height * pixelRatio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
if (!frame) return;
|
||||
|
||||
const scale = Math.min(
|
||||
width / overlay.imageWidth,
|
||||
height / overlay.imageHeight,
|
||||
);
|
||||
const drawWidth = overlay.imageWidth * scale;
|
||||
const drawHeight = overlay.imageHeight * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
for (const item of frame.objects) {
|
||||
const [left, top, right, bottom] = item.boxXyxy;
|
||||
const x = offsetX + left * scale;
|
||||
const y = offsetY + top * scale;
|
||||
const boxWidth = (right - left) * scale;
|
||||
const boxHeight = (bottom - top) * scale;
|
||||
const stroke = stateColor(host, item.motionState);
|
||||
context.strokeStyle = stroke;
|
||||
context.lineWidth = Math.max(1.5, 2 * scale);
|
||||
context.setLineDash(item.cameraEvidenceCurrent ? [] : [5, 4]);
|
||||
context.strokeRect(x, y, boxWidth, boxHeight);
|
||||
context.setLineDash([]);
|
||||
|
||||
const identity = `S${item.routeTrackId}${
|
||||
item.worldTrackId === null ? "" : `→W${item.worldTrackId}`
|
||||
}`;
|
||||
const label = `${identity} · ${item.displayCategory} · ${stateLabel(
|
||||
item.motionState,
|
||||
)} · ${Math.round(item.score * 100)}%`;
|
||||
const fontSize = Math.max(9, 10 * scale);
|
||||
context.font = `650 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
const labelWidth = context.measureText(label).width + 8;
|
||||
const labelHeight = fontSize + 6;
|
||||
const labelX = Math.min(
|
||||
offsetX + drawWidth - labelWidth,
|
||||
Math.max(offsetX, x),
|
||||
);
|
||||
const labelY = Math.max(offsetY, y - labelHeight);
|
||||
context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9);
|
||||
context.fillRect(labelX, labelY, labelWidth, labelHeight);
|
||||
context.fillStyle = stroke;
|
||||
context.fillText(label, labelX + 4, labelY + fontSize + 1);
|
||||
}
|
||||
};
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [frame, overlay.imageHeight, overlay.imageWidth]);
|
||||
|
||||
return (
|
||||
<div className="e46c-video-scene" ref={hostRef}>
|
||||
<RecordedFmp4Player
|
||||
source={source}
|
||||
playback={playback}
|
||||
interactive
|
||||
prepare
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
/>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label={
|
||||
frame
|
||||
? `E46C video frame ${frame.frameIndex}: ${frame.objects.length} route objects`
|
||||
: "E46C recorded video overlay"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type {
|
||||
E46DReviewClip,
|
||||
E46DTemporalFailureAuditResult,
|
||||
E46DTemporalSignalKind,
|
||||
} from "../../core/laboratory/e46dTemporalFailureAudit";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import {
|
||||
E46CFullReplayWorldTracksVisual,
|
||||
type E46CTemporalAuditWindow,
|
||||
} from "./E46CFullReplayWorldTracksVisual";
|
||||
|
||||
function evidenceNumber(clip: E46DReviewClip, key: string): number | null {
|
||||
const value = clip.evidence[key];
|
||||
return typeof value === "number" ? value : null;
|
||||
}
|
||||
|
||||
function kindTitle(kind: E46DTemporalSignalKind): string {
|
||||
if (kind === "layer-blackout") return "Объектный слой исчез";
|
||||
if (kind === "camera-evidence-hold") return "Tracker держит объект без свежей рамки";
|
||||
if (kind === "route-layer-gap") return "Route-track пропал и вернулся";
|
||||
if (kind === "route-id-rebirth-candidate") return "Похожий объект сменил S-ID";
|
||||
if (kind === "bbox-jump") return "Рамка скачком сменила положение";
|
||||
if (kind === "motion-state-flap") return "Статус движения нестабилен";
|
||||
if (kind === "world-binding-flap") return "Привязка к W-ID нестабильна";
|
||||
return "Всплеск короткоживущих S-ID";
|
||||
}
|
||||
|
||||
function clipTitle(clip: E46DReviewClip): string {
|
||||
const tracks = clip.routeTrackIds.length
|
||||
? ` · S${clip.routeTrackIds.slice(0, 4).join(", S")}${clip.routeTrackIds.length > 4 ? "…" : ""}`
|
||||
: "";
|
||||
if (clip.kind === "layer-blackout") {
|
||||
const before = evidenceNumber(clip, "before_object_count") ?? 0;
|
||||
const after = evidenceNumber(clip, "after_object_count") ?? 0;
|
||||
return `${kindTitle(clip.kind)}: ${before} → 0 → ${after}`;
|
||||
}
|
||||
if (clip.kind === "route-id-rebirth-candidate" && clip.routeTrackIds.length >= 2) {
|
||||
return `${kindTitle(clip.kind)}: S${clip.routeTrackIds[0]} → S${clip.routeTrackIds[1]}`;
|
||||
}
|
||||
return `${kindTitle(clip.kind)}${tracks}`;
|
||||
}
|
||||
|
||||
function clipDetail(clip: E46DReviewClip): string {
|
||||
const duration = Math.max(0, clip.eventEndSeconds - clip.eventStartSeconds);
|
||||
if (clip.kind === "layer-blackout") {
|
||||
return `${evidenceNumber(clip, "zero_frame_count") ?? 0} пустых кадров · ${duration.toFixed(1)} с · published layer, не truth`;
|
||||
}
|
||||
if (clip.kind === "route-layer-gap") {
|
||||
return `${evidenceNumber(clip, "missing_frame_count") ?? 0} кадров без S-ID · тот же ID затем вернулся`;
|
||||
}
|
||||
if (clip.kind === "camera-evidence-hold") {
|
||||
return `${evidenceNumber(clip, "held_frame_count") ?? 0} кадров без current detector evidence`;
|
||||
}
|
||||
if (clip.kind === "route-id-rebirth-candidate" || clip.kind === "bbox-jump") {
|
||||
const overlap = evidenceNumber(clip, "bbox_iou");
|
||||
return `frame ${clip.startFrame}–${clip.endFrame}${overlap === null ? "" : ` · IoU ${overlap.toFixed(2)}`} · нужна визуальная проверка`;
|
||||
}
|
||||
if (clip.kind === "short-track-burst") {
|
||||
return `${evidenceNumber(clip, "short_track_count") ?? clip.routeTrackIds.length} ID с ≤3 наблюдениями за короткое окно`;
|
||||
}
|
||||
return `${evidenceNumber(clip, "transition_count") ?? 0} переключения за ${Math.max(0, duration).toFixed(1)} с`;
|
||||
}
|
||||
|
||||
function auditWindows(
|
||||
clips: readonly E46DReviewClip[],
|
||||
): readonly E46CTemporalAuditWindow[] {
|
||||
return clips.map((clip) => ({
|
||||
id: clip.clipId,
|
||||
label: `#${String(clip.rank).padStart(2, "0")} · ${clip.eventStartSeconds.toFixed(1)} с · ${clipTitle(clip)}`,
|
||||
startSeconds: clip.startSeconds,
|
||||
endSeconds: clip.endSeconds,
|
||||
title: clipTitle(clip),
|
||||
detail: clipDetail(clip),
|
||||
}));
|
||||
}
|
||||
|
||||
export function E46DTemporalFailureAuditResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: E46DTemporalFailureAuditResult;
|
||||
}) {
|
||||
const metrics = result.metrics;
|
||||
const windows = auditWindows(result.reviewClips);
|
||||
const zeroPercent = metrics.zeroObjectFrameFraction * 100;
|
||||
const shortPercent = metrics.shortRouteTrackFraction * 100;
|
||||
const heldPercent = metrics.cameraHeldObservationFraction * 100;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E46D · full replay temporal failure audit"
|
||||
description="Полный recorded RIGHT replay автоматически просканирован на обвалы object-layer, разрывы и перерождение S-ID, скачки рамок, motion-flap и нестабильную W-привязку. Приоритетные эпизоды открываются как видео с точным временным контекстом."
|
||||
status={`Temporal continuity failed · ${metrics.failureSignalCount} signals · ${metrics.reviewClipCount} clips`}
|
||||
statusTone="danger"
|
||||
facts={[
|
||||
{
|
||||
label: "Конфигурация",
|
||||
value: `${rigLabel} · RIGHT camera · recorded replay · E26/E46C frozen`,
|
||||
},
|
||||
{
|
||||
label: "Покрытие",
|
||||
value: `${metrics.routeFrameCount}/4489 кадров · ${formatNumber(metrics.routeSpanSeconds, 1)} с · полный маршрут`,
|
||||
},
|
||||
{
|
||||
label: "Continuity",
|
||||
value: `${metrics.layerBlackoutEpisodeCount} обвалов слоя · ${metrics.routeLayerGapEpisodeCount} route-gap · ${metrics.routeIdRebirthCandidateCount} ID-candidates`,
|
||||
},
|
||||
{
|
||||
label: "Визуал",
|
||||
value: `${metrics.reviewClipCount} автоматически выбранных VIDEO-эпизодов · CAMERA/WORLD доступны в том же viewer`,
|
||||
},
|
||||
]}
|
||||
brief={{
|
||||
question: "Удерживает ли полный recorded perception replay рамки и идентичности во времени без скрытых провалов между удачными статичными кадрами?",
|
||||
approach: "Все 4489 hash-bound E26 fusion-кадров проверены детерминированным temporal scanner. Он не пересчитывает модель: фиксирует только наблюдаемые разрывы опубликованного слоя и связывает их с исходным RIGHT-видео через ту же session-time шкалу.",
|
||||
principalResult: `${metrics.zeroObjectFrameCount} кадров (${zeroPercent.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%) имеют пустой object-layer; найдено ${metrics.layerBlackoutEpisodeCount} обвалов между непустыми кадрами. ${metrics.shortRouteTrackCount} из ${metrics.routeTrackCount} S-ID (${shortPercent.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%) живут не дольше трёх наблюдений.`,
|
||||
limitation: "Это честная диагностика опубликованного temporal layer, а не independent truth. Клип доказывает разрыв данных; семантический false positive или пропуск конкретного физического объекта подтверждается визуально в том же видео.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "deterministic",
|
||||
pipelineId: "e46d-full-replay-temporal-failure-audit/v1",
|
||||
components: [
|
||||
{
|
||||
kind: "source",
|
||||
name: result.sourceE46CResultId,
|
||||
version: "4489-frame full recorded RIGHT replay",
|
||||
role: "video, route-track and world-binding substrate",
|
||||
identitySha256: result.sourceE46CResultId.split("-").at(-1) ?? null,
|
||||
},
|
||||
{
|
||||
kind: "source",
|
||||
name: result.sourceE26ResultId,
|
||||
version: "accepted diagnostic fusion",
|
||||
role: "camera evidence, S-ID, W-ID and motion state",
|
||||
identitySha256: result.sourceE26ResultId.split("-").at(-1) ?? null,
|
||||
},
|
||||
{
|
||||
kind: "algorithm",
|
||||
name: "Deterministic temporal exception scanner",
|
||||
version: "e46d/v1",
|
||||
role: "full-route detection, ranking and clip selection",
|
||||
identitySha256: null,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="E46D VISUAL EVIDENCE · AUTOMATIC TEMPORAL EXCEPTIONS"
|
||||
title="Приоритетные видеоэпизоды: до сбоя, сам разрыв и восстановление"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<E46CFullReplayWorldTracksVisual
|
||||
resultId={result.sourceE46CResultId}
|
||||
auditWindows={windows}
|
||||
/>
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Полный temporal слой не прошёл continuity gate"
|
||||
status="Regression confirmed in published layer · detector/tracker/world causes separated"
|
||||
statusTone="danger"
|
||||
metrics={[
|
||||
{
|
||||
label: "Empty layer",
|
||||
value: `${metrics.zeroObjectFrameCount}/${metrics.routeFrameCount}`,
|
||||
hint: `${zeroPercent.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}% frames · ${metrics.layerBlackoutEpisodeCount} bracketed episodes`,
|
||||
},
|
||||
{
|
||||
label: "Short S-ID",
|
||||
value: `${metrics.shortRouteTrackCount}/${metrics.routeTrackCount}`,
|
||||
hint: `≤3 observations · ${shortPercent.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`,
|
||||
},
|
||||
{
|
||||
label: "Held evidence",
|
||||
value: formatNumber(metrics.cameraHeldObservationCount, 0),
|
||||
hint: `${heldPercent.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}% observations without fresh camera evidence`,
|
||||
},
|
||||
{
|
||||
label: "Identity / world",
|
||||
value: `${metrics.routeIdRebirthCandidateCount} / ${metrics.worldBindingFlapEpisodeCount}`,
|
||||
hint: "S-ID rebirth candidates / W-binding flap episodes",
|
||||
},
|
||||
]}
|
||||
conclusion={{
|
||||
proved: `Полный маршрут учтён без пропуска входных строк, но опубликованный temporal layer нестабилен: ${metrics.layerBlackoutEpisodeCount} раз он полностью обнуляется между непустыми кадрами, ${metrics.routeLayerGapEpisodeCount} раз тот же S-ID исчезает и возвращается, а ${metrics.bboxJumpEpisodeCount} current/current перехода имеют резкий bbox-разрыв.`,
|
||||
notProved: "Без независимой покадровой truth не доказано, что каждый короткий S-ID является false positive, а каждый overlap-кандидат — физический ID-switch. Не доказаны live/hardware, free-space, navigation или safety-пригодность.",
|
||||
decision: "Не продвигать текущий E26/E46C temporal layer. Сначала устранить глобальные object-layer blackouts и route fragmentation, затем стабилизировать W-binding и motion-state и сравнить старый/новый pipeline на том же recorded RIGHT replay.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { E46EReadyStackResult } from "../../core/laboratory/e46eReadyStack";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { E46EReadyStackVisual } from "./E46EReadyStackVisual";
|
||||
|
||||
export function E46EReadyStackResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: E46EReadyStackResult;
|
||||
}) {
|
||||
const metrics = result.metrics;
|
||||
const zeroTrackPercent = (metrics.zeroTrackFrameCount / metrics.frameCount) * 100;
|
||||
const shortPercent = metrics.shortTrackFraction * 100;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E46E · NVIDIA ready-stack bake-off R1"
|
||||
description="Тот же полный recorded RIGHT проход пересчитан готовым NVIDIA-контуром: TrafficCamNet Transformer Lite (RT-DETR ResNet50) выдаёт детекции, штатный NvDCF — временные ID. Наш код только проверяет и публикует результат."
|
||||
status={`Ready stack frozen · ${metrics.frameCount}/4489 · custom tracking 0`}
|
||||
statusTone="accent"
|
||||
facts={[
|
||||
{
|
||||
label: "Конфигурация",
|
||||
value: `${rigLabel} · RIGHT camera · recorded replay · 800×600`,
|
||||
},
|
||||
{
|
||||
label: "Модель",
|
||||
value: "NVIDIA TrafficCamNet Transformer Lite · RT-DETR ResNet50 · FP16",
|
||||
},
|
||||
{
|
||||
label: "Tracker",
|
||||
value: "NVIDIA NvDCF performance profile · штатные ID · без ReID и hold/stitch Mission Core",
|
||||
},
|
||||
{
|
||||
label: "Визуал",
|
||||
value: `полное overlay-видео · ${formatNumber(result.video.byteLength / 1_048_576, 1)} MiB · hash-bound`,
|
||||
},
|
||||
]}
|
||||
brief={{
|
||||
question: "Даст ли готовый NVIDIA detector + tracker более честную и устойчивую основу компьютерного зрения, чем текущий YOLOX-контур с самописной временной склейкой?",
|
||||
approach: "На Worker 006 один раз прогнан весь неизменный RIGHT-архив из 4489 кадров. DeepStream 9.1 работал без сети; RT-DETR и NvDCF не дообучались и не подстраивались под этот маршрут. Для каждого кадра опубликованы проверяемые detector/NvDCF-наблюдения, исходные LTRB-координаты и то же полноразмерное overlay-видео.",
|
||||
principalResult: `${formatNumber(metrics.detectionObservationCount, 0)} detector-наблюдений превратились в ${formatNumber(metrics.trackObservationCount, 0)} NvDCF-наблюдений и ${formatNumber(metrics.uniqueTrackCount, 0)} route-local ID. Tracker пережил ${formatNumber(metrics.trackerRecoveredFrameCount, 0)} кадров без свежей детекции; зафиксировано ${metrics.fullLayerBlackoutEventCount} полных обвалов слоя.`,
|
||||
limitation: "Набор классов ограничен car/person/bicycle/road_sign, а независимой покадровой truth для всего маршрута нет. Поэтому LAB доказывает воспроизводимый ready-stack и его временное поведение, но не заявляет precision/recall, motion-state, navigation или safety.",
|
||||
}}
|
||||
method={result.method}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="E46E VISUAL EVIDENCE · STOCK NVIDIA FULL REPLAY"
|
||||
title="Полный RIGHT-видос: рамки RT-DETR и устойчивость NvDCF ID во времени"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<E46EReadyStackVisual videoUrl={result.video.url} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Готовый NVIDIA-контур зафиксирован как новый сравнительный baseline"
|
||||
status="Full replay available · objective continuity metrics · visual review in the same artifact"
|
||||
statusTone="accent"
|
||||
metrics={[
|
||||
{
|
||||
label: "Detector / NvDCF",
|
||||
value: `${formatNumber(metrics.detectionObservationCount, 0)} / ${formatNumber(metrics.trackObservationCount, 0)}`,
|
||||
hint: `mean ${formatNumber(metrics.meanTrackedObjectsPerFrame, 2)} tracked objects/frame`,
|
||||
},
|
||||
{
|
||||
label: "Empty layer",
|
||||
value: `${metrics.zeroTrackFrameCount}/${metrics.frameCount}`,
|
||||
hint: `${formatNumber(zeroTrackPercent, 1)}% frames · ${metrics.fullLayerBlackoutEventCount} bracketed blackouts`,
|
||||
},
|
||||
{
|
||||
label: "NvDCF recovery",
|
||||
value: formatNumber(metrics.trackerRecoveredFrameCount, 0),
|
||||
hint: "frames with track output and no current detector box",
|
||||
},
|
||||
{
|
||||
label: "Short / gap",
|
||||
value: `${metrics.shortTrackCount} / ${metrics.routeIdGapEventCount}`,
|
||||
hint: `≤3 observations: ${formatNumber(shortPercent, 1)}% · ${metrics.trackBoxClippedCount} display-only box clips`,
|
||||
},
|
||||
]}
|
||||
conclusion={{
|
||||
proved: `Все ${metrics.frameCount} кадров одного immutable RIGHT-маршрута обработаны официальным NVIDIA stack; готовое видео и покадровые ID связаны одной identity. Mission Core не выполнял association, hold, stitch или detector postprocessing; ${metrics.trackBoxClippedCount} выходов рамок за границу только обрезаны для отображения с сохранением raw-координат.`,
|
||||
notProved: "Без независимой full-route truth не доказаны абсолютные precision/recall и корректность каждого ID. Не реализованы классификация dynamic/static, LiDAR-range fusion, live hardware, free-space, commands, navigation или safety.",
|
||||
decision: "Считать E46E готовым A/B baseline и оценивать его по полному видео и тем же continuity-метрикам против E46D. Дальнейшую работу вести через готовые модели/трекеры и адаптеры контрактов, а не через подгонку самописного tracker под один ролик.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
|
||||
export function E46EReadyStackVisual({
|
||||
videoUrl,
|
||||
label = "E46E NVIDIA ready-stack full recorded RIGHT overlay",
|
||||
ariaLabel = "E46E full recorded RIGHT video with NVIDIA RT-DETR detections and NvDCF track IDs",
|
||||
}: {
|
||||
videoUrl: string;
|
||||
label?: string;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return (
|
||||
<div className="l3-visual-audit">
|
||||
<LaboratoryEvidenceViewer
|
||||
label={label}
|
||||
mode="video"
|
||||
modes={[{ value: "video", label: "VIDEO" }]}
|
||||
expanded={expanded}
|
||||
onModeChange={() => undefined}
|
||||
onExpandedChange={setExpanded}
|
||||
>
|
||||
<div className="e46e-ready-stack-video">
|
||||
<video
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
src={videoUrl}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
</div>
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { E46FDashCamBakeoffResult } from "../../core/laboratory/e46fDashCamBakeoff";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { E46EReadyStackVisual } from "./E46EReadyStackVisual";
|
||||
|
||||
export function E46FDashCamBakeoffResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: E46FDashCamBakeoffResult;
|
||||
}) {
|
||||
const metrics = result.metrics;
|
||||
const baseline = result.comparison.baselineMetrics;
|
||||
const triage = result.comparison.largeBoxVisualTriage;
|
||||
const candidateLargePercent = (triage.candidate.frameCount / metrics.frameCount) * 100;
|
||||
const baselineLargePercent = (triage.baseline.frameCount / baseline.frameCount) * 100;
|
||||
const shortPercent = metrics.shortTrackFraction * 100;
|
||||
const baselineShortPercent = baseline.shortTrackFraction * 100;
|
||||
const reviewSeconds = result.comparison.visualReview.sampleVideoSeconds
|
||||
.map((value) => `${formatNumber(value, 1)} c`)
|
||||
.join(" · ");
|
||||
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E46F · NVIDIA mobile-detector bake-off R2"
|
||||
description="Контролируемая замена только детектора: тот же RIGHT-архив, DeepStream 9.1, FP16, NvDCF и 800×600. TrafficCamNet E46E заменён на готовый DashCamNet DetectNet_v2, обученный под движущуюся камеру."
|
||||
status="Rejected · temporal progress, semantic regression on unrectified fisheye"
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{
|
||||
label: "Конфигурация",
|
||||
value: `${rigLabel} · RIGHT recorded replay · detector-only A/B · 4489/4489`,
|
||||
},
|
||||
{
|
||||
label: "Кандидат",
|
||||
value: "NVIDIA DashCamNet · DetectNet_v2 ResNet18 · signed ONNX v1.0.4 · FP16",
|
||||
},
|
||||
{
|
||||
label: "Контроль",
|
||||
value: "NVIDIA NvDCF performance profile · без Mission Core NMS, association, hold или stitch",
|
||||
},
|
||||
{
|
||||
label: "Предквалификация",
|
||||
value: "v1.0.5 отклонён до LAB: на реальных кадрах ненулевым был только один confidence-канал",
|
||||
},
|
||||
]}
|
||||
brief={{
|
||||
question: "Станет ли готовый moving-camera DashCamNet лучшим detector drop-in для нашего сырого fisheye RIGHT-потока, если весь остальной NVIDIA-контур оставить неизменным?",
|
||||
approach: "На Worker 006 весь проход пересчитан при одном контролируемом изменении — TrafficCamNet RT-DETR заменён на DashCamNet DetectNet_v2. Официальные NMS-параметры NVIDIA и точный model hash заморожены. Результат проверен не только числами: опубликован полный overlay-видос и отдельно просмотрены повторяющиеся крупные рамки на краях fisheye.",
|
||||
principalResult: `Continuity немного улучшилась: пустые track-кадры ${metrics.zeroTrackFrameCount} против ${baseline.zeroTrackFrameCount}, blackout ${metrics.fullLayerBlackoutEventCount} против ${baseline.fullLayerBlackoutEventCount}, route-ID ${metrics.uniqueTrackCount} против ${baseline.uniqueTrackCount}. Но крупные рамки ≥20% кадра появились на ${triage.candidate.frameCount} кадрах против ${triage.baseline.frameCount} у E46E; визуально это повторяющиеся person-треки на чёрной кайме и краевой геометрии.`,
|
||||
limitation: "Large-box triage — диагностический указатель для навигации по видео, а не precision/recall. Независимой full-route truth нет; LAB не оценивает motion-state, LiDAR range, free-space, live hardware, navigation или safety.",
|
||||
}}
|
||||
method={result.method}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="E46F VISUAL EVIDENCE · DETECTOR-ONLY FULL-ROUTE A/B"
|
||||
title="Полный RIGHT-видос: DashCamNet + NvDCF и семантическая регрессия на краях fisheye"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<E46EReadyStackVisual
|
||||
videoUrl={result.video.url}
|
||||
label="E46F DashCamNet detector-only full recorded RIGHT overlay"
|
||||
ariaLabel="E46F full recorded RIGHT video with DashCamNet detections and NvDCF track IDs"
|
||||
/>
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="DashCamNet отклонён на сыром fisheye; следующий контракт — rectification перед detector"
|
||||
status={`Visual reject · контрольные эпизоды: ${reviewSeconds}`}
|
||||
statusTone="warning"
|
||||
metrics={[
|
||||
{
|
||||
label: "Empty / blackout",
|
||||
value: `${metrics.zeroTrackFrameCount} / ${metrics.fullLayerBlackoutEventCount}`,
|
||||
hint: `E46E: ${baseline.zeroTrackFrameCount} / ${baseline.fullLayerBlackoutEventCount} · temporal progress`,
|
||||
},
|
||||
{
|
||||
label: "Route ID",
|
||||
value: `${metrics.uniqueTrackCount}`,
|
||||
hint: `E46E: ${baseline.uniqueTrackCount} · short ${formatNumber(shortPercent, 1)}% vs ${formatNumber(baselineShortPercent, 1)}%`,
|
||||
},
|
||||
{
|
||||
label: "Large-box frames",
|
||||
value: `${triage.candidate.frameCount}/${metrics.frameCount}`,
|
||||
hint: `${formatNumber(candidateLargePercent, 1)}% · E46E ${triage.baseline.frameCount} (${formatNumber(baselineLargePercent, 1)}%)`,
|
||||
},
|
||||
{
|
||||
label: "Large-box class",
|
||||
value: Object.keys(triage.candidate.classObservations).join(", "),
|
||||
hint: `${triage.candidate.observationCount} observations · ${triage.candidate.trackIdCount} route IDs`,
|
||||
},
|
||||
]}
|
||||
conclusion={{
|
||||
proved: `Готовый DashCamNet действительно сокращает фрагментацию: ${metrics.uniqueTrackCount} ID вместо ${baseline.uniqueTrackCount}, ${formatNumber(shortPercent, 1)}% коротких треков вместо ${formatNumber(baselineShortPercent, 1)}%. Все ${metrics.frameCount} кадров и полное видео связаны immutable identity; E46F не содержит собственной detector/tracker-логики Mission Core.`,
|
||||
notProved: `Числа continuity не означают корректное зрение. На ${triage.candidate.frameCount} кадрах видны очень крупные person-рамки, регулярно захватывающие чёрную кайму и край fisheye. Поэтому E46F не принят как perception candidate и не получает motion, free-space, navigation или safety authority.`,
|
||||
decision: "Не чинить DashCamNet порогами под этот ролик. Сохранить E46E текущим готовым baseline. Следующий detector bake-off запускать только после одной calibration-derived rectification/valid-FOV projection, одинаковой для всех перспективных моделей.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { E46GRectifiedDetectorBakeoffResult } from "../../core/laboratory/e46gRectifiedDetectorBakeoff";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { E46GRectifiedDetectorBakeoffVisual } from "./E46GRectifiedDetectorBakeoffVisual";
|
||||
|
||||
export function E46GRectifiedDetectorBakeoffResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: E46GRectifiedDetectorBakeoffResult;
|
||||
}) {
|
||||
const traffic = result.metrics.trafficcamnet;
|
||||
const dash = result.metrics.dashcamnet;
|
||||
const trafficFront = traffic.views.front;
|
||||
const dashFront = dash.views.front;
|
||||
const trafficSideLarge = traffic.views.left.largeTrackObservationCount
|
||||
+ traffic.views.right.largeTrackObservationCount;
|
||||
const dashSideLarge = dash.views.left.largeTrackObservationCount
|
||||
+ dash.views.right.largeTrackObservationCount;
|
||||
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E46G · calibrated ready-detector bake-off R3"
|
||||
description="Один физический RIGHT-поток recorded rig, factory KB4 и штатный NVIDIA nvdewarper. TrafficCamNet и DashCamNet пересчитаны на одних 600 кадрах и одних LEFT / FRONT / RIGHT проекциях с неизменным NvDCF."
|
||||
status="Selected for next diagnostic · TrafficCamNet FRONT only"
|
||||
statusTone="accent"
|
||||
facts={[
|
||||
{
|
||||
label: "Конфигурация",
|
||||
value: `${rigLabel} · одна RIGHT-запись · frames ${result.selection.firstSourceFrameIndex}…${result.selection.lastSourceFrameIndex}`,
|
||||
},
|
||||
{
|
||||
label: "Геометрия",
|
||||
value: `Factory camera_1 KB4 · NVIDIA nvdewarper · 3 × ${result.rectification.outputResolution[0]}×${result.rectification.outputResolution[1]} · FOV ${result.rectification.horizontalFovDegrees}°`,
|
||||
},
|
||||
{
|
||||
label: "Кандидаты",
|
||||
value: "TrafficCamNet Transformer Lite RT-DETR vs DashCamNet DetectNet_v2 · FP16",
|
||||
},
|
||||
{
|
||||
label: "Temporal",
|
||||
value: "Stock NVIDIA NvDCF performance profile · identity остаётся view-local",
|
||||
},
|
||||
]}
|
||||
brief={{
|
||||
question: "Какой готовый NVIDIA-детектор лучше переносится на калиброванную перспективную проекцию нашего единственного RIGHT-потока и годится для следующего полного recorded replay?",
|
||||
approach: "На Worker 006 factory KB4 подан в официальный Gst-nvdewarper. Оба детектора получили одни и те же 600 последовательных кадров, три синхронные проекции и один stock NvDCF. Решение принято по двум полным 60-секундным видео, а counts использованы только как навигация по визуалу.",
|
||||
principalResult: `FRONT пригоден: TrafficCamNet удерживает видимые машины и людей заметно полнее — ${trafficFront.trackObservationCount} track-observations и ${trafficFront.zeroTrackFrameCount} пустых track-кадров против ${dashFront.trackObservationCount} и ${dashFront.zeroTrackFrameCount} у DashCamNet. LEFT/RIGHT непригодны: в них доминирует корпус крепления, на котором TrafficCamNet создаёт крупные ложные треки.`,
|
||||
limitation: "Независимой исчерпывающей truth нет. У TrafficCamNet остаются дубли и stale boxes вокруг отдельных машин; E46G выбирает только следующий диагностический full-route кандидат и не принимает perception, motion-state, free-space, navigation или safety.",
|
||||
}}
|
||||
method={result.method}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="E46G VISUAL EVIDENCE · CALIBRATED DETECTOR A/B"
|
||||
title="Два синхронных 60-секундных видео · колонки LEFT / FRONT / RIGHT"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<E46GRectifiedDetectorBakeoffVisual videos={result.videos} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="TrafficCamNet выбран для полного FRONT replay; боковые проекции исключены"
|
||||
status="Diagnostic selection · no perception authority"
|
||||
statusTone="accent"
|
||||
metrics={[
|
||||
{
|
||||
label: "Traffic FRONT",
|
||||
value: `${trafficFront.trackObservationCount} tracks`,
|
||||
hint: `${trafficFront.zeroTrackFrameCount}/600 empty · ${trafficFront.uniqueTrackCount} view-local ID`,
|
||||
},
|
||||
{
|
||||
label: "Dash FRONT",
|
||||
value: `${dashFront.trackObservationCount} tracks`,
|
||||
hint: `${dashFront.zeroTrackFrameCount}/600 empty · ${dashFront.uniqueTrackCount} view-local ID`,
|
||||
},
|
||||
{
|
||||
label: "FRONT large-box",
|
||||
value: `${formatNumber(trafficFront.largeTrackFraction * 100, 2)}% / ${formatNumber(dashFront.largeTrackFraction * 100, 2)}%`,
|
||||
hint: "TrafficCamNet / DashCamNet · диагностический порог ≥20% plane",
|
||||
},
|
||||
{
|
||||
label: "LEFT+RIGHT large",
|
||||
value: `${trafficSideLarge} / ${dashSideLarge}`,
|
||||
hint: "TrafficCamNet / DashCamNet · визуально связано с корпусом крепления",
|
||||
},
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "Factory KB4 и штатный NVIDIA nvdewarper дают читаемую FRONT-перспективу из одной физической RIGHT-камеры. На одинаковом gate TrafficCamNet визуально сохраняет значительно больше реальных транспортных объектов и людей, тогда как DashCamNet часто оставляет видимые объекты без рамок.",
|
||||
notProved: "Counts не являются accuracy. TrafficCamNet ещё дублирует отдельные машины, а LEFT/RIGHT содержат сильную self-occlusion корпуса. E46G не доказывает cross-view identity, динамику/статику, LiDAR range, free-space или работу на другом маршруте.",
|
||||
decision: "Не тюнить модель под ролик и не использовать боковые проекции. E46H считать на полном retained route только для FRONT: готовый TrafficCamNet + stock NvDCF, затем проверить непрерывность всего видео и отдельно разобрать дубли/stale tracks.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import type {
|
||||
E46GCandidate,
|
||||
E46GVideo,
|
||||
} from "../../core/laboratory/e46gRectifiedDetectorBakeoff";
|
||||
|
||||
const MODES = [
|
||||
{ value: "trafficcamnet", label: "TRAFFICCAMNET" },
|
||||
{ value: "dashcamnet", label: "DASHCAMNET" },
|
||||
] as const;
|
||||
|
||||
export function E46GRectifiedDetectorBakeoffVisual({
|
||||
videos,
|
||||
}: {
|
||||
videos: Readonly<Record<E46GCandidate, E46GVideo>>;
|
||||
}) {
|
||||
const [candidate, setCandidate] = useState<E46GCandidate>("trafficcamnet");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const selected = videos[candidate];
|
||||
return (
|
||||
<div className="l3-visual-audit">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="E46G synchronized calibrated detector comparison"
|
||||
mode={candidate}
|
||||
modes={MODES}
|
||||
expanded={expanded}
|
||||
onModeChange={setCandidate}
|
||||
onExpandedChange={setExpanded}
|
||||
>
|
||||
<div className="e46e-ready-stack-video">
|
||||
<video
|
||||
key={selected.url}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
src={selected.url}
|
||||
aria-label={`E46G ${candidate} synchronized left front right comparison video`}
|
||||
/>
|
||||
</div>
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type {
|
||||
E46HFullRectifiedFrontReplayResult,
|
||||
} from "../../core/laboratory/e46hFullRectifiedFrontReplay";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { E46HFullRectifiedFrontReplayVisual } from "./E46HFullRectifiedFrontReplayVisual";
|
||||
|
||||
export function E46HFullRectifiedFrontReplayResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: E46HFullRectifiedFrontReplayResult;
|
||||
}) {
|
||||
const metrics = result.metrics;
|
||||
const falseSemanticWindows = result.visualReview.reviewWindows.filter(
|
||||
({ verdict }) => verdict === "semantic-false-positive",
|
||||
);
|
||||
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E46H · full calibrated FRONT replay R1"
|
||||
description="Полный retained prefix единственной RIGHT-записи: factory KB4 → штатный NVIDIA nvdewarper FRONT → готовый TrafficCamNet → stock NvDCF. Без собственного детектора, трекера и route-specific постобработки."
|
||||
status="Diagnostic regression · large semantic false tracks"
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{
|
||||
label: "Конфигурация",
|
||||
value: `${rigLabel} · одна RIGHT-запись · frames 0…4487 из 4489`,
|
||||
},
|
||||
{
|
||||
label: "Геометрия",
|
||||
value: `FRONT ${result.rectification.outputResolution[0]}×${result.rectification.outputResolution[1]} · FOV ${result.rectification.horizontalFovDegrees}° · ${result.rectification.providerVersion}`,
|
||||
},
|
||||
{
|
||||
label: "Provider stack",
|
||||
value: "TrafficCamNet Transformer Lite RT-DETR + NVIDIA NvDCF performance profile",
|
||||
},
|
||||
{
|
||||
label: "Длительность",
|
||||
value: `448,8 с seekable overlay · ${metrics.frameCount} последовательных кадров`,
|
||||
},
|
||||
]}
|
||||
brief={{
|
||||
question: "Сохраняет ли выбранный готовый NVIDIA FRONT-stack полезные объекты на всём recorded route и можно ли после короткого bake-off продвигать его в perception?",
|
||||
approach: "На Worker 006 полностью пересчитан неизменяемый retained prefix. В интерфейс опубликован весь seekable overlay, а числовые исключения связаны с конкретными окнами видео: пять крупных ложных semantic tracks и два zero-track интервала.",
|
||||
principalResult: `FRONT читаем и большую часть маршрута держит видимые машины и людей. Но обнаружено ${falseSemanticWindows.length} крупных ложных car-треков на стене, кусте, дороге и террасе. Оба zero-track события в конце показывают реально пустую сцену — это не отказ object layer.`,
|
||||
limitation: "Независимой полной truth нет, поэтому counts не являются precision/recall. Класс, route-local ID, physical identity, LiDAR range и dynamic/static — разные слои доказательств; E46H принимает только диагностический recorded replay.",
|
||||
}}
|
||||
method={result.method}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="E46H VISUAL EVIDENCE · FULL RETAINED FRONT ROUTE"
|
||||
title="Полное 448,8-секундное видео + навигация по проверенным исключениям"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<E46HFullRectifiedFrontReplayVisual result={result} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="FRONT continuity подтверждена; текущий ready-provider не принят из-за semantic false tracks"
|
||||
status="Blocked for promotion · no perception authority"
|
||||
statusTone="warning"
|
||||
metrics={[
|
||||
{
|
||||
label: "Tracked observations",
|
||||
value: metrics.trackObservationCount.toLocaleString("ru-RU"),
|
||||
hint: `${formatNumber(metrics.meanTrackedObjectsPerFrame, 2)} на кадр · ${metrics.uniqueTrackCount} route-local ID`,
|
||||
},
|
||||
{
|
||||
label: "Пустые track-кадры",
|
||||
value: `${metrics.zeroTrackFrameCount}/${metrics.frameCount}`,
|
||||
hint: "два интервала · визуально реальная пустая сцена",
|
||||
},
|
||||
{
|
||||
label: "Short tracks",
|
||||
value: `${formatNumber(metrics.shortTrackFraction * 100, 2)}%`,
|
||||
hint: `${metrics.shortTrackCount} из ${metrics.uniqueTrackCount} ID`,
|
||||
},
|
||||
{
|
||||
label: "Large-box observations",
|
||||
value: metrics.largeTrackObservationCount.toLocaleString("ru-RU"),
|
||||
hint: `${formatNumber(metrics.largeTrackFraction * 100, 3)}% · пять ложных semantic tracks`,
|
||||
},
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "Один физический RIGHT-поток можно штатно преобразовать в читаемый FRONT и полностью воспроизвести готовым NVIDIA detector/tracker stack. Object layer не исчезает в двух tail-интервалах: там действительно нет объектов.",
|
||||
notProved: "Stack не доказал достаточную семантическую точность: длительные крупные car-треки закрепляются за фоном и местами перекрывают значительную часть кадра. E46H не принимает perception, motion-state, free-space, navigation или safety.",
|
||||
decision: "Не подгонять TrafficCamNet под этот маршрут. Сохранить калиброванный FRONT adapter и full-route replay как неизменный benchmark; дальше сравнить на нём другой готовый provider или provider-level semantic suppression, затем снова проверить полное видео.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Icon, IconButton, Select } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import type {
|
||||
E46HFullRectifiedFrontReplayResult,
|
||||
} from "../../core/laboratory/e46hFullRectifiedFrontReplay";
|
||||
|
||||
const MODES = [{ value: "video", label: "VIDEO" }] as const;
|
||||
|
||||
export function E46HFullRectifiedFrontReplayVisual({
|
||||
result,
|
||||
}: {
|
||||
result: E46HFullRectifiedFrontReplayResult;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [selectedWindowId, setSelectedWindowId] = useState("full-route");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const windows = result.visualReview.reviewWindows;
|
||||
const selectedWindow = windows.find(({ id }) => id === selectedWindowId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !selectedWindow) return;
|
||||
video.pause();
|
||||
video.currentTime = selectedWindow.startSeconds;
|
||||
}, [selectedWindow]);
|
||||
|
||||
const navigate = (offset: -1 | 1) => {
|
||||
const currentIndex = windows.findIndex(({ id }) => id === selectedWindowId);
|
||||
const nextIndex = currentIndex < 0
|
||||
? (offset > 0 ? 0 : windows.length - 1)
|
||||
: (currentIndex + offset + windows.length) % windows.length;
|
||||
setSelectedWindowId(windows[nextIndex]?.id ?? "full-route");
|
||||
};
|
||||
|
||||
const actions = (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton label="Предыдущее окно E46H" onClick={() => navigate(-1)}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Следующее окно E46H" onClick={() => navigate(1)}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Select
|
||||
label="Выбрать окно полного видео E46H"
|
||||
value={selectedWindowId}
|
||||
options={[
|
||||
{ value: "full-route", label: "Весь проход · 0.0–448.8 с" },
|
||||
...windows.map((window) => ({
|
||||
value: window.id,
|
||||
label: window.label,
|
||||
})),
|
||||
]}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={setSelectedWindowId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const overlay = selectedWindow ? (
|
||||
<div className="l3-visual-audit__overlay">
|
||||
<div>
|
||||
<span>E46H · визуальная проверка</span>
|
||||
<strong>{selectedWindow.label}</strong>
|
||||
<small>
|
||||
{selectedWindow.verdict === "empty-scene-expected"
|
||||
? "Пустая сцена подтверждена визуально"
|
||||
: `Ложный крупный semantic track ${selectedWindow.sourceTrackId ?? "—"}`}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="l3-visual-audit">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="E46H full FRONT tracked replay"
|
||||
mode="video"
|
||||
modes={MODES}
|
||||
expanded={expanded}
|
||||
onModeChange={() => undefined}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
>
|
||||
<div className="e46e-ready-stack-video">
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
src={result.video.url}
|
||||
aria-label="E46H full FRONT tracked replay video"
|
||||
/>
|
||||
</div>
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { E46IGroundingDinoFullReplayResult } from "../../core/laboratory/e46iGroundingDinoFullReplay";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { E46IGroundingDinoFullReplayVisual } from "./E46IGroundingDinoFullReplayVisual";
|
||||
|
||||
export function E46IGroundingDinoFullReplayResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: E46IGroundingDinoFullReplayResult;
|
||||
}) {
|
||||
const metrics = result.metrics;
|
||||
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E46I · Grounding DINO full FRONT shadow R1"
|
||||
description="Тот же неизменяемый RIGHT → KB4 FRONT источник E46H, но другой готовый NVIDIA provider: коммерческий Grounding DINO Swin-Tiny через TAO Deploy/TensorRT FP16. Без route-specific тюнинга и собственного детекторного postprocessing."
|
||||
status="Semantic progress · temporal layer pending"
|
||||
statusTone="success"
|
||||
facts={[
|
||||
{
|
||||
label: "Конфигурация",
|
||||
value: `${rigLabel} · одна RIGHT-запись · FRONT 960×544 · ${result.source.frameCount} кадров`,
|
||||
},
|
||||
{
|
||||
label: "Provider",
|
||||
value: `${result.provider.name} ${result.provider.version} · ${result.provider.precision}`,
|
||||
},
|
||||
{
|
||||
label: "Фиксированный контракт",
|
||||
value: `${result.inference.captions.join(" · ")} · threshold ${result.inference.confidenceThreshold}`,
|
||||
},
|
||||
{
|
||||
label: "Recorded throughput",
|
||||
value: `${formatNumber(metrics.workerMeanFramesPerSecond, 2)} fps на RTX 4090 · ${formatNumber(metrics.workerElapsedSeconds, 0)} с`,
|
||||
},
|
||||
]}
|
||||
brief={{
|
||||
question: "Убирает ли готовый open-vocabulary NVIDIA detector крупные фоновые car-галлюцинации E46H, не теряя полезные машины и людей на полном recorded route?",
|
||||
approach: "Сначала выполнен фиксированный 11-кадровый shadow gate, затем без изменения threshold пересчитаны все 4488 FRONT-кадров. В LAB опубликованы полный seekable overlay, обзор всего маршрута и отдельная сетка старых failure windows.",
|
||||
principalResult: `Все ${result.shadowGate.suppressedCases}/${result.shadowGate.legacyCases} крупных фоновых случаев E46H подавлены. Все ${result.shadowGate.positiveCasesWithRelevantDetection}/${result.shadowGate.positiveCases} позитивных anchor-кадров сохранили релевантный объект. Это материальная семантическая прогрессия, а не финальное принятие perception.`,
|
||||
limitation: "Выход пока покадровый: постоянных ID, dynamic/static и LiDAR range здесь нет. Caption ontology ограничена четырьмя классами; коляска названа bicycle, один частично обрезанный person пропущен. Tokenizer во время этого запуска скачивался из сети.",
|
||||
}}
|
||||
method={result.method}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="E46I VISUAL EVIDENCE · FULL ROUTE + LEGACY FAILURE WINDOWS"
|
||||
title="Полное 448,8-секундное видео, route overview и shadow-gate"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<E46IGroundingDinoFullReplayVisual result={result} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Семантическая регрессия E46H снята; следующий блокер — temporal identity"
|
||||
status="Progress confirmed · not perception authority"
|
||||
statusTone="success"
|
||||
metrics={[
|
||||
{
|
||||
label: "Legacy false backgrounds",
|
||||
value: `${result.shadowGate.suppressedCases}/${result.shadowGate.legacyCases} suppressed`,
|
||||
hint: "стена · куст · земля · дорога · терраса",
|
||||
},
|
||||
{
|
||||
label: "Detection observations",
|
||||
value: metrics.detectionObservationCount.toLocaleString("ru-RU"),
|
||||
hint: `${formatNumber(metrics.meanDetectionsPerFrame, 2)} на кадр · max ${metrics.maxDetectionsPerFrame}`,
|
||||
},
|
||||
{
|
||||
label: "Zero-detection frames",
|
||||
value: `${metrics.zeroDetectionFrameCount}/${metrics.frameCount}`,
|
||||
hint: `longest run ${metrics.longestZeroDetectionRunFrames} кадров`,
|
||||
},
|
||||
{
|
||||
label: "Large boxes ≥25%",
|
||||
value: metrics.largeBoxObservationCount.toLocaleString("ru-RU"),
|
||||
hint: `${formatNumber(metrics.largeBoxObservationFraction * 100, 3)}% · визуально близкие реальные машины`,
|
||||
},
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "Готовый NVIDIA Grounding DINO на том же FRONT adapter принципиально чище TrafficCamNet на этом recorded route: пять известных крупных фоновых false-car случаев исчезли, а полезные объекты остались видимыми.",
|
||||
notProved: "Покадровый detector ещё не доказывает стабильную identity, удержание рамки, dynamic/static, физическую природу неизвестного объекта, LiDAR range, free-space или безопасность. Полной независимой truth по маршруту нет.",
|
||||
decision: "Зафиксировать Grounding DINO как прошедший semantic provider shadow, не подгонять его под ролик. Следом сделать offline-seal tokenizer и подключить готовый temporal tracker; после этого публиковать второе полное видео уже с постоянными ID и motion-state.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Icon, IconButton, Select } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import type { E46IGroundingDinoFullReplayResult } from "../../core/laboratory/e46iGroundingDinoFullReplay";
|
||||
|
||||
const MODES = [
|
||||
{ value: "video", label: "VIDEO" },
|
||||
{ value: "targeted", label: "FAILURES" },
|
||||
{ value: "full-route", label: "ROUTE" },
|
||||
{ value: "shadow-gate", label: "GATE" },
|
||||
] as const;
|
||||
|
||||
type Mode = (typeof MODES)[number]["value"];
|
||||
|
||||
export function E46IGroundingDinoFullReplayVisual({
|
||||
result,
|
||||
}: {
|
||||
result: E46IGroundingDinoFullReplayResult;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [mode, setMode] = useState<Mode>("video");
|
||||
const [selectedWindowId, setSelectedWindowId] = useState("full-route");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const windows = result.visualReview.reviewWindows;
|
||||
const selectedWindow = windows.find(({ id }) => id === selectedWindowId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !selectedWindow) return;
|
||||
video.pause();
|
||||
video.currentTime = selectedWindow.startSeconds;
|
||||
}, [selectedWindow]);
|
||||
|
||||
const navigate = (offset: -1 | 1) => {
|
||||
const currentIndex = windows.findIndex(({ id }) => id === selectedWindowId);
|
||||
const nextIndex = currentIndex < 0
|
||||
? (offset > 0 ? 0 : windows.length - 1)
|
||||
: (currentIndex + offset + windows.length) % windows.length;
|
||||
setSelectedWindowId(windows[nextIndex]?.id ?? "full-route");
|
||||
setMode("video");
|
||||
};
|
||||
|
||||
const actions = mode === "video" ? (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton label="Предыдущее окно E46I" onClick={() => navigate(-1)}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Следующее окно E46I" onClick={() => navigate(1)}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Select
|
||||
label="Выбрать окно полного видео E46I"
|
||||
value={selectedWindowId}
|
||||
options={[
|
||||
{ value: "full-route", label: "Весь проход · 0.0–448.8 с" },
|
||||
...windows.map((window) => ({ value: window.id, label: window.label })),
|
||||
]}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={setSelectedWindowId}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const overlay = mode === "video" && selectedWindow ? (
|
||||
<div className="l3-visual-audit__overlay">
|
||||
<div>
|
||||
<span>E46I · provider comparison</span>
|
||||
<strong>{selectedWindow.label}</strong>
|
||||
<small>
|
||||
{selectedWindow.verdict === "empty-scene-mostly-preserved"
|
||||
? "Пустая сцена в основном сохранена"
|
||||
: "Крупная фоновая car-галлюцинация E46H подавлена"}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const image = mode === "targeted"
|
||||
? result.visuals.targetedWindows
|
||||
: mode === "full-route"
|
||||
? result.visuals.fullRoute
|
||||
: result.visuals.shadowGate;
|
||||
|
||||
return (
|
||||
<div className="l3-visual-audit">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="E46I Grounding DINO full FRONT replay"
|
||||
mode={mode}
|
||||
modes={MODES}
|
||||
expanded={expanded}
|
||||
onModeChange={(value) => setMode(value as Mode)}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
>
|
||||
<div className="e46e-ready-stack-video">
|
||||
{mode === "video" ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
src={result.video.url}
|
||||
aria-label="E46I Grounding DINO full FRONT replay video"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={image.url}
|
||||
alt={
|
||||
mode === "targeted"
|
||||
? "E46I targeted legacy failure windows"
|
||||
: mode === "full-route"
|
||||
? "E46I full route ten-second contact sheet"
|
||||
: "E46I eleven-frame semantic shadow gate"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { E46JRawFisheyeRealtimeResult } from "../../core/laboratory/e46jRawFisheyeRealtime";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { E46JRawFisheyeRealtimeVisual } from "./E46JRawFisheyeRealtimeVisual";
|
||||
|
||||
export function E46JRawFisheyeRealtimeResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: E46JRawFisheyeRealtimeResult;
|
||||
}) {
|
||||
const metrics = result.metrics;
|
||||
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E46J · full raw fisheye realtime gate"
|
||||
description="Одна физическая RIGHT-камера, один полный сырой KB4-кадр и один YOLOX-S inference. Без кропов, dewarp, виртуальных камер, тайлов, route-specific фильтров и собственного detector logic."
|
||||
status="Realtime capacity passed · temporal layer pending"
|
||||
statusTone="success"
|
||||
facts={[
|
||||
{
|
||||
label: "Конфигурация",
|
||||
value: `${rigLabel} · RIGHT raw KB4 800×600 · ${result.source.frameCount} кадров`,
|
||||
},
|
||||
{
|
||||
label: "Provider",
|
||||
value: `${result.detector.architecture} · ${result.detector.license} · ${result.detector.runtime}`,
|
||||
},
|
||||
{
|
||||
label: "Фиксированный контракт",
|
||||
value: `1 кадр → 1 infer · score ${result.detection.minimumScore} · NMS ${result.detection.nmsIouThreshold}`,
|
||||
},
|
||||
{
|
||||
label: "Realtime capacity",
|
||||
value: `${formatNumber(metrics.coreCapacityFps, 2)} fps · core p95 ${formatNumber(metrics.corePathP95Ms, 2)} мс`,
|
||||
},
|
||||
]}
|
||||
brief={{
|
||||
question: "Может ли готовый YOLOX-S работать одним проходом на полном сыром fisheye и сохранить исходный FOV при целевых 10 FPS?",
|
||||
approach: "Все 4489 записанных RIGHT-кадров прошли через один co-located Triton inference. Видео опубликовано целиком; отдельно проверены маршрутная сетка, прежние failure windows и окно с тенью оператора.",
|
||||
principalResult: `4489/4489 кадров, 0 ошибок, ${formatNumber(metrics.coreCapacityFps, 2)} fps вычислительной ёмкости. Полный fisheye сохранён; пять прежних крупных фоновых car-срабатываний в целевых окнах не наблюдаются.`,
|
||||
limitation: `Это покадровый detector без ID и motion-state. В окне тени оператора person-box присутствует на ${metrics.operatorShadowPersonFrameCount}/${metrics.operatorShadowWindowFrameCount} кадров. Независимой полной truth нет.`,
|
||||
}}
|
||||
method={result.method}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="E46J VISUAL EVIDENCE · FULL RAW FISHEYE + TARGETED WINDOWS"
|
||||
title="Полное 448,723-секундное видео без обрезки FOV"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<E46JRawFisheyeRealtimeVisual result={result} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Полный fisheye проходит realtime-gate; следующий блок — temporal identity"
|
||||
status="Compute accepted · detector not promoted"
|
||||
statusTone="success"
|
||||
metrics={[
|
||||
{
|
||||
label: "Core capacity",
|
||||
value: `${formatNumber(metrics.coreCapacityFps, 2)} fps`,
|
||||
hint: `mean ${formatNumber(metrics.corePathMeanMs, 2)} мс · p95 ${formatNumber(metrics.corePathP95Ms, 2)} мс`,
|
||||
},
|
||||
{
|
||||
label: "Inference request",
|
||||
value: `${formatNumber(metrics.inferenceRequestP95Ms, 2)} мс p95`,
|
||||
hint: `mean ${formatNumber(metrics.inferenceRequestMeanMs, 2)} мс · local GPU path`,
|
||||
},
|
||||
{
|
||||
label: "Detection observations",
|
||||
value: metrics.detectionObservationCount.toLocaleString("ru-RU"),
|
||||
hint: `${formatNumber(metrics.meanDetectionsPerFrame, 2)} на кадр · max ${metrics.maxDetectionsPerFrame}`,
|
||||
},
|
||||
{
|
||||
label: "Known shadow FP",
|
||||
value: `${metrics.operatorShadowPersonFrameCount}/${metrics.operatorShadowWindowFrameCount} кадров`,
|
||||
hint: "419.4–426.9 с · операторская тень → person",
|
||||
},
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "Готовый YOLOX-S способен работать одним проходом по полному raw KB4 fisheye с запасом относительно 10 FPS. Обрезать исходный FOV или считать несколько виртуальных камер для detector-stage не требуется.",
|
||||
notProved: "Покадровые рамки ещё не доказывают устойчивые ID, удержание объекта, drop/recovery, dynamic/static, LiDAR range или безопасность. Полной независимой разметки маршрута нет; известен false person на тени оператора.",
|
||||
decision: result.decision.nextAction,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Icon, IconButton, Select } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import type { E46JRawFisheyeRealtimeResult } from "../../core/laboratory/e46jRawFisheyeRealtime";
|
||||
|
||||
const MODES = [
|
||||
{ value: "video", label: "VIDEO" },
|
||||
{ value: "targeted", label: "FAILURES" },
|
||||
{ value: "full-route", label: "ROUTE" },
|
||||
{ value: "operator-shadow", label: "SHADOW" },
|
||||
] as const;
|
||||
|
||||
type Mode = (typeof MODES)[number]["value"];
|
||||
|
||||
export function E46JRawFisheyeRealtimeVisual({
|
||||
result,
|
||||
}: {
|
||||
result: E46JRawFisheyeRealtimeResult;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [mode, setMode] = useState<Mode>("video");
|
||||
const [selectedWindowId, setSelectedWindowId] = useState("full-route");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const windows = result.visualReview.reviewWindows;
|
||||
const selectedWindow = windows.find(({ id }) => id === selectedWindowId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (mode !== "video" || !video || !selectedWindow) return;
|
||||
video.pause();
|
||||
video.currentTime = selectedWindow.startSeconds;
|
||||
}, [mode, selectedWindow]);
|
||||
|
||||
const navigate = (offset: -1 | 1) => {
|
||||
const currentIndex = windows.findIndex(({ id }) => id === selectedWindowId);
|
||||
const nextIndex = currentIndex < 0
|
||||
? (offset > 0 ? 0 : windows.length - 1)
|
||||
: (currentIndex + offset + windows.length) % windows.length;
|
||||
setSelectedWindowId(windows[nextIndex]?.id ?? "full-route");
|
||||
setMode("video");
|
||||
};
|
||||
|
||||
const actions = mode === "video" ? (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton label="Предыдущее окно E46J" onClick={() => navigate(-1)}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Следующее окно E46J" onClick={() => navigate(1)}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Select
|
||||
label="Выбрать окно полного видео E46J"
|
||||
value={selectedWindowId}
|
||||
options={[
|
||||
{ value: "full-route", label: "Весь проход · 0.0–448.723 с" },
|
||||
...windows.map((window) => ({ value: window.id, label: window.label })),
|
||||
]}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={setSelectedWindowId}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const overlay = mode === "video" && selectedWindow ? (
|
||||
<div className="l3-visual-audit__overlay">
|
||||
<div>
|
||||
<span>E46J · raw fisheye one-pass</span>
|
||||
<strong>{selectedWindow.label}</strong>
|
||||
<small>
|
||||
{selectedWindow.verdict === "operator-shadow-person-false-positive-observed"
|
||||
? "Известное исключение · тень оператора → person"
|
||||
: "Прежняя крупная фоновая car-ошибка не наблюдается"}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const image = mode === "targeted"
|
||||
? result.visuals.targetedWindows
|
||||
: mode === "full-route"
|
||||
? result.visuals.fullRoute
|
||||
: result.visuals.operatorShadow;
|
||||
|
||||
return (
|
||||
<div className="l3-visual-audit">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="E46J full raw fisheye YOLOX-S realtime gate"
|
||||
mode={mode}
|
||||
modes={MODES}
|
||||
expanded={expanded}
|
||||
onModeChange={(value) => setMode(value as Mode)}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
>
|
||||
<div className="e46e-ready-stack-video">
|
||||
{mode === "video" ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
src={result.video.url}
|
||||
aria-label="E46J full raw fisheye YOLOX-S video"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={image.url}
|
||||
alt={
|
||||
mode === "targeted"
|
||||
? "E46J targeted legacy failure windows"
|
||||
: mode === "full-route"
|
||||
? "E46J full raw fisheye route contact sheet"
|
||||
: "E46J operator shadow false-person exception"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { L32PointPillarsCameraReviewResult } from "../../core/laboratory/l32PointPillarsCameraReview";
|
||||
import { L32PointPillarsCameraReviewVisual } from "./L32PointPillarsCameraReviewVisual";
|
||||
|
||||
function percent(value: number): string {
|
||||
return `${(value * 100).toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
|
||||
}
|
||||
|
||||
export function L32PointPillarsCameraReviewResult({
|
||||
result,
|
||||
}: {
|
||||
result: L32PointPillarsCameraReviewResult;
|
||||
}) {
|
||||
const metrics = result.metrics;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={<LaboratorySummary
|
||||
title="L3.2 · Камера + LiDAR · PointPillars на RAVNOVES00"
|
||||
description="Семантическая проверка запечатанного PointPillars-прогона: точный кадр правой камеры, калиброванные точки LiDAR и те же модельные 3D-гипотезы в одном viewer."
|
||||
status="Проверено · текущий кандидат отклонён"
|
||||
statusTone="danger"
|
||||
facts={[
|
||||
{ label: "Источник", value: `RAVNOVES00 · ${metrics.reviewFrameCount} camera-bound кадров` },
|
||||
{ label: "Синхронизация", value: `p95 ${metrics.cameraBindingAbsoluteDeltaMs.p95.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} мс` },
|
||||
{ label: "Режимы", value: "CAM / 3D / BEV · LiDAR overlay отключаемый" },
|
||||
{ label: "Полномочия", value: "Shadow-only · accuracy и safety не приняты" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Соответствуют ли cross-domain гипотезы PointPillars видимым объектам RAVNOVES00?",
|
||||
approach: "17 route-wide кадров старой L3.1 привязаны к ближайшему точному кадру правой камеры. Точки и 3D-боксы спроецированы неизменённой заводской KB4-калибровкой; повторный inference не выполнялся.",
|
||||
principalResult: `${percent(metrics.scoreBelow025Fraction)} опубликованных гипотез имеют score ниже 0,25, ${percent(metrics.scoreBelow050Fraction)} — ниже 0,50. Камера делает семантическое несоответствие визуально проверяемым.`,
|
||||
limitation: "Камера не заменяет независимый 3D ground truth, поэтому precision/recall не вычисляются. Эта работа доказательно отклоняет текущий кандидат, но не измеряет точность будущего детектора.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "deterministic",
|
||||
pipelineId: "l32-pointpillars-camera-review/v1",
|
||||
components: [
|
||||
{ kind: "source", name: result.sourceL31ResultId, version: "sealed L3.1", role: "неизменённые точки и гипотезы PointPillars", identitySha256: result.sourceL31ResultId.split("-").at(-1) ?? null },
|
||||
{ kind: "source", name: "sensor.camera.right", version: result.sourceSessionId, role: "семантический camera reference", identitySha256: null },
|
||||
{ kind: "algorithm", name: "Factory KB4 projection", version: "camera_1 · 800×600", role: "проекция LiDAR и 3D-боксов в кадр", identitySha256: null },
|
||||
{ kind: "runtime", name: "Mission Core local evidence builder", version: result.resultId, role: "последовательная immutable-материализация", identitySha256: result.resultId.split("-").at(-1) ?? null },
|
||||
],
|
||||
}}
|
||||
/>}
|
||||
evidence={<LaboratoryEvidence
|
||||
eyebrow="RAVNOVES00 → CAMERA-BOUND ДОКАЗАТЕЛЬСТВО"
|
||||
title="Камера, точки LiDAR и гипотезы PointPillars"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<L32PointPillarsCameraReviewVisual result={result} />
|
||||
</LaboratoryEvidence>}
|
||||
result={<LaboratoryResultSummary
|
||||
title="Runtime работает, но текущая модель семантически непригодна"
|
||||
status="Кандидат отклонён"
|
||||
statusTone="danger"
|
||||
metrics={[
|
||||
{ label: "Camera-bound frames", value: metrics.reviewFrameCount.toLocaleString("ru-RU"), hint: `из 18 старых visual frames; 1 был до начала камеры` },
|
||||
{ label: "Camera sync p95", value: `${metrics.cameraBindingAbsoluteDeltaMs.p95.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} мс`, hint: `max ${metrics.cameraBindingAbsoluteDeltaMs.maximum.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} мс` },
|
||||
{ label: "Score < 0,25", value: percent(metrics.scoreBelow025Fraction), hint: `${metrics.reviewPredictionCount.toLocaleString("ru-RU")} гипотез в ревизии` },
|
||||
{ label: "Видимые projected boxes", value: metrics.reviewVisibleProjectedBoxCount.toLocaleString("ru-RU"), hint: "контуры, попавшие в camera FOV" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "Точки сенсорного рига, правая камера и PointPillars-гипотезы воспроизводимо совмещаются в одной системе просмотра; источник и калибровка привязаны к RAVNOVES00.",
|
||||
notProved: "Текущий PointPillars не доказал семантическую корректность и не получает эксплуатационных полномочий. Независимой 3D-разметки по-прежнему нет.",
|
||||
decision: "Старую L3.1 убрать из продуктового каталога, сохранить как audit trail. L3.2 считать отрицательным baseline; следующий detector-кандидат проверять тем же CAM → LiDAR → target evidence-контрактом.",
|
||||
}}
|
||||
/>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon, IconButton, Select } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
fetchL32PointPillarsCameraReviewFrame,
|
||||
L32_REVIEW_SCORE_THRESHOLD,
|
||||
type L32PointPillarsCameraReviewResult,
|
||||
type L32VisualFrame,
|
||||
} from "../../core/laboratory/l32PointPillarsCameraReview";
|
||||
import { L3PointPillarsScene } from "./L3PointPillarsScene";
|
||||
import { L32PointPillarsCameraScene } from "./L32PointPillarsCameraScene";
|
||||
|
||||
type ViewerMode = "cam" | "3d" | "bev";
|
||||
type LidarMode = "on" | "off";
|
||||
|
||||
function frameLabel(frame: L32PointPillarsCameraReviewResult["frames"][number]) {
|
||||
return `${frame.sessionSeconds.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})} с · кадр ${frame.frameId} · Vehicle ${frame.classCounts.Vehicle}`;
|
||||
}
|
||||
|
||||
export function L32PointPillarsCameraReviewVisual({
|
||||
result,
|
||||
}: {
|
||||
result: L32PointPillarsCameraReviewResult;
|
||||
}) {
|
||||
const [selectedFrameId, setSelectedFrameId] = useState(result.frames[0]?.frameId ?? "");
|
||||
const [frame, setFrame] = useState<L32VisualFrame | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<ViewerMode>("cam");
|
||||
const [lidarMode, setLidarMode] = useState<LidarMode>("on");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFrameId) return;
|
||||
const controller = new AbortController();
|
||||
setFrame(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchL32PointPillarsCameraReviewFrame(
|
||||
result.resultId,
|
||||
selectedFrameId,
|
||||
{ signal: controller.signal },
|
||||
).then((next) => {
|
||||
if (!controller.signal.aborted) setFrame(next);
|
||||
}).catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(caught instanceof Error ? caught.message : "Кадр L3.2 недоступен.");
|
||||
}
|
||||
}).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 (selectedIndex < 0) return;
|
||||
const index = (selectedIndex + offset + result.frames.length) % result.frames.length;
|
||||
setSelectedFrameId(result.frames[index].frameId);
|
||||
};
|
||||
const actions = (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton label="Предыдущий кадр RAVNOVES00" onClick={() => navigate(-1)}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Следующий кадр RAVNOVES00" onClick={() => navigate(1)}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Select
|
||||
label="Выбрать camera-bound кадр RAVNOVES00"
|
||||
value={selectedFrameId}
|
||||
options={result.frames.map((item) => ({ value: item.frameId, label: frameLabel(item) }))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={setSelectedFrameId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const reviewableBoxCount = frame?.projectedBoxes.filter(
|
||||
({ score }) => score >= L32_REVIEW_SCORE_THRESHOLD,
|
||||
).length ?? 0;
|
||||
const rejectedBoxCount = (frame?.projectedBoxes.length ?? 0) - reviewableBoxCount;
|
||||
const overlay = frame ? (
|
||||
<div className="l3-visual-audit__overlay">
|
||||
<div>
|
||||
<span>RAVNOVES00 · правая камера</span>
|
||||
<strong>{frame.summary.sessionSeconds.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} с · кадр {frame.frameId}</strong>
|
||||
<small>Δ camera/LiDAR {Math.abs(frame.summary.cameraDeltaMs).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} мс</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>PointPillars · кандидаты, не truth</span>
|
||||
<strong>Vehicle {frame.summary.classCounts.Vehicle} · Pedestrian {frame.summary.classCounts.Pedestrian} · Cyclist {frame.summary.classCounts.Cyclist}</strong>
|
||||
<small>{reviewableBoxCount} для ревизии · {rejectedBoxCount} ниже confidence 25%</small>
|
||||
</div>
|
||||
<div className="l3-visual-audit__legend">
|
||||
<span data-tone="warning">Жёлтый пунктир · слабый кандидат 25–50%</span>
|
||||
<span data-tone="prediction">Белый контур · кандидат от 50%</span>
|
||||
<span>Цветные точки · метрический LiDAR overlay</span>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className="l3-visual-audit">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="Camera-bound PointPillars на RAVNOVES00"
|
||||
mode={mode}
|
||||
modes={[
|
||||
{ value: "cam", label: "CAM" },
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "bev", label: "BEV" },
|
||||
]}
|
||||
secondaryMode={mode === "cam" ? {
|
||||
value: lidarMode,
|
||||
modes: [
|
||||
{ value: "on", label: "L on" },
|
||||
{ value: "off", label: "L off" },
|
||||
],
|
||||
label: "LiDAR поверх камеры",
|
||||
onChange: setLidarMode,
|
||||
} : undefined}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Открываем camera-bound кадр RAVNOVES00</span>
|
||||
</div>
|
||||
) : error || !frame ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{error ?? "Кадр L3.2 недоступен."}</span>
|
||||
</div>
|
||||
) : mode === "cam" ? (
|
||||
<L32PointPillarsCameraScene frame={frame} lidarVisible={lidarMode === "on"} />
|
||||
) : (
|
||||
<L3PointPillarsScene frame={{
|
||||
frameId: frame.frameId,
|
||||
pointsXyzi: frame.pointsXyzi,
|
||||
truthBoxes: [],
|
||||
predictionBoxes: frame.predictionBoxes,
|
||||
}} mode={mode} bevCenterX={0} bevHalfExtent={55} />
|
||||
)}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
L32_REVIEW_SCORE_THRESHOLD,
|
||||
type L32VisualFrame,
|
||||
} from "../../core/laboratory/l32PointPillarsCameraReview";
|
||||
|
||||
export function L32PointPillarsCameraScene({
|
||||
frame,
|
||||
lidarVisible,
|
||||
}: {
|
||||
frame: L32VisualFrame;
|
||||
lidarVisible: boolean;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const next = new Image();
|
||||
next.decoding = "async";
|
||||
next.onload = () => {
|
||||
setError(false);
|
||||
setImage(next);
|
||||
};
|
||||
next.onerror = () => {
|
||||
setImage(null);
|
||||
setError(true);
|
||||
};
|
||||
next.src = frame.cameraUrl;
|
||||
return () => {
|
||||
next.onload = null;
|
||||
next.onerror = null;
|
||||
};
|
||||
}, [frame.cameraUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!host || !canvas || !image) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const ratio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * ratio);
|
||||
canvas.height = Math.round(height * ratio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
context.fillStyle = "#050506";
|
||||
context.fillRect(0, 0, width, height);
|
||||
const scale = Math.min(width / frame.cameraWidth, height / frame.cameraHeight);
|
||||
const drawWidth = frame.cameraWidth * scale;
|
||||
const drawHeight = frame.cameraHeight * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
context.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
|
||||
if (lidarVisible) {
|
||||
for (let index = 0; index < frame.projectedPointsXyd.length; index += 3) {
|
||||
const x = offsetX + frame.projectedPointsXyd[index] * scale;
|
||||
const y = offsetY + frame.projectedPointsXyd[index + 1] * scale;
|
||||
const depth = frame.projectedPointsXyd[index + 2];
|
||||
const hue = Math.max(188, Math.min(226, 226 - depth * 1.1));
|
||||
context.fillStyle = `hsla(${hue} 92% 68% / 0.72)`;
|
||||
context.beginPath();
|
||||
context.arc(x, y, Math.max(1.2, 1.65 * scale), 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
}
|
||||
frame.projectedBoxes.filter(
|
||||
({ score }) => score >= L32_REVIEW_SCORE_THRESHOLD,
|
||||
).forEach((box) => {
|
||||
const admitted = box.score >= 0.5;
|
||||
context.lineWidth = Math.max(1.25, (admitted ? 1.8 : 1.55) * scale);
|
||||
context.strokeStyle = admitted
|
||||
? "rgba(255, 248, 232, 0.94)"
|
||||
: "rgba(255, 196, 71, 0.9)";
|
||||
context.setLineDash(admitted
|
||||
? []
|
||||
: [Math.max(4, 6 * scale), Math.max(3, 4 * scale)]);
|
||||
const segments = box.segmentsXyxy;
|
||||
for (let index = 0; index < segments.length; index += 4) {
|
||||
context.beginPath();
|
||||
context.moveTo(
|
||||
offsetX + segments[index] * scale,
|
||||
offsetY + segments[index + 1] * scale,
|
||||
);
|
||||
context.lineTo(
|
||||
offsetX + segments[index + 2] * scale,
|
||||
offsetY + segments[index + 3] * scale,
|
||||
);
|
||||
context.stroke();
|
||||
}
|
||||
const xValues = segments.filter((_, index) => index % 2 === 0);
|
||||
const yValues = segments.filter((_, index) => index % 2 === 1);
|
||||
if (xValues.length && yValues.length) {
|
||||
const label = `${box.modelClass} ${(box.score * 100).toFixed(0)}%`;
|
||||
const fontSize = Math.max(10, 11 * scale);
|
||||
const x = offsetX + Math.min(...xValues) * scale;
|
||||
const y = offsetY + Math.min(...yValues) * scale;
|
||||
context.font = `600 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
const labelWidth = context.measureText(label).width;
|
||||
context.setLineDash([]);
|
||||
context.fillStyle = "rgba(5, 5, 6, 0.82)";
|
||||
context.fillRect(
|
||||
x,
|
||||
Math.max(offsetY, y - fontSize - 7),
|
||||
labelWidth + 12,
|
||||
fontSize + 7,
|
||||
);
|
||||
context.fillStyle = admitted ? "#fff8e8" : "#ffc447";
|
||||
context.fillText(label, x + 6, Math.max(offsetY + fontSize, y - 5));
|
||||
}
|
||||
});
|
||||
context.setLineDash([]);
|
||||
};
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [frame, image, lidarVisible]);
|
||||
|
||||
return (
|
||||
<div className="l32-camera-scene" ref={hostRef}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label={`RAVNOVES00 camera frame ${frame.frameId}${lidarVisible ? ", LiDAR on" : ", LiDAR off"}`}
|
||||
/>
|
||||
{error ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
Точный кадр правой камеры недоступен.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { L33CameraFirstDetectorReviewResult } from "../../core/laboratory/l33CameraFirstDetectorReview";
|
||||
import { L33CameraFirstDetectorVisual } from "./L33CameraFirstDetectorVisual";
|
||||
|
||||
function percent(value: number): string {
|
||||
return `${(value * 100).toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
|
||||
}
|
||||
|
||||
export function L33CameraFirstDetectorReviewResult({
|
||||
result,
|
||||
}: {
|
||||
result: L33CameraFirstDetectorReviewResult;
|
||||
}) {
|
||||
const metrics = result.metrics;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={<LaboratorySummary
|
||||
title="L3.3 · Camera-first detector + LiDAR range · RAVNOVES00"
|
||||
description="Полный записанный маршрут проходит через rectified YOLOX-S, ByteTrack, EoMT, калиброванную LiDAR-геометрию, 3D-кубоиды и ограниченную temporal-стабилизацию. Камера сохраняет объект без дальности; LiDAR добавляет только доказанную метрическую геометрию."
|
||||
status="Полный replay принят · runtime ещё не подключён"
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{ label: "Источник", value: `RAVNOVES00 · ${metrics.framesProcessed.toLocaleString("ru-RU")} кадров полного маршрута` },
|
||||
{ label: "Контур", value: `${result.detector.architecture} + ByteTrack + EoMT + LiDAR + E23` },
|
||||
{ label: "Производительность", value: `${metrics.effectiveComposedFps.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} кадр/с · p95 ${metrics.composedP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} мс` },
|
||||
{ label: "Полномочия", value: "Shadow-only · accuracy, navigation и safety не приняты" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Собирается ли из правой камеры сенсорного рига и LiDAR единый объектный world state на всём маршруте, а не только на выбранных красивых кадрах?",
|
||||
approach: "Все 4 489 кадров RAVNOVES00 обработаны rectified core-3 YOLOX-S и ByteTrack. Принятый EoMT работает как multi-rate семантическая поддержка 2 Гц; калиброванный LiDAR назначает дальность и завершает кубоид, E23 ограниченно удерживает состояние между наблюдениями. Ни один camera-only объект не получает выдуманную дальность.",
|
||||
principalResult: `${metrics.routeDetectionCount.toLocaleString("ru-RU")} camera-track наблюдений получены на ${metrics.routeDetectionFrameCount.toLocaleString("ru-RU")} кадрах. LiDAR-фьюжн доступен на ${metrics.lidarFusedFrames.toLocaleString("ru-RU")} кадрах; принято ${metrics.rawAcceptedCuboids.toLocaleString("ru-RU")} исходных и ${metrics.temporalAcceptedCuboids.toLocaleString("ru-RU")} temporal-наблюдений 3D-кубоидов. ${metrics.duplicateSupportRejections.toLocaleString("ru-RU")} конфликтующих повторных назначений одной LiDAR-опоры заблокированы.`,
|
||||
limitation: "Это полный recorded replay, а не живой транспорт и не независимая accuracy-разметка. EoMT остаётся multi-rate 2 Гц; следующий gate — тот же контур в тёплом worker runtime.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "deterministic",
|
||||
pipelineId: "kb4-core3-yolox-eomt-lidar-e23-temporal/v1",
|
||||
components: [
|
||||
{ kind: "model", name: result.detector.architecture, version: result.detector.modelSha256, role: "camera semantic class and 2D track", identitySha256: result.detector.modelSha256 },
|
||||
{ kind: "model", name: "EoMT semantic segmentation", version: `${metrics.semanticEffectiveFps.toFixed(2)} Hz multi-rate`, role: "semantic support for LiDAR association", identitySha256: null },
|
||||
{ kind: "algorithm", name: "Calibrated LiDAR cuboid completion", version: result.sourceWorldStateResultId, role: "metric range and bounded amodal 3D cuboid", identitySha256: result.sourceWorldStateResultId.split("-").at(-1) ?? null },
|
||||
{ kind: "algorithm", name: "E23 inline temporal state", version: "bounded hold and stitching", role: "preserve object state between qualified observations", identitySha256: null },
|
||||
{ kind: "source", name: result.sourceL32ResultId, version: result.sourceSessionId, role: "exact camera frames and calibrated LiDAR overlay", identitySha256: result.sourceL32ResultId.split("-").at(-1) ?? null },
|
||||
],
|
||||
}}
|
||||
/>}
|
||||
evidence={<LaboratoryEvidence
|
||||
eyebrow="RAVNOVES00 → CAMERA-FIRST ДОКАЗАТЕЛЬСТВО"
|
||||
title="Объекты камеры, LiDAR-поддержка и метрическая дальность"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<L33CameraFirstDetectorVisual result={result} />
|
||||
</LaboratoryEvidence>}
|
||||
result={<LaboratoryResultSummary
|
||||
title="Полный camera-first world state собран на записанном маршруте"
|
||||
status="Replay gate принят · live gate следующий"
|
||||
statusTone="warning"
|
||||
metrics={[
|
||||
{ label: "Полный маршрут", value: metrics.framesProcessed.toLocaleString("ru-RU"), hint: `${metrics.effectiveComposedFps.toFixed(2)} кадр/с effective` },
|
||||
{ label: "LiDAR fusion", value: metrics.lidarFusedFrames.toLocaleString("ru-RU"), hint: `${percent(metrics.lidarFusedFrames / metrics.framesProcessed)} кадров` },
|
||||
{ label: "3D cuboids", value: metrics.rawAcceptedCuboids.toLocaleString("ru-RU"), hint: "приняты до temporal hold" },
|
||||
{ label: "Конфликты геометрии", value: metrics.duplicateSupportRejections.toLocaleString("ru-RU"), hint: "одна LiDAR-опора не размножает объекты" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "На всех 4 489 кадрах одной записи детектор, трекер, multi-rate сегментация, LiDAR-дальность, кубоид и temporal world state выполняются как один ограниченный контур с полным учётом кадров и принятым latency gate. Одна LiDAR-опора больше не может одновременно породить геометрию нескольких camera-track объектов.",
|
||||
notProved: "Пока не доказаны live transport, длительная эксплуатационная устойчивость, precision/recall на независимой разметке и пригодность для navigation/safety. Визуальные пустые кадры остаются частью проверки, а не скрываются.",
|
||||
decision: "Не создавать новый LAB. Эту L3.3 использовать как визуальный аудит полного replay; следующий инженерный шаг — подключить тот же состав в тёплый worker и измерить live cadence без изменения device acquisition sequence.",
|
||||
}}
|
||||
/>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { L33VisualFrame } from "../../core/laboratory/l33CameraFirstDetectorReview";
|
||||
|
||||
function tokenColor(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
alpha = 1,
|
||||
): string {
|
||||
const value = getComputedStyle(host).getPropertyValue(token).trim();
|
||||
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
function labelForDetection(
|
||||
label: string,
|
||||
rangeM: number | null,
|
||||
score: number,
|
||||
): string {
|
||||
const names: Readonly<Record<string, string>> = {
|
||||
car: "Авто",
|
||||
truck: "Грузовик",
|
||||
bus: "Автобус",
|
||||
person: "Человек",
|
||||
bicycle: "Велосипед",
|
||||
motorcycle: "Мотоцикл",
|
||||
};
|
||||
const qualifier = rangeM === null
|
||||
? `${(score * 100).toFixed(0)}%`
|
||||
: `${rangeM.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} м`;
|
||||
return `${names[label] ?? label} · ${qualifier}`;
|
||||
}
|
||||
|
||||
type LabelRect = Readonly<{
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}>;
|
||||
|
||||
function overlaps(left: LabelRect, right: LabelRect): boolean {
|
||||
const margin = 3;
|
||||
return !(
|
||||
left.x + left.width + margin <= right.x
|
||||
|| right.x + right.width + margin <= left.x
|
||||
|| left.y + left.height + margin <= right.y
|
||||
|| right.y + right.height + margin <= left.y
|
||||
);
|
||||
}
|
||||
|
||||
function placeLabel(
|
||||
box: LabelRect,
|
||||
labelWidth: number,
|
||||
labelHeight: number,
|
||||
imageBounds: LabelRect,
|
||||
occupied: readonly LabelRect[],
|
||||
): LabelRect | null {
|
||||
const clampX = (value: number) => Math.min(
|
||||
imageBounds.x + imageBounds.width - labelWidth,
|
||||
Math.max(imageBounds.x, value),
|
||||
);
|
||||
const candidates: LabelRect[] = [];
|
||||
const horizontal = [box.x, box.x + box.width - labelWidth];
|
||||
for (let row = 0; row < 4; row += 1) {
|
||||
const above = box.y - labelHeight - 3 - row * (labelHeight + 3);
|
||||
const below = box.y + box.height + 3 + row * (labelHeight + 3);
|
||||
for (const x of horizontal) {
|
||||
candidates.push({ x: clampX(x), y: above, width: labelWidth, height: labelHeight });
|
||||
candidates.push({ x: clampX(x), y: below, width: labelWidth, height: labelHeight });
|
||||
}
|
||||
}
|
||||
candidates.push({
|
||||
x: clampX(box.x + 3),
|
||||
y: box.y + 3,
|
||||
width: labelWidth,
|
||||
height: labelHeight,
|
||||
});
|
||||
return candidates.find((candidate) => (
|
||||
candidate.y >= imageBounds.y
|
||||
&& candidate.y + candidate.height <= imageBounds.y + imageBounds.height
|
||||
&& occupied.every((current) => !overlaps(candidate, current))
|
||||
)) ?? null;
|
||||
}
|
||||
|
||||
export function L33CameraFirstDetectorScene({
|
||||
frame,
|
||||
lidarVisible,
|
||||
}: {
|
||||
frame: L33VisualFrame;
|
||||
lidarVisible: boolean;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const next = new Image();
|
||||
next.decoding = "async";
|
||||
next.onload = () => {
|
||||
setError(false);
|
||||
setImage(next);
|
||||
};
|
||||
next.onerror = () => {
|
||||
setImage(null);
|
||||
setError(true);
|
||||
};
|
||||
next.src = frame.cameraUrl;
|
||||
return () => {
|
||||
next.onload = null;
|
||||
next.onerror = null;
|
||||
};
|
||||
}, [frame.cameraUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!host || !canvas || !image) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const ratio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * ratio);
|
||||
canvas.height = Math.round(height * ratio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
context.fillStyle = tokenColor(host, "--nodedc-canvas-rgb", [5, 5, 6]);
|
||||
context.fillRect(0, 0, width, height);
|
||||
const scale = Math.min(width / frame.cameraWidth, height / frame.cameraHeight);
|
||||
const drawWidth = frame.cameraWidth * scale;
|
||||
const drawHeight = frame.cameraHeight * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
context.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
|
||||
|
||||
if (lidarVisible) {
|
||||
for (let index = 0; index < frame.projectedPointsXyd.length; index += 3) {
|
||||
const x = offsetX + frame.projectedPointsXyd[index] * scale;
|
||||
const y = offsetY + frame.projectedPointsXyd[index + 1] * scale;
|
||||
const depth = frame.projectedPointsXyd[index + 2];
|
||||
const hue = Math.max(188, Math.min(226, 226 - depth * 1.1));
|
||||
context.fillStyle = `hsla(${hue} 92% 68% / 0.56)`;
|
||||
context.beginPath();
|
||||
context.arc(x, y, Math.max(1.0, 1.45 * scale), 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
}
|
||||
|
||||
const rangeColor = tokenColor(host, "--nodedc-success-rgb", [143, 255, 93], 0.96);
|
||||
const cameraColor = tokenColor(host, "--nodedc-text-primary-rgb", [247, 248, 244], 0.92);
|
||||
const glassColor = tokenColor(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.82);
|
||||
const boxes = frame.detections.map((detection) => {
|
||||
const [left, top, right, bottom] = detection.bboxXyxy;
|
||||
const x = offsetX + left * scale;
|
||||
const y = offsetY + top * scale;
|
||||
const boxWidth = (right - left) * scale;
|
||||
const boxHeight = (bottom - top) * scale;
|
||||
const ranged = detection.rangeM !== null;
|
||||
context.strokeStyle = ranged ? rangeColor : cameraColor;
|
||||
context.lineWidth = Math.max(1.25, 1.8 * scale);
|
||||
context.setLineDash(
|
||||
detection.semanticProvenance === "bounded-track-interpolation"
|
||||
|| detection.semanticProvenance === "e23-temporal-hold"
|
||||
? [Math.max(4, 6 * scale), Math.max(3, 4 * scale)]
|
||||
: [],
|
||||
);
|
||||
context.strokeRect(x, y, boxWidth, boxHeight);
|
||||
return { detection, x, y, width: boxWidth, height: boxHeight, ranged };
|
||||
});
|
||||
const occupied: LabelRect[] = [];
|
||||
const imageBounds = { x: offsetX, y: offsetY, width: drawWidth, height: drawHeight };
|
||||
[...boxes]
|
||||
.sort((left, right) => (
|
||||
Number(right.ranged) - Number(left.ranged)
|
||||
|| right.width * right.height - left.width * left.height
|
||||
))
|
||||
.forEach(({ detection, x, y, width: boxWidth, height: boxHeight, ranged }) => {
|
||||
const label = labelForDetection(
|
||||
detection.label,
|
||||
detection.rangeM,
|
||||
detection.score,
|
||||
);
|
||||
const fontSize = Math.max(9, 10 * scale);
|
||||
const labelHeight = fontSize + 7;
|
||||
context.font = `600 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
const labelWidth = context.measureText(label).width + 12;
|
||||
const placement = placeLabel(
|
||||
{ x, y, width: boxWidth, height: boxHeight },
|
||||
labelWidth,
|
||||
labelHeight,
|
||||
imageBounds,
|
||||
occupied,
|
||||
);
|
||||
if (!placement) return;
|
||||
occupied.push(placement);
|
||||
context.setLineDash([]);
|
||||
context.fillStyle = glassColor;
|
||||
context.fillRect(
|
||||
placement.x,
|
||||
placement.y,
|
||||
placement.width,
|
||||
placement.height,
|
||||
);
|
||||
context.fillStyle = ranged ? rangeColor : cameraColor;
|
||||
context.fillText(
|
||||
label,
|
||||
placement.x + 6,
|
||||
placement.y + fontSize + 1,
|
||||
);
|
||||
});
|
||||
context.setLineDash([]);
|
||||
};
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [frame, image, lidarVisible]);
|
||||
|
||||
return (
|
||||
<div className="l32-camera-scene" ref={hostRef}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label={`RAVNOVES00 camera-first frame ${frame.frameId}${lidarVisible ? ", LiDAR on" : ", LiDAR off"}`}
|
||||
/>
|
||||
{error ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
Точный кадр правой камеры недоступен.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon, IconButton, Select } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
fetchL33CameraFirstDetectorReviewFrame,
|
||||
type L33CameraFirstDetectorReviewResult,
|
||||
type L33VisualFrame,
|
||||
} from "../../core/laboratory/l33CameraFirstDetectorReview";
|
||||
import { L3PointPillarsScene } from "./L3PointPillarsScene";
|
||||
import { L33CameraFirstDetectorScene } from "./L33CameraFirstDetectorScene";
|
||||
|
||||
type ViewerMode = "cam" | "3d" | "bev";
|
||||
type LidarMode = "on" | "off";
|
||||
|
||||
function frameLabel(frame: L33CameraFirstDetectorReviewResult["frames"][number]) {
|
||||
return `${frame.sessionSeconds.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})} с · кадр ${frame.frameId} · объектов ${frame.detectionCount}`;
|
||||
}
|
||||
|
||||
export function L33CameraFirstDetectorVisual({
|
||||
result,
|
||||
}: {
|
||||
result: L33CameraFirstDetectorReviewResult;
|
||||
}) {
|
||||
const [selectedFrameId, setSelectedFrameId] = useState(result.frames[0]?.frameId ?? "");
|
||||
const [frame, setFrame] = useState<L33VisualFrame | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<ViewerMode>("cam");
|
||||
const [lidarMode, setLidarMode] = useState<LidarMode>("on");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFrameId) return;
|
||||
const controller = new AbortController();
|
||||
setFrame(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchL33CameraFirstDetectorReviewFrame(
|
||||
result.resultId,
|
||||
selectedFrameId,
|
||||
{ signal: controller.signal },
|
||||
).then((next) => {
|
||||
if (!controller.signal.aborted) setFrame(next);
|
||||
}).catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(caught instanceof Error ? caught.message : "Кадр L3.3 недоступен.");
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId, selectedFrameId]);
|
||||
|
||||
const selectedIndex = result.frames.findIndex(({ frameId }) => frameId === selectedFrameId);
|
||||
const associationRays = useMemo(() => frame?.detections.map((detection) => ({
|
||||
originXyzM: detection.cameraRayLidar.originXyzM,
|
||||
directionXyz: detection.cameraRayLidar.directionXyz,
|
||||
anchorXyzM: detection.geometryAnchorXyzM,
|
||||
temporalHeld: detection.semanticProvenance === "e23-temporal-hold",
|
||||
})) ?? [], [frame]);
|
||||
const navigate = (offset: -1 | 1) => {
|
||||
if (selectedIndex < 0) return;
|
||||
const index = (selectedIndex + offset + result.frames.length) % result.frames.length;
|
||||
setSelectedFrameId(result.frames[index].frameId);
|
||||
};
|
||||
const actions = (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton label="Предыдущий camera-first кадр" onClick={() => navigate(-1)}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Следующий camera-first кадр" onClick={() => navigate(1)}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Select
|
||||
label="Выбрать camera-first кадр RAVNOVES00"
|
||||
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>RAVNOVES00 · правая камера</span>
|
||||
<strong>{frame.summary.sessionSeconds.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} с · кадр {frame.frameId}</strong>
|
||||
<small>{frame.summary.semanticProvenance === "e23-temporal-hold" ? "E23 удерживает состояние между наблюдениями" : "Текущий rectified camera track"}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>YOLOX-S · камера владеет семантикой</span>
|
||||
<strong>{frame.detections.length} объектов · {frame.summary.rangedDetectionCount} с LiDAR-дистанцией</strong>
|
||||
<small>Порог detector score {(result.metrics.minimumDetectorScore * 100).toFixed(0)}%</small>
|
||||
</div>
|
||||
{mode === "cam" ? (
|
||||
<div className="l3-visual-audit__legend">
|
||||
<span data-tone="success">Зелёный контур · LiDAR подтвердил дальность</span>
|
||||
<span data-tone="prediction">Белый контур · объект камеры без метрической дальности</span>
|
||||
<span>Пунктир · E23 временно удерживает camera track</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="l3-visual-audit__legend">
|
||||
<span data-tone="success">Зелёный луч и точка · LiDAR-дальность; пунктир · E23 hold</span>
|
||||
<span data-tone="prediction">Белый пунктирный луч · camera-only направление</span>
|
||||
<span>Светлый куб · world-state объект; пунктир · E23 hold</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className="l3-visual-audit">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="Camera-first detector на RAVNOVES00"
|
||||
mode={mode}
|
||||
modes={[
|
||||
{ value: "cam", label: "CAM" },
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "bev", label: "BEV" },
|
||||
]}
|
||||
secondaryMode={mode === "cam" ? {
|
||||
value: lidarMode,
|
||||
modes: [
|
||||
{ value: "on", label: "L on" },
|
||||
{ value: "off", label: "L off" },
|
||||
],
|
||||
label: "LiDAR поверх камеры",
|
||||
onChange: setLidarMode,
|
||||
} : undefined}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Открываем camera-first кадр RAVNOVES00</span>
|
||||
</div>
|
||||
) : error || !frame ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{error ?? "Кадр L3.3 недоступен."}</span>
|
||||
</div>
|
||||
) : mode === "cam" ? (
|
||||
<L33CameraFirstDetectorScene frame={frame} lidarVisible={lidarMode === "on"} />
|
||||
) : (
|
||||
<L3PointPillarsScene frame={{
|
||||
frameId: frame.frameId,
|
||||
pointsXyzi: frame.pointsXyzi,
|
||||
truthBoxes: [],
|
||||
predictionBoxes: frame.predictionBoxes,
|
||||
}}
|
||||
mode={mode}
|
||||
associationRays={associationRays}
|
||||
fitToEvidence
|
||||
/>
|
||||
)}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type {
|
||||
L34AAssistedYoloxErrorAuditResult,
|
||||
} from "../../core/laboratory/l34aAssistedYoloxErrorAudit";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { L34AAssistedYoloxErrorVisual } from "./L34AAssistedYoloxErrorVisual";
|
||||
|
||||
function digest(value: string): string | null {
|
||||
const candidate = value.split("-").at(-1) ?? "";
|
||||
return /^[a-f0-9]{64}$/.test(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${(value * 100).toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
|
||||
}
|
||||
|
||||
export function L34AAssistedYoloxErrorResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: L34AAssistedYoloxErrorAuditResult;
|
||||
}) {
|
||||
const metrics = result.metrics;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB L3.4A · assisted YOLOX error audit"
|
||||
description="Детерминированное сопоставление точного L3.4 candidate freeze с полностью проверенной candidate-seeded разметкой: инженерная диагностика ошибок, но не независимая truth."
|
||||
status="Assisted diagnostic · не truth"
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · RIGHT camera · recorded replay` },
|
||||
{ label: "Сопоставление", value: "Greedy max-IoU · threshold 0.50" },
|
||||
{ label: "Покрытие", value: `${formatNumber(metrics.frameCount, 0)} кадров · ${formatNumber(metrics.referenceCount, 0)} объектов` },
|
||||
{ label: "Полномочия", value: "Диагностика · candidate не принят" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Какие конкретные FP, FN, дубли и ошибки класса делает замороженный YOLOX-S на 32 кадрах RAVNOVES00?",
|
||||
approach: "Точные 268 L3.4 predictions пространственно сопоставлены при IoU ≥ 0.50 с 278 объектами сохранённой AI-assisted review. Каждый результат связан с исходным кадром, prediction box и reference box.",
|
||||
principalResult: `${formatNumber(metrics.truePositive, 0)} совпадения, ${formatNumber(metrics.falsePositive, 0)} FP, ${formatNumber(metrics.falseNegative, 0)} FN, ${formatNumber(metrics.classMismatch, 0)} ошибок класса и ${formatNumber(metrics.duplicateFalsePositive, 0)} дублей; визуальные ошибки есть на ${formatNumber(metrics.errorCaseCount, 0)} из ${formatNumber(metrics.frameCount, 0)} кадров.`,
|
||||
limitation: "Разметка создавалась поверх frozen candidate, поэтому alignment-метрики смещены в пользу модели и не являются blind accuracy, AP или основанием для acceptance. L3.5 остаётся закрыт до independent truth seal.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "deterministic",
|
||||
pipelineId: result.pipelineId,
|
||||
components: [
|
||||
{
|
||||
kind: "source",
|
||||
name: "L3.4 RIGHT YOLOX freeze",
|
||||
version: "268 immutable candidate boxes",
|
||||
role: "prediction substrate",
|
||||
identitySha256: null,
|
||||
},
|
||||
{
|
||||
kind: "source",
|
||||
name: result.assistedAnnotation.sessionId,
|
||||
version: `revision ${result.assistedAnnotation.revision} · not truth`,
|
||||
role: "candidate-seeded engineering reference",
|
||||
identitySha256: result.assistedAnnotation.sessionSha256,
|
||||
},
|
||||
{
|
||||
kind: "algorithm",
|
||||
name: result.profileId,
|
||||
version: "greedy max-IoU 0.50 · spatial match before class verdict",
|
||||
role: "TP/FP/FN, duplicate and class-mismatch audit",
|
||||
identitySha256: digest(result.resultId),
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="ASSISTED ERROR AUDIT · SOURCE-BOUND VISUALS"
|
||||
title="FP, FN, дубли и неверные классы на точных кадрах"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<L34AAssistedYoloxErrorVisual result={result} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Детектор требует раздельной коррекции post-processing, классов и данных"
|
||||
status="Engineering diagnosis only"
|
||||
statusTone="warning"
|
||||
metrics={[
|
||||
{
|
||||
label: "Assisted precision@0.50",
|
||||
value: formatPercent(metrics.precisionIou50),
|
||||
hint: `${formatNumber(metrics.truePositive, 0)} TP · ${formatNumber(metrics.falsePositive, 0)} FP · не blind`,
|
||||
},
|
||||
{
|
||||
label: "Assisted recall@0.50",
|
||||
value: formatPercent(metrics.recallIou50),
|
||||
hint: `${formatNumber(metrics.falseNegative, 0)} FN · не acceptance`,
|
||||
},
|
||||
{
|
||||
label: "Классы",
|
||||
value: formatNumber(metrics.classMismatch, 0),
|
||||
hint: `${formatNumber(metrics.customReferenceCount, 0)} объектов с proposed ontology labels`,
|
||||
},
|
||||
{
|
||||
label: "Дубли",
|
||||
value: formatNumber(metrics.duplicateFalsePositive, 0),
|
||||
hint: "отдельный post-processing сигнал",
|
||||
},
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "На этих записанных кадрах воспроизводимо локализованы конкретные классы ошибок: дубли и лишние боксы, пропуски людей/статических препятствий и несовпадения stroller/scooter/laptop с текущей онтологией кандидата.",
|
||||
notProved: "Не доказаны blind AP/recall, перенос на другой маршрут, работа LiDAR range, live/hardware качество, navigation или safety. Assisted alignment нельзя использовать как независимую truth.",
|
||||
decision: "Использовать кейсы для отдельной коррекции NMS/post-processing, class mapping и подготовки detector-data. Не тюнить пороги по агрегату и не открывать L3.5 до двух независимых review и adjudication.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type {
|
||||
L34AAuditAnnotation,
|
||||
L34AAuditCase,
|
||||
L34AAuditPrediction,
|
||||
} from "../../core/laboratory/l34aAssistedYoloxErrorAudit";
|
||||
|
||||
function tokenColor(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
alpha = 1,
|
||||
): string {
|
||||
const value = getComputedStyle(host).getPropertyValue(token).trim();
|
||||
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
const CATEGORY_NAMES: Readonly<Record<string, string>> = {
|
||||
car: "Авто",
|
||||
heavy_vehicle: "Тяжёлый транспорт",
|
||||
person: "Человек",
|
||||
bicycle: "Велосипед",
|
||||
motorcycle: "Мотоцикл",
|
||||
static_obstacle: "Препятствие",
|
||||
animal: "Животное",
|
||||
};
|
||||
|
||||
function categoryName(value: string): string {
|
||||
if (value.startsWith("unmapped:")) return value.slice("unmapped:".length);
|
||||
return CATEGORY_NAMES[value] ?? value;
|
||||
}
|
||||
|
||||
function predictionLabel(
|
||||
prediction: L34AAuditPrediction,
|
||||
annotation: L34AAuditAnnotation | undefined,
|
||||
): string | null {
|
||||
const category = categoryName(prediction.category);
|
||||
const score = `${(prediction.score * 100).toFixed(0)}%`;
|
||||
if (prediction.verdict === "true_positive") return null;
|
||||
if (prediction.verdict === "duplicate_false_positive") {
|
||||
return `Дубль · ${category} · ${score}`;
|
||||
}
|
||||
if (prediction.verdict === "class_mismatch") {
|
||||
return `Класс · ${category} → ${categoryName(annotation?.displayCategory ?? "?")}`;
|
||||
}
|
||||
return `FP · ${category} · ${score}`;
|
||||
}
|
||||
|
||||
function annotationLabel(annotation: L34AAuditAnnotation): string | null {
|
||||
if (annotation.verdict !== "false_negative") return null;
|
||||
return `FN · ${categoryName(annotation.displayCategory)}`;
|
||||
}
|
||||
|
||||
type LabelRect = Readonly<{
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}>;
|
||||
|
||||
function overlaps(left: LabelRect, right: LabelRect): boolean {
|
||||
const margin = 3;
|
||||
return !(
|
||||
left.x + left.width + margin <= right.x
|
||||
|| right.x + right.width + margin <= left.x
|
||||
|| left.y + left.height + margin <= right.y
|
||||
|| right.y + right.height + margin <= left.y
|
||||
);
|
||||
}
|
||||
|
||||
function placeLabel(
|
||||
box: LabelRect,
|
||||
labelWidth: number,
|
||||
labelHeight: number,
|
||||
imageBounds: LabelRect,
|
||||
occupied: readonly LabelRect[],
|
||||
): LabelRect | null {
|
||||
const x = Math.min(
|
||||
imageBounds.x + imageBounds.width - labelWidth,
|
||||
Math.max(imageBounds.x, box.x),
|
||||
);
|
||||
const candidates = [
|
||||
box.y - labelHeight - 3,
|
||||
box.y + box.height + 3,
|
||||
box.y + 3,
|
||||
].map((y) => ({ x, y, width: labelWidth, height: labelHeight }));
|
||||
return candidates.find((candidate) => (
|
||||
candidate.y >= imageBounds.y
|
||||
&& candidate.y + candidate.height <= imageBounds.y + imageBounds.height
|
||||
&& occupied.every((current) => !overlaps(candidate, current))
|
||||
)) ?? null;
|
||||
}
|
||||
|
||||
export function L34AAssistedYoloxErrorScene({
|
||||
auditCase,
|
||||
errorsVisible,
|
||||
}: {
|
||||
auditCase: L34AAuditCase;
|
||||
errorsVisible: boolean;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const next = new Image();
|
||||
next.decoding = "async";
|
||||
next.onload = () => {
|
||||
setError(false);
|
||||
setImage(next);
|
||||
};
|
||||
next.onerror = () => {
|
||||
setImage(null);
|
||||
setError(true);
|
||||
};
|
||||
next.src = auditCase.cameraUrl;
|
||||
return () => {
|
||||
next.onload = null;
|
||||
next.onerror = null;
|
||||
};
|
||||
}, [auditCase.cameraUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!host || !canvas || !image) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const ratio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * ratio);
|
||||
canvas.height = Math.round(height * ratio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
context.fillStyle = tokenColor(host, "--nodedc-canvas-rgb", [5, 5, 6]);
|
||||
context.fillRect(0, 0, width, height);
|
||||
const scale = Math.min(
|
||||
width / auditCase.cameraWidth,
|
||||
height / auditCase.cameraHeight,
|
||||
);
|
||||
const drawWidth = auditCase.cameraWidth * scale;
|
||||
const drawHeight = auditCase.cameraHeight * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
context.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
|
||||
if (!errorsVisible) return;
|
||||
|
||||
const colors = {
|
||||
tp: tokenColor(host, "--nodedc-success-rgb", [181, 255, 90], 0.66),
|
||||
fp: tokenColor(host, "--nodedc-danger-rgb", [255, 116, 116], 0.98),
|
||||
fn: tokenColor(host, "--nodedc-warning-rgb", [255, 209, 102], 0.98),
|
||||
mismatch: tokenColor(host, "--nodedc-accent-rgb", [255, 47, 146], 0.98),
|
||||
label: tokenColor(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.86),
|
||||
};
|
||||
const imageBounds = {
|
||||
x: offsetX,
|
||||
y: offsetY,
|
||||
width: drawWidth,
|
||||
height: drawHeight,
|
||||
};
|
||||
const occupied: LabelRect[] = [];
|
||||
const annotationById = new Map(
|
||||
auditCase.annotations.map((annotation) => [annotation.objectId, annotation]),
|
||||
);
|
||||
const drawBox = (
|
||||
rawBox: readonly [number, number, number, number],
|
||||
color: string,
|
||||
dashed: boolean,
|
||||
): LabelRect => {
|
||||
const [left, top, right, bottom] = rawBox;
|
||||
const box = {
|
||||
x: offsetX + left * scale,
|
||||
y: offsetY + top * scale,
|
||||
width: (right - left) * scale,
|
||||
height: (bottom - top) * scale,
|
||||
};
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = Math.max(1.35, 1.9 * scale);
|
||||
context.setLineDash(dashed ? [6, 4] : []);
|
||||
context.strokeRect(box.x, box.y, box.width, box.height);
|
||||
context.setLineDash([]);
|
||||
return box;
|
||||
};
|
||||
const drawLabel = (label: string | null, box: LabelRect, color: string) => {
|
||||
if (!label) return;
|
||||
const fontSize = Math.max(9, 10 * scale);
|
||||
const labelHeight = fontSize + 7;
|
||||
context.font = `600 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
const labelWidth = context.measureText(label).width + 12;
|
||||
const placement = placeLabel(
|
||||
box,
|
||||
labelWidth,
|
||||
labelHeight,
|
||||
imageBounds,
|
||||
occupied,
|
||||
);
|
||||
if (!placement) return;
|
||||
occupied.push(placement);
|
||||
context.fillStyle = colors.label;
|
||||
context.fillRect(
|
||||
placement.x,
|
||||
placement.y,
|
||||
placement.width,
|
||||
placement.height,
|
||||
);
|
||||
context.fillStyle = color;
|
||||
context.fillText(label, placement.x + 6, placement.y + fontSize + 1);
|
||||
};
|
||||
|
||||
auditCase.predictions.forEach((prediction) => {
|
||||
const color = prediction.verdict === "true_positive"
|
||||
? colors.tp
|
||||
: prediction.verdict === "class_mismatch"
|
||||
? colors.mismatch
|
||||
: colors.fp;
|
||||
const box = drawBox(prediction.boxXyxy, color, false);
|
||||
drawLabel(
|
||||
predictionLabel(
|
||||
prediction,
|
||||
prediction.matchedObjectId
|
||||
? annotationById.get(prediction.matchedObjectId)
|
||||
: undefined,
|
||||
),
|
||||
box,
|
||||
color,
|
||||
);
|
||||
});
|
||||
auditCase.annotations.forEach((annotation) => {
|
||||
if (annotation.verdict === "true_positive") return;
|
||||
const color = annotation.verdict === "class_mismatch"
|
||||
? colors.mismatch
|
||||
: colors.fn;
|
||||
const box = drawBox(annotation.boxXyxy, color, true);
|
||||
drawLabel(annotationLabel(annotation), box, color);
|
||||
});
|
||||
};
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [auditCase, errorsVisible, image]);
|
||||
|
||||
return (
|
||||
<div className="l32-camera-scene" ref={hostRef}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label={`L3.4A assisted error case frame ${auditCase.frameIndex}: ${auditCase.summary.truePositive} TP, ${auditCase.summary.falsePositive} FP, ${auditCase.summary.falseNegative} FN`}
|
||||
/>
|
||||
{error ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
Точный кадр правой камеры L3.4A недоступен.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon, IconButton, Select } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
fetchL34AAuditCase,
|
||||
type L34AAssistedYoloxErrorAuditResult,
|
||||
type L34AAuditCase,
|
||||
type L34ACaseSummary,
|
||||
} from "../../core/laboratory/l34aAssistedYoloxErrorAudit";
|
||||
import { L34AAssistedYoloxErrorScene } from "./L34AAssistedYoloxErrorScene";
|
||||
|
||||
type ViewerMode = "errors" | "source";
|
||||
|
||||
function caseLabel(item: L34ACaseSummary): string {
|
||||
const metrics = item.summary;
|
||||
return `${item.truthIslandSequence}/32 · frame ${item.frameIndex} · ${item.groupId} · ${metrics.falsePositive} FP · ${metrics.falseNegative} FN · ${metrics.classMismatch} class`;
|
||||
}
|
||||
|
||||
export function L34AAssistedYoloxErrorVisual({
|
||||
result,
|
||||
}: {
|
||||
result: L34AAssistedYoloxErrorAuditResult;
|
||||
}) {
|
||||
const orderedCases = useMemo(() => {
|
||||
const bySequence = new Map(
|
||||
result.cases.map((item) => [item.truthIslandSequence, item]),
|
||||
);
|
||||
return result.caseOrder.map((sequence) => bySequence.get(sequence)).filter(
|
||||
(item): item is L34ACaseSummary => item !== undefined,
|
||||
);
|
||||
}, [result.caseOrder, result.cases]);
|
||||
const [selectedSequence, setSelectedSequence] = useState(
|
||||
orderedCases[0]?.truthIslandSequence ?? 1,
|
||||
);
|
||||
const [auditCase, setAuditCase] = useState<L34AAuditCase | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<ViewerMode>("errors");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setAuditCase(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchL34AAuditCase(result.resultId, selectedSequence, {
|
||||
signal: controller.signal,
|
||||
}).then((next) => {
|
||||
if (!controller.signal.aborted) setAuditCase(next);
|
||||
}).catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(
|
||||
caught instanceof Error
|
||||
? caught.message
|
||||
: "Визуальный кейс L3.4A недоступен.",
|
||||
);
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId, selectedSequence]);
|
||||
|
||||
const selectedIndex = orderedCases.findIndex(
|
||||
({ truthIslandSequence }) => truthIslandSequence === selectedSequence,
|
||||
);
|
||||
const navigate = (offset: -1 | 1) => {
|
||||
if (selectedIndex < 0 || !orderedCases.length) return;
|
||||
const index = (selectedIndex + offset + orderedCases.length) % orderedCases.length;
|
||||
setSelectedSequence(orderedCases[index].truthIslandSequence);
|
||||
};
|
||||
const actions = (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton label="Предыдущий error-case L3.4A" onClick={() => navigate(-1)}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Следующий error-case L3.4A" onClick={() => navigate(1)}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Select
|
||||
label="Выбрать error-case L3.4A"
|
||||
value={String(selectedSequence)}
|
||||
options={orderedCases.map((item) => ({
|
||||
value: String(item.truthIslandSequence),
|
||||
label: caseLabel(item),
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
searchable
|
||||
searchPlaceholder="Найти кадр, группу или ошибку"
|
||||
onChange={(value) => setSelectedSequence(Number(value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const overlay = auditCase ? (
|
||||
<div className="l3-visual-audit__overlay">
|
||||
<div>
|
||||
<span>RAVNOVES00 · sensor.camera.right</span>
|
||||
<strong>{auditCase.sessionSeconds.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} с · frame {auditCase.frameIndex}</strong>
|
||||
<small>Sequence {auditCase.truthIslandSequence}/32 · {auditCase.groupId}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Assisted IoU 0.50 diagnostic</span>
|
||||
<strong>{auditCase.summary.truePositive} TP · {auditCase.summary.falsePositive} FP · {auditCase.summary.falseNegative} FN</strong>
|
||||
<small>{auditCase.summary.classMismatch} class mismatch · {auditCase.summary.duplicateFalsePositive} duplicate</small>
|
||||
</div>
|
||||
<div className="l3-visual-audit__legend">
|
||||
<span data-tone="tp">Зелёный · совпало</span>
|
||||
<span data-tone="fp">Красный · FP/дубль</span>
|
||||
<span data-tone="fn">Жёлтый пунктир · FN</span>
|
||||
<span data-tone="prediction">Акцент · неверный класс</span>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className="l3-visual-audit">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="L3.4A assisted YOLOX error audit"
|
||||
mode={mode}
|
||||
modes={[
|
||||
{ value: "errors", label: "ERRORS" },
|
||||
{ value: "source", label: "SOURCE" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Открываем hash-bound кадр и error layers</span>
|
||||
</div>
|
||||
) : error || !auditCase ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{error ?? "Визуальный кейс L3.4A недоступен."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<L34AAssistedYoloxErrorScene
|
||||
auditCase={auditCase}
|
||||
errorsVisible={mode === "errors"}
|
||||
/>
|
||||
)}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { L34BResult } from "../../core/laboratory/l34bNestedBoxConsolidation";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { L34BNestedBoxConsolidationVisual } from "./L34BNestedBoxConsolidationVisual";
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${(value * 100).toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
|
||||
}
|
||||
|
||||
export function L34BNestedBoxConsolidationResultView({ rigLabel, result }: { rigLabel: string; result: L34BResult }) {
|
||||
const { before, after } = result.metrics;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={<LaboratorySummary
|
||||
title="LAB L3.4B · nested-box consolidation shadow"
|
||||
description="Ограниченная post-normalization проверка: только почти полностью вложенные рамки одного класса объединяются; global IoU NMS не меняется."
|
||||
status="Regression-free assisted shadow · не truth"
|
||||
statusTone="success"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · RIGHT camera · recorded replay` },
|
||||
{ label: "Правило", value: "Same class · overlap-over-smaller ≥ 0.95 · union box" },
|
||||
{ label: "Покрытие", value: `${formatNumber(after.frameCount, 0)} кадров · ${formatNumber(result.metrics.affectedCaseCount, 0)} изменён` },
|
||||
{ label: "Полномочия", value: "Shadow post-processing · candidate не принят" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Можно ли убрать подтверждённые вложенные рамки, не потеряв корректные объекты?",
|
||||
approach: "К точным 268 L3.4 predictions применено одно детерминированное правило: совпадающий нормализованный класс и покрытие меньшей рамки не менее 95%. Геометрия объединяется, score берётся максимальный; результат повторно сопоставляется с той же assisted review.",
|
||||
principalResult: `${formatNumber(before.predictionCount, 0)} → ${formatNumber(after.predictionCount, 0)} рамок, ${formatNumber(before.falsePositive, 0)} → ${formatNumber(after.falsePositive, 0)} assisted FP; TP ${formatNumber(after.truePositive, 0)} и FN ${formatNumber(after.falseNegative, 0)} не изменились.`,
|
||||
limitation: "Это source-scoped assisted shadow, а не blind accuracy. Четыре left/front seam split и semantic stroller collision этим правилом не исправляются.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "deterministic",
|
||||
pipelineId: result.pipelineId,
|
||||
components: [
|
||||
{ kind: "source", name: "L3.4 immutable freeze", version: "268 normalized YOLOX boxes", role: "before substrate", identitySha256: null },
|
||||
{ kind: "source", name: "L3.4A assisted audit", version: "32 reviewed frames · not truth", role: "diagnostic comparison", identitySha256: null },
|
||||
{ kind: "algorithm", name: result.profile.profileId, version: "same class · overlap 0.95 · union", role: "nested-box consolidation shadow", identitySha256: result.resultId.split("-").at(-1) ?? null },
|
||||
],
|
||||
}}
|
||||
/>}
|
||||
evidence={<LaboratoryEvidence eyebrow="POST-PROCESSING SHADOW · BEFORE / AFTER / SOURCE" title="Одна вложенная пара объединена без assisted-регрессии" kind="diagnostic-model" resizable><L34BNestedBoxConsolidationVisual result={result} /></LaboratoryEvidence>}
|
||||
result={<LaboratoryResultSummary
|
||||
title="Узкое nested-box правило прошло; глобальный NMS — нет"
|
||||
status="Bounded shadow accepted · not candidate acceptance"
|
||||
statusTone="success"
|
||||
metrics={[
|
||||
{ label: "Assisted precision", value: `${formatPercent(before.precisionIou50)} → ${formatPercent(after.precisionIou50)}`, hint: "+0,3 п.п. · не blind" },
|
||||
{ label: "Assisted recall", value: formatPercent(after.recallIou50), hint: "Без изменения" },
|
||||
{ label: "FP", value: `${formatNumber(before.falsePositive, 0)} → ${formatNumber(after.falsePositive, 0)}`, hint: "Одна рамка удалена объединением" },
|
||||
{ label: "Осталось seam-сигналов", value: formatNumber(result.decision.remainingL34aDuplicateSignals, 0), hint: "Нужен tile-aware stitching" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "На текущих 32 записанных кадрах правило вложенности ≥95% объединяет ровно одну пару одного класса и не ухудшает assisted TP, FN, recall или class mismatch. BEFORE/AFTER связан с исходным кадром.",
|
||||
notProved: "Не доказаны blind AP/recall, перенос на другой маршрут, live/hardware, LiDAR range, navigation или safety. Четыре seam split не являются обычными IoU-дублями.",
|
||||
decision: "Оставить nested-box consolidation как bounded shadow. Не снижать глобальный IoU NMS: тест 0.30 удалял корректные соседние машины. Следующая работа — сохранить rectification_tile в prediction contract и проверить temporal left/front seam stitching.",
|
||||
}}
|
||||
/>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { L34BCase } from "../../core/laboratory/l34bNestedBoxConsolidation";
|
||||
|
||||
type SceneMode = "after" | "before" | "source";
|
||||
|
||||
function color(host: HTMLElement, token: string, fallback: readonly [number, number, number], alpha = 1): string {
|
||||
const value = getComputedStyle(host).getPropertyValue(token).trim();
|
||||
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
export function L34BNestedBoxConsolidationScene({
|
||||
shadowCase,
|
||||
mode,
|
||||
}: {
|
||||
shadowCase: L34BCase;
|
||||
mode: SceneMode;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const next = new Image();
|
||||
next.decoding = "async";
|
||||
next.onload = () => { setImage(next); setFailed(false); };
|
||||
next.onerror = () => { setImage(null); setFailed(true); };
|
||||
next.src = shadowCase.cameraUrl;
|
||||
return () => { next.onload = null; next.onerror = null; };
|
||||
}, [shadowCase.cameraUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!host || !canvas || !image) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const ratio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * ratio);
|
||||
canvas.height = Math.round(height * ratio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6]);
|
||||
context.fillRect(0, 0, width, height);
|
||||
const scale = Math.min(width / shadowCase.cameraWidth, height / shadowCase.cameraHeight);
|
||||
const drawWidth = shadowCase.cameraWidth * scale;
|
||||
const drawHeight = shadowCase.cameraHeight * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
context.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
|
||||
if (mode === "source") return;
|
||||
const sourceIndices = new Set(shadowCase.consolidations.flatMap((item) => item.sourcePredictionIndices));
|
||||
const mergedIndices = new Set(shadowCase.consolidations.map((item) => item.outputPredictionIndex));
|
||||
const predictions = mode === "before" ? shadowCase.beforePredictions : shadowCase.afterPredictions;
|
||||
let highlightedLabelIndex = 0;
|
||||
predictions.forEach((prediction) => {
|
||||
const highlighted = mode === "before"
|
||||
? sourceIndices.has(prediction.predictionIndex)
|
||||
: mergedIndices.has(prediction.predictionIndex);
|
||||
const [left, top, right, bottom] = prediction.boxXyxy;
|
||||
const x = offsetX + left * scale;
|
||||
const y = offsetY + top * scale;
|
||||
const boxWidth = (right - left) * scale;
|
||||
const boxHeight = (bottom - top) * scale;
|
||||
const stroke = highlighted
|
||||
? mode === "before"
|
||||
? color(host, "--nodedc-danger-rgb", [255, 116, 116], 1)
|
||||
: color(host, "--nodedc-success-rgb", [181, 255, 90], 1)
|
||||
: color(host, "--nodedc-foreground-rgb", [240, 240, 240], 0.46);
|
||||
context.strokeStyle = stroke;
|
||||
context.lineWidth = highlighted ? Math.max(2, 2.7 * scale) : Math.max(1, 1.2 * scale);
|
||||
context.setLineDash(mode === "before" && highlighted ? [7, 4] : []);
|
||||
context.strokeRect(x, y, boxWidth, boxHeight);
|
||||
context.setLineDash([]);
|
||||
if (!highlighted) return;
|
||||
const label = `${mode === "before" ? "BEFORE" : "AFTER"} · ${prediction.category} · ${(prediction.score * 100).toFixed(0)}%`;
|
||||
const fontSize = Math.max(10, 11 * scale);
|
||||
context.font = `600 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
const labelWidth = context.measureText(label).width + 12;
|
||||
const labelHeight = fontSize + 8;
|
||||
const labelX = Math.min(
|
||||
offsetX + drawWidth - labelWidth,
|
||||
Math.max(offsetX, x),
|
||||
);
|
||||
const labelY = Math.max(
|
||||
offsetY,
|
||||
y - labelHeight - 3 - highlightedLabelIndex * (labelHeight + 3),
|
||||
);
|
||||
highlightedLabelIndex += 1;
|
||||
context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9);
|
||||
context.fillRect(labelX, labelY, labelWidth, labelHeight);
|
||||
context.fillStyle = stroke;
|
||||
context.fillText(label, labelX + 6, labelY + fontSize + 1);
|
||||
});
|
||||
};
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [image, mode, shadowCase]);
|
||||
|
||||
return (
|
||||
<div className="l32-camera-scene" ref={hostRef}>
|
||||
<canvas ref={canvasRef} role="img" aria-label={`L3.4B frame ${shadowCase.frameIndex}: ${mode} nested-box consolidation`} />
|
||||
{failed ? <div className="l3-visual-audit__state" role="status">Точный кадр L3.4B недоступен.</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon, IconButton, Select } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
fetchL34BCase,
|
||||
type L34BCase,
|
||||
type L34BCaseSummary,
|
||||
type L34BResult,
|
||||
} from "../../core/laboratory/l34bNestedBoxConsolidation";
|
||||
import { L34BNestedBoxConsolidationScene } from "./L34BNestedBoxConsolidationScene";
|
||||
|
||||
type ViewerMode = "after" | "before" | "source";
|
||||
|
||||
function caseLabel(item: L34BCaseSummary): string {
|
||||
return `${item.truthIslandSequence}/32 · frame ${item.frameIndex} · ${item.groupId} · ${item.consolidationCount ? "CONSOLIDATED" : "UNCHANGED"}`;
|
||||
}
|
||||
|
||||
export function L34BNestedBoxConsolidationVisual({ result }: { result: L34BResult }) {
|
||||
const orderedCases = useMemo(() => {
|
||||
const bySequence = new Map(result.cases.map((item) => [item.truthIslandSequence, item]));
|
||||
return result.caseOrder.map((sequence) => bySequence.get(sequence)).filter((item): item is L34BCaseSummary => item !== undefined);
|
||||
}, [result.caseOrder, result.cases]);
|
||||
const [selectedSequence, setSelectedSequence] = useState(orderedCases[0]?.truthIslandSequence ?? 1);
|
||||
const [shadowCase, setShadowCase] = useState<L34BCase | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<ViewerMode>("after");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setShadowCase(null);
|
||||
void fetchL34BCase(result.resultId, selectedSequence, { signal: controller.signal })
|
||||
.then((next) => { if (!controller.signal.aborted) setShadowCase(next); })
|
||||
.catch((caught: unknown) => { if (!controller.signal.aborted) setError(caught instanceof Error ? caught.message : "Визуальный кейс L3.4B недоступен."); })
|
||||
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
||||
return () => controller.abort();
|
||||
}, [result.resultId, selectedSequence]);
|
||||
|
||||
const selectedIndex = orderedCases.findIndex((item) => item.truthIslandSequence === selectedSequence);
|
||||
const navigate = (offset: -1 | 1) => {
|
||||
if (selectedIndex < 0 || !orderedCases.length) return;
|
||||
setSelectedSequence(orderedCases[(selectedIndex + offset + orderedCases.length) % orderedCases.length].truthIslandSequence);
|
||||
};
|
||||
const actions = (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton label="Предыдущий L3.4B case" onClick={() => navigate(-1)}><Icon name="chevron-left" size={16} /></IconButton>
|
||||
<IconButton label="Следующий L3.4B case" onClick={() => navigate(1)}><Icon name="chevron-right" size={16} /></IconButton>
|
||||
</div>
|
||||
<Select
|
||||
label="Выбрать L3.4B case"
|
||||
value={String(selectedSequence)}
|
||||
options={orderedCases.map((item) => ({ value: String(item.truthIslandSequence), label: caseLabel(item) }))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
searchable
|
||||
searchPlaceholder="Найти кадр или группу"
|
||||
onChange={(value) => setSelectedSequence(Number(value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const overlay = shadowCase ? (
|
||||
<div className="l3-visual-audit__overlay">
|
||||
<div><span>RAVNOVES00 · sensor.camera.right</span><strong>{shadowCase.sessionSeconds.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} с · frame {shadowCase.frameIndex}</strong><small>Sequence {shadowCase.truthIslandSequence}/32 · {shadowCase.groupId}</small></div>
|
||||
<div><span>Nested same-class overlap ≥95%</span><strong>{shadowCase.beforeSummary.predictionCount} → {shadowCase.afterSummary.predictionCount} boxes</strong><small>{shadowCase.beforeSummary.falsePositive} → {shadowCase.afterSummary.falsePositive} assisted FP · recall unchanged</small></div>
|
||||
<div className="l3-visual-audit__legend"><span data-tone="fp">Красный пунктир · исходные вложенные рамки</span><span data-tone="tp">Зелёный · объединённая геометрия</span><span data-tone="prediction">SOURCE · чистый hash-bound кадр</span></div>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className="l3-visual-audit">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="L3.4B nested-box consolidation before/after"
|
||||
mode={mode}
|
||||
modes={[{ value: "after", label: "AFTER" }, { value: "before", label: "BEFORE" }, { value: "source", label: "SOURCE" }]}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
>
|
||||
{loading ? <div className="l3-visual-audit__state" role="status"><span className="busy-indicator" aria-hidden="true" /><span>Открываем BEFORE/AFTER evidence</span></div>
|
||||
: error || !shadowCase ? <div className="l3-visual-audit__state" role="status"><Icon name="alert" size={18} /><span>{error ?? "Визуальный кейс L3.4B недоступен."}</span></div>
|
||||
: <L34BNestedBoxConsolidationScene shadowCase={shadowCase} mode={mode} />}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { L34CResult } from "../../core/laboratory/l34cTileSeamStitch";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { L34CTileSeamStitchVisual } from "./L34CTileSeamStitchVisual";
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${(value * 100).toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
|
||||
}
|
||||
|
||||
export function L34CTileSeamStitchResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: L34CResult;
|
||||
}) {
|
||||
const { before, after } = result.metrics;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={<LaboratorySummary
|
||||
title="LAB L3.4C · temporal tile-seam stitch shadow"
|
||||
description="Tile provenance восстановлен exact join с qualification; front/left split объединяется только при устойчивой геометрии минимум на трёх последовательных кадрах."
|
||||
status="Regression-free assisted shadow · не truth"
|
||||
statusTone="success"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · RIGHT camera · recorded replay` },
|
||||
{ label: "Provenance", value: "Exact L3.4 ↔ qualification · front / left tile" },
|
||||
{ label: "Временной контракт", value: `≥${result.profile.minimumConsecutiveFrames} кадров · union IoU ≥${result.profile.temporalUnionIouThreshold.toLocaleString("ru-RU")}` },
|
||||
{ label: "Полномочия", value: "Shadow post-processing · candidate не принят" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Можно ли убрать устойчивый разрыв одной машины между front и left rectification tiles, не склеивая соседние объекты?",
|
||||
approach: "Каждая из 268 frozen YOLOX-рамок exact-join связана с raw label, class id, center и rectification tile из immutable qualification. Склейка допускается только для одного класса, front/left, крупной пересекающейся seam-геометрии и серии минимум из трёх последовательных кадров с union-IoU не ниже 0,80.",
|
||||
principalResult: `${formatNumber(before.predictionCount, 0)} → ${formatNumber(after.predictionCount, 0)} рамок, ${formatNumber(before.falsePositive, 0)} → ${formatNumber(after.falsePositive, 0)} assisted FP и ${formatNumber(before.duplicateFalsePositive, 0)} → ${formatNumber(after.duplicateFalsePositive, 0)} duplicate-сигналов; TP и FN не изменились.`,
|
||||
limitation: "Это source-scoped assisted shadow, а не blind accuracy. Подтверждён только один четырёхкадровый front/left run; одиночные случаи, front/right, другой маршрут и live/hardware не допущены.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "deterministic",
|
||||
pipelineId: result.pipelineId,
|
||||
components: [
|
||||
{ kind: "source", name: "L3.4 immutable freeze", version: "268 normalized YOLOX boxes", role: "prediction substrate", identitySha256: null },
|
||||
{ kind: "source", name: "rectified YOLOX qualification", version: "4489 frames", role: "exact tile provenance", identitySha256: null },
|
||||
{ kind: "algorithm", name: result.profile.profileId, version: "front/left · temporal ≥3 · union", role: "fail-closed seam stitch shadow", identitySha256: result.resultId.split("-").at(-1) ?? null },
|
||||
],
|
||||
}}
|
||||
/>}
|
||||
evidence={<LaboratoryEvidence
|
||||
eyebrow="TILE-AWARE SHADOW · BEFORE / AFTER / SOURCE"
|
||||
title="Одна front/left seam-композиция подтверждена на четырёх кадрах"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<L34CTileSeamStitchVisual result={result} />
|
||||
</LaboratoryEvidence>}
|
||||
result={<LaboratoryResultSummary
|
||||
title="Temporal front/left stitch прошёл assisted-проверку"
|
||||
status="Bounded shadow accepted · not candidate acceptance"
|
||||
statusTone="success"
|
||||
metrics={[
|
||||
{ label: "Assisted precision", value: `${formatPercent(before.precisionIou50)} → ${formatPercent(after.precisionIou50)}`, hint: "+1,3 п.п. · не blind" },
|
||||
{ label: "Assisted recall", value: formatPercent(after.recallIou50), hint: "Без изменения" },
|
||||
{ label: "Duplicate FP", value: `${formatNumber(before.duplicateFalsePositive, 0)} → ${formatNumber(after.duplicateFalsePositive, 0)}`, hint: "Четыре seam-рамки объединены" },
|
||||
{ label: "Temporal admission", value: `${formatNumber(result.metrics.stitchCount, 0)} / ${formatNumber(result.metrics.staticCandidateCount, 0)}`, hint: "Один статический кандидат отклонён" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "На текущих 32 записанных RIGHT-кадрах exact tile provenance однозначно восстанавливается для каждой frozen prediction. Один front/left seam-run на кадрах 2620–2623 проходит временной контракт; четыре duplicate FP исчезают без ухудшения assisted TP, FN, recall или class mismatch.",
|
||||
notProved: "Не доказаны blind AP/recall, перенос на front/right, другой маршрут, live/hardware, LiDAR range, navigation или safety. Assisted review не является независимой truth.",
|
||||
decision: "Принять temporal seam stitch как отдельный bounded shadow. Глобальный NMS оставить неизменным. Следом композиционно проверить принятые L3.4B nested-box и L3.4C seam правила и только затем замораживать единый cumulative candidate.",
|
||||
}}
|
||||
/>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { L34CCase } from "../../core/laboratory/l34cTileSeamStitch";
|
||||
|
||||
type SceneMode = "after" | "before" | "source";
|
||||
|
||||
function color(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
alpha = 1,
|
||||
): string {
|
||||
const value = getComputedStyle(host).getPropertyValue(token).trim();
|
||||
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
export function L34CTileSeamStitchScene({
|
||||
shadowCase,
|
||||
mode,
|
||||
}: {
|
||||
shadowCase: L34CCase;
|
||||
mode: SceneMode;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const next = new Image();
|
||||
next.decoding = "async";
|
||||
next.onload = () => { setImage(next); setFailed(false); };
|
||||
next.onerror = () => { setImage(null); setFailed(true); };
|
||||
next.src = shadowCase.cameraUrl;
|
||||
return () => { next.onload = null; next.onerror = null; };
|
||||
}, [shadowCase.cameraUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!host || !canvas || !image) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const ratio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * ratio);
|
||||
canvas.height = Math.round(height * ratio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6]);
|
||||
context.fillRect(0, 0, width, height);
|
||||
const scale = Math.min(
|
||||
width / shadowCase.cameraWidth,
|
||||
height / shadowCase.cameraHeight,
|
||||
);
|
||||
const drawWidth = shadowCase.cameraWidth * scale;
|
||||
const drawHeight = shadowCase.cameraHeight * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
context.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
|
||||
if (mode === "source") return;
|
||||
|
||||
const sourceIndices = new Set(
|
||||
shadowCase.stitches.flatMap((item) => item.sourcePredictionIndices),
|
||||
);
|
||||
const predictions = mode === "before"
|
||||
? shadowCase.beforePredictions
|
||||
: shadowCase.afterPredictions;
|
||||
const mergedIndices = new Set(
|
||||
predictions
|
||||
.filter((prediction) => (
|
||||
"sourcePredictionIndices" in prediction
|
||||
&& prediction.sourcePredictionIndices.length > 1
|
||||
))
|
||||
.map((prediction) => prediction.predictionIndex),
|
||||
);
|
||||
let highlightedLabelIndex = 0;
|
||||
predictions.forEach((prediction) => {
|
||||
const highlighted = mode === "before"
|
||||
? sourceIndices.has(prediction.predictionIndex)
|
||||
: mergedIndices.has(prediction.predictionIndex);
|
||||
const [left, top, right, bottom] = prediction.boxXyxy;
|
||||
const x = offsetX + left * scale;
|
||||
const y = offsetY + top * scale;
|
||||
const boxWidth = (right - left) * scale;
|
||||
const boxHeight = (bottom - top) * scale;
|
||||
const tile = "rectificationTile" in prediction
|
||||
? prediction.rectificationTile
|
||||
: prediction.sourceRectificationTiles.join("+");
|
||||
const stroke = highlighted
|
||||
? mode === "after"
|
||||
? color(host, "--nodedc-success-rgb", [181, 255, 90])
|
||||
: tile === "front"
|
||||
? color(host, "--nodedc-accent-rgb", [232, 56, 126])
|
||||
: color(host, "--nodedc-warning-rgb", [255, 197, 92])
|
||||
: color(host, "--nodedc-foreground-rgb", [240, 240, 240], 0.35);
|
||||
context.strokeStyle = stroke;
|
||||
context.lineWidth = highlighted
|
||||
? Math.max(2, 2.7 * scale)
|
||||
: Math.max(1, 1.1 * scale);
|
||||
context.setLineDash(mode === "before" && highlighted ? [7, 4] : []);
|
||||
context.strokeRect(x, y, boxWidth, boxHeight);
|
||||
context.setLineDash([]);
|
||||
if (!highlighted) return;
|
||||
const label = mode === "before"
|
||||
? `BEFORE · ${tile} · ${prediction.category} · ${(prediction.score * 100).toFixed(0)}%`
|
||||
: `AFTER · front+left union · ${prediction.category} · ${(prediction.score * 100).toFixed(0)}%`;
|
||||
const fontSize = Math.max(10, 11 * scale);
|
||||
context.font = `600 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
const labelWidth = context.measureText(label).width + 12;
|
||||
const labelHeight = fontSize + 8;
|
||||
const labelX = Math.min(
|
||||
offsetX + drawWidth - labelWidth,
|
||||
Math.max(offsetX, x),
|
||||
);
|
||||
const labelY = Math.max(
|
||||
offsetY,
|
||||
y - labelHeight - 3 - highlightedLabelIndex * (labelHeight + 3),
|
||||
);
|
||||
highlightedLabelIndex += 1;
|
||||
context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9);
|
||||
context.fillRect(labelX, labelY, labelWidth, labelHeight);
|
||||
context.fillStyle = stroke;
|
||||
context.fillText(label, labelX + 6, labelY + fontSize + 1);
|
||||
});
|
||||
};
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [image, mode, shadowCase]);
|
||||
|
||||
return (
|
||||
<div className="l32-camera-scene" ref={hostRef}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label={`L3.4C frame ${shadowCase.frameIndex}: ${mode} tile-seam stitch`}
|
||||
/>
|
||||
{failed ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
Точный кадр L3.4C недоступен.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon, IconButton, Select } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
fetchL34CCase,
|
||||
type L34CCase,
|
||||
type L34CCaseSummary,
|
||||
type L34CResult,
|
||||
} from "../../core/laboratory/l34cTileSeamStitch";
|
||||
import { L34CTileSeamStitchScene } from "./L34CTileSeamStitchScene";
|
||||
|
||||
type ViewerMode = "after" | "before" | "source";
|
||||
|
||||
function caseLabel(item: L34CCaseSummary): string {
|
||||
return `${item.truthIslandSequence}/32 · frame ${item.frameIndex} · ${item.groupId} · ${item.stitchCount ? "STITCHED" : "UNCHANGED"}`;
|
||||
}
|
||||
|
||||
export function L34CTileSeamStitchVisual({ result }: { result: L34CResult }) {
|
||||
const orderedCases = useMemo(() => {
|
||||
const bySequence = new Map(result.cases.map((item) => [item.truthIslandSequence, item]));
|
||||
return result.caseOrder
|
||||
.map((sequence) => bySequence.get(sequence))
|
||||
.filter((item): item is L34CCaseSummary => item !== undefined);
|
||||
}, [result.caseOrder, result.cases]);
|
||||
const [selectedSequence, setSelectedSequence] = useState(
|
||||
orderedCases[0]?.truthIslandSequence ?? 1,
|
||||
);
|
||||
const [shadowCase, setShadowCase] = useState<L34CCase | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<ViewerMode>("after");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setShadowCase(null);
|
||||
void fetchL34CCase(result.resultId, selectedSequence, {
|
||||
signal: controller.signal,
|
||||
}).then((next) => {
|
||||
if (!controller.signal.aborted) setShadowCase(next);
|
||||
}).catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(caught instanceof Error
|
||||
? caught.message
|
||||
: "Визуальный кейс L3.4C недоступен.");
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId, selectedSequence]);
|
||||
|
||||
const selectedIndex = orderedCases.findIndex(
|
||||
(item) => item.truthIslandSequence === selectedSequence,
|
||||
);
|
||||
const navigate = (offset: -1 | 1) => {
|
||||
if (selectedIndex < 0 || !orderedCases.length) return;
|
||||
const next = (selectedIndex + offset + orderedCases.length) % orderedCases.length;
|
||||
setSelectedSequence(orderedCases[next].truthIslandSequence);
|
||||
};
|
||||
const actions = (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton label="Предыдущий L3.4C case" onClick={() => navigate(-1)}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Следующий L3.4C case" onClick={() => navigate(1)}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Select
|
||||
label="Выбрать L3.4C case"
|
||||
value={String(selectedSequence)}
|
||||
options={orderedCases.map((item) => ({
|
||||
value: String(item.truthIslandSequence),
|
||||
label: caseLabel(item),
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
searchable
|
||||
searchPlaceholder="Найти кадр или группу"
|
||||
onChange={(value) => setSelectedSequence(Number(value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const stitch = shadowCase?.stitches[0];
|
||||
const overlay = shadowCase ? (
|
||||
<div className="l3-visual-audit__overlay">
|
||||
<div>
|
||||
<span>RAVNOVES00 · sensor.camera.right</span>
|
||||
<strong>{shadowCase.sessionSeconds.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} с · frame {shadowCase.frameIndex}</strong>
|
||||
<small>Sequence {shadowCase.truthIslandSequence}/32 · {shadowCase.groupId}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Exact tile provenance · temporal seam contract</span>
|
||||
<strong>{stitch ? `${stitch.sourceTiles.join(" + ")} → union` : "UNCHANGED"}</strong>
|
||||
<small>{stitch ? `${stitch.temporalRunLength} consecutive frames · source IoU ${stitch.sourceIou.toLocaleString("ru-RU", { maximumFractionDigits: 2 })}` : "Не прошёл fail-closed admission"}</small>
|
||||
</div>
|
||||
<div className="l3-visual-audit__legend">
|
||||
<span data-tone="fp">BEFORE · front и left source boxes</span>
|
||||
<span data-tone="tp">AFTER · единая union geometry</span>
|
||||
<span data-tone="prediction">SOURCE · чистый hash-bound кадр</span>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className="l3-visual-audit">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="L3.4C temporal tile-seam stitch before/after"
|
||||
mode={mode}
|
||||
modes={[
|
||||
{ value: "after", label: "AFTER" },
|
||||
{ value: "before", label: "BEFORE" },
|
||||
{ value: "source", label: "SOURCE" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Открываем tile-aware BEFORE/AFTER evidence</span>
|
||||
</div>
|
||||
) : error || !shadowCase ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{error ?? "Визуальный кейс L3.4C недоступен."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<L34CTileSeamStitchScene shadowCase={shadowCase} mode={mode} />
|
||||
)}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { L34DResult } from "../../core/laboratory/l34dCumulativePostprocessing";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { L34DCumulativePostprocessingVisual } from "./L34DCumulativePostprocessingVisual";
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${(value * 100).toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
|
||||
}
|
||||
|
||||
export function L34DCumulativePostprocessingResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: L34DResult;
|
||||
}) {
|
||||
const { before, after } = result.metrics;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={<LaboratorySummary
|
||||
title="LAB L3.4D · cumulative B+C candidate freeze"
|
||||
description="Принятые nested-box и temporal seam операции применены одновременно к исходным L3.4 indices; любое пересечение политик отклоняет сборку."
|
||||
status="Cumulative candidate frozen · не truth"
|
||||
statusTone="success"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · RIGHT camera · recorded replay` },
|
||||
{ label: "Композиция", value: "L3.4B nested-box + L3.4C temporal seam" },
|
||||
{ label: "Инвариант", value: "Original source indices · 0 policy conflicts" },
|
||||
{ label: "Полномочия", value: "Candidate frozen · blind gate закрыт" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Можно ли объединить уже принятые L3.4B и L3.4C правила в один детерминированный candidate без зависимости от порядка и без assisted-регрессии?",
|
||||
approach: "Операции B и C повторно связаны с исходными L3.4 prediction indices и проверены против immutable payload. Они применяются одновременно; пересечение source indices, drift рамок, score, класса или tile provenance приводит к отказу сборки.",
|
||||
principalResult: `${formatNumber(before.predictionCount, 0)} → ${formatNumber(after.predictionCount, 0)} рамки и ${formatNumber(before.falsePositive, 0)} → ${formatNumber(after.falsePositive, 0)} assisted FP. Одна nested-box и четыре seam-операции дали точную сумму count-effects; TP, FN и recall не изменились.`,
|
||||
limitation: "Candidate только заморожен, но не принят: сравнение использует ту же candidate-seeded assisted review. Independent prediction-hidden labels ещё не получены; live, hardware, left camera и другой маршрут не проверялись.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "deterministic",
|
||||
pipelineId: result.pipelineId,
|
||||
components: [
|
||||
{ kind: "source", name: "L3.4B immutable shadow", version: "nested-box v1", role: "source-indexed consolidation operations", identitySha256: null },
|
||||
{ kind: "source", name: "L3.4C immutable shadow", version: "temporal seam v1", role: "tile provenance and seam operations", identitySha256: null },
|
||||
{ kind: "algorithm", name: result.profile.profileId, version: "fail-closed source-index composition", role: "freeze one cumulative post-processing candidate", identitySha256: result.resultId.split("-").at(-1) ?? null },
|
||||
],
|
||||
}}
|
||||
/>}
|
||||
evidence={<LaboratoryEvidence
|
||||
eyebrow="CUMULATIVE CANDIDATE · BEFORE / AFTER / SOURCE"
|
||||
title="Пять изменённых кадров: одна nested-box и четыре temporal seam операции"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<L34DCumulativePostprocessingVisual result={result} />
|
||||
</LaboratoryEvidence>}
|
||||
result={<LaboratoryResultSummary
|
||||
title="B+C композиция заморожена без assisted-регрессии"
|
||||
status="Frozen candidate · not independent acceptance"
|
||||
statusTone="success"
|
||||
metrics={[
|
||||
{ label: "Assisted precision", value: `${formatPercent(before.precisionIou50)} → ${formatPercent(after.precisionIou50)}`, hint: "+1,7 п.п. · не blind" },
|
||||
{ label: "Assisted recall", value: formatPercent(after.recallIou50), hint: "Без изменения" },
|
||||
{ label: "False positive", value: `${formatNumber(before.falsePositive, 0)} → ${formatNumber(after.falsePositive, 0)}`, hint: "−5 на 32 кадрах" },
|
||||
{ label: "B / C / conflicts", value: `${result.metrics.nestedConsolidationCount} / ${result.metrics.temporalStitchCount} / ${result.metrics.operationConflictCount}`, hint: "Count-effects additive" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "На текущем immutable RIGHT-наборе L3.4B и L3.4C работают на непересекающихся исходных predictions. Совместная проекция воспроизводит одну nested-box и четыре temporal seam операции, убирает пять assisted FP и не меняет TP, FN, recall или class mismatch.",
|
||||
notProved: "Не доказаны blind AP/recall, независимость assisted labels, перенос на другой маршрут или камеру, live/hardware, LiDAR range, navigation и safety. Замороженный candidate ещё не является принятым detector pipeline.",
|
||||
decision: "Зафиксировать этот exact B+C состав как единственный cumulative candidate. Не менять правила, thresholds, global NMS или модель до одного prediction-hidden независимого label round и одноразовой оценки после раскрытия.",
|
||||
}}
|
||||
/>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type {
|
||||
L34DCase,
|
||||
L34DOperationType,
|
||||
} from "../../core/laboratory/l34dCumulativePostprocessing";
|
||||
|
||||
type SceneMode = "after" | "before" | "source";
|
||||
|
||||
function color(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
alpha = 1,
|
||||
): string {
|
||||
const value = getComputedStyle(host).getPropertyValue(token).trim();
|
||||
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
function shortOperation(type: L34DOperationType | undefined): string {
|
||||
return type === "nested-box-consolidation" ? "B nested union"
|
||||
: type === "temporal-tile-seam-stitch" ? "C seam union"
|
||||
: "unchanged";
|
||||
}
|
||||
|
||||
export function L34DCumulativePostprocessingScene({
|
||||
candidateCase,
|
||||
mode,
|
||||
}: {
|
||||
candidateCase: L34DCase;
|
||||
mode: SceneMode;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const next = new Image();
|
||||
next.decoding = "async";
|
||||
next.onload = () => { setImage(next); setFailed(false); };
|
||||
next.onerror = () => { setImage(null); setFailed(true); };
|
||||
next.src = candidateCase.cameraUrl;
|
||||
return () => { next.onload = null; next.onerror = null; };
|
||||
}, [candidateCase.cameraUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!host || !canvas || !image) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const ratio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * ratio);
|
||||
canvas.height = Math.round(height * ratio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6]);
|
||||
context.fillRect(0, 0, width, height);
|
||||
const scale = Math.min(
|
||||
width / candidateCase.cameraWidth,
|
||||
height / candidateCase.cameraHeight,
|
||||
);
|
||||
const drawWidth = candidateCase.cameraWidth * scale;
|
||||
const drawHeight = candidateCase.cameraHeight * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
context.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
|
||||
if (mode === "source") return;
|
||||
|
||||
const operationBySourceIndex = new Map<number, L34DOperationType>();
|
||||
candidateCase.operations.forEach((operation) => {
|
||||
operation.sourcePredictionIndices.forEach((index) => {
|
||||
operationBySourceIndex.set(index, operation.operationType);
|
||||
});
|
||||
});
|
||||
const predictions = mode === "before"
|
||||
? candidateCase.beforePredictions
|
||||
: candidateCase.afterPredictions;
|
||||
let highlightedLabelIndex = 0;
|
||||
predictions.forEach((prediction) => {
|
||||
const operation = mode === "before"
|
||||
? operationBySourceIndex.get(prediction.predictionIndex)
|
||||
: "operationTypes" in prediction
|
||||
? prediction.operationTypes[0]
|
||||
: undefined;
|
||||
const highlighted = operation !== undefined;
|
||||
const [left, top, right, bottom] = prediction.boxXyxy;
|
||||
const x = offsetX + left * scale;
|
||||
const y = offsetY + top * scale;
|
||||
const boxWidth = (right - left) * scale;
|
||||
const boxHeight = (bottom - top) * scale;
|
||||
const tile = "rectificationTile" in prediction
|
||||
? prediction.rectificationTile
|
||||
: prediction.sourceRectificationTiles.join("+");
|
||||
const stroke = highlighted
|
||||
? mode === "after"
|
||||
? color(host, "--nodedc-success-rgb", [181, 255, 90])
|
||||
: operation === "nested-box-consolidation"
|
||||
? color(host, "--nodedc-warning-rgb", [255, 197, 92])
|
||||
: tile === "front"
|
||||
? color(host, "--nodedc-accent-rgb", [232, 56, 126])
|
||||
: color(host, "--nodedc-warning-rgb", [255, 197, 92])
|
||||
: color(host, "--nodedc-foreground-rgb", [240, 240, 240], 0.35);
|
||||
context.strokeStyle = stroke;
|
||||
context.lineWidth = highlighted
|
||||
? Math.max(2, 2.7 * scale)
|
||||
: Math.max(1, 1.1 * scale);
|
||||
context.setLineDash(mode === "before" && highlighted ? [7, 4] : []);
|
||||
context.strokeRect(x, y, boxWidth, boxHeight);
|
||||
context.setLineDash([]);
|
||||
if (!highlighted) return;
|
||||
const label = mode === "before"
|
||||
? `BEFORE · ${operation === "nested-box-consolidation" ? "B nested" : tile} · ${prediction.category} · ${(prediction.score * 100).toFixed(0)}%`
|
||||
: `AFTER · ${shortOperation(operation)} · ${prediction.category} · ${(prediction.score * 100).toFixed(0)}%`;
|
||||
const fontSize = Math.max(10, 11 * scale);
|
||||
context.font = `600 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
const labelWidth = context.measureText(label).width + 12;
|
||||
const labelHeight = fontSize + 8;
|
||||
const labelX = Math.min(
|
||||
offsetX + drawWidth - labelWidth,
|
||||
Math.max(offsetX, x),
|
||||
);
|
||||
const labelY = Math.max(
|
||||
offsetY,
|
||||
y - labelHeight - 3 - highlightedLabelIndex * (labelHeight + 3),
|
||||
);
|
||||
highlightedLabelIndex += 1;
|
||||
context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9);
|
||||
context.fillRect(labelX, labelY, labelWidth, labelHeight);
|
||||
context.fillStyle = stroke;
|
||||
context.fillText(label, labelX + 6, labelY + fontSize + 1);
|
||||
});
|
||||
};
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [candidateCase, image, mode]);
|
||||
|
||||
return (
|
||||
<div className="l32-camera-scene" ref={hostRef}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label={`L3.4D frame ${candidateCase.frameIndex}: ${mode} cumulative candidate`}
|
||||
/>
|
||||
{failed ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
Точный кадр L3.4D недоступен.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon, IconButton, Select } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
fetchL34DCase,
|
||||
type L34DCase,
|
||||
type L34DCaseSummary,
|
||||
type L34DOperationType,
|
||||
type L34DResult,
|
||||
} from "../../core/laboratory/l34dCumulativePostprocessing";
|
||||
import { L34DCumulativePostprocessingScene } from "./L34DCumulativePostprocessingScene";
|
||||
|
||||
type ViewerMode = "after" | "before" | "source";
|
||||
|
||||
function operationLabel(types: readonly L34DOperationType[]): string {
|
||||
return types.includes("nested-box-consolidation")
|
||||
? "B · NESTED"
|
||||
: types.includes("temporal-tile-seam-stitch")
|
||||
? "C · SEAM"
|
||||
: "UNCHANGED";
|
||||
}
|
||||
|
||||
function caseLabel(item: L34DCaseSummary): string {
|
||||
return `${item.truthIslandSequence}/32 · frame ${item.frameIndex} · ${item.groupId} · ${operationLabel(item.operationTypes)}`;
|
||||
}
|
||||
|
||||
export function L34DCumulativePostprocessingVisual({
|
||||
result,
|
||||
}: {
|
||||
result: L34DResult;
|
||||
}) {
|
||||
const orderedCases = useMemo(() => {
|
||||
const bySequence = new Map(
|
||||
result.cases.map((item) => [item.truthIslandSequence, item]),
|
||||
);
|
||||
return result.caseOrder
|
||||
.map((sequence) => bySequence.get(sequence))
|
||||
.filter((item): item is L34DCaseSummary => item !== undefined);
|
||||
}, [result.caseOrder, result.cases]);
|
||||
const [selectedSequence, setSelectedSequence] = useState(
|
||||
orderedCases[0]?.truthIslandSequence ?? 1,
|
||||
);
|
||||
const [candidateCase, setCandidateCase] = useState<L34DCase | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<ViewerMode>("after");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCandidateCase(null);
|
||||
void fetchL34DCase(result.resultId, selectedSequence, {
|
||||
signal: controller.signal,
|
||||
}).then((next) => {
|
||||
if (!controller.signal.aborted) setCandidateCase(next);
|
||||
}).catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(caught instanceof Error
|
||||
? caught.message
|
||||
: "Визуальный кейс L3.4D недоступен.");
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId, selectedSequence]);
|
||||
|
||||
const selectedIndex = orderedCases.findIndex(
|
||||
(item) => item.truthIslandSequence === selectedSequence,
|
||||
);
|
||||
const navigate = (offset: -1 | 1) => {
|
||||
if (selectedIndex < 0 || !orderedCases.length) return;
|
||||
const next = (selectedIndex + offset + orderedCases.length)
|
||||
% orderedCases.length;
|
||||
setSelectedSequence(orderedCases[next].truthIslandSequence);
|
||||
};
|
||||
const actions = (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton label="Предыдущий L3.4D case" onClick={() => navigate(-1)}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Следующий L3.4D case" onClick={() => navigate(1)}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Select
|
||||
label="Выбрать L3.4D case"
|
||||
value={String(selectedSequence)}
|
||||
options={orderedCases.map((item) => ({
|
||||
value: String(item.truthIslandSequence),
|
||||
label: caseLabel(item),
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
searchable
|
||||
searchPlaceholder="Найти кадр или операцию"
|
||||
onChange={(value) => setSelectedSequence(Number(value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const operation = candidateCase?.operations[0];
|
||||
const operationSummary = operation?.operationType
|
||||
=== "nested-box-consolidation"
|
||||
? "L3.4B nested-box → union"
|
||||
: operation?.operationType === "temporal-tile-seam-stitch"
|
||||
? `${operation.sourceTiles.join(" + ")} → union`
|
||||
: "UNCHANGED";
|
||||
const operationDetail = operation?.operationType
|
||||
=== "nested-box-consolidation"
|
||||
? `${operation.sourcePredictionIndices.length} nested source boxes · ${operation.category}`
|
||||
: operation?.operationType === "temporal-tile-seam-stitch"
|
||||
? `${operation.temporalRunLength} consecutive frames · ${operation.category}`
|
||||
: "Ни одно принятое правило не изменяет этот кадр";
|
||||
const overlay = candidateCase ? (
|
||||
<div className="l3-visual-audit__overlay">
|
||||
<div>
|
||||
<span>RAVNOVES00 · sensor.camera.right</span>
|
||||
<strong>{candidateCase.sessionSeconds.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} с · frame {candidateCase.frameIndex}</strong>
|
||||
<small>Sequence {candidateCase.truthIslandSequence}/32 · {candidateCase.groupId}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Frozen source-index cumulative candidate</span>
|
||||
<strong>{operationSummary}</strong>
|
||||
<small>{operationDetail}</small>
|
||||
</div>
|
||||
<div className="l3-visual-audit__legend">
|
||||
<span data-tone="fp">BEFORE · исходные B/C boxes</span>
|
||||
<span data-tone="tp">AFTER · frozen cumulative geometry</span>
|
||||
<span data-tone="prediction">SOURCE · чистый hash-bound кадр</span>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className="l3-visual-audit">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="L3.4D cumulative post-processing before/after"
|
||||
mode={mode}
|
||||
modes={[
|
||||
{ value: "after", label: "AFTER" },
|
||||
{ value: "before", label: "BEFORE" },
|
||||
{ value: "source", label: "SOURCE" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Открываем cumulative BEFORE/AFTER evidence</span>
|
||||
</div>
|
||||
) : error || !candidateCase ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{error ?? "Визуальный кейс L3.4D недоступен."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<L34DCumulativePostprocessingScene
|
||||
candidateCase={candidateCase}
|
||||
mode={mode}
|
||||
/>
|
||||
)}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { L34EResult } from "../../core/laboratory/l34eSelfReviewDiagnostic";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { L34ESelfReviewDiagnosticVisual } from "./L34ESelfReviewDiagnosticVisual";
|
||||
|
||||
function percent(value: number): string {
|
||||
return `${(value * 100).toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
|
||||
}
|
||||
|
||||
export function L34ESelfReviewDiagnosticResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: L34EResult;
|
||||
}) {
|
||||
const strict = result.metrics.strictIou50;
|
||||
const diagnostic = result.metrics.diagnosticAssociation;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={<LaboratorySummary
|
||||
title="LAB L3.4E · self-review diagnostic disagreement audit"
|
||||
description="Ручной prediction-hidden проход сопоставлен с exact L3.4D candidate. Строгий IoU50 сохранён как технический срез, но отделён от объектных совпадений и ошибок локализации."
|
||||
status="Diagnostic complete · reference не metric-grade"
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · RIGHT camera · recorded replay` },
|
||||
{ label: "Разметка", value: "32/32 · 260 manual objects · 0 prelabels" },
|
||||
{ label: "Сопоставление", value: "IoU50 strict → loose visual association" },
|
||||
{ label: "Полномочия", value: "Not truth · retuning запрещён · blind gate закрыт" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Что именно расходится между замороженным L3.4D candidate и ручным self-review: существование объекта, класс или геометрия рамки?",
|
||||
approach: "Сначала выполнен воспроизводимый greedy IoU50. Затем только для оставшихся объектов проведена диагностическая spatial-association по IoU ≥ 0,10 или overlap-over-smaller ≥ 0,30. Каждый конфликт опубликован поверх точного hash-bound source frame.",
|
||||
principalResult: `${formatNumber(diagnostic.associatedPairCount, 0)} объектных пар сопоставлены: ${formatNumber(diagnostic.strictAlignment, 0)} строгих, ${formatNumber(diagnostic.localizationDisagreement, 0)} с разъехавшимися рамками и ${formatNumber(diagnostic.strictClassMismatch + diagnostic.classAndLocalizationDisagreement, 0)} с конфликтом класса. Без пары остались ${formatNumber(diagnostic.predictionOnly, 0)} candidate и ${formatNumber(diagnostic.referenceOnly, 0)} self-review объектов.`,
|
||||
limitation: `Strict IoU50 F1 = ${percent(strict.f1Iou50)} не является качеством детектора: ручные рамки грубые, особенно на дальних машинах, а reviewer видел identity candidate. Нужны refinement/adjudication и независимый reviewer.`,
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "deterministic",
|
||||
pipelineId: result.pipelineId,
|
||||
components: [
|
||||
{ kind: "source", name: "L3.4D immutable candidate", version: "cumulative B+C freeze", role: "candidate boxes and exact source binding", identitySha256: null },
|
||||
{ kind: "source", name: "Manual self-review", version: "revision 3 · prediction-hidden", role: "coarse diagnostic reference · not truth", identitySha256: null },
|
||||
{ kind: "algorithm", name: result.profile.profileId, version: "strict IoU50 + loose spatial association", role: "classify alignment, localization and unmatched objects", identitySha256: result.resultId.split("-").at(-1) ?? null },
|
||||
],
|
||||
}}
|
||||
/>}
|
||||
evidence={<LaboratoryEvidence
|
||||
eyebrow="DIAGNOSTIC MISMATCH GALLERY · OVERLAY / CANDIDATE / SELF-REVIEW / SOURCE"
|
||||
title="Все 32 кадра отсортированы по тяжести расхождения"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<L34ESelfReviewDiagnosticVisual result={result} />
|
||||
</LaboratoryEvidence>}
|
||||
result={<LaboratoryResultSummary
|
||||
title="Self-review выявил расхождение, но не дал metric-grade benchmark"
|
||||
status="Refine labels before detector decision"
|
||||
statusTone="warning"
|
||||
metrics={[
|
||||
{ label: "Object associations", value: `${formatNumber(diagnostic.associatedPairCount, 0)} / ${formatNumber(diagnostic.referenceCount, 0)}`, hint: `${percent(diagnostic.referenceAssociationCoverage)} self-review coverage` },
|
||||
{ label: "Localization", value: formatNumber(diagnostic.localizationDisagreement, 0), hint: "Object paired · geometry disagrees" },
|
||||
{ label: "Unmatched P / R", value: `${formatNumber(diagnostic.predictionOnly, 0)} / ${formatNumber(diagnostic.referenceOnly, 0)}`, hint: "Requires visual adjudication" },
|
||||
{ label: "Strict IoU50 F1", value: percent(strict.f1Iou50), hint: "Diagnostic only · not detector accuracy" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "Ручной проход завершён без prelabels, exact L3.4D source binding сохранён, и каждый конфликт можно визуально проверить. Основная масса strict FP/FN вызвана не только наличием объектов, но и сильным расхождением геометрии ручных и модельных рамок.",
|
||||
notProved: "Не доказаны detector precision/recall/AP, регрессия или прогрессия L3.4D, пригодность ручных рамок как metric-grade truth и независимость review. Loose associations являются только маршрутизацией визуального аудита.",
|
||||
decision: "Не перетюнивать модель, thresholds или post-processing по этим агрегатам. Сначала пройти mismatch gallery, уточнить спорные ручные рамки и классы, провести adjudication, затем отдать exact frozen candidate независимому reviewer и выполнить один acceptance calculation.",
|
||||
}}
|
||||
/>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type {
|
||||
L34ECase,
|
||||
L34EDiagnosticVerdict,
|
||||
} from "../../core/laboratory/l34eSelfReviewDiagnostic";
|
||||
|
||||
export type L34ESceneMode = "overlay" | "candidate" | "review" | "source";
|
||||
|
||||
function color(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
alpha = 1,
|
||||
): string {
|
||||
const value = getComputedStyle(host).getPropertyValue(token).trim();
|
||||
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
function verdictColor(
|
||||
host: HTMLElement,
|
||||
verdict: L34EDiagnosticVerdict,
|
||||
reference: boolean,
|
||||
): string {
|
||||
if (verdict === "strict_alignment") {
|
||||
return color(host, "--nodedc-success-rgb", [181, 255, 90], 0.42);
|
||||
}
|
||||
if (verdict === "localization_disagreement") {
|
||||
return color(host, "--nodedc-warning-rgb", [255, 197, 92], 0.95);
|
||||
}
|
||||
if (verdict === "strict_class_mismatch"
|
||||
|| verdict === "class_and_localization_disagreement") {
|
||||
return color(host, "--nodedc-accent-rgb", [232, 56, 126], 0.98);
|
||||
}
|
||||
return reference
|
||||
? color(host, "--nodedc-accent-rgb", [232, 56, 126], 1)
|
||||
: color(host, "--nodedc-danger-rgb", [255, 94, 94], 1);
|
||||
}
|
||||
|
||||
function shortVerdict(verdict: L34EDiagnosticVerdict): string {
|
||||
return verdict === "strict_alignment" ? "STRICT"
|
||||
: verdict === "localization_disagreement" ? "LOC"
|
||||
: verdict === "strict_class_mismatch" ? "CLASS"
|
||||
: verdict === "class_and_localization_disagreement" ? "CLASS+LOC"
|
||||
: verdict === "prediction_only" ? "P-ONLY"
|
||||
: "R-ONLY";
|
||||
}
|
||||
|
||||
export function L34ESelfReviewDiagnosticScene({
|
||||
diagnosticCase,
|
||||
mode,
|
||||
}: {
|
||||
diagnosticCase: L34ECase;
|
||||
mode: L34ESceneMode;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const next = new Image();
|
||||
next.decoding = "async";
|
||||
next.onload = () => { setImage(next); setFailed(false); };
|
||||
next.onerror = () => { setImage(null); setFailed(true); };
|
||||
next.src = diagnosticCase.cameraUrl;
|
||||
return () => { next.onload = null; next.onerror = null; };
|
||||
}, [diagnosticCase.cameraUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!host || !canvas || !image) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * pixelRatio);
|
||||
canvas.height = Math.round(height * pixelRatio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6]);
|
||||
context.fillRect(0, 0, width, height);
|
||||
const scale = Math.min(
|
||||
width / diagnosticCase.cameraWidth,
|
||||
height / diagnosticCase.cameraHeight,
|
||||
);
|
||||
const drawWidth = diagnosticCase.cameraWidth * scale;
|
||||
const drawHeight = diagnosticCase.cameraHeight * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
context.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
|
||||
if (mode === "source") return;
|
||||
|
||||
const labelRects: { x: number; y: number; width: number; height: number }[] = [];
|
||||
const drawBox = ({
|
||||
box,
|
||||
category,
|
||||
verdict,
|
||||
reference,
|
||||
}: {
|
||||
box: readonly [number, number, number, number];
|
||||
category: string;
|
||||
verdict: L34EDiagnosticVerdict;
|
||||
reference: boolean;
|
||||
}) => {
|
||||
const [left, top, right, bottom] = box;
|
||||
const x = offsetX + left * scale;
|
||||
const y = offsetY + top * scale;
|
||||
const boxWidth = (right - left) * scale;
|
||||
const boxHeight = (bottom - top) * scale;
|
||||
const stroke = verdictColor(host, verdict, reference);
|
||||
context.strokeStyle = stroke;
|
||||
context.lineWidth = verdict === "strict_alignment"
|
||||
? Math.max(1, 1.1 * scale)
|
||||
: Math.max(2, 2.4 * scale);
|
||||
context.setLineDash(reference ? [7, 4] : []);
|
||||
context.strokeRect(x, y, boxWidth, boxHeight);
|
||||
context.setLineDash([]);
|
||||
if (verdict === "strict_alignment" || mode === "overlay") return;
|
||||
const label = `${shortVerdict(verdict)} · ${category}`;
|
||||
const fontSize = Math.max(10, 11 * scale);
|
||||
context.font = `600 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
const labelWidth = context.measureText(label).width + 12;
|
||||
const labelHeight = fontSize + 8;
|
||||
const labelX = Math.min(
|
||||
offsetX + drawWidth - labelWidth,
|
||||
Math.max(offsetX, x),
|
||||
);
|
||||
const candidateY = [
|
||||
y - labelHeight - 2,
|
||||
y + boxHeight + 2,
|
||||
...Array.from({ length: 8 }, (_, index) => (
|
||||
y - (index + 2) * (labelHeight + 2)
|
||||
)),
|
||||
...Array.from({ length: 8 }, (_, index) => (
|
||||
y + boxHeight + (index + 2) * (labelHeight + 2)
|
||||
)),
|
||||
];
|
||||
const fits = (candidate: number) => {
|
||||
if (candidate < offsetY
|
||||
|| candidate + labelHeight > offsetY + drawHeight) return false;
|
||||
return labelRects.every((rect) => (
|
||||
labelX + labelWidth <= rect.x
|
||||
|| rect.x + rect.width <= labelX
|
||||
|| candidate + labelHeight <= rect.y
|
||||
|| rect.y + rect.height <= candidate
|
||||
));
|
||||
};
|
||||
const labelY = candidateY.find(fits)
|
||||
?? Math.min(offsetY + drawHeight - labelHeight, Math.max(offsetY, y));
|
||||
labelRects.push({ x: labelX, y: labelY, width: labelWidth, height: labelHeight });
|
||||
const boxAnchorX = x + boxWidth / 2;
|
||||
const boxAnchorY = y;
|
||||
const labelAnchorX = Math.min(
|
||||
labelX + labelWidth,
|
||||
Math.max(labelX, boxAnchorX),
|
||||
);
|
||||
const labelAnchorY = labelY > boxAnchorY
|
||||
? labelY
|
||||
: labelY + labelHeight;
|
||||
context.strokeStyle = stroke;
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.moveTo(boxAnchorX, boxAnchorY);
|
||||
context.lineTo(labelAnchorX, labelAnchorY);
|
||||
context.stroke();
|
||||
context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.88);
|
||||
context.fillRect(labelX, labelY, labelWidth, labelHeight);
|
||||
context.fillStyle = stroke;
|
||||
context.fillText(label, labelX + 6, labelY + fontSize + 1);
|
||||
};
|
||||
|
||||
if (mode === "overlay" || mode === "candidate") {
|
||||
diagnosticCase.predictions.forEach((prediction) => drawBox({
|
||||
box: prediction.boxXyxy,
|
||||
category: prediction.category,
|
||||
verdict: prediction.diagnosticVerdict,
|
||||
reference: false,
|
||||
}));
|
||||
}
|
||||
if (mode === "overlay" || mode === "review") {
|
||||
diagnosticCase.references.forEach((reference) => drawBox({
|
||||
box: reference.boxXyxy,
|
||||
category: reference.displayCategory,
|
||||
verdict: reference.diagnosticVerdict,
|
||||
reference: true,
|
||||
}));
|
||||
}
|
||||
};
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [diagnosticCase, image, mode]);
|
||||
|
||||
return (
|
||||
<div className="l32-camera-scene" ref={hostRef}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label={`L3.4E frame ${diagnosticCase.frameIndex}: ${mode} self-review diagnostic`}
|
||||
/>
|
||||
{failed ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
Точный кадр L3.4E недоступен.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon, IconButton, Select } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
fetchL34ECase,
|
||||
type L34ECase,
|
||||
type L34ECaseSummary,
|
||||
type L34EResult,
|
||||
} from "../../core/laboratory/l34eSelfReviewDiagnostic";
|
||||
import {
|
||||
L34ESelfReviewDiagnosticScene,
|
||||
type L34ESceneMode,
|
||||
} from "./L34ESelfReviewDiagnosticScene";
|
||||
|
||||
function caseLabel(item: L34ECaseSummary): string {
|
||||
const summary = item.diagnosticSummary;
|
||||
return `${item.truthIslandSequence}/32 · frame ${item.frameIndex} · LOC ${summary.localizationDisagreement} · P ${summary.predictionOnly} · R ${summary.referenceOnly}`;
|
||||
}
|
||||
|
||||
export function L34ESelfReviewDiagnosticVisual({
|
||||
result,
|
||||
}: {
|
||||
result: L34EResult;
|
||||
}) {
|
||||
const orderedCases = useMemo(() => {
|
||||
const bySequence = new Map(
|
||||
result.cases.map((item) => [item.truthIslandSequence, item]),
|
||||
);
|
||||
return result.caseOrder
|
||||
.map((sequence) => bySequence.get(sequence))
|
||||
.filter((item): item is L34ECaseSummary => item !== undefined);
|
||||
}, [result.caseOrder, result.cases]);
|
||||
const [selectedSequence, setSelectedSequence] = useState(
|
||||
orderedCases[0]?.truthIslandSequence ?? 1,
|
||||
);
|
||||
const [diagnosticCase, setDiagnosticCase] = useState<L34ECase | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<L34ESceneMode>("overlay");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setDiagnosticCase(null);
|
||||
void fetchL34ECase(result.resultId, selectedSequence, {
|
||||
signal: controller.signal,
|
||||
}).then((next) => {
|
||||
if (!controller.signal.aborted) setDiagnosticCase(next);
|
||||
}).catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(caught instanceof Error
|
||||
? caught.message
|
||||
: "Визуальный кейс L3.4E недоступен.");
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId, selectedSequence]);
|
||||
|
||||
const selectedIndex = orderedCases.findIndex(
|
||||
(item) => item.truthIslandSequence === selectedSequence,
|
||||
);
|
||||
const navigate = (offset: -1 | 1) => {
|
||||
if (selectedIndex < 0 || !orderedCases.length) return;
|
||||
const next = (selectedIndex + offset + orderedCases.length)
|
||||
% orderedCases.length;
|
||||
setSelectedSequence(orderedCases[next].truthIslandSequence);
|
||||
};
|
||||
const actions = (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton label="Предыдущий конфликт L3.4E" onClick={() => navigate(-1)}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Следующий конфликт L3.4E" onClick={() => navigate(1)}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Select
|
||||
label="Выбрать конфликтный кадр L3.4E"
|
||||
value={String(selectedSequence)}
|
||||
options={orderedCases.map((item) => ({
|
||||
value: String(item.truthIslandSequence),
|
||||
label: caseLabel(item),
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
searchable
|
||||
searchPlaceholder="Найти кадр или тип расхождения"
|
||||
onChange={(value) => setSelectedSequence(Number(value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const summary = diagnosticCase?.diagnosticSummary;
|
||||
const overlay = diagnosticCase && summary ? (
|
||||
<div className="l3-visual-audit__overlay">
|
||||
<div>
|
||||
<span>RAVNOVES00 · sensor.camera.right</span>
|
||||
<strong>{diagnosticCase.sessionSeconds.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} с · frame {diagnosticCase.frameIndex}</strong>
|
||||
<small>Sequence {diagnosticCase.truthIslandSequence}/32 · {diagnosticCase.groupId}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Diagnostic self-review · не truth</span>
|
||||
<strong>{summary.associatedPairCount} пар · {summary.localizationDisagreement} LOC · {summary.strictClassMismatch + summary.classAndLocalizationDisagreement} CLASS</strong>
|
||||
<small>{summary.predictionOnly} candidate-only · {summary.referenceOnly} review-only</small>
|
||||
</div>
|
||||
<div className="l3-visual-audit__legend">
|
||||
<span data-tone="tp">STRICT · совпавшая геометрия</span>
|
||||
<span data-tone="warning">LOC · объект совпал, рамка разъехалась</span>
|
||||
<span data-tone="fp">P-ONLY · только candidate</span>
|
||||
<span data-tone="fn">R-ONLY · только self-review</span>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className="l3-visual-audit">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="L3.4E candidate and manual self-review disagreement gallery"
|
||||
mode={mode}
|
||||
modes={[
|
||||
{ value: "overlay", label: "OVERLAY" },
|
||||
{ value: "candidate", label: "CANDIDATE" },
|
||||
{ value: "review", label: "SELF-REVIEW" },
|
||||
{ value: "source", label: "SOURCE" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Открываем hash-bound mismatch evidence</span>
|
||||
</div>
|
||||
) : error || !diagnosticCase ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{error ?? "Визуальный кейс L3.4E недоступен."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<L34ESelfReviewDiagnosticScene
|
||||
diagnosticCase={diagnosticCase}
|
||||
mode={mode}
|
||||
/>
|
||||
)}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import { fetchL34ESelfReviewDiagnostic, type L34EResult } from "../../core/laboratory/l34eSelfReviewDiagnostic";
|
||||
import type { L34FFrozenResult } from "../../core/laboratory/l34fAdjudication";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { L34ESelfReviewDiagnosticVisual } from "./L34ESelfReviewDiagnosticVisual";
|
||||
|
||||
function FrozenEvidence({ result }: { result: L34FFrozenResult }) {
|
||||
const [diagnostic, setDiagnostic] = useState<L34EResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetchL34ESelfReviewDiagnostic({ signal: controller.signal })
|
||||
.then((value) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (value.resultId !== result.diagnosticResultId) {
|
||||
setError("Hash-bound diagnostic source L3.4E не совпал с L3.4F.");
|
||||
return;
|
||||
}
|
||||
setDiagnostic(value);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(caught instanceof Error ? caught.message : "Evidence L3.4F недоступен.");
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.diagnosticResultId]);
|
||||
|
||||
if (error) return <div className="l3-visual-audit__state" role="status">{error}</div>;
|
||||
if (!diagnostic) {
|
||||
return <div className="l3-visual-audit__state" role="status"><span className="busy-indicator" aria-hidden="true" />Открываем frozen evidence L3.4F</div>;
|
||||
}
|
||||
return <L34ESelfReviewDiagnosticVisual result={diagnostic} />;
|
||||
}
|
||||
|
||||
export function L34FAdjudicatedReferenceResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: L34FFrozenResult;
|
||||
}) {
|
||||
const changedObjects = result.metrics.geometryChangedObjectCount
|
||||
+ result.metrics.classChangedObjectCount
|
||||
+ result.metrics.attributeChangedObjectCount
|
||||
+ result.metrics.addedObjectCount
|
||||
+ result.metrics.deletedObjectCount;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={<LaboratorySummary
|
||||
title="LAB L3.4F · candidate-visible adjudicated engineering reference"
|
||||
description="Все 32 конфликтных кадра L3.4E повторно просмотрены в полноэкранном редакторе: candidate оставался read-only, human-reference можно было двигать, ресайзить, удалять, добавлять и переклассифицировать."
|
||||
status="Adjudication complete · engineering reference · not truth"
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · RIGHT camera · recorded replay` },
|
||||
{ label: "Проход", value: `${result.metrics.frameCount}/32 · ${result.metrics.adjudicatedReferenceCount} human objects` },
|
||||
{ label: "Правки", value: `${result.metrics.changedFrameCount} кадров · ${changedObjects} операций` },
|
||||
{ label: "Полномочия", value: "Candidate не принят · retuning запрещён · L3.5 закрыт" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Нужно ли исправлять человеческую self-review разметку после визуального разбора расхождений с candidate L3.4D?",
|
||||
approach: "Каждый конфликт открыт на exact hash-bound source frame в четырёх слоях: OVERLAY, CANDIDATE, HUMAN и SOURCE. Candidate был видим, но неизменяем; сохранение и freeze разрешались только после отметки 32/32 кадров.",
|
||||
principalResult: `${formatNumber(result.metrics.unchangedObjectCount, 0)} из ${formatNumber(result.metrics.sourceReferenceCount, 0)} исходных human-объектов оставлены без изменений. Добавлено ${formatNumber(result.metrics.addedObjectCount, 0)}, удалено ${formatNumber(result.metrics.deletedObjectCount, 0)}, геометрия изменена у ${formatNumber(result.metrics.geometryChangedObjectCount, 0)} объектов, класс — у ${formatNumber(result.metrics.classChangedObjectCount, 0)}.`,
|
||||
limitation: "Reviewer видел candidate и ранее создавал исходную self-review разметку. Поэтому результат полезен как инженерная reference-разметка и визуальный протокол, но не является independent или metric-grade truth.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: "l34e-candidate-visible-adjudication/v1",
|
||||
components: [
|
||||
{ kind: "source", name: "L3.4E mismatch gallery", version: result.diagnosticResultId, role: "candidate + self-review + exact source", identitySha256: result.diagnosticResultId.split("-").at(-1) ?? null },
|
||||
{ kind: "algorithm", name: "Fullscreen box adjudication", version: "revisioned 32/32 gate", role: "move, resize, add, delete, relabel, approve", identitySha256: null },
|
||||
{ kind: "model", name: "Frozen L3.4D candidate", version: "scores hidden", role: "visible read-only comparison layer", identitySha256: null },
|
||||
],
|
||||
}}
|
||||
/>}
|
||||
evidence={<LaboratoryEvidence
|
||||
eyebrow="FROZEN VISUAL EVIDENCE · OVERLAY / CANDIDATE / HUMAN / SOURCE"
|
||||
title="32/32 кадров доступны для повторной визуальной проверки"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<FrozenEvidence result={result} />
|
||||
</LaboratoryEvidence>}
|
||||
result={<LaboratoryResultSummary
|
||||
title="Разметка выдержала adjudication, но detector gate всё ещё закрыт"
|
||||
status="Two independent blind reviews required before E48 / L3.5"
|
||||
statusTone="warning"
|
||||
metrics={[
|
||||
{ label: "Reviewed", value: `${result.metrics.frameCount}/32`, hint: "Все конфликтные кадры" },
|
||||
{ label: "Human objects", value: formatNumber(result.metrics.adjudicatedReferenceCount, 0), hint: `${formatNumber(result.metrics.unchangedObjectCount, 0)} unchanged` },
|
||||
{ label: "Changed frames", value: formatNumber(result.metrics.changedFrameCount, 0), hint: `${formatNumber(changedObjects, 0)} edit operations` },
|
||||
{ label: "Blind authority", value: "0", hint: "Not independent truth" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "Полный candidate-visible разбор завершён и заморожен как воспроизводимый immutable artifact. На просмотренных кадрах не нашлось честных оснований менять исходные human boxes только ради лучшего совпадения с моделью.",
|
||||
notProved: "Не доказаны точность детектора, AP/precision/recall, прогрессия L3.4D, независимость разметки или пригодность результата для safety/navigation. Ноль правок не означает ноль ошибок модели.",
|
||||
decision: "Не трогать модель по этому набору. Следующий шаг — два разных реальных reviewer получают те же 32 source frames без candidate identity, predictions и этой разметки. После двух freeze выполняется отдельная adjudication, затем E48 seal и только потом одноразовый L3.5 acceptance calculation.",
|
||||
}}
|
||||
/>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type {
|
||||
L34RightYoloxTruthIslandResult,
|
||||
} from "../../core/laboratory/l34RightYoloxTruthIsland";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { L34RightYoloxTruthIslandVisual } from "./L34RightYoloxTruthIslandVisual";
|
||||
|
||||
function digest(value: string): string | null {
|
||||
const candidate = value.split("-").at(-1) ?? "";
|
||||
return /^[a-f0-9]{64}$/.test(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
export function L34RightYoloxTruthIslandResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: L34RightYoloxTruthIslandResult;
|
||||
}) {
|
||||
const metrics = result.metrics;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB L3.4 · RIGHT YOLOX truth-island freeze"
|
||||
description="Первый честный benchmark-прогон текущего camera-first YOLOX pipeline: точные предсказания заморожены на 32 скрытых кадрах RAVNOVES00 до раскрытия независимой человеческой разметки."
|
||||
status="Ожидает независимую truth"
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · RIGHT camera · recorded replay` },
|
||||
{ label: "Кандидат", value: `${result.candidate.architecture} · score ≥ ${result.candidate.minimumScore}` },
|
||||
{ label: "Truth island", value: `${formatNumber(metrics.frameCount, 0)} кадров · ${formatNumber(metrics.temporalGroupCount, 0)} групп` },
|
||||
{ label: "Состояние", value: "Predictions frozen · labels unread" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Как текущий YOLOX-S распознаёт task-relevant объекты на заранее выбранных скрытых кадрах RAVNOVES00?",
|
||||
approach: "Использован только записанный sensor.camera.right. Из E46 взяты 32 model-independent кадра; из полного YOLOX replay зафиксированы все допущенные detection boxes при score ≥ 0.25. Human labels не читались и не использовались для настройки.",
|
||||
principalResult: `Заморожено ${formatNumber(metrics.predictionCount, 0)} предсказаний на ${formatNumber(metrics.framesWithPredictions, 0)} из ${formatNumber(metrics.frameCount, 0)} кадров. Это полный candidate freeze, а не метрика качества.`,
|
||||
limitation: "AP, recall, misses и false positives пока не существуют: их можно вычислить только после двух независимых blind-review и adjudication. Live, hardware, левая камера и второй маршрут исключены из этого прогона.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "ai-inference",
|
||||
pipelineId: result.pipelineId,
|
||||
components: [
|
||||
{
|
||||
kind: "source",
|
||||
name: result.truthIsland.resultId,
|
||||
version: "E46 blind truth island · labels unavailable",
|
||||
role: "model-independent frame selection",
|
||||
identitySha256: digest(result.truthIsland.resultId),
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
name: result.candidate.architecture,
|
||||
version: `COCO-80 · score ≥ ${result.candidate.minimumScore}`,
|
||||
role: "right-camera task-relevant 2D detection candidate",
|
||||
identitySha256: result.candidate.modelSha256,
|
||||
},
|
||||
{
|
||||
kind: "algorithm",
|
||||
name: result.profileId,
|
||||
version: "candidate-freeze/v1",
|
||||
role: "freeze predictions before independent truth reveal",
|
||||
identitySha256: digest(result.resultId),
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="FROZEN CANDIDATE · NO TRUTH REVEAL"
|
||||
title="Правые кадры RAVNOVES00 и точные frozen YOLOX boxes"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<L34RightYoloxTruthIslandVisual result={result} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Кандидат зафиксирован; оценка качества ещё закрыта"
|
||||
status="Human truth required"
|
||||
statusTone="warning"
|
||||
metrics={[
|
||||
{
|
||||
label: "Кадры",
|
||||
value: formatNumber(metrics.frameCount, 0),
|
||||
hint: `${formatNumber(metrics.temporalGroupCount, 0)} независимых temporal/anchor групп`,
|
||||
},
|
||||
{
|
||||
label: "Предсказания",
|
||||
value: formatNumber(metrics.predictionCount, 0),
|
||||
hint: "content-addressed freeze",
|
||||
},
|
||||
{
|
||||
label: "Truth read",
|
||||
value: result.decision.truthLabelsRead ? "Да" : "Нет",
|
||||
hint: "blindness contract preserved",
|
||||
},
|
||||
{
|
||||
label: "Accuracy",
|
||||
value: metrics.accuracyMetricsAvailable ? "Доступна" : "Недоступна",
|
||||
hint: "не подменяется descriptive counts",
|
||||
},
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "Текущий YOLOX-кандидат воспроизводимо привязан к одному записанному источнику, одной правой камере и заранее выбранным 32 кадрам; предсказания заморожены до раскрытия truth.",
|
||||
notProved: "Не доказаны accuracy, recall, FP/FN, качество LiDAR range, перенос на другой маршрут, live transport, hardware runtime, navigation или safety.",
|
||||
decision: "Не тюнить модель по этим кадрам. Следующий gate — две независимые слепые разметки E46, adjudication и вычисление AP/recall по этой точной prediction generation.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type {
|
||||
L34Prediction,
|
||||
L34VisualFrame,
|
||||
} from "../../core/laboratory/l34RightYoloxTruthIsland";
|
||||
|
||||
function tokenColor(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
alpha = 1,
|
||||
): string {
|
||||
const value = getComputedStyle(host).getPropertyValue(token).trim();
|
||||
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
function predictionLabel(prediction: L34Prediction): string {
|
||||
const names: Readonly<Record<string, string>> = {
|
||||
car: "Авто",
|
||||
heavy_vehicle: "Тяжёлый транспорт",
|
||||
person: "Человек",
|
||||
bicycle: "Велосипед",
|
||||
motorcycle: "Мотоцикл",
|
||||
static_obstacle: "Препятствие",
|
||||
animal: "Животное",
|
||||
};
|
||||
return `${names[prediction.label] ?? prediction.label} · ${(prediction.score * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
type LabelRect = Readonly<{
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}>;
|
||||
|
||||
function overlaps(left: LabelRect, right: LabelRect): boolean {
|
||||
const margin = 3;
|
||||
return !(
|
||||
left.x + left.width + margin <= right.x
|
||||
|| right.x + right.width + margin <= left.x
|
||||
|| left.y + left.height + margin <= right.y
|
||||
|| right.y + right.height + margin <= left.y
|
||||
);
|
||||
}
|
||||
|
||||
function placeLabel(
|
||||
box: LabelRect,
|
||||
labelWidth: number,
|
||||
labelHeight: number,
|
||||
imageBounds: LabelRect,
|
||||
occupied: readonly LabelRect[],
|
||||
): LabelRect | null {
|
||||
const x = Math.min(
|
||||
imageBounds.x + imageBounds.width - labelWidth,
|
||||
Math.max(imageBounds.x, box.x),
|
||||
);
|
||||
const candidates = [
|
||||
box.y - labelHeight - 3,
|
||||
box.y + box.height + 3,
|
||||
box.y + 3,
|
||||
].map((y) => ({ x, y, width: labelWidth, height: labelHeight }));
|
||||
return candidates.find((candidate) => (
|
||||
candidate.y >= imageBounds.y
|
||||
&& candidate.y + candidate.height <= imageBounds.y + imageBounds.height
|
||||
&& occupied.every((current) => !overlaps(candidate, current))
|
||||
)) ?? null;
|
||||
}
|
||||
|
||||
export function L34RightYoloxTruthIslandScene({
|
||||
frame,
|
||||
predictionsVisible,
|
||||
}: {
|
||||
frame: L34VisualFrame;
|
||||
predictionsVisible: boolean;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const next = new Image();
|
||||
next.decoding = "async";
|
||||
next.onload = () => {
|
||||
setError(false);
|
||||
setImage(next);
|
||||
};
|
||||
next.onerror = () => {
|
||||
setImage(null);
|
||||
setError(true);
|
||||
};
|
||||
next.src = frame.cameraUrl;
|
||||
return () => {
|
||||
next.onload = null;
|
||||
next.onerror = null;
|
||||
};
|
||||
}, [frame.cameraUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!host || !canvas || !image) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const ratio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * ratio);
|
||||
canvas.height = Math.round(height * ratio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
context.fillStyle = tokenColor(host, "--nodedc-canvas-rgb", [5, 5, 6]);
|
||||
context.fillRect(0, 0, width, height);
|
||||
const scale = Math.min(width / frame.cameraWidth, height / frame.cameraHeight);
|
||||
const drawWidth = frame.cameraWidth * scale;
|
||||
const drawHeight = frame.cameraHeight * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
context.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
|
||||
if (!predictionsVisible) return;
|
||||
|
||||
const predictionColor = tokenColor(
|
||||
host,
|
||||
"--nodedc-accent-rgb",
|
||||
[174, 255, 88],
|
||||
0.96,
|
||||
);
|
||||
const labelSurface = tokenColor(
|
||||
host,
|
||||
"--nodedc-canvas-rgb",
|
||||
[5, 5, 6],
|
||||
0.84,
|
||||
);
|
||||
const boxes = frame.predictions.map((prediction) => {
|
||||
const [left, top, right, bottom] = prediction.bboxXyxy;
|
||||
const box = {
|
||||
x: offsetX + left * scale,
|
||||
y: offsetY + top * scale,
|
||||
width: (right - left) * scale,
|
||||
height: (bottom - top) * scale,
|
||||
};
|
||||
context.strokeStyle = predictionColor;
|
||||
context.lineWidth = Math.max(1.25, 1.8 * scale);
|
||||
context.strokeRect(box.x, box.y, box.width, box.height);
|
||||
return { prediction, box };
|
||||
});
|
||||
const occupied: LabelRect[] = [];
|
||||
const imageBounds = {
|
||||
x: offsetX,
|
||||
y: offsetY,
|
||||
width: drawWidth,
|
||||
height: drawHeight,
|
||||
};
|
||||
[...boxes]
|
||||
.sort((left, right) => right.prediction.score - left.prediction.score)
|
||||
.forEach(({ prediction, box }) => {
|
||||
const label = predictionLabel(prediction);
|
||||
const fontSize = Math.max(9, 10 * scale);
|
||||
const labelHeight = fontSize + 7;
|
||||
context.font = `600 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
const labelWidth = context.measureText(label).width + 12;
|
||||
const placement = placeLabel(
|
||||
box,
|
||||
labelWidth,
|
||||
labelHeight,
|
||||
imageBounds,
|
||||
occupied,
|
||||
);
|
||||
if (!placement) return;
|
||||
occupied.push(placement);
|
||||
context.fillStyle = labelSurface;
|
||||
context.fillRect(
|
||||
placement.x,
|
||||
placement.y,
|
||||
placement.width,
|
||||
placement.height,
|
||||
);
|
||||
context.fillStyle = predictionColor;
|
||||
context.fillText(
|
||||
label,
|
||||
placement.x + 6,
|
||||
placement.y + fontSize + 1,
|
||||
);
|
||||
});
|
||||
};
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [frame, image, predictionsVisible]);
|
||||
|
||||
return (
|
||||
<div className="l32-camera-scene" ref={hostRef}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label={`L3.4 right-camera frame ${frame.frameIndex}, ${predictionsVisible ? `${frame.predictions.length} frozen predictions` : "immutable source only"}`}
|
||||
/>
|
||||
{error ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
Точный кадр правой камеры L3.4 недоступен.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon, IconButton, Select } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
fetchL34RightYoloxTruthIslandFrame,
|
||||
type L34FrameSummary,
|
||||
type L34RightYoloxTruthIslandResult,
|
||||
type L34VisualFrame,
|
||||
} from "../../core/laboratory/l34RightYoloxTruthIsland";
|
||||
import { L34RightYoloxTruthIslandScene } from "./L34RightYoloxTruthIslandScene";
|
||||
|
||||
type ViewerMode = "predictions" | "source";
|
||||
|
||||
function frameLabel(frame: L34FrameSummary): string {
|
||||
const score = frame.maximumScore > 0
|
||||
? ` · max ${(frame.maximumScore * 100).toFixed(0)}%`
|
||||
: "";
|
||||
return `${frame.truthIslandSequence}/32 · frame ${frame.frameIndex} · ${frame.groupId} · ${frame.predictionCount} boxes${score}`;
|
||||
}
|
||||
|
||||
export function L34RightYoloxTruthIslandVisual({
|
||||
result,
|
||||
}: {
|
||||
result: L34RightYoloxTruthIslandResult;
|
||||
}) {
|
||||
const initialSequence = result.frames.find(
|
||||
({ groupId }) => groupId === "clip-stroller-person",
|
||||
)?.truthIslandSequence ?? result.frames[0]?.truthIslandSequence ?? 1;
|
||||
const [selectedSequence, setSelectedSequence] = useState(initialSequence);
|
||||
const [frame, setFrame] = useState<L34VisualFrame | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<ViewerMode>("predictions");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setFrame(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchL34RightYoloxTruthIslandFrame(
|
||||
result.resultId,
|
||||
selectedSequence,
|
||||
{ signal: controller.signal },
|
||||
).then((next) => {
|
||||
if (!controller.signal.aborted) setFrame(next);
|
||||
}).catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(
|
||||
caught instanceof Error
|
||||
? caught.message
|
||||
: "Визуальный кадр L3.4 недоступен.",
|
||||
);
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId, selectedSequence]);
|
||||
|
||||
const selectedIndex = result.frames.findIndex(
|
||||
({ truthIslandSequence }) => truthIslandSequence === selectedSequence,
|
||||
);
|
||||
const navigate = (offset: -1 | 1) => {
|
||||
if (selectedIndex < 0) return;
|
||||
const index = (
|
||||
selectedIndex + offset + result.frames.length
|
||||
) % result.frames.length;
|
||||
setSelectedSequence(result.frames[index].truthIslandSequence);
|
||||
};
|
||||
const actions = (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton label="Предыдущий визуальный кадр L3.4" onClick={() => navigate(-1)}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Следующий визуальный кадр L3.4" onClick={() => navigate(1)}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Select
|
||||
label="Выбрать визуальный кадр L3.4"
|
||||
value={String(selectedSequence)}
|
||||
options={result.frames.map((item) => ({
|
||||
value: String(item.truthIslandSequence),
|
||||
label: frameLabel(item),
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
searchable
|
||||
searchPlaceholder="Найти кадр или группу"
|
||||
onChange={(value) => setSelectedSequence(Number(value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const overlay = frame ? (
|
||||
<div className="l3-visual-audit__overlay">
|
||||
<div>
|
||||
<span>RAVNOVES00 · sensor.camera.right</span>
|
||||
<strong>{frame.sessionSeconds.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} с · frame {frame.frameIndex}</strong>
|
||||
<small>{frame.role === "temporal" ? "Temporal sequence" : "Independent anchor"} · image {frame.imageId}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Frozen YOLOX-S candidate</span>
|
||||
<strong>{frame.predictions.length} boxes · labels скрыты</strong>
|
||||
<small>image {frame.cameraSha256.slice(0, 12)} · predictions {frame.predictionRowsSha256.slice(0, 12)}</small>
|
||||
</div>
|
||||
<div className="l3-visual-audit__legend">
|
||||
<span data-tone="prediction">Акцентный контур · frozen prediction</span>
|
||||
<span>Источник переключается без нового backend-расчёта</span>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className="l3-visual-audit">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="L3.4 visual candidate review"
|
||||
mode={mode}
|
||||
modes={[
|
||||
{ value: "predictions", label: "BOXES" },
|
||||
{ value: "source", label: "SOURCE" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Открываем hash-bound кадр и frozen boxes</span>
|
||||
</div>
|
||||
) : error || !frame ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{error ?? "Визуальный кадр L3.4 недоступен."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<L34RightYoloxTruthIslandScene
|
||||
frame={frame}
|
||||
predictionsVisible={mode === "predictions"}
|
||||
/>
|
||||
)}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,13 @@ import type {
|
||||
|
||||
export type L3VisualMode = "3d" | "bev";
|
||||
|
||||
export interface L3VisualAssociationRay {
|
||||
originXyzM: readonly [number, number, number];
|
||||
directionXyz: readonly [number, number, number];
|
||||
anchorXyzM: readonly [number, number, number] | null;
|
||||
temporalHeld?: boolean;
|
||||
}
|
||||
|
||||
function tokenColor(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
@@ -66,6 +73,90 @@ function boxSegments(box: L3VisualBox): Float32Array {
|
||||
return positions;
|
||||
}
|
||||
|
||||
function scenePoint(value: readonly [number, number, number]): THREE.Vector3 {
|
||||
return new THREE.Vector3(value[0], value[2], -value[1]);
|
||||
}
|
||||
|
||||
function evidenceBounds(
|
||||
boxes: readonly L3VisualBox[],
|
||||
rays: readonly L3VisualAssociationRay[],
|
||||
): THREE.Box3 | null {
|
||||
const bounds = new THREE.Box3();
|
||||
boxes.forEach((box) => {
|
||||
const segments = boxSegments(box);
|
||||
for (let index = 0; index < segments.length; index += 3) {
|
||||
bounds.expandByPoint(new THREE.Vector3(
|
||||
segments[index],
|
||||
segments[index + 1],
|
||||
segments[index + 2],
|
||||
));
|
||||
}
|
||||
});
|
||||
rays.forEach((ray) => {
|
||||
bounds.expandByPoint(scenePoint(ray.originXyzM));
|
||||
if (ray.anchorXyzM) bounds.expandByPoint(scenePoint(ray.anchorXyzM));
|
||||
});
|
||||
return bounds.isEmpty() ? null : bounds;
|
||||
}
|
||||
|
||||
function addAssociationRays(
|
||||
scene: THREE.Scene,
|
||||
rays: readonly L3VisualAssociationRay[],
|
||||
rangedColor: THREE.Color,
|
||||
bearingColor: THREE.Color,
|
||||
): { lines: THREE.Line[]; anchors: THREE.Mesh[] } {
|
||||
const lines: THREE.Line[] = [];
|
||||
const anchors: THREE.Mesh[] = [];
|
||||
for (const ray of rays) {
|
||||
const origin = scenePoint(ray.originXyzM);
|
||||
const endpoint = ray.anchorXyzM
|
||||
? scenePoint(ray.anchorXyzM)
|
||||
: scenePoint([
|
||||
ray.originXyzM[0] + ray.directionXyz[0] * 25,
|
||||
ray.originXyzM[1] + ray.directionXyz[1] * 25,
|
||||
ray.originXyzM[2] + ray.directionXyz[2] * 25,
|
||||
]);
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints([origin, endpoint]);
|
||||
const material = ray.anchorXyzM
|
||||
? ray.temporalHeld
|
||||
? new THREE.LineDashedMaterial({
|
||||
color: rangedColor,
|
||||
dashSize: 0.55,
|
||||
gapSize: 0.34,
|
||||
transparent: true,
|
||||
opacity: 0.88,
|
||||
depthWrite: false,
|
||||
})
|
||||
: new THREE.LineBasicMaterial({
|
||||
color: rangedColor,
|
||||
transparent: true,
|
||||
opacity: 0.96,
|
||||
depthWrite: false,
|
||||
})
|
||||
: new THREE.LineDashedMaterial({
|
||||
color: bearingColor,
|
||||
dashSize: 0.75,
|
||||
gapSize: 0.5,
|
||||
transparent: true,
|
||||
opacity: 0.56,
|
||||
depthWrite: false,
|
||||
});
|
||||
const line = new THREE.Line(geometry, material);
|
||||
if (!ray.anchorXyzM || ray.temporalHeld) line.computeLineDistances();
|
||||
scene.add(line);
|
||||
lines.push(line);
|
||||
if (ray.anchorXyzM) {
|
||||
const anchorGeometry = new THREE.SphereGeometry(0.28, 14, 10);
|
||||
const anchorMaterial = new THREE.MeshBasicMaterial({ color: rangedColor });
|
||||
const anchor = new THREE.Mesh(anchorGeometry, anchorMaterial);
|
||||
anchor.position.copy(endpoint);
|
||||
scene.add(anchor);
|
||||
anchors.push(anchor);
|
||||
}
|
||||
}
|
||||
return { lines, anchors };
|
||||
}
|
||||
|
||||
function addBoxes(
|
||||
scene: THREE.Scene,
|
||||
boxes: readonly L3VisualBox[],
|
||||
@@ -78,14 +169,26 @@ function addBoxes(
|
||||
"position",
|
||||
new THREE.BufferAttribute(boxSegments(box), 3),
|
||||
);
|
||||
const material = new THREE.LineBasicMaterial({
|
||||
color: colors[box.status],
|
||||
transparent: true,
|
||||
opacity,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
const held = box.temporalStatus?.startsWith("e23-held") ?? false;
|
||||
const material = held
|
||||
? new THREE.LineDashedMaterial({
|
||||
color: colors[box.status],
|
||||
dashSize: 0.48,
|
||||
gapSize: 0.3,
|
||||
transparent: true,
|
||||
opacity: Math.min(opacity, 0.62),
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
})
|
||||
: new THREE.LineBasicMaterial({
|
||||
color: colors[box.status],
|
||||
transparent: true,
|
||||
opacity,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
const lines = new THREE.LineSegments(geometry, material);
|
||||
if (held) lines.computeLineDistances();
|
||||
scene.add(lines);
|
||||
return lines;
|
||||
});
|
||||
@@ -96,6 +199,8 @@ export function L3PointPillarsScene({
|
||||
mode,
|
||||
bevCenterX = 30,
|
||||
bevHalfExtent = 42,
|
||||
associationRays = [],
|
||||
fitToEvidence = false,
|
||||
}: {
|
||||
frame: Pick<
|
||||
L3VisualFrame,
|
||||
@@ -104,6 +209,8 @@ export function L3PointPillarsScene({
|
||||
mode: L3VisualMode;
|
||||
bevCenterX?: number;
|
||||
bevHalfExtent?: number;
|
||||
associationRays?: readonly L3VisualAssociationRay[];
|
||||
fitToEvidence?: boolean;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const [renderError, setRenderError] = useState<string | null>(null);
|
||||
@@ -183,6 +290,19 @@ export function L3PointPillarsScene({
|
||||
colors,
|
||||
0.72,
|
||||
);
|
||||
const associationGeometry = addAssociationRays(
|
||||
scene,
|
||||
associationRays,
|
||||
colors["true-positive"],
|
||||
colors.matched,
|
||||
);
|
||||
const fittedBounds = fitToEvidence
|
||||
? evidenceBounds(frame.predictionBoxes, associationRays)
|
||||
: null;
|
||||
const fittedCenter = fittedBounds?.getCenter(new THREE.Vector3())
|
||||
?? new THREE.Vector3(mode === "bev" ? bevCenterX : 30, 0, 0);
|
||||
const fittedSize = fittedBounds?.getSize(new THREE.Vector3())
|
||||
?? new THREE.Vector3(bevHalfExtent * 2, 8, bevHalfExtent * 2);
|
||||
const grid = new THREE.GridHelper(
|
||||
80,
|
||||
40,
|
||||
@@ -197,15 +317,32 @@ export function L3PointPillarsScene({
|
||||
material.opacity = 0.22;
|
||||
material.depthWrite = false;
|
||||
});
|
||||
if (fitToEvidence) {
|
||||
grid.position.x = fittedCenter.x;
|
||||
grid.position.z = fittedCenter.z;
|
||||
}
|
||||
scene.add(grid);
|
||||
|
||||
const perspective = new THREE.PerspectiveCamera(52, 1, 0.1, 500);
|
||||
perspective.position.set(-12, 18, 36);
|
||||
if (fitToEvidence) {
|
||||
const span = Math.max(fittedSize.x, fittedSize.z, fittedSize.y * 2, 10);
|
||||
perspective.position.copy(fittedCenter).add(new THREE.Vector3(
|
||||
-span * 0.9,
|
||||
span * 0.72,
|
||||
span * 1.2,
|
||||
));
|
||||
} else {
|
||||
perspective.position.set(-12, 18, 36);
|
||||
}
|
||||
const orthographic = new THREE.OrthographicCamera(-40, 40, 40, -40, 0.1, 500);
|
||||
orthographic.position.set(bevCenterX + 5, 100, 0);
|
||||
orthographic.position.set(
|
||||
fitToEvidence ? fittedCenter.x : bevCenterX + 5,
|
||||
100,
|
||||
fitToEvidence ? fittedCenter.z : 0,
|
||||
);
|
||||
orthographic.up.set(1, 0, 0);
|
||||
const camera = mode === "bev" ? orthographic : perspective;
|
||||
camera.lookAt(mode === "bev" ? bevCenterX : 30, 0, 0);
|
||||
camera.lookAt(fittedCenter);
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = false;
|
||||
@@ -213,7 +350,7 @@ export function L3PointPillarsScene({
|
||||
controls.enablePan = true;
|
||||
controls.enableZoom = true;
|
||||
controls.screenSpacePanning = true;
|
||||
controls.target.set(mode === "bev" ? bevCenterX : 30, 0, 0);
|
||||
controls.target.copy(fittedCenter);
|
||||
controls.update();
|
||||
|
||||
const render = () => renderer.render(scene, camera);
|
||||
@@ -226,11 +363,18 @@ export function L3PointPillarsScene({
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
} else {
|
||||
const horizontal = bevHalfExtent;
|
||||
const aspect = width / height;
|
||||
const horizontal = fitToEvidence
|
||||
? Math.max(
|
||||
8,
|
||||
fittedSize.z * 0.64,
|
||||
fittedSize.x * aspect * 0.64,
|
||||
)
|
||||
: bevHalfExtent;
|
||||
camera.left = -horizontal;
|
||||
camera.right = horizontal;
|
||||
camera.top = horizontal / (width / height);
|
||||
camera.bottom = -horizontal / (width / height);
|
||||
camera.top = horizontal / aspect;
|
||||
camera.bottom = -horizontal / aspect;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
render();
|
||||
@@ -249,12 +393,20 @@ export function L3PointPillarsScene({
|
||||
lines.geometry.dispose();
|
||||
(lines.material as THREE.Material).dispose();
|
||||
});
|
||||
associationGeometry.lines.forEach((line) => {
|
||||
line.geometry.dispose();
|
||||
(line.material as THREE.Material).dispose();
|
||||
});
|
||||
associationGeometry.anchors.forEach((anchor) => {
|
||||
anchor.geometry.dispose();
|
||||
(anchor.material as THREE.Material).dispose();
|
||||
});
|
||||
grid.geometry.dispose();
|
||||
gridMaterials.forEach((material) => material.dispose());
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
};
|
||||
}, [bevCenterX, bevHalfExtent, frame, mode]);
|
||||
}, [associationRays, bevCenterX, bevHalfExtent, fitToEvidence, frame, mode]);
|
||||
|
||||
return (
|
||||
<div className="l3-visual-audit__scene" ref={hostRef}>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import { Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratorySelector,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
LaboratoryWorkTemplate,
|
||||
type LaboratoryMethod,
|
||||
type LaboratoryMethodComponent,
|
||||
type LaboratoryOption,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
||||
import { useObservationSessions } from "../../core/observation/useObservationSessions";
|
||||
@@ -42,7 +40,6 @@ import type { WorkspaceRendererProps } from "../contracts";
|
||||
import {
|
||||
AdvancedLaboratoryResult,
|
||||
advancedLaboratorySourceSession,
|
||||
advancedLaboratoryWorkOptions,
|
||||
isAdvancedLaboratoryWorkId,
|
||||
} from "./AdvancedLaboratoryResult";
|
||||
import {
|
||||
@@ -50,15 +47,22 @@ import {
|
||||
e30LaboratoryBrief, PUBLISHED_LABORATORY_BRIEF,
|
||||
} from "./laboratoryArchiveBriefs";
|
||||
import { useAdvancedLaboratoryCatalog } from "./useAdvancedLaboratoryCatalog";
|
||||
import { buildLaboratoryProfiles, workOptionsForProfile } from "./laboratoryArchiveProfiles";
|
||||
import type { LaboratoryProfileId, LaboratoryWorkId } from "./laboratoryArchiveProfiles";
|
||||
import { useL34AnnotationCapability } from "./annotation/useL34AnnotationCapability";
|
||||
import {
|
||||
buildLaboratoryCatalog,
|
||||
buildLaboratoryProfiles,
|
||||
experimentOptionsForProfile,
|
||||
workOptionsForExperiment,
|
||||
} from "./laboratoryArchiveProfiles";
|
||||
import type {
|
||||
LaboratoryCatalogSeed,
|
||||
LaboratoryExperimentId,
|
||||
LaboratoryProfileId,
|
||||
LaboratoryWorkId,
|
||||
} from "./laboratoryArchiveProfiles";
|
||||
type LaboratoryWorkspaceProps = WorkspaceRendererProps & {
|
||||
SpatialView: ComponentType<WorkspaceRendererProps>;
|
||||
};
|
||||
function laboratoryWorkOrdinal(value: string): number {
|
||||
const match = value.match(/\bE(\d+)\b/i);
|
||||
return match ? Number(match[1]) : -1;
|
||||
}
|
||||
|
||||
function digestFromContentId(value: string | null | undefined): string | null {
|
||||
const digest = value?.split("-").at(-1) ?? "";
|
||||
@@ -546,8 +550,15 @@ function PublishedLaboratoryResult({
|
||||
}
|
||||
|
||||
export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
const [profileId, setProfileId] = useState<LaboratoryProfileId>("sensor-fusion");
|
||||
const [workId, setWorkId] = useState<LaboratoryWorkId>("e28-local-surface");
|
||||
const [profileId, setProfileId] = useState<LaboratoryProfileId>(
|
||||
"rig-right-yolox-lidar-range-v1",
|
||||
);
|
||||
const [experimentId, setExperimentId] = useState<LaboratoryExperimentId>(
|
||||
"ravnoves00-perception-benchmark-v1",
|
||||
);
|
||||
const [workId, setWorkId] = useState<LaboratoryWorkId>(
|
||||
"l34-right-yolox-truth-island-freeze",
|
||||
);
|
||||
const initialWorkSelectedRef = useRef(false);
|
||||
const [e28Model, setE28Model] = useState<LidarLocalSurfaceModel | null>(null);
|
||||
const [e29Result, setE29Result] = useState<E29EvidenceResult | null>(null);
|
||||
@@ -569,12 +580,10 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
&& session.status === "ready"
|
||||
&& session.replayable
|
||||
&& session.modalities.includes("point-cloud")
|
||||
)).sort((left, right) => {
|
||||
const ordinalDelta = laboratoryWorkOrdinal(right.lab?.labId ?? "")
|
||||
- laboratoryWorkOrdinal(left.lab?.labId ?? "");
|
||||
if (ordinalDelta !== 0) return ordinalDelta;
|
||||
return (right.startedAtUtc ?? "").localeCompare(left.startedAtUtc ?? "");
|
||||
}),
|
||||
)).sort((left, right) => (
|
||||
Date.parse(right.lab?.runCreatedAtUtc ?? right.startedAtUtc)
|
||||
- Date.parse(left.lab?.runCreatedAtUtc ?? left.startedAtUtc)
|
||||
)),
|
||||
[sessions.items],
|
||||
);
|
||||
const sourceSessions = useMemo(
|
||||
@@ -593,7 +602,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
},
|
||||
});
|
||||
const advancedResults: AdvancedLaboratoryResults = advanced.results;
|
||||
|
||||
const annotationWorkspace = useL34AnnotationCapability({ selectedWorkId: workId, l34Result: advancedResults.l34, l34dResult: advancedResults.l34d, l34eResult: advancedResults.l34e, e46Result: advancedResults.e46, e46aResult: advancedResults.e46a, onActionChange: props.onLaboratoryAnnotationActionChange });
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setEvidenceLoading(true);
|
||||
@@ -636,12 +645,12 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
: "";
|
||||
return sensorToken ? sensorToken.toLocaleUpperCase("ru-RU") : "Сенсорный риг";
|
||||
}, [props.deviceLabel, publishedWorks]);
|
||||
const sensorWorks = useMemo(() => {
|
||||
const items: LaboratoryOption<LaboratoryWorkId>[] = [];
|
||||
const knownWorks = useMemo(() => {
|
||||
const items: LaboratoryCatalogSeed[] = [];
|
||||
if (e28Model) {
|
||||
items.push({
|
||||
id: "e28-local-surface",
|
||||
label: "LAB E28 · локальная поверхность L2.6",
|
||||
createdAtUtc: e28Model.createdAtUtc ?? "",
|
||||
});
|
||||
}
|
||||
if (
|
||||
@@ -650,50 +659,42 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
) {
|
||||
items.push({
|
||||
id: "e29-camera-geometry",
|
||||
label: "LAB E29 · camera-first + geometry",
|
||||
createdAtUtc: e29Result.createdAtUtc ?? "",
|
||||
});
|
||||
}
|
||||
if (e30Result && sourceSessions.has(e30Result.sourceSessionId)) {
|
||||
items.push({
|
||||
id: "e30-evidence-review",
|
||||
label: "LAB E30 · evidence review A2",
|
||||
createdAtUtc: e30Result.createdAtUtc ?? "",
|
||||
});
|
||||
}
|
||||
items.push(
|
||||
...advancedLaboratoryWorkOptions(advanced.index).filter(
|
||||
({ id }) => id !== "l3-pointpillars-visual-audit",
|
||||
),
|
||||
);
|
||||
return items.sort(
|
||||
(left, right) => laboratoryWorkOrdinal(right.label) - laboratoryWorkOrdinal(left.label),
|
||||
);
|
||||
return items.filter(({ createdAtUtc }) => createdAtUtc.trim());
|
||||
}, [
|
||||
advanced.index,
|
||||
e28Model,
|
||||
e29Result,
|
||||
e30Result,
|
||||
sourceSessions,
|
||||
]);
|
||||
const publicBenchmarkWorks = useMemo(
|
||||
() => advancedLaboratoryWorkOptions(advanced.index).filter(
|
||||
({ id }) => id === "l3-pointpillars-visual-audit",
|
||||
),
|
||||
[advanced.index],
|
||||
const catalog = useMemo(
|
||||
() => buildLaboratoryCatalog({
|
||||
rigLabel,
|
||||
knownWorks,
|
||||
advancedIndex: advanced.index,
|
||||
publishedWorks,
|
||||
}),
|
||||
[advanced.index, knownWorks, publishedWorks, rigLabel],
|
||||
);
|
||||
const profiles = useMemo(
|
||||
() => buildLaboratoryProfiles({
|
||||
rigLabel,
|
||||
sensorAvailable: sensorWorks.length > 0,
|
||||
publicBenchmarkAvailable: publicBenchmarkWorks.length > 0,
|
||||
publishedAvailable: publishedWorks.length > 0,
|
||||
}),
|
||||
[publicBenchmarkWorks.length, publishedWorks.length, rigLabel, sensorWorks.length],
|
||||
() => buildLaboratoryProfiles(catalog),
|
||||
[catalog],
|
||||
);
|
||||
const publishedWorkOptions = publishedWorks.map((session) => ({
|
||||
id: `session:${session.id}` as const,
|
||||
label: `${session.lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`,
|
||||
}));
|
||||
const workOptions = workOptionsForProfile(
|
||||
profileId, sensorWorks, publicBenchmarkWorks, publishedWorkOptions,
|
||||
const experimentOptions = useMemo(
|
||||
() => experimentOptionsForProfile(profileId, catalog),
|
||||
[catalog, profileId],
|
||||
);
|
||||
const workOptions = useMemo(
|
||||
() => workOptionsForExperiment(profileId, experimentId, catalog),
|
||||
[catalog, experimentId, profileId],
|
||||
);
|
||||
const selectedSessionId = workId.startsWith("session:")
|
||||
? workId.slice("session:".length)
|
||||
@@ -719,25 +720,11 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
const firstProfile = profiles[0];
|
||||
if (!firstProfile) return;
|
||||
setProfileId(firstProfile.id);
|
||||
if (firstProfile.id === "sensor-fusion") {
|
||||
const firstWork = sensorWorks[0];
|
||||
if (firstWork) {
|
||||
setWorkId(firstWork.id);
|
||||
initialWorkSelectedRef.current = true;
|
||||
}
|
||||
} else if (firstProfile.id === "public-benchmarks") {
|
||||
const firstWork = publicBenchmarkWorks[0];
|
||||
if (firstWork) {
|
||||
setWorkId(firstWork.id);
|
||||
initialWorkSelectedRef.current = true;
|
||||
}
|
||||
} else {
|
||||
const first = publishedWorks[0];
|
||||
if (first) {
|
||||
setWorkId(`session:${first.id}`);
|
||||
initialWorkSelectedRef.current = true;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!experimentOptions.some((experiment) => experiment.id === experimentId)) {
|
||||
const firstExperiment = experimentOptions[0];
|
||||
if (firstExperiment) setExperimentId(firstExperiment.id);
|
||||
return;
|
||||
}
|
||||
if (!initialWorkSelectedRef.current) {
|
||||
@@ -754,11 +741,10 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
}, [
|
||||
evidenceLoading,
|
||||
advanced.indexLoading,
|
||||
experimentId,
|
||||
experimentOptions,
|
||||
profileId,
|
||||
profiles,
|
||||
publishedWorks,
|
||||
publicBenchmarkWorks,
|
||||
sensorWorks,
|
||||
sessions.state,
|
||||
workId,
|
||||
workOptions,
|
||||
@@ -767,21 +753,18 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
const selectProfile = (next: LaboratoryProfileId) => {
|
||||
setProfileId(next);
|
||||
initialWorkSelectedRef.current = true;
|
||||
if (next === "sensor-fusion") {
|
||||
const first = sensorWorks[0];
|
||||
if (first) setWorkId(first.id);
|
||||
return;
|
||||
}
|
||||
if (next === "public-benchmarks") {
|
||||
const first = publicBenchmarkWorks[0];
|
||||
if (first) setWorkId(first.id);
|
||||
return;
|
||||
}
|
||||
const first = publishedWorks[0];
|
||||
if (!first) return;
|
||||
const nextWork = `session:${first.id}` as const;
|
||||
setWorkId(nextWork);
|
||||
void sessions.replay(first.id);
|
||||
const firstExperiment = experimentOptionsForProfile(next, catalog)[0];
|
||||
if (!firstExperiment) return;
|
||||
setExperimentId(firstExperiment.id);
|
||||
const firstWork = workOptionsForExperiment(next, firstExperiment.id, catalog)[0];
|
||||
if (firstWork) selectWork(firstWork.id);
|
||||
};
|
||||
|
||||
const selectExperiment = (next: LaboratoryExperimentId) => {
|
||||
setExperimentId(next);
|
||||
initialWorkSelectedRef.current = true;
|
||||
const firstWork = workOptionsForExperiment(profileId, next, catalog)[0];
|
||||
if (firstWork) selectWork(firstWork.id);
|
||||
};
|
||||
|
||||
const selectWork = (next: LaboratoryWorkId) => {
|
||||
@@ -851,23 +834,39 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
data-viewer-focused={viewerFocused ? "true" : undefined}
|
||||
>
|
||||
<LaboratorySelector
|
||||
eyebrow="ПРОФИЛЬ ЛАБОРАТОРНОГО КОНТУРА"
|
||||
eyebrow="PIPELINE-КОНТУР"
|
||||
title={profiles.find((profile) => profile.id === profileId)?.label ?? rigLabel}
|
||||
description="Профиль фиксирует объект исследования, сенсорные модули и вычислительный контур. Исходные данные остаются read-only; профиль объединяет серию сопоставимых лабораторных работ."
|
||||
label="Профиль"
|
||||
description="Датированный снимок конкретного perception pipeline. Контуры отсортированы по времени последнего зафиксированного результата; источник остаётся read-only."
|
||||
label="Pipeline"
|
||||
value={profileId}
|
||||
options={profiles}
|
||||
searchable
|
||||
onChange={selectProfile}
|
||||
/>
|
||||
|
||||
<LaboratorySelector
|
||||
eyebrow="ЛАБОРАТОРНАЯ РАБОТА"
|
||||
eyebrow="ЭКСПЕРИМЕНТ"
|
||||
title={experimentOptions.find((experiment) => (
|
||||
experiment.id === experimentId
|
||||
))?.label ?? "Эксперимент не выбран"}
|
||||
description="Эксперимент объединяет сопоставимые прогоны и модификации только внутри выбранного pipeline-контракта."
|
||||
label="Эксперимент"
|
||||
value={experimentId}
|
||||
options={experimentOptions}
|
||||
disabled={experimentOptions.length === 0}
|
||||
searchable
|
||||
onChange={selectExperiment}
|
||||
/>
|
||||
|
||||
<LaboratorySelector
|
||||
eyebrow="ПРОГОН / ВАРИАНТ"
|
||||
title={workOptions.find((work) => work.id === workId)?.label ?? "Работа не выбрана"}
|
||||
description="Выберите один зафиксированный эксперимент. Ниже откроются его задача и структурированный результат; viewer появляется только у опубликованного серверного доказательства."
|
||||
label="Работа"
|
||||
description="Строго хронологический список immutable-прогонов: дата запуска, LAB-id и конкретная модификация. Ниже открывается только опубликованное серверное доказательство."
|
||||
label="Прогон"
|
||||
value={workId}
|
||||
options={workOptions}
|
||||
disabled={workOptions.length === 0}
|
||||
searchable
|
||||
onChange={selectWork}
|
||||
/>
|
||||
|
||||
@@ -994,6 +993,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{annotationWorkspace}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import { Select } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
L34_ANNOTATION_CLASS_OPTIONS,
|
||||
L34_ANNOTATION_UNMAPPED_OPTION,
|
||||
annotationOperationKey,
|
||||
type L34AnnotationCategory,
|
||||
type L34AnnotationObject,
|
||||
type L34AnnotationSourceFrame,
|
||||
} from "../../../core/laboratory/l34Annotation";
|
||||
|
||||
export interface L34AnnotationObjectDraft
|
||||
extends Omit<L34AnnotationObject, "category" | "origin"> {
|
||||
category: L34AnnotationCategory | null;
|
||||
origin: string;
|
||||
}
|
||||
|
||||
export interface L34AnnotationComparisonObject {
|
||||
objectId: string;
|
||||
category: string;
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
}
|
||||
|
||||
export type L34AnnotationLayerMode = "overlay" | "candidate" | "review" | "source";
|
||||
|
||||
type Point = Readonly<{ x: number; y: number }>;
|
||||
type PlaneSize = Readonly<{ width: number; height: number }>;
|
||||
type ResizeHandle = "nw" | "ne" | "sw" | "se";
|
||||
type Interaction =
|
||||
| Readonly<{
|
||||
kind: "draw";
|
||||
pointerId: number;
|
||||
start: Point;
|
||||
end: Point;
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: "move" | "resize";
|
||||
pointerId: number;
|
||||
objectId: string;
|
||||
start: Point;
|
||||
originalBox: readonly [number, number, number, number];
|
||||
handle?: ResizeHandle;
|
||||
}>;
|
||||
|
||||
function boundedPoint(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
svg: SVGSVGElement,
|
||||
frame: L34AnnotationSourceFrame,
|
||||
): Point {
|
||||
const bounds = svg.getBoundingClientRect();
|
||||
const x = (clientX - bounds.left) * frame.cameraWidth / bounds.width;
|
||||
const y = (clientY - bounds.top) * frame.cameraHeight / bounds.height;
|
||||
return {
|
||||
x: Math.max(0, Math.min(frame.cameraWidth, x)),
|
||||
y: Math.max(0, Math.min(frame.cameraHeight, y)),
|
||||
};
|
||||
}
|
||||
|
||||
function movedBox(
|
||||
original: readonly [number, number, number, number],
|
||||
start: Point,
|
||||
end: Point,
|
||||
frame: L34AnnotationSourceFrame,
|
||||
): readonly [number, number, number, number] {
|
||||
const width = original[2] - original[0];
|
||||
const height = original[3] - original[1];
|
||||
const left = Math.max(
|
||||
0,
|
||||
Math.min(frame.cameraWidth - width, original[0] + end.x - start.x),
|
||||
);
|
||||
const top = Math.max(
|
||||
0,
|
||||
Math.min(frame.cameraHeight - height, original[1] + end.y - start.y),
|
||||
);
|
||||
return [left, top, left + width, top + height];
|
||||
}
|
||||
|
||||
function resizedBox(
|
||||
original: readonly [number, number, number, number],
|
||||
handle: ResizeHandle,
|
||||
point: Point,
|
||||
): readonly [number, number, number, number] {
|
||||
let [left, top, right, bottom] = original;
|
||||
if (handle.includes("n")) top = Math.min(point.y, bottom - 4);
|
||||
if (handle.includes("s")) bottom = Math.max(point.y, top + 4);
|
||||
if (handle.includes("w")) left = Math.min(point.x, right - 4);
|
||||
if (handle.includes("e")) right = Math.max(point.x, left + 4);
|
||||
return [left, top, right, bottom];
|
||||
}
|
||||
|
||||
function boxesNearlyEqual(
|
||||
left: readonly [number, number, number, number],
|
||||
right: readonly [number, number, number, number],
|
||||
): boolean {
|
||||
return left.every((value, index) => Math.abs(value - right[index]) < 0.5);
|
||||
}
|
||||
|
||||
function customLabelValue(label: string): string {
|
||||
return `custom:${encodeURIComponent(label)}`;
|
||||
}
|
||||
|
||||
function objectLabel(object: L34AnnotationObjectDraft): string {
|
||||
if (object.category === null) return "Выберите класс";
|
||||
if (object.category === "unmapped") {
|
||||
return object.proposedLabel ?? L34_ANNOTATION_UNMAPPED_OPTION.label;
|
||||
}
|
||||
return L34_ANNOTATION_CLASS_OPTIONS.find(
|
||||
({ value }) => value === object.category,
|
||||
)?.label ?? object.category;
|
||||
}
|
||||
|
||||
function boxFromPoints(
|
||||
start: Point,
|
||||
end: Point,
|
||||
): readonly [number, number, number, number] {
|
||||
return [
|
||||
Math.min(start.x, end.x),
|
||||
Math.min(start.y, end.y),
|
||||
Math.max(start.x, end.x),
|
||||
Math.max(start.y, end.y),
|
||||
];
|
||||
}
|
||||
|
||||
export function L34AnnotationCanvas({
|
||||
frame,
|
||||
objects,
|
||||
drawingEnabled,
|
||||
selectedObjectId,
|
||||
unmappedLabels,
|
||||
allowUnmapped = true,
|
||||
comparisonObjects = [],
|
||||
layerMode = "review",
|
||||
newObjectOrigin = "manual",
|
||||
onObjectsChange,
|
||||
onSelectedObjectIdChange,
|
||||
onRequestUnmappedLabel,
|
||||
}: {
|
||||
frame: L34AnnotationSourceFrame;
|
||||
objects: readonly L34AnnotationObjectDraft[];
|
||||
drawingEnabled: boolean;
|
||||
selectedObjectId: string | null;
|
||||
unmappedLabels: readonly string[];
|
||||
allowUnmapped?: boolean;
|
||||
comparisonObjects?: readonly L34AnnotationComparisonObject[];
|
||||
layerMode?: L34AnnotationLayerMode;
|
||||
newObjectOrigin?: string;
|
||||
onObjectsChange: (objects: readonly L34AnnotationObjectDraft[]) => void;
|
||||
onSelectedObjectIdChange: (objectId: string | null) => void;
|
||||
onRequestUnmappedLabel: (objectId: string) => void;
|
||||
}) {
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const [planeSize, setPlaneSize] = useState<PlaneSize>({ width: 1, height: 1 });
|
||||
const [interaction, setInteraction] = useState<Interaction | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = stageRef.current;
|
||||
if (!host) return;
|
||||
const measure = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const scale = Math.min(
|
||||
width / frame.cameraWidth,
|
||||
height / frame.cameraHeight,
|
||||
);
|
||||
setPlaneSize({
|
||||
width: Math.max(frame.cameraWidth * scale, 1),
|
||||
height: Math.max(frame.cameraHeight * scale, 1),
|
||||
});
|
||||
};
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(host);
|
||||
measure();
|
||||
return () => observer.disconnect();
|
||||
}, [frame.cameraHeight, frame.cameraWidth]);
|
||||
|
||||
useEffect(() => setInteraction(null), [frame.truthIslandSequence]);
|
||||
|
||||
const updateObject = (
|
||||
objectId: string,
|
||||
patch: Partial<L34AnnotationObjectDraft>,
|
||||
) => {
|
||||
let changed = false;
|
||||
const next = objects.map((object) => {
|
||||
if (object.objectId !== objectId) return object;
|
||||
const keys = Object.keys(patch) as Array<keyof L34AnnotationObjectDraft>;
|
||||
const scalarChanged = keys.some((key) => (
|
||||
key !== "boxXyxy" && object[key] !== patch[key]
|
||||
));
|
||||
const boxChanged = patch.boxXyxy !== undefined
|
||||
&& !boxesNearlyEqual(object.boxXyxy, patch.boxXyxy);
|
||||
if (!scalarChanged && !boxChanged) return object;
|
||||
changed = true;
|
||||
return { ...object, ...patch };
|
||||
});
|
||||
if (changed) onObjectsChange(next);
|
||||
};
|
||||
|
||||
const finishInteraction = (event: ReactPointerEvent<SVGSVGElement>) => {
|
||||
if (!interaction || interaction.pointerId !== event.pointerId) return;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
const end = boundedPoint(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
event.currentTarget,
|
||||
frame,
|
||||
);
|
||||
if (interaction.kind !== "draw") {
|
||||
const box = interaction.kind === "move"
|
||||
? movedBox(interaction.originalBox, interaction.start, end, frame)
|
||||
: resizedBox(
|
||||
interaction.originalBox,
|
||||
interaction.handle ?? "se",
|
||||
end,
|
||||
);
|
||||
updateObject(interaction.objectId, { boxXyxy: box });
|
||||
setInteraction(null);
|
||||
return;
|
||||
}
|
||||
const box = boxFromPoints(interaction.start, end);
|
||||
setInteraction(null);
|
||||
if (box[2] - box[0] < 4 || box[3] - box[1] < 4) return;
|
||||
const objectId = annotationOperationKey("box").replace(":", "-");
|
||||
onObjectsChange([
|
||||
...objects,
|
||||
{
|
||||
objectId,
|
||||
category: null,
|
||||
proposedLabel: null,
|
||||
origin: newObjectOrigin,
|
||||
boxXyxy: box,
|
||||
occluded: false,
|
||||
truncated: false,
|
||||
},
|
||||
]);
|
||||
onSelectedObjectIdChange(objectId);
|
||||
};
|
||||
|
||||
const humanVisible = layerMode === "overlay" || layerMode === "review";
|
||||
const candidateVisible = layerMode === "overlay" || layerMode === "candidate";
|
||||
const humanLabelsVisible = layerMode === "review";
|
||||
const candidateLabelsVisible = layerMode === "candidate";
|
||||
const editable = humanVisible;
|
||||
const effectiveDrawingEnabled = drawingEnabled && editable;
|
||||
const draftBox = interaction?.kind === "draw"
|
||||
? boxFromPoints(interaction.start, interaction.end)
|
||||
: null;
|
||||
const selectedObject = objects.find(
|
||||
({ objectId }) => objectId === selectedObjectId,
|
||||
) ?? null;
|
||||
return (
|
||||
<div className="l34-annotation-canvas" ref={stageRef}>
|
||||
<div
|
||||
className="l34-annotation-canvas__plane"
|
||||
style={{ width: planeSize.width, height: planeSize.height }}
|
||||
>
|
||||
<img
|
||||
src={frame.cameraUrl}
|
||||
alt={`Исходный кадр ${frame.frameIndex} правой камеры без model overlay`}
|
||||
draggable={false}
|
||||
/>
|
||||
<svg
|
||||
viewBox={`0 0 ${frame.cameraWidth} ${frame.cameraHeight}`}
|
||||
role="img"
|
||||
aria-label={`Разметка кадра ${frame.frameIndex}: ${objects.length} рамок`}
|
||||
data-drawing={effectiveDrawingEnabled ? "true" : undefined}
|
||||
onPointerDown={(event) => {
|
||||
if (!effectiveDrawingEnabled || event.button !== 0) return;
|
||||
const point = boundedPoint(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
event.currentTarget,
|
||||
frame,
|
||||
);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setInteraction({
|
||||
kind: "draw",
|
||||
pointerId: event.pointerId,
|
||||
start: point,
|
||||
end: point,
|
||||
});
|
||||
onSelectedObjectIdChange(null);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!interaction || interaction.pointerId !== event.pointerId) return;
|
||||
const point = boundedPoint(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
event.currentTarget,
|
||||
frame,
|
||||
);
|
||||
if (interaction.kind === "draw") {
|
||||
setInteraction({ ...interaction, end: point });
|
||||
return;
|
||||
}
|
||||
const box = interaction.kind === "move"
|
||||
? movedBox(interaction.originalBox, interaction.start, point, frame)
|
||||
: resizedBox(
|
||||
interaction.originalBox,
|
||||
interaction.handle ?? "se",
|
||||
point,
|
||||
);
|
||||
updateObject(interaction.objectId, { boxXyxy: box });
|
||||
}}
|
||||
onPointerUp={finishInteraction}
|
||||
onPointerCancel={() => setInteraction(null)}
|
||||
>
|
||||
{candidateVisible ? comparisonObjects.map((object) => {
|
||||
const [left, top, right, bottom] = object.boxXyxy;
|
||||
return (
|
||||
<rect
|
||||
className="l34-annotation-canvas__comparison"
|
||||
key={object.objectId}
|
||||
x={left}
|
||||
y={top}
|
||||
width={right - left}
|
||||
height={bottom - top}
|
||||
/>
|
||||
);
|
||||
}) : null}
|
||||
{humanVisible ? objects.map((object) => {
|
||||
const [left, top, right, bottom] = object.boxXyxy;
|
||||
return (
|
||||
<rect
|
||||
key={object.objectId}
|
||||
x={left}
|
||||
y={top}
|
||||
width={right - left}
|
||||
height={bottom - top}
|
||||
data-selected={object.objectId === selectedObjectId ? "true" : undefined}
|
||||
onPointerDown={(event) => {
|
||||
if (effectiveDrawingEnabled || event.button !== 0) return;
|
||||
event.stopPropagation();
|
||||
const svg = event.currentTarget.ownerSVGElement;
|
||||
if (!svg) return;
|
||||
const point = boundedPoint(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
svg,
|
||||
frame,
|
||||
);
|
||||
svg.setPointerCapture(event.pointerId);
|
||||
onSelectedObjectIdChange(object.objectId);
|
||||
setInteraction({
|
||||
kind: "move",
|
||||
pointerId: event.pointerId,
|
||||
objectId: object.objectId,
|
||||
start: point,
|
||||
originalBox: object.boxXyxy,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}) : null}
|
||||
{draftBox ? (
|
||||
<rect
|
||||
className="l34-annotation-canvas__draft"
|
||||
x={draftBox[0]}
|
||||
y={draftBox[1]}
|
||||
width={draftBox[2] - draftBox[0]}
|
||||
height={draftBox[3] - draftBox[1]}
|
||||
/>
|
||||
) : null}
|
||||
{selectedObject && editable && !effectiveDrawingEnabled ? ([
|
||||
["nw", selectedObject.boxXyxy[0], selectedObject.boxXyxy[1]],
|
||||
["ne", selectedObject.boxXyxy[2], selectedObject.boxXyxy[1]],
|
||||
["sw", selectedObject.boxXyxy[0], selectedObject.boxXyxy[3]],
|
||||
["se", selectedObject.boxXyxy[2], selectedObject.boxXyxy[3]],
|
||||
] as const).map(([handle, x, y]) => (
|
||||
<circle
|
||||
className="l34-annotation-canvas__resize-handle"
|
||||
data-handle={handle}
|
||||
key={`${selectedObject.objectId}-${handle}`}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={5}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
event.stopPropagation();
|
||||
const svg = event.currentTarget.ownerSVGElement;
|
||||
if (!svg) return;
|
||||
svg.setPointerCapture(event.pointerId);
|
||||
setInteraction({
|
||||
kind: "resize",
|
||||
pointerId: event.pointerId,
|
||||
objectId: selectedObject.objectId,
|
||||
start: boundedPoint(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
svg,
|
||||
frame,
|
||||
),
|
||||
originalBox: selectedObject.boxXyxy,
|
||||
handle,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)) : null}
|
||||
</svg>
|
||||
{candidateLabelsVisible ? comparisonObjects.map((object) => {
|
||||
const [left, top] = object.boxXyxy;
|
||||
return (
|
||||
<div
|
||||
className="l34-annotation-canvas__comparison-label"
|
||||
key={`${object.objectId}-comparison-label`}
|
||||
style={{
|
||||
left: `${left / frame.cameraWidth * 100}%`,
|
||||
top: `${top / frame.cameraHeight * 100}%`,
|
||||
transform: top < 42 ? "none" : "translateY(-100%)",
|
||||
}}
|
||||
>
|
||||
Candidate · {object.category}
|
||||
</div>
|
||||
);
|
||||
}) : null}
|
||||
{humanLabelsVisible ? objects.map((object) => {
|
||||
const [left, top] = object.boxXyxy;
|
||||
const below = top < 42;
|
||||
const selected = object.objectId === selectedObjectId;
|
||||
return (
|
||||
<div
|
||||
className="l34-annotation-canvas__label"
|
||||
data-unclassified={object.category === null ? "true" : undefined}
|
||||
data-unmapped={object.category === "unmapped" ? "true" : undefined}
|
||||
data-selected={selected ? "true" : undefined}
|
||||
key={`${object.objectId}-label`}
|
||||
style={{
|
||||
left: `${left / frame.cameraWidth * 100}%`,
|
||||
top: `${top / frame.cameraHeight * 100}%`,
|
||||
transform: below ? "none" : "translateY(-100%)",
|
||||
}}
|
||||
onPointerDown={() => onSelectedObjectIdChange(object.objectId)}
|
||||
>
|
||||
{selected ? <Select
|
||||
label={`Класс рамки ${object.objectId}`}
|
||||
value={object.category === "unmapped" && object.proposedLabel
|
||||
? customLabelValue(object.proposedLabel)
|
||||
: object.category ?? ""}
|
||||
options={[
|
||||
{ value: "", label: "Выберите класс", disabled: true },
|
||||
...L34_ANNOTATION_CLASS_OPTIONS,
|
||||
...(allowUnmapped ? [
|
||||
...Array.from(new Set([
|
||||
...unmappedLabels,
|
||||
...(object.proposedLabel ? [object.proposedLabel] : []),
|
||||
])).map((label) => ({
|
||||
value: customLabelValue(label),
|
||||
label: `Другой · ${label}`,
|
||||
})),
|
||||
L34_ANNOTATION_UNMAPPED_OPTION,
|
||||
] : []),
|
||||
]}
|
||||
menuWidth={210}
|
||||
minMenuWidth={210}
|
||||
placement={below ? "bottom-start" : "top-start"}
|
||||
onChange={(value) => {
|
||||
if (value.startsWith("custom:")) {
|
||||
updateObject(object.objectId, {
|
||||
category: "unmapped",
|
||||
proposedLabel: decodeURIComponent(value.slice(7)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (value === L34_ANNOTATION_UNMAPPED_OPTION.value) {
|
||||
onRequestUnmappedLabel(object.objectId);
|
||||
return;
|
||||
}
|
||||
updateObject(object.objectId, {
|
||||
category: value as L34AnnotationCategory,
|
||||
proposedLabel: null,
|
||||
});
|
||||
}}
|
||||
/> : <span>{objectLabel(object)}</span>}
|
||||
</div>
|
||||
);
|
||||
}) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1019
File diff suppressed because it is too large
Load Diff
+635
@@ -0,0 +1,635 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Button,
|
||||
Checker,
|
||||
Icon,
|
||||
IconButton,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
ToastStack,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
type ToastItem,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
type L34AnnotationSourceFrame,
|
||||
} from "../../../core/laboratory/l34Annotation";
|
||||
import type { L34EResult } from "../../../core/laboratory/l34eSelfReviewDiagnostic";
|
||||
import {
|
||||
createL34FSession,
|
||||
fetchL34FCase,
|
||||
fetchL34FSession,
|
||||
fetchL34FSessions,
|
||||
freezeL34FSession,
|
||||
saveL34FSession,
|
||||
type L34FCase,
|
||||
type L34FFrame,
|
||||
type L34FObject,
|
||||
type L34FSession,
|
||||
type L34FSessionSummary,
|
||||
} from "../../../core/laboratory/l34fAdjudication";
|
||||
import {
|
||||
L34AnnotationCanvas,
|
||||
type L34AnnotationLayerMode,
|
||||
type L34AnnotationObjectDraft,
|
||||
} from "./L34AnnotationCanvas";
|
||||
|
||||
interface FrameDraft extends Omit<L34FFrame, "objects"> {
|
||||
objects: readonly L34AnnotationObjectDraft[];
|
||||
}
|
||||
|
||||
const MODES: ReadonlyArray<{ value: L34AnnotationLayerMode; label: string }> = [
|
||||
{ value: "overlay", label: "ВМЕСТЕ" },
|
||||
{ value: "candidate", label: "CANDIDATE" },
|
||||
{ value: "review", label: "HUMAN" },
|
||||
{ value: "source", label: "SOURCE" },
|
||||
];
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Операция L3.4F не выполнена.";
|
||||
}
|
||||
|
||||
function draftsFromSession(session: L34FSession): Map<number, FrameDraft> {
|
||||
return new Map(session.frames.map((frame) => [
|
||||
frame.truthIslandSequence,
|
||||
{
|
||||
...frame,
|
||||
objects: frame.objects.map((object) => ({ ...object })),
|
||||
},
|
||||
]));
|
||||
}
|
||||
|
||||
function sessionLabel(session: L34FSessionSummary): string {
|
||||
return `${session.title} · ${session.progress.reviewedFrameCount}/32`;
|
||||
}
|
||||
|
||||
export function L34FAdjudicationWorkspace({
|
||||
result,
|
||||
onClose,
|
||||
}: {
|
||||
result: L34EResult;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [sessions, setSessions] = useState<readonly L34FSessionSummary[]>([]);
|
||||
const [session, setSession] = useState<L34FSession | null>(null);
|
||||
const [drafts, setDrafts] = useState<ReadonlyMap<number, FrameDraft>>(new Map());
|
||||
const [selectedSequence, setSelectedSequence] = useState(result.caseOrder[0] ?? 1);
|
||||
const [diagnosticCase, setDiagnosticCase] = useState<L34FCase | null>(null);
|
||||
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<L34AnnotationLayerMode>("overlay");
|
||||
const [drawingEnabled, setDrawingEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [frameLoading, setFrameLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [discardOpen, setDiscardOpen] = useState(false);
|
||||
const [customLabelOpen, setCustomLabelOpen] = useState(false);
|
||||
const [customLabelObjectId, setCustomLabelObjectId] = useState<string | null>(null);
|
||||
const [customLabelDraft, setCustomLabelDraft] = useState("");
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const notify = useCallback((item: Omit<ToastItem, "id">) => {
|
||||
setToasts((current) => [
|
||||
...current.filter(({ tone }) => tone !== "loading"),
|
||||
{ ...item, id: `${Date.now()}-${Math.random()}` },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
previousFocusRef.current = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
previousFocusRef.current?.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchL34FSessions(result.resultId, { signal: controller.signal })
|
||||
.then(async (items) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setSessions(items);
|
||||
const latest = items.at(-1);
|
||||
if (!latest) return;
|
||||
const loaded = await fetchL34FSession(result.resultId, latest.sessionId, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) return;
|
||||
setSession(loaded);
|
||||
setTitleDraft(loaded.title);
|
||||
setDrafts(draftsFromSession(loaded));
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) setError(errorMessage(caught));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setFrameLoading(true);
|
||||
setDiagnosticCase(null);
|
||||
void fetchL34FCase(result.resultId, selectedSequence, {
|
||||
signal: controller.signal,
|
||||
}).then((value) => {
|
||||
if (!controller.signal.aborted) setDiagnosticCase(value);
|
||||
}).catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) setError(errorMessage(caught));
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setFrameLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId, selectedSequence]);
|
||||
|
||||
useEffect(() => setSelectedObjectId(null), [selectedSequence]);
|
||||
|
||||
const currentDraft = drafts.get(selectedSequence) ?? null;
|
||||
const selectedObject = currentDraft?.objects.find(
|
||||
({ objectId }) => objectId === selectedObjectId,
|
||||
) ?? null;
|
||||
const reviewedCount = [...drafts.values()].filter(({ reviewed }) => reviewed).length;
|
||||
const objectCount = [...drafts.values()].reduce(
|
||||
(total, frame) => total + frame.objects.length,
|
||||
0,
|
||||
);
|
||||
const unresolvedCount = [...drafts.values()].reduce(
|
||||
(total, frame) => total + frame.objects.filter((object) => (
|
||||
object.category === null
|
||||
|| (object.category === "unmapped" && !object.proposedLabel?.trim())
|
||||
)).length,
|
||||
0,
|
||||
);
|
||||
const customLabels = useMemo(() => Array.from(new Set(
|
||||
[...drafts.values()].flatMap(({ objects }) => objects
|
||||
.filter((object) => object.category === "unmapped" && object.proposedLabel)
|
||||
.map((object) => object.proposedLabel!)),
|
||||
)).sort((left, right) => left.localeCompare(right, "ru-RU")), [drafts]);
|
||||
|
||||
const sourceFrame = useMemo<L34AnnotationSourceFrame | null>(() => (
|
||||
diagnosticCase ? {
|
||||
resultId: diagnosticCase.diagnosticResultId,
|
||||
truthIslandSequence: diagnosticCase.truthIslandSequence,
|
||||
imageId: diagnosticCase.imageId,
|
||||
frameIndex: diagnosticCase.frameIndex,
|
||||
groupId: diagnosticCase.groupId,
|
||||
role: "temporal",
|
||||
sessionSeconds: diagnosticCase.sessionSeconds,
|
||||
cameraWidth: diagnosticCase.cameraWidth,
|
||||
cameraHeight: diagnosticCase.cameraHeight,
|
||||
cameraSha256: diagnosticCase.sourceImageSha256,
|
||||
cameraUrl: diagnosticCase.cameraUrl,
|
||||
modelMaterialIncluded: false,
|
||||
} : null
|
||||
), [diagnosticCase]);
|
||||
|
||||
const setCurrentObjects = (objects: readonly L34AnnotationObjectDraft[]) => {
|
||||
if (!currentDraft) return;
|
||||
setDrafts((current) => {
|
||||
const next = new Map(current);
|
||||
next.set(selectedSequence, { ...currentDraft, objects, reviewed: false });
|
||||
return next;
|
||||
});
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const createSession = async () => {
|
||||
if (dirty) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await createL34FSession(result.resultId);
|
||||
setSession(created);
|
||||
setSessions((current) => [
|
||||
...current.filter(({ sessionId }) => sessionId !== created.sessionId),
|
||||
created,
|
||||
]);
|
||||
setDrafts(draftsFromSession(created));
|
||||
setTitleDraft(created.title);
|
||||
setDirty(false);
|
||||
notify({
|
||||
tone: "success",
|
||||
title: "Сессия L3.4F создана",
|
||||
description: "Human-слой скопирован из self-review; candidate остаётся read-only.",
|
||||
});
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectSession = async (sessionId: string) => {
|
||||
if (dirty || sessionId === session?.sessionId) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const loaded = await fetchL34FSession(result.resultId, sessionId);
|
||||
setSession(loaded);
|
||||
setDrafts(draftsFromSession(loaded));
|
||||
setTitleDraft(loaded.title);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const savableFrames = (): readonly L34FFrame[] | null => {
|
||||
if (drafts.size !== 32) return null;
|
||||
const frames: L34FFrame[] = [];
|
||||
for (const frame of drafts.values()) {
|
||||
if (frame.objects.some(({ category }) => category === null)) return null;
|
||||
frames.push({
|
||||
...frame,
|
||||
objects: frame.objects as readonly L34FObject[],
|
||||
});
|
||||
}
|
||||
return frames.sort((left, right) => left.truthIslandSequence - right.truthIslandSequence);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!session || !titleDraft.trim()) return;
|
||||
const frames = savableFrames();
|
||||
if (!frames) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await saveL34FSession(session, titleDraft.trim(), frames);
|
||||
setSession(saved);
|
||||
setDrafts(draftsFromSession(saved));
|
||||
setSessions((current) => [
|
||||
...current.filter(({ sessionId }) => sessionId !== saved.sessionId),
|
||||
saved,
|
||||
]);
|
||||
setDirty(false);
|
||||
setSaveOpen(false);
|
||||
notify({
|
||||
tone: "success",
|
||||
title: "Ревизия L3.4F сохранена",
|
||||
description: `${saved.progress.reviewedFrameCount}/32 кадров · revision ${saved.revision}.`,
|
||||
});
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const freeze = async () => {
|
||||
if (!session || dirty || !session.progress.complete) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const frozen = await freezeL34FSession(session);
|
||||
notify({
|
||||
tone: "success",
|
||||
title: "L3.4F зафиксирован",
|
||||
description: `${frozen.metrics.changedFrameCount} изменённых кадров · не independent truth.`,
|
||||
});
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSelectedObject = (patch: Partial<L34AnnotationObjectDraft>) => {
|
||||
if (!currentDraft || !selectedObject) return;
|
||||
setCurrentObjects(currentDraft.objects.map((object) => (
|
||||
object.objectId === selectedObject.objectId ? { ...object, ...patch } : object
|
||||
)));
|
||||
};
|
||||
|
||||
const requestCustomLabel = (objectId: string) => {
|
||||
const object = currentDraft?.objects.find((item) => item.objectId === objectId);
|
||||
if (!object) return;
|
||||
setSelectedObjectId(objectId);
|
||||
setCustomLabelObjectId(objectId);
|
||||
setCustomLabelDraft(object.proposedLabel ?? "");
|
||||
setCustomLabelOpen(true);
|
||||
};
|
||||
|
||||
const confirmCustomLabel = () => {
|
||||
const label = customLabelDraft.trim();
|
||||
if (!currentDraft || !customLabelObjectId || !label) return;
|
||||
setCurrentObjects(currentDraft.objects.map((object) => (
|
||||
object.objectId === customLabelObjectId
|
||||
? { ...object, category: "unmapped", proposedLabel: label }
|
||||
: object
|
||||
)));
|
||||
setCustomLabelOpen(false);
|
||||
setCustomLabelObjectId(null);
|
||||
};
|
||||
|
||||
const orderedCases = result.caseOrder.map((sequence) => result.cases.find(
|
||||
(item) => item.truthIslandSequence === sequence,
|
||||
)).filter((item): item is L34EResult["cases"][number] => Boolean(item));
|
||||
const selectedIndex = result.caseOrder.indexOf(selectedSequence);
|
||||
const navigate = (offset: -1 | 1) => {
|
||||
const index = (selectedIndex + offset + result.caseOrder.length) % result.caseOrder.length;
|
||||
setSelectedSequence(result.caseOrder[index]);
|
||||
};
|
||||
const requestClose = () => dirty ? setDiscardOpen(true) : onClose();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || saveOpen || customLabelOpen || discardOpen) return;
|
||||
event.preventDefault();
|
||||
requestClose();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
});
|
||||
|
||||
const workspace = (
|
||||
<section
|
||||
className="l34-annotation-workspace"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Разбор конфликтов LAB L3.4F"
|
||||
>
|
||||
<header className="l34-annotation-workspace__toolbar">
|
||||
<div className="l34-annotation-workspace__session-tools">
|
||||
<Select
|
||||
label="Сессия разбора конфликтов"
|
||||
value={session?.sessionId ?? ""}
|
||||
options={sessions.length ? sessions.map((item) => ({
|
||||
value: item.sessionId,
|
||||
label: sessionLabel(item),
|
||||
})) : [{ value: "", label: "Сессия не создана", disabled: true }]}
|
||||
disabled={busy}
|
||||
searchable
|
||||
menuWidth={380}
|
||||
minMenuWidth={300}
|
||||
onChange={(value) => void selectSession(value)}
|
||||
/>
|
||||
<IconButton
|
||||
label="Создать сессию L3.4F"
|
||||
disabled={busy || dirty}
|
||||
onClick={() => void createSession()}
|
||||
>
|
||||
<Icon name="plus" size={16} />
|
||||
</IconButton>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={drawingEnabled ? "accent" : "secondary"}
|
||||
icon={<Icon name="edit" size={16} />}
|
||||
aria-pressed={drawingEnabled}
|
||||
disabled={!session || busy || mode === "candidate" || mode === "source"}
|
||||
onClick={() => setDrawingEnabled((value) => !value)}
|
||||
>
|
||||
Рамка
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="primary"
|
||||
icon={<Icon name="save" size={16} />}
|
||||
disabled={!session || busy}
|
||||
onClick={() => setSaveOpen(true)}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={busy || dirty || !session?.progress.complete}
|
||||
onClick={() => void freeze()}
|
||||
>
|
||||
Зафиксировать L3.4F
|
||||
</Button>
|
||||
</div>
|
||||
<div className="l34-annotation-workspace__frame-tools">
|
||||
<IconButton label="Предыдущий конфликт" onClick={() => navigate(-1)}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Следующий конфликт" onClick={() => navigate(1)}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
<Select
|
||||
label="Конфликтный кадр"
|
||||
value={String(selectedSequence)}
|
||||
options={orderedCases.map((item) => ({
|
||||
value: String(item.truthIslandSequence),
|
||||
label: `${item.truthIslandSequence}/32 · frame ${item.frameIndex} · severity ${item.diagnosticSummary.severityScore}`,
|
||||
}))}
|
||||
searchable
|
||||
menuWidth={380}
|
||||
minMenuWidth={300}
|
||||
onChange={(value) => setSelectedSequence(Number(value))}
|
||||
/>
|
||||
<StatusBadge tone={dirty ? "warning" : reviewedCount === 32 ? "success" : "neutral"}>
|
||||
{dirty ? "Не сохранено" : `${reviewedCount}/32 · не truth`}
|
||||
</StatusBadge>
|
||||
<IconButton label="Закрыть L3.4F" onClick={requestClose}>
|
||||
<Icon name="close" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="l34-annotation-workspace__stage">
|
||||
{loading || frameLoading ? (
|
||||
<div className="l34-annotation-workspace__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<strong>Открываем candidate и human слоя без model scores</strong>
|
||||
</div>
|
||||
) : error || !sourceFrame || !diagnosticCase ? (
|
||||
<div className="l34-annotation-workspace__state" role="alert">
|
||||
<Icon name="alert" size={18} />
|
||||
<strong>{error ?? "Кадр L3.4F недоступен."}</strong>
|
||||
</div>
|
||||
) : (
|
||||
<L34AnnotationCanvas
|
||||
frame={sourceFrame}
|
||||
objects={currentDraft?.objects ?? []}
|
||||
comparisonObjects={diagnosticCase.candidateObjects.map((object) => ({
|
||||
objectId: object.candidateId,
|
||||
category: object.category,
|
||||
boxXyxy: object.boxXyxy,
|
||||
}))}
|
||||
layerMode={mode}
|
||||
newObjectOrigin="adjudicated_manual"
|
||||
drawingEnabled={drawingEnabled && Boolean(session)}
|
||||
selectedObjectId={selectedObjectId}
|
||||
unmappedLabels={customLabels}
|
||||
onObjectsChange={setCurrentObjects}
|
||||
onSelectedObjectIdChange={setSelectedObjectId}
|
||||
onRequestUnmappedLabel={requestCustomLabel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="l34-annotation-workspace__inspector">
|
||||
<div className="l34-annotation-workspace__source-state">
|
||||
<span>RAVNOVES00 · sensor.camera.right · L3.4F</span>
|
||||
<strong>
|
||||
{diagnosticCase
|
||||
? `frame ${diagnosticCase.frameIndex} · severity ${diagnosticCase.severityScore}`
|
||||
: "Источник проверяется"}
|
||||
</strong>
|
||||
<small>Candidate visible · scores скрыты · engineering reference · не independent truth</small>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
items={[...MODES]}
|
||||
label="Слои L3.4F"
|
||||
onChange={setMode}
|
||||
/>
|
||||
<div className="l34f-adjudication-workspace__legend">
|
||||
<span>Candidate read-only</span>
|
||||
<span>Human editable</span>
|
||||
</div>
|
||||
{session && currentDraft ? (
|
||||
<Checker
|
||||
checked={currentDraft.reviewed}
|
||||
label={currentDraft.objects.length ? "Конфликт разобран" : "Кадр подтверждён пустым"}
|
||||
onChange={(reviewed) => {
|
||||
setDrafts((current) => {
|
||||
const next = new Map(current);
|
||||
next.set(selectedSequence, { ...currentDraft, reviewed });
|
||||
return next;
|
||||
});
|
||||
setDirty(true);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{selectedObject && (mode === "overlay" || mode === "review") ? (
|
||||
<div className="l34-annotation-workspace__object-tools">
|
||||
<Checker
|
||||
checked={selectedObject.occluded}
|
||||
label="Перекрыт"
|
||||
onChange={(occluded) => updateSelectedObject({ occluded })}
|
||||
/>
|
||||
<Checker
|
||||
checked={selectedObject.truncated}
|
||||
label="Обрезан"
|
||||
onChange={(truncated) => updateSelectedObject({ truncated })}
|
||||
/>
|
||||
<IconButton
|
||||
label="Удалить human-рамку"
|
||||
onClick={() => {
|
||||
if (!currentDraft) return;
|
||||
setCurrentObjects(currentDraft.objects.filter(
|
||||
({ objectId }) => objectId !== selectedObject.objectId,
|
||||
));
|
||||
setSelectedObjectId(null);
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : null}
|
||||
</footer>
|
||||
|
||||
<Window
|
||||
open={customLabelOpen}
|
||||
title="Другой объект"
|
||||
subtitle="Название останется предложением до отдельной нормализации taxonomy."
|
||||
size="sm"
|
||||
onClose={() => setCustomLabelOpen(false)}
|
||||
footer={(
|
||||
<WindowFooterActions>
|
||||
<Button onClick={() => setCustomLabelOpen(false)}>Отмена</Button>
|
||||
<Button variant="primary" disabled={!customLabelDraft.trim()} onClick={confirmCustomLabel}>
|
||||
Добавить название
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
)}
|
||||
>
|
||||
<TextField
|
||||
label="Название объекта"
|
||||
value={customLabelDraft}
|
||||
maxLength={80}
|
||||
autoFocus
|
||||
placeholder="Например, детская коляска"
|
||||
onChange={(event) => setCustomLabelDraft(event.target.value)}
|
||||
/>
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
open={saveOpen}
|
||||
title="Сохранить ревизию L3.4F"
|
||||
subtitle="Сохранение не превращает candidate-visible adjudication в ground truth."
|
||||
size="md"
|
||||
closeOnBackdrop={!busy}
|
||||
closeOnEscape={!busy}
|
||||
onClose={() => !busy && setSaveOpen(false)}
|
||||
footer={(
|
||||
<WindowFooterActions>
|
||||
<Button disabled={busy} onClick={() => setSaveOpen(false)}>Отмена</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || !titleDraft.trim() || unresolvedCount > 0}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{busy ? "Сохраняем" : "Сохранить ревизию"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
)}
|
||||
>
|
||||
<div className="l34-annotation-save-form">
|
||||
<TextField
|
||||
label="Название сессии"
|
||||
value={titleDraft}
|
||||
maxLength={160}
|
||||
autoFocus
|
||||
onChange={(event) => setTitleDraft(event.target.value)}
|
||||
/>
|
||||
<div className="l34-annotation-save-form__summary">
|
||||
<span>Разобрано кадров</span><strong>{reviewedCount} / 32</strong>
|
||||
<span>Human-объектов</span><strong>{objectCount}</strong>
|
||||
<span>Без класса</span><strong>{unresolvedCount}</strong>
|
||||
</div>
|
||||
<p>
|
||||
После 32/32 сохраните ревизию и нажмите «Зафиксировать L3.4F». Следующий независимый gate — reviewer-B без candidate overlay.
|
||||
</p>
|
||||
</div>
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
open={discardOpen}
|
||||
title="Закрыть без сохранения?"
|
||||
subtitle="Несохранённые изменения human-слоя будут потеряны."
|
||||
size="sm"
|
||||
onClose={() => setDiscardOpen(false)}
|
||||
footer={(
|
||||
<WindowFooterActions>
|
||||
<Button onClick={() => setDiscardOpen(false)}>Остаться</Button>
|
||||
<Button variant="danger" onClick={onClose}>Закрыть без сохранения</Button>
|
||||
</WindowFooterActions>
|
||||
)}
|
||||
>
|
||||
Сохранённая серверная ревизия останется неизменной.
|
||||
</Window>
|
||||
<ToastStack
|
||||
items={toasts}
|
||||
onDismiss={(id) => setToasts((current) => current.filter((item) => item.id !== id))}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
return typeof document === "undefined" ? null : createPortal(workspace, document.body);
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import type { L34RightYoloxTruthIslandResult } from "../../../core/laboratory/l34RightYoloxTruthIsland";
|
||||
import type { L34DResult } from "../../../core/laboratory/l34dCumulativePostprocessing";
|
||||
import type { L34EResult } from "../../../core/laboratory/l34eSelfReviewDiagnostic";
|
||||
import type { E46BlindReviewResult } from "../../../core/laboratory/e46BlindReview";
|
||||
import type { E46AAiEngineeringPreannotationResult } from "../../../core/laboratory/e46aAiEngineeringPreannotation";
|
||||
import type { LaboratoryAnnotationAction } from "../../contracts";
|
||||
import { L34AnnotationWorkspace } from "./L34AnnotationWorkspace";
|
||||
import { L34FAdjudicationWorkspace } from "./L34FAdjudicationWorkspace";
|
||||
|
||||
export function useL34AnnotationCapability({
|
||||
selectedWorkId,
|
||||
l34Result,
|
||||
l34dResult,
|
||||
l34eResult,
|
||||
e46Result,
|
||||
e46aResult,
|
||||
onActionChange,
|
||||
}: {
|
||||
selectedWorkId: string;
|
||||
l34Result: L34RightYoloxTruthIslandResult | null;
|
||||
l34dResult: L34DResult | null;
|
||||
l34eResult: L34EResult | null;
|
||||
e46Result: E46BlindReviewResult | null;
|
||||
e46aResult: E46AAiEngineeringPreannotationResult | null;
|
||||
onActionChange: (action: LaboratoryAnnotationAction | null) => void;
|
||||
}): ReactNode {
|
||||
const [open, setOpen] = useState(false);
|
||||
const openWorkspace = useCallback(() => setOpen(true), []);
|
||||
const available = selectedWorkId === "l34-right-yolox-truth-island-freeze"
|
||||
&& l34Result
|
||||
? { resultId: l34Result.resultId, workflow: "assisted-candidate" as const }
|
||||
: selectedWorkId === "e46-detector-truth-island" && e46Result
|
||||
? { resultId: e46Result.resultId, workflow: "independent-blind" as const }
|
||||
: selectedWorkId === "e46a-ai-engineering-preannotation" && e46aResult
|
||||
? { resultId: e46aResult.resultId, workflow: "engineering-preannotation" as const }
|
||||
: selectedWorkId === "l34d-cumulative-postprocessing-candidate"
|
||||
&& l34dResult
|
||||
? { resultId: l34dResult.resultId, workflow: "prediction-hidden" as const }
|
||||
: selectedWorkId === "l34e-self-review-diagnostic" && l34eResult
|
||||
? { resultId: l34eResult.resultId, workflow: "adjudication" as const }
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!available) {
|
||||
onActionChange(null);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
onActionChange({
|
||||
label: open
|
||||
? "Рабочая область открыта"
|
||||
: available.workflow === "adjudication"
|
||||
? "Разобрать конфликты"
|
||||
: available.workflow === "independent-blind"
|
||||
? "Независимая разметка"
|
||||
: available.workflow === "engineering-preannotation"
|
||||
? "Проверить и исправить"
|
||||
: "Разметить данные",
|
||||
disabled: open,
|
||||
onClick: openWorkspace,
|
||||
});
|
||||
return () => onActionChange(null);
|
||||
}, [available, onActionChange, open, openWorkspace]);
|
||||
|
||||
if (open && available?.workflow === "adjudication" && l34eResult) {
|
||||
return (
|
||||
<L34FAdjudicationWorkspace
|
||||
result={l34eResult}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return open && available && available.workflow !== "adjudication" ? (
|
||||
<L34AnnotationWorkspace
|
||||
resultId={available.resultId}
|
||||
workflow={available.workflow}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
@@ -1,10 +1,25 @@
|
||||
import type { LaboratoryOption } from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { AdvancedLaboratoryWorkId } from "../../core/laboratory/advancedIndex";
|
||||
import type {
|
||||
AdvancedLaboratoryIndexItem,
|
||||
AdvancedLaboratoryWorkId,
|
||||
} from "../../core/laboratory/advancedIndex";
|
||||
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
||||
|
||||
export type LaboratoryProfileId =
|
||||
| "sensor-fusion"
|
||||
| "public-benchmarks"
|
||||
| "published-perception";
|
||||
| "rig-camera-local-surface-v1"
|
||||
| "rig-track-geometry-temporal-v1"
|
||||
| "rig-ravnoves-perception-gate-v1"
|
||||
| "rig-pointpillars-transfer-v1"
|
||||
| "rig-right-yolox-lidar-range-v1"
|
||||
| "rig-nvidia-ready-stack-v1"
|
||||
| "rig-nvidia-dashcam-ready-stack-v1"
|
||||
| "rig-nvidia-rectified-ready-stack-v1"
|
||||
| "rig-nvidia-grounding-dino-front-v1"
|
||||
| "rig-right-raw-fisheye-yolox-realtime-v1"
|
||||
| "kitti-pointpillars-benchmark-v1"
|
||||
| `published:${string}`;
|
||||
|
||||
export type LaboratoryExperimentId = string;
|
||||
|
||||
export type LaboratoryWorkId =
|
||||
| "e28-local-surface"
|
||||
@@ -13,48 +28,430 @@ export type LaboratoryWorkId =
|
||||
| AdvancedLaboratoryWorkId
|
||||
| `session:${string}`;
|
||||
|
||||
export function buildLaboratoryProfiles({
|
||||
rigLabel,
|
||||
sensorAvailable,
|
||||
publicBenchmarkAvailable,
|
||||
publishedAvailable,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
sensorAvailable: boolean;
|
||||
publicBenchmarkAvailable: boolean;
|
||||
publishedAvailable: boolean;
|
||||
}): readonly LaboratoryOption<LaboratoryProfileId>[] {
|
||||
const profiles: LaboratoryOption<LaboratoryProfileId>[] = [];
|
||||
if (sensorAvailable) {
|
||||
profiles.push({
|
||||
id: "sensor-fusion",
|
||||
label: `${rigLabel} · камера + LiDAR · control plane`,
|
||||
});
|
||||
}
|
||||
if (publicBenchmarkAvailable) {
|
||||
profiles.push({
|
||||
id: "public-benchmarks",
|
||||
label: "Публичные датасеты · внешний benchmark-контур",
|
||||
});
|
||||
}
|
||||
if (publishedAvailable) {
|
||||
profiles.push({
|
||||
id: "published-perception",
|
||||
label: `${rigLabel} · опубликованный perception pipeline`,
|
||||
});
|
||||
}
|
||||
return profiles;
|
||||
export interface LaboratoryCatalogSeed {
|
||||
id: LaboratoryWorkId;
|
||||
createdAtUtc: string;
|
||||
}
|
||||
|
||||
export function workOptionsForProfile(
|
||||
export interface LaboratoryCatalogEntry {
|
||||
id: LaboratoryWorkId;
|
||||
createdAtUtc: string;
|
||||
profileId: LaboratoryProfileId;
|
||||
profileName: string;
|
||||
experimentId: LaboratoryExperimentId;
|
||||
experimentName: string;
|
||||
variantName: string;
|
||||
}
|
||||
|
||||
interface KnownWorkDefinition {
|
||||
profileId: Exclude<LaboratoryProfileId, `published:${string}`>;
|
||||
profileName: (rigLabel: string) => string;
|
||||
experimentId: LaboratoryExperimentId;
|
||||
experimentName: string;
|
||||
variantName: string;
|
||||
}
|
||||
|
||||
const rig = (rigLabel: string): string => rigLabel.trim() || "Сенсорный риг";
|
||||
|
||||
const KNOWN_WORKS: Readonly<Record<Exclude<LaboratoryWorkId, `session:${string}`>, KnownWorkDefinition>> = {
|
||||
"e28-local-surface": {
|
||||
profileId: "rig-camera-local-surface-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera-first + local-surface LiDAR`,
|
||||
experimentId: "local-surface-semantic-geometry",
|
||||
experimentName: "Local surface + semantic geometry",
|
||||
variantName: "E28 · Local surface L2.6",
|
||||
},
|
||||
"e29-camera-geometry": {
|
||||
profileId: "rig-camera-local-surface-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera-first + local-surface LiDAR`,
|
||||
experimentId: "local-surface-semantic-geometry",
|
||||
experimentName: "Local surface + semantic geometry",
|
||||
variantName: "E29 · Camera-first + geometry",
|
||||
},
|
||||
"e30-evidence-review": {
|
||||
profileId: "rig-camera-local-surface-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera-first + local-surface LiDAR`,
|
||||
experimentId: "local-surface-semantic-geometry",
|
||||
experimentName: "Local surface + semantic geometry",
|
||||
variantName: "E30 · Evidence review A2",
|
||||
},
|
||||
"e31-source-binding": {
|
||||
profileId: "rig-track-geometry-temporal-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · TrackGeometry + temporal occupied/unknown`,
|
||||
experimentId: "track-geometry-source-contract",
|
||||
experimentName: "Source contract + TrackGeometry",
|
||||
variantName: "E31 · Source binding",
|
||||
},
|
||||
"e32-track-geometry": {
|
||||
profileId: "rig-track-geometry-temporal-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · TrackGeometry + temporal occupied/unknown`,
|
||||
experimentId: "track-geometry-source-contract",
|
||||
experimentName: "Source contract + TrackGeometry",
|
||||
variantName: "E32 · TrackGeometry v1",
|
||||
},
|
||||
"e33-worker-shadow": {
|
||||
profileId: "rig-track-geometry-temporal-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · TrackGeometry + temporal occupied/unknown`,
|
||||
experimentId: "temporal-shadow-envelope",
|
||||
experimentName: "Temporal shadow envelope",
|
||||
variantName: "E33 · Worker shadow 1×",
|
||||
},
|
||||
"e34-temporal-layer": {
|
||||
profileId: "rig-track-geometry-temporal-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · TrackGeometry + temporal occupied/unknown`,
|
||||
experimentId: "temporal-shadow-envelope",
|
||||
experimentName: "Temporal shadow envelope",
|
||||
variantName: "E34 · Occupied/unknown layer",
|
||||
},
|
||||
"e35-degradation-recovery": {
|
||||
profileId: "rig-track-geometry-temporal-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · TrackGeometry + temporal occupied/unknown`,
|
||||
experimentId: "temporal-shadow-envelope",
|
||||
experimentName: "Temporal shadow envelope",
|
||||
variantName: "E35 · Degradation recovery",
|
||||
},
|
||||
"e37-ravnoves-acceptance": {
|
||||
profileId: "rig-ravnoves-perception-gate-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · RAVNOVES00 perception gate`,
|
||||
experimentId: "ravnoves00-quality-gate",
|
||||
experimentName: "RAVNOVES00 quality gate",
|
||||
variantName: "E37 · Acceptance R0",
|
||||
},
|
||||
"e38-perception-baseline": {
|
||||
profileId: "rig-ravnoves-perception-gate-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · RAVNOVES00 perception gate`,
|
||||
experimentId: "ravnoves00-quality-gate",
|
||||
experimentName: "RAVNOVES00 quality gate",
|
||||
variantName: "E38 · Perception quality R1",
|
||||
},
|
||||
"e39-perception-refinement": {
|
||||
profileId: "rig-ravnoves-perception-gate-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · RAVNOVES00 perception gate`,
|
||||
experimentId: "ravnoves00-quality-gate",
|
||||
experimentName: "RAVNOVES00 quality gate",
|
||||
variantName: "E39 · Perception refinement R1",
|
||||
},
|
||||
"e40-perception-product-gate": {
|
||||
profileId: "rig-ravnoves-perception-gate-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · RAVNOVES00 perception gate`,
|
||||
experimentId: "ravnoves00-quality-gate",
|
||||
experimentName: "RAVNOVES00 quality gate",
|
||||
variantName: "E40 · Historical visible evaluation",
|
||||
},
|
||||
"e46-detector-truth-island": {
|
||||
profileId: "rig-right-yolox-lidar-range-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · YOLOX camera-first + LiDAR range`,
|
||||
experimentId: "ravnoves00-perception-benchmark-v1",
|
||||
experimentName: "RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1",
|
||||
variantName: "E46 · Independent source-only reviewer collection · not truth",
|
||||
},
|
||||
"e46a-ai-engineering-preannotation": {
|
||||
profileId: "rig-right-yolox-lidar-range-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · YOLOX camera-first + LiDAR range`,
|
||||
experimentId: "ravnoves00-perception-benchmark-v1",
|
||||
experimentName: "RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1",
|
||||
variantName: "E46A · AI engineering preannotation · 32/32 · not truth",
|
||||
},
|
||||
"e46b-temporal-motion": {
|
||||
profileId: "rig-right-yolox-lidar-range-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · YOLOX camera-first + LiDAR range`,
|
||||
experimentId: "ravnoves00-perception-benchmark-v1",
|
||||
experimentName: "RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1",
|
||||
variantName: "E46B · RIGHT temporal tracks + motion-state · 16/16",
|
||||
},
|
||||
"e46c-full-replay-world-tracks": {
|
||||
profileId: "rig-right-yolox-lidar-range-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · YOLOX camera-first + LiDAR range`,
|
||||
experimentId: "ravnoves00-perception-benchmark-v1",
|
||||
experimentName: "RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1",
|
||||
variantName: "E46C · full recorded RIGHT route/world tracks · 4489/4489",
|
||||
},
|
||||
"e46d-temporal-failure-audit": {
|
||||
profileId: "rig-right-yolox-lidar-range-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · YOLOX camera-first + LiDAR range`,
|
||||
experimentId: "ravnoves00-perception-benchmark-v1",
|
||||
experimentName: "RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1",
|
||||
variantName: "E46D · full replay temporal failure audit · automatic clips",
|
||||
},
|
||||
"e46e-ready-stack": {
|
||||
profileId: "rig-nvidia-ready-stack-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · NVIDIA RT-DETR + NvDCF ready stack`,
|
||||
experimentId: "nvidia-ready-stack-bakeoff-r1",
|
||||
experimentName: "NVIDIA ready-stack bake-off R1",
|
||||
variantName: "E46E · TrafficCamNet RT-DETR + NvDCF · full replay",
|
||||
},
|
||||
"e46f-dashcam-bakeoff": {
|
||||
profileId: "rig-nvidia-dashcam-ready-stack-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · DashCamNet DetectNet_v2 + NvDCF`,
|
||||
experimentId: "nvidia-mobile-detector-bakeoff-r2",
|
||||
experimentName: "NVIDIA mobile-detector bake-off R2",
|
||||
variantName: "E46F · DashCamNet DetectNet_v2 + NvDCF · rejected on raw fisheye",
|
||||
},
|
||||
"e46g-rectified-detector-bakeoff": {
|
||||
profileId: "rig-nvidia-rectified-ready-stack-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · NVIDIA KB4 FRONT + TrafficCamNet`,
|
||||
experimentId: "nvidia-calibrated-detector-bakeoff-r3",
|
||||
experimentName: "NVIDIA calibrated ready-detector bake-off R3",
|
||||
variantName: "E46G · KB4 nvdewarper · TrafficCamNet FRONT selected",
|
||||
},
|
||||
"e46h-full-rectified-front-replay": {
|
||||
profileId: "rig-nvidia-rectified-ready-stack-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · NVIDIA KB4 FRONT + TrafficCamNet`,
|
||||
experimentId: "nvidia-front-full-route-qualification-r1",
|
||||
experimentName: "NVIDIA FRONT full-route qualification R1",
|
||||
variantName: "E46H · full FRONT replay · large semantic false tracks",
|
||||
},
|
||||
"e46i-grounding-dino-full-replay": {
|
||||
profileId: "rig-nvidia-grounding-dino-front-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · NVIDIA KB4 FRONT + Grounding DINO`,
|
||||
experimentId: "nvidia-grounding-dino-full-route-shadow-r1",
|
||||
experimentName: "NVIDIA Grounding DINO full-route shadow R1",
|
||||
variantName: "E46I · semantic regression suppressed · awaiting temporal layer",
|
||||
},
|
||||
"e46j-raw-fisheye-realtime": {
|
||||
profileId: "rig-right-raw-fisheye-yolox-realtime-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · raw KB4 fisheye + YOLOX-S`,
|
||||
experimentId: "raw-fisheye-one-pass-realtime-r1",
|
||||
experimentName: "Raw fisheye one-pass realtime qualification R1",
|
||||
variantName: "E46J · full raw fisheye · realtime capacity passed",
|
||||
},
|
||||
"l34-right-yolox-truth-island-freeze": {
|
||||
profileId: "rig-right-yolox-lidar-range-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · YOLOX camera-first + LiDAR range`,
|
||||
experimentId: "ravnoves00-perception-benchmark-v1",
|
||||
experimentName: "RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1",
|
||||
variantName: "L3.4 · RIGHT YOLOX predictions frozen · awaiting truth",
|
||||
},
|
||||
"l34a-assisted-yolox-error-audit": {
|
||||
profileId: "rig-right-yolox-lidar-range-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · YOLOX camera-first + LiDAR range`,
|
||||
experimentId: "ravnoves00-perception-benchmark-v1",
|
||||
experimentName: "RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1",
|
||||
variantName: "L3.4A · Assisted YOLOX error audit · not truth",
|
||||
},
|
||||
"l34b-nested-box-consolidation-shadow": {
|
||||
profileId: "rig-right-yolox-lidar-range-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · YOLOX camera-first + LiDAR range`,
|
||||
experimentId: "ravnoves00-perception-benchmark-v1",
|
||||
experimentName: "RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1",
|
||||
variantName: "L3.4B · Nested-box consolidation shadow · not truth",
|
||||
},
|
||||
"l34c-tile-seam-stitch-shadow": {
|
||||
profileId: "rig-right-yolox-lidar-range-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · YOLOX camera-first + LiDAR range`,
|
||||
experimentId: "ravnoves00-perception-benchmark-v1",
|
||||
experimentName: "RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1",
|
||||
variantName: "L3.4C · Temporal front/left seam stitch · not truth",
|
||||
},
|
||||
"l34d-cumulative-postprocessing-candidate": {
|
||||
profileId: "rig-right-yolox-lidar-range-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · YOLOX camera-first + LiDAR range`,
|
||||
experimentId: "ravnoves00-perception-benchmark-v1",
|
||||
experimentName: "RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1",
|
||||
variantName: "L3.4D · Cumulative B+C candidate freeze · not truth",
|
||||
},
|
||||
"l34e-self-review-diagnostic": {
|
||||
profileId: "rig-right-yolox-lidar-range-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · YOLOX camera-first + LiDAR range`,
|
||||
experimentId: "ravnoves00-perception-benchmark-v1",
|
||||
experimentName: "RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1",
|
||||
variantName: "L3.4E · Self-review diagnostic disagreement audit · not truth",
|
||||
},
|
||||
"l34f-adjudicated-reference": {
|
||||
profileId: "rig-right-yolox-lidar-range-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · YOLOX camera-first + LiDAR range`,
|
||||
experimentId: "ravnoves00-perception-benchmark-v1",
|
||||
experimentName: "RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1",
|
||||
variantName: "L3.4F · Candidate-visible adjudicated engineering reference · not truth",
|
||||
},
|
||||
"l3-pointpillars-visual-audit": {
|
||||
profileId: "kitti-pointpillars-benchmark-v1",
|
||||
profileName: () => "KITTI · PointPillars external benchmark",
|
||||
experimentId: "kitti-pointpillars-visual-audit",
|
||||
experimentName: "PointPillars external validation",
|
||||
variantName: "L3 · KITTI visual audit",
|
||||
},
|
||||
"l31-pointpillars-ravnoves": {
|
||||
profileId: "rig-pointpillars-transfer-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} · PointPillars LiDAR transfer`,
|
||||
experimentId: "pointpillars-ravnoves00-transfer",
|
||||
experimentName: "RAVNOVES00 transfer",
|
||||
variantName: "L3.1 · PointPillars hypotheses",
|
||||
},
|
||||
"l32-pointpillars-camera-review": {
|
||||
profileId: "rig-pointpillars-transfer-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} · PointPillars LiDAR transfer`,
|
||||
experimentId: "pointpillars-ravnoves00-transfer",
|
||||
experimentName: "RAVNOVES00 transfer",
|
||||
variantName: "L3.2 · RIGHT camera review",
|
||||
},
|
||||
"l33-camera-first-detector-review": {
|
||||
profileId: "rig-right-yolox-lidar-range-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · YOLOX camera-first + LiDAR range`,
|
||||
experimentId: "right-camera-detector-admission",
|
||||
experimentName: "Detector admission + metric range",
|
||||
variantName: "L3.3 · Detector review + LiDAR range",
|
||||
},
|
||||
};
|
||||
|
||||
const PUBLISHED_PIPELINE_NAMES: Readonly<Record<string, string>> = {
|
||||
"e10-integrated-perception": "Camera semantics + LiDAR metric fusion",
|
||||
"e21-realtime-envelope": "Bounded real-time perception replay",
|
||||
"e22-temporal-stability": "Temporal 2D/3D stabilization",
|
||||
"e23-inline-temporal-stability": "Inline warm-worker stabilization",
|
||||
"e24-world-motion": "World-frame motion tracking",
|
||||
"e25-persistent-support-motion": "Persistent occupied-support tracking",
|
||||
"e26-camera-ego-motion-fusion": "Camera + ego-motion fusion",
|
||||
};
|
||||
|
||||
function timestamp(value: string): number {
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function laboratoryDate(value: string): string {
|
||||
const parsed = new Date(value);
|
||||
if (!Number.isFinite(parsed.getTime())) return "0000-00-00";
|
||||
return new Intl.DateTimeFormat("sv-SE", {
|
||||
timeZone: "Europe/Moscow",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(parsed);
|
||||
}
|
||||
|
||||
export function laboratoryTimestamp(value: string): string {
|
||||
const parsed = new Date(value);
|
||||
if (!Number.isFinite(parsed.getTime())) return "0000-00-00 00:00:00 MSK";
|
||||
const parts = Object.fromEntries(new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Moscow",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(parsed).map(({ type, value: part }) => [type, part]));
|
||||
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second} MSK`;
|
||||
}
|
||||
|
||||
function newestFirst(
|
||||
left: Pick<LaboratoryCatalogEntry, "id" | "createdAtUtc">,
|
||||
right: Pick<LaboratoryCatalogEntry, "id" | "createdAtUtc">,
|
||||
): number {
|
||||
return timestamp(right.createdAtUtc) - timestamp(left.createdAtUtc)
|
||||
|| right.id.localeCompare(left.id);
|
||||
}
|
||||
|
||||
function latestTimestamp(entries: readonly LaboratoryCatalogEntry[]): string {
|
||||
const latest = [...entries].sort(newestFirst)[0];
|
||||
return laboratoryTimestamp(latest?.createdAtUtc ?? "");
|
||||
}
|
||||
|
||||
function pipelineIdForSession(session: ObservationSessionSummary): string {
|
||||
const method = session.lab?.provenance.method;
|
||||
if (method && typeof method === "object" && !Array.isArray(method)) {
|
||||
const pipelineId = (method as Record<string, unknown>).pipeline_id;
|
||||
if (typeof pipelineId === "string" && pipelineId.trim()) return pipelineId.trim();
|
||||
}
|
||||
return session.lab?.resultKind ?? "legacy-perception";
|
||||
}
|
||||
|
||||
export function buildLaboratoryCatalog({
|
||||
rigLabel,
|
||||
knownWorks,
|
||||
advancedIndex,
|
||||
publishedWorks,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
knownWorks: readonly LaboratoryCatalogSeed[];
|
||||
advancedIndex: readonly AdvancedLaboratoryIndexItem[];
|
||||
publishedWorks: readonly ObservationSessionSummary[];
|
||||
}): readonly LaboratoryCatalogEntry[] {
|
||||
const seeded = new Map<LaboratoryWorkId, string>();
|
||||
for (const work of knownWorks) seeded.set(work.id, work.createdAtUtc);
|
||||
for (const work of advancedIndex) seeded.set(work.workId, work.createdAtUtc);
|
||||
|
||||
const entries: LaboratoryCatalogEntry[] = [];
|
||||
for (const [id, createdAtUtc] of seeded) {
|
||||
if (id.startsWith("session:")) continue;
|
||||
const definition = KNOWN_WORKS[id as Exclude<LaboratoryWorkId, `session:${string}`>];
|
||||
if (!definition) continue;
|
||||
entries.push({
|
||||
id,
|
||||
createdAtUtc,
|
||||
profileId: definition.profileId,
|
||||
profileName: definition.profileName(rigLabel),
|
||||
experimentId: definition.experimentId,
|
||||
experimentName: definition.experimentName,
|
||||
variantName: definition.variantName,
|
||||
});
|
||||
}
|
||||
|
||||
for (const session of publishedWorks) {
|
||||
const pipelineId = pipelineIdForSession(session);
|
||||
const profileId = `published:${pipelineId}` as const;
|
||||
const pipelineName = PUBLISHED_PIPELINE_NAMES[pipelineId] ?? pipelineId;
|
||||
entries.push({
|
||||
id: `session:${session.id}`,
|
||||
createdAtUtc: session.lab?.runCreatedAtUtc ?? session.startedAtUtc,
|
||||
profileId,
|
||||
profileName: `${rig(rigLabel)} RIGHT · ${pipelineName}`,
|
||||
experimentId: `${profileId}:ravnoves00`,
|
||||
experimentName: `RAVNOVES00 · ${pipelineName}`,
|
||||
variantName: session.label,
|
||||
});
|
||||
}
|
||||
|
||||
return entries.sort(newestFirst);
|
||||
}
|
||||
|
||||
export function buildLaboratoryProfiles(
|
||||
catalog: readonly LaboratoryCatalogEntry[],
|
||||
): readonly LaboratoryOption<LaboratoryProfileId>[] {
|
||||
const groups = new Map<LaboratoryProfileId, LaboratoryCatalogEntry[]>();
|
||||
for (const entry of catalog) {
|
||||
const group = groups.get(entry.profileId) ?? [];
|
||||
group.push(entry);
|
||||
groups.set(entry.profileId, group);
|
||||
}
|
||||
return [...groups.entries()].map(([id, entries]) => ({
|
||||
id,
|
||||
label: `${latestTimestamp(entries)} · ${entries[0]?.profileName ?? id}`,
|
||||
latest: Math.max(...entries.map(({ createdAtUtc }) => timestamp(createdAtUtc))),
|
||||
})).sort((left, right) => right.latest - left.latest || left.label.localeCompare(right.label))
|
||||
.map(({ id, label }) => ({ id, label }));
|
||||
}
|
||||
|
||||
export function experimentOptionsForProfile(
|
||||
profileId: LaboratoryProfileId,
|
||||
sensorWorks: readonly LaboratoryOption<LaboratoryWorkId>[],
|
||||
publicBenchmarkWorks: readonly LaboratoryOption<LaboratoryWorkId>[],
|
||||
publishedWorks: readonly LaboratoryOption<LaboratoryWorkId>[],
|
||||
catalog: readonly LaboratoryCatalogEntry[],
|
||||
): readonly LaboratoryOption<LaboratoryExperimentId>[] {
|
||||
const groups = new Map<LaboratoryExperimentId, LaboratoryCatalogEntry[]>();
|
||||
for (const entry of catalog) {
|
||||
if (entry.profileId !== profileId) continue;
|
||||
const group = groups.get(entry.experimentId) ?? [];
|
||||
group.push(entry);
|
||||
groups.set(entry.experimentId, group);
|
||||
}
|
||||
return [...groups.entries()].map(([id, entries]) => ({
|
||||
id,
|
||||
label: `${latestTimestamp(entries)} · ${entries[0]?.experimentName ?? id}`,
|
||||
latest: Math.max(...entries.map(({ createdAtUtc }) => timestamp(createdAtUtc))),
|
||||
})).sort((left, right) => right.latest - left.latest || left.label.localeCompare(right.label))
|
||||
.map(({ id, label }) => ({ id, label }));
|
||||
}
|
||||
|
||||
export function workOptionsForExperiment(
|
||||
profileId: LaboratoryProfileId,
|
||||
experimentId: LaboratoryExperimentId,
|
||||
catalog: readonly LaboratoryCatalogEntry[],
|
||||
): readonly LaboratoryOption<LaboratoryWorkId>[] {
|
||||
return profileId === "sensor-fusion"
|
||||
? sensorWorks
|
||||
: profileId === "public-benchmarks"
|
||||
? publicBenchmarkWorks
|
||||
: publishedWorks;
|
||||
return catalog.filter((entry) => (
|
||||
entry.profileId === profileId && entry.experimentId === experimentId
|
||||
)).sort(newestFirst).map((entry) => ({
|
||||
id: entry.id,
|
||||
label: `${laboratoryTimestamp(entry.createdAtUtc)} · ${entry.variantName}`,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ function mergeResults(
|
||||
return {
|
||||
l3: next.l3 ?? current.l3,
|
||||
l31: next.l31 ?? current.l31,
|
||||
l32: next.l32 ?? current.l32,
|
||||
l33: next.l33 ?? current.l33,
|
||||
e31: next.e31 ?? current.e31,
|
||||
e32: next.e32 ?? current.e32,
|
||||
e33: next.e33 ?? current.e33,
|
||||
@@ -29,6 +31,24 @@ function mergeResults(
|
||||
e38: next.e38 ?? current.e38,
|
||||
e39: next.e39 ?? current.e39,
|
||||
e40: next.e40 ?? current.e40,
|
||||
e46: next.e46 ?? current.e46,
|
||||
e46a: next.e46a ?? current.e46a,
|
||||
e46b: next.e46b ?? current.e46b,
|
||||
e46c: next.e46c ?? current.e46c,
|
||||
e46d: next.e46d ?? current.e46d,
|
||||
e46e: next.e46e ?? current.e46e,
|
||||
e46f: next.e46f ?? current.e46f,
|
||||
e46g: next.e46g ?? current.e46g,
|
||||
e46h: next.e46h ?? current.e46h,
|
||||
e46i: next.e46i ?? current.e46i,
|
||||
e46j: next.e46j ?? current.e46j,
|
||||
l34: next.l34 ?? current.l34,
|
||||
l34a: next.l34a ?? current.l34a,
|
||||
l34b: next.l34b ?? current.l34b,
|
||||
l34c: next.l34c ?? current.l34c,
|
||||
l34d: next.l34d ?? current.l34d,
|
||||
l34e: next.l34e ?? current.l34e,
|
||||
l34f: next.l34f ?? current.l34f,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user