feat(ui): stabilize shared M4.8 review viewer
This commit is contained in:
@@ -42,6 +42,8 @@ import { E46JRawFisheyeRealtimeResultView } from "./E46JRawFisheyeRealtimeResult
|
||||
import { E47SemanticSlamResultView } from "./E47SemanticSlamResult";
|
||||
import { M4ReplayThreatResultView } from "./M4ReplayThreatResult";
|
||||
import { M47ReferenceGraphResultView } from "./M47ReferenceGraphResult";
|
||||
import { M48ObjectCentricQualityResultView } from "./M48ObjectCentricQualityResult";
|
||||
import { M48SmallStaticPassageRegressionResultView } from "./M48SmallStaticPassageRegressionResult";
|
||||
|
||||
export { isAdvancedLaboratoryWorkId };
|
||||
export type { AdvancedLaboratoryWorkId };
|
||||
@@ -84,6 +86,12 @@ export function AdvancedLaboratoryResult({
|
||||
failedSessionId: string | null;
|
||||
replayError: string | null;
|
||||
}) {
|
||||
if (workId === "m48-object-centric-quality" && results.m48) {
|
||||
return <M48ObjectCentricQualityResultView rigLabel={rigLabel} result={results.m48} />;
|
||||
}
|
||||
if (workId === "m48-small-static-passage-regression" && results.m48SmallStatic) {
|
||||
return <M48SmallStaticPassageRegressionResultView rigLabel={rigLabel} result={results.m48SmallStatic} />;
|
||||
}
|
||||
if (workId === "m47-reference-graph-shadow" && results.m47Graph) {
|
||||
return <M47ReferenceGraphResultView rigLabel={rigLabel} result={results.m47Graph} />;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useLaboratoryValueReviewIndex } from "./useLaboratoryValueReviewIndex";
|
||||
import { useLaboratoryEvidenceReport } from "./useLaboratoryEvidenceReport";
|
||||
import { useLaboratoryViewMode } from "./useLaboratoryViewMode";
|
||||
import { useL34AnnotationCapability } from "./annotation/useL34AnnotationCapability";
|
||||
import { useM48ReviewCapability } from "./annotation/useM48ReviewCapability";
|
||||
import {
|
||||
buildLaboratoryCatalog,
|
||||
buildLaboratoryProfiles,
|
||||
@@ -526,6 +527,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
});
|
||||
const advancedResults: AdvancedLaboratoryResults = advanced.results;
|
||||
const annotationWorkspace = useL34AnnotationCapability({ selectedWorkId: viewMode === "laboratory" ? workId : "", l34Result: advancedResults.l34, l34dResult: advancedResults.l34d, l34eResult: advancedResults.l34e, e46Result: advancedResults.e46, e46aResult: advancedResults.e46a, onActionChange: props.onLaboratoryAnnotationActionChange });
|
||||
const m48Review = useM48ReviewCapability({ selectedWorkId: viewMode === "laboratory" ? workId : "", initialGate: advancedResults.m48?.kind === "review" ? advancedResults.m48 : null, onActionChange: props.onLaboratoryAnnotationActionChange });
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setEvidenceLoading(true);
|
||||
@@ -825,7 +827,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
onChange={selectWork}
|
||||
/>
|
||||
|
||||
<div className="laboratory-work-output">
|
||||
{!m48Review.active ? <div className="laboratory-work-output">
|
||||
{workId === "e28-local-surface" ? (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
@@ -947,8 +949,9 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
<p>Неподтверждённый результат скрыт из лабораторного каталога.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div> : null}
|
||||
{annotationWorkspace}
|
||||
{m48Review.workspace}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon, IconButton, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
RecordedEvidenceBoxOverlay,
|
||||
type RecordedEvidenceBox,
|
||||
} from "../../components/laboratory/RecordedEvidenceBoxOverlay";
|
||||
import {
|
||||
fetchM48FailureAtlas,
|
||||
fetchM48FailureCase,
|
||||
fetchM48ReviewSourceCatalog,
|
||||
type M48FailureCase,
|
||||
type M48FailureCaseSummary,
|
||||
} from "../../core/laboratory/m48ObjectCentricQuality";
|
||||
import {
|
||||
M48BlindClipPlayer,
|
||||
type M48BlindEvidenceMode,
|
||||
} from "./annotation/M48BlindClipPlayer";
|
||||
import { useM48SpatialClipPlayback } from "./annotation/useM48SpatialClipPlayback";
|
||||
import { M48EvidenceModeRail } from "./annotation/M48EvidenceModeControls";
|
||||
|
||||
const ATLAS_MODES = [
|
||||
{ value: "source", label: "SOURCE" },
|
||||
{ value: "truth", label: "TRUTH" },
|
||||
{ value: "graph", label: "GRAPH" },
|
||||
{ value: "overlay", label: "OVERLAY" },
|
||||
] as const;
|
||||
type AtlasMode = typeof ATLAS_MODES[number]["value"];
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim() ? error.message : "M4.8 evidence недоступно.";
|
||||
}
|
||||
|
||||
function FailureAtlasScene({ item, mode }: { item: M48FailureCase; mode: AtlasMode }) {
|
||||
const [size, setSize] = useState({ width: 1440, height: 1080 });
|
||||
const boxes = useMemo<RecordedEvidenceBox[]>(() => {
|
||||
const truth = mode === "truth" || mode === "overlay"
|
||||
? item.truth.map((object) => ({
|
||||
boxXyxy: [object.extentXyxy[0] * size.width, object.extentXyxy[1] * size.height, object.extentXyxy[2] * size.width, object.extentXyxy[3] * size.height] as const,
|
||||
label: `truth · ${object.objectId}`,
|
||||
tone: "success" as const,
|
||||
}))
|
||||
: [];
|
||||
const graph = mode === "graph" || mode === "overlay"
|
||||
? item.graph.map((object) => ({
|
||||
boxXyxy: [object.extentXyxy[0] * size.width, object.extentXyxy[1] * size.height, object.extentXyxy[2] * size.width, object.extentXyxy[3] * size.height] as const,
|
||||
label: `graph · ${object.objectId}`,
|
||||
tone: "danger" as const,
|
||||
dashed: mode === "overlay",
|
||||
}))
|
||||
: [];
|
||||
return [...truth, ...graph];
|
||||
}, [item.graph, item.truth, mode, size.height, size.width]);
|
||||
|
||||
if (!item.frame.cameraUrl) {
|
||||
return <div className="m48-atlas-visual__state" role="alert"><Icon name="alert" size={18} />Точный camera-кадр failure case недоступен.</div>;
|
||||
}
|
||||
return (
|
||||
<div className="m48-atlas-visual__scene">
|
||||
<img
|
||||
src={item.frame.cameraUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
onLoad={(event) => setSize({
|
||||
width: event.currentTarget.naturalWidth || 1440,
|
||||
height: event.currentTarget.naturalHeight || 1080,
|
||||
})}
|
||||
/>
|
||||
<RecordedEvidenceBoxOverlay imageWidth={size.width} imageHeight={size.height} boxes={boxes} ariaLabel="M4.8 truth/graph failure overlay" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function M48FailureAtlasVisual({ resultId }: { resultId: string }) {
|
||||
const [cases, setCases] = useState<readonly M48FailureCaseSummary[]>([]);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [item, setItem] = useState<M48FailureCase | null>(null);
|
||||
const [mode, setMode] = useState<AtlasMode>("overlay");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
void fetchM48FailureAtlas(resultId, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (!controller.signal.aborted) setCases(next);
|
||||
})
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)))
|
||||
.finally(() => !controller.signal.aborted && setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
const selected = cases[index];
|
||||
if (!selected) {
|
||||
setItem(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
void fetchM48FailureCase(resultId, selected.caseId, { signal: controller.signal })
|
||||
.then((next) => !controller.signal.aborted && setItem(next))
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)))
|
||||
.finally(() => !controller.signal.aborted && setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [cases, index, resultId]);
|
||||
|
||||
return (
|
||||
<LaboratoryEvidenceViewer
|
||||
label="M4.8 failure atlas"
|
||||
className="m48-atlas-visual"
|
||||
mode={mode}
|
||||
modes={ATLAS_MODES}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={(
|
||||
<>
|
||||
<IconButton label="Предыдущий failure case" disabled={!cases.length} onClick={() => setIndex((current) => (current - 1 + cases.length) % cases.length)}><Icon name="chevron-left" size={16} /></IconButton>
|
||||
<IconButton label="Следующий failure case" disabled={!cases.length} onClick={() => setIndex((current) => (current + 1) % cases.length)}><Icon name="chevron-right" size={16} /></IconButton>
|
||||
</>
|
||||
)}
|
||||
overlay={item ? <div className="m48-atlas-visual__case"><StatusBadge tone={item.split === "development" ? "neutral" : item.severity === "critical" ? "danger" : "warning"}>{item.split.toUpperCase()} · {item.severity}</StatusBadge><strong>{item.clipId} · frame {item.sequence}</strong><small>{item.split === "development" ? "Diagnostic only · " : "Validation acceptance evidence · "}{item.failures.join(" · ")}</small></div> : null}
|
||||
>
|
||||
{loading ? <div className="m48-atlas-visual__state" role="status"><span className="busy-indicator" aria-hidden="true" />Загружаем bounded failure case</div>
|
||||
: error ? <div className="m48-atlas-visual__state" role="alert"><Icon name="alert" size={18} />{error}</div>
|
||||
: item ? <FailureAtlasScene item={item} mode={mode} />
|
||||
: <div className="m48-atlas-visual__state" role="status"><Icon name="check" size={18} />Failure atlas пуст: ни один bounded failure case не зафиксирован.</div>}
|
||||
</LaboratoryEvidenceViewer>
|
||||
);
|
||||
}
|
||||
|
||||
export function M48ReviewPackVisual({ packId }: { packId: string }) {
|
||||
const [catalog, setCatalog] = useState<Awaited<ReturnType<typeof fetchM48ReviewSourceCatalog>> | null>(null);
|
||||
const [clipIndex, setClipIndex] = useState(0);
|
||||
const [sequence, setSequence] = useState(1);
|
||||
const [mode, setMode] = useState<M48BlindEvidenceMode>("camera");
|
||||
const [cameraVisible, setCameraVisible] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetchM48ReviewSourceCatalog(packId, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setCatalog(next);
|
||||
if (next.clips[0]) setSequence(next.clips[0].startSequence);
|
||||
})
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)));
|
||||
return () => controller.abort();
|
||||
}, [packId]);
|
||||
|
||||
const clip = catalog?.clips[clipIndex] ?? null;
|
||||
const spatialEnabled = Boolean(catalog?.evidenceCapabilities.currentPointCloudBodyXyzM && catalog.evidenceCapabilities.rig && catalog.evidenceCapabilities.virtualCorridor);
|
||||
const {
|
||||
frame: spatial,
|
||||
loading: spatialLoading,
|
||||
error: spatialError,
|
||||
} = useM48SpatialClipPlayback({
|
||||
packId,
|
||||
clip,
|
||||
sequence,
|
||||
enabled: mode !== "camera" && spatialEnabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<LaboratoryEvidenceViewer
|
||||
label="M4.8 neutral review pack"
|
||||
className="m48-atlas-visual"
|
||||
mode={mode}
|
||||
modes={[]}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
modeControlsVisible={false}
|
||||
chromeLayout="stacked"
|
||||
actions={<><IconButton label="Предыдущий клип" disabled={!catalog} onClick={() => {
|
||||
if (!catalog) return;
|
||||
const next = (clipIndex - 1 + catalog.clips.length) % catalog.clips.length;
|
||||
setClipIndex(next);
|
||||
setSequence(catalog.clips[next]!.startSequence);
|
||||
}}><Icon name="chevron-left" size={16} /></IconButton><IconButton label="Следующий клип" disabled={!catalog} onClick={() => {
|
||||
if (!catalog) return;
|
||||
const next = (clipIndex + 1) % catalog.clips.length;
|
||||
setClipIndex(next);
|
||||
setSequence(catalog.clips[next]!.startSequence);
|
||||
}}><Icon name="chevron-right" size={16} /></IconButton></>}
|
||||
overlay={clip ? <div className="m48-atlas-visual__case"><StatusBadge tone="neutral">SOURCE ONLY</StatusBadge><strong>{clip.ordinal}/{catalog?.clipCount} · {clip.clipId}</strong><small>0 classes · 0 candidate identity · 0 model predictions</small></div> : null}
|
||||
>
|
||||
<div className="m48-evidence-stage">
|
||||
{error ? <div className="m48-atlas-visual__state" role="alert"><Icon name="alert" size={18} />{error}</div>
|
||||
: clip && catalog?.cameraPlayback ? <M48BlindClipPlayer cameraPlayback={catalog.cameraPlayback} clip={clip} sequence={sequence} mode={mode} cameraVisible={cameraVisible} tracklets={[]} selectedObjectId={null} editable={false} drawing={false} spatialFrame={spatial} spatialLoading={spatialLoading} spatialError={spatialError} spatialEvidenceAvailable={spatialEnabled} onSequenceChange={setSequence} onDrawingChange={() => undefined} onSelectedObjectIdChange={() => undefined} onTrackletsChange={() => undefined} />
|
||||
: catalog ? <div className="m48-atlas-visual__state" role="alert"><Icon name="alert" size={18} />Pack-bound camera playback недоступен.</div>
|
||||
: <div className="m48-atlas-visual__state" role="status"><span className="busy-indicator" aria-hidden="true" />Загружаем source-only clip pack</div>}
|
||||
{catalog ? <M48EvidenceModeRail mode={mode} cameraVisible={cameraVisible} spatialAvailable={spatialEnabled} onModeChange={setMode} onCameraVisibleChange={setCameraVisible} /> : null}
|
||||
</div>
|
||||
</LaboratoryEvidenceViewer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type {
|
||||
M48AdvancedResult,
|
||||
M48GateStatus,
|
||||
M48QualityResult,
|
||||
} from "../../core/laboratory/m48ObjectCentricQuality";
|
||||
import { M48FailureAtlasVisual, M48ReviewPackVisual } from "./M48FailureAtlasVisual";
|
||||
|
||||
function percent(value: number): string {
|
||||
return `${(value * 100).toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
|
||||
}
|
||||
|
||||
function reviewStatus(result: M48GateStatus): { text: string; tone: "neutral" | "warning" | "success" } {
|
||||
if (result.evaluated) return { text: "Evaluation завершена · откройте финальный M4.8 result", tone: "success" };
|
||||
if (result.correctionState === "frozen") return { text: "24/24 клипов проверено · assisted evidence Worker 006 зафиксировано", tone: "success" };
|
||||
if (result.correctionState !== "not-started") return { text: `${result.correctionReviewedClipCount}/${result.clipCount} клипов проверено · исправляем авторазметку Worker 006`, tone: "warning" };
|
||||
if (result.adjudicationFrozen) return { text: "Truth seal зафиксирован · готово к evaluation", tone: "success" };
|
||||
if (result.adjudicationUnlocked) return { text: "2/2 независимых review · открыта adjudication", tone: "warning" };
|
||||
if (result.frozenReviewerCount > 0) return { text: `${result.frozenReviewerCount}/2 независимых review зафиксировано · quality verdict ещё отсутствует`, tone: "warning" };
|
||||
return { text: "Проверочный набор готов · корректность object graph ещё не измерена", tone: "warning" };
|
||||
}
|
||||
|
||||
function ReviewResult({ rigLabel, result }: { rigLabel: string; result: M48GateStatus }) {
|
||||
const status = reviewStatus(result);
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="M4.8 · проверяем, видит ли система реальные объекты"
|
||||
description="Worker 006 уже поставил рамки на связанных кадрах RIGHT-камеры. M4.8 показывает его ответ поверх синхронных CAMERA + LiDAR: оператор подтверждает правильные рамки и исправляет только false positive, miss и неточную геометрию."
|
||||
status={status.text}
|
||||
statusTone={status.tone}
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} RIGHT · camera + prediction-free current spatial evidence` },
|
||||
{ label: "Покрытие", value: `${result.clipCount} клипов · ${result.frameCount} кадров` },
|
||||
{ label: "Авторазметка", value: `Worker 006 · ${result.seedObjectCount.toLocaleString("ru-RU")} frozen-рамок` },
|
||||
{ label: "Correction", value: `${result.correctionReviewedClipCount}/${result.clipCount} клипов · ${result.correctionState}` },
|
||||
{ label: "Authority", value: "REPLAY-SIMULATED · commands OFF · actuation OFF" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Не пропускает ли система реальный объект, не придумывает ли лишний, не теряет ли его между кадрами и правильно ли понимает геометрию, свежесть, движение и опасность в коридоре движения?",
|
||||
approach: "RIGHT-камера, рамки Worker 006 и синхронный LiDAR идут на одном таймлайне. На каждом из 24 клипов оператор удаляет лишнее, добавляет пропущенное, двигает или ресайзит неточную рамку и подтверждает клип.",
|
||||
principalResult: result.correctionState === "frozen"
|
||||
? "Проверка зафиксирована: исходный seed и все ручные изменения связаны одной immutable дельтой."
|
||||
: `Готовы ${result.seedObjectCount.toLocaleString("ru-RU")} автоматических рамок на ${result.frameCount} кадрах; ручная работа начинается с результата системы, а не с пустого кадра.`,
|
||||
limitation: "Correction выполнен с видимым ответом Worker 006 и потому не является independent ground truth. Отдельный двухрецензентный truth seal всё ещё нужен для формального quality gate; physical live, навигация и команды моторам запрещены.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: "m48-class-free-object-quality/v1",
|
||||
components: [
|
||||
{ kind: "source", name: result.packId, version: "immutable connected clips", role: "camera/current-spatial evidence + frozen Worker 006 seed", identitySha256: result.packId.split("-").at(-1) ?? null },
|
||||
{ kind: "algorithm", name: "frozen-candidate-seeded correction", version: "object-tracklet/v1", role: "editable assisted evidence without semantic classes", identitySha256: null },
|
||||
{ kind: "algorithm", name: "independent dual review + adjudication", version: "release-gate/v1", role: "separate formal truth-seal workflow", identitySha256: result.truthSealId?.split("-").at(-1) ?? null },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence eyebrow="M4.8 VISUAL EVIDENCE · CAMERA + 3D" title="Один объект в RIGHT-камере и LiDAR на общем времени" kind="recorded-replay" resizable>
|
||||
<M48ReviewPackVisual packId={result.packId} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Что дал прогон: Worker 006 поставил рамки; оператор проверяет только ошибки"
|
||||
status={status.text}
|
||||
statusTone={status.tone}
|
||||
metrics={[
|
||||
{ label: "Worker boxes", value: result.seedObjectCount.toLocaleString("ru-RU"), hint: `${result.frameCount} кадров · immutable seed` },
|
||||
{ label: "Clips checked", value: `${result.correctionReviewedClipCount}/${result.clipCount}`, hint: "operator correction progress" },
|
||||
{ label: "Assisted evidence", value: result.correctionState === "frozen" ? "FROZEN" : "OPEN", hint: "candidate-visible · not independent truth" },
|
||||
{ label: "Authority", value: "OFF", hint: "physical live · commands · actuation" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: `Worker 006 воспроизводимо выдал ${result.seedObjectCount.toLocaleString("ru-RU")} рамок; CAMERA, 3D/PLAN и рамки связаны одним временем. После freeze будет сохранена точная дельта подтверждений, исправлений, false positive и miss.`,
|
||||
notProved: "Пока оператор не проверил 24/24 клипа, корректность этих рамок не подтверждена. Даже завершённый assisted correction не заменяет независимую ground truth и не даёт motor/planner authority.",
|
||||
decision: result.correctionState === "frozen" ? "Assisted regression evidence закрыто; для формального release gate отдельно выполнить два blind review и adjudication." : "Открыть проверку Worker 006, пройти все 24 клипа, исправить ошибки и зафиксировать evidence.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function QualityResult({ rigLabel, result }: { rigLabel: string; result: M48QualityResult }) {
|
||||
const gateCount = Object.keys(result.gates).length;
|
||||
const passedGates = Object.values(result.gates).filter(Boolean).length;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="M4.8 · object-centric source quality gate"
|
||||
description="Frozen M4.7 graph сопоставлен с adjudicated class-free truth. Acceptance считается только на sealed validation split; development остаётся диагностическим и не может улучшить gate."
|
||||
status={result.accepted ? "Object-centric source quality принята" : "Quality gate не пройден · открыт bounded failure atlas"}
|
||||
statusTone={result.accepted ? "success" : "warning"}
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} RIGHT · frozen graph vs adjudicated truth` },
|
||||
{ label: "Pack", value: result.packId },
|
||||
{ label: "Truth seal", value: result.truthSealId },
|
||||
{ label: "Acceptance", value: "VALIDATION ONLY · development informational" },
|
||||
{ label: "Authority", value: "SOURCE-SCOPED · commands OFF · actuation OFF" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Проходит ли canonical graph минимальный object-centric quality gate на sealed validation split RAVNOVES00 source?",
|
||||
approach: "Per-frame class-free matching выполняется только после двух независимых reviews и adjudication. Gate берёт metrics только из validation; каждый отказ связан с конкретными клипами и кадрами в failure atlas.",
|
||||
principalResult: `${passedGates}/${gateCount} validation gates passed · presence P/R ${percent(result.metrics.obstaclePresencePrecision)} / ${percent(result.metrics.obstaclePresenceRecall)} · ${result.metrics.failureCaseCount} failure cases.`,
|
||||
limitation: "Результат ограничен recorded source и не доказывает realtime live, физическую collision safety, planning или motor control.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: "m48-object-centric-quality/v1",
|
||||
components: [
|
||||
{ kind: "source", name: result.packId, version: "frozen-before-label-reveal", role: "candidate graph and connected source clips", identitySha256: result.packId.split("-").at(-1) ?? null },
|
||||
{ kind: "source", name: result.truthSealId, version: "two reviewers + adjudication", role: "class-free object truth", identitySha256: result.truthSealId.split("-").at(-1) ?? null },
|
||||
{ kind: "algorithm", name: "object-centric quality scorer", version: "v1", role: "per-frame matching, gates and failure atlas", identitySha256: result.resultId.split("-").at(-1) ?? null },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence eyebrow="M4.8 VISUAL EVIDENCE · FAILURE ATLAS" title="Точные bounded cases: SOURCE / TRUTH / GRAPH / OVERLAY" kind="diagnostic-model" resizable>
|
||||
<M48FailureAtlasVisual resultId={result.resultId} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title={result.accepted ? "Object-centric gate принят; можно готовить recorded realtime release candidate" : "Gate отклонён; исправления привязаны к bounded failure clusters"}
|
||||
status={`${passedGates}/${gateCount} validation gates · ${result.metrics.failureCaseCount} failure cases · ${result.metrics.unknownPredictionCount} unknown predictions`}
|
||||
statusTone={result.accepted ? "success" : "warning"}
|
||||
metrics={[
|
||||
{ label: "Presence P / R", value: `${percent(result.metrics.obstaclePresencePrecision)} / ${percent(result.metrics.obstaclePresenceRecall)}`, hint: "class-free object presence" },
|
||||
{ label: "Critical recall", value: percent(result.metrics.criticalCorridorObstacleRecall), hint: "objects intersecting the virtual corridor" },
|
||||
{ label: "Geometry / freshness", value: `${percent(result.metrics.geometryAssociationCorrectness)} / ${percent(result.metrics.freshnessCorrectness)}`, hint: "adjudicated state correctness" },
|
||||
{ label: "Motion / false not-threat", value: `${percent(result.metrics.motionDecisionCorrectness)} / ${result.metrics.criticalNotThreatCount}`, hint: "critical corridor violations · conservative unknown retained" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: `На sealed validation split граф прошёл ${passedGates}/${gateCount} object-centric gates; development metrics не участвовали в acceptance, каждый отказ и unknown имеет frame-level cause.`,
|
||||
notProved: "Не доказаны physical live, измеренная collision safety, navigation planner, motor commands или переносимость на другие маршруты.",
|
||||
decision: result.accepted ? "Открыть M4.9 recorded realtime release-candidate gate без расширения authority." : "Исправлять только кластеры из failure atlas, повторно заморозить candidate до label reveal и пересчитать M4.8.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function M48ObjectCentricQualityResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: M48AdvancedResult;
|
||||
}) {
|
||||
return result.kind === "review"
|
||||
? <ReviewResult rigLabel={rigLabel} result={result} />
|
||||
: <QualityResult rigLabel={rigLabel} result={result} />;
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { M48SmallStaticRegressionResult } from "../../core/laboratory/m48SmallStaticRegression";
|
||||
import { M48SmallStaticRegressionVisual } from "./M48SmallStaticRegressionVisual";
|
||||
|
||||
function percent(value: number): string {
|
||||
return `${(value * 100).toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
|
||||
}
|
||||
|
||||
export function M48SmallStaticPassageRegressionResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: M48SmallStaticRegressionResult;
|
||||
}) {
|
||||
const status = result.accepted
|
||||
? "Development regression target пройден"
|
||||
: `Worker 006 пропустил ${result.metrics.workerMissedAnchorCount}/${result.metrics.assistedAnchorCount} assisted-якорей`;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="M4.8R1 · мелкие статические ограничения проезда"
|
||||
description="Отдельный append-only прогон внутри M4.8 проверяет, покрывает ли замороженный Worker 006 вручную добавленные столбики, полусферы, урны и другие малые статические ограничения. Текущий M4.8 correction не изменяется."
|
||||
status={status}
|
||||
statusTone={result.accepted ? "success" : "warning"}
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} RIGHT · camera + prediction-free current spatial evidence` },
|
||||
{ label: "Пайплайн", value: result.pipelineId },
|
||||
{ label: "Эксперимент", value: result.experimentId },
|
||||
{ label: "Прогон", value: `${result.runLabel} · immutable ${result.resultId}` },
|
||||
{ label: "Authority", value: "REPLAY-SIMULATED · commands OFF · actuation OFF" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Находит ли текущий Worker 006 малые статические ограничения прохода, которые оператору пришлось добавить вручную при assisted correction?",
|
||||
approach: `Зафиксирован отдельный снимок ${result.metrics.assistedAnchorCount} operator-added якорей на ${result.metrics.anchorClipCount} клипах. На точном исходном кадре каждый якорь сопоставлен с frozen-ответом Worker 006 по IoU ≥ ${result.metrics.extentIouThreshold.toFixed(2)}.`,
|
||||
principalResult: `${result.metrics.workerRecalledAnchorCount}/${result.metrics.assistedAnchorCount} якорей покрыты Worker 006; ${result.metrics.workerMissedAnchorCount} не имеют совпадающей frozen-рамки.`,
|
||||
limitation: "Это candidate-visible assisted seed, намеренно собранный из ручных добавлений, поэтому он полезен как regression baseline, но не является independent ground truth. Camera bbox не является 3D-коллайдером и не выдаёт planner/safety authority.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "deterministic",
|
||||
pipelineId: result.pipelineId,
|
||||
components: [
|
||||
{ kind: "source", name: result.packId, version: "immutable Worker 006 pack", role: "frozen candidate output + exact camera/current-spatial evidence", identitySha256: result.packId.split("-").at(-1) ?? null },
|
||||
{ kind: "source", name: "M4.8 assisted correction snapshot", version: "revision-bound", role: "operator-added anchors · not independent truth", identitySha256: null },
|
||||
{ kind: "algorithm", name: "small-static assisted-anchor comparator", version: "v1", role: "exact-frame class-free IoU regression", identitySha256: result.resultId.split("-").at(-1) ?? null },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence eyebrow="M4.8R1 VISUAL EVIDENCE · ASSISTED ANCHOR + WORKER" title="Точный кадр: ручной якорь и frozen-ответ Worker 006" kind="recorded-replay" resizable>
|
||||
<M48SmallStaticRegressionVisual resultId={result.resultId} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Что дал прогон: зафиксирован измеримый baseline пропусков малых ограничений"
|
||||
status={status}
|
||||
statusTone={result.accepted ? "success" : "warning"}
|
||||
metrics={[
|
||||
{ label: "Assisted recall", value: percent(result.metrics.assistedAnchorRecall), hint: `target ${percent(result.metrics.minimumAssistedAnchorRecall)} · diagnostic only` },
|
||||
{ label: "Recalled / missed", value: `${result.metrics.workerRecalledAnchorCount} / ${result.metrics.workerMissedAnchorCount}`, hint: `IoU ≥ ${result.metrics.extentIouThreshold.toFixed(2)}` },
|
||||
{ label: "Объезд или запас", value: result.metrics.requiresAvoidanceOrClearanceCount.toLocaleString("ru-RU"), hint: "operator-marked small static constraints" },
|
||||
{ label: "Independent truth", value: "НЕТ", hint: "assisted candidate-visible evidence" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: `Worker 006 детерминированно покрывает ${result.metrics.workerRecalledAnchorCount}/${result.metrics.assistedAnchorCount} вручную добавленных якорей на точных кадрах; результат сохранён отдельно и не перезаписывает correction-сессию.`,
|
||||
notProved: "Не измерены unbiased precision/recall, 3D clearance, проезжаемость конкретного шасси, realtime live или collision safety.",
|
||||
decision: result.accepted
|
||||
? "Сохранить прогон как development baseline и отдельно открыть independent truth evaluation."
|
||||
: "Использовать пропуски как bounded regression set для следующей версии Worker 006; после обновления выполнить новый append-only run на том же снимке и только затем — independent truth gate.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon, IconButton, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
fetchM48ReviewSourceCatalog,
|
||||
type M48ReviewTracklet,
|
||||
} from "../../core/laboratory/m48ObjectCentricQuality";
|
||||
import {
|
||||
fetchM48SmallStaticRegressionCase,
|
||||
fetchM48SmallStaticRegressionCases,
|
||||
type M48SmallStaticRegressionCase,
|
||||
type M48SmallStaticRegressionCaseSummary,
|
||||
} from "../../core/laboratory/m48SmallStaticRegression";
|
||||
import {
|
||||
M48BlindClipPlayer,
|
||||
type M48BlindEvidenceMode,
|
||||
} from "./annotation/M48BlindClipPlayer";
|
||||
import { M48EvidenceModeRail } from "./annotation/M48EvidenceModeControls";
|
||||
import { useM48SpatialClipPlayback } from "./annotation/useM48SpatialClipPlayback";
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "M4.8R1 evidence недоступно.";
|
||||
}
|
||||
|
||||
function exactFrameTracklets(item: M48SmallStaticRegressionCase): readonly M48ReviewTracklet[] {
|
||||
const sequence = item.anchor.sequence;
|
||||
const state = (
|
||||
objectId: string,
|
||||
extentXyxy: readonly [number, number, number, number],
|
||||
geometryAssociation: M48ReviewTracklet["stateSegments"][number]["geometryAssociation"],
|
||||
freshness: M48ReviewTracklet["stateSegments"][number]["freshness"],
|
||||
motion: M48ReviewTracklet["stateSegments"][number]["motion"],
|
||||
threat: M48ReviewTracklet["stateSegments"][number]["threat"],
|
||||
criticalCorridorObstacle: boolean,
|
||||
): M48ReviewTracklet => ({
|
||||
objectId,
|
||||
firstSequence: sequence,
|
||||
lastSequence: sequence,
|
||||
keyframes: [{ sequence, extentXyxy, visibility: "visible" }],
|
||||
stateSegments: [{
|
||||
startSequence: sequence,
|
||||
endSequence: sequence,
|
||||
geometryAssociation,
|
||||
freshness,
|
||||
motion,
|
||||
threat,
|
||||
criticalCorridorObstacle,
|
||||
}],
|
||||
notes: null,
|
||||
});
|
||||
|
||||
return [
|
||||
state(
|
||||
`ASSISTED · ${item.anchor.objectId}`,
|
||||
item.anchor.extentXyxy,
|
||||
item.anchor.geometryAssociation,
|
||||
item.anchor.freshness,
|
||||
item.anchor.motion,
|
||||
item.anchor.threat,
|
||||
item.anchor.requiresAvoidanceOrClearance,
|
||||
),
|
||||
...item.comparison.workerObjects.map((object) => state(
|
||||
`WORKER · ${object.predictionId}`,
|
||||
object.extentXyxy,
|
||||
object.geometryAssociation,
|
||||
object.freshness,
|
||||
object.motion,
|
||||
object.threat,
|
||||
false,
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
export function M48SmallStaticRegressionVisual({ resultId }: { resultId: string }) {
|
||||
const [cases, setCases] = useState<readonly M48SmallStaticRegressionCaseSummary[]>([]);
|
||||
const [caseIndex, setCaseIndex] = useState(0);
|
||||
const [item, setItem] = useState<M48SmallStaticRegressionCase | null>(null);
|
||||
const [catalog, setCatalog] = useState<Awaited<ReturnType<typeof fetchM48ReviewSourceCatalog>> | null>(null);
|
||||
const [sequence, setSequence] = useState(1);
|
||||
const [mode, setMode] = useState<M48BlindEvidenceMode>("camera");
|
||||
const [cameraVisible, setCameraVisible] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchM48SmallStaticRegressionCases(resultId, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (!controller.signal.aborted) setCases(next);
|
||||
})
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)))
|
||||
.finally(() => !controller.signal.aborted && setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
const selected = cases[caseIndex];
|
||||
if (!selected) {
|
||||
setItem(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchM48SmallStaticRegressionCase(resultId, selected.anchorId, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setItem(next);
|
||||
setSequence(next.anchor.sequence);
|
||||
})
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)))
|
||||
.finally(() => !controller.signal.aborted && setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [caseIndex, cases, resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!item) return;
|
||||
const controller = new AbortController();
|
||||
setCatalog(null);
|
||||
void fetchM48ReviewSourceCatalog(item.packId, { signal: controller.signal })
|
||||
.then((next) => !controller.signal.aborted && setCatalog(next))
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)));
|
||||
return () => controller.abort();
|
||||
}, [item?.packId]);
|
||||
|
||||
const clip = item && catalog
|
||||
? catalog.clips.find((candidate) => candidate.clipId === item.anchor.clipId) ?? null
|
||||
: null;
|
||||
const spatialEnabled = Boolean(
|
||||
catalog?.evidenceCapabilities.currentPointCloudBodyXyzM
|
||||
&& catalog.evidenceCapabilities.rig
|
||||
&& catalog.evidenceCapabilities.virtualCorridor,
|
||||
);
|
||||
const spatial = useM48SpatialClipPlayback({
|
||||
packId: item?.packId ?? "",
|
||||
clip,
|
||||
sequence,
|
||||
enabled: mode !== "camera" && spatialEnabled,
|
||||
});
|
||||
const tracklets = useMemo(
|
||||
() => item ? exactFrameTracklets(item) : [],
|
||||
[item],
|
||||
);
|
||||
|
||||
return (
|
||||
<LaboratoryEvidenceViewer
|
||||
label="M4.8R1 assisted-anchor regression"
|
||||
className="m48-atlas-visual"
|
||||
mode={mode}
|
||||
modes={[]}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
modeControlsVisible={false}
|
||||
chromeLayout="stacked"
|
||||
actions={(
|
||||
<>
|
||||
<IconButton
|
||||
label="Предыдущий assisted-якорь"
|
||||
disabled={!cases.length}
|
||||
onClick={() => setCaseIndex((current) => (current - 1 + cases.length) % cases.length)}
|
||||
>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="Следующий assisted-якорь"
|
||||
disabled={!cases.length}
|
||||
onClick={() => setCaseIndex((current) => (current + 1) % cases.length)}
|
||||
>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
overlay={item ? (
|
||||
<div className="m48-atlas-visual__case">
|
||||
<StatusBadge tone={item.comparison.matchedAtThreshold ? "success" : "warning"}>
|
||||
{item.comparison.matchedAtThreshold ? "WORKER RECALL" : "WORKER MISS"}
|
||||
</StatusBadge>
|
||||
<strong>{caseIndex + 1}/{cases.length} · {item.anchor.clipId} · кадр {item.anchor.sequence}</strong>
|
||||
<small>ASSISTED-якорь, не independent truth · best IoU {item.comparison.bestIou.toFixed(3)}</small>
|
||||
</div>
|
||||
) : null}
|
||||
>
|
||||
<div className="m48-evidence-stage">
|
||||
{loading ? (
|
||||
<div className="m48-atlas-visual__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
Загружаем M4.8R1 bounded case
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="m48-atlas-visual__state" role="alert"><Icon name="alert" size={18} />{error}</div>
|
||||
) : clip && catalog?.cameraPlayback && item ? (
|
||||
<M48BlindClipPlayer
|
||||
cameraPlayback={catalog.cameraPlayback}
|
||||
clip={clip}
|
||||
sequence={sequence}
|
||||
mode={mode}
|
||||
cameraVisible={cameraVisible}
|
||||
tracklets={tracklets}
|
||||
selectedObjectId={`ASSISTED · ${item.anchor.objectId}`}
|
||||
editable={false}
|
||||
drawing={false}
|
||||
spatialFrame={spatial.frame}
|
||||
spatialLoading={spatial.loading}
|
||||
spatialError={spatial.error}
|
||||
spatialEvidenceAvailable={spatialEnabled}
|
||||
onSequenceChange={setSequence}
|
||||
onDrawingChange={() => undefined}
|
||||
onSelectedObjectIdChange={() => undefined}
|
||||
onTrackletsChange={() => undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className="m48-atlas-visual__state" role="status">
|
||||
<Icon name="alert" size={18} />Точный источник M4.8R1 недоступен.
|
||||
</div>
|
||||
)}
|
||||
{catalog ? (
|
||||
<M48EvidenceModeRail
|
||||
mode={mode}
|
||||
cameraVisible={cameraVisible}
|
||||
spatialAvailable={spatialEnabled}
|
||||
onModeChange={setMode}
|
||||
onCameraVisibleChange={setCameraVisible}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</LaboratoryEvidenceViewer>
|
||||
);
|
||||
}
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Checker,
|
||||
Icon,
|
||||
IconButton,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
ToastStack,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
type ToastItem,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
createM48AdjudicationSession,
|
||||
evaluateM48Adjudication,
|
||||
fetchM48ReviewSourceCatalog,
|
||||
freezeM48AdjudicationSession,
|
||||
saveM48AdjudicationSession,
|
||||
type M48AdjudicationSession,
|
||||
type M48GateStatus,
|
||||
type M48ReviewClipDraft,
|
||||
type M48ReviewSourceCatalog,
|
||||
type M48ReviewTracklet,
|
||||
} from "../../../core/laboratory/m48ObjectCentricQuality";
|
||||
import { LaboratoryReviewWorkspaceFrame } from "../../../components/laboratory/LaboratoryReviewWorkspaceFrame";
|
||||
import { M48BlindClipPlayer, type M48BlindEvidenceMode } from "./M48BlindClipPlayer";
|
||||
import { useM48SpatialClipPlayback } from "./useM48SpatialClipPlayback";
|
||||
import { M48EvidenceModeRail } from "./M48EvidenceModeControls";
|
||||
|
||||
const REVIEW_LAYERS = [
|
||||
{ value: "reviewer-a", label: "REVIEWER A" },
|
||||
{ value: "reviewer-b", label: "REVIEWER B" },
|
||||
{ value: "decision", label: "РЕШЕНИЕ" },
|
||||
] as const;
|
||||
type ReviewLayer = typeof REVIEW_LAYERS[number]["value"];
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim() ? error.message : "M4.8 adjudication не выполнена.";
|
||||
}
|
||||
|
||||
function operationKey(packId: string): string {
|
||||
const storageKey = `missioncore:m48:${packId}:adjudication-operation-key`;
|
||||
const current = localStorage.getItem(storageKey);
|
||||
if (current) return current;
|
||||
const created = `adjudication-${crypto.randomUUID()}`;
|
||||
localStorage.setItem(storageKey, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function decisionCopy(clip: M48ReviewClipDraft): M48ReviewClipDraft {
|
||||
return { ...clip, reviewState: "adjudicated", tracklets: clip.tracklets.map((tracklet) => ({ ...tracklet, keyframes: tracklet.keyframes.map((keyframe) => ({ ...keyframe })), stateSegments: tracklet.stateSegments.map((segment) => ({ ...segment })) })) };
|
||||
}
|
||||
|
||||
export function M48AdjudicationWorkspace({
|
||||
gate,
|
||||
returnFocusTarget,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
gate: M48GateStatus;
|
||||
returnFocusTarget?: HTMLElement | null;
|
||||
onClose: () => void;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<M48ReviewSourceCatalog | null>(null);
|
||||
const [session, setSession] = useState<M48AdjudicationSession | null>(null);
|
||||
const [drafts, setDrafts] = useState<ReadonlyMap<string, M48ReviewClipDraft>>(new Map());
|
||||
const [clipId, setClipId] = useState("");
|
||||
const [sequence, setSequence] = useState(1);
|
||||
const [reviewLayer, setReviewLayer] = useState<ReviewLayer>("reviewer-a");
|
||||
const [evidenceMode, setEvidenceMode] = useState<M48BlindEvidenceMode>("camera");
|
||||
const [cameraVisible, setCameraVisible] = useState(true);
|
||||
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null);
|
||||
const [drawing, setDrawing] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [freezeOpen, setFreezeOpen] = useState(false);
|
||||
const [adjudicatorId, setAdjudicatorId] = useState("");
|
||||
const [resolvedAttested, setResolvedAttested] = useState(false);
|
||||
const [blindAttested, setBlindAttested] = useState(false);
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
|
||||
const notify = useCallback((toast: Omit<ToastItem, "id">) => setToasts((current) => [...current, { ...toast, id: crypto.randomUUID() }]), []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
void fetchM48ReviewSourceCatalog(gate.packId, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setCatalog(next);
|
||||
const first = next.clips[0];
|
||||
if (first) {
|
||||
setClipId(first.clipId);
|
||||
setSequence(first.startSequence);
|
||||
}
|
||||
})
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)))
|
||||
.finally(() => !controller.signal.aborted && setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [gate.packId]);
|
||||
|
||||
const clip = useMemo(() => catalog?.clips.find((item) => item.clipId === clipId) ?? null, [catalog, clipId]);
|
||||
const reviewerA = session?.reviewInputs.find(({ reviewerSlot }) => reviewerSlot === 1)?.clips.find((item) => item.clipId === clipId) ?? null;
|
||||
const reviewerB = session?.reviewInputs.find(({ reviewerSlot }) => reviewerSlot === 2)?.clips.find((item) => item.clipId === clipId) ?? null;
|
||||
const decision = drafts.get(clipId) ?? null;
|
||||
const visibleDraft = reviewLayer === "reviewer-a" ? reviewerA : reviewLayer === "reviewer-b" ? reviewerB : decision;
|
||||
const selectedDecisionTracklet = decision?.tracklets.find(({ objectId }) => objectId === selectedObjectId) ?? null;
|
||||
const editable = Boolean(session && reviewLayer === "decision" && !["adjudication-frozen", "evaluated"].includes(session.state));
|
||||
const spatialEnabled = Boolean(catalog?.evidenceCapabilities.currentPointCloudBodyXyzM && catalog.evidenceCapabilities.rig && catalog.evidenceCapabilities.virtualCorridor);
|
||||
const {
|
||||
frame: spatial,
|
||||
loading: spatialLoading,
|
||||
error: spatialError,
|
||||
} = useM48SpatialClipPlayback({
|
||||
packId: gate.packId,
|
||||
clip,
|
||||
sequence,
|
||||
enabled: evidenceMode !== "camera" && spatialEnabled,
|
||||
});
|
||||
|
||||
const createSession = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await createM48AdjudicationSession(gate.packId, operationKey(gate.packId));
|
||||
setSession(next);
|
||||
setDrafts(new Map(next.clips.map((item) => [item.clipId, item])));
|
||||
notify({ tone: "success", title: "Adjudication открыта", description: "Reviewer A/B видны без model predictions." });
|
||||
} catch (caught) {
|
||||
setError(message(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setDecision = (next: M48ReviewClipDraft) => {
|
||||
setDrafts((current) => new Map(current).set(next.clipId, next));
|
||||
setDirty(true);
|
||||
setReviewLayer("decision");
|
||||
};
|
||||
|
||||
const updateDecisionTrackState = (patch: Partial<M48ReviewTracklet["stateSegments"][number]>) => {
|
||||
if (!decision || !selectedDecisionTracklet) return;
|
||||
setDecision({
|
||||
...decision,
|
||||
reviewState: "pending",
|
||||
noObject: null,
|
||||
tracklets: decision.tracklets.map((tracklet) => tracklet.objectId === selectedDecisionTracklet.objectId
|
||||
? { ...tracklet, stateSegments: tracklet.stateSegments.map((segment) => ({ ...segment, ...patch })) }
|
||||
: tracklet),
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!session) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const clips = session.clips.map((item) => drafts.get(item.clipId) ?? item);
|
||||
const saved = await saveM48AdjudicationSession(session, session.title, clips, `save-${session.revision + 1}-${crypto.randomUUID()}`);
|
||||
setSession(saved);
|
||||
setDrafts(new Map(saved.clips.map((item) => [item.clipId, item])));
|
||||
setDirty(false);
|
||||
notify({ tone: "success", title: "Решения сохранены", description: `${saved.resolvedClipCount}/${saved.clipCount} клипов согласовано.` });
|
||||
onChanged?.();
|
||||
} catch (caught) {
|
||||
setError(message(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const freeze = async () => {
|
||||
if (!session || dirty || !session.complete || !adjudicatorId.trim() || !resolvedAttested || !blindAttested) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const frozen = await freezeM48AdjudicationSession(session, adjudicatorId.trim());
|
||||
setSession(frozen);
|
||||
setFreezeOpen(false);
|
||||
notify({ tone: "success", title: "Truth seal создан", description: "Frozen adjudication готова к одноразовой оценке." });
|
||||
onChanged?.();
|
||||
} catch (caught) {
|
||||
setError(message(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const evaluate = async () => {
|
||||
if (!session || session.state !== "adjudication-frozen") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const evaluated = await evaluateM48Adjudication(session, `evaluate-${crypto.randomUUID()}`);
|
||||
setSession(evaluated);
|
||||
notify({ tone: "success", title: "M4.8 рассчитана", description: evaluated.qualityResultId ?? "Результат зарегистрирован." });
|
||||
onChanged?.();
|
||||
} catch (caught) {
|
||||
setError(message(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const requestClose = () => {
|
||||
if (!dirty || window.confirm("Закрыть без сохранения?")) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<LaboratoryReviewWorkspaceFrame
|
||||
ariaLabel="M4.8 adjudication"
|
||||
onClose={requestClose}
|
||||
interactionEnabled={!freezeOpen}
|
||||
returnFocusTarget={returnFocusTarget}
|
||||
toolbar={(
|
||||
<>
|
||||
<div className="m48-review-workspace__toolbar-group">
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="plus" size={16} />} disabled={busy || Boolean(session)} onClick={() => void createSession()}>Открыть adjudication</Button>
|
||||
<Button size="compact" variant={drawing ? "accent" : "secondary"} icon={<Icon name="edit" size={16} />} disabled={!editable} onClick={() => setDrawing((value) => !value)}>Объект</Button>
|
||||
<Button size="compact" variant="primary" icon={<Icon name="save" size={16} />} disabled={!editable || !dirty || busy} onClick={() => void save()}>Сохранить</Button>
|
||||
<Button size="compact" variant="accent" icon={<Icon name="check" size={16} />} disabled={!session?.complete || dirty || busy || session.state !== "saved"} onClick={() => setFreezeOpen(true)}>Truth seal</Button>
|
||||
<Button size="compact" variant="accent" disabled={session?.state !== "adjudication-frozen" || busy} onClick={() => void evaluate()}>Рассчитать gate</Button>
|
||||
</div>
|
||||
<div className="m48-review-workspace__toolbar-group">
|
||||
<Select label="Клип" value={clipId} options={(catalog?.clips ?? []).map((item) => ({ value: item.clipId, label: `${item.ordinal}/${catalog?.clipCount ?? 0} · ${item.clipId} · ${drafts.get(item.clipId)?.reviewState ?? "pending"}` }))} disabled={!catalog} searchable menuWidth={380} onChange={(value) => {
|
||||
const next = catalog?.clips.find((item) => item.clipId === value);
|
||||
if (!next) return;
|
||||
setClipId(value);
|
||||
setSequence(next.startSequence);
|
||||
setSelectedObjectId(null);
|
||||
}} />
|
||||
<StatusBadge tone={session?.state === "evaluated" ? "success" : dirty ? "warning" : session ? "neutral" : "warning"}>{session ? `${session.state} · ${session.resolvedClipCount}/${session.clipCount}` : "Adjudication не создана"}</StatusBadge>
|
||||
<IconButton label="Закрыть M4.8 adjudication" onClick={requestClose}><Icon name="close" size={16} /></IconButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
stage={(
|
||||
<div className="m48-evidence-stage">
|
||||
{loading ? <div className="m48-review-workspace__state" role="status"><span className="busy-indicator" aria-hidden="true" />Загружаем source-only клипы</div>
|
||||
: !catalog || !catalog.cameraPlayback || !clip ? <div className="m48-review-workspace__state" role="alert"><Icon name="alert" size={18} />{error ?? "Источник недоступен."}</div>
|
||||
: <M48BlindClipPlayer cameraPlayback={catalog.cameraPlayback} clip={clip} sequence={sequence} mode={evidenceMode} cameraVisible={cameraVisible} tracklets={visibleDraft?.tracklets ?? []} selectedObjectId={selectedObjectId} editable={editable} drawing={drawing} spatialFrame={spatial} spatialLoading={spatialLoading} spatialError={spatialError} spatialEvidenceAvailable={spatialEnabled} onSequenceChange={setSequence} onDrawingChange={setDrawing} onSelectedObjectIdChange={setSelectedObjectId} onTrackletsChange={(tracklets) => {
|
||||
if (!decision) return;
|
||||
setDecision({ ...decision, reviewState: "pending", noObject: null, tracklets });
|
||||
}} />}
|
||||
{catalog ? <M48EvidenceModeRail mode={evidenceMode} cameraVisible={cameraVisible} spatialAvailable={spatialEnabled} onModeChange={setEvidenceMode} onCameraVisibleChange={setCameraVisible} /> : null}
|
||||
</div>
|
||||
)}
|
||||
inspector={(
|
||||
<>
|
||||
<div className="m48-review-workspace__source-state"><span>M4.8 · reviewer disagreement</span><strong>{clip ? `${clip.clipId} · frame ${sequence}` : "Источник проверяется"}</strong><small>Два независимых class-free review; frozen graph output остаётся скрыт.</small></div>
|
||||
{(error || spatialError) && catalog ? <StatusBadge tone="danger">{error ?? spatialError}</StatusBadge> : null}
|
||||
<SegmentedControl value={reviewLayer} items={[...REVIEW_LAYERS]} label="Reviewer inputs" onChange={setReviewLayer} />
|
||||
{session && reviewerA && reviewerB && !["adjudication-frozen", "evaluated"].includes(session.state) ? (
|
||||
<div className="m48-review-workspace__review-actions">
|
||||
<Button size="compact" variant="secondary" onClick={() => setDecision(decisionCopy(reviewerA))}>Принять A</Button>
|
||||
<Button size="compact" variant="secondary" onClick={() => setDecision(decisionCopy(reviewerB))}>Принять B</Button>
|
||||
{decision ? <Checker checked={decision.reviewState === "adjudicated"} label={decision.noObject ? "Согласовано: объектов нет" : `Согласовано: ${decision.tracklets.length} tracklet`} onChange={(checked) => setDecision({ ...decision, reviewState: checked ? "adjudicated" : "pending", noObject: checked ? decision.tracklets.length === 0 : null })} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{editable && selectedDecisionTracklet ? (
|
||||
<div className="m48-review-workspace__object-tools">
|
||||
<strong>{selectedDecisionTracklet.objectId}</strong>
|
||||
<Select disabled={!catalog?.evidenceCapabilities.geometryAssociation} label="Геометрия" value={selectedDecisionTracklet.stateSegments[0]?.geometryAssociation ?? "unknown"} options={[{ value: "associated", label: "Связана" }, { value: "unavailable", label: "Недоступна" }, { value: "ineligible", label: "Неприменима" }, { value: "unknown", label: "Неизвестно" }]} onChange={(value) => updateDecisionTrackState({ geometryAssociation: value as M48ReviewTracklet["stateSegments"][number]["geometryAssociation"] })} />
|
||||
<Select disabled={!catalog?.evidenceCapabilities.freshness} label="Актуальность" value={selectedDecisionTracklet.stateSegments[0]?.freshness ?? "unavailable"} options={[{ value: "current", label: "Актуальна" }, { value: "held", label: "Удержана" }, { value: "stale", label: "Устарела" }, { value: "unavailable", label: "Недоступна" }]} onChange={(value) => updateDecisionTrackState({ freshness: value as M48ReviewTracklet["stateSegments"][number]["freshness"] })} />
|
||||
<Select disabled={!catalog?.evidenceCapabilities.motion} label="Движение" value={selectedDecisionTracklet.stateSegments[0]?.motion ?? "unknown"} options={[{ value: "moving", label: "Движется" }, { value: "static", label: "Стоит" }, { value: "unknown", label: "Неизвестно" }, { value: "unsupported", label: "Не поддержано" }]} onChange={(value) => updateDecisionTrackState({ motion: value as M48ReviewTracklet["stateSegments"][number]["motion"] })} />
|
||||
<Select disabled={!catalog?.evidenceCapabilities.threat} label="Угроза" value={selectedDecisionTracklet.stateSegments[0]?.threat ?? "unknown"} options={[{ value: "threat", label: "Угроза" }, { value: "not-threat", label: "Не угроза" }, { value: "unknown", label: "Неизвестно" }]} onChange={(value) => updateDecisionTrackState({ threat: value as M48ReviewTracklet["stateSegments"][number]["threat"] })} />
|
||||
<Checker disabled={!catalog?.evidenceCapabilities.criticalCorridorObstacle} checked={selectedDecisionTracklet.stateSegments[0]?.criticalCorridorObstacle ?? false} label="Критический объект" onChange={(criticalCorridorObstacle) => updateDecisionTrackState({ criticalCorridorObstacle })} />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
overlays={(
|
||||
<>
|
||||
<Window open={freezeOpen} title="Создать M4.8 truth seal" subtitle="После freeze Reviewer A/B и adjudication станут immutable входом quality gate." size="md" closeOnBackdrop={!busy} closeOnEscape={!busy} onClose={() => !busy && setFreezeOpen(false)} footer={<WindowFooterActions><Button disabled={busy} onClick={() => setFreezeOpen(false)}>Отмена</Button><Button variant="accent" disabled={busy || !adjudicatorId.trim() || !resolvedAttested || !blindAttested} onClick={() => void freeze()}>Freeze adjudication</Button></WindowFooterActions>}>
|
||||
<div className="m48-review-workspace__freeze-form">
|
||||
<TextField label="Opaque adjudicator ID" value={adjudicatorId} maxLength={96} placeholder="adjudicator-1" onChange={(event) => setAdjudicatorId(event.target.value)} />
|
||||
<Checker checked={resolvedAttested} label="Все 20–30 клипов согласованы" onChange={setResolvedAttested} />
|
||||
<Checker checked={blindAttested} label="Model predictions до truth seal не просматривались" onChange={setBlindAttested} />
|
||||
</div>
|
||||
</Window>
|
||||
<ToastStack items={toasts} onDismiss={(id) => setToasts((current) => current.filter((item) => item.id !== id))} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { ActivityIndicator, Icon } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryRecordedClipPlayer } from "../../../components/laboratory/LaboratoryRecordedClipPlayer";
|
||||
import { LaboratoryMetricEvidenceScene } from "../../../components/laboratory/LaboratoryMetricEvidenceScene";
|
||||
import type {
|
||||
M48RecordedCameraPlayback,
|
||||
M48ReviewClipSource,
|
||||
M48ReviewSpatialFrame,
|
||||
M48ReviewTracklet,
|
||||
} from "../../../core/laboratory/m48ObjectCentricQuality";
|
||||
import { m48RecordedCameraSourceDescriptor } from "../../../core/laboratory/m48ObjectCentricQuality";
|
||||
import type { M48BlindEvidenceMode } from "./M48EvidenceModeControls";
|
||||
|
||||
export type { M48BlindEvidenceMode } from "./M48EvidenceModeControls";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type ResizeHandle = "nw" | "ne" | "sw" | "se";
|
||||
|
||||
interface BoxInteraction {
|
||||
kind: "move" | "resize";
|
||||
pointerId: number;
|
||||
objectId: string;
|
||||
start: readonly [number, number];
|
||||
startClient: readonly [number, number];
|
||||
current: readonly [number, number];
|
||||
originalExtent: readonly [number, number, number, number];
|
||||
handle?: ResizeHandle;
|
||||
}
|
||||
|
||||
function interpolate(left: number, right: number, progress: number): number {
|
||||
return left + (right - left) * progress;
|
||||
}
|
||||
|
||||
export function interpolateM48Extent(
|
||||
tracklet: M48ReviewTracklet,
|
||||
sequence: number,
|
||||
): readonly [number, number, number, number] | null {
|
||||
if (sequence < tracklet.firstSequence || sequence > tracklet.lastSequence) return null;
|
||||
const rightIndex = tracklet.keyframes.findIndex((keyframe) => keyframe.sequence >= sequence);
|
||||
const right = tracklet.keyframes[rightIndex < 0 ? tracklet.keyframes.length - 1 : rightIndex];
|
||||
if (!right) return null;
|
||||
const left = tracklet.keyframes[Math.max(0, (rightIndex < 0 ? tracklet.keyframes.length : rightIndex) - 1)] ?? right;
|
||||
if (left.sequence === right.sequence) return right.extentXyxy;
|
||||
const progress = (sequence - left.sequence) / (right.sequence - left.sequence);
|
||||
return right.extentXyxy.map((value, index) => interpolate(left.extentXyxy[index]!, value, progress)) as unknown as readonly [number, number, number, number];
|
||||
}
|
||||
|
||||
export function createM48Tracklet(
|
||||
objectId: string,
|
||||
clip: M48ReviewClipSource,
|
||||
extentXyxy: readonly [number, number, number, number],
|
||||
spatialEvidenceAvailable = true,
|
||||
sequence = clip.startSequence,
|
||||
): M48ReviewTracklet {
|
||||
return {
|
||||
objectId,
|
||||
firstSequence: sequence,
|
||||
lastSequence: sequence,
|
||||
keyframes: [{ sequence, extentXyxy, visibility: "visible" as const }],
|
||||
stateSegments: [{
|
||||
startSequence: sequence,
|
||||
endSequence: sequence,
|
||||
geometryAssociation: spatialEvidenceAvailable ? "unknown" : "unavailable",
|
||||
freshness: "unavailable",
|
||||
motion: spatialEvidenceAvailable ? "unknown" : "unsupported",
|
||||
threat: "unknown",
|
||||
criticalCorridorObstacle: false,
|
||||
}],
|
||||
notes: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function nextM48ObjectId(tracklets: readonly M48ReviewTracklet[]): string {
|
||||
const occupied = new Set(tracklets.map(({ objectId }) => objectId));
|
||||
let ordinal = 1;
|
||||
while (occupied.has(`object-${String(ordinal).padStart(2, "0")}`)) ordinal += 1;
|
||||
return `object-${String(ordinal).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function upsertM48Extent(
|
||||
tracklet: M48ReviewTracklet,
|
||||
sequence: number,
|
||||
extentXyxy: readonly [number, number, number, number],
|
||||
): M48ReviewTracklet {
|
||||
const visibility = tracklet.keyframes.find((keyframe) => keyframe.sequence === sequence)?.visibility
|
||||
?? tracklet.keyframes.filter((keyframe) => keyframe.sequence <= sequence).at(-1)?.visibility
|
||||
?? "visible";
|
||||
return {
|
||||
...tracklet,
|
||||
keyframes: [
|
||||
...tracklet.keyframes.filter((keyframe) => keyframe.sequence !== sequence),
|
||||
{ sequence, extentXyxy, visibility },
|
||||
].sort((left, right) => left.sequence - right.sequence),
|
||||
};
|
||||
}
|
||||
|
||||
function useHostSize(ref: RefObject<HTMLDivElement | null>): Rect {
|
||||
const [rect, setRect] = useState<Rect>({ x: 0, y: 0, width: 1, height: 1 });
|
||||
useEffect(() => {
|
||||
const host = ref.current;
|
||||
if (!host) return;
|
||||
const update = () => setRect({ x: 0, y: 0, width: Math.max(host.clientWidth, 1), height: Math.max(host.clientHeight, 1) });
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(host);
|
||||
return () => observer.disconnect();
|
||||
}, [ref]);
|
||||
return rect;
|
||||
}
|
||||
|
||||
function imagePlane(host: Rect, naturalWidth: number, naturalHeight: number): Rect {
|
||||
const scale = Math.min(host.width / Math.max(naturalWidth, 1), host.height / Math.max(naturalHeight, 1));
|
||||
const width = naturalWidth * scale;
|
||||
const height = naturalHeight * scale;
|
||||
return { x: (host.width - width) / 2, y: (host.height - height) / 2, width, height };
|
||||
}
|
||||
|
||||
function normalizedPoint(event: ReactPointerEvent<SVGSVGElement>, plane: Rect): readonly [number, number] | null {
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const x = (event.clientX - bounds.left - plane.x) / Math.max(plane.width, 1);
|
||||
const y = (event.clientY - bounds.top - plane.y) / Math.max(plane.height, 1);
|
||||
if (x < 0 || x > 1 || y < 0 || y > 1) return null;
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
function boundedNormalizedPoint(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
svg: SVGSVGElement,
|
||||
plane: Rect,
|
||||
): readonly [number, number] {
|
||||
const bounds = svg.getBoundingClientRect();
|
||||
return [
|
||||
Math.max(0, Math.min(1, (clientX - bounds.left - plane.x) / Math.max(plane.width, 1))),
|
||||
Math.max(0, Math.min(1, (clientY - bounds.top - plane.y) / Math.max(plane.height, 1))),
|
||||
];
|
||||
}
|
||||
|
||||
function movedExtent(
|
||||
original: readonly [number, number, number, number],
|
||||
start: readonly [number, number],
|
||||
current: readonly [number, number],
|
||||
): readonly [number, number, number, number] {
|
||||
const width = original[2] - original[0];
|
||||
const height = original[3] - original[1];
|
||||
const left = Math.max(0, Math.min(1 - width, original[0] + current[0] - start[0]));
|
||||
const top = Math.max(0, Math.min(1 - height, original[1] + current[1] - start[1]));
|
||||
return [left, top, left + width, top + height];
|
||||
}
|
||||
|
||||
function resizedExtent(
|
||||
original: readonly [number, number, number, number],
|
||||
handle: ResizeHandle,
|
||||
current: readonly [number, number],
|
||||
): readonly [number, number, number, number] {
|
||||
const minimum = 0.005;
|
||||
let [left, top, right, bottom] = original;
|
||||
if (handle.includes("n")) top = Math.min(current[1], bottom - minimum);
|
||||
if (handle.includes("s")) bottom = Math.max(current[1], top + minimum);
|
||||
if (handle.includes("w")) left = Math.min(current[0], right - minimum);
|
||||
if (handle.includes("e")) right = Math.max(current[0], left + minimum);
|
||||
return [left, top, right, bottom];
|
||||
}
|
||||
|
||||
function interactionExtent(interaction: BoxInteraction) {
|
||||
return interaction.kind === "move"
|
||||
? movedExtent(interaction.originalExtent, interaction.start, interaction.current)
|
||||
: resizedExtent(
|
||||
interaction.originalExtent,
|
||||
interaction.handle ?? "se",
|
||||
interaction.current,
|
||||
);
|
||||
}
|
||||
|
||||
function extentsDiffer(
|
||||
left: readonly [number, number, number, number],
|
||||
right: readonly [number, number, number, number],
|
||||
): boolean {
|
||||
return left.some((value, index) => Math.abs(value - right[index]!) > 1e-6);
|
||||
}
|
||||
|
||||
function interactionMoved(
|
||||
start: readonly [number, number],
|
||||
current: readonly [number, number],
|
||||
): boolean {
|
||||
return Math.hypot(current[0] - start[0], current[1] - start[1]) >= 3;
|
||||
}
|
||||
|
||||
export function M48BlindClipPlayer({
|
||||
cameraPlayback,
|
||||
clip,
|
||||
sequence,
|
||||
mode,
|
||||
cameraVisible,
|
||||
tracklets,
|
||||
selectedObjectId,
|
||||
editable,
|
||||
drawing,
|
||||
spatialFrame,
|
||||
spatialLoading,
|
||||
spatialError,
|
||||
spatialEvidenceAvailable = true,
|
||||
onSequenceChange,
|
||||
onDrawingChange,
|
||||
onSelectedObjectIdChange,
|
||||
onTrackletsChange,
|
||||
}: {
|
||||
cameraPlayback: M48RecordedCameraPlayback;
|
||||
clip: M48ReviewClipSource;
|
||||
sequence: number;
|
||||
mode: M48BlindEvidenceMode;
|
||||
cameraVisible: boolean;
|
||||
tracklets: readonly M48ReviewTracklet[];
|
||||
selectedObjectId: string | null;
|
||||
editable: boolean;
|
||||
drawing: boolean;
|
||||
spatialFrame: M48ReviewSpatialFrame | null;
|
||||
spatialLoading: boolean;
|
||||
spatialError?: string | null;
|
||||
spatialEvidenceAvailable?: boolean;
|
||||
onSequenceChange: (sequence: number) => void;
|
||||
onDrawingChange: (drawing: boolean) => void;
|
||||
onSelectedObjectIdChange: (objectId: string | null) => void;
|
||||
onTrackletsChange: (tracklets: readonly M48ReviewTracklet[]) => void;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const host = useHostSize(hostRef);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [playbackRate, setPlaybackRate] = useState(1);
|
||||
const [drawStart, setDrawStart] = useState<readonly [number, number] | null>(null);
|
||||
const [drawCurrent, setDrawCurrent] = useState<readonly [number, number] | null>(null);
|
||||
const [boxInteraction, setBoxInteraction] = useState<BoxInteraction | null>(null);
|
||||
const cameraSource = useMemo(
|
||||
() => m48RecordedCameraSourceDescriptor(cameraPlayback),
|
||||
[cameraPlayback],
|
||||
);
|
||||
const spatialReady = Boolean(
|
||||
spatialFrame?.sequence === sequence
|
||||
&& spatialFrame.sourceAvailable
|
||||
&& spatialFrame.bodyFrameAvailable
|
||||
&& spatialFrame.pointCloudBodyXyzM.length > 0,
|
||||
);
|
||||
const spatialVisible = mode !== "camera" && spatialEvidenceAvailable;
|
||||
const effectiveCameraVisible = cameraVisible || !spatialVisible;
|
||||
const plane = imagePlane(host, 1440, 1080);
|
||||
|
||||
useEffect(() => setPlaying(false), [clip.clipId]);
|
||||
useEffect(() => {
|
||||
setDrawStart(null);
|
||||
setDrawCurrent(null);
|
||||
setBoxInteraction(null);
|
||||
}, [clip.clipId, sequence]);
|
||||
|
||||
const boxes = useMemo(() => tracklets.flatMap((tracklet) => {
|
||||
const extent = interpolateM48Extent(tracklet, sequence);
|
||||
return extent ? [{ tracklet, extent }] : [];
|
||||
}), [sequence, tracklets]);
|
||||
|
||||
const finishDrawing = (event: ReactPointerEvent<SVGSVGElement>) => {
|
||||
if (!editable || !drawing || !drawStart) return;
|
||||
const end = normalizedPoint(event, plane) ?? drawCurrent;
|
||||
setDrawStart(null);
|
||||
setDrawCurrent(null);
|
||||
if (!end) return;
|
||||
const extent = [
|
||||
Math.min(drawStart[0], end[0]),
|
||||
Math.min(drawStart[1], end[1]),
|
||||
Math.max(drawStart[0], end[0]),
|
||||
Math.max(drawStart[1], end[1]),
|
||||
] as const;
|
||||
if (extent[2] - extent[0] < 0.01 || extent[3] - extent[1] < 0.01) return;
|
||||
const selected = selectedObjectId
|
||||
? tracklets.find((tracklet) => (
|
||||
tracklet.objectId === selectedObjectId
|
||||
&& sequence >= tracklet.firstSequence
|
||||
&& sequence <= tracklet.lastSequence
|
||||
))
|
||||
: null;
|
||||
if (selected) {
|
||||
onTrackletsChange(tracklets.map((tracklet) => (
|
||||
tracklet.objectId === selected.objectId
|
||||
? upsertM48Extent(tracklet, sequence, extent)
|
||||
: tracklet
|
||||
)));
|
||||
onDrawingChange(false);
|
||||
return;
|
||||
}
|
||||
const objectId = nextM48ObjectId(tracklets);
|
||||
onTrackletsChange([...tracklets, createM48Tracklet(objectId, clip, extent, spatialEvidenceAvailable, sequence)]);
|
||||
onSelectedObjectIdChange(objectId);
|
||||
onDrawingChange(false);
|
||||
};
|
||||
|
||||
const finishBoxInteraction = (event: ReactPointerEvent<SVGSVGElement>) => {
|
||||
if (!boxInteraction || boxInteraction.pointerId !== event.pointerId) return;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
const current = boundedNormalizedPoint(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
event.currentTarget,
|
||||
plane,
|
||||
);
|
||||
const extent = interactionExtent({ ...boxInteraction, current });
|
||||
if (
|
||||
interactionMoved(
|
||||
boxInteraction.startClient,
|
||||
[event.clientX, event.clientY],
|
||||
)
|
||||
&& extentsDiffer(boxInteraction.originalExtent, extent)
|
||||
) {
|
||||
onTrackletsChange(tracklets.map((tracklet) => (
|
||||
tracklet.objectId === boxInteraction.objectId
|
||||
? upsertM48Extent(tracklet, sequence, extent)
|
||||
: tracklet
|
||||
)));
|
||||
}
|
||||
setBoxInteraction(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<LaboratoryRecordedClipPlayer
|
||||
source={cameraSource}
|
||||
segmentCount={cameraPlayback.segmentCount}
|
||||
frames={clip.frames}
|
||||
sequence={sequence}
|
||||
playing={playing}
|
||||
playbackRate={playbackRate}
|
||||
cameraPresentation={spatialVisible
|
||||
? effectiveCameraVisible ? "companion" : "hidden"
|
||||
: "primary"}
|
||||
continuousPlayback
|
||||
sourceCount={Number(effectiveCameraVisible) + Number(spatialVisible)}
|
||||
cameraRef={hostRef}
|
||||
onSequenceChange={onSequenceChange}
|
||||
onPlayingChange={setPlaying}
|
||||
onPlaybackRateChange={setPlaybackRate}
|
||||
cameraOverlay={(<>
|
||||
<div className="m48-clip-player__pane-label" data-pane="camera">
|
||||
ПРАВАЯ КАМЕРА · СИНХРОННО · КАДР {sequence}
|
||||
</div>
|
||||
<svg
|
||||
className="m48-clip-player__overlay"
|
||||
viewBox={`0 0 ${host.width} ${host.height}`}
|
||||
aria-label="Объектные tracklet-рамки без классов"
|
||||
data-drawing={editable && drawing ? "true" : undefined}
|
||||
onPointerDown={(event) => {
|
||||
if (!drawing) {
|
||||
onSelectedObjectIdChange(null);
|
||||
return;
|
||||
}
|
||||
if (!editable) return;
|
||||
setPlaying(false);
|
||||
const point = normalizedPoint(event, plane);
|
||||
if (point) {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setDrawStart(point);
|
||||
setDrawCurrent(point);
|
||||
}
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (drawStart) setDrawCurrent(normalizedPoint(event, plane));
|
||||
if (boxInteraction?.pointerId === event.pointerId) {
|
||||
setBoxInteraction({
|
||||
...boxInteraction,
|
||||
current: boundedNormalizedPoint(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
event.currentTarget,
|
||||
plane,
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (boxInteraction) finishBoxInteraction(event);
|
||||
else finishDrawing(event);
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
setDrawStart(null);
|
||||
setDrawCurrent(null);
|
||||
setBoxInteraction(null);
|
||||
}}
|
||||
>
|
||||
{boxes.map(({ tracklet, extent }) => {
|
||||
const displayedExtent = boxInteraction?.objectId === tracklet.objectId
|
||||
? interactionExtent(boxInteraction)
|
||||
: extent;
|
||||
const [left, top, right, bottom] = displayedExtent;
|
||||
return (
|
||||
<g
|
||||
key={tracklet.objectId}
|
||||
data-selected={tracklet.objectId === selectedObjectId ? "true" : undefined}
|
||||
onPointerDown={(event) => {
|
||||
if (drawing) return;
|
||||
event.stopPropagation();
|
||||
onSelectedObjectIdChange(tracklet.objectId);
|
||||
if (!editable || event.button !== 0) return;
|
||||
setPlaying(false);
|
||||
const svg = event.currentTarget.ownerSVGElement;
|
||||
if (!svg) return;
|
||||
svg.setPointerCapture(event.pointerId);
|
||||
const point = boundedNormalizedPoint(event.clientX, event.clientY, svg, plane);
|
||||
setBoxInteraction({
|
||||
kind: "move",
|
||||
pointerId: event.pointerId,
|
||||
objectId: tracklet.objectId,
|
||||
start: point,
|
||||
startClient: [event.clientX, event.clientY],
|
||||
current: point,
|
||||
originalExtent: extent,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<rect x={plane.x + left * plane.width} y={plane.y + top * plane.height} width={(right - left) * plane.width} height={(bottom - top) * plane.height} />
|
||||
<text x={plane.x + left * plane.width} y={Math.max(14, plane.y + top * plane.height - 6)}>{tracklet.objectId}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{drawStart && drawCurrent ? (
|
||||
<rect
|
||||
className="m48-clip-player__draft-box"
|
||||
x={plane.x + Math.min(drawStart[0], drawCurrent[0]) * plane.width}
|
||||
y={plane.y + Math.min(drawStart[1], drawCurrent[1]) * plane.height}
|
||||
width={Math.abs(drawCurrent[0] - drawStart[0]) * plane.width}
|
||||
height={Math.abs(drawCurrent[1] - drawStart[1]) * plane.height}
|
||||
/>
|
||||
) : null}
|
||||
{editable && !drawing && selectedObjectId ? boxes
|
||||
.filter(({ tracklet }) => tracklet.objectId === selectedObjectId)
|
||||
.flatMap(({ tracklet, extent }) => {
|
||||
const displayedExtent = boxInteraction?.objectId === tracklet.objectId
|
||||
? interactionExtent(boxInteraction)
|
||||
: extent;
|
||||
const [left, top, right, bottom] = displayedExtent;
|
||||
return ([
|
||||
["nw", left, top],
|
||||
["ne", right, top],
|
||||
["sw", left, bottom],
|
||||
["se", right, bottom],
|
||||
] as const).map(([handle, x, y]) => (
|
||||
<circle
|
||||
className="m48-clip-player__resize-handle"
|
||||
data-handle={handle}
|
||||
key={`${tracklet.objectId}-${handle}`}
|
||||
cx={plane.x + x * plane.width}
|
||||
cy={plane.y + y * plane.height}
|
||||
r={6}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
event.stopPropagation();
|
||||
setPlaying(false);
|
||||
const svg = event.currentTarget.ownerSVGElement;
|
||||
if (!svg) return;
|
||||
svg.setPointerCapture(event.pointerId);
|
||||
const point = boundedNormalizedPoint(event.clientX, event.clientY, svg, plane);
|
||||
setBoxInteraction({
|
||||
kind: "resize",
|
||||
pointerId: event.pointerId,
|
||||
objectId: tracklet.objectId,
|
||||
start: point,
|
||||
startClient: [event.clientX, event.clientY],
|
||||
current: point,
|
||||
originalExtent: extent,
|
||||
handle,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
));
|
||||
}) : null}
|
||||
</svg>
|
||||
</>)}
|
||||
alternativeScene={(
|
||||
<div
|
||||
className="m48-clip-player__spatial-pane"
|
||||
data-spatial-sequence={spatialReady ? sequence : undefined}
|
||||
>
|
||||
<div className="m48-clip-player__pane-label" data-pane="spatial">
|
||||
{mode === "3d" ? "3D LIDAR" : "ПЛАН LIDAR"} · КАДР {sequence}
|
||||
</div>
|
||||
{spatialReady && spatialFrame ? (
|
||||
<LaboratoryMetricEvidenceScene
|
||||
pointCloudBodyXyzM={spatialFrame.pointCloudBodyXyzM}
|
||||
localSurfaceBodyXyzM={[]}
|
||||
obstacles={[]}
|
||||
rig={spatialFrame.rig}
|
||||
corridor={spatialFrame.corridor}
|
||||
occupiedVoxelSizeM={spatialFrame.occupiedVoxelSizeM}
|
||||
mode={mode === "3d" ? "3d" : "plan"}
|
||||
label={`M4.8 пространственные данные источника · кадр ${sequence}`}
|
||||
showCurrentIncrement
|
||||
showLocalSurface={false}
|
||||
showRollingMap={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="m48-clip-player__state" role={spatialLoading ? "status" : "alert"}>
|
||||
{spatialLoading ? <ActivityIndicator size="compact" /> : <Icon name="alert" size={18} />}
|
||||
{spatialLoading
|
||||
? "Подготавливаем синхронный LiDAR-кадр"
|
||||
: spatialError
|
||||
? spatialError
|
||||
: spatialFrame && !spatialFrame.sourceAvailable
|
||||
? "Текущий LiDAR-кадр недоступен"
|
||||
: spatialFrame && !spatialFrame.bodyFrameAvailable
|
||||
? "LiDAR в системе координат корпуса для этого кадра недоступен"
|
||||
: "Исходные пространственные данные для этого кадра недоступны"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+568
@@ -0,0 +1,568 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Button,
|
||||
Checker,
|
||||
FieldFrame,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
IconButton,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
ToastStack,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
type ToastItem,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
createM48CorrectionSession,
|
||||
fetchM48ReviewSourceCatalog,
|
||||
freezeM48CorrectionSession,
|
||||
saveM48CorrectionSession,
|
||||
type M48CorrectionSession,
|
||||
type M48GateStatus,
|
||||
type M48ReviewClipDraft,
|
||||
type M48ReviewSourceCatalog,
|
||||
type M48ReviewTracklet,
|
||||
} from "../../../core/laboratory/m48ObjectCentricQuality";
|
||||
import { LaboratoryReviewWorkspaceFrame } from "../../../components/laboratory/LaboratoryReviewWorkspaceFrame";
|
||||
import {
|
||||
M48BlindClipPlayer,
|
||||
interpolateM48Extent,
|
||||
upsertM48Extent,
|
||||
type M48BlindEvidenceMode,
|
||||
} from "./M48BlindClipPlayer";
|
||||
import { useM48SpatialClipPlayback } from "./useM48SpatialClipPlayback";
|
||||
import { M48EvidenceModeRail } from "./M48EvidenceModeControls";
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Операция M4.8 не выполнена.";
|
||||
}
|
||||
|
||||
function operationKey(packId: string): string {
|
||||
const key = `missioncore:m48:${packId}:correction-operation-key`;
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) return stored;
|
||||
const created = `correction-${crypto.randomUUID()}`;
|
||||
localStorage.setItem(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function draftMap(session: M48CorrectionSession): Map<string, M48ReviewClipDraft> {
|
||||
return new Map(session.clips.map((clip) => [clip.clipId, clip]));
|
||||
}
|
||||
|
||||
function stateLabel(session: M48CorrectionSession | null): string {
|
||||
if (!session) return "Проверка загружается";
|
||||
if (session.state === "frozen") return "Проверка завершена";
|
||||
return `Проверено клипов: ${session.reviewedClipCount} из ${session.clipCount}`;
|
||||
}
|
||||
|
||||
function clipOptionLabel(
|
||||
ordinal: number,
|
||||
clipCount: number,
|
||||
clipId: string,
|
||||
reviewState: M48ReviewClipDraft["reviewState"] | undefined,
|
||||
): string {
|
||||
const progress = `${String(ordinal).padStart(2, "0")}/${String(clipCount).padStart(2, "0")}`;
|
||||
return `${progress} · ${clipId} · ${reviewState === "reviewed" ? "проверен" : "не проверен"}`;
|
||||
}
|
||||
|
||||
type M48CorrectionSaveReason = "clip-status" | "object-edit";
|
||||
|
||||
interface M48CorrectionSaveRollback {
|
||||
drafts: ReadonlyMap<string, M48ReviewClipDraft>;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export function M48CorrectionWorkspace({
|
||||
gate,
|
||||
returnFocusTarget,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
gate: M48GateStatus;
|
||||
returnFocusTarget?: HTMLElement | null;
|
||||
onClose: () => void;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<M48ReviewSourceCatalog | null>(null);
|
||||
const [session, setSession] = useState<M48CorrectionSession | null>(null);
|
||||
const [drafts, setDrafts] = useState<ReadonlyMap<string, M48ReviewClipDraft>>(new Map());
|
||||
const [selectedClipId, setSelectedClipId] = useState("");
|
||||
const [sequence, setSequence] = useState(1);
|
||||
const [mode, setMode] = useState<M48BlindEvidenceMode>("camera");
|
||||
const [cameraVisible, setCameraVisible] = useState(true);
|
||||
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null);
|
||||
const [drawing, setDrawing] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [freezeOpen, setFreezeOpen] = useState(false);
|
||||
const [reviewerId, setReviewerId] = useState("");
|
||||
const [candidateVisible, setCandidateVisible] = useState(false);
|
||||
const [classFree, setClassFree] = useState(false);
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const savingRef = useRef(false);
|
||||
|
||||
const notify = useCallback((toast: Omit<ToastItem, "id">) => {
|
||||
setToasts((current) => [...current, { ...toast, id: crypto.randomUUID() }]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
void Promise.all([
|
||||
fetchM48ReviewSourceCatalog(gate.packId, { signal: controller.signal }),
|
||||
createM48CorrectionSession(gate.packId, operationKey(gate.packId), { signal: controller.signal }),
|
||||
])
|
||||
.then(([next, correction]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setCatalog(next);
|
||||
setSession(correction);
|
||||
setDrafts(draftMap(correction));
|
||||
const first = next.clips[0];
|
||||
if (first) {
|
||||
setSelectedClipId(first.clipId);
|
||||
setSequence(first.startSequence);
|
||||
}
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) setError(errorMessage(caught));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [gate.packId]);
|
||||
|
||||
const clip = useMemo(
|
||||
() => catalog?.clips.find((item) => item.clipId === selectedClipId) ?? null,
|
||||
[catalog, selectedClipId],
|
||||
);
|
||||
const selectedClipIndex = useMemo(
|
||||
() => catalog?.clips.findIndex((item) => item.clipId === selectedClipId) ?? -1,
|
||||
[catalog, selectedClipId],
|
||||
);
|
||||
const currentDraft = clip ? drafts.get(clip.clipId) ?? null : null;
|
||||
const selectedTracklet = currentDraft?.tracklets.find(({ objectId }) => objectId === selectedObjectId) ?? null;
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedTracklet
|
||||
&& (sequence < selectedTracklet.firstSequence || sequence > selectedTracklet.lastSequence)
|
||||
) {
|
||||
setSelectedObjectId(null);
|
||||
}
|
||||
}, [selectedTracklet, sequence]);
|
||||
const spatialEnabled = Boolean(
|
||||
catalog?.evidenceCapabilities.currentPointCloudBodyXyzM
|
||||
&& catalog.evidenceCapabilities.rig
|
||||
&& catalog.evidenceCapabilities.virtualCorridor,
|
||||
);
|
||||
const extentEnabled = Boolean(catalog?.evidenceCapabilities.obstaclePresenceAndExtent);
|
||||
const editable = Boolean(session && session.state !== "frozen");
|
||||
const editingEnabled = editable && !busy;
|
||||
const {
|
||||
frame: spatial,
|
||||
loading: spatialLoading,
|
||||
error: spatialError,
|
||||
} = useM48SpatialClipPlayback({
|
||||
packId: gate.packId,
|
||||
clip,
|
||||
sequence,
|
||||
enabled: mode !== "camera" && spatialEnabled,
|
||||
});
|
||||
|
||||
const setCurrentDraft = useCallback((next: M48ReviewClipDraft) => {
|
||||
setDrafts((current) => {
|
||||
const updated = new Map(current);
|
||||
updated.set(next.clipId, next);
|
||||
return updated;
|
||||
});
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
const save = async (
|
||||
nextDrafts: ReadonlyMap<string, M48ReviewClipDraft> = drafts,
|
||||
reason: M48CorrectionSaveReason = "object-edit",
|
||||
rollback?: M48CorrectionSaveRollback,
|
||||
) => {
|
||||
if (!session || nextDrafts.size !== session.clipCount || savingRef.current) return false;
|
||||
savingRef.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const clips = session.clips.map((item) => nextDrafts.get(item.clipId) ?? item);
|
||||
const saved = await saveM48CorrectionSession(
|
||||
session,
|
||||
session.title,
|
||||
clips,
|
||||
`save-${session.revision + 1}-${crypto.randomUUID()}`,
|
||||
);
|
||||
setSession(saved);
|
||||
setDrafts(draftMap(saved));
|
||||
setDirty(false);
|
||||
notify({
|
||||
tone: "success",
|
||||
title: reason === "clip-status" ? "Статус клипа сохранён" : "Изменения объекта сохранены",
|
||||
description: `${saved.reviewedClipCount}/${saved.clipCount} клипов проверено.`,
|
||||
});
|
||||
onChanged?.();
|
||||
return true;
|
||||
} catch (caught) {
|
||||
const message = errorMessage(caught);
|
||||
setError(message);
|
||||
if (rollback) {
|
||||
setDrafts(rollback.drafts);
|
||||
setDirty(rollback.dirty);
|
||||
} else {
|
||||
setDirty(true);
|
||||
}
|
||||
notify({
|
||||
tone: "error",
|
||||
title: reason === "clip-status" ? "Статус клипа не сохранён" : "Изменения объекта не сохранены",
|
||||
description: message,
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const freeze = async () => {
|
||||
if (!session || !reviewerId.trim() || dirty || !session.complete || !candidateVisible || !classFree) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const frozen = await freezeM48CorrectionSession(session, reviewerId.trim());
|
||||
setSession(frozen);
|
||||
setDrafts(draftMap(frozen));
|
||||
setFreezeOpen(false);
|
||||
setDrawing(false);
|
||||
notify({ tone: "success", title: "Проверка Worker 006 зафиксирована", description: "Дельта correction сохранена как assisted evidence, не independent truth." });
|
||||
onChanged?.();
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSelectedTracklet = (patch: Partial<M48ReviewTracklet>) => {
|
||||
if (!currentDraft || !selectedTracklet) return;
|
||||
setCurrentDraft({
|
||||
...currentDraft,
|
||||
reviewState: "pending",
|
||||
noObject: null,
|
||||
tracklets: currentDraft.tracklets.map((tracklet) => (
|
||||
tracklet.objectId === selectedTracklet.objectId ? { ...tracklet, ...patch } : tracklet
|
||||
)),
|
||||
});
|
||||
};
|
||||
|
||||
const updateTrackState = (patch: Partial<M48ReviewTracklet["stateSegments"][number]>) => {
|
||||
if (!selectedTracklet) return;
|
||||
updateSelectedTracklet({
|
||||
stateSegments: selectedTracklet.stateSegments.map((segment) => ({ ...segment, ...patch })),
|
||||
});
|
||||
};
|
||||
|
||||
const updateVisibility = (visibility: M48ReviewTracklet["keyframes"][number]["visibility"]) => {
|
||||
if (!selectedTracklet) return;
|
||||
const extent = interpolateM48Extent(selectedTracklet, sequence);
|
||||
if (!extent) return;
|
||||
const withKeyframe = upsertM48Extent(selectedTracklet, sequence, extent);
|
||||
updateSelectedTracklet({
|
||||
keyframes: withKeyframe.keyframes.map((keyframe) => (
|
||||
keyframe.sequence === sequence ? { ...keyframe, visibility } : keyframe
|
||||
)),
|
||||
});
|
||||
};
|
||||
|
||||
const markReviewed = (reviewed: boolean) => {
|
||||
if (!currentDraft || !editingEnabled) return;
|
||||
const nextDraft: M48ReviewClipDraft = reviewed
|
||||
? {
|
||||
...currentDraft,
|
||||
reviewState: "reviewed",
|
||||
noObject: currentDraft.tracklets.length === 0,
|
||||
}
|
||||
: { ...currentDraft, reviewState: "pending", noObject: null };
|
||||
const nextDrafts = new Map(drafts);
|
||||
nextDrafts.set(nextDraft.clipId, nextDraft);
|
||||
setDrafts(nextDrafts);
|
||||
setDirty(true);
|
||||
void save(nextDrafts, "clip-status", { drafts, dirty });
|
||||
};
|
||||
|
||||
const selectClip = (clipId: string) => {
|
||||
const next = catalog?.clips.find((item) => item.clipId === clipId);
|
||||
if (!next) return;
|
||||
setSelectedClipId(next.clipId);
|
||||
setSequence(next.startSequence);
|
||||
setSelectedObjectId(null);
|
||||
setDrawing(false);
|
||||
};
|
||||
|
||||
const selectAdjacentClip = (offset: -1 | 1) => {
|
||||
const next = catalog?.clips[selectedClipIndex + offset];
|
||||
if (next) selectClip(next.clipId);
|
||||
};
|
||||
|
||||
const requestClose = () => {
|
||||
if (dirty) {
|
||||
if (!window.confirm("Закрыть рабочую область без сохранения черновика?")) return;
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (session?.complete && session.state !== "frozen") {
|
||||
setFreezeOpen(true);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<LaboratoryReviewWorkspaceFrame
|
||||
ariaLabel="M4.8 проверка авторазметки Worker 006"
|
||||
onClose={requestClose}
|
||||
interactionEnabled={!freezeOpen}
|
||||
returnFocusTarget={returnFocusTarget}
|
||||
toolbar={(
|
||||
<div className="m48-review-workspace__header">
|
||||
<div className="m48-review-workspace__topbar">
|
||||
<div className="m48-review-workspace__topbar-start">
|
||||
<div className="m48-review-workspace__clip-navigation">
|
||||
<IconButton
|
||||
label="Предыдущий клип"
|
||||
disabled={selectedClipIndex <= 0 || busy}
|
||||
onClick={() => selectAdjacentClip(-1)}
|
||||
>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="Следующий клип"
|
||||
disabled={!catalog || selectedClipIndex < 0 || selectedClipIndex >= catalog.clips.length - 1 || busy}
|
||||
onClick={() => selectAdjacentClip(1)}
|
||||
>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<FieldFrame label="Выбор клипа" className="m48-review-workspace__clip-field">
|
||||
<Select
|
||||
label="Клип для проверки"
|
||||
value={selectedClipId}
|
||||
options={(catalog?.clips ?? []).map((item) => ({
|
||||
value: item.clipId,
|
||||
label: clipOptionLabel(
|
||||
item.ordinal,
|
||||
catalog?.clipCount ?? 0,
|
||||
item.clipId,
|
||||
drafts.get(item.clipId)?.reviewState,
|
||||
),
|
||||
}))}
|
||||
disabled={!catalog || busy}
|
||||
searchable
|
||||
menuWidth={380}
|
||||
onChange={selectClip}
|
||||
/>
|
||||
</FieldFrame>
|
||||
{currentDraft && editable ? (
|
||||
<Checker
|
||||
className="m48-review-workspace__clip-reviewed"
|
||||
checked={currentDraft.reviewState === "reviewed"}
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
aria-label={currentDraft.tracklets.length
|
||||
? `Клип проверен · ${currentDraft.tracklets.length} объектов`
|
||||
: "Клип проверен · без объектов"}
|
||||
label={currentDraft.tracklets.length
|
||||
? `Проверен · ${currentDraft.tracklets.length} объектов`
|
||||
: "Проверен · без объектов"}
|
||||
onChange={markReviewed}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="m48-review-workspace__topbar-end">
|
||||
<IconButton
|
||||
label="Добавить объект"
|
||||
aria-pressed={drawing}
|
||||
disabled={!editingEnabled || !extentEnabled}
|
||||
onClick={() => {
|
||||
setSelectedObjectId(null);
|
||||
setDrawing((value) => !value);
|
||||
}}
|
||||
>
|
||||
<Icon name="plus" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Закрыть проверку M4.8" disabled={busy} onClick={requestClose}>
|
||||
<Icon name="close" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
{selectedTracklet && editable ? (
|
||||
<div className="m48-review-workspace__object-tools">
|
||||
<FieldFrame label="Видимость" className="m48-review-workspace__object-field">
|
||||
<Select disabled={busy} label="Видимость объекта" value={selectedTracklet.keyframes.filter((keyframe) => keyframe.sequence <= sequence).at(-1)?.visibility ?? "visible"} options={[{ value: "visible", label: "Виден" }, { value: "partial", label: "Виден частично" }, { value: "occluded", label: "Перекрыт" }]} onChange={(value) => updateVisibility(value as M48ReviewTracklet["keyframes"][number]["visibility"])} />
|
||||
</FieldFrame>
|
||||
<FieldFrame label="Связь с LiDAR" className="m48-review-workspace__object-field">
|
||||
<Select disabled={busy || !catalog?.evidenceCapabilities.geometryAssociation} label="Связь объекта с LiDAR" value={selectedTracklet.stateSegments[0]?.geometryAssociation ?? "unknown"} options={[{ value: "associated", label: "Связана" }, { value: "unavailable", label: "Недоступна" }, { value: "ineligible", label: "Не применяется" }, { value: "unknown", label: "Не определена" }]} onChange={(value) => updateTrackState({ geometryAssociation: value as M48ReviewTracklet["stateSegments"][number]["geometryAssociation"] })} />
|
||||
</FieldFrame>
|
||||
<FieldFrame label="Актуальность" className="m48-review-workspace__object-field">
|
||||
<Select disabled={busy || !catalog?.evidenceCapabilities.freshness} label="Актуальность объекта" value={selectedTracklet.stateSegments[0]?.freshness ?? "unavailable"} options={[{ value: "current", label: "Актуальна" }, { value: "held", label: "Удержана" }, { value: "stale", label: "Устарела" }, { value: "unavailable", label: "Недоступна" }]} onChange={(value) => updateTrackState({ freshness: value as M48ReviewTracklet["stateSegments"][number]["freshness"] })} />
|
||||
</FieldFrame>
|
||||
<FieldFrame label="Движение" className="m48-review-workspace__object-field">
|
||||
<Select disabled={busy || !catalog?.evidenceCapabilities.motion} label="Движение объекта" value={selectedTracklet.stateSegments[0]?.motion ?? "unknown"} options={[{ value: "moving", label: "Движется" }, { value: "static", label: "Стоит" }, { value: "unknown", label: "Не определено" }, { value: "unsupported", label: "Не поддерживается" }]} onChange={(value) => updateTrackState({ motion: value as M48ReviewTracklet["stateSegments"][number]["motion"] })} />
|
||||
</FieldFrame>
|
||||
<FieldFrame label="Непосредственная опасность" className="m48-review-workspace__object-field m48-review-workspace__object-field--wide">
|
||||
<Select disabled={busy || !catalog?.evidenceCapabilities.threat} label="Непосредственная опасность объекта" value={selectedTracklet.stateSegments[0]?.threat ?? "unknown"} options={[{ value: "threat", label: "Опасен сейчас" }, { value: "not-threat", label: "Не опасен сейчас" }, { value: "unknown", label: "Не определено" }]} onChange={(value) => updateTrackState({ threat: value as M48ReviewTracklet["stateSegments"][number]["threat"] })} />
|
||||
</FieldFrame>
|
||||
<FieldFrame label="Проезд" className="m48-review-workspace__passage-field">
|
||||
<Checker disabled={busy || !catalog?.evidenceCapabilities.criticalCorridorObstacle} checked={selectedTracklet.stateSegments[0]?.criticalCorridorObstacle ?? false} label="Объезд или запас" onChange={(criticalCorridorObstacle) => updateTrackState({ criticalCorridorObstacle })} />
|
||||
</FieldFrame>
|
||||
<IconButton
|
||||
label="Добавить ещё один объект"
|
||||
disabled={busy || !extentEnabled}
|
||||
onClick={() => {
|
||||
setSelectedObjectId(null);
|
||||
setDrawing(true);
|
||||
}}
|
||||
>
|
||||
<Icon name="plus" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="Сохранить изменения объекта"
|
||||
disabled={!dirty || busy}
|
||||
aria-busy={busy}
|
||||
onClick={() => void save(drafts, "object-edit")}
|
||||
>
|
||||
<Icon name="save" size={16} />
|
||||
</IconButton>
|
||||
<IconButton disabled={busy} label="Удалить объект" onClick={() => {
|
||||
if (!currentDraft) return;
|
||||
setCurrentDraft({ ...currentDraft, reviewState: "pending", noObject: null, tracklets: currentDraft.tracklets.filter(({ objectId }) => objectId !== selectedTracklet.objectId) });
|
||||
setSelectedObjectId(null);
|
||||
}}><Icon name="trash" size={16} /></IconButton>
|
||||
<IconButton
|
||||
label="Закрыть редактор объекта"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setSelectedObjectId(null);
|
||||
setDrawing(false);
|
||||
}}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
stage={(
|
||||
<div className="m48-review-workspace__stage-shell">
|
||||
{loading ? (
|
||||
<div className="m48-review-workspace__state" role="status"><span className="busy-indicator" aria-hidden="true" />Загружаем клипы и frozen-candidate seed Worker 006</div>
|
||||
) : !catalog || !catalog.cameraPlayback || !clip ? (
|
||||
<div className="m48-review-workspace__state" role="alert"><Icon name="alert" size={18} />{error ?? "M4.8 источник недоступен."}</div>
|
||||
) : (
|
||||
<M48BlindClipPlayer
|
||||
cameraPlayback={catalog.cameraPlayback}
|
||||
clip={clip}
|
||||
sequence={sequence}
|
||||
mode={mode}
|
||||
cameraVisible={cameraVisible}
|
||||
tracklets={currentDraft?.tracklets ?? []}
|
||||
selectedObjectId={selectedObjectId}
|
||||
editable={editingEnabled && extentEnabled}
|
||||
drawing={drawing}
|
||||
spatialFrame={spatial}
|
||||
spatialLoading={spatialLoading}
|
||||
spatialError={spatialError}
|
||||
spatialEvidenceAvailable={spatialEnabled}
|
||||
onSequenceChange={setSequence}
|
||||
onDrawingChange={setDrawing}
|
||||
onSelectedObjectIdChange={setSelectedObjectId}
|
||||
onTrackletsChange={(tracklets) => {
|
||||
if (!currentDraft) return;
|
||||
setCurrentDraft({ ...currentDraft, reviewState: "pending", noObject: null, tracklets });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{catalog ? (
|
||||
<M48EvidenceModeRail
|
||||
mode={mode}
|
||||
cameraVisible={cameraVisible}
|
||||
spatialAvailable={spatialEnabled}
|
||||
onModeChange={setMode}
|
||||
onCameraVisibleChange={setCameraVisible}
|
||||
/>
|
||||
) : null}
|
||||
{catalog ? (
|
||||
<GlassSurface
|
||||
className="m48-review-workspace__source-sticker"
|
||||
tone="strong"
|
||||
padding="sm"
|
||||
materialRim={false}
|
||||
>
|
||||
<div className="m48-review-workspace__source-heading">
|
||||
<StatusBadge tone={session ? "success" : "warning"}>
|
||||
{session
|
||||
? `Worker 006 · ${session.seedObjectCount.toLocaleString("ru-RU")} авторамок`
|
||||
: "Загружаем авторазметку"}
|
||||
</StatusBadge>
|
||||
<strong>{clip ? `${clip.clipId} · кадр ${sequence}` : "Источник проверяется"}</strong>
|
||||
</div>
|
||||
<small>Исправьте авторамки; новая ручная рамка относится только к текущему кадру и не имитирует трекинг.</small>
|
||||
<small>«Опасность» — немедленная угроза. «Проезд» — статическое ограничение, которое требует объезда или геометрического запаса.</small>
|
||||
<StatusBadge tone={dirty ? "warning" : session?.state === "frozen" ? "success" : session ? "neutral" : "warning"}>
|
||||
{busy ? "Сохраняем изменения" : dirty ? "Есть несохранённые изменения" : stateLabel(session)}
|
||||
</StatusBadge>
|
||||
{(error || spatialError) ? <StatusBadge tone="danger">{error ?? spatialError}</StatusBadge> : null}
|
||||
{session?.evidenceSummary ? (
|
||||
<div className="m48-review-workspace__evidence-summary">
|
||||
<strong>Результат проверки Worker 006</strong>
|
||||
<span>Подтверждено: {session.evidenceSummary.confirmedCandidateCount}/{session.evidenceSummary.seedObjectCount}</span>
|
||||
<span>Исправлено: {session.evidenceSummary.modifiedCandidateCount}</span>
|
||||
<span>Удалено лишних: {session.evidenceSummary.falsePositiveRemovedCount}</span>
|
||||
<span>Добавлено пропущенных: {session.evidenceSummary.missedObjectAddedCount}</span>
|
||||
<small>Это проверка авторазметки, а не независимая контрольная разметка.</small>
|
||||
</div>
|
||||
) : null}
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
overlays={(
|
||||
<>
|
||||
<Window
|
||||
open={freezeOpen}
|
||||
title="Завершить проверку Worker 006"
|
||||
subtitle="Будут сохранены исходные авторамки, ваши исправления и итоговая разница. Результат не является независимой контрольной разметкой."
|
||||
size="md"
|
||||
closeOnBackdrop={!busy}
|
||||
closeOnEscape={!busy}
|
||||
onClose={() => !busy && setFreezeOpen(false)}
|
||||
footer={<WindowFooterActions><Button disabled={busy} onClick={() => setFreezeOpen(false)}>Отмена</Button><Button variant="accent" disabled={busy || !reviewerId.trim() || !candidateVisible || !classFree} onClick={() => void freeze()}>{busy ? "Завершаем" : "Завершить проверку"}</Button></WindowFooterActions>}
|
||||
>
|
||||
<div className="m48-review-workspace__freeze-form">
|
||||
<TextField label="Кто проверил" value={reviewerId} maxLength={96} placeholder="Имя или ID проверяющего" onChange={(event) => setReviewerId(event.target.value)} />
|
||||
<Checker checked={candidateVisible} label="Все авторамки Worker 006 просмотрены, найденные ошибки исправлены" onChange={setCandidateVisible} />
|
||||
<Checker checked={classFree} label="Проверка не назначает объектам семантические классы" onChange={setClassFree} />
|
||||
</div>
|
||||
</Window>
|
||||
<ToastStack items={toasts} onDismiss={(id) => setToasts((current) => current.filter((item) => item.id !== id))} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
GlassSurface,
|
||||
Icon,
|
||||
IconButton,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
export type M48BlindEvidenceMode = "camera" | "3d" | "plan";
|
||||
|
||||
interface M48EvidenceModeControlProps {
|
||||
mode: M48BlindEvidenceMode;
|
||||
cameraVisible: boolean;
|
||||
spatialAvailable: boolean;
|
||||
onModeChange: (mode: M48BlindEvidenceMode) => void;
|
||||
onCameraVisibleChange: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
export function nextM48CameraVisibility(
|
||||
mode: M48BlindEvidenceMode,
|
||||
cameraVisible: boolean,
|
||||
): boolean {
|
||||
return mode === "camera" ? true : !cameraVisible;
|
||||
}
|
||||
|
||||
export function nextM48SpatialMode(
|
||||
mode: M48BlindEvidenceMode,
|
||||
cameraVisible: boolean,
|
||||
selected: Exclude<M48BlindEvidenceMode, "camera">,
|
||||
): M48BlindEvidenceMode {
|
||||
if (mode !== selected) return selected;
|
||||
return cameraVisible ? "camera" : mode;
|
||||
}
|
||||
|
||||
export function M48EvidenceModeControls({
|
||||
mode,
|
||||
cameraVisible,
|
||||
spatialAvailable,
|
||||
onModeChange,
|
||||
onCameraVisibleChange,
|
||||
}: M48EvidenceModeControlProps) {
|
||||
const spatialMode = mode === "camera" ? null : mode;
|
||||
return (
|
||||
<div
|
||||
className="m48-evidence-mode-controls"
|
||||
role="group"
|
||||
aria-label="Каналы доказательства"
|
||||
>
|
||||
<IconButton
|
||||
label={cameraVisible ? "Скрыть правую камеру" : "Показать правую камеру"}
|
||||
aria-pressed={cameraVisible}
|
||||
disabled={spatialMode === null}
|
||||
onClick={() => onCameraVisibleChange(
|
||||
nextM48CameraVisibility(mode, cameraVisible),
|
||||
)}
|
||||
>
|
||||
<Icon name="video" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={spatialMode === "3d" ? "Скрыть 3D" : "Показать 3D"}
|
||||
aria-pressed={spatialMode === "3d"}
|
||||
disabled={!spatialAvailable || (!cameraVisible && spatialMode === "3d")}
|
||||
onClick={() => {
|
||||
if (!spatialAvailable) return;
|
||||
onModeChange(nextM48SpatialMode(mode, cameraVisible, "3d"));
|
||||
}}
|
||||
>
|
||||
<span className="m48-evidence-mode-controls__text" aria-hidden="true">3D</span>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={spatialMode === "plan" ? "Скрыть план" : "Показать план"}
|
||||
aria-pressed={spatialMode === "plan"}
|
||||
disabled={!spatialAvailable || (!cameraVisible && spatialMode === "plan")}
|
||||
onClick={() => {
|
||||
if (!spatialAvailable) return;
|
||||
onModeChange(nextM48SpatialMode(mode, cameraVisible, "plan"));
|
||||
}}
|
||||
>
|
||||
<Icon name="plan" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function M48EvidenceModeRail(props: M48EvidenceModeControlProps) {
|
||||
return (
|
||||
<GlassSurface
|
||||
className="m48-evidence-mode-rail"
|
||||
tone="strong"
|
||||
radius="pill"
|
||||
padding="sm"
|
||||
materialRim={false}
|
||||
role="toolbar"
|
||||
aria-label="Режимы CAMERA, 3D и план"
|
||||
>
|
||||
<M48EvidenceModeControls {...props} />
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
fetchM48GateStatus,
|
||||
type M48GateStatus,
|
||||
} from "../../../core/laboratory/m48ObjectCentricQuality";
|
||||
import type { LaboratoryAnnotationAction } from "../../contracts";
|
||||
import { M48CorrectionWorkspace } from "./M48BlindReviewWorkspace";
|
||||
|
||||
export function useM48ReviewCapability({
|
||||
selectedWorkId,
|
||||
initialGate,
|
||||
onActionChange,
|
||||
}: {
|
||||
selectedWorkId: string;
|
||||
initialGate: M48GateStatus | null;
|
||||
onActionChange: (action: LaboratoryAnnotationAction | null) => void;
|
||||
}): { workspace: ReactNode; active: boolean } {
|
||||
const [gate, setGate] = useState(initialGate);
|
||||
const [open, setOpen] = useState(false);
|
||||
const ownsAction = useRef(false);
|
||||
const actionTrigger = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => setGate(initialGate), [initialGate]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!gate) return;
|
||||
void fetchM48GateStatus(gate.packId).then(setGate).catch(() => undefined);
|
||||
}, [gate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedWorkId !== "m48-object-centric-quality" || !gate) {
|
||||
if (ownsAction.current) {
|
||||
onActionChange(null);
|
||||
ownsAction.current = false;
|
||||
}
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
ownsAction.current = true;
|
||||
onActionChange({
|
||||
label: open
|
||||
? "Рабочая область открыта"
|
||||
: "Проверить Worker 006",
|
||||
disabled: Boolean(open) || gate.evaluated,
|
||||
onClick: () => {
|
||||
actionTrigger.current = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
setOpen(true);
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
if (ownsAction.current) {
|
||||
onActionChange(null);
|
||||
ownsAction.current = false;
|
||||
}
|
||||
};
|
||||
}, [gate, onActionChange, open, selectedWorkId]);
|
||||
|
||||
if (!gate || !open) return { workspace: null, active: false };
|
||||
return {
|
||||
active: true,
|
||||
workspace: (
|
||||
<M48CorrectionWorkspace
|
||||
gate={gate}
|
||||
returnFocusTarget={actionTrigger.current}
|
||||
onClose={() => setOpen(false)}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchM48ReviewSpatialFrame,
|
||||
type M48ReviewClipSource,
|
||||
type M48ReviewSpatialFrame,
|
||||
} from "../../../core/laboratory/m48ObjectCentricQuality";
|
||||
|
||||
export const M48_SPATIAL_PREFETCH_FRAME_COUNT = 14;
|
||||
export const M48_SPATIAL_CACHE_FRAME_LIMIT = 24;
|
||||
|
||||
export function m48SpatialPlaybackWindow(
|
||||
frames: readonly { sequence: number }[],
|
||||
sequence: number,
|
||||
frameCount = M48_SPATIAL_PREFETCH_FRAME_COUNT,
|
||||
): readonly number[] {
|
||||
if (!frames.length || frameCount <= 0) return [];
|
||||
const currentIndex = Math.max(0, frames.findIndex((frame) => frame.sequence === sequence));
|
||||
const count = Math.min(frameCount, frames.length);
|
||||
return Array.from({ length: count }, (_, offset) => (
|
||||
frames[(currentIndex + offset) % frames.length]!.sequence
|
||||
));
|
||||
}
|
||||
|
||||
export function trimM48SpatialPlaybackCache<T>(
|
||||
cache: Map<number, T>,
|
||||
protectedSequences: readonly number[],
|
||||
limit = M48_SPATIAL_CACHE_FRAME_LIMIT,
|
||||
): void {
|
||||
const protectedSet = new Set(protectedSequences);
|
||||
for (const key of cache.keys()) {
|
||||
if (cache.size <= limit) return;
|
||||
if (!protectedSet.has(key)) cache.delete(key);
|
||||
}
|
||||
for (const key of cache.keys()) {
|
||||
if (cache.size <= limit) return;
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Spatial evidence для текущего кадра недоступно.";
|
||||
}
|
||||
|
||||
function aborted(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === "AbortError";
|
||||
}
|
||||
|
||||
export function useM48SpatialClipPlayback({
|
||||
packId,
|
||||
clip,
|
||||
sequence,
|
||||
enabled,
|
||||
}: {
|
||||
packId: string;
|
||||
clip: M48ReviewClipSource | null;
|
||||
sequence: number;
|
||||
enabled: boolean;
|
||||
}): {
|
||||
frame: M48ReviewSpatialFrame | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
} {
|
||||
const cacheRef = useRef(new Map<number, M48ReviewSpatialFrame>());
|
||||
const errorsRef = useRef(new Map<number, string>());
|
||||
const inFlightRef = useRef(new Map<number, Promise<void>>());
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
const generationRef = useRef(0);
|
||||
const [, setRevision] = useState(0);
|
||||
const sourceKey = enabled && clip ? `${packId}:${clip.clipId}` : null;
|
||||
|
||||
useEffect(() => {
|
||||
generationRef.current += 1;
|
||||
controllerRef.current?.abort();
|
||||
controllerRef.current = sourceKey ? new AbortController() : null;
|
||||
cacheRef.current.clear();
|
||||
errorsRef.current.clear();
|
||||
inFlightRef.current.clear();
|
||||
setRevision((value) => value + 1);
|
||||
return () => controllerRef.current?.abort();
|
||||
}, [sourceKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = controllerRef.current;
|
||||
if (!sourceKey || !clip || !controller || controller.signal.aborted) return;
|
||||
const generation = generationRef.current;
|
||||
const wanted = m48SpatialPlaybackWindow(clip.frames, sequence);
|
||||
|
||||
const load = (nextSequence: number): Promise<void> => {
|
||||
const existing = inFlightRef.current.get(nextSequence);
|
||||
if (existing) return existing;
|
||||
if (cacheRef.current.has(nextSequence)) return Promise.resolve();
|
||||
const request = fetchM48ReviewSpatialFrame(
|
||||
packId,
|
||||
clip.clipId,
|
||||
nextSequence,
|
||||
{ signal: controller.signal },
|
||||
).then((next) => {
|
||||
if (controller.signal.aborted || generation !== generationRef.current) return;
|
||||
cacheRef.current.set(nextSequence, next);
|
||||
errorsRef.current.delete(nextSequence);
|
||||
trimM48SpatialPlaybackCache(cacheRef.current, wanted);
|
||||
setRevision((value) => value + 1);
|
||||
}).catch((caught: unknown) => {
|
||||
if (controller.signal.aborted || aborted(caught) || generation !== generationRef.current) return;
|
||||
errorsRef.current.set(nextSequence, errorMessage(caught));
|
||||
setRevision((value) => value + 1);
|
||||
}).finally(() => {
|
||||
if (generation === generationRef.current) inFlightRef.current.delete(nextSequence);
|
||||
});
|
||||
inFlightRef.current.set(nextSequence, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
for (const nextSequence of wanted) void load(nextSequence);
|
||||
}, [clip, packId, sequence, sourceKey]);
|
||||
|
||||
const frame = sourceKey ? cacheRef.current.get(sequence) ?? null : null;
|
||||
return {
|
||||
frame,
|
||||
loading: Boolean(sourceKey && !frame && !errorsRef.current.has(sequence)),
|
||||
error: sourceKey ? errorsRef.current.get(sequence) ?? null : null,
|
||||
};
|
||||
}
|
||||
@@ -63,6 +63,20 @@ interface KnownWorkDefinition {
|
||||
const rig = (rigLabel: string): string => rigLabel.trim() || "Сенсорный риг";
|
||||
|
||||
const KNOWN_WORKS: Readonly<Record<Exclude<LaboratoryWorkId, `session:${string}`>, KnownWorkDefinition>> = {
|
||||
"m48-object-centric-quality": {
|
||||
profileId: "rig-dual-evidence-virtual-corridor-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera + prediction-free spatial evidence`,
|
||||
experimentId: "m48-object-centric-source-quality",
|
||||
experimentName: "RAVNOVES00 class-free object-centric source quality",
|
||||
variantName: "M4.8 · Worker 006 assisted correction → evidence delta",
|
||||
},
|
||||
"m48-small-static-passage-regression": {
|
||||
profileId: "rig-dual-evidence-virtual-corridor-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera + prediction-free spatial evidence`,
|
||||
experimentId: "m48-small-static-passage-regression",
|
||||
experimentName: "M4.8 · small static passage regression",
|
||||
variantName: "M4.8R1 · Worker 006 small-static assisted baseline",
|
||||
},
|
||||
"m47-reference-graph-shadow": {
|
||||
profileId: "rig-dual-evidence-virtual-corridor-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera + LiDAR dual evidence`,
|
||||
|
||||
@@ -19,6 +19,8 @@ function mergeResults(
|
||||
): AdvancedLaboratoryResults {
|
||||
return {
|
||||
m47Graph: next.m47Graph ?? current.m47Graph,
|
||||
m48: next.m48 ?? current.m48,
|
||||
m48SmallStatic: next.m48SmallStatic ?? current.m48SmallStatic,
|
||||
m4Threat: next.m4Threat ?? current.m4Threat,
|
||||
l3: next.l3 ?? current.l3,
|
||||
l31: next.l31 ?? current.l31,
|
||||
@@ -111,7 +113,14 @@ export function useAdvancedLaboratoryCatalog({
|
||||
|| advancedLaboratoryResultAvailable(selectedWorkId, results)
|
||||
) return;
|
||||
const indexedResultId = index.find((item) => item.workId === selectedWorkId)?.resultId;
|
||||
if (selectedWorkId === "m47-reference-graph-shadow" && !indexedResultId) return;
|
||||
if (
|
||||
[
|
||||
"m47-reference-graph-shadow",
|
||||
"m48-object-centric-quality",
|
||||
"m48-small-static-passage-regression",
|
||||
].includes(selectedWorkId)
|
||||
&& !indexedResultId
|
||||
) return;
|
||||
const controller = new AbortController();
|
||||
setLoadingWorkId(selectedWorkId);
|
||||
setFailedWorkId(null);
|
||||
|
||||
Reference in New Issue
Block a user