feat(perception): add mixed-route vegetation review
This commit is contained in:
@@ -79,6 +79,33 @@ export interface VegetationRouteVideo {
|
|||||||
validFovMaskSha256: string | null;
|
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 {
|
export interface VegetationShadowResult {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
createdAtUtc: string;
|
createdAtUtc: string;
|
||||||
@@ -88,6 +115,7 @@ export interface VegetationShadowResult {
|
|||||||
routeCases: readonly VegetationVisualCase[];
|
routeCases: readonly VegetationVisualCase[];
|
||||||
validationCases: readonly VegetationVisualCase[];
|
validationCases: readonly VegetationVisualCase[];
|
||||||
routeVideo: VegetationRouteVideo | null;
|
routeVideo: VegetationRouteVideo | null;
|
||||||
|
routeReview: VegetationMixedRouteReview | null;
|
||||||
limitations: readonly string[];
|
limitations: readonly string[];
|
||||||
visualShadowReady: true;
|
visualShadowReady: true;
|
||||||
missionPolicyReadyForConfiguration: 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(
|
function parseResult(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
resultId: string,
|
resultId: string,
|
||||||
@@ -466,7 +588,11 @@ function parseResult(
|
|||||||
.map((item) => visualCaseValue(item, resultId, "ravnoves", endpointRoot));
|
.map((item) => visualCaseValue(item, resultId, "ravnoves", endpointRoot));
|
||||||
const validationCases = arrayValue(catalogs.goose, "vegetation.catalogs.goose")
|
const validationCases = arrayValue(catalogs.goose, "vegetation.catalogs.goose")
|
||||||
.map((item) => visualCaseValue(item, resultId, "goose", endpointRoot));
|
.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.");
|
throw new VegetationShadowContractError("vegetation.catalogs: ожидалось 12 truth-backed GOOSE случаев без route viewer.");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -478,6 +604,7 @@ function parseResult(
|
|||||||
routeCases,
|
routeCases,
|
||||||
validationCases,
|
validationCases,
|
||||||
routeVideo: routeVideoValue(payload.route_video),
|
routeVideo: routeVideoValue(payload.route_video),
|
||||||
|
routeReview,
|
||||||
limitations: arrayValue(payload.limitations, "vegetation.limitations")
|
limitations: arrayValue(payload.limitations, "vegetation.limitations")
|
||||||
.map((item, index) => textValue(item, `vegetation.limitations[${index}]`)),
|
.map((item, index) => textValue(item, `vegetation.limitations[${index}]`)),
|
||||||
visualShadowReady: true,
|
visualShadowReady: true,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Icon, IconButton, StatusBadge } from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||||
import {
|
import {
|
||||||
LaboratoryEvidence,
|
LaboratoryEvidence,
|
||||||
LaboratoryResultSummary,
|
LaboratoryResultSummary,
|
||||||
@@ -8,6 +10,7 @@ import {
|
|||||||
} from "../../components/laboratory/LaboratoryPresentation";
|
} from "../../components/laboratory/LaboratoryPresentation";
|
||||||
import {
|
import {
|
||||||
vegetationVideoMaskUrl,
|
vegetationVideoMaskUrl,
|
||||||
|
type VegetationMixedRouteReview,
|
||||||
type VegetationShadowResult,
|
type VegetationShadowResult,
|
||||||
} from "../../core/laboratory/vegetationShadow";
|
} from "../../core/laboratory/vegetationShadow";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +24,128 @@ function decimal(value: number, digits = 1): string {
|
|||||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
|
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 }) {
|
function VegetationRouteEvidence({ result }: { result: VegetationShadowResult }) {
|
||||||
const route = result.routeVideo!;
|
const route = result.routeVideo!;
|
||||||
const [tgs, setTgs] = useState<M49TgsFullShadowResult | null>(null);
|
const [tgs, setTgs] = useState<M49TgsFullShadowResult | null>(null);
|
||||||
@@ -99,6 +224,9 @@ export function VegetationShadowResultView({
|
|||||||
rigLabel: string;
|
rigLabel: string;
|
||||||
result: VegetationShadowResult;
|
result: VegetationShadowResult;
|
||||||
}) {
|
}) {
|
||||||
|
if (result.routeReview) {
|
||||||
|
return <MixedRouteReviewResult rigLabel={rigLabel} review={result.routeReview} />;
|
||||||
|
}
|
||||||
const route = result.routeVideo;
|
const route = result.routeVideo;
|
||||||
const selected = result.candidates.find(
|
const selected = result.candidates.find(
|
||||||
(candidate) => candidate.candidate === result.selectedCandidate,
|
(candidate) => candidate.candidate === result.selectedCandidate,
|
||||||
|
|||||||
+6
-6
@@ -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 { L34RightYoloxTruthIslandResult } from "../../../core/laboratory/l34RightYoloxTruthIsland";
|
||||||
import type { L34DResult } from "../../../core/laboratory/l34dCumulativePostprocessing";
|
import type { L34DResult } from "../../../core/laboratory/l34dCumulativePostprocessing";
|
||||||
@@ -28,19 +28,19 @@ export function useL34AnnotationCapability({
|
|||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const openWorkspace = useCallback(() => setOpen(true), []);
|
const openWorkspace = useCallback(() => setOpen(true), []);
|
||||||
const available = selectedWorkId === "l34-right-yolox-truth-island-freeze"
|
const available = useMemo(() => (
|
||||||
&& l34Result
|
selectedWorkId === "l34-right-yolox-truth-island-freeze" && l34Result
|
||||||
? { resultId: l34Result.resultId, workflow: "assisted-candidate" as const }
|
? { resultId: l34Result.resultId, workflow: "assisted-candidate" as const }
|
||||||
: selectedWorkId === "e46-detector-truth-island" && e46Result
|
: selectedWorkId === "e46-detector-truth-island" && e46Result
|
||||||
? { resultId: e46Result.resultId, workflow: "independent-blind" as const }
|
? { resultId: e46Result.resultId, workflow: "independent-blind" as const }
|
||||||
: selectedWorkId === "e46a-ai-engineering-preannotation" && e46aResult
|
: selectedWorkId === "e46a-ai-engineering-preannotation" && e46aResult
|
||||||
? { resultId: e46aResult.resultId, workflow: "engineering-preannotation" as const }
|
? { resultId: e46aResult.resultId, workflow: "engineering-preannotation" as const }
|
||||||
: selectedWorkId === "l34d-cumulative-postprocessing-candidate"
|
: selectedWorkId === "l34d-cumulative-postprocessing-candidate" && l34dResult
|
||||||
&& l34dResult
|
|
||||||
? { resultId: l34dResult.resultId, workflow: "prediction-hidden" as const }
|
? { resultId: l34dResult.resultId, workflow: "prediction-hidden" as const }
|
||||||
: selectedWorkId === "l34e-self-review-diagnostic" && l34eResult
|
: selectedWorkId === "l34e-self-review-diagnostic" && l34eResult
|
||||||
? { resultId: l34eResult.resultId, workflow: "adjudication" as const }
|
? { resultId: l34eResult.resultId, workflow: "adjudication" as const }
|
||||||
: null;
|
: null
|
||||||
|
), [e46Result, e46aResult, l34Result, l34dResult, l34eResult, selectedWorkId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!available) {
|
if (!available) {
|
||||||
|
|||||||
@@ -271,7 +271,8 @@ test("vegetation realtime LAB and archival benchmark use separate admitted instr
|
|||||||
assert.match(resultSource, /M49TgsFullShadowEvidence/);
|
assert.match(resultSource, /M49TgsFullShadowEvidence/);
|
||||||
assert.match(resultSource, /semanticOverride/);
|
assert.match(resultSource, /semanticOverride/);
|
||||||
assert.match(resultSource, /EoMT CITY \/ DDRNet VEGETATION/);
|
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(resultSource, /linkedTgsResultId/);
|
||||||
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
|
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
|
||||||
assert.doesNotMatch(benchmarkSource, /M49TgsFullShadowEvidence/);
|
assert.doesNotMatch(benchmarkSource, /M49TgsFullShadowEvidence/);
|
||||||
|
|||||||
@@ -291,7 +291,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"catalog_id": "lab-v1-vegetation-shadow",
|
"catalog_id": "lab-v1-vegetation-shadow",
|
||||||
"evidence_id": "lab-v1-vegetation-shadow-394a5bca860e49a619a90c0f267259fd17e550a0abc4b51f821b1f482040c00e",
|
"evidence_id": "lab-v1-vegetation-shadow-d179462134967ace1c5ebd6fbdbdd8659905d390484b9c01ea7930f083bb74d1",
|
||||||
"signal": "progress",
|
"signal": "progress",
|
||||||
"lifecycle": "current",
|
"lifecycle": "current",
|
||||||
"visual_evidence": "available"
|
"visual_evidence": "available"
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "missioncore.mixed-route-tgs-review-profile/v1",
|
||||||
|
"profile_id": "ravnoves004tree-mixed-route-tgs-review/v1",
|
||||||
|
"source": {
|
||||||
|
"source_id": "RAVNOVES004TREE",
|
||||||
|
"session_id": "20260828T130511Z_viewer_live",
|
||||||
|
"review_pack_id": "mixed-route-review-pack-a8d245eb08a9581a994c4ae5ad242fec20f02c7c512c5ca5d3a6dd9464012753",
|
||||||
|
"source_pack_id": "mixed-route-lidar-pack-e3fe195588cc4a2ec17e15af6f46582ed71c9bed643943779c4ed5e565a3c839",
|
||||||
|
"source_pack_sha256": "10c759463da7711fbbe67e70df931597d85ab21325f7f8026e2c945b677e1bc6",
|
||||||
|
"input_coordinate_frame": "map-gravity-local-translation-only"
|
||||||
|
},
|
||||||
|
"tgs": {
|
||||||
|
"max_range_m": 80.0,
|
||||||
|
"min_range_m": 1.0,
|
||||||
|
"resolution_m": 8.0,
|
||||||
|
"num_iterations": 3,
|
||||||
|
"num_lowest_representative_points": 5,
|
||||||
|
"minimum_points": 10,
|
||||||
|
"seed_threshold_m": 0.5,
|
||||||
|
"distance_threshold_m": 0.125,
|
||||||
|
"outlier_threshold_m": 0.3,
|
||||||
|
"normal_threshold": 0.94,
|
||||||
|
"weight_threshold": 200.0,
|
||||||
|
"lcc_normal_similarity": 0.03,
|
||||||
|
"lcc_planar_distance_m": 0.1,
|
||||||
|
"obstacle_height_m": 1.0,
|
||||||
|
"refine_mode": true
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"current_increment": {
|
||||||
|
"role": "diagnostic-current-evidence"
|
||||||
|
},
|
||||||
|
"causal_rolling_1s": {
|
||||||
|
"role": "primary-local-evidence",
|
||||||
|
"history_seconds": 1.0,
|
||||||
|
"local_radius_m": 12.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"costmap": {
|
||||||
|
"coordinate_frame": "map-gravity-local",
|
||||||
|
"cell_size_m": 0.45,
|
||||||
|
"radius_m": 12.0,
|
||||||
|
"state_priority": [
|
||||||
|
"NONGROUND_OCCUPIED",
|
||||||
|
"UNKNOWN_REJECTED",
|
||||||
|
"GROUND_SUPPORT",
|
||||||
|
"UNOBSERVED"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"state_codes": {
|
||||||
|
"UNOBSERVED": 0,
|
||||||
|
"GROUND_SUPPORT": 1,
|
||||||
|
"NONGROUND_OCCUPIED": 2,
|
||||||
|
"UNKNOWN_REJECTED": 3
|
||||||
|
},
|
||||||
|
"invariants": {
|
||||||
|
"all_eligible_input_points_accounted": true,
|
||||||
|
"aos_allowed": false,
|
||||||
|
"lidar_orientation_applied_to_tgs_input": false,
|
||||||
|
"map_gravity_axis_preserved": true,
|
||||||
|
"missing_support_means_free": false,
|
||||||
|
"unobserved_cells_are_emitted": true,
|
||||||
|
"camera_projection_is_authoritative": false,
|
||||||
|
"future_frames_used": false,
|
||||||
|
"gpu_allowed": false,
|
||||||
|
"navigation_or_actuation_allowed": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Publish LiDAR/pose evidence aligned to an immutable mixed-route review pack."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from fuse_e6_tracking_lidar import CameraAnchor, _lidar_samples
|
||||||
|
|
||||||
|
from k1link.compute.jobs import validate_camera_compute_job
|
||||||
|
from k1link.device_plugins.xgrids_k1.analyze.calibrated_overlay import (
|
||||||
|
_load_calibration_snapshot,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.analyze.calibrated_projection import (
|
||||||
|
Kb4ProjectionProfile,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.mqtt.capture import read_capture_clock_origin
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.streams import decode_lio_pcl
|
||||||
|
from k1link.device_plugins.xgrids_k1.viewer.replay import iter_replay_messages
|
||||||
|
|
||||||
|
SCHEMA = "missioncore.mixed-route-lidar-pack/v1"
|
||||||
|
REVIEW_SCHEMA = "missioncore.mixed-route-review-pack/v1"
|
||||||
|
MAXIMUM_LIDAR_CAMERA_DELTA_MS = 100.0
|
||||||
|
MAXIMUM_POSE_POINT_DELTA_MS = 100.0
|
||||||
|
CAUSAL_HISTORY_SECONDS = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class MixedRouteLidarPackError(RuntimeError):
|
||||||
|
"""The recorded route cannot satisfy the selected LiDAR evidence contract."""
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _arguments() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--job", type=Path, required=True)
|
||||||
|
parser.add_argument("--session", type=Path, required=True)
|
||||||
|
parser.add_argument("--review-pack", type=Path, required=True)
|
||||||
|
parser.add_argument("--calibration", type=Path, required=True)
|
||||||
|
parser.add_argument("--output-root", type=Path, required=True)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_review_pack(root: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||||
|
resolved = root.resolve(strict=True)
|
||||||
|
manifest_path = resolved / "manifest.json"
|
||||||
|
try:
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise MixedRouteLidarPackError("mixed-route review manifest is invalid") from exc
|
||||||
|
identity = manifest.get("identity") if isinstance(manifest, dict) else None
|
||||||
|
timeline = manifest.get("timeline") if isinstance(manifest, dict) else None
|
||||||
|
frames = manifest.get("frames") if isinstance(manifest, dict) else None
|
||||||
|
if (
|
||||||
|
manifest.get("schema_version") != REVIEW_SCHEMA
|
||||||
|
or not isinstance(identity, dict)
|
||||||
|
or identity.get("schema_version") != REVIEW_SCHEMA
|
||||||
|
or identity.get("ground_truth") is not False
|
||||||
|
or not isinstance(timeline, dict)
|
||||||
|
or not isinstance(frames, list)
|
||||||
|
or manifest.get("frame_count") != len(frames)
|
||||||
|
or not frames
|
||||||
|
):
|
||||||
|
raise MixedRouteLidarPackError("mixed-route review contract changed")
|
||||||
|
timeline_path = resolved / str(timeline.get("path"))
|
||||||
|
if (
|
||||||
|
not timeline_path.is_file()
|
||||||
|
or timeline.get("sha256") != _sha256(timeline_path)
|
||||||
|
or timeline.get("byte_length") != timeline_path.stat().st_size
|
||||||
|
):
|
||||||
|
raise MixedRouteLidarPackError("mixed-route review timeline changed")
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
previous_seconds = -1.0
|
||||||
|
with timeline_path.open(encoding="utf-8") as stream:
|
||||||
|
for expected, line in enumerate(stream):
|
||||||
|
try:
|
||||||
|
row = json.loads(line)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise MixedRouteLidarPackError("mixed-route timeline JSON is invalid") from exc
|
||||||
|
seconds = row.get("session_seconds") if isinstance(row, dict) else None
|
||||||
|
if (
|
||||||
|
not isinstance(row, dict)
|
||||||
|
or row.get("frame_index") != expected
|
||||||
|
or row.get("sequence") != expected + 1
|
||||||
|
or row.get("source_sequence") != row.get("source_frame_index") + 1
|
||||||
|
or not isinstance(seconds, (int, float))
|
||||||
|
or isinstance(seconds, bool)
|
||||||
|
or float(seconds) <= previous_seconds
|
||||||
|
):
|
||||||
|
raise MixedRouteLidarPackError("mixed-route timeline row changed")
|
||||||
|
rows.append(row)
|
||||||
|
previous_seconds = float(seconds)
|
||||||
|
if len(rows) != len(frames):
|
||||||
|
raise MixedRouteLidarPackError("mixed-route timeline is incomplete")
|
||||||
|
for frame in frames:
|
||||||
|
path = resolved / str(frame.get("path"))
|
||||||
|
if (
|
||||||
|
not path.is_file()
|
||||||
|
or frame.get("byte_length") != path.stat().st_size
|
||||||
|
or frame.get("sha256") != _sha256(path)
|
||||||
|
):
|
||||||
|
raise MixedRouteLidarPackError("mixed-route source frame changed")
|
||||||
|
return manifest, rows
|
||||||
|
|
||||||
|
|
||||||
|
def _causal_history_clouds(
|
||||||
|
raw_path: Path,
|
||||||
|
*,
|
||||||
|
origin_monotonic_ns: int,
|
||||||
|
sample_seconds: list[float],
|
||||||
|
) -> list[np.ndarray]:
|
||||||
|
grouped: list[list[np.ndarray]] = [[] for _ in sample_seconds]
|
||||||
|
last = sample_seconds[-1]
|
||||||
|
for message in iter_replay_messages(raw_path):
|
||||||
|
monotonic_ns = message.received_monotonic_ns
|
||||||
|
if not isinstance(monotonic_ns, int) or monotonic_ns < origin_monotonic_ns:
|
||||||
|
raise MixedRouteLidarPackError("MQTT replay message has no compatible clock")
|
||||||
|
seconds = (monotonic_ns - origin_monotonic_ns) / 1e9
|
||||||
|
if seconds > last:
|
||||||
|
break
|
||||||
|
if not message.topic.endswith("/lio_pcl"):
|
||||||
|
continue
|
||||||
|
matching = [
|
||||||
|
index
|
||||||
|
for index, sample_time in enumerate(sample_seconds)
|
||||||
|
if sample_time - CAUSAL_HISTORY_SECONDS <= seconds <= sample_time
|
||||||
|
]
|
||||||
|
if not matching:
|
||||||
|
continue
|
||||||
|
frame = decode_lio_pcl(message.payload)
|
||||||
|
cloud = np.asarray(
|
||||||
|
[point.scaled_xyz(frame.header.scaler) for point in frame.points],
|
||||||
|
dtype=np.float32,
|
||||||
|
).reshape((-1, 3))
|
||||||
|
if cloud.shape[0] == 0 or not np.isfinite(cloud).all():
|
||||||
|
raise MixedRouteLidarPackError("causal LiDAR history is empty or non-finite")
|
||||||
|
for index in matching:
|
||||||
|
grouped[index].append(cloud)
|
||||||
|
result: list[np.ndarray] = []
|
||||||
|
for clouds in grouped:
|
||||||
|
if not clouds:
|
||||||
|
raise MixedRouteLidarPackError("selected frame has no causal LiDAR history")
|
||||||
|
result.append(np.concatenate(clouds))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(
|
||||||
|
*,
|
||||||
|
job_root: Path,
|
||||||
|
session_root: Path,
|
||||||
|
review_pack_root: Path,
|
||||||
|
calibration_root: Path,
|
||||||
|
output_root: Path,
|
||||||
|
) -> Path:
|
||||||
|
job = validate_camera_compute_job(job_root)
|
||||||
|
session = session_root.resolve(strict=True)
|
||||||
|
if not session.is_dir() or session.name != job.session_id:
|
||||||
|
raise MixedRouteLidarPackError("camera job and observation session differ")
|
||||||
|
review, timeline = _read_review_pack(review_pack_root)
|
||||||
|
review_identity = review["identity"]
|
||||||
|
if (
|
||||||
|
review_identity.get("job_id") != job.job_id
|
||||||
|
or review_identity.get("input_sha256") != job.input_sha256
|
||||||
|
or review_identity.get("session_id") != job.session_id
|
||||||
|
or review_identity.get("source_id") != job.source_id
|
||||||
|
or review_identity.get("codec_epoch") != job.codec_epoch
|
||||||
|
):
|
||||||
|
raise MixedRouteLidarPackError("review pack and camera job differ")
|
||||||
|
|
||||||
|
calibration, calibration_sha256 = _load_calibration_snapshot(
|
||||||
|
calibration_root.resolve(strict=True)
|
||||||
|
)
|
||||||
|
projection = Kb4ProjectionProfile.from_factory_calibration(calibration, job.source_id)
|
||||||
|
capture_root = session / "captures" / "mqtt_live"
|
||||||
|
origin_path = capture_root / "mqtt.timeline.origin.json"
|
||||||
|
origin = read_capture_clock_origin(origin_path)
|
||||||
|
anchors = [
|
||||||
|
CameraAnchor(
|
||||||
|
frame_index=int(row["frame_index"]),
|
||||||
|
source_frame_index=int(row["source_frame_index"]),
|
||||||
|
host_session_seconds=(
|
||||||
|
int(row["host_monotonic_ns"]) - origin.started_monotonic_ns
|
||||||
|
)
|
||||||
|
/ 1e9,
|
||||||
|
video_session_seconds=float(row["session_seconds"]),
|
||||||
|
)
|
||||||
|
for row in timeline
|
||||||
|
]
|
||||||
|
if any(
|
||||||
|
anchor.host_session_seconds != anchor.video_session_seconds
|
||||||
|
for anchor in anchors
|
||||||
|
):
|
||||||
|
raise MixedRouteLidarPackError("review timeline does not use host arrival time")
|
||||||
|
samples = list(
|
||||||
|
_lidar_samples(
|
||||||
|
capture_root / "mqtt.raw.k1mqtt",
|
||||||
|
anchors,
|
||||||
|
origin_monotonic_ns=origin.started_monotonic_ns,
|
||||||
|
maximum_lidar_camera_delta_s=MAXIMUM_LIDAR_CAMERA_DELTA_MS / 1000.0,
|
||||||
|
maximum_pose_point_delta_s=MAXIMUM_POSE_POINT_DELTA_MS / 1000.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(samples) != len(anchors):
|
||||||
|
raise MixedRouteLidarPackError("LiDAR sampler did not account for every anchor")
|
||||||
|
|
||||||
|
count = len(anchors)
|
||||||
|
available = np.zeros((count,), dtype=np.bool_)
|
||||||
|
offsets = [0]
|
||||||
|
clouds: list[np.ndarray] = []
|
||||||
|
positions = np.full((count, 3), np.nan, dtype=np.float64)
|
||||||
|
quaternions = np.full((count, 4), np.nan, dtype=np.float64)
|
||||||
|
lidar_delta = np.full((count,), np.nan, dtype=np.float64)
|
||||||
|
pose_delta = np.full((count,), np.nan, dtype=np.float64)
|
||||||
|
sample_seconds: list[float] = []
|
||||||
|
for index, (anchor, sample) in enumerate(zip(anchors, samples, strict=True)):
|
||||||
|
if sample is None:
|
||||||
|
offsets.append(offsets[-1])
|
||||||
|
sample_seconds.append(float("nan"))
|
||||||
|
continue
|
||||||
|
cloud = np.asarray(
|
||||||
|
[
|
||||||
|
point.scaled_xyz(sample.point_frame.header.scaler)
|
||||||
|
for point in sample.point_frame.points
|
||||||
|
],
|
||||||
|
dtype=np.float32,
|
||||||
|
).reshape((-1, 3))
|
||||||
|
if cloud.shape[0] == 0 or not np.isfinite(cloud).all():
|
||||||
|
raise MixedRouteLidarPackError("selected LiDAR sample is empty or non-finite")
|
||||||
|
available[index] = True
|
||||||
|
clouds.append(cloud)
|
||||||
|
offsets.append(offsets[-1] + cloud.shape[0])
|
||||||
|
positions[index] = sample.pose_frame.position_xyz
|
||||||
|
quaternions[index] = sample.pose_frame.orientation_xyzw
|
||||||
|
lidar_delta[index] = (
|
||||||
|
sample.point_session_seconds - anchor.host_session_seconds
|
||||||
|
) * 1000.0
|
||||||
|
pose_delta[index] = (
|
||||||
|
sample.pose_session_seconds - sample.point_session_seconds
|
||||||
|
) * 1000.0
|
||||||
|
sample_seconds.append(sample.point_session_seconds)
|
||||||
|
|
||||||
|
if not available.all() or not np.isfinite(np.asarray(sample_seconds)).all():
|
||||||
|
raise MixedRouteLidarPackError(
|
||||||
|
"every mixed-route review island must have a temporally admissible LiDAR sample"
|
||||||
|
)
|
||||||
|
history_clouds = _causal_history_clouds(
|
||||||
|
capture_root / "mqtt.raw.k1mqtt",
|
||||||
|
origin_monotonic_ns=origin.started_monotonic_ns,
|
||||||
|
sample_seconds=sample_seconds,
|
||||||
|
)
|
||||||
|
history_offsets = [0]
|
||||||
|
for cloud in history_clouds:
|
||||||
|
history_offsets.append(history_offsets[-1] + cloud.shape[0])
|
||||||
|
|
||||||
|
identity = {
|
||||||
|
"schema_version": SCHEMA,
|
||||||
|
"job_id": job.job_id,
|
||||||
|
"input_sha256": job.input_sha256,
|
||||||
|
"session_id": job.session_id,
|
||||||
|
"source_id": job.source_id,
|
||||||
|
"camera_slot": "camera_1",
|
||||||
|
"calibration_sha256": calibration_sha256,
|
||||||
|
"review_pack_id": review["pack_id"],
|
||||||
|
"review_pack_identity_sha256": review["identity_sha256"],
|
||||||
|
"selected_source_frame_indices": [
|
||||||
|
int(row["source_frame_index"]) for row in timeline
|
||||||
|
],
|
||||||
|
"frame_count": count,
|
||||||
|
"available_lidar_frames": int(available.sum()),
|
||||||
|
"point_count": int(offsets[-1]),
|
||||||
|
"causal_history_seconds": CAUSAL_HISTORY_SECONDS,
|
||||||
|
"causal_history_point_count": int(history_offsets[-1]),
|
||||||
|
"temporal_policy": {
|
||||||
|
"binding": "nearest-host-arrival-best-effort",
|
||||||
|
"maximum_lidar_camera_delta_ms": MAXIMUM_LIDAR_CAMERA_DELTA_MS,
|
||||||
|
"maximum_pose_point_delta_ms": MAXIMUM_POSE_POINT_DELTA_MS,
|
||||||
|
"clock_source": "recorded-host-monotonic-arrival",
|
||||||
|
},
|
||||||
|
"projection": {
|
||||||
|
"model": "kb4",
|
||||||
|
"width": projection.width,
|
||||||
|
"height": projection.height,
|
||||||
|
"source_coordinates": "k1-map",
|
||||||
|
"target_camera": job.source_id,
|
||||||
|
},
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": {
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
"actuation_allowed": False,
|
||||||
|
},
|
||||||
|
"producer_sha256": _sha256(Path(__file__).resolve(strict=True)),
|
||||||
|
}
|
||||||
|
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||||
|
pack_id = f"mixed-route-lidar-pack-{identity_sha256}"
|
||||||
|
parent = output_root.resolve()
|
||||||
|
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
final = parent / pack_id
|
||||||
|
if final.exists():
|
||||||
|
return final
|
||||||
|
staging = Path(tempfile.mkdtemp(prefix=f".{pack_id}.", dir=parent))
|
||||||
|
published = False
|
||||||
|
try:
|
||||||
|
arrays_path = staging / "lidar-pack.npz"
|
||||||
|
np.savez_compressed(
|
||||||
|
arrays_path,
|
||||||
|
frame_indices=np.arange(count, dtype=np.int64),
|
||||||
|
source_frame_indices=np.asarray(
|
||||||
|
[row["source_frame_index"] for row in timeline], dtype=np.int64
|
||||||
|
),
|
||||||
|
session_seconds=np.asarray(
|
||||||
|
[anchor.video_session_seconds for anchor in anchors], dtype=np.float64
|
||||||
|
),
|
||||||
|
host_session_seconds=np.asarray(
|
||||||
|
[anchor.host_session_seconds for anchor in anchors], dtype=np.float64
|
||||||
|
),
|
||||||
|
lidar_session_seconds=np.asarray(sample_seconds, dtype=np.float64),
|
||||||
|
sample_available=available,
|
||||||
|
cloud_offsets=np.asarray(offsets, dtype=np.int64),
|
||||||
|
cloud_points_map=(
|
||||||
|
np.concatenate(clouds) if clouds else np.empty((0, 3), dtype=np.float32)
|
||||||
|
),
|
||||||
|
pose_positions_map=positions,
|
||||||
|
pose_quaternions_map_from_lidar=quaternions,
|
||||||
|
lidar_camera_delta_ms=lidar_delta,
|
||||||
|
pose_point_delta_ms=pose_delta,
|
||||||
|
causal_history_seconds=np.asarray(
|
||||||
|
[CAUSAL_HISTORY_SECONDS], dtype=np.float64
|
||||||
|
),
|
||||||
|
causal_history_offsets=np.asarray(history_offsets, dtype=np.int64),
|
||||||
|
causal_history_points_map=np.concatenate(history_clouds),
|
||||||
|
intrinsic_fx_fy_cx_cy=np.asarray(
|
||||||
|
projection.intrinsic_fx_fy_cx_cy, dtype=np.float64
|
||||||
|
),
|
||||||
|
distortion_kb4=np.asarray(projection.distortion_kb4, dtype=np.float64),
|
||||||
|
t_camera_from_lidar=np.asarray(projection.t_camera_from_lidar, dtype=np.float64),
|
||||||
|
)
|
||||||
|
manifest = {
|
||||||
|
"schema_version": SCHEMA,
|
||||||
|
"pack_id": pack_id,
|
||||||
|
"identity_sha256": identity_sha256,
|
||||||
|
"identity": identity,
|
||||||
|
"created_at_utc": datetime.now(UTC)
|
||||||
|
.isoformat(timespec="milliseconds")
|
||||||
|
.replace("+00:00", "Z"),
|
||||||
|
"classification": "private-recorded-sensor-review-input",
|
||||||
|
"ground_truth": False,
|
||||||
|
"artifact": {
|
||||||
|
"path": arrays_path.name,
|
||||||
|
"media_type": "application/x-npz",
|
||||||
|
"byte_length": arrays_path.stat().st_size,
|
||||||
|
"sha256": _sha256(arrays_path),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(staging / "manifest.json").write_text(
|
||||||
|
json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(staging, final)
|
||||||
|
published = True
|
||||||
|
finally:
|
||||||
|
if not published:
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
return final
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = _arguments()
|
||||||
|
output = prepare(
|
||||||
|
job_root=args.job,
|
||||||
|
session_root=args.session,
|
||||||
|
review_pack_root=args.review_pack,
|
||||||
|
calibration_root=args.calibration,
|
||||||
|
output_root=args.output_root,
|
||||||
|
)
|
||||||
|
manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"pack_id": manifest["pack_id"],
|
||||||
|
"output": str(output),
|
||||||
|
"frames": manifest["identity"]["frame_count"],
|
||||||
|
"lidar_frames": manifest["identity"]["available_lidar_frames"],
|
||||||
|
"points": manifest["identity"]["point_count"],
|
||||||
|
"artifact_sha256": manifest["artifact"]["sha256"],
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+340
@@ -0,0 +1,340 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run DDRNet on an immutable mixed-route camera review pack."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import platform
|
||||||
|
import statistics
|
||||||
|
import time
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from PIL import Image
|
||||||
|
from run_goose_vegetation_benchmark import (
|
||||||
|
CLASS_COUNT,
|
||||||
|
expand_mask,
|
||||||
|
infer,
|
||||||
|
load_mapping,
|
||||||
|
load_model,
|
||||||
|
percentile,
|
||||||
|
preprocess,
|
||||||
|
read_json,
|
||||||
|
save_image,
|
||||||
|
sha256,
|
||||||
|
stable_digest,
|
||||||
|
validate_contracts,
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA = "missioncore.mixed-route-ddrnet-islands/v1"
|
||||||
|
PACK_SCHEMA = "missioncore.mixed-route-review-pack/v1"
|
||||||
|
AUTHORITY = {
|
||||||
|
"ground_truth": False,
|
||||||
|
"candidate_accepted": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
"camera_semantics_can_clear_rigid_geometry": False,
|
||||||
|
"actuation_allowed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MixedRouteDdrnetError(RuntimeError):
|
||||||
|
"""The route pack or DDRNet evidence changed or is incomplete."""
|
||||||
|
|
||||||
|
|
||||||
|
def arguments() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--pack", type=Path, required=True)
|
||||||
|
parser.add_argument("--config", type=Path, required=True)
|
||||||
|
parser.add_argument("--policy", type=Path, required=True)
|
||||||
|
parser.add_argument("--provider-map", type=Path, required=True)
|
||||||
|
parser.add_argument("--checkpoint", type=Path, required=True)
|
||||||
|
parser.add_argument("--dataset-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_json(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def object_value(value: object, label: str) -> dict[str, Any]:
|
||||||
|
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
||||||
|
raise MixedRouteDdrnetError(f"{label} must be an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def load_pack(root: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||||
|
pack = root.resolve(strict=True)
|
||||||
|
if not pack.is_dir() or pack.is_symlink():
|
||||||
|
raise MixedRouteDdrnetError("mixed-route review pack is unavailable")
|
||||||
|
manifest_path = pack / "manifest.json"
|
||||||
|
manifest = object_value(
|
||||||
|
json.loads(manifest_path.read_text(encoding="utf-8")),
|
||||||
|
"mixed-route manifest",
|
||||||
|
)
|
||||||
|
identity = object_value(manifest.get("identity"), "mixed-route identity")
|
||||||
|
identity_sha256 = manifest.get("identity_sha256")
|
||||||
|
frames = manifest.get("frames")
|
||||||
|
frame_count = manifest.get("frame_count")
|
||||||
|
if (
|
||||||
|
manifest.get("schema_version") != PACK_SCHEMA
|
||||||
|
or identity.get("schema_version") != PACK_SCHEMA
|
||||||
|
or not isinstance(identity_sha256, str)
|
||||||
|
or hashlib.sha256(canonical_json(identity)).hexdigest() != identity_sha256
|
||||||
|
or manifest.get("pack_id") != f"mixed-route-review-pack-{identity_sha256}"
|
||||||
|
or identity.get("ground_truth") is not False
|
||||||
|
or object_value(identity.get("authority"), "mixed-route authority").get(
|
||||||
|
"navigation_or_safety_accepted"
|
||||||
|
)
|
||||||
|
is not False
|
||||||
|
or not isinstance(frame_count, int)
|
||||||
|
or isinstance(frame_count, bool)
|
||||||
|
or not 1 <= frame_count <= 64
|
||||||
|
or not isinstance(frames, list)
|
||||||
|
or len(frames) != frame_count
|
||||||
|
):
|
||||||
|
raise MixedRouteDdrnetError("mixed-route review pack identity changed")
|
||||||
|
timeline_descriptor = object_value(manifest.get("timeline"), "mixed-route timeline")
|
||||||
|
timeline_path = pack / "timeline.jsonl"
|
||||||
|
if (
|
||||||
|
timeline_descriptor.get("path") != timeline_path.name
|
||||||
|
or timeline_path.stat().st_size != timeline_descriptor.get("byte_length")
|
||||||
|
or sha256(timeline_path) != timeline_descriptor.get("sha256")
|
||||||
|
):
|
||||||
|
raise MixedRouteDdrnetError("mixed-route timeline proof changed")
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
with timeline_path.open(encoding="utf-8") as stream:
|
||||||
|
for expected, line in enumerate(stream):
|
||||||
|
row = object_value(json.loads(line), "mixed-route timeline row")
|
||||||
|
seconds = row.get("session_seconds")
|
||||||
|
if (
|
||||||
|
row.get("frame_index") != expected
|
||||||
|
or row.get("sequence") != expected + 1
|
||||||
|
or not isinstance(row.get("source_sequence"), int)
|
||||||
|
or row.get("source_frame_index") != row["source_sequence"] - 1
|
||||||
|
or not isinstance(seconds, (int, float))
|
||||||
|
or isinstance(seconds, bool)
|
||||||
|
or (rows and float(seconds) <= float(rows[-1]["session_seconds"]))
|
||||||
|
):
|
||||||
|
raise MixedRouteDdrnetError("mixed-route timeline order changed")
|
||||||
|
rows.append(row)
|
||||||
|
if len(rows) != frame_count:
|
||||||
|
raise MixedRouteDdrnetError("mixed-route timeline is incomplete")
|
||||||
|
for expected, (descriptor_raw, row) in enumerate(zip(frames, rows)): # noqa: B905
|
||||||
|
descriptor = object_value(descriptor_raw, "mixed-route frame descriptor")
|
||||||
|
relative = descriptor.get("path")
|
||||||
|
if relative != f"frames/frame-{expected + 1:06d}.png":
|
||||||
|
raise MixedRouteDdrnetError("mixed-route frame path changed")
|
||||||
|
pure = PurePosixPath(relative)
|
||||||
|
path = pack.joinpath(*pure.parts)
|
||||||
|
if (
|
||||||
|
path.is_symlink()
|
||||||
|
or not path.is_file()
|
||||||
|
or not path.resolve().is_relative_to(pack)
|
||||||
|
or path.stat().st_size != descriptor.get("byte_length")
|
||||||
|
or sha256(path) != descriptor.get("sha256")
|
||||||
|
or not isinstance(descriptor.get("source_segment_sha256"), str)
|
||||||
|
or row.get("source_sequence")
|
||||||
|
!= identity["selected_sequences"][expected]
|
||||||
|
):
|
||||||
|
raise MixedRouteDdrnetError("mixed-route frame proof changed")
|
||||||
|
return manifest, rows
|
||||||
|
|
||||||
|
|
||||||
|
def overlay(source: Image.Image, semantic: np.ndarray, palette: np.ndarray) -> Image.Image:
|
||||||
|
if semantic.shape != (600, 800):
|
||||||
|
raise MixedRouteDdrnetError("expanded semantic mask shape changed")
|
||||||
|
base = source.convert("RGBA")
|
||||||
|
colors = Image.fromarray(palette[semantic], mode="RGBA")
|
||||||
|
return Image.alpha_composite(base, colors)
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> int:
|
||||||
|
args = arguments()
|
||||||
|
if not torch.cuda.is_available():
|
||||||
|
raise MixedRouteDdrnetError("CUDA is required for DDRNet islands")
|
||||||
|
if args.output.exists():
|
||||||
|
raise MixedRouteDdrnetError("DDRNet islands output already exists")
|
||||||
|
manifest, timeline = load_pack(args.pack)
|
||||||
|
config = read_json(args.config, "benchmark config")
|
||||||
|
policy = read_json(args.policy, "mission policy")
|
||||||
|
provider_map = read_json(args.provider_map, "provider map")
|
||||||
|
candidate = validate_contracts(config, policy, provider_map, "ddrnet")
|
||||||
|
checkpoint = args.checkpoint.resolve(strict=True)
|
||||||
|
if (
|
||||||
|
checkpoint.is_symlink()
|
||||||
|
or checkpoint.stat().st_size != candidate["checkpoint_size_bytes"]
|
||||||
|
or sha256(checkpoint) != candidate["checkpoint_sha256"]
|
||||||
|
):
|
||||||
|
raise MixedRouteDdrnetError("DDRNet checkpoint identity changed")
|
||||||
|
dataset_root = args.dataset_root.resolve(strict=True)
|
||||||
|
mapping_path = dataset_root / config["dataset"]["mapping_relative_path"]
|
||||||
|
names, palette = load_mapping(mapping_path, config["dataset"]["mapping_sha256"])
|
||||||
|
|
||||||
|
args.output.mkdir(mode=0o700, parents=True, exist_ok=False)
|
||||||
|
mask_root = args.output / "semantic-masks"
|
||||||
|
overlay_root = args.output / "overlay-frames"
|
||||||
|
mask_root.mkdir(mode=0o700)
|
||||||
|
overlay_root.mkdir(mode=0o700)
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
model, model_name, architecture_failures = load_model("ddrnet", checkpoint)
|
||||||
|
first_path = args.pack / manifest["frames"][0]["path"]
|
||||||
|
with Image.open(first_path) as opened:
|
||||||
|
warm_source = opened.convert("RGB")
|
||||||
|
warm_tensor, _ = preprocess(warm_source)
|
||||||
|
warmup_ms = [infer(model, warm_tensor)[1] for _ in range(3)]
|
||||||
|
torch.cuda.reset_peak_memory_stats()
|
||||||
|
|
||||||
|
latencies_ms: list[float] = []
|
||||||
|
aggregate = np.zeros(CLASS_COUNT, dtype=np.int64)
|
||||||
|
frame_results: list[dict[str, Any]] = []
|
||||||
|
started = time.perf_counter()
|
||||||
|
for index, (descriptor, timeline_row) in enumerate(
|
||||||
|
zip(manifest["frames"], timeline) # noqa: B905 - Worker image uses Python 3.9.
|
||||||
|
):
|
||||||
|
source_path = args.pack / descriptor["path"]
|
||||||
|
with Image.open(source_path) as opened:
|
||||||
|
source = opened.convert("RGB")
|
||||||
|
if source.size != (800, 600):
|
||||||
|
raise MixedRouteDdrnetError("mixed-route source resolution changed")
|
||||||
|
tensor, crop_box = preprocess(source)
|
||||||
|
prediction, latency_ms = infer(model, tensor)
|
||||||
|
expanded = expand_mask(prediction, source.size, crop_box)
|
||||||
|
latencies_ms.append(latency_ms)
|
||||||
|
aggregate += np.bincount(expanded.reshape(-1), minlength=CLASS_COUNT)
|
||||||
|
mask_path = mask_root / f"frame-{index + 1:06d}.png"
|
||||||
|
overlay_path = overlay_root / f"frame-{index + 1:06d}.png"
|
||||||
|
mask_sha256 = save_image(mask_path, expanded, "L")
|
||||||
|
overlay_sha256 = save_image(overlay_path, overlay(source, expanded, palette))
|
||||||
|
present = np.flatnonzero(np.bincount(expanded.reshape(-1), minlength=CLASS_COUNT))
|
||||||
|
frame_results.append(
|
||||||
|
{
|
||||||
|
"frame_index": index,
|
||||||
|
"source_sequence": timeline_row["source_sequence"],
|
||||||
|
"source_frame_index": timeline_row["source_frame_index"],
|
||||||
|
"session_seconds": timeline_row["session_seconds"],
|
||||||
|
"latency_ms": round(latency_ms, 6),
|
||||||
|
"present_classes": [
|
||||||
|
{"class_id": int(class_id), "label": names[int(class_id)]}
|
||||||
|
for class_id in present
|
||||||
|
],
|
||||||
|
"mask": {
|
||||||
|
"path": mask_path.relative_to(args.output).as_posix(),
|
||||||
|
"byte_length": mask_path.stat().st_size,
|
||||||
|
"sha256": mask_sha256,
|
||||||
|
},
|
||||||
|
"overlay": {
|
||||||
|
"path": overlay_path.relative_to(args.output).as_posix(),
|
||||||
|
"byte_length": overlay_path.stat().st_size,
|
||||||
|
"sha256": overlay_sha256,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
wall_seconds = time.perf_counter() - started
|
||||||
|
if len(frame_results) != manifest["frame_count"]:
|
||||||
|
raise MixedRouteDdrnetError("DDRNet island accounting changed")
|
||||||
|
timing = {
|
||||||
|
"prewarm_inference_count": len(warmup_ms),
|
||||||
|
"prewarm_latency_ms_first": round(warmup_ms[0], 6),
|
||||||
|
"prewarm_latency_ms_last": round(warmup_ms[-1], 6),
|
||||||
|
"inference_wall_seconds": round(wall_seconds, 6),
|
||||||
|
"latency_ms_mean": round(statistics.fmean(latencies_ms), 6),
|
||||||
|
"latency_ms_p50": round(percentile(latencies_ms, 0.5), 6),
|
||||||
|
"latency_ms_p95": round(percentile(latencies_ms, 0.95), 6),
|
||||||
|
"throughput_fps_from_mean_inference": round(
|
||||||
|
1000.0 / statistics.fmean(latencies_ms), 6
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if any(not math.isfinite(float(value)) for value in timing.values()):
|
||||||
|
raise MixedRouteDdrnetError("DDRNet timing is non-finite")
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"schema_version": SCHEMA,
|
||||||
|
"status": "review-islands-ready-not-accepted",
|
||||||
|
"worker_id": "worker-006",
|
||||||
|
"source": {
|
||||||
|
"pack_id": manifest["pack_id"],
|
||||||
|
"pack_identity_sha256": manifest["identity_sha256"],
|
||||||
|
"job_id": manifest["identity"]["job_id"],
|
||||||
|
"input_sha256": manifest["identity"]["input_sha256"],
|
||||||
|
"session_id": manifest["identity"]["session_id"],
|
||||||
|
"source_id": manifest["identity"]["source_id"],
|
||||||
|
"frame_count": manifest["frame_count"],
|
||||||
|
"ground_truth_available": False,
|
||||||
|
},
|
||||||
|
"candidate": {
|
||||||
|
"candidate_key": "ddrnet",
|
||||||
|
"candidate_id": candidate["candidate_id"],
|
||||||
|
"loaded_model_name": model_name,
|
||||||
|
"architecture_probe_failures": architecture_failures,
|
||||||
|
"checkpoint_size_bytes": checkpoint.stat().st_size,
|
||||||
|
"checkpoint_sha256": sha256(checkpoint),
|
||||||
|
},
|
||||||
|
"taxonomy": {
|
||||||
|
"schema_version": "missioncore.lab-v1-vegetation-taxonomy/v1",
|
||||||
|
"classes": [
|
||||||
|
{
|
||||||
|
"class_id": class_id,
|
||||||
|
"label": names[class_id],
|
||||||
|
"color_rgb": palette[class_id, :3].astype(int).tolist(),
|
||||||
|
"disposition": "undefined" if class_id == 0 else "prediction",
|
||||||
|
}
|
||||||
|
for class_id in range(CLASS_COUNT)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"aggregate_prediction_pixels": aggregate.tolist(),
|
||||||
|
"frames": frame_results,
|
||||||
|
"timing": timing,
|
||||||
|
"resource": {
|
||||||
|
"hostname": platform.node(),
|
||||||
|
"gpu_name": torch.cuda.get_device_name(0),
|
||||||
|
"peak_allocated_vram_bytes": int(torch.cuda.max_memory_allocated()),
|
||||||
|
"peak_reserved_vram_bytes": int(torch.cuda.max_memory_reserved()),
|
||||||
|
"torch_version": torch.__version__,
|
||||||
|
"cuda_runtime_version": torch.version.cuda,
|
||||||
|
"python_version": platform.python_version(),
|
||||||
|
},
|
||||||
|
"provenance": {
|
||||||
|
"pack_manifest_sha256": sha256(args.pack / "manifest.json"),
|
||||||
|
"config_sha256": sha256(args.config),
|
||||||
|
"policy_sha256": sha256(args.policy),
|
||||||
|
"provider_map_sha256": sha256(args.provider_map),
|
||||||
|
"runner_sha256": sha256(Path(__file__)),
|
||||||
|
},
|
||||||
|
"limitations": [
|
||||||
|
"Selected independently decodable islands are not a complete route timeline.",
|
||||||
|
"RAVNOVES004TREE has no route truth; class colors are model predictions.",
|
||||||
|
"DDRNet evidence cannot clear rigid geometry, person or vehicle vetoes.",
|
||||||
|
],
|
||||||
|
"authority": AUTHORITY,
|
||||||
|
}
|
||||||
|
result["result_id"] = f"mixed-route-ddrnet-islands-{stable_digest(result)}"
|
||||||
|
(args.output / "result.json").write_text(
|
||||||
|
json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"result_id": result["result_id"],
|
||||||
|
"frames": len(frame_results),
|
||||||
|
"latency_p95_ms": timing["latency_ms_p95"],
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(run())
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Seal fail-closed TRAVEL/TGS evidence for mixed-route review islands."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from build_tgs_fail_closed_evidence import (
|
||||||
|
TgsEvidenceError,
|
||||||
|
_load_float32,
|
||||||
|
classify_exact_input,
|
||||||
|
costmap_grid,
|
||||||
|
rasterize_costmap,
|
||||||
|
sha256_file,
|
||||||
|
write_deterministic_npz,
|
||||||
|
)
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = "missioncore.mixed-route-tgs-review-profile/v1"
|
||||||
|
INPUT_SCHEMA = "missioncore.mixed-route-tgs-input/v1"
|
||||||
|
RESULT_SCHEMA = "missioncore.mixed-route-tgs-result/v1"
|
||||||
|
FRAME_COUNT = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _timing(path: Path) -> dict[str, object]:
|
||||||
|
rows: list[dict[str, object]] = []
|
||||||
|
with path.open(encoding="utf-8", newline="") as stream:
|
||||||
|
for raw in csv.DictReader(stream, delimiter="\t"):
|
||||||
|
try:
|
||||||
|
row = {
|
||||||
|
"profile_id": str(raw["profile"]),
|
||||||
|
"slot": int(raw["slot"]),
|
||||||
|
"wall_seconds": float(raw["wall_seconds"]),
|
||||||
|
"max_rss_kib": int(raw["max_rss_kib"]),
|
||||||
|
}
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise TgsEvidenceError("TGS timing row is invalid") from exc
|
||||||
|
if (
|
||||||
|
row["profile_id"] not in {"current_increment", "causal_rolling_1s"}
|
||||||
|
or not 0 <= row["slot"] < FRAME_COUNT
|
||||||
|
or not 0 <= row["wall_seconds"] < 60
|
||||||
|
or not 0 < row["max_rss_kib"] < 16 * 1024 * 1024
|
||||||
|
):
|
||||||
|
raise TgsEvidenceError("TGS timing value is invalid")
|
||||||
|
rows.append(row)
|
||||||
|
if len(rows) != FRAME_COUNT * 2:
|
||||||
|
raise TgsEvidenceError("TGS timing is incomplete")
|
||||||
|
seconds = np.asarray([row["wall_seconds"] for row in rows], dtype=np.float64)
|
||||||
|
return {
|
||||||
|
"runs": rows,
|
||||||
|
"wall_seconds_mean": round(float(seconds.mean()), 6),
|
||||||
|
"wall_seconds_p95": round(float(np.percentile(seconds, 95)), 6),
|
||||||
|
"max_rss_kib": max(int(row["max_rss_kib"]) for row in rows),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build(run_root: Path, config_path: Path, output_root: Path) -> dict[str, object]:
|
||||||
|
if output_root.exists():
|
||||||
|
raise TgsEvidenceError("mixed-route TGS evidence already exists")
|
||||||
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
source = config.get("source") if isinstance(config, dict) else None
|
||||||
|
invariants = config.get("invariants") if isinstance(config, dict) else None
|
||||||
|
if (
|
||||||
|
config.get("schema_version") != CONFIG_SCHEMA
|
||||||
|
or not isinstance(source, dict)
|
||||||
|
or not isinstance(invariants, dict)
|
||||||
|
or invariants.get("aos_allowed") is not False
|
||||||
|
or invariants.get("missing_support_means_free") is not False
|
||||||
|
or invariants.get("future_frames_used") is not False
|
||||||
|
or invariants.get("navigation_or_actuation_allowed") is not False
|
||||||
|
or config.get("state_codes")
|
||||||
|
!= {
|
||||||
|
"UNOBSERVED": 0,
|
||||||
|
"GROUND_SUPPORT": 1,
|
||||||
|
"NONGROUND_OCCUPIED": 2,
|
||||||
|
"UNKNOWN_REJECTED": 3,
|
||||||
|
}
|
||||||
|
):
|
||||||
|
raise TgsEvidenceError("mixed-route TGS profile changed")
|
||||||
|
input_manifest_path = run_root / "inputs" / "input-manifest.json"
|
||||||
|
input_manifest = json.loads(input_manifest_path.read_text(encoding="utf-8"))
|
||||||
|
if (
|
||||||
|
input_manifest.get("schema_version") != INPUT_SCHEMA
|
||||||
|
or input_manifest.get("source_pack_id") != source.get("source_pack_id")
|
||||||
|
or input_manifest.get("source_pack_sha256")
|
||||||
|
!= source.get("source_pack_sha256")
|
||||||
|
or input_manifest.get("config_sha256") != sha256_file(config_path)
|
||||||
|
or input_manifest.get("coordinate_frame") != "map-gravity-local"
|
||||||
|
or input_manifest.get("future_frames_used") is not False
|
||||||
|
or input_manifest.get("frame_count") != FRAME_COUNT
|
||||||
|
or len(input_manifest.get("records", [])) != FRAME_COUNT * 2
|
||||||
|
):
|
||||||
|
raise TgsEvidenceError("mixed-route TGS input manifest changed")
|
||||||
|
records = {
|
||||||
|
(str(row["profile_id"]), int(row["slot"])): row
|
||||||
|
for row in input_manifest["records"]
|
||||||
|
}
|
||||||
|
if len(records) != FRAME_COUNT * 2:
|
||||||
|
raise TgsEvidenceError("mixed-route TGS input records are not unique")
|
||||||
|
|
||||||
|
cell_size = float(config["costmap"]["cell_size_m"])
|
||||||
|
radius = float(config["costmap"]["radius_m"])
|
||||||
|
grid = costmap_grid(radius, cell_size)
|
||||||
|
arrays: dict[str, np.ndarray] = {
|
||||||
|
"costmap_cell_indices_xy": grid[:, :2].astype(np.int32),
|
||||||
|
"costmap_cell_centers_xy_m": grid[:, 2:].astype(np.float32),
|
||||||
|
"source_frame_indices": np.asarray(
|
||||||
|
[
|
||||||
|
records[("current_increment", slot)]["source_frame_index"]
|
||||||
|
for slot in range(FRAME_COUNT)
|
||||||
|
],
|
||||||
|
dtype=np.int64,
|
||||||
|
),
|
||||||
|
"session_seconds": np.asarray(
|
||||||
|
[
|
||||||
|
records[("current_increment", slot)]["session_seconds"]
|
||||||
|
for slot in range(FRAME_COUNT)
|
||||||
|
],
|
||||||
|
dtype=np.float64,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
summaries: list[dict[str, object]] = []
|
||||||
|
for profile_id in ("current_increment", "causal_rolling_1s"):
|
||||||
|
all_points: list[np.ndarray] = []
|
||||||
|
all_states: list[np.ndarray] = []
|
||||||
|
offsets = [0]
|
||||||
|
grid_states: list[np.ndarray] = []
|
||||||
|
ground_counts: list[np.ndarray] = []
|
||||||
|
nonground_counts: list[np.ndarray] = []
|
||||||
|
rejected_counts: list[np.ndarray] = []
|
||||||
|
z_bounds_rows: list[np.ndarray] = []
|
||||||
|
for slot in range(FRAME_COUNT):
|
||||||
|
record = records[(profile_id, slot)]
|
||||||
|
native_path = run_root / "inputs" / str(record["relative_path"])
|
||||||
|
if (
|
||||||
|
not native_path.is_file()
|
||||||
|
or native_path.stat().st_size != record["bytes"]
|
||||||
|
or sha256_file(native_path) != record["sha256"]
|
||||||
|
):
|
||||||
|
raise TgsEvidenceError("sealed mixed-route TGS input changed")
|
||||||
|
output = run_root / "outputs" / profile_id
|
||||||
|
points, states = classify_exact_input(
|
||||||
|
_load_float32(native_path, 4),
|
||||||
|
_load_float32(output / f"{slot}_ground.bin", 4),
|
||||||
|
_load_float32(output / f"{slot}_nonground.bin", 4),
|
||||||
|
min_range_m=float(config["tgs"]["min_range_m"]),
|
||||||
|
max_range_m=float(config["tgs"]["max_range_m"]),
|
||||||
|
)
|
||||||
|
grid_state, ground, nonground, rejected, z_bounds = rasterize_costmap(
|
||||||
|
points,
|
||||||
|
states,
|
||||||
|
grid,
|
||||||
|
cell_size_m=cell_size,
|
||||||
|
)
|
||||||
|
all_points.append(points.astype(np.float32, copy=False))
|
||||||
|
all_states.append(states)
|
||||||
|
offsets.append(offsets[-1] + points.shape[0])
|
||||||
|
grid_states.append(grid_state)
|
||||||
|
ground_counts.append(ground)
|
||||||
|
nonground_counts.append(nonground)
|
||||||
|
rejected_counts.append(rejected)
|
||||||
|
z_bounds_rows.append(z_bounds)
|
||||||
|
accounted = (
|
||||||
|
np.count_nonzero(states == 1)
|
||||||
|
+ np.count_nonzero(states == 2)
|
||||||
|
+ np.count_nonzero(states == 3)
|
||||||
|
== points.shape[0]
|
||||||
|
)
|
||||||
|
summaries.append(
|
||||||
|
{
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"slot": slot,
|
||||||
|
"frame_index": int(record["frame_index"]),
|
||||||
|
"source_frame_index": int(record["source_frame_index"]),
|
||||||
|
"source_sequence": int(record["source_sequence"]),
|
||||||
|
"session_seconds": float(record["session_seconds"]),
|
||||||
|
"point_count": int(points.shape[0]),
|
||||||
|
"ground_point_count": int(np.count_nonzero(states == 1)),
|
||||||
|
"nonground_point_count": int(np.count_nonzero(states == 2)),
|
||||||
|
"rejected_point_count": int(np.count_nonzero(states == 3)),
|
||||||
|
"ground_cell_count": int(np.count_nonzero(grid_state == 1)),
|
||||||
|
"nonground_cell_count": int(np.count_nonzero(grid_state == 2)),
|
||||||
|
"rejected_cell_count": int(np.count_nonzero(grid_state == 3)),
|
||||||
|
"unobserved_cell_count": int(np.count_nonzero(grid_state == 0)),
|
||||||
|
"all_points_accounted": bool(accounted),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
arrays[f"{profile_id}_points_xyz_m"] = np.concatenate(all_points)
|
||||||
|
arrays[f"{profile_id}_point_states"] = np.concatenate(all_states)
|
||||||
|
arrays[f"{profile_id}_point_offsets"] = np.asarray(offsets, dtype=np.int64)
|
||||||
|
arrays[f"{profile_id}_costmap_states"] = np.stack(grid_states)
|
||||||
|
arrays[f"{profile_id}_costmap_ground_point_counts"] = np.stack(ground_counts)
|
||||||
|
arrays[f"{profile_id}_costmap_nonground_point_counts"] = np.stack(
|
||||||
|
nonground_counts
|
||||||
|
)
|
||||||
|
arrays[f"{profile_id}_costmap_rejected_point_counts"] = np.stack(
|
||||||
|
rejected_counts
|
||||||
|
)
|
||||||
|
arrays[f"{profile_id}_costmap_z_bounds_m"] = np.stack(z_bounds_rows)
|
||||||
|
if not all(bool(row["all_points_accounted"]) for row in summaries):
|
||||||
|
raise TgsEvidenceError("mixed-route TGS lost an eligible point")
|
||||||
|
|
||||||
|
output_root.mkdir(parents=True)
|
||||||
|
evidence_path = output_root / "evidence.npz"
|
||||||
|
write_deterministic_npz(evidence_path, arrays)
|
||||||
|
timing = _timing(run_root / "tgs-timing.tsv")
|
||||||
|
result = {
|
||||||
|
"schema_version": RESULT_SCHEMA,
|
||||||
|
"status": "passed-review-only",
|
||||||
|
"source": {
|
||||||
|
"source_id": source["source_id"],
|
||||||
|
"session_id": source["session_id"],
|
||||||
|
"review_pack_id": source["review_pack_id"],
|
||||||
|
"source_pack_id": source["source_pack_id"],
|
||||||
|
"source_pack_sha256": source["source_pack_sha256"],
|
||||||
|
},
|
||||||
|
"config_sha256": sha256_file(config_path),
|
||||||
|
"input_manifest_sha256": sha256_file(input_manifest_path),
|
||||||
|
"evidence": {
|
||||||
|
"path": "evidence.npz",
|
||||||
|
"bytes": evidence_path.stat().st_size,
|
||||||
|
"sha256": sha256_file(evidence_path),
|
||||||
|
},
|
||||||
|
"costmap": {
|
||||||
|
"coordinate_frame": "map-gravity-local",
|
||||||
|
"cell_size_m": cell_size,
|
||||||
|
"radius_m": radius,
|
||||||
|
"cell_count": int(grid.shape[0]),
|
||||||
|
},
|
||||||
|
"anchors": summaries,
|
||||||
|
"timing": timing,
|
||||||
|
"summary": {
|
||||||
|
"frame_count": FRAME_COUNT,
|
||||||
|
"anchor_profile_count": len(summaries),
|
||||||
|
"all_eligible_points_accounted": True,
|
||||||
|
"aos_used": False,
|
||||||
|
"primary_profile": "causal_rolling_1s",
|
||||||
|
},
|
||||||
|
"limitations": [
|
||||||
|
"Selected review islands are not a complete route timeline.",
|
||||||
|
(
|
||||||
|
"TGS separates local ground support from non-ground evidence; it does not "
|
||||||
|
"prove ditch or negative-obstacle detection."
|
||||||
|
),
|
||||||
|
"Camera projection is visual evidence only and cannot clear rigid geometry.",
|
||||||
|
],
|
||||||
|
"authority": {
|
||||||
|
"visual_quality_accepted": False,
|
||||||
|
"traversability_accepted": False,
|
||||||
|
"realtime_accepted": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
"actuation_allowed": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(output_root / "result.json").write_text(
|
||||||
|
json.dumps(result, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--run-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--config", type=Path, required=True)
|
||||||
|
parser.add_argument("--output-root", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
result = build(args.run_root, args.config, args.output_root)
|
||||||
|
print(json.dumps(result["summary"], sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Prepare exact mixed-route LiDAR islands for isolated TRAVEL/TGS review."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from prepare_tgs_fail_closed_inputs import TgsInputError, gravity_local_xyzi
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = "missioncore.mixed-route-tgs-review-profile/v1"
|
||||||
|
PACK_SCHEMA = "missioncore.mixed-route-lidar-pack/v1"
|
||||||
|
INPUT_SCHEMA = "missioncore.mixed-route-tgs-input/v1"
|
||||||
|
FRAME_COUNT = 10
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _slice(points: np.ndarray, offsets: np.ndarray, index: int) -> np.ndarray:
|
||||||
|
return points[int(offsets[index]) : int(offsets[index + 1])]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_offsets(offsets: np.ndarray, point_count: int) -> bool:
|
||||||
|
return bool(
|
||||||
|
offsets.shape == (FRAME_COUNT + 1,)
|
||||||
|
and offsets.dtype == np.int64
|
||||||
|
and int(offsets[0]) == 0
|
||||||
|
and int(offsets[-1]) == point_count
|
||||||
|
and np.all(np.diff(offsets) > 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(source_root: Path, config_path: Path, output_root: Path) -> dict[str, object]:
|
||||||
|
if output_root.exists():
|
||||||
|
raise TgsInputError("mixed-route TGS output already exists")
|
||||||
|
source = source_root.resolve(strict=True)
|
||||||
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
manifest = json.loads((source / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
identity = manifest.get("identity") if isinstance(manifest, dict) else None
|
||||||
|
artifact = manifest.get("artifact") if isinstance(manifest, dict) else None
|
||||||
|
source_config = config.get("source") if isinstance(config, dict) else None
|
||||||
|
invariants = config.get("invariants") if isinstance(config, dict) else None
|
||||||
|
profiles = config.get("profiles") if isinstance(config, dict) else None
|
||||||
|
if (
|
||||||
|
config.get("schema_version") != CONFIG_SCHEMA
|
||||||
|
or not isinstance(source_config, dict)
|
||||||
|
or not isinstance(invariants, dict)
|
||||||
|
or not isinstance(profiles, dict)
|
||||||
|
or set(profiles) != {"current_increment", "causal_rolling_1s"}
|
||||||
|
or source_config.get("input_coordinate_frame")
|
||||||
|
!= "map-gravity-local-translation-only"
|
||||||
|
or invariants.get("lidar_orientation_applied_to_tgs_input") is not False
|
||||||
|
or invariants.get("future_frames_used") is not False
|
||||||
|
or invariants.get("navigation_or_actuation_allowed") is not False
|
||||||
|
or manifest.get("schema_version") != PACK_SCHEMA
|
||||||
|
or not isinstance(identity, dict)
|
||||||
|
or identity.get("schema_version") != PACK_SCHEMA
|
||||||
|
or identity.get("session_id") != source_config.get("session_id")
|
||||||
|
or identity.get("review_pack_id") != source_config.get("review_pack_id")
|
||||||
|
or manifest.get("pack_id") != source_config.get("source_pack_id")
|
||||||
|
or not isinstance(artifact, dict)
|
||||||
|
or artifact.get("path") != "lidar-pack.npz"
|
||||||
|
or artifact.get("sha256") != source_config.get("source_pack_sha256")
|
||||||
|
or identity.get("frame_count") != FRAME_COUNT
|
||||||
|
or identity.get("available_lidar_frames") != FRAME_COUNT
|
||||||
|
or identity.get("causal_history_seconds")
|
||||||
|
!= float(profiles["causal_rolling_1s"]["history_seconds"])
|
||||||
|
or identity.get("ground_truth") is not False
|
||||||
|
):
|
||||||
|
raise TgsInputError("mixed-route TGS source contract changed")
|
||||||
|
pack_path = source / "lidar-pack.npz"
|
||||||
|
if (
|
||||||
|
not pack_path.is_file()
|
||||||
|
or pack_path.stat().st_size != artifact.get("byte_length")
|
||||||
|
or sha256_file(pack_path) != artifact.get("sha256")
|
||||||
|
):
|
||||||
|
raise TgsInputError("mixed-route LiDAR pack changed")
|
||||||
|
|
||||||
|
required = {
|
||||||
|
"frame_indices",
|
||||||
|
"source_frame_indices",
|
||||||
|
"session_seconds",
|
||||||
|
"lidar_session_seconds",
|
||||||
|
"sample_available",
|
||||||
|
"cloud_offsets",
|
||||||
|
"cloud_points_map",
|
||||||
|
"pose_positions_map",
|
||||||
|
"lidar_camera_delta_ms",
|
||||||
|
"pose_point_delta_ms",
|
||||||
|
"causal_history_seconds",
|
||||||
|
"causal_history_offsets",
|
||||||
|
"causal_history_points_map",
|
||||||
|
}
|
||||||
|
with np.load(pack_path, allow_pickle=False) as archive:
|
||||||
|
if not required.issubset(archive.files):
|
||||||
|
raise TgsInputError("mixed-route LiDAR pack members changed")
|
||||||
|
arrays = {name: archive[name] for name in required}
|
||||||
|
current_points = arrays["cloud_points_map"]
|
||||||
|
history_points = arrays["causal_history_points_map"]
|
||||||
|
if (
|
||||||
|
arrays["frame_indices"].shape != (FRAME_COUNT,)
|
||||||
|
or arrays["frame_indices"].dtype != np.int64
|
||||||
|
or not np.array_equal(arrays["frame_indices"], np.arange(FRAME_COUNT))
|
||||||
|
or arrays["source_frame_indices"].shape != (FRAME_COUNT,)
|
||||||
|
or arrays["source_frame_indices"].dtype != np.int64
|
||||||
|
or np.any(np.diff(arrays["source_frame_indices"]) <= 0)
|
||||||
|
or arrays["session_seconds"].shape != (FRAME_COUNT,)
|
||||||
|
or arrays["session_seconds"].dtype != np.float64
|
||||||
|
or np.any(np.diff(arrays["session_seconds"]) <= 0)
|
||||||
|
or arrays["lidar_session_seconds"].shape != (FRAME_COUNT,)
|
||||||
|
or arrays["lidar_session_seconds"].dtype != np.float64
|
||||||
|
or arrays["sample_available"].shape != (FRAME_COUNT,)
|
||||||
|
or arrays["sample_available"].dtype != np.bool_
|
||||||
|
or not arrays["sample_available"].all()
|
||||||
|
or current_points.ndim != 2
|
||||||
|
or current_points.shape[1:] != (3,)
|
||||||
|
or current_points.dtype != np.float32
|
||||||
|
or history_points.ndim != 2
|
||||||
|
or history_points.shape[1:] != (3,)
|
||||||
|
or history_points.dtype != np.float32
|
||||||
|
or not np.isfinite(current_points).all()
|
||||||
|
or not np.isfinite(history_points).all()
|
||||||
|
or not _validate_offsets(arrays["cloud_offsets"], current_points.shape[0])
|
||||||
|
or not _validate_offsets(
|
||||||
|
arrays["causal_history_offsets"], history_points.shape[0]
|
||||||
|
)
|
||||||
|
or arrays["pose_positions_map"].shape != (FRAME_COUNT, 3)
|
||||||
|
or arrays["pose_positions_map"].dtype != np.float64
|
||||||
|
or not np.isfinite(arrays["pose_positions_map"]).all()
|
||||||
|
or arrays["causal_history_seconds"].shape != (1,)
|
||||||
|
or float(arrays["causal_history_seconds"][0])
|
||||||
|
!= float(profiles["causal_rolling_1s"]["history_seconds"])
|
||||||
|
or np.any(np.abs(arrays["lidar_camera_delta_ms"]) > 100.0)
|
||||||
|
or np.any(np.abs(arrays["pose_point_delta_ms"]) > 100.0)
|
||||||
|
):
|
||||||
|
raise TgsInputError("mixed-route LiDAR arrays changed")
|
||||||
|
|
||||||
|
records: list[dict[str, object]] = []
|
||||||
|
for profile_id in ("current_increment", "causal_rolling_1s"):
|
||||||
|
for slot in range(FRAME_COUNT):
|
||||||
|
if profile_id == "current_increment":
|
||||||
|
points_map = _slice(
|
||||||
|
current_points, arrays["cloud_offsets"], slot
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
points_map = _slice(
|
||||||
|
history_points, arrays["causal_history_offsets"], slot
|
||||||
|
)
|
||||||
|
radius = float(profiles[profile_id]["local_radius_m"])
|
||||||
|
relative_xy = (
|
||||||
|
points_map[:, :2].astype(np.float64)
|
||||||
|
- arrays["pose_positions_map"][slot, :2]
|
||||||
|
)
|
||||||
|
points_map = points_map[np.linalg.norm(relative_xy, axis=1) <= radius]
|
||||||
|
native = gravity_local_xyzi(
|
||||||
|
points_map, arrays["pose_positions_map"][slot]
|
||||||
|
)
|
||||||
|
if native.shape[0] == 0:
|
||||||
|
raise TgsInputError("mixed-route TGS profile produced an empty cloud")
|
||||||
|
target = (
|
||||||
|
output_root
|
||||||
|
/ "profiles"
|
||||||
|
/ profile_id
|
||||||
|
/ "velodyne"
|
||||||
|
/ f"{slot:06d}.bin"
|
||||||
|
)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_bytes(np.ascontiguousarray(native).tobytes())
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"slot": slot,
|
||||||
|
"frame_index": slot,
|
||||||
|
"source_frame_index": int(
|
||||||
|
arrays["source_frame_indices"][slot]
|
||||||
|
),
|
||||||
|
"source_sequence": int(
|
||||||
|
arrays["source_frame_indices"][slot]
|
||||||
|
)
|
||||||
|
+ 1,
|
||||||
|
"session_seconds": float(arrays["session_seconds"][slot]),
|
||||||
|
"lidar_session_seconds": float(
|
||||||
|
arrays["lidar_session_seconds"][slot]
|
||||||
|
),
|
||||||
|
"lidar_camera_delta_ms": float(
|
||||||
|
arrays["lidar_camera_delta_ms"][slot]
|
||||||
|
),
|
||||||
|
"pose_point_delta_ms": float(
|
||||||
|
arrays["pose_point_delta_ms"][slot]
|
||||||
|
),
|
||||||
|
"point_count": int(native.shape[0]),
|
||||||
|
"relative_path": target.relative_to(output_root).as_posix(),
|
||||||
|
"bytes": target.stat().st_size,
|
||||||
|
"sha256": sha256_file(target),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
manifest_out = {
|
||||||
|
"schema_version": INPUT_SCHEMA,
|
||||||
|
"source_pack_id": manifest["pack_id"],
|
||||||
|
"source_pack_sha256": artifact["sha256"],
|
||||||
|
"config_sha256": sha256_file(config_path),
|
||||||
|
"coordinate_frame": "map-gravity-local",
|
||||||
|
"transform": "translation-only-preserve-map-gravity-axis",
|
||||||
|
"intensity_policy": "zero-filled-algorithm-compatibility-only",
|
||||||
|
"future_frames_used": False,
|
||||||
|
"frame_count": FRAME_COUNT,
|
||||||
|
"profile_count": 2,
|
||||||
|
"records": records,
|
||||||
|
"authority": {
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
"actuation_allowed": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
manifest_path = output_root / "input-manifest.json"
|
||||||
|
manifest_path.write_text(
|
||||||
|
json.dumps(manifest_out, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return manifest_out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--source-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--config", type=Path, required=True)
|
||||||
|
parser.add_argument("--output-root", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
manifest = prepare(args.source_root, args.config, args.output_root)
|
||||||
|
print(json.dumps({"ok": True, "records": len(manifest["records"])}, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Publish exact, independently decodable camera islands for mixed-route review."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from k1link.compute.jobs import validate_camera_compute_job
|
||||||
|
from k1link.device_plugins.xgrids_k1.mqtt.capture import read_capture_clock_origin
|
||||||
|
|
||||||
|
SCHEMA = "missioncore.mixed-route-review-pack/v1"
|
||||||
|
MAX_INDEX_LINE_BYTES = 64 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class MixedRouteReviewPackError(RuntimeError):
|
||||||
|
"""The selected camera evidence cannot be published without ambiguity."""
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _sequences(value: str) -> tuple[int, ...]:
|
||||||
|
try:
|
||||||
|
sequences = tuple(int(item) for item in value.split(","))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise argparse.ArgumentTypeError("sequences must be comma-separated integers") from exc
|
||||||
|
if not sequences or any(item < 1 for item in sequences):
|
||||||
|
raise argparse.ArgumentTypeError("sequences must be positive")
|
||||||
|
if len(set(sequences)) != len(sequences) or tuple(sorted(sequences)) != sequences:
|
||||||
|
raise argparse.ArgumentTypeError("sequences must be unique and increasing")
|
||||||
|
return sequences
|
||||||
|
|
||||||
|
|
||||||
|
def _arguments() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--job", type=Path, required=True)
|
||||||
|
parser.add_argument("--session", type=Path, required=True)
|
||||||
|
parser.add_argument("--sequences", type=_sequences, required=True)
|
||||||
|
parser.add_argument("--output-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--ffmpeg", type=Path, required=True)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_selected_index(
|
||||||
|
path: Path,
|
||||||
|
sequences: tuple[int, ...],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
wanted = set(sequences)
|
||||||
|
selected: dict[int, dict[str, Any]] = {}
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for expected_sequence, line in enumerate(stream, start=1):
|
||||||
|
if len(line) > MAX_INDEX_LINE_BYTES or not line.endswith(b"\n"):
|
||||||
|
raise MixedRouteReviewPackError("camera index line is invalid")
|
||||||
|
if expected_sequence not in wanted:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
value = json.loads(line)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise MixedRouteReviewPackError("camera index JSON is invalid") from exc
|
||||||
|
if (
|
||||||
|
not isinstance(value, dict)
|
||||||
|
or value.get("schema_version")
|
||||||
|
!= "missioncore.camera-recording-index/v1"
|
||||||
|
or value.get("kind") != "media"
|
||||||
|
or value.get("sequence") != expected_sequence
|
||||||
|
or value.get("path") != f"segments/{expected_sequence}.m4s"
|
||||||
|
or not isinstance(value.get("session_monotonic_ns"), int)
|
||||||
|
or not isinstance(value.get("host_monotonic_ns"), int)
|
||||||
|
or not isinstance(value.get("host_epoch_ns"), int)
|
||||||
|
):
|
||||||
|
raise MixedRouteReviewPackError("selected camera index row changed")
|
||||||
|
selected[expected_sequence] = value
|
||||||
|
if tuple(sorted(selected)) != sequences:
|
||||||
|
raise MixedRouteReviewPackError("selected camera sequence is incomplete")
|
||||||
|
return [selected[sequence] for sequence in sequences]
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_exact_fragment(
|
||||||
|
*,
|
||||||
|
ffmpeg: Path,
|
||||||
|
init_path: Path,
|
||||||
|
segment_path: Path,
|
||||||
|
output_path: Path,
|
||||||
|
) -> None:
|
||||||
|
input_value = f"concat:{init_path}|{segment_path}"
|
||||||
|
completed = subprocess.run(
|
||||||
|
[
|
||||||
|
os.fspath(ffmpeg),
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-nostdin",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
input_value,
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
os.fspath(output_path),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if completed.returncode != 0 or not output_path.is_file():
|
||||||
|
detail = completed.stderr.strip().splitlines()[-1:] or ["no decoded frame"]
|
||||||
|
raise MixedRouteReviewPackError(
|
||||||
|
f"selected fragment is not independently decodable: {segment_path.name}: {detail[0]}"
|
||||||
|
)
|
||||||
|
with Image.open(output_path) as image:
|
||||||
|
if image.mode != "RGB" or image.size != (800, 600):
|
||||||
|
raise MixedRouteReviewPackError("selected camera frame shape changed")
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(
|
||||||
|
*,
|
||||||
|
job_root: Path,
|
||||||
|
session_root: Path,
|
||||||
|
sequences: tuple[int, ...],
|
||||||
|
output_root: Path,
|
||||||
|
ffmpeg_path: Path,
|
||||||
|
) -> Path:
|
||||||
|
job = validate_camera_compute_job(job_root)
|
||||||
|
session = session_root.resolve(strict=True)
|
||||||
|
if not session.is_dir() or session.name != job.session_id:
|
||||||
|
raise MixedRouteReviewPackError("camera job and observation session differ")
|
||||||
|
capture_root = session / "captures" / "mqtt_live"
|
||||||
|
origin_path = capture_root / "mqtt.timeline.origin.json"
|
||||||
|
origin = read_capture_clock_origin(origin_path)
|
||||||
|
if sequences[-1] > job.segment_count:
|
||||||
|
raise MixedRouteReviewPackError("selected sequence escapes the camera epoch")
|
||||||
|
ffmpeg = ffmpeg_path.resolve(strict=True)
|
||||||
|
if not ffmpeg.is_file():
|
||||||
|
raise MixedRouteReviewPackError("ffmpeg is unavailable")
|
||||||
|
epoch_root = (
|
||||||
|
job.job_root
|
||||||
|
/ "input"
|
||||||
|
/ "camera"
|
||||||
|
/ job.source_id
|
||||||
|
/ f"epoch-{job.codec_epoch}"
|
||||||
|
)
|
||||||
|
selected = _read_selected_index(epoch_root / "index.jsonl", sequences)
|
||||||
|
identity = {
|
||||||
|
"schema_version": SCHEMA,
|
||||||
|
"job_id": job.job_id,
|
||||||
|
"input_sha256": job.input_sha256,
|
||||||
|
"session_id": job.session_id,
|
||||||
|
"source_id": job.source_id,
|
||||||
|
"codec_epoch": job.codec_epoch,
|
||||||
|
"clock_origin": {
|
||||||
|
"artifact_sha256": _sha256(origin_path),
|
||||||
|
"started_epoch_ns": origin.started_at_epoch_ns,
|
||||||
|
"started_monotonic_ns": origin.started_monotonic_ns,
|
||||||
|
},
|
||||||
|
"selected_sequences": list(sequences),
|
||||||
|
"selection_policy": "exact-independently-decodable-fragments/v1",
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": {
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
"actuation_allowed": False,
|
||||||
|
},
|
||||||
|
"producer_sha256": _sha256(Path(__file__).resolve(strict=True)),
|
||||||
|
}
|
||||||
|
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||||
|
pack_id = f"mixed-route-review-pack-{identity_sha256}"
|
||||||
|
parent = output_root.resolve()
|
||||||
|
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
final = parent / pack_id
|
||||||
|
if final.exists():
|
||||||
|
return final
|
||||||
|
staging = Path(tempfile.mkdtemp(prefix=f".{pack_id}.", dir=parent))
|
||||||
|
published = False
|
||||||
|
try:
|
||||||
|
frames_root = staging / "frames"
|
||||||
|
frames_root.mkdir(mode=0o700)
|
||||||
|
timeline_rows: list[dict[str, Any]] = []
|
||||||
|
artifacts: list[dict[str, Any]] = []
|
||||||
|
for frame_index, (sequence, row) in enumerate(
|
||||||
|
zip(sequences, selected, strict=True)
|
||||||
|
):
|
||||||
|
output_path = frames_root / f"frame-{frame_index + 1:06d}.png"
|
||||||
|
segment_path = epoch_root / "segments" / f"{sequence}.m4s"
|
||||||
|
_decode_exact_fragment(
|
||||||
|
ffmpeg=ffmpeg,
|
||||||
|
init_path=epoch_root / "init.mp4",
|
||||||
|
segment_path=segment_path,
|
||||||
|
output_path=output_path,
|
||||||
|
)
|
||||||
|
host_monotonic_ns = int(row["host_monotonic_ns"])
|
||||||
|
if host_monotonic_ns < origin.started_monotonic_ns:
|
||||||
|
raise MixedRouteReviewPackError("selected frame predates the session clock origin")
|
||||||
|
session_seconds = (
|
||||||
|
host_monotonic_ns - origin.started_monotonic_ns
|
||||||
|
) / 1e9
|
||||||
|
timeline_rows.append(
|
||||||
|
{
|
||||||
|
"frame_index": frame_index,
|
||||||
|
"sequence": frame_index + 1,
|
||||||
|
"source_frame_index": sequence - 1,
|
||||||
|
"source_sequence": sequence,
|
||||||
|
"session_seconds": session_seconds,
|
||||||
|
"host_monotonic_ns": row["host_monotonic_ns"],
|
||||||
|
"host_epoch_ns": row["host_epoch_ns"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
artifacts.append(
|
||||||
|
{
|
||||||
|
"path": output_path.relative_to(staging).as_posix(),
|
||||||
|
"byte_length": output_path.stat().st_size,
|
||||||
|
"sha256": _sha256(output_path),
|
||||||
|
"source_segment_sha256": row["sha256"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
timeline_path = staging / "timeline.jsonl"
|
||||||
|
timeline_path.write_text(
|
||||||
|
"".join(
|
||||||
|
json.dumps(
|
||||||
|
row,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
for row in timeline_rows
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
manifest = {
|
||||||
|
"schema_version": SCHEMA,
|
||||||
|
"pack_id": pack_id,
|
||||||
|
"identity_sha256": identity_sha256,
|
||||||
|
"identity": identity,
|
||||||
|
"created_at_utc": datetime.now(UTC)
|
||||||
|
.isoformat(timespec="milliseconds")
|
||||||
|
.replace("+00:00", "Z"),
|
||||||
|
"frame_count": len(sequences),
|
||||||
|
"timeline": {
|
||||||
|
"path": timeline_path.name,
|
||||||
|
"byte_length": timeline_path.stat().st_size,
|
||||||
|
"sha256": _sha256(timeline_path),
|
||||||
|
},
|
||||||
|
"frames": artifacts,
|
||||||
|
}
|
||||||
|
(staging / "manifest.json").write_text(
|
||||||
|
json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(staging, final)
|
||||||
|
published = True
|
||||||
|
finally:
|
||||||
|
if not published:
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
return final
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = _arguments()
|
||||||
|
result = prepare(
|
||||||
|
job_root=args.job,
|
||||||
|
session_root=args.session,
|
||||||
|
sequences=args.sequences,
|
||||||
|
output_root=args.output_root,
|
||||||
|
ffmpeg_path=args.ffmpeg,
|
||||||
|
)
|
||||||
|
print(json.dumps({"pack_id": result.name, "output": os.fspath(result)}, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
"""Seal RAVNOVES004TREE mixed-route review into the existing vegetation LAB."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
from k1link.laboratory.vegetation_shadow_lab import (
|
||||||
|
LAB_SCHEMA,
|
||||||
|
RESULT_PREFIX,
|
||||||
|
VegetationShadowLabError,
|
||||||
|
canonical_json,
|
||||||
|
sha256_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
REVIEW_SCHEMA = "missioncore.mixed-route-review-pack/v1"
|
||||||
|
DDRNET_SCHEMA = "missioncore.mixed-route-ddrnet-islands/v1"
|
||||||
|
TGS_SCHEMA = "missioncore.mixed-route-tgs-result/v1"
|
||||||
|
FRAME_COUNT = 10
|
||||||
|
PHASES = (
|
||||||
|
"rural",
|
||||||
|
"rural",
|
||||||
|
"rural",
|
||||||
|
"rural",
|
||||||
|
"rural",
|
||||||
|
"transition",
|
||||||
|
"urban",
|
||||||
|
"urban",
|
||||||
|
"urban",
|
||||||
|
"urban",
|
||||||
|
)
|
||||||
|
TGS_COLORS = {
|
||||||
|
0: (5, 7, 9),
|
||||||
|
1: (132, 188, 86),
|
||||||
|
2: (235, 112, 122),
|
||||||
|
3: (150, 154, 163),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path, label: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise VegetationShadowLabError(f"{label} is invalid") from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise VegetationShadowLabError(f"{label} must be an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact(
|
||||||
|
source: Path,
|
||||||
|
staging: Path,
|
||||||
|
relative: str,
|
||||||
|
artifacts: list[dict[str, object]],
|
||||||
|
*,
|
||||||
|
role: str,
|
||||||
|
media_type: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if source.is_symlink() or not source.is_file():
|
||||||
|
raise VegetationShadowLabError(f"mixed-route artifact is unavailable: {relative}")
|
||||||
|
target = staging / relative
|
||||||
|
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
shutil.copyfile(source, target)
|
||||||
|
descriptor = {
|
||||||
|
"role": role,
|
||||||
|
"path": relative,
|
||||||
|
"byte_length": target.stat().st_size,
|
||||||
|
"sha256": sha256_path(target),
|
||||||
|
"media_type": media_type,
|
||||||
|
}
|
||||||
|
artifacts.append(descriptor)
|
||||||
|
return descriptor
|
||||||
|
|
||||||
|
|
||||||
|
def _image_proof(descriptor: dict[str, object]) -> dict[str, object]:
|
||||||
|
return {"path": descriptor["path"], "sha256": descriptor["sha256"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_tgs_costmaps(tgs_root: Path, destination: Path) -> list[Path]:
|
||||||
|
result = _read_json(tgs_root / "result.json", "mixed-route TGS result")
|
||||||
|
evidence = result.get("evidence")
|
||||||
|
costmap = result.get("costmap")
|
||||||
|
if (
|
||||||
|
result.get("schema_version") != TGS_SCHEMA
|
||||||
|
or result.get("status") != "passed-review-only"
|
||||||
|
or not isinstance(evidence, dict)
|
||||||
|
or not isinstance(costmap, dict)
|
||||||
|
or result.get("summary", {}).get("frame_count") != FRAME_COUNT
|
||||||
|
or result.get("authority", {}).get("actuation_allowed") is not False
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("mixed-route TGS contract changed")
|
||||||
|
evidence_path = tgs_root / str(evidence.get("path"))
|
||||||
|
if (
|
||||||
|
not evidence_path.is_file()
|
||||||
|
or evidence.get("bytes") != evidence_path.stat().st_size
|
||||||
|
or evidence.get("sha256") != sha256_path(evidence_path)
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("mixed-route TGS evidence changed")
|
||||||
|
with np.load(evidence_path, allow_pickle=False) as archive:
|
||||||
|
centers = archive["costmap_cell_centers_xy_m"]
|
||||||
|
states = archive["causal_rolling_1s_costmap_states"]
|
||||||
|
if centers.shape != (2244, 2) or states.shape != (FRAME_COUNT, 2244):
|
||||||
|
raise VegetationShadowLabError("mixed-route TGS costmap shape changed")
|
||||||
|
radius = float(costmap["radius_m"])
|
||||||
|
cell_size = float(costmap["cell_size_m"])
|
||||||
|
size = 600
|
||||||
|
scale = size / (radius * 2.0)
|
||||||
|
outputs: list[Path] = []
|
||||||
|
destination.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
for slot in range(FRAME_COUNT):
|
||||||
|
image = Image.new("RGB", (size, size), TGS_COLORS[0])
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
half = cell_size * scale / 2.0
|
||||||
|
for center, state in zip(centers, states[slot], strict=True):
|
||||||
|
x = (float(center[0]) + radius) * scale
|
||||||
|
y = (radius - float(center[1])) * scale
|
||||||
|
draw.rectangle((x - half, y - half, x + half, y + half), fill=TGS_COLORS[int(state)])
|
||||||
|
rover_w = 0.8 * scale
|
||||||
|
rover_l = 1.0 * scale
|
||||||
|
cx = size / 2.0
|
||||||
|
cy = size / 2.0
|
||||||
|
draw.rectangle(
|
||||||
|
(cx - rover_w / 2, cy - rover_l / 2, cx + rover_w / 2, cy + rover_l / 2),
|
||||||
|
outline=(255, 255, 255),
|
||||||
|
width=3,
|
||||||
|
)
|
||||||
|
path = destination / f"frame-{slot + 1:06d}.png"
|
||||||
|
image.save(path, format="PNG", optimize=True)
|
||||||
|
outputs.append(path)
|
||||||
|
return outputs
|
||||||
|
|
||||||
|
|
||||||
|
def seal_mixed_route_vegetation_review(
|
||||||
|
*,
|
||||||
|
base_lab_root: Path,
|
||||||
|
review_pack_root: Path,
|
||||||
|
eomt_root: Path,
|
||||||
|
ddrnet_root: Path,
|
||||||
|
tgs_root: Path,
|
||||||
|
output_root: Path,
|
||||||
|
) -> Path:
|
||||||
|
base_root = base_lab_root.resolve(strict=True)
|
||||||
|
base = _read_json(base_root / "result.json", "base vegetation LAB")
|
||||||
|
base_identity = base.get("identity")
|
||||||
|
if (
|
||||||
|
base.get("schema_version") != LAB_SCHEMA
|
||||||
|
or not isinstance(base_identity, dict)
|
||||||
|
or hashlib.sha256(canonical_json(base_identity)).hexdigest()
|
||||||
|
!= base.get("identity_sha256")
|
||||||
|
or base.get("result_id") != base_root.name
|
||||||
|
or not base_root.name.startswith(RESULT_PREFIX)
|
||||||
|
or base.get("authority", {}).get("commands_enabled") is not False
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("base vegetation LAB proof changed")
|
||||||
|
|
||||||
|
pack_root = review_pack_root.resolve(strict=True)
|
||||||
|
pack = _read_json(pack_root / "manifest.json", "mixed-route review pack")
|
||||||
|
timeline_path = pack_root / str(pack.get("timeline", {}).get("path"))
|
||||||
|
if (
|
||||||
|
pack.get("schema_version") != REVIEW_SCHEMA
|
||||||
|
or pack.get("frame_count") != FRAME_COUNT
|
||||||
|
or pack.get("identity", {}).get("session_id") != "20260828T130511Z_viewer_live"
|
||||||
|
or pack.get("identity", {}).get("ground_truth") is not False
|
||||||
|
or not timeline_path.is_file()
|
||||||
|
or pack.get("timeline", {}).get("sha256") != sha256_path(timeline_path)
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("mixed-route review pack changed")
|
||||||
|
timeline = [json.loads(line) for line in timeline_path.read_text(encoding="utf-8").splitlines()]
|
||||||
|
if len(timeline) != FRAME_COUNT:
|
||||||
|
raise VegetationShadowLabError("mixed-route timeline is incomplete")
|
||||||
|
|
||||||
|
eomt = _read_json(eomt_root / "run-report.partial.json", "mixed-route EoMT result")
|
||||||
|
ddrnet = _read_json(ddrnet_root / "result.json", "mixed-route DDRNet result")
|
||||||
|
tgs = _read_json(tgs_root / "result.json", "mixed-route TGS result")
|
||||||
|
if (
|
||||||
|
eomt.get("input", {}).get("frames_admitted") != FRAME_COUNT
|
||||||
|
or eomt.get("metrics", {}).get("frames_processed") != FRAME_COUNT
|
||||||
|
or eomt.get("ground_truth") is not False
|
||||||
|
or ddrnet.get("schema_version") != DDRNET_SCHEMA
|
||||||
|
or ddrnet.get("source", {}).get("pack_id") != pack["pack_id"]
|
||||||
|
or len(ddrnet.get("frames", [])) != FRAME_COUNT
|
||||||
|
or ddrnet.get("authority", {}).get("candidate_accepted") is not False
|
||||||
|
or tgs.get("schema_version") != TGS_SCHEMA
|
||||||
|
or tgs.get("source", {}).get("review_pack_id") != pack["pack_id"]
|
||||||
|
or tgs.get("summary", {}).get("frame_count") != FRAME_COUNT
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("mixed-route model identities differ")
|
||||||
|
|
||||||
|
output_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
temporary = Path(tempfile.mkdtemp(prefix=".mixed-route-vegetation-", dir=output_root))
|
||||||
|
artifacts: list[dict[str, object]] = []
|
||||||
|
try:
|
||||||
|
tgs_images = _render_tgs_costmaps(tgs_root, temporary / ".tgs-render")
|
||||||
|
cases: list[dict[str, object]] = []
|
||||||
|
tgs_anchors = {
|
||||||
|
int(row["slot"]): row
|
||||||
|
for row in tgs["anchors"]
|
||||||
|
if row.get("profile_id") == "causal_rolling_1s"
|
||||||
|
}
|
||||||
|
for slot, row in enumerate(timeline):
|
||||||
|
case_id = f"route-{slot + 1:02d}"
|
||||||
|
relative_root = f"route-review/{case_id}"
|
||||||
|
source_descriptor = _artifact(
|
||||||
|
pack_root / "frames" / f"frame-{slot + 1:06d}.png",
|
||||||
|
temporary,
|
||||||
|
f"{relative_root}/source.png",
|
||||||
|
artifacts,
|
||||||
|
role="mixed-route-source-frame",
|
||||||
|
media_type="image/png",
|
||||||
|
)
|
||||||
|
city_descriptor = _artifact(
|
||||||
|
eomt_root / "overlay-frames" / f"frame-{slot + 1:06d}.png",
|
||||||
|
temporary,
|
||||||
|
f"{relative_root}/city.png",
|
||||||
|
artifacts,
|
||||||
|
role="mixed-route-eomt-overlay",
|
||||||
|
media_type="image/png",
|
||||||
|
)
|
||||||
|
vegetation_descriptor = _artifact(
|
||||||
|
ddrnet_root / "overlay-frames" / f"frame-{slot + 1:06d}.png",
|
||||||
|
temporary,
|
||||||
|
f"{relative_root}/vegetation.png",
|
||||||
|
artifacts,
|
||||||
|
role="mixed-route-ddrnet-overlay",
|
||||||
|
media_type="image/png",
|
||||||
|
)
|
||||||
|
tgs_descriptor = _artifact(
|
||||||
|
tgs_images[slot],
|
||||||
|
temporary,
|
||||||
|
f"{relative_root}/tgs.png",
|
||||||
|
artifacts,
|
||||||
|
role="mixed-route-tgs-costmap",
|
||||||
|
media_type="image/png",
|
||||||
|
)
|
||||||
|
anchor = tgs_anchors[slot]
|
||||||
|
cases.append(
|
||||||
|
{
|
||||||
|
"case_id": case_id,
|
||||||
|
"phase": PHASES[slot],
|
||||||
|
"source_sequence": int(row["source_sequence"]),
|
||||||
|
"session_seconds": float(row["session_seconds"]),
|
||||||
|
"assets": {
|
||||||
|
"source": _image_proof(source_descriptor),
|
||||||
|
"city": _image_proof(city_descriptor),
|
||||||
|
"vegetation": _image_proof(vegetation_descriptor),
|
||||||
|
"tgs": _image_proof(tgs_descriptor),
|
||||||
|
},
|
||||||
|
"tgs": {
|
||||||
|
"ground_cells": int(anchor["ground_cell_count"]),
|
||||||
|
"occupied_cells": int(anchor["nonground_cell_count"]),
|
||||||
|
"rejected_cells": int(anchor["rejected_cell_count"]),
|
||||||
|
"unobserved_cells": int(anchor["unobserved_cell_count"]),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
shutil.rmtree(temporary / ".tgs-render")
|
||||||
|
|
||||||
|
proofs = {}
|
||||||
|
for key, path in (
|
||||||
|
("base", base_root / "result.json"),
|
||||||
|
("eomt", eomt_root / "run-report.partial.json"),
|
||||||
|
("ddrnet", ddrnet_root / "result.json"),
|
||||||
|
("tgs", tgs_root / "result.json"),
|
||||||
|
):
|
||||||
|
descriptor = _artifact(
|
||||||
|
path,
|
||||||
|
temporary,
|
||||||
|
f"proofs/{key}.json",
|
||||||
|
artifacts,
|
||||||
|
role="mixed-route-proof",
|
||||||
|
media_type="application/json",
|
||||||
|
)
|
||||||
|
proofs[key] = _image_proof(descriptor)
|
||||||
|
_artifact(
|
||||||
|
tgs_root / str(tgs["evidence"]["path"]),
|
||||||
|
temporary,
|
||||||
|
"proofs/tgs-evidence.npz",
|
||||||
|
artifacts,
|
||||||
|
role="mixed-route-tgs-evidence",
|
||||||
|
media_type="application/x-npz",
|
||||||
|
)
|
||||||
|
|
||||||
|
route_review = {
|
||||||
|
"source_id": "RAVNOVES004TREE",
|
||||||
|
"session_id": "20260828T130511Z_viewer_live",
|
||||||
|
"pack_id": pack["pack_id"],
|
||||||
|
"frame_count": FRAME_COUNT,
|
||||||
|
"ground_truth": False,
|
||||||
|
"selection_policy": "same-scene-camera-lidar-aligned-review-islands/v1",
|
||||||
|
"models": {
|
||||||
|
"city": {
|
||||||
|
"name": "EoMT Cityscapes",
|
||||||
|
"frames": FRAME_COUNT,
|
||||||
|
"inference_fps": eomt["metrics"]["inference_frames_per_second"],
|
||||||
|
"end_to_end_p95_ms": eomt["metrics"]["latency_ms"]["end_to_end_ms"]["p95"],
|
||||||
|
},
|
||||||
|
"vegetation": {
|
||||||
|
"name": ddrnet["candidate"]["loaded_model_name"],
|
||||||
|
"result_id": ddrnet["result_id"],
|
||||||
|
"frames": FRAME_COUNT,
|
||||||
|
"latency_p95_ms": ddrnet["timing"]["latency_ms_p95"],
|
||||||
|
},
|
||||||
|
"tgs": {
|
||||||
|
"name": "TRAVEL/TGS causal rolling 1 s",
|
||||||
|
"frames": FRAME_COUNT,
|
||||||
|
"latency_p95_ms": tgs["timing"]["wall_seconds_p95"] * 1000.0,
|
||||||
|
"cell_size_m": tgs["costmap"]["cell_size_m"],
|
||||||
|
"radius_m": tgs["costmap"]["radius_m"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"cases": cases,
|
||||||
|
"proofs": proofs,
|
||||||
|
"limitations": [
|
||||||
|
"Ten aligned review islands are not a complete route timeline.",
|
||||||
|
"RAVNOVES004TREE has no manual truth.",
|
||||||
|
"DDRNet vegetation subtypes remain visually noisy and are not planner authority.",
|
||||||
|
"TGS does not prove ditch or negative-obstacle detection.",
|
||||||
|
"People and vehicles require an independent fail-safe detector and STOP path.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
authority = {
|
||||||
|
"commands_enabled": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
"actuation_accepted": False,
|
||||||
|
"camera_semantics_can_clear_rigid_geometry": False,
|
||||||
|
}
|
||||||
|
identity = {
|
||||||
|
"lab_id": "lab-v1-vegetation-mission-policy",
|
||||||
|
"base_result_id": base["result_id"],
|
||||||
|
"selected_candidate": base_identity["selected_candidate"],
|
||||||
|
"candidate_metrics": base_identity["candidate_metrics"],
|
||||||
|
"source": {
|
||||||
|
"shadow_session": "RAVNOVES004TREE",
|
||||||
|
"shadow_camera": "sensor.camera.right",
|
||||||
|
"shadow_frame_count": FRAME_COUNT,
|
||||||
|
"video_shadow_frame_count": 0,
|
||||||
|
},
|
||||||
|
"route_review": route_review,
|
||||||
|
"authority": authority,
|
||||||
|
}
|
||||||
|
identity_sha256 = hashlib.sha256(canonical_json(identity)).hexdigest()
|
||||||
|
result_id = f"{RESULT_PREFIX}{identity_sha256}"
|
||||||
|
manifest = {
|
||||||
|
"schema_version": LAB_SCHEMA,
|
||||||
|
"result_id": result_id,
|
||||||
|
"identity_sha256": identity_sha256,
|
||||||
|
"created_at_utc": datetime.now(UTC).isoformat(),
|
||||||
|
"ground_truth": False,
|
||||||
|
"status": "visual-shadow-ready-policy-not-authorized",
|
||||||
|
"identity": identity,
|
||||||
|
"source": identity["source"],
|
||||||
|
"route_video": None,
|
||||||
|
"route_review": route_review,
|
||||||
|
"method": {
|
||||||
|
"completeness": "bounded-review-islands",
|
||||||
|
"execution_class": "ai-inference",
|
||||||
|
"pipeline_id": "ravnoves004tree-eomt-ddrnet-causal-tgs-review/v1",
|
||||||
|
},
|
||||||
|
"metrics": {"candidates": base["metrics"]["candidates"]},
|
||||||
|
"decision": {
|
||||||
|
"selected_candidate": base_identity["selected_candidate"],
|
||||||
|
"visual_shadow_ready": True,
|
||||||
|
"full_video_shadow_ready": False,
|
||||||
|
"mission_policy_ready_for_configuration": True,
|
||||||
|
"multilayer_policy_review_ready": True,
|
||||||
|
"navigation_accepted": False,
|
||||||
|
"production_accepted": False,
|
||||||
|
},
|
||||||
|
"limitations": route_review["limitations"],
|
||||||
|
"authority": authority,
|
||||||
|
"catalogs": {"goose": [], "ravnoves": []},
|
||||||
|
"artifacts": artifacts,
|
||||||
|
}
|
||||||
|
(temporary / "result.json").write_bytes(canonical_json(manifest) + b"\n")
|
||||||
|
destination = output_root / result_id
|
||||||
|
if destination.exists():
|
||||||
|
raise VegetationShadowLabError("immutable mixed-route LAB result already exists")
|
||||||
|
os.replace(temporary, destination)
|
||||||
|
return destination
|
||||||
|
except Exception:
|
||||||
|
shutil.rmtree(temporary, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--base-lab-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--review-pack-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--eomt-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--ddrnet-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--tgs-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--output-root", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
print(
|
||||||
|
seal_mixed_route_vegetation_review(
|
||||||
|
base_lab_root=args.base_lab_root,
|
||||||
|
review_pack_root=args.review_pack_root,
|
||||||
|
eomt_root=args.eomt_root,
|
||||||
|
ddrnet_root=args.ddrnet_root,
|
||||||
|
tgs_root=args.tgs_root,
|
||||||
|
output_root=args.output_root,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user