feat(perception): add mixed-route vegetation review
This commit is contained in:
@@ -79,6 +79,33 @@ export interface VegetationRouteVideo {
|
||||
validFovMaskSha256: string | null;
|
||||
}
|
||||
|
||||
export interface VegetationMixedRouteCase {
|
||||
caseId: string;
|
||||
phase: "rural" | "transition" | "urban";
|
||||
sourceSequence: number;
|
||||
sessionSeconds: number;
|
||||
assets: Readonly<Record<"source" | "city" | "vegetation" | "tgs", string>>;
|
||||
tgs: {
|
||||
groundCells: number;
|
||||
occupiedCells: number;
|
||||
rejectedCells: number;
|
||||
unobservedCells: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface VegetationMixedRouteReview {
|
||||
sourceId: "RAVNOVES004TREE";
|
||||
sessionId: string;
|
||||
packId: string;
|
||||
frameCount: 10;
|
||||
models: {
|
||||
city: { name: string; inferenceFps: number; endToEndP95Ms: number };
|
||||
vegetation: { name: string; latencyP95Ms: number };
|
||||
tgs: { name: string; latencyP95Ms: number; cellSizeM: number; radiusM: number };
|
||||
};
|
||||
cases: readonly VegetationMixedRouteCase[];
|
||||
}
|
||||
|
||||
export interface VegetationShadowResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
@@ -88,6 +115,7 @@ export interface VegetationShadowResult {
|
||||
routeCases: readonly VegetationVisualCase[];
|
||||
validationCases: readonly VegetationVisualCase[];
|
||||
routeVideo: VegetationRouteVideo | null;
|
||||
routeReview: VegetationMixedRouteReview | null;
|
||||
limitations: readonly string[];
|
||||
visualShadowReady: true;
|
||||
missionPolicyReadyForConfiguration: true;
|
||||
@@ -423,6 +451,100 @@ function routeVideoValue(value: unknown): VegetationRouteVideo | null {
|
||||
};
|
||||
}
|
||||
|
||||
function mixedRouteReviewValue(
|
||||
value: unknown,
|
||||
resultId: string,
|
||||
endpointRoot: string,
|
||||
): VegetationMixedRouteReview | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const row = objectValue(value, "vegetation.route_review");
|
||||
exact(row.source_id, "RAVNOVES004TREE", "vegetation.route_review.source_id");
|
||||
exact(row.frame_count, 10, "vegetation.route_review.frame_count");
|
||||
exact(row.ground_truth, false, "vegetation.route_review.ground_truth");
|
||||
exact(
|
||||
row.selection_policy,
|
||||
"same-scene-camera-lidar-aligned-review-islands/v1",
|
||||
"vegetation.route_review.selection_policy",
|
||||
);
|
||||
const packId = textValue(row.pack_id, "vegetation.route_review.pack_id");
|
||||
if (!/^mixed-route-review-pack-[a-f0-9]{64}$/.test(packId)) {
|
||||
throw new VegetationShadowContractError("vegetation.route_review.pack_id: identity invalid.");
|
||||
}
|
||||
const models = objectValue(row.models, "vegetation.route_review.models");
|
||||
const city = objectValue(models.city, "vegetation.route_review.models.city");
|
||||
const vegetation = objectValue(models.vegetation, "vegetation.route_review.models.vegetation");
|
||||
const tgsModel = objectValue(models.tgs, "vegetation.route_review.models.tgs");
|
||||
exact(city.frames, 10, "vegetation.route_review.models.city.frames");
|
||||
exact(vegetation.frames, 10, "vegetation.route_review.models.vegetation.frames");
|
||||
exact(tgsModel.frames, 10, "vegetation.route_review.models.tgs.frames");
|
||||
const cases = arrayValue(row.cases, "vegetation.route_review.cases").map((raw, index) => {
|
||||
const item = objectValue(raw, `vegetation.route_review.cases[${index}]`);
|
||||
const caseId = textValue(item.case_id, `vegetation.route_review.cases[${index}].case_id`);
|
||||
if (caseId !== `route-${String(index + 1).padStart(2, "0")}`) {
|
||||
throw new VegetationShadowContractError("vegetation.route_review.case order changed.");
|
||||
}
|
||||
const phaseValue = item.phase;
|
||||
if (phaseValue !== "rural" && phaseValue !== "transition" && phaseValue !== "urban") {
|
||||
throw new VegetationShadowContractError("vegetation.route_review.phase changed.");
|
||||
}
|
||||
const phase: VegetationMixedRouteCase["phase"] = phaseValue;
|
||||
const assets = objectValue(item.assets, `vegetation.route_review.cases[${index}].assets`);
|
||||
const projected = Object.fromEntries(["source", "city", "vegetation", "tgs"].map((key) => {
|
||||
const descriptor = objectValue(assets[key], `vegetation.route_review.assets.${key}`);
|
||||
const path = textValue(descriptor.path, `vegetation.route_review.assets.${key}.path`);
|
||||
const digest = textValue(descriptor.sha256, `vegetation.route_review.assets.${key}.sha256`);
|
||||
if (!SHA256.test(digest) || !path.startsWith(`route-review/${caseId}/`)) {
|
||||
throw new VegetationShadowContractError(`vegetation.route_review.assets.${key}: proof invalid.`);
|
||||
}
|
||||
return [key, `${endpointRoot}/${encodeURIComponent(resultId)}/assets/${path
|
||||
.split("/").map(encodeURIComponent).join("/")}`];
|
||||
})) as Record<"source" | "city" | "vegetation" | "tgs", string>;
|
||||
const tgs = objectValue(item.tgs, `vegetation.route_review.cases[${index}].tgs`);
|
||||
const groundCells = integerValue(tgs.ground_cells, "vegetation.route_review.tgs.ground");
|
||||
const occupiedCells = integerValue(tgs.occupied_cells, "vegetation.route_review.tgs.occupied");
|
||||
const rejectedCells = integerValue(tgs.rejected_cells, "vegetation.route_review.tgs.rejected");
|
||||
const unobservedCells = integerValue(tgs.unobserved_cells, "vegetation.route_review.tgs.unobserved");
|
||||
if (groundCells + occupiedCells + rejectedCells + unobservedCells !== 2244) {
|
||||
throw new VegetationShadowContractError("vegetation.route_review.tgs cell accounting changed.");
|
||||
}
|
||||
return {
|
||||
caseId,
|
||||
phase,
|
||||
sourceSequence: integerValue(item.source_sequence, "vegetation.route_review.source_sequence"),
|
||||
sessionSeconds: numberValue(item.session_seconds, "vegetation.route_review.session_seconds"),
|
||||
assets: projected,
|
||||
tgs: { groundCells, occupiedCells, rejectedCells, unobservedCells },
|
||||
};
|
||||
});
|
||||
if (cases.length !== 10) {
|
||||
throw new VegetationShadowContractError("vegetation.route_review.cases: expected 10 aligned islands.");
|
||||
}
|
||||
return {
|
||||
sourceId: "RAVNOVES004TREE",
|
||||
sessionId: textValue(row.session_id, "vegetation.route_review.session_id"),
|
||||
packId,
|
||||
frameCount: 10,
|
||||
models: {
|
||||
city: {
|
||||
name: textValue(city.name, "vegetation.route_review.models.city.name"),
|
||||
inferenceFps: numberValue(city.inference_fps, "vegetation.route_review.models.city.fps"),
|
||||
endToEndP95Ms: numberValue(city.end_to_end_p95_ms, "vegetation.route_review.models.city.p95"),
|
||||
},
|
||||
vegetation: {
|
||||
name: textValue(vegetation.name, "vegetation.route_review.models.vegetation.name"),
|
||||
latencyP95Ms: numberValue(vegetation.latency_p95_ms, "vegetation.route_review.models.vegetation.p95"),
|
||||
},
|
||||
tgs: {
|
||||
name: textValue(tgsModel.name, "vegetation.route_review.models.tgs.name"),
|
||||
latencyP95Ms: numberValue(tgsModel.latency_p95_ms, "vegetation.route_review.models.tgs.p95"),
|
||||
cellSizeM: numberValue(tgsModel.cell_size_m, "vegetation.route_review.models.tgs.cell"),
|
||||
radiusM: numberValue(tgsModel.radius_m, "vegetation.route_review.models.tgs.radius"),
|
||||
},
|
||||
},
|
||||
cases,
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(
|
||||
value: unknown,
|
||||
resultId: string,
|
||||
@@ -466,7 +588,11 @@ function parseResult(
|
||||
.map((item) => visualCaseValue(item, resultId, "ravnoves", endpointRoot));
|
||||
const validationCases = arrayValue(catalogs.goose, "vegetation.catalogs.goose")
|
||||
.map((item) => visualCaseValue(item, resultId, "goose", endpointRoot));
|
||||
if (routeCases.length !== 0 || validationCases.length !== 12) {
|
||||
const routeReview = mixedRouteReviewValue(payload.route_review, resultId, endpointRoot);
|
||||
if (
|
||||
routeCases.length !== 0
|
||||
|| (routeReview ? validationCases.length !== 0 : validationCases.length !== 12)
|
||||
) {
|
||||
throw new VegetationShadowContractError("vegetation.catalogs: ожидалось 12 truth-backed GOOSE случаев без route viewer.");
|
||||
}
|
||||
return {
|
||||
@@ -478,6 +604,7 @@ function parseResult(
|
||||
routeCases,
|
||||
validationCases,
|
||||
routeVideo: routeVideoValue(payload.route_video),
|
||||
routeReview,
|
||||
limitations: arrayValue(payload.limitations, "vegetation.limitations")
|
||||
.map((item, index) => textValue(item, `vegetation.limitations[${index}]`)),
|
||||
visualShadowReady: true,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon, IconButton, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import {
|
||||
vegetationVideoMaskUrl,
|
||||
type VegetationMixedRouteReview,
|
||||
type VegetationShadowResult,
|
||||
} from "../../core/laboratory/vegetationShadow";
|
||||
import {
|
||||
@@ -21,6 +24,128 @@ function decimal(value: number, digits = 1): string {
|
||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
const MIXED_ROUTE_MODES = [
|
||||
{ value: "source", label: "SOURCE" },
|
||||
{ value: "city", label: "ГОРОД · EoMT" },
|
||||
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
|
||||
{ value: "tgs", label: "TGS" },
|
||||
] as const;
|
||||
|
||||
function MixedRouteReviewEvidence({ review }: { review: VegetationMixedRouteReview }) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [mode, setMode] = useState<typeof MIXED_ROUTE_MODES[number]["value"]>("vegetation");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const item = review.cases[index]!;
|
||||
return (
|
||||
<LaboratoryEvidenceViewer
|
||||
label="RAVNOVES004TREE mixed route review"
|
||||
className="m48-atlas-visual"
|
||||
mode={mode}
|
||||
modes={MIXED_ROUTE_MODES}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
chromeLayout="stacked"
|
||||
actions={(
|
||||
<>
|
||||
<IconButton label="Предыдущая сцена" onClick={() => setIndex((index - 1 + review.cases.length) % review.cases.length)}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Следующая сцена" onClick={() => setIndex((index + 1) % review.cases.length)}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
overlay={(
|
||||
<div className="m48-atlas-visual__case">
|
||||
<StatusBadge tone={item.phase === "urban" ? "accent" : item.phase === "transition" ? "warning" : "neutral"}>
|
||||
{item.phase.toUpperCase()} · {index + 1}/{review.cases.length}
|
||||
</StatusBadge>
|
||||
<strong>sequence {item.sourceSequence} · +{decimal(item.sessionSeconds, 2)} s</strong>
|
||||
<small>
|
||||
TGS: {item.tgs.groundCells} ground · {item.tgs.occupiedCells} occupied · {item.tgs.unobservedCells} unobserved
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="recorded-evidence-image-scene">
|
||||
<img src={item.assets[mode]} alt="" draggable={false} />
|
||||
</div>
|
||||
</LaboratoryEvidenceViewer>
|
||||
);
|
||||
}
|
||||
|
||||
function MixedRouteReviewResult({
|
||||
rigLabel,
|
||||
review,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
review: VegetationMixedRouteReview;
|
||||
}) {
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB V1 · RAVNOVES004TREE · село → город"
|
||||
description="Существующий LAB-шаблон показывает 10 синхронных camera/LiDAR сцен одной записи. EoMT и DDRNet остаются независимыми слоями; TGS показывает отдельную геометрию и не может быть очищен семантической маской."
|
||||
status="BOUNDED RECORDED REVIEW · truth отсутствует · commands OFF"
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{ label: "Источник", value: `${review.sourceId} · ${review.frameCount} camera/LiDAR islands` },
|
||||
{ label: "Переход", value: "5 rural · 1 transition · 4 urban" },
|
||||
{ label: "Слои", value: "SOURCE · EoMT CITY · DDRNet VEGETATION · causal TGS" },
|
||||
{ label: "Authority", value: `${rigLabel} · VISUAL REVIEW ONLY · commands OFF` },
|
||||
]}
|
||||
brief={{
|
||||
question: "Сохраняются ли городская семантика, растительность и геометрия при переходе из сельской среды в город?",
|
||||
approach: "Выбраны десять соседних с исходными сцен camera-кадров, каждый синхронизирован с LiDAR в пределах 100 мс. Все три вычислительных слоя прогнаны на Worker 006 и запечатаны локально.",
|
||||
principalResult: "Все 10 сцен обработаны EoMT, DDRNet и causal TGS. Слои можно переключать без наложения цветов и без зависимости LAB от воркера.",
|
||||
limitation: "Это bounded islands без ручной truth. DDRNet шумит по подтипам растительности; TGS не доказывает обнаружение кювета или отрицательного препятствия.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "ai-inference",
|
||||
pipelineId: "ravnoves004tree-eomt-ddrnet-causal-tgs-review/v1",
|
||||
components: [
|
||||
{ kind: "model", name: review.models.city.name, version: "sealed Worker run", role: "urban semantic review", identitySha256: null },
|
||||
{ kind: "model", name: review.models.vegetation.name, version: "GOOSE DDRNet-39", role: "vegetation semantic review", identitySha256: null },
|
||||
{ kind: "algorithm", name: review.models.tgs.name, version: "TRAVEL compatibility runner", role: "independent local geometry", identitySha256: null },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="M4.7 TEMPLATE · RAVNOVES004TREE"
|
||||
title="SOURCE / ГОРОД / ПРИРОДА / TGS · 10/10 · TRUTH отсутствует"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<MixedRouteReviewEvidence review={review} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Переход село → город воспроизведён; safety gate не закрыт"
|
||||
status="Review ready · navigation/actuation OFF"
|
||||
statusTone="warning"
|
||||
metrics={[
|
||||
{ label: "Aligned scenes", value: "10/10", hint: "camera + LiDAR + pose · автономный archive" },
|
||||
{ label: "EoMT end-to-end p95", value: `${decimal(review.models.city.endToEndP95Ms, 2)} ms`, hint: `${decimal(review.models.city.inferenceFps, 2)} fps в изолированном прогоне` },
|
||||
{ label: "DDRNet inference p95", value: `${decimal(review.models.vegetation.latencyP95Ms, 2)} ms`, hint: "candidate review · не совместный realtime stack" },
|
||||
{ label: "TGS p95", value: `${decimal(review.models.tgs.latencyP95Ms, 2)} ms`, hint: `${review.models.tgs.cellSizeM} m cells · ${review.models.tgs.radiusM} m radius` },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "Оба semantic слоя и causal TGS воспроизводимо работают на сельской, переходной и городской части новой записи.",
|
||||
notProved: "Не доказаны accuracy без truth, временная стабильность по всему видео, детект кюветов и безопасное совместное realtime-управление ровером.",
|
||||
decision: "Оставить navigation/actuation OFF. Следующий короткий gate — непрерывный realtime-load двух моделей плюс независимый person/vehicle STOP; кюветы проверять отдельной записью.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function VegetationRouteEvidence({ result }: { result: VegetationShadowResult }) {
|
||||
const route = result.routeVideo!;
|
||||
const [tgs, setTgs] = useState<M49TgsFullShadowResult | null>(null);
|
||||
@@ -99,6 +224,9 @@ export function VegetationShadowResultView({
|
||||
rigLabel: string;
|
||||
result: VegetationShadowResult;
|
||||
}) {
|
||||
if (result.routeReview) {
|
||||
return <MixedRouteReviewResult rigLabel={rigLabel} review={result.routeReview} />;
|
||||
}
|
||||
const route = result.routeVideo;
|
||||
const selected = result.candidates.find(
|
||||
(candidate) => candidate.candidate === result.selectedCandidate,
|
||||
|
||||
+14
-14
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
import type { L34RightYoloxTruthIslandResult } from "../../../core/laboratory/l34RightYoloxTruthIsland";
|
||||
import type { L34DResult } from "../../../core/laboratory/l34dCumulativePostprocessing";
|
||||
@@ -28,19 +28,19 @@ export function useL34AnnotationCapability({
|
||||
}): ReactNode {
|
||||
const [open, setOpen] = useState(false);
|
||||
const openWorkspace = useCallback(() => setOpen(true), []);
|
||||
const available = selectedWorkId === "l34-right-yolox-truth-island-freeze"
|
||||
&& l34Result
|
||||
? { resultId: l34Result.resultId, workflow: "assisted-candidate" as const }
|
||||
: selectedWorkId === "e46-detector-truth-island" && e46Result
|
||||
? { resultId: e46Result.resultId, workflow: "independent-blind" as const }
|
||||
: selectedWorkId === "e46a-ai-engineering-preannotation" && e46aResult
|
||||
? { resultId: e46aResult.resultId, workflow: "engineering-preannotation" as const }
|
||||
: selectedWorkId === "l34d-cumulative-postprocessing-candidate"
|
||||
&& l34dResult
|
||||
? { resultId: l34dResult.resultId, workflow: "prediction-hidden" as const }
|
||||
: selectedWorkId === "l34e-self-review-diagnostic" && l34eResult
|
||||
? { resultId: l34eResult.resultId, workflow: "adjudication" as const }
|
||||
: null;
|
||||
const available = useMemo(() => (
|
||||
selectedWorkId === "l34-right-yolox-truth-island-freeze" && l34Result
|
||||
? { resultId: l34Result.resultId, workflow: "assisted-candidate" as const }
|
||||
: selectedWorkId === "e46-detector-truth-island" && e46Result
|
||||
? { resultId: e46Result.resultId, workflow: "independent-blind" as const }
|
||||
: selectedWorkId === "e46a-ai-engineering-preannotation" && e46aResult
|
||||
? { resultId: e46aResult.resultId, workflow: "engineering-preannotation" as const }
|
||||
: selectedWorkId === "l34d-cumulative-postprocessing-candidate" && l34dResult
|
||||
? { resultId: l34dResult.resultId, workflow: "prediction-hidden" as const }
|
||||
: selectedWorkId === "l34e-self-review-diagnostic" && l34eResult
|
||||
? { resultId: l34eResult.resultId, workflow: "adjudication" as const }
|
||||
: null
|
||||
), [e46Result, e46aResult, l34Result, l34dResult, l34eResult, selectedWorkId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!available) {
|
||||
|
||||
@@ -271,7 +271,8 @@ test("vegetation realtime LAB and archival benchmark use separate admitted instr
|
||||
assert.match(resultSource, /M49TgsFullShadowEvidence/);
|
||||
assert.match(resultSource, /semanticOverride/);
|
||||
assert.match(resultSource, /EoMT CITY \/ DDRNet VEGETATION/);
|
||||
assert.equal(resultSource.match(/<LaboratoryEvidence\b/g)?.length, 2);
|
||||
assert.equal(resultSource.match(/<LaboratoryEvidence\b/g)?.length, 3);
|
||||
assert.match(resultSource, /RAVNOVES004TREE mixed route review/);
|
||||
assert.match(resultSource, /linkedTgsResultId/);
|
||||
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
|
||||
assert.doesNotMatch(benchmarkSource, /M49TgsFullShadowEvidence/);
|
||||
|
||||
Reference in New Issue
Block a user