fix(m4): retain compact obstacle evidence

This commit is contained in:
DCCONSTRUCTIONS
2026-08-05 22:16:33 +03:00
parent 13ed096f80
commit aacc6dc43b
24 changed files with 981 additions and 410 deletions
@@ -20,6 +20,7 @@ export function LaboratoryEvidenceViewer<
U extends string = string,
>({
label,
className,
mode,
modes,
expanded,
@@ -31,6 +32,7 @@ export function LaboratoryEvidenceViewer<
children,
}: {
label: string;
className?: string;
mode: T;
modes: readonly LaboratoryEvidenceViewerMode<T>[];
expanded: boolean;
@@ -62,7 +64,10 @@ export function LaboratoryEvidenceViewer<
const viewer = (
<section
className="laboratory-evidence-viewer"
className={[
"laboratory-evidence-viewer",
className,
].filter(Boolean).join(" ")}
data-expanded={expanded ? "true" : undefined}
aria-label={label}
>
@@ -0,0 +1,110 @@
import { useEffect, useRef } from "react";
export type RecordedEvidenceBoxTone =
| "accent"
| "danger"
| "success"
| "warning"
| "neutral";
export interface RecordedEvidenceBox {
boxXyxy: readonly [number, number, number, number];
label: string;
tone: RecordedEvidenceBoxTone;
dashed?: boolean;
}
function rgba(
host: HTMLElement,
token: string,
fallback: readonly [number, number, number],
alpha = 1,
): string {
const channels = getComputedStyle(host)
.getPropertyValue(token)
.trim()
.match(/[\d.]+/g)
?.slice(0, 3)
.map(Number);
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
function toneColor(host: HTMLElement, tone: RecordedEvidenceBoxTone): string {
if (tone === "danger") return rgba(host, "--nodedc-danger-rgb", [255, 104, 112]);
if (tone === "success") return rgba(host, "--nodedc-success-rgb", [181, 255, 90]);
if (tone === "warning") return rgba(host, "--nodedc-warning-rgb", [255, 197, 92]);
if (tone === "neutral") return rgba(host, "--nodedc-foreground-rgb", [245, 245, 245], 0.7);
return rgba(host, "--nodedc-accent-rgb", [232, 56, 126]);
}
export function RecordedEvidenceBoxOverlay({
imageWidth,
imageHeight,
boxes,
ariaLabel,
}: {
imageWidth: number;
imageHeight: number;
boxes: readonly RecordedEvidenceBox[];
ariaLabel: string;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
const host = canvas?.parentElement;
if (!host || !canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const render = () => {
const width = Math.max(host.clientWidth, 1);
const height = Math.max(host.clientHeight, 1);
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
canvas.width = Math.round(width * pixelRatio);
canvas.height = Math.round(height * pixelRatio);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.clearRect(0, 0, width, height);
const scale = Math.min(width / imageWidth, height / imageHeight);
const drawWidth = imageWidth * scale;
const drawHeight = imageHeight * scale;
const offsetX = (width - drawWidth) / 2;
const offsetY = (height - drawHeight) / 2;
for (const item of boxes) {
const [left, top, right, bottom] = item.boxXyxy;
const x = offsetX + left * scale;
const y = offsetY + top * scale;
const boxWidth = (right - left) * scale;
const boxHeight = (bottom - top) * scale;
const stroke = toneColor(host, item.tone);
context.strokeStyle = stroke;
context.lineWidth = Math.max(1.5, 2 * scale);
context.setLineDash(item.dashed ? [5, 4] : []);
context.strokeRect(x, y, boxWidth, boxHeight);
context.setLineDash([]);
const fontSize = Math.max(9, 10 * scale);
context.font = `650 ${fontSize}px Inter, system-ui, sans-serif`;
const labelWidth = Math.min(drawWidth, context.measureText(item.label).width + 8);
const labelHeight = fontSize + 6;
const labelX = Math.min(offsetX + drawWidth - labelWidth, Math.max(offsetX, x));
const labelY = Math.max(offsetY, y - labelHeight);
context.fillStyle = rgba(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9);
context.fillRect(labelX, labelY, labelWidth, labelHeight);
context.fillStyle = stroke;
context.fillText(item.label, labelX + 4, labelY + fontSize + 1, labelWidth - 8);
}
};
const observer = new ResizeObserver(render);
observer.observe(host);
render();
return () => observer.disconnect();
}, [boxes, imageHeight, imageWidth]);
return <canvas ref={canvasRef} role="img" aria-label={ariaLabel} />;
}
@@ -0,0 +1,58 @@
import { useEffect, useState } from "react";
import { Icon } from "@nodedc/ui-react";
import {
RecordedEvidenceBoxOverlay,
type RecordedEvidenceBox,
} from "./RecordedEvidenceBoxOverlay";
export function RecordedEvidenceImageScene({
src,
imageWidth,
imageHeight,
boxes,
ariaLabel,
}: {
src: string;
imageWidth: number;
imageHeight: number;
boxes: readonly RecordedEvidenceBox[];
ariaLabel: string;
}) {
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => setState("loading"), [src]);
return (
<div className="recorded-evidence-image-scene">
<img
src={src}
alt=""
draggable={false}
onLoad={() => setState("ready")}
onError={() => setState("error")}
/>
{state === "ready" ? (
<RecordedEvidenceBoxOverlay
imageWidth={imageWidth}
imageHeight={imageHeight}
boxes={boxes}
ariaLabel={ariaLabel}
/>
) : (
<div className="l3-visual-audit__state" role="status">
{state === "loading" ? (
<span className="busy-indicator" aria-hidden="true" />
) : (
<Icon name="alert" size={18} />
)}
<span>
{state === "loading"
? "Декодируем один точный CAMERA-кадр"
: "Точный CAMERA-кадр недоступен."}
</span>
</div>
)}
</div>
);
}
@@ -1,48 +1,15 @@
import { useEffect, useRef } from "react";
import {
RecordedFmp4Player,
type RecordedObservationPlayback,
} from "../RecordedFmp4Player";
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
import {
RecordedEvidenceBoxOverlay,
type RecordedEvidenceBox,
type RecordedEvidenceBoxTone,
} from "./RecordedEvidenceBoxOverlay";
export type RecordedEvidenceBoxTone =
| "accent"
| "danger"
| "success"
| "warning"
| "neutral";
export interface RecordedEvidenceBox {
boxXyxy: readonly [number, number, number, number];
label: string;
tone: RecordedEvidenceBoxTone;
dashed?: boolean;
}
function rgba(
host: HTMLElement,
token: string,
fallback: readonly [number, number, number],
alpha = 1,
): string {
const channels = getComputedStyle(host)
.getPropertyValue(token)
.trim()
.match(/[\d.]+/g)
?.slice(0, 3)
.map(Number);
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
function toneColor(host: HTMLElement, tone: RecordedEvidenceBoxTone): string {
if (tone === "danger") return rgba(host, "--nodedc-danger-rgb", [255, 104, 112]);
if (tone === "success") return rgba(host, "--nodedc-success-rgb", [181, 255, 90]);
if (tone === "warning") return rgba(host, "--nodedc-warning-rgb", [255, 197, 92]);
if (tone === "neutral") return rgba(host, "--nodedc-foreground-rgb", [245, 245, 245], 0.7);
return rgba(host, "--nodedc-accent-rgb", [232, 56, 126]);
}
export type { RecordedEvidenceBox, RecordedEvidenceBoxTone };
export function RecordedEvidenceVideoScene({
source,
@@ -61,66 +28,8 @@ export function RecordedEvidenceVideoScene({
ariaLabel: string;
onPlaybackChange: (playback: RecordedObservationPlayback) => void;
}) {
const hostRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const host = hostRef.current;
const canvas = canvasRef.current;
if (!host || !canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const render = () => {
const width = Math.max(host.clientWidth, 1);
const height = Math.max(host.clientHeight, 1);
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
canvas.width = Math.round(width * pixelRatio);
canvas.height = Math.round(height * pixelRatio);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.clearRect(0, 0, width, height);
const scale = Math.min(width / imageWidth, height / imageHeight);
const drawWidth = imageWidth * scale;
const drawHeight = imageHeight * scale;
const offsetX = (width - drawWidth) / 2;
const offsetY = (height - drawHeight) / 2;
for (const item of boxes) {
const [left, top, right, bottom] = item.boxXyxy;
const x = offsetX + left * scale;
const y = offsetY + top * scale;
const boxWidth = (right - left) * scale;
const boxHeight = (bottom - top) * scale;
const stroke = toneColor(host, item.tone);
context.strokeStyle = stroke;
context.lineWidth = Math.max(1.5, 2 * scale);
context.setLineDash(item.dashed ? [5, 4] : []);
context.strokeRect(x, y, boxWidth, boxHeight);
context.setLineDash([]);
const fontSize = Math.max(9, 10 * scale);
context.font = `650 ${fontSize}px Inter, system-ui, sans-serif`;
const labelWidth = Math.min(drawWidth, context.measureText(item.label).width + 8);
const labelHeight = fontSize + 6;
const labelX = Math.min(offsetX + drawWidth - labelWidth, Math.max(offsetX, x));
const labelY = Math.max(offsetY, y - labelHeight);
context.fillStyle = rgba(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9);
context.fillRect(labelX, labelY, labelWidth, labelHeight);
context.fillStyle = stroke;
context.fillText(item.label, labelX + 4, labelY + fontSize + 1, labelWidth - 8);
}
};
const observer = new ResizeObserver(render);
observer.observe(host);
render();
return () => observer.disconnect();
}, [boxes, imageHeight, imageWidth]);
return (
<div className="recorded-evidence-video-scene" ref={hostRef}>
<div className="recorded-evidence-video-scene">
<RecordedFmp4Player
source={source}
playback={playback}
@@ -128,7 +37,12 @@ export function RecordedEvidenceVideoScene({
prepare
onPlaybackChange={onPlaybackChange}
/>
<canvas ref={canvasRef} role="img" aria-label={ariaLabel} />
<RecordedEvidenceBoxOverlay
imageWidth={imageWidth}
imageHeight={imageHeight}
boxes={boxes}
ariaLabel={ariaLabel}
/>
</div>
);
}
@@ -90,6 +90,7 @@ export interface M4ThreatCameraProposal {
export interface M4ThreatVisualFrame {
resultId: string;
cameraUrl: string;
ordinal: number;
sequence: number;
frameId: string;
@@ -408,6 +409,7 @@ export async function fetchM4ThreatVisual(
const corridor = object(item.corridor, "M4.6 visual corridor");
return {
resultId: result,
cameraUrl: text(item.camera_url, "M4.6 camera URL"),
ordinal: integer(item.ordinal, "M4.6 ordinal"),
sequence: integer(item.sequence, "M4.6 sequence"),
frameId: text(item.frame_id, "M4.6 frame id"),
@@ -46,7 +46,8 @@
user-select: none;
}
.recorded-evidence-video-scene {
.recorded-evidence-video-scene,
.recorded-evidence-image-scene {
position: relative;
width: 100%;
height: 100%;
@@ -55,12 +56,23 @@
background: var(--nodedc-canvas);
}
.recorded-evidence-image-scene > img {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: contain;
user-select: none;
}
.recorded-evidence-video-scene > .recorded-media-player {
position: absolute;
inset: 0;
}
.recorded-evidence-video-scene > canvas {
.recorded-evidence-video-scene > canvas,
.recorded-evidence-image-scene > canvas {
position: absolute;
z-index: 2;
inset: 0;
@@ -70,6 +82,18 @@
pointer-events: none;
}
.recorded-evidence-image-scene > .l3-visual-audit__state {
position: absolute;
z-index: 3;
inset: 0;
}
.m4-replay-threat-evidence-viewer .laboratory-metric-evidence-scene__toolbar {
top: 5.4rem;
max-width: calc(100% - 1.2rem);
flex-wrap: wrap;
}
.e46e-ready-stack-video {
width: 100%;
height: 100%;
@@ -26,7 +26,7 @@ export function M4ReplayThreatResultView({
<LaboratorySummary
title="M4.6 · dual-evidence threat replay"
description="Camera и LiDAR дают независимые доказательства, после чего один source-neutral слой оценивает пересечение виртуального коридора, ближайшее сближение и TTC. Ни один сенсор не назначен first."
status="4489/4489 · replay-simulated · accepted"
status="Replay contract passed · CV quality gate open"
statusTone="warning"
facts={[
{
@@ -53,7 +53,7 @@ export function M4ReplayThreatResultView({
brief={{
question: "Может ли единый слой обнаруживать потенциальное препятствие по двум независимым источникам, не теряя LiDAR-only объекты и не объявляя camera-only наблюдение безопасным?",
approach: "Все 4489 кадров RAVNOVES00 повторно пропущены через неизменяемые detector, metric geometry и temporal ledgers. Текущий lio_pcl increment хранится отдельно от bounded rolling map: отсутствие повторной публикации точки не считается свободным пространством. Виртуальный base_footprint привязан к gravity-оси SLAM map и направлению сглаженной траектории, проверенному camera extrinsic.",
principalResult: `${metrics.evidence.currentMetric.toLocaleString("ru-RU")} current metric, ${metrics.evidence.rollingMapRetained.toLocaleString("ru-RU")} rolling-map и ${metrics.evidence.cameraOnly.toLocaleString("ru-RU")} camera-only публикаций учтены. Кадр 1880 удерживает обе видимые бетонные полусферы; критические fixtures: ${metrics.fixtures.passed}/${metrics.fixtures.total}, ложных safe: ${metrics.fixtures.criticalFalseNotThreat}.`,
principalResult: `${metrics.evidence.currentMetric.toLocaleString("ru-RU")} current metric, ${metrics.evidence.rollingMapRetained.toLocaleString("ru-RU")} rolling-map и ${metrics.evidence.cameraOnly.toLocaleString("ru-RU")} camera-only публикаций учтены. Кадры 1880 и 2584 закрепляют компактные бетонные полусферы как автоматические метрические регрессии; критические fixtures: ${metrics.fixtures.passed}/${metrics.fixtures.total}, ложных safe: ${metrics.fixtures.criticalFalseNotThreat}.`,
limitation: "Корпус и коридор пока виртуальные, replay не является live-проходом или физическим collision test. На машине виртуальная привязка должна замениться измеренным rigid T_body_from_sensor; independent object truth остаётся следующим gate.",
}}
method={{
@@ -105,8 +105,8 @@ export function M4ReplayThreatResultView({
)}
result={(
<LaboratoryResultSummary
title="Dual-evidence слой готов к следующей CV-итерации на recorded replay"
status="Replay gate accepted · physical authority withheld"
title="Replay-инфраструктура принята; object recall ещё проверяется"
status="Жёлтый: pipeline целостен, независимый object-truth gate не пройден"
statusTone="warning"
metrics={[
{
@@ -131,7 +131,7 @@ export function M4ReplayThreatResultView({
},
]}
conclusion={{
proved: `На неизменяемом RAVNOVES00 каждый current, rolling-map, stale/held и camera-only объект получил ровно одну консервативную оценку. CURRENT INCREMENT и ROLLING MAP независимо включаются в viewer; кадр 1880 закреплён как регрессия двух бетонных полусфер. ${metrics.bodyFrame.qualified}/${metrics.bodyFrame.available} body frames квалифицированы без переноса handheld roll/pitch на SLAM-мир.`,
proved: `На неизменяемом RAVNOVES00 каждый current, rolling-map, stale/held и camera-only объект получил ровно одну консервативную оценку. CURRENT INCREMENT и ROLLING MAP независимо включаются в viewer; кадры 1880 и 2584 закреплены как регрессии компактных бетонных полусфер. ${metrics.bodyFrame.qualified}/${metrics.bodyFrame.available} body frames квалифицированы без переноса handheld roll/pitch на SLAM-мир.`,
notProved: "Не доказаны live realtime, измеренный T_body_from_sensor и геометрия физического корпуса, независимая object-level правильность, навигационная или safety-пригодность и выдача команд.",
decision: "Сохранить dual-evidence provider как канонический replay seam и переходить к независимому object-centric gate; физическую геометрию и live/actuation authority не смешивать с дальнейшей CV-разработкой.",
}}
@@ -7,6 +7,7 @@ import {
type LaboratoryMetricSceneMode,
} from "../../components/laboratory/LaboratoryMetricEvidenceScene";
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
import { RecordedEvidenceImageScene } from "../../components/laboratory/RecordedEvidenceImageScene";
import {
RecordedEvidenceVideoScene,
type RecordedEvidenceBox,
@@ -108,7 +109,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
}, [ordinal, resultId]);
useEffect(() => {
if ((mode !== "video" && mode !== "camera") || (videoOverlay && videoSource)) return;
if (mode !== "video" || (videoOverlay && videoSource)) return;
const controller = new AbortController();
setVideoLoading(true);
setVideoError(null);
@@ -157,14 +158,6 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
return () => controller.abort();
}, [mode, resultId, videoOverlay, videoSource]);
useEffect(() => {
if (mode !== "camera" || !frame) return;
setVideoPlayback({
currentSeconds: frame.sourceTimeNs / 1_000_000_000,
playing: false,
});
}, [frame, mode]);
const activeVideoFrame = useMemo(
() => videoOverlay
? selectM4ThreatVideoFrame(videoOverlay.frames, videoPlayback.currentSeconds)
@@ -317,7 +310,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
) : undefined;
let content;
if (mode === "video" || mode === "camera") {
if (mode === "video") {
content = videoLoading ? (
<div className="l3-visual-audit__state" role="status">
<span className="busy-indicator" aria-hidden="true" />
@@ -335,14 +328,30 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
imageWidth={videoOverlay.imageWidth}
imageHeight={videoOverlay.imageHeight}
boxes={activeBoxes}
ariaLabel={
mode === "camera"
? `M4.6 exact camera sample ${ordinal}: ${activeBoxes.length} proposals`
: `M4.6 full video frame ${activeVideoFrame?.frameIndex ?? 0}: ${activeBoxes.length} proposals`
}
ariaLabel={`M4.6 full video frame ${activeVideoFrame?.frameIndex ?? 0}: ${activeBoxes.length} proposals`}
onPlaybackChange={setVideoPlayback}
/>
);
} else if (mode === "camera") {
content = sampleLoading ? (
<div className="l3-visual-audit__state" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>Открываем точный CAMERA-кадр M4.6</span>
</div>
) : sampleError || !frame ? (
<div className="l3-visual-audit__state" role="status">
<Icon name="alert" size={18} />
<span>{sampleError ?? "CAMERA-кадр M4.6 недоступен."}</span>
</div>
) : (
<RecordedEvidenceImageScene
src={frame.cameraUrl}
imageWidth={800}
imageHeight={600}
boxes={activeBoxes}
ariaLabel={`M4.6 exact camera sample ${ordinal}: ${activeBoxes.length} proposals`}
/>
);
} else {
content = sampleLoading ? (
<div className="l3-visual-audit__state" role="status">
@@ -376,6 +385,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
<div className="l3-visual-audit m4-replay-threat-visual">
<LaboratoryEvidenceViewer
label="M4.6 dual-evidence replay: video, camera and metric 3D"
className="m4-replay-threat-evidence-viewer"
mode={mode}
modes={[
{ value: "video", label: "VIDEO" },