feat: add local surface review triage
This commit is contained in:
@@ -3,14 +3,19 @@ import { StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
fetchLidarLocalSurfaceFrame,
|
||||
fetchLidarLocalSurfaceReview,
|
||||
fetchLidarLocalSurfaceTimeline,
|
||||
fetchLidarLocalSurfaces,
|
||||
type LidarLocalSurfaceFrame,
|
||||
type LidarLocalSurfaceModel,
|
||||
type LidarLocalSurfaceReview,
|
||||
type LidarLocalSurfaceTimeline as Timeline,
|
||||
} from "../core/lidar/localSurface";
|
||||
import { LidarGroundPointCloud } from "./LidarGroundPointCloud";
|
||||
import { LidarLocalSurfaceTimeline } from "./LidarLocalSurfaceTimeline";
|
||||
import {
|
||||
LidarLocalSurfaceReviewQueue,
|
||||
LidarLocalSurfaceTimeline,
|
||||
} from "./LidarLocalSurfaceTimeline";
|
||||
|
||||
function formatNumber(value: number | null, digits = 2): string {
|
||||
if (value === null) return "—";
|
||||
@@ -33,6 +38,7 @@ export function LidarLocalSurfacePanel({
|
||||
const [model, setModel] = useState<LidarLocalSurfaceModel | null>(null);
|
||||
const [frame, setFrame] = useState<LidarLocalSurfaceFrame | null>(null);
|
||||
const [timeline, setTimeline] = useState<Timeline | null>(null);
|
||||
const [review, setReview] = useState<LidarLocalSurfaceReview | null>(null);
|
||||
const [selectedFrameIndex, setSelectedFrameIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
@@ -68,6 +74,7 @@ export function LidarLocalSurfacePanel({
|
||||
setModel(null);
|
||||
setFrame(null);
|
||||
setTimeline(null);
|
||||
setReview(null);
|
||||
setError(errorMessage(loadError));
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -83,18 +90,27 @@ export function LidarLocalSurfacePanel({
|
||||
useEffect(() => {
|
||||
if (!model) {
|
||||
setTimeline(null);
|
||||
setReview(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
void fetchLidarLocalSurfaceTimeline(model.modelId, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((nextTimeline) => {
|
||||
if (!controller.signal.aborted) setTimeline(nextTimeline);
|
||||
void Promise.all([
|
||||
fetchLidarLocalSurfaceTimeline(model.modelId, {
|
||||
signal: controller.signal,
|
||||
}),
|
||||
fetchLidarLocalSurfaceReview(model.modelId, {
|
||||
signal: controller.signal,
|
||||
}),
|
||||
])
|
||||
.then(([nextTimeline, nextReview]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setTimeline(nextTimeline);
|
||||
setReview(nextReview);
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setTimeline(null);
|
||||
setReview(null);
|
||||
setError(errorMessage(loadError));
|
||||
});
|
||||
return () => controller.abort();
|
||||
@@ -143,6 +159,12 @@ export function LidarLocalSurfacePanel({
|
||||
},
|
||||
};
|
||||
}, [frame]);
|
||||
const selectedReviewItem = useMemo(
|
||||
() => review?.items.find(
|
||||
(item) => item.frameIndex === selectedFrameIndex,
|
||||
) ?? null,
|
||||
[review, selectedFrameIndex],
|
||||
);
|
||||
|
||||
if (!model && !loading && !error) {
|
||||
return null;
|
||||
@@ -167,7 +189,7 @@ export function LidarLocalSurfacePanel({
|
||||
tone={
|
||||
error
|
||||
? "danger"
|
||||
: frame?.temporal.jump
|
||||
: selectedReviewItem
|
||||
? "warning"
|
||||
: frame?.valid
|
||||
? "success"
|
||||
@@ -176,8 +198,10 @@ export function LidarLocalSurfacePanel({
|
||||
>
|
||||
{error
|
||||
? "Недоступно"
|
||||
: frame?.temporal.jump
|
||||
? "Temporal jump"
|
||||
: selectedReviewItem
|
||||
? selectedReviewItem.attention === "high"
|
||||
? "Высокий приоритет"
|
||||
: "Требует разбора"
|
||||
: frame?.valid
|
||||
? "Кадр рассчитан"
|
||||
: "Диагностический режим"}
|
||||
@@ -226,12 +250,20 @@ export function LidarLocalSurfacePanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{timeline && selectedFrameIndex !== null ? (
|
||||
<LidarLocalSurfaceTimeline
|
||||
timeline={timeline}
|
||||
selectedFrameIndex={selectedFrameIndex}
|
||||
onSelectFrame={setSelectedFrameIndex}
|
||||
/>
|
||||
{timeline && review && selectedFrameIndex !== null ? (
|
||||
<>
|
||||
<LidarLocalSurfaceTimeline
|
||||
timeline={timeline}
|
||||
review={review}
|
||||
selectedFrameIndex={selectedFrameIndex}
|
||||
onSelectFrame={setSelectedFrameIndex}
|
||||
/>
|
||||
<LidarLocalSurfaceReviewQueue
|
||||
review={review}
|
||||
selectedFrameIndex={selectedFrameIndex}
|
||||
onSelectFrame={setSelectedFrameIndex}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="lidar-local-surface__stage">
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { LidarLocalSurfaceTimeline as Timeline } from "../core/lidar/localSurface";
|
||||
import type {
|
||||
LidarLocalSurfaceReview,
|
||||
LidarLocalSurfaceReviewItem,
|
||||
LidarLocalSurfaceReviewReason,
|
||||
LidarLocalSurfaceTimeline as Timeline,
|
||||
} from "../core/lidar/localSurface";
|
||||
|
||||
const VIEWBOX_WIDTH = 1000;
|
||||
const VIEWBOX_HEIGHT = 168;
|
||||
@@ -14,6 +19,20 @@ function formatMeters(value: number): string {
|
||||
});
|
||||
}
|
||||
|
||||
function russianPlural(
|
||||
value: number,
|
||||
one: string,
|
||||
few: string,
|
||||
many: string,
|
||||
): string {
|
||||
const mod100 = value % 100;
|
||||
const mod10 = value % 10;
|
||||
if (mod100 >= 11 && mod100 <= 14) return many;
|
||||
if (mod10 === 1) return one;
|
||||
if (mod10 >= 2 && mod10 <= 4) return few;
|
||||
return many;
|
||||
}
|
||||
|
||||
function xAt(index: number, frameCount: number): number {
|
||||
if (frameCount <= 1) return 0;
|
||||
return (index / (frameCount - 1)) * VIEWBOX_WIDTH;
|
||||
@@ -21,10 +40,12 @@ function xAt(index: number, frameCount: number): number {
|
||||
|
||||
export function LidarLocalSurfaceTimeline({
|
||||
timeline,
|
||||
review,
|
||||
selectedFrameIndex,
|
||||
onSelectFrame,
|
||||
}: {
|
||||
timeline: Timeline;
|
||||
review: LidarLocalSurfaceReview;
|
||||
selectedFrameIndex: number;
|
||||
onSelectFrame: (frameIndex: number) => void;
|
||||
}) {
|
||||
@@ -62,6 +83,16 @@ export function LidarLocalSurfaceTimeline({
|
||||
(total, value) => total + value,
|
||||
0,
|
||||
);
|
||||
const predictionAttentionFrames = useMemo(
|
||||
() => new Set(
|
||||
review.items
|
||||
.filter((item) =>
|
||||
item.reasons.some((reason) => reason.startsWith("prediction-"))
|
||||
)
|
||||
.map((item) => item.frameIndex),
|
||||
),
|
||||
[review.items],
|
||||
);
|
||||
|
||||
const selectAtPointer = (clientX: number, target: SVGSVGElement) => {
|
||||
const bounds = target.getBoundingClientRect();
|
||||
@@ -130,6 +161,16 @@ export function LidarLocalSurfaceTimeline({
|
||||
className="lidar-local-surface__timeline-line"
|
||||
points={plot.points}
|
||||
/>
|
||||
{[...predictionAttentionFrames].map((frameIndex) => (
|
||||
<line
|
||||
className="lidar-local-surface__timeline-tail"
|
||||
key={`prediction-${frameIndex}`}
|
||||
x1={xAt(frameIndex, timeline.frameCount)}
|
||||
x2={xAt(frameIndex, timeline.frameCount)}
|
||||
y1={PLOT_TOP}
|
||||
y2={PLOT_BOTTOM}
|
||||
/>
|
||||
))}
|
||||
{timeline.temporalJump.map((value, index) =>
|
||||
value === 1 ? (
|
||||
<line
|
||||
@@ -152,9 +193,146 @@ export function LidarLocalSurfaceTimeline({
|
||||
</svg>
|
||||
<footer>
|
||||
<span>начало</span>
|
||||
<span><i /> скачок модели</span>
|
||||
<span><i data-kind="tail" /> prediction tail</span>
|
||||
<span><i data-kind="jump" /> скачок модели</span>
|
||||
<span>конец</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ReviewFilter = "all" | "high" | "prediction" | "surface";
|
||||
|
||||
const REASON_LABELS: Record<LidarLocalSurfaceReviewReason, string> = {
|
||||
"prediction-tail": "локальный хвост prediction",
|
||||
"prediction-inlier-drop": "падение inliers",
|
||||
"surface-height-jump": "скачок высоты",
|
||||
"surface-slope-jump": "скачок уклона",
|
||||
"surface-roughness-jump": "скачок шероховатости",
|
||||
};
|
||||
|
||||
function hasReasonKind(
|
||||
item: LidarLocalSurfaceReviewItem,
|
||||
kind: "prediction" | "surface",
|
||||
): boolean {
|
||||
return item.reasons.some((reason) => reason.startsWith(`${kind}-`));
|
||||
}
|
||||
|
||||
export function LidarLocalSurfaceReviewQueue({
|
||||
review,
|
||||
selectedFrameIndex,
|
||||
onSelectFrame,
|
||||
}: {
|
||||
review: LidarLocalSurfaceReview;
|
||||
selectedFrameIndex: number;
|
||||
onSelectFrame: (frameIndex: number) => void;
|
||||
}) {
|
||||
const [filter, setFilter] = useState<ReviewFilter>("high");
|
||||
const predictionCount = review.items.filter(
|
||||
(item) => hasReasonKind(item, "prediction"),
|
||||
).length;
|
||||
const surfaceCount = review.items.filter(
|
||||
(item) => hasReasonKind(item, "surface"),
|
||||
).length;
|
||||
const visibleItems = review.items.filter((item) => {
|
||||
if (filter === "high") return item.attention === "high";
|
||||
if (filter === "prediction") return hasReasonKind(item, "prediction");
|
||||
if (filter === "surface") return hasReasonKind(item, "surface");
|
||||
return true;
|
||||
});
|
||||
const filters: Array<{ key: ReviewFilter; label: string; count: number }> = [
|
||||
{
|
||||
key: "high",
|
||||
label: "Высокий приоритет",
|
||||
count: review.summary.highAttentionCount,
|
||||
},
|
||||
{ key: "surface", label: "Скачки поверхности", count: surfaceCount },
|
||||
{ key: "prediction", label: "Хвост prediction", count: predictionCount },
|
||||
{ key: "all", label: "Все кадры", count: review.summary.itemCount },
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
className="lidar-local-surface__review"
|
||||
aria-label="Кадры локальной поверхности для разбора"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span>REPLAY TRIAGE · НЕ SAFETY GATE</span>
|
||||
<strong>Кадры для разбора</strong>
|
||||
</div>
|
||||
<small>
|
||||
{review.summary.itemCount} кадров · {review.summary.episodeCount}{" "}
|
||||
{russianPlural(
|
||||
review.summary.episodeCount,
|
||||
"эпизод",
|
||||
"эпизода",
|
||||
"эпизодов",
|
||||
)}
|
||||
</small>
|
||||
</header>
|
||||
<div
|
||||
className="lidar-local-surface__review-filters"
|
||||
role="group"
|
||||
aria-label="Фильтр кадров для разбора"
|
||||
>
|
||||
{filters.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.key}
|
||||
data-active={filter === item.key ? "true" : undefined}
|
||||
onClick={() => setFilter(item.key)}
|
||||
>
|
||||
{item.label} <span>{item.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{visibleItems.length ? (
|
||||
<div className="lidar-local-surface__review-items">
|
||||
{visibleItems.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.frameIndex}
|
||||
data-active={
|
||||
item.frameIndex === selectedFrameIndex ? "true" : undefined
|
||||
}
|
||||
data-attention={item.attention}
|
||||
onClick={() => onSelectFrame(item.frameIndex)}
|
||||
>
|
||||
<span>
|
||||
#{item.rank} · кадр {item.sourceFrameIndex} · {item.episodeId}
|
||||
</span>
|
||||
<strong>
|
||||
{item.reasons.map((reason) => REASON_LABELS[reason]).join(" · ")}
|
||||
</strong>
|
||||
<small>
|
||||
p95 {formatMeters(item.prediction.residualP95M)} м
|
||||
{" · "}
|
||||
inliers {(item.prediction.inlierFraction * 100).toLocaleString(
|
||||
"ru-RU",
|
||||
{ maximumFractionDigits: 1 },
|
||||
)}%
|
||||
{" · "}
|
||||
score {item.attentionScore.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>В этой группе нет кадров.</p>
|
||||
)}
|
||||
<footer>
|
||||
Хвост: p95 ≥ {formatMeters(review.criteria.predictionTailResidualP95M)} м.
|
||||
Падение inliers: ниже{" "}
|
||||
{(review.criteria.predictionInlierFractionFloor * 100).toLocaleString(
|
||||
"ru-RU",
|
||||
{ maximumFractionDigits: 0 },
|
||||
)}%. Список предназначен только для replay-разбора.
|
||||
Переключается только source-aligned LiDAR кадр; верхний camera context
|
||||
остаётся обзором выбранного интервала.
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user