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

View File

@ -147,9 +147,10 @@ surface/occupied/unknown evidence. All `526/526` available samples produced a
diagnostic result; no free-space, command, navigation or safety authority is diagnostic result; no free-space, command, navigation or safety authority is
inferred. Leave-current-frame-out qualification produced `525` next-frame inferred. Leave-current-frame-out qualification produced `525` next-frame
samples with `0.038543 m` p50 median residual and `12` strict temporal jumps; samples with `0.038543 m` p50 median residual and `12` strict temporal jumps;
the p95 residual tail remains too large for planner use. The selected scene and the p95 residual tail remains too large for planner use. Read-only replay
clickable complete-recording timeline are visible in triage narrows this to `37` attention frames in `21` episodes and four
**Парк → Диагностика LiDAR**. high-priority frames. The selected scene, clickable complete-recording timeline
and source-frame review queue are visible in **Парк → Диагностика LiDAR**.
The complete RELLIS-3D v1.1 release is now admitted there and its full The complete RELLIS-3D v1.1 release is now admitted there and its full
`2,413`-frame validation split is available in **Полигон → Датасеты**. The `2,413`-frame validation split is available in **Полигон → Датасеты**. The

View File

@ -181,6 +181,70 @@ export interface LidarLocalSurfaceTimeline {
authority: LidarLocalSurfaceModel["authority"]; 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 LidarLocalSurfaceContractError extends Error {}
export class LidarLocalSurfaceApiError 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_PACK_ID = /^e10-lidar-pack-[a-f0-9]{64}$/;
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/; 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_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> { function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) { 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( async function responseJson(
response: Response, response: Response,
fallback: string, 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.",
),
);
}

View File

@ -2956,13 +2956,19 @@
vector-effect: non-scaling-stroke; 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 { .lidar-local-surface__timeline-selected {
stroke: rgb(255 255 255 / 0.88); stroke: rgb(255 255 255 / 0.88);
stroke-width: 1; stroke-width: 1;
vector-effect: non-scaling-stroke; vector-effect: non-scaling-stroke;
} }
.lidar-local-surface__timeline footer span:nth-child(2) { .lidar-local-surface__timeline footer span {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.3rem; gap: 0.3rem;
@ -2975,6 +2981,133 @@
background: #f5c23d; 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 { .lidar-local-surface__stage {
display: grid; display: grid;
overflow: hidden; overflow: hidden;

View File

@ -3,14 +3,19 @@ import { StatusBadge } from "@nodedc/ui-react";
import { import {
fetchLidarLocalSurfaceFrame, fetchLidarLocalSurfaceFrame,
fetchLidarLocalSurfaceReview,
fetchLidarLocalSurfaceTimeline, fetchLidarLocalSurfaceTimeline,
fetchLidarLocalSurfaces, fetchLidarLocalSurfaces,
type LidarLocalSurfaceFrame, type LidarLocalSurfaceFrame,
type LidarLocalSurfaceModel, type LidarLocalSurfaceModel,
type LidarLocalSurfaceReview,
type LidarLocalSurfaceTimeline as Timeline, type LidarLocalSurfaceTimeline as Timeline,
} from "../core/lidar/localSurface"; } from "../core/lidar/localSurface";
import { LidarGroundPointCloud } from "./LidarGroundPointCloud"; import { LidarGroundPointCloud } from "./LidarGroundPointCloud";
import { LidarLocalSurfaceTimeline } from "./LidarLocalSurfaceTimeline"; import {
LidarLocalSurfaceReviewQueue,
LidarLocalSurfaceTimeline,
} from "./LidarLocalSurfaceTimeline";
function formatNumber(value: number | null, digits = 2): string { function formatNumber(value: number | null, digits = 2): string {
if (value === null) return "—"; if (value === null) return "—";
@ -33,6 +38,7 @@ export function LidarLocalSurfacePanel({
const [model, setModel] = useState<LidarLocalSurfaceModel | null>(null); const [model, setModel] = useState<LidarLocalSurfaceModel | null>(null);
const [frame, setFrame] = useState<LidarLocalSurfaceFrame | null>(null); const [frame, setFrame] = useState<LidarLocalSurfaceFrame | null>(null);
const [timeline, setTimeline] = useState<Timeline | null>(null); const [timeline, setTimeline] = useState<Timeline | null>(null);
const [review, setReview] = useState<LidarLocalSurfaceReview | null>(null);
const [selectedFrameIndex, setSelectedFrameIndex] = useState<number | null>( const [selectedFrameIndex, setSelectedFrameIndex] = useState<number | null>(
null, null,
); );
@ -68,6 +74,7 @@ export function LidarLocalSurfacePanel({
setModel(null); setModel(null);
setFrame(null); setFrame(null);
setTimeline(null); setTimeline(null);
setReview(null);
setError(errorMessage(loadError)); setError(errorMessage(loadError));
}) })
.finally(() => { .finally(() => {
@ -83,18 +90,27 @@ export function LidarLocalSurfacePanel({
useEffect(() => { useEffect(() => {
if (!model) { if (!model) {
setTimeline(null); setTimeline(null);
setReview(null);
return; return;
} }
const controller = new AbortController(); const controller = new AbortController();
void fetchLidarLocalSurfaceTimeline(model.modelId, { void Promise.all([
signal: controller.signal, fetchLidarLocalSurfaceTimeline(model.modelId, {
}) signal: controller.signal,
.then((nextTimeline) => { }),
if (!controller.signal.aborted) setTimeline(nextTimeline); fetchLidarLocalSurfaceReview(model.modelId, {
signal: controller.signal,
}),
])
.then(([nextTimeline, nextReview]) => {
if (controller.signal.aborted) return;
setTimeline(nextTimeline);
setReview(nextReview);
}) })
.catch((loadError) => { .catch((loadError) => {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
setTimeline(null); setTimeline(null);
setReview(null);
setError(errorMessage(loadError)); setError(errorMessage(loadError));
}); });
return () => controller.abort(); return () => controller.abort();
@ -143,6 +159,12 @@ export function LidarLocalSurfacePanel({
}, },
}; };
}, [frame]); }, [frame]);
const selectedReviewItem = useMemo(
() => review?.items.find(
(item) => item.frameIndex === selectedFrameIndex,
) ?? null,
[review, selectedFrameIndex],
);
if (!model && !loading && !error) { if (!model && !loading && !error) {
return null; return null;
@ -167,7 +189,7 @@ export function LidarLocalSurfacePanel({
tone={ tone={
error error
? "danger" ? "danger"
: frame?.temporal.jump : selectedReviewItem
? "warning" ? "warning"
: frame?.valid : frame?.valid
? "success" ? "success"
@ -176,8 +198,10 @@ export function LidarLocalSurfacePanel({
> >
{error {error
? "Недоступно" ? "Недоступно"
: frame?.temporal.jump : selectedReviewItem
? "Temporal jump" ? selectedReviewItem.attention === "high"
? "Высокий приоритет"
: "Требует разбора"
: frame?.valid : frame?.valid
? "Кадр рассчитан" ? "Кадр рассчитан"
: "Диагностический режим"} : "Диагностический режим"}
@ -226,12 +250,20 @@ export function LidarLocalSurfacePanel({
</div> </div>
) : null} ) : null}
{timeline && selectedFrameIndex !== null ? ( {timeline && review && selectedFrameIndex !== null ? (
<LidarLocalSurfaceTimeline <>
timeline={timeline} <LidarLocalSurfaceTimeline
selectedFrameIndex={selectedFrameIndex} timeline={timeline}
onSelectFrame={setSelectedFrameIndex} review={review}
/> selectedFrameIndex={selectedFrameIndex}
onSelectFrame={setSelectedFrameIndex}
/>
<LidarLocalSurfaceReviewQueue
review={review}
selectedFrameIndex={selectedFrameIndex}
onSelectFrame={setSelectedFrameIndex}
/>
</>
) : null} ) : null}
<div className="lidar-local-surface__stage"> <div className="lidar-local-surface__stage">

View File

@ -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_WIDTH = 1000;
const VIEWBOX_HEIGHT = 168; 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 { function xAt(index: number, frameCount: number): number {
if (frameCount <= 1) return 0; if (frameCount <= 1) return 0;
return (index / (frameCount - 1)) * VIEWBOX_WIDTH; return (index / (frameCount - 1)) * VIEWBOX_WIDTH;
@ -21,10 +40,12 @@ function xAt(index: number, frameCount: number): number {
export function LidarLocalSurfaceTimeline({ export function LidarLocalSurfaceTimeline({
timeline, timeline,
review,
selectedFrameIndex, selectedFrameIndex,
onSelectFrame, onSelectFrame,
}: { }: {
timeline: Timeline; timeline: Timeline;
review: LidarLocalSurfaceReview;
selectedFrameIndex: number; selectedFrameIndex: number;
onSelectFrame: (frameIndex: number) => void; onSelectFrame: (frameIndex: number) => void;
}) { }) {
@ -62,6 +83,16 @@ export function LidarLocalSurfaceTimeline({
(total, value) => total + value, (total, value) => total + value,
0, 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 selectAtPointer = (clientX: number, target: SVGSVGElement) => {
const bounds = target.getBoundingClientRect(); const bounds = target.getBoundingClientRect();
@ -130,6 +161,16 @@ export function LidarLocalSurfaceTimeline({
className="lidar-local-surface__timeline-line" className="lidar-local-surface__timeline-line"
points={plot.points} 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) => {timeline.temporalJump.map((value, index) =>
value === 1 ? ( value === 1 ? (
<line <line
@ -152,9 +193,146 @@ export function LidarLocalSurfaceTimeline({
</svg> </svg>
<footer> <footer>
<span>начало</span> <span>начало</span>
<span><i /> скачок модели</span> <span><i data-kind="tail" /> prediction tail</span>
<span><i data-kind="jump" /> скачок модели</span>
<span>конец</span> <span>конец</span>
</footer> </footer>
</div> </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>
);
}

View File

@ -7,6 +7,8 @@ let server;
let parseLidarLocalSurfaceCatalog; let parseLidarLocalSurfaceCatalog;
let parseLidarLocalSurfaceFrame; let parseLidarLocalSurfaceFrame;
let parseLidarLocalSurfaceTimeline; let parseLidarLocalSurfaceTimeline;
let parseLidarLocalSurfaceReview;
let fetchLidarLocalSurfaceReview;
let fetchLidarLocalSurfaceTimeline; let fetchLidarLocalSurfaceTimeline;
let LidarLocalSurfaceContractError; 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 () => { before(async () => {
server = await createServer({ server = await createServer({
appType: "custom", appType: "custom",
@ -247,6 +349,8 @@ before(async () => {
parseLidarLocalSurfaceCatalog, parseLidarLocalSurfaceCatalog,
parseLidarLocalSurfaceFrame, parseLidarLocalSurfaceFrame,
parseLidarLocalSurfaceTimeline, parseLidarLocalSurfaceTimeline,
parseLidarLocalSurfaceReview,
fetchLidarLocalSurfaceReview,
fetchLidarLocalSurfaceTimeline, fetchLidarLocalSurfaceTimeline,
LidarLocalSurfaceContractError, LidarLocalSurfaceContractError,
} = await server.ssrLoadModule("/src/core/lidar/localSurface.ts")); } = await server.ssrLoadModule("/src/core/lidar/localSurface.ts"));
@ -276,6 +380,14 @@ test("decodes passive local-surface evidence", () => {
assert.equal(decodedTimeline.frameCount, 4); assert.equal(decodedTimeline.frameCount, 4);
assert.deepEqual(decodedTimeline.temporalJump, [0, 0, 1, 0]); assert.deepEqual(decodedTimeline.temporalJump, [0, 0, 1, 0]);
assert.equal(decodedTimeline.predictionResidualP50M[2], 0.05); 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", () => { test("rejects inferred free space", () => {
@ -318,3 +430,30 @@ test("fetches the complete local-surface timeline read-only", async () => {
}]); }]);
assert.equal(decoded.frameCount, 4); 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,
);
});

View File

@ -2,9 +2,9 @@
Date: 2026-07-25 Date: 2026-07-25
Status: accepted architecture plan; L0/L1 implemented; L2 diagnostic A/B Status: accepted architecture plan; L0/L1 implemented; L2 diagnostic A/B
complete; full GOOSE and RELLIS qualification complete; L2.6b K1 replay complete; full GOOSE and RELLIS qualification complete; L2.6c K1 replay
local-surface temporal qualification implemented; operator review and live local-surface temporal qualification and operator triage implemented;
shadow next residual explainability and live shadow next
Scope: passively received real-time K1 point/pose evidence, immutable replay and Scope: passively received real-time K1 point/pose evidence, immutable replay and
future live shadow processing future live shadow processing
Explicitly out of scope: K1 firmware modification, a new onboard exporter, new Explicitly out of scope: K1 firmware modification, a new onboard exporter, new
@ -411,6 +411,12 @@ Dataset expansion is no longer the next gate.
layer with source, confidence, freshness and conflict fields. layer with source, confidence, freshness and conflict fields.
- [x] Qualify next-frame prediction and temporal stability with the evaluated - [x] Qualify next-frame prediction and temporal stability with the evaluated
frame excluded from prediction input. frame excluded from prediction input.
- [x] Publish a deterministic, read-only operator queue for temporal
transitions and heavy prediction tails, with source-frame navigation and no
safety authority.
- [ ] Preserve the prior prediction plane and point/cell-aligned residual
evidence so a selected tail can be explained spatially instead of only by an
aggregate p95.
- [ ] Complete the remaining qualification report with per-frame latency, - [ ] Complete the remaining qualification report with per-frame latency,
point age, obstacle preservation and memory growth. point age, obstacle preservation and memory growth.
- [ ] Replay the same profiles through a bounded latest-wins live-shadow queue; - [ ] Replay the same profiles through a bounded latest-wins live-shadow queue;
@ -418,8 +424,8 @@ Dataset expansion is no longer the next gate.
The implemented `missioncore.k1-local-surface/v1` derivative is reproducible The implemented `missioncore.k1-local-surface/v1` derivative is reproducible
through `experiments/perception/run_k1_local_surface.py` and is exposed through `experiments/perception/run_k1_local_surface.py` and is exposed
read-only through `GET /api/v1/lidar/local-surfaces` plus the bound frame read-only through `GET /api/v1/lidar/local-surfaces` plus the bound frame,
and timeline endpoints. **Парк → Диагностика LiDAR** reuses the five timeline and review endpoints. **Парк → Диагностика LiDAR** reuses the five
RAVNOVES00 scene selectors and also exposes a clickable timeline over the RAVNOVES00 scene selectors and also exposes a clickable timeline over the
complete recording. The selected source frame shows observed surface, observed complete recording. The selected source frame shows observed surface, observed
occupied-above-surface, negative outlier, unclassified evidence and yellow occupied-above-surface, negative outlier, unclassified evidence and yellow
@ -451,6 +457,26 @@ which proves that the review layer is active but also that it is broad and
still requires independent review. Free space remains unavailable; none of still requires independent review. Free space remains unavailable; none of
these metrics grants navigation, command or safety authority. these metrics grants navigation, command or safety authority.
The `missioncore.k1-local-surface-review/v1` triage contract selects a frame
when its prediction p95 residual is at least `0.45 m`, prediction inlier
fraction falls below `85%`, or one of the content-bound temporal thresholds is
crossed. It groups observations separated by no more than two frames into one
episode and ranks threshold exceedance without changing the source artifact.
RAVNOVES00 produced `37` review frames in `21` episodes: `18` prediction-tail
frames, `14` inlier drops and `12` height transitions. Four frames have a
normalized attention score of at least `2.0`.
The four highest-priority frames are concentrated in two episodes. Source
frame `1195` crosses height, slope and roughness thresholds together; the new
surface regime remains on `1196`, so the evidence is a sustained transition,
not merely a one-frame numerical spike. Source frames `12531254` reach
`1.0471.083 m` p95 residual and `68.063.3%` inliers while the fitted global
surface remains stable. This separates a local prediction-tail problem from a
global plane-transition problem. The recording has no independent ground truth
for either episode, so the UI calls them review evidence rather than algorithm
failures. The next replay slice must retain and display the prior-plane
cell/point residuals before changing fit thresholds or entering live shadow.
Exit: one immutable K1 session yields both a persistent reconstruction and a Exit: one immutable K1 session yields both a persistent reconstruction and a
bounded local world state without hard-coded terrain height or scanner-side bounded local world state without hard-coded terrain height or scanner-side
changes. changes.
@ -536,8 +562,9 @@ enough for the current decision. The first K1 local-surface replay slice now
covers all available `RAVNOVES00` samples and is visible in the operator covers all available `RAVNOVES00` samples and is visible in the operator
interface. Leave-current-frame-out temporal qualification now covers `525` interface. Leave-current-frame-out temporal qualification now covers `525`
samples: the median surface error is stable, while the p95 tail remains too samples: the median surface error is stable, while the p95 tail remains too
large for a free-space claim. The highest-value immediate work is review of the large for a free-space claim. Deterministic triage has reduced the first manual
`12` temporal jumps and worst prediction tails, then a bounded live-shadow inspection set to four high-priority frames in two episodes. The highest-value
queue and separate dynamic-observation layer. Nvblox, raw-scan detectors and immediate work is prior-plane residual explainability for those episodes, then
alternative SLAM remain optional later gates because the current report a bounded live-shadow queue and separate dynamic-observation layer. Nvblox,
contract does not carry their required ray/timing semantics. raw-scan detectors and alternative SLAM remain optional later gates because the
current report contract does not carry their required ray/timing semantics.

View File

@ -51,6 +51,9 @@ Implemented now:
explicit pose-binding age and conservative observed occupied/unknown policy; explicit pose-binding age and conservative observed occupied/unknown policy;
- leave-current-frame-out next-frame qualification over `525` samples, - leave-current-frame-out next-frame qualification over `525` samples,
temporal jump evidence and unverified local-discontinuity candidates; temporal jump evidence and unverified local-discontinuity candidates;
- deterministic `missioncore.k1-local-surface-review/v1` replay triage:
`37` attention frames grouped into `21` episodes, with four
high-priority frames and direct source-frame navigation;
- a provider-neutral read-only local-surface view in - a provider-neutral read-only local-surface view in
**Парк → Диагностика LiDAR**, synchronized to the five existing **Парк → Диагностика LiDAR**, synchronized to the five existing
RAVNOVES00 scene selectors and a clickable complete-recording timeline; RAVNOVES00 scene selectors and a clickable complete-recording timeline;
@ -86,6 +89,8 @@ Not implemented:
- no RELLIS ROS bag admission, continuous synchronized playback or production - no RELLIS ROS bag admission, continuous synchronized playback or production
promotion; promotion;
- no ray-cleared free-space or planner-authoritative rolling occupancy map. - no ray-cleared free-space or planner-authoritative rolling occupancy map.
- no point/cell-aligned prior-plane residual overlay yet; aggregate tail
evidence is not enough to identify its physical cause.
## Product surface boundary ## Product surface boundary

View File

@ -110,6 +110,7 @@ from .lidar_local_surface import (
DEFAULT_K1_LOCAL_SURFACE_PROFILE, DEFAULT_K1_LOCAL_SURFACE_PROFILE,
K1_LOCAL_SURFACE_FRAME_SCHEMA, K1_LOCAL_SURFACE_FRAME_SCHEMA,
K1_LOCAL_SURFACE_REPORT_SCHEMA, K1_LOCAL_SURFACE_REPORT_SCHEMA,
K1_LOCAL_SURFACE_REVIEW_SCHEMA,
K1_LOCAL_SURFACE_SCHEMA, K1_LOCAL_SURFACE_SCHEMA,
K1_LOCAL_SURFACE_TIMELINE_SCHEMA, K1_LOCAL_SURFACE_TIMELINE_SCHEMA,
K1LocalSurfaceProfile, K1LocalSurfaceProfile,
@ -209,6 +210,7 @@ __all__ = [
"LIDAR_GROUND_FRAME_SCHEMA", "LIDAR_GROUND_FRAME_SCHEMA",
"K1_LOCAL_SURFACE_FRAME_SCHEMA", "K1_LOCAL_SURFACE_FRAME_SCHEMA",
"K1_LOCAL_SURFACE_REPORT_SCHEMA", "K1_LOCAL_SURFACE_REPORT_SCHEMA",
"K1_LOCAL_SURFACE_REVIEW_SCHEMA",
"K1_LOCAL_SURFACE_SCHEMA", "K1_LOCAL_SURFACE_SCHEMA",
"K1_LOCAL_SURFACE_TIMELINE_SCHEMA", "K1_LOCAL_SURFACE_TIMELINE_SCHEMA",
"LIDAR_FIELD_REVIEW_REPORT_SCHEMA", "LIDAR_FIELD_REVIEW_REPORT_SCHEMA",

View File

@ -11,7 +11,7 @@ from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any, Final from typing import Any, Final, cast
import numpy as np import numpy as np
import numpy.typing as npt import numpy.typing as npt
@ -29,9 +29,14 @@ K1_LOCAL_SURFACE_SCHEMA: Final = "missioncore.k1-local-surface/v1"
K1_LOCAL_SURFACE_REPORT_SCHEMA: Final = "missioncore.k1-local-surface-report/v1" K1_LOCAL_SURFACE_REPORT_SCHEMA: Final = "missioncore.k1-local-surface-report/v1"
K1_LOCAL_SURFACE_FRAME_SCHEMA: Final = "missioncore.k1-local-surface-frame/v1" K1_LOCAL_SURFACE_FRAME_SCHEMA: Final = "missioncore.k1-local-surface-frame/v1"
K1_LOCAL_SURFACE_TIMELINE_SCHEMA: Final = "missioncore.k1-local-surface-timeline/v1" K1_LOCAL_SURFACE_TIMELINE_SCHEMA: Final = "missioncore.k1-local-surface-timeline/v1"
K1_LOCAL_SURFACE_REVIEW_SCHEMA: Final = "missioncore.k1-local-surface-review/v1"
K1_LOCAL_SURFACE_ARRAYS_NAME: Final = "local-surface.npz" K1_LOCAL_SURFACE_ARRAYS_NAME: Final = "local-surface.npz"
K1_LOCAL_SURFACE_REPORT_NAME: Final = "local-surface.json" K1_LOCAL_SURFACE_REPORT_NAME: Final = "local-surface.json"
K1_LOCAL_SURFACE_MANIFEST_NAME: Final = "manifest.json" K1_LOCAL_SURFACE_MANIFEST_NAME: Final = "manifest.json"
K1_LOCAL_SURFACE_REVIEW_PROFILE_ID: Final = "missioncore-local-surface-attention/v1"
K1_LOCAL_SURFACE_REVIEW_TAIL_M: Final = 0.45
K1_LOCAL_SURFACE_REVIEW_INLIER_FLOOR: Final = 0.85
K1_LOCAL_SURFACE_REVIEW_HIGH_SCORE: Final = 2.0
POINT_UNCLASSIFIED: Final = 0 POINT_UNCLASSIFIED: Final = 0
POINT_SURFACE: Final = 1 POINT_SURFACE: Final = 1
@ -631,6 +636,195 @@ class K1LocalSurfaceV1:
"authority": self.report["authority"], "authority": self.report["authority"],
} }
def review_detail(self, source: E10LidarFieldSource) -> dict[str, object]:
_validate_source_binding(self, source)
criteria = self._review_criteria()
reason_counts = {
"prediction-tail": 0,
"prediction-inlier-drop": 0,
"surface-height-jump": 0,
"surface-slope-jump": 0,
"surface-roughness-jump": 0,
}
if not self.has_temporal_qualification:
return {
"schema_version": K1_LOCAL_SURFACE_REVIEW_SCHEMA,
"review_profile_id": K1_LOCAL_SURFACE_REVIEW_PROFILE_ID,
"model_id": self.model_id,
"source_pack_id": source.pack_id,
"session_id": source.identity["session_id"],
"available": False,
"criteria": criteria,
"summary": {
"item_count": 0,
"episode_count": 0,
"high_attention_count": 0,
"review_attention_count": 0,
"reason_counts": reason_counts,
},
"items": [],
"ground_truth": False,
"access": "read-only",
"authority": self.report["authority"],
}
tail_threshold = float(criteria["prediction_tail_residual_p95_m"])
inlier_floor = float(criteria["prediction_inlier_fraction_floor"])
height_threshold = float(criteria["surface_height_jump_m"])
slope_threshold = float(criteria["surface_slope_jump_deg"])
roughness_threshold = float(criteria["surface_roughness_jump_m"])
chronological: list[dict[str, object]] = []
last_review_frame: int | None = None
episode_index = 0
for frame_index in range(source.frame_count):
reasons: list[str] = []
ratios: list[float] = []
prediction_available = bool(
self.arrays["prediction_available"][frame_index]
)
prediction_p95 = float(
self.arrays["prediction_residual_p95_m"][frame_index]
)
prediction_inlier = float(
self.arrays["prediction_inlier_fraction"][frame_index]
)
if prediction_available and prediction_p95 >= tail_threshold:
reasons.append("prediction-tail")
ratios.append(prediction_p95 / tail_threshold)
if prediction_available and prediction_inlier < inlier_floor:
reasons.append("prediction-inlier-drop")
ratios.append((1.0 - prediction_inlier) / (1.0 - inlier_floor))
temporal_compared = bool(self.arrays["temporal_compared"][frame_index])
height_delta = float(self.arrays["height_delta_m"][frame_index])
slope_delta = float(self.arrays["slope_delta_deg"][frame_index])
roughness_delta = float(self.arrays["roughness_delta_m"][frame_index])
if temporal_compared and height_delta >= height_threshold:
reasons.append("surface-height-jump")
ratios.append(height_delta / height_threshold)
if temporal_compared and slope_delta >= slope_threshold:
reasons.append("surface-slope-jump")
ratios.append(slope_delta / slope_threshold)
if temporal_compared and roughness_delta >= roughness_threshold:
reasons.append("surface-roughness-jump")
ratios.append(roughness_delta / roughness_threshold)
if not reasons:
continue
if last_review_frame is None or frame_index - last_review_frame > 2:
episode_index += 1
last_review_frame = frame_index
for reason in reasons:
reason_counts[reason] += 1
score = max(ratios)
chronological.append(
{
"rank": 0,
"frame_index": frame_index,
"source_frame_index": int(
source.arrays["source_frame_indices"][frame_index]
),
"session_seconds": float(
source.arrays["session_seconds"][frame_index]
),
"episode_id": f"episode-{episode_index:02d}",
"attention": (
"high"
if score >= K1_LOCAL_SURFACE_REVIEW_HIGH_SCORE
else "review"
),
"attention_score": score,
"reasons": reasons,
"prediction": {
"available": prediction_available,
"residual_p50_m": float(
self.arrays["prediction_residual_p50_m"][frame_index]
),
"residual_p95_m": prediction_p95,
"inlier_fraction": prediction_inlier,
},
"temporal": {
"compared": temporal_compared,
"height_delta_m": height_delta,
"slope_delta_deg": slope_delta,
"roughness_delta_m": roughness_delta,
},
"surface": {
"sensor_height_m": float(
self.arrays["sensor_height_m"][frame_index]
),
"slope_deg": float(self.arrays["slope_deg"][frame_index]),
"roughness_m": float(
self.arrays["roughness_m"][frame_index]
),
"confidence": float(
self.arrays["confidence"][frame_index]
),
},
"step_candidate_point_count": int(
self.arrays["step_candidate_point_count"][frame_index]
),
}
)
items = sorted(
chronological,
key=lambda item: (
-cast(float, item["attention_score"]),
cast(int, item["frame_index"]),
),
)
for rank, item in enumerate(items, start=1):
item["rank"] = rank
high_attention_count = sum(
item["attention"] == "high" for item in items
)
return {
"schema_version": K1_LOCAL_SURFACE_REVIEW_SCHEMA,
"review_profile_id": K1_LOCAL_SURFACE_REVIEW_PROFILE_ID,
"model_id": self.model_id,
"source_pack_id": source.pack_id,
"session_id": source.identity["session_id"],
"available": True,
"criteria": criteria,
"summary": {
"item_count": len(items),
"episode_count": episode_index,
"high_attention_count": high_attention_count,
"review_attention_count": len(items) - high_attention_count,
"reason_counts": reason_counts,
},
"items": items,
"ground_truth": False,
"access": "read-only",
"authority": self.report["authority"],
}
def _review_criteria(self) -> dict[str, float | int]:
profile = _object(self.identity.get("profile"), "K1 local-surface profile")
temporal = _object(
profile.get("temporal_qualification"),
"K1 local-surface temporal profile",
)
return {
"prediction_tail_residual_p95_m": K1_LOCAL_SURFACE_REVIEW_TAIL_M,
"prediction_inlier_fraction_floor": (
K1_LOCAL_SURFACE_REVIEW_INLIER_FLOOR
),
"surface_height_jump_m": _positive_number(
temporal.get("height_jump_m"),
"K1 local-surface height jump threshold",
),
"surface_slope_jump_deg": _positive_number(
temporal.get("slope_jump_deg"),
"K1 local-surface slope jump threshold",
),
"surface_roughness_jump_m": _positive_number(
temporal.get("roughness_jump_m"),
"K1 local-surface roughness jump threshold",
),
"high_attention_score": K1_LOCAL_SURFACE_REVIEW_HIGH_SCORE,
"episode_max_frame_gap": 2,
}
def build_k1_local_surface( def build_k1_local_surface(
source: E10LidarFieldSource, source: E10LidarFieldSource,
@ -1479,6 +1673,17 @@ def _nonnegative_int(value: object, label: str) -> int:
return value return value
def _positive_number(value: object, label: str) -> float:
if (
not isinstance(value, (int, float))
or isinstance(value, bool)
or not math.isfinite(value)
or value <= 0.0
):
raise LidarGroundError(f"{label} is invalid")
return float(value)
def _sha256(path: Path) -> str: def _sha256(path: Path) -> str:
digest = hashlib.sha256() digest = hashlib.sha256()
with path.open("rb") as stream: with path.open("rb") as stream:

View File

@ -600,6 +600,61 @@ def build_lidar_router(
detail="K1 local-surface timeline не прошёл проверку целостности", detail="K1 local-surface timeline не прошёл проверку целостности",
) from exc ) from exc
@router.get("/local-surfaces/{model_id}/review")
def get_k1_local_surface_review(model_id: str) -> dict[str, object]:
if _LOCAL_SURFACE_ID.fullmatch(model_id) is None:
raise HTTPException(
status_code=404,
detail="K1 local-surface review не найден",
)
model_root = local_surface_root_provider()
source_root = e10_source_root_provider()
if model_root is None or not model_root.is_dir():
raise HTTPException(
status_code=503,
detail="K1 local-surface storage не настроен",
)
if source_root is None or not source_root.is_dir():
raise HTTPException(
status_code=503,
detail="E10 LiDAR source storage не настроен",
)
model_path = model_root / model_id
if not model_path.is_dir():
raise HTTPException(
status_code=404,
detail="K1 local-surface model не найден",
)
try:
model = K1LocalSurfaceV1(model_path)
try:
source_pack_id = model.identity.get("source_pack_id")
if (
not isinstance(source_pack_id, str)
or _E10_PACK_ID.fullmatch(source_pack_id) is None
):
raise LidarGroundError("K1 local-surface source id is invalid")
source_path = source_root / source_pack_id
if not source_path.is_dir():
raise HTTPException(
status_code=404,
detail="Связанный E10 LiDAR source не найден",
)
source = E10LidarFieldSource(source_path)
try:
return model.review_detail(source)
finally:
source.close()
finally:
model.close()
except HTTPException:
raise
except (LidarGroundError, OSError) as exc:
raise HTTPException(
status_code=409,
detail="K1 local-surface review не прошёл проверку целостности",
) from exc
@router.get("/local-surfaces/{model_id}/frames/{frame_index}") @router.get("/local-surfaces/{model_id}/frames/{frame_index}")
def get_k1_local_surface_frame( def get_k1_local_surface_frame(
model_id: str, model_id: str,

View File

@ -159,6 +159,7 @@ def test_k1_local_surface_is_dynamic_source_bound_and_read_only(
source = E10LidarFieldSource(source_path) source = E10LidarFieldSource(source_path)
try: try:
detail = model.frame_detail(source, 2) detail = model.frame_detail(source, 2)
review = model.review_detail(source)
assert model.report["source"]["passive_processing_only"] is True assert model.report["source"]["passive_processing_only"] is True
assert model.report["source"]["firmware_or_device_commands_used"] is False assert model.report["source"]["firmware_or_device_commands_used"] is False
assert model.report["surface_model"]["hardcoded_height_m"] is None assert model.report["surface_model"]["hardcoded_height_m"] is None
@ -180,8 +181,13 @@ def test_k1_local_surface_is_dynamic_source_bound_and_read_only(
assert detail["prediction"]["available"] is True assert detail["prediction"]["available"] is True
assert detail["temporal"]["compared"] is True assert detail["temporal"]["compared"] is True
assert detail["authority"]["commands_enabled"] is False assert detail["authority"]["commands_enabled"] is False
assert review["available"] is True
assert review["ground_truth"] is False
assert review["criteria"]["prediction_inlier_fraction_floor"] == 0.85
assert review["summary"]["item_count"] == len(review["items"])
assert "1.27" not in repr({"identity": model.identity, "report": model.report}) assert "1.27" not in repr({"identity": model.identity, "report": model.report})
assert str(tmp_path) not in repr(detail) assert str(tmp_path) not in repr(detail)
assert str(tmp_path) not in repr(review)
finally: finally:
source.close() source.close()
model.close() model.close()
@ -202,9 +208,14 @@ def test_k1_local_surface_is_dynamic_source_bound_and_read_only(
router, router,
"/api/v1/lidar/local-surfaces/{model_id}/timeline", "/api/v1/lidar/local-surfaces/{model_id}/timeline",
) )
review_route = _endpoint(
router,
"/api/v1/lidar/local-surfaces/{model_id}/review",
)
catalog = catalog_route(limit=10) # type: ignore[operator] catalog = catalog_route(limit=10) # type: ignore[operator]
frame = frame_route(model_id=output.name, frame_index=2) # type: ignore[operator] frame = frame_route(model_id=output.name, frame_index=2) # type: ignore[operator]
timeline = timeline_route(model_id=output.name) # type: ignore[operator] timeline = timeline_route(model_id=output.name) # type: ignore[operator]
review = review_route(model_id=output.name) # type: ignore[operator]
assert catalog["valid_total"] == 1 assert catalog["valid_total"] == 1
assert catalog["items"][0]["status"] == "diagnostic-only" assert catalog["items"][0]["status"] == "diagnostic-only"
assert frame["model_id"] == output.name assert frame["model_id"] == output.name
@ -213,3 +224,6 @@ def test_k1_local_surface_is_dynamic_source_bound_and_read_only(
assert sum(timeline["prediction_available"]) >= 4 assert sum(timeline["prediction_available"]) >= 4
assert len(timeline["temporal_jump"]) == 8 assert len(timeline["temporal_jump"]) == 8
assert str(tmp_path) not in repr(timeline) assert str(tmp_path) not in repr(timeline)
assert review["review_profile_id"] == "missioncore-local-surface-attention/v1"
assert review["access"] == "read-only"
assert str(tmp_path) not in repr(review)