feat: add local surface review triage
This commit is contained in:
parent
3449b2bc3e
commit
c6c48fbc38
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 `1253–1254` reach
|
||||||
|
`1.047–1.083 m` p95 residual and `68.0–63.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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue