feat: add local surface review triage

This commit is contained in:
DCCONSTRUCTIONS
2026-07-26 00:11:42 +03:00
parent 3449b2bc3e
commit c6c48fbc38
12 changed files with 1273 additions and 33 deletions
@@ -181,6 +181,70 @@ export interface LidarLocalSurfaceTimeline {
authority: LidarLocalSurfaceModel["authority"];
}
export type LidarLocalSurfaceReviewReason =
| "prediction-tail"
| "prediction-inlier-drop"
| "surface-height-jump"
| "surface-slope-jump"
| "surface-roughness-jump";
export interface LidarLocalSurfaceReviewItem {
rank: number;
frameIndex: number;
sourceFrameIndex: number;
sessionSeconds: number;
episodeId: string;
attention: "high" | "review";
attentionScore: number;
reasons: LidarLocalSurfaceReviewReason[];
prediction: {
available: boolean;
residualP50M: number;
residualP95M: number;
inlierFraction: number;
};
temporal: {
compared: boolean;
heightDeltaM: number;
slopeDeltaDeg: number;
roughnessDeltaM: number;
};
surface: {
sensorHeightM: number;
slopeDeg: number;
roughnessM: number;
confidence: number;
};
stepCandidatePointCount: number;
}
export interface LidarLocalSurfaceReview {
reviewProfileId: "missioncore-local-surface-attention/v1";
modelId: string;
sourcePackId: string;
sessionId: string;
available: boolean;
criteria: {
predictionTailResidualP95M: number;
predictionInlierFractionFloor: number;
surfaceHeightJumpM: number;
surfaceSlopeJumpDeg: number;
surfaceRoughnessJumpM: number;
highAttentionScore: number;
episodeMaxFrameGap: number;
};
summary: {
itemCount: number;
episodeCount: number;
highAttentionCount: number;
reviewAttentionCount: number;
reasonCounts: Record<LidarLocalSurfaceReviewReason, number>;
};
items: LidarLocalSurfaceReviewItem[];
groundTruth: false;
authority: LidarLocalSurfaceModel["authority"];
}
export class LidarLocalSurfaceContractError extends Error {}
export class LidarLocalSurfaceApiError extends Error {
@@ -200,6 +264,15 @@ const SAFE_MODEL_ID = new RegExp(`^${LOCAL_SURFACE_MODEL_PREFIX}[a-f0-9]{64}$`);
const SAFE_PACK_ID = /^e10-lidar-pack-[a-f0-9]{64}$/;
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/;
const SAFE_KEY = /^[a-z0-9][a-z0-9-]{0,63}$/;
const SAFE_EPISODE_ID = /^episode-[0-9]{2,4}$/;
const REVIEW_PROFILE_ID = "missioncore-local-surface-attention/v1";
const REVIEW_REASONS = [
"prediction-tail",
"prediction-inlier-drop",
"surface-height-jump",
"surface-slope-jump",
"surface-roughness-jump",
] as const satisfies readonly LidarLocalSurfaceReviewReason[];
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -925,6 +998,356 @@ export function parseLidarLocalSurfaceTimeline(
};
}
export function parseLidarLocalSurfaceReview(
value: unknown,
): LidarLocalSurfaceReview {
const source = record(value, "LiDAR local-surface review");
if (
source.schema_version !== `${LOCAL_SURFACE_SCHEMA_PREFIX}-review/v1`
|| source.review_profile_id !== REVIEW_PROFILE_ID
|| source.access !== "read-only"
|| source.ground_truth !== false
) {
throw new LidarLocalSurfaceContractError(
"LiDAR local-surface review несовместим",
);
}
const available = boolean(source.available, "available");
const criteriaSource = record(source.criteria, "criteria");
const criteria = {
predictionTailResidualP95M: finite(
criteriaSource.prediction_tail_residual_p95_m,
"criteria.prediction_tail_residual_p95_m",
),
predictionInlierFractionFloor: finite(
criteriaSource.prediction_inlier_fraction_floor,
"criteria.prediction_inlier_fraction_floor",
),
surfaceHeightJumpM: finite(
criteriaSource.surface_height_jump_m,
"criteria.surface_height_jump_m",
),
surfaceSlopeJumpDeg: finite(
criteriaSource.surface_slope_jump_deg,
"criteria.surface_slope_jump_deg",
),
surfaceRoughnessJumpM: finite(
criteriaSource.surface_roughness_jump_m,
"criteria.surface_roughness_jump_m",
),
highAttentionScore: finite(
criteriaSource.high_attention_score,
"criteria.high_attention_score",
),
episodeMaxFrameGap: integer(
criteriaSource.episode_max_frame_gap,
"criteria.episode_max_frame_gap",
),
};
if (
criteria.predictionTailResidualP95M <= 0
|| criteria.predictionInlierFractionFloor <= 0
|| criteria.predictionInlierFractionFloor >= 1
|| criteria.surfaceHeightJumpM <= 0
|| criteria.surfaceSlopeJumpDeg <= 0
|| criteria.surfaceRoughnessJumpM <= 0
|| criteria.highAttentionScore <= 1
|| criteria.episodeMaxFrameGap < 1
|| criteria.episodeMaxFrameGap > 100
) {
throw new LidarLocalSurfaceContractError(
"LiDAR local-surface review criteria несовместимы",
);
}
const summarySource = record(source.summary, "summary");
const reasonCountsSource = record(
summarySource.reason_counts,
"summary.reason_counts",
);
const reasonCounts: Record<LidarLocalSurfaceReviewReason, number> = {
"prediction-tail": integer(
reasonCountsSource["prediction-tail"],
"reason_counts.prediction-tail",
),
"prediction-inlier-drop": integer(
reasonCountsSource["prediction-inlier-drop"],
"reason_counts.prediction-inlier-drop",
),
"surface-height-jump": integer(
reasonCountsSource["surface-height-jump"],
"reason_counts.surface-height-jump",
),
"surface-slope-jump": integer(
reasonCountsSource["surface-slope-jump"],
"reason_counts.surface-slope-jump",
),
"surface-roughness-jump": integer(
reasonCountsSource["surface-roughness-jump"],
"reason_counts.surface-roughness-jump",
),
};
const summary = {
itemCount: integer(summarySource.item_count, "summary.item_count"),
episodeCount: integer(summarySource.episode_count, "summary.episode_count"),
highAttentionCount: integer(
summarySource.high_attention_count,
"summary.high_attention_count",
),
reviewAttentionCount: integer(
summarySource.review_attention_count,
"summary.review_attention_count",
),
reasonCounts,
};
const rawItems = array(source.items, "items");
if (rawItems.length > 10_000) {
throw new LidarLocalSurfaceContractError(
"LiDAR local-surface review слишком большой",
);
}
const items = rawItems.map((value, index): LidarLocalSurfaceReviewItem => {
const item = record(value, `items[${index}]`);
const attention = item.attention;
if (attention !== "high" && attention !== "review") {
throw new LidarLocalSurfaceContractError(
`items[${index}].attention: несовместимое значение`,
);
}
const reasons = array(item.reasons, `items[${index}].reasons`).map(
(reason, reasonIndex): LidarLocalSurfaceReviewReason => {
if (
typeof reason !== "string"
|| !REVIEW_REASONS.includes(
reason as LidarLocalSurfaceReviewReason,
)
) {
throw new LidarLocalSurfaceContractError(
`items[${index}].reasons[${reasonIndex}]: неизвестная причина`,
);
}
return reason as LidarLocalSurfaceReviewReason;
},
);
if (!reasons.length || new Set(reasons).size !== reasons.length) {
throw new LidarLocalSurfaceContractError(
`items[${index}].reasons: несовместимый набор`,
);
}
const predictionSource = record(
item.prediction,
`items[${index}].prediction`,
);
const temporalSource = record(
item.temporal,
`items[${index}].temporal`,
);
const surfaceSource = record(item.surface, `items[${index}].surface`);
const predictionAvailable = boolean(
predictionSource.available,
`items[${index}].prediction.available`,
);
const temporalCompared = boolean(
temporalSource.compared,
`items[${index}].temporal.compared`,
);
const predictionInlierFraction = finite(
predictionSource.inlier_fraction,
`items[${index}].prediction.inlier_fraction`,
);
const predictionResidualP50M = finite(
predictionSource.residual_p50_m,
`items[${index}].prediction.residual_p50_m`,
);
const predictionResidualP95M = finite(
predictionSource.residual_p95_m,
`items[${index}].prediction.residual_p95_m`,
);
const heightDeltaM = finite(
temporalSource.height_delta_m,
`items[${index}].temporal.height_delta_m`,
);
const slopeDeltaDeg = finite(
temporalSource.slope_delta_deg,
`items[${index}].temporal.slope_delta_deg`,
);
const roughnessDeltaM = finite(
temporalSource.roughness_delta_m,
`items[${index}].temporal.roughness_delta_m`,
);
const surfaceConfidence = finite(
surfaceSource.confidence,
`items[${index}].surface.confidence`,
);
const attentionScore = finite(
item.attention_score,
`items[${index}].attention_score`,
);
const expectedReasons: LidarLocalSurfaceReviewReason[] = [];
const expectedRatios: number[] = [];
if (
predictionAvailable
&& predictionResidualP95M >= criteria.predictionTailResidualP95M
) {
expectedReasons.push("prediction-tail");
expectedRatios.push(
predictionResidualP95M / criteria.predictionTailResidualP95M,
);
}
if (
predictionAvailable
&& predictionInlierFraction < criteria.predictionInlierFractionFloor
) {
expectedReasons.push("prediction-inlier-drop");
expectedRatios.push(
(1 - predictionInlierFraction)
/ (1 - criteria.predictionInlierFractionFloor),
);
}
if (temporalCompared && heightDeltaM >= criteria.surfaceHeightJumpM) {
expectedReasons.push("surface-height-jump");
expectedRatios.push(heightDeltaM / criteria.surfaceHeightJumpM);
}
if (temporalCompared && slopeDeltaDeg >= criteria.surfaceSlopeJumpDeg) {
expectedReasons.push("surface-slope-jump");
expectedRatios.push(slopeDeltaDeg / criteria.surfaceSlopeJumpDeg);
}
if (
temporalCompared
&& roughnessDeltaM >= criteria.surfaceRoughnessJumpM
) {
expectedReasons.push("surface-roughness-jump");
expectedRatios.push(roughnessDeltaM / criteria.surfaceRoughnessJumpM);
}
const expectedAttentionScore = Math.max(...expectedRatios);
if (
predictionInlierFraction < 0
|| predictionInlierFraction > 1
|| surfaceConfidence < 0
|| surfaceConfidence > 1
|| attentionScore < 1
|| (attention === "high")
!== (attentionScore >= criteria.highAttentionScore)
|| (
reasons.some((reason) => reason.startsWith("prediction-"))
&& !predictionAvailable
)
|| (
reasons.some((reason) => reason.startsWith("surface-"))
&& !temporalCompared
)
|| reasons.join("|") !== expectedReasons.join("|")
|| !Number.isFinite(expectedAttentionScore)
|| Math.abs(attentionScore - expectedAttentionScore) > 1e-9
) {
throw new LidarLocalSurfaceContractError(
`items[${index}]: attention evidence несовместим`,
);
}
return {
rank: integer(item.rank, `items[${index}].rank`),
frameIndex: integer(item.frame_index, `items[${index}].frame_index`),
sourceFrameIndex: integer(
item.source_frame_index,
`items[${index}].source_frame_index`,
),
sessionSeconds: finite(
item.session_seconds,
`items[${index}].session_seconds`,
),
episodeId: text(
item.episode_id,
`items[${index}].episode_id`,
SAFE_EPISODE_ID,
),
attention,
attentionScore,
reasons,
prediction: {
available: predictionAvailable,
residualP50M: predictionResidualP50M,
residualP95M: predictionResidualP95M,
inlierFraction: predictionInlierFraction,
},
temporal: {
compared: temporalCompared,
heightDeltaM,
slopeDeltaDeg,
roughnessDeltaM,
},
surface: {
sensorHeightM: finite(
surfaceSource.sensor_height_m,
`items[${index}].surface.sensor_height_m`,
),
slopeDeg: finite(
surfaceSource.slope_deg,
`items[${index}].surface.slope_deg`,
),
roughnessM: finite(
surfaceSource.roughness_m,
`items[${index}].surface.roughness_m`,
),
confidence: surfaceConfidence,
},
stepCandidatePointCount: integer(
item.step_candidate_point_count,
`items[${index}].step_candidate_point_count`,
),
};
});
const observedReasonCounts: Record<LidarLocalSurfaceReviewReason, number> = {
"prediction-tail": 0,
"prediction-inlier-drop": 0,
"surface-height-jump": 0,
"surface-slope-jump": 0,
"surface-roughness-jump": 0,
};
for (const item of items) {
for (const reason of item.reasons) observedReasonCounts[reason] += 1;
}
if (
summary.itemCount !== items.length
|| summary.highAttentionCount + summary.reviewAttentionCount
!== summary.itemCount
|| summary.highAttentionCount
!== items.filter((item) => item.attention === "high").length
|| summary.episodeCount !== new Set(items.map((item) => item.episodeId)).size
|| !REVIEW_REASONS.every(
(reason) => reasonCounts[reason] === observedReasonCounts[reason],
)
|| (!available && items.length > 0)
|| items.some((item, index) =>
item.rank !== index + 1
|| (
index > 0
&& (
item.attentionScore > items[index - 1].attentionScore
|| (
item.attentionScore === items[index - 1].attentionScore
&& item.frameIndex < items[index - 1].frameIndex
)
)
)
)
) {
throw new LidarLocalSurfaceContractError(
"LiDAR local-surface review content несовместим",
);
}
return {
reviewProfileId: REVIEW_PROFILE_ID,
modelId: text(source.model_id, "model_id", SAFE_MODEL_ID),
sourcePackId: text(source.source_pack_id, "source_pack_id", SAFE_PACK_ID),
sessionId: text(source.session_id, "session_id", SAFE_ID),
available,
criteria,
summary,
items,
groundTruth: false,
authority: authority(source.authority),
};
}
async function responseJson(
response: Response,
fallback: string,
@@ -1015,3 +1438,29 @@ export async function fetchLidarLocalSurfaceTimeline(
),
);
}
export async function fetchLidarLocalSurfaceReview(
modelId: string,
options: { signal?: AbortSignal; fetcher?: LidarFetch } = {},
): Promise<LidarLocalSurfaceReview> {
if (!SAFE_MODEL_ID.test(modelId)) {
throw new LidarLocalSurfaceContractError(
"Некорректный LiDAR local-surface review",
);
}
const fetcher = options.fetcher ?? fetch;
const response = await fetcher(
`/api/v1/lidar/local-surfaces/${modelId}/review`,
{
method: "GET",
headers: { Accept: "application/json" },
signal: options.signal,
},
);
return parseLidarLocalSurfaceReview(
await responseJson(
response,
"Не удалось получить LiDAR local-surface review.",
),
);
}
+134 -1
View File
@@ -2956,13 +2956,19 @@
vector-effect: non-scaling-stroke;
}
.lidar-local-surface__timeline-tail {
stroke: #f0783d;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.lidar-local-surface__timeline-selected {
stroke: rgb(255 255 255 / 0.88);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.lidar-local-surface__timeline footer span:nth-child(2) {
.lidar-local-surface__timeline footer span {
display: flex;
align-items: center;
gap: 0.3rem;
@@ -2975,6 +2981,133 @@
background: #f5c23d;
}
.lidar-local-surface__timeline footer i[data-kind="tail"] {
background: #f0783d;
}
.lidar-local-surface__review {
display: grid;
gap: 0.55rem;
background: rgb(255 255 255 / 0.018);
padding: 0.68rem;
}
.lidar-local-surface__review > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.lidar-local-surface__review > header > div {
display: grid;
gap: 0.12rem;
}
.lidar-local-surface__review > header span,
.lidar-local-surface__review > header small,
.lidar-local-surface__review > footer,
.lidar-local-surface__review > p {
color: var(--nodedc-text-muted);
font-size: 0.56rem;
}
.lidar-local-surface__review > header strong {
color: var(--nodedc-text-primary);
font-size: 0.72rem;
}
.lidar-local-surface__review-filters {
display: flex;
flex-wrap: wrap;
gap: 0.32rem;
}
.lidar-local-surface__review-filters button {
border: 0;
border-radius: 999px;
background: rgb(255 255 255 / 0.035);
color: var(--nodedc-text-muted);
padding: 0.34rem 0.52rem;
font-size: 0.56rem;
}
.lidar-local-surface__review-filters button:hover,
.lidar-local-surface__review-filters button:focus-visible,
.lidar-local-surface__review-filters button[data-active="true"] {
outline: 0;
background: rgb(255 255 255 / 0.09);
color: var(--nodedc-text-primary);
}
.lidar-local-surface__review-filters button span {
margin-left: 0.2rem;
color: var(--nodedc-text-secondary);
}
.lidar-local-surface__review-items {
display: grid;
max-height: 17rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.34rem;
overflow-y: auto;
}
.lidar-local-surface__review-items button {
position: relative;
display: grid;
min-width: 0;
gap: 0.16rem;
border: 0;
border-radius: 0.65rem;
background: rgb(255 255 255 / 0.025);
padding: 0.55rem 1.1rem 0.55rem 0.62rem;
text-align: left;
}
.lidar-local-surface__review-items button::after {
position: absolute;
top: 0.62rem;
right: 0.58rem;
width: 0.35rem;
height: 0.35rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
content: "";
}
.lidar-local-surface__review-items button[data-attention="high"]::after {
background: #f0783d;
}
.lidar-local-surface__review-items button:hover,
.lidar-local-surface__review-items button:focus-visible,
.lidar-local-surface__review-items button[data-active="true"] {
outline: 0;
background: rgb(255 255 255 / 0.075);
}
.lidar-local-surface__review-items span,
.lidar-local-surface__review-items small {
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.54rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.lidar-local-surface__review-items strong {
overflow: hidden;
color: var(--nodedc-text-secondary);
font-size: 0.61rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.lidar-local-surface__review > footer {
line-height: 1.45;
}
.lidar-local-surface__stage {
display: grid;
overflow: hidden;
@@ -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>
);
}
@@ -7,6 +7,8 @@ let server;
let parseLidarLocalSurfaceCatalog;
let parseLidarLocalSurfaceFrame;
let parseLidarLocalSurfaceTimeline;
let parseLidarLocalSurfaceReview;
let fetchLidarLocalSurfaceReview;
let fetchLidarLocalSurfaceTimeline;
let LidarLocalSurfaceContractError;
@@ -237,6 +239,106 @@ function timeline(overrides = {}) {
};
}
function review(overrides = {}) {
return {
schema_version: "missioncore.k1-local-surface-review/v1",
review_profile_id: "missioncore-local-surface-attention/v1",
model_id: modelId,
source_pack_id: sourcePackId,
session_id: "20260720T065719Z_viewer_live",
available: true,
criteria: {
prediction_tail_residual_p95_m: 0.45,
prediction_inlier_fraction_floor: 0.85,
surface_height_jump_m: 0.03,
surface_slope_jump_deg: 0.5,
surface_roughness_jump_m: 0.015,
high_attention_score: 2,
episode_max_frame_gap: 2,
},
summary: {
item_count: 2,
episode_count: 2,
high_attention_count: 1,
review_attention_count: 1,
reason_counts: {
"prediction-tail": 1,
"prediction-inlier-drop": 1,
"surface-height-jump": 1,
"surface-slope-jump": 0,
"surface-roughness-jump": 0,
},
},
items: [
{
rank: 1,
frame_index: 2,
source_frame_index: 1002,
session_seconds: 0.2,
episode_id: "episode-02",
attention: "high",
attention_score: 2.4,
reasons: ["prediction-tail", "prediction-inlier-drop"],
prediction: {
available: true,
residual_p50_m: 0.05,
residual_p95_m: 1.08,
inlier_fraction: 0.64,
},
temporal: {
compared: true,
height_delta_m: 0.01,
slope_delta_deg: 0.1,
roughness_delta_m: 0.002,
},
surface: {
sensor_height_m: 1.3,
slope_deg: 2,
roughness_m: 0.04,
confidence: 0.8,
},
step_candidate_point_count: 13,
},
{
rank: 2,
frame_index: 1,
source_frame_index: 1001,
session_seconds: 0.1,
episode_id: "episode-01",
attention: "review",
attention_score: 1.2,
reasons: ["surface-height-jump"],
prediction: {
available: true,
residual_p50_m: 0.04,
residual_p95_m: 0.2,
inlier_fraction: 0.95,
},
temporal: {
compared: true,
height_delta_m: 0.036,
slope_delta_deg: 0.1,
roughness_delta_m: 0.002,
},
surface: {
sensor_height_m: 1.34,
slope_deg: 2,
roughness_m: 0.04,
confidence: 0.8,
},
step_candidate_point_count: 20,
},
],
ground_truth: false,
access: "read-only",
authority: {
commands_enabled: false,
navigation_or_safety_accepted: false,
},
...overrides,
};
}
before(async () => {
server = await createServer({
appType: "custom",
@@ -247,6 +349,8 @@ before(async () => {
parseLidarLocalSurfaceCatalog,
parseLidarLocalSurfaceFrame,
parseLidarLocalSurfaceTimeline,
parseLidarLocalSurfaceReview,
fetchLidarLocalSurfaceReview,
fetchLidarLocalSurfaceTimeline,
LidarLocalSurfaceContractError,
} = await server.ssrLoadModule("/src/core/lidar/localSurface.ts"));
@@ -276,6 +380,14 @@ test("decodes passive local-surface evidence", () => {
assert.equal(decodedTimeline.frameCount, 4);
assert.deepEqual(decodedTimeline.temporalJump, [0, 0, 1, 0]);
assert.equal(decodedTimeline.predictionResidualP50M[2], 0.05);
const decodedReview = parseLidarLocalSurfaceReview(review());
assert.equal(decodedReview.summary.itemCount, 2);
assert.equal(decodedReview.items[0].attention, "high");
assert.deepEqual(decodedReview.items[0].reasons, [
"prediction-tail",
"prediction-inlier-drop",
]);
});
test("rejects inferred free space", () => {
@@ -318,3 +430,30 @@ test("fetches the complete local-surface timeline read-only", async () => {
}]);
assert.equal(decoded.frameCount, 4);
});
test("fetches a deterministic local-surface review queue read-only", async () => {
const requests = [];
const decoded = await fetchLidarLocalSurfaceReview(modelId, {
fetcher: async (input, init) => {
requests.push({ input: String(input), method: init?.method });
return new Response(JSON.stringify(review()), {
status: 200,
headers: { "Content-Type": "application/json" },
});
},
});
assert.deepEqual(requests, [{
input: `/api/v1/lidar/local-surfaces/${modelId}/review`,
method: "GET",
}]);
assert.equal(decoded.items[0].sourceFrameIndex, 1002);
});
test("rejects a review queue with forged priority", () => {
const forged = review();
forged.items[0].attention = "review";
assert.throws(
() => parseLidarLocalSurfaceReview(forged),
LidarLocalSurfaceContractError,
);
});