feat(perception): add DDRNet full-video vegetation replay
This commit is contained in:
@@ -51,6 +51,26 @@ export interface VegetationVisualCase {
|
||||
assets: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface VegetationVideoSemanticClass {
|
||||
classId: number;
|
||||
label: string;
|
||||
colorRgb: readonly [number, number, number];
|
||||
disposition: "prediction" | "undefined";
|
||||
}
|
||||
|
||||
export interface VegetationRouteVideo {
|
||||
workerResultId: string;
|
||||
m47ReferenceGraphResultId: string;
|
||||
baseM4ResultId: string;
|
||||
frameCount: 4489;
|
||||
width: 800;
|
||||
height: 600;
|
||||
centerCropXyxy: readonly [100, 0, 700, 600];
|
||||
outsideCropState: "undefined";
|
||||
taxonomy: readonly VegetationVideoSemanticClass[];
|
||||
aggregatePredictionPixels: readonly number[];
|
||||
}
|
||||
|
||||
export interface VegetationShadowResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
@@ -59,6 +79,7 @@ export interface VegetationShadowResult {
|
||||
candidates: readonly VegetationCandidateMetrics[];
|
||||
routeCases: readonly VegetationVisualCase[];
|
||||
validationCases: readonly VegetationVisualCase[];
|
||||
routeVideo: VegetationRouteVideo | null;
|
||||
limitations: readonly string[];
|
||||
visualShadowReady: true;
|
||||
missionPolicyReadyForConfiguration: true;
|
||||
@@ -227,6 +248,98 @@ function visualCaseValue(
|
||||
};
|
||||
}
|
||||
|
||||
function routeVideoValue(value: unknown): VegetationRouteVideo | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const row = objectValue(value, "vegetation.route_video");
|
||||
const workerResultId = textValue(row.worker_result_id, "vegetation.route_video.worker_result_id");
|
||||
const m47ReferenceGraphResultId = textValue(
|
||||
row.m47_reference_graph_result_id,
|
||||
"vegetation.route_video.m47_reference_graph_result_id",
|
||||
);
|
||||
const baseM4ResultId = textValue(row.base_m4_result_id, "vegetation.route_video.base_m4_result_id");
|
||||
if (
|
||||
!/^lab-v1-ravnoves-video-ddrnet-[a-f0-9]{64}$/.test(workerResultId)
|
||||
|| !/^m47-reference-graph-lab-[a-f0-9]{64}$/.test(m47ReferenceGraphResultId)
|
||||
|| !/^m4-threat-replay-[a-f0-9]{64}$/.test(baseM4ResultId)
|
||||
) {
|
||||
throw new VegetationShadowContractError("vegetation.route_video: identity invalid.");
|
||||
}
|
||||
exact(row.frame_count, 4489, "vegetation.route_video.frame_count");
|
||||
exact(row.width, 800, "vegetation.route_video.width");
|
||||
exact(row.height, 600, "vegetation.route_video.height");
|
||||
exact(row.outside_crop_state, "undefined", "vegetation.route_video.outside_crop_state");
|
||||
exact(
|
||||
row.sequence_binding,
|
||||
"sequence-0-to-masks/frame-000001.png",
|
||||
"vegetation.route_video.sequence_binding",
|
||||
);
|
||||
const crop = arrayValue(row.center_crop_xyxy, "vegetation.route_video.center_crop_xyxy")
|
||||
.map((item, index) => integerValue(item, `vegetation.route_video.crop[${index}]`));
|
||||
if (crop.join(",") !== "100,0,700,600") {
|
||||
throw new VegetationShadowContractError("vegetation.route_video: crop contract changed.");
|
||||
}
|
||||
const taxonomy = objectValue(row.taxonomy, "vegetation.route_video.taxonomy");
|
||||
exact(
|
||||
taxonomy.schema_version,
|
||||
"missioncore.lab-v1-vegetation-taxonomy/v1",
|
||||
"vegetation.route_video.taxonomy.schema",
|
||||
);
|
||||
const classes = arrayValue(taxonomy.classes, "vegetation.route_video.taxonomy.classes")
|
||||
.map((value, expectedId): VegetationVideoSemanticClass => {
|
||||
const item = objectValue(value, `vegetation.route_video.taxonomy[${expectedId}]`);
|
||||
const classId = integerValue(item.class_id, `vegetation.route_video.class_id[${expectedId}]`);
|
||||
if (classId !== expectedId) {
|
||||
throw new VegetationShadowContractError("vegetation.route_video: taxonomy order changed.");
|
||||
}
|
||||
const color = arrayValue(item.color_rgb, `vegetation.route_video.color[${expectedId}]`)
|
||||
.map((channel, index) => integerValue(channel, `vegetation.route_video.color[${expectedId}][${index}]`));
|
||||
if (color.length !== 3 || color.some((channel) => channel > 255)) {
|
||||
throw new VegetationShadowContractError("vegetation.route_video: taxonomy color invalid.");
|
||||
}
|
||||
const disposition: VegetationVideoSemanticClass["disposition"] = expectedId === 0
|
||||
? "undefined"
|
||||
: "prediction";
|
||||
if (item.disposition !== disposition) {
|
||||
throw new VegetationShadowContractError("vegetation.route_video: taxonomy disposition changed.");
|
||||
}
|
||||
return {
|
||||
classId,
|
||||
label: textValue(item.label, `vegetation.route_video.label[${expectedId}]`),
|
||||
colorRgb: color as unknown as readonly [number, number, number],
|
||||
disposition,
|
||||
};
|
||||
});
|
||||
if (classes.length !== 64) {
|
||||
throw new VegetationShadowContractError("vegetation.route_video: taxonomy must contain 64 classes.");
|
||||
}
|
||||
const aggregatePredictionPixels = arrayValue(
|
||||
row.aggregate_prediction_pixels,
|
||||
"vegetation.route_video.aggregate_prediction_pixels",
|
||||
).map((value, index) => integerValue(value, `vegetation.route_video.pixels[${index}]`));
|
||||
if (aggregatePredictionPixels.length !== 64) {
|
||||
throw new VegetationShadowContractError("vegetation.route_video: class accounting changed.");
|
||||
}
|
||||
const maskArchive = objectValue(row.mask_archive, "vegetation.route_video.mask_archive");
|
||||
exact(maskArchive.path, "video/ddrnet-semantic-masks.zip", "vegetation.route_video.mask_archive.path");
|
||||
const archiveSha256 = textValue(maskArchive.sha256, "vegetation.route_video.mask_archive.sha256");
|
||||
if (!SHA256.test(archiveSha256)) {
|
||||
throw new VegetationShadowContractError("vegetation.route_video: archive digest invalid.");
|
||||
}
|
||||
integerValue(maskArchive.byte_length, "vegetation.route_video.mask_archive.byte_length");
|
||||
return {
|
||||
workerResultId,
|
||||
m47ReferenceGraphResultId,
|
||||
baseM4ResultId,
|
||||
frameCount: 4489,
|
||||
width: 800,
|
||||
height: 600,
|
||||
centerCropXyxy: [100, 0, 700, 600],
|
||||
outsideCropState: "undefined",
|
||||
taxonomy: classes,
|
||||
aggregatePredictionPixels,
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(value: unknown, resultId: string): VegetationShadowResult {
|
||||
const payload = objectValue(value, "Vegetation LAB");
|
||||
exact(payload.schema_version, "missioncore.lab-v1-vegetation-shadow/v1", "vegetation.schema");
|
||||
@@ -277,6 +390,7 @@ function parseResult(value: unknown, resultId: string): VegetationShadowResult {
|
||||
candidates: CANDIDATES.map((candidate) => candidateMetricsValue(candidates[candidate], candidate)),
|
||||
routeCases,
|
||||
validationCases,
|
||||
routeVideo: routeVideoValue(payload.route_video),
|
||||
limitations: arrayValue(payload.limitations, "vegetation.limitations")
|
||||
.map((item, index) => textValue(item, `vegetation.limitations[${index}]`)),
|
||||
visualShadowReady: true,
|
||||
@@ -290,6 +404,13 @@ function parseResult(value: unknown, resultId: string): VegetationShadowResult {
|
||||
};
|
||||
}
|
||||
|
||||
export function vegetationVideoMaskUrl(resultId: string, sequence: number): string {
|
||||
if (!RESULT_ID.test(resultId) || !Number.isInteger(sequence) || sequence < 0 || sequence >= 4489) {
|
||||
throw new VegetationShadowContractError("Vegetation video mask identity недопустима.");
|
||||
}
|
||||
return `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/masks/${sequence}`;
|
||||
}
|
||||
|
||||
export async function fetchVegetationShadowResult(
|
||||
resultId: string,
|
||||
{
|
||||
|
||||
@@ -97,7 +97,16 @@ function SpatialState({ message: text }: { message: string }) {
|
||||
|
||||
export interface M4ReplayThreatSemanticLayer {
|
||||
resultId: string;
|
||||
taxonomy: readonly E47SemanticClass[];
|
||||
spatialResultId?: string | null;
|
||||
maskUrl?: (sequence: number) => string;
|
||||
label?: string;
|
||||
maskAriaLabel?: string;
|
||||
taxonomy: readonly {
|
||||
classId: number;
|
||||
label: string;
|
||||
disposition: "labeled" | "ambiguous" | "prediction" | "undefined";
|
||||
colorRgb: readonly [number, number, number];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface M4ReplayThreatReviewAnchor {
|
||||
@@ -153,6 +162,8 @@ export function M4ReplayThreatVisual({
|
||||
evidenceLabel = "M4.6",
|
||||
initialSpatialMode = null,
|
||||
classifiedSpatialLayer,
|
||||
showReferenceMediaLayers = true,
|
||||
showSpatialOverlaySummary = true,
|
||||
onActiveSequenceChange,
|
||||
}: {
|
||||
resultId: string;
|
||||
@@ -164,6 +175,8 @@ export function M4ReplayThreatVisual({
|
||||
evidenceLabel?: string;
|
||||
initialSpatialMode?: LaboratoryMetricSceneMode | null;
|
||||
classifiedSpatialLayer?: M4ReplayClassifiedSpatialLayer;
|
||||
showReferenceMediaLayers?: boolean;
|
||||
showSpatialOverlaySummary?: boolean;
|
||||
onActiveSequenceChange?: (sequence: number | null) => void;
|
||||
}) {
|
||||
const [mediaMode, setMediaMode] = useState<M4ThreatMediaMode | null>("video");
|
||||
@@ -280,16 +293,30 @@ export function M4ReplayThreatVisual({
|
||||
? lastSpatialFrameRef.current.frame
|
||||
: null;
|
||||
const cameraPointOverlay = useM4ThreatCameraPointOverlay({
|
||||
enabled: showMediaPoints,
|
||||
enabled: showReferenceMediaLayers && showMediaPoints,
|
||||
resultId,
|
||||
sequence: frame?.sequence ?? null,
|
||||
endpointRoot: timelineEndpointRoot,
|
||||
});
|
||||
const semanticSpatialResultId = semantic
|
||||
? semantic.spatialResultId === undefined ? semantic.resultId : semantic.spatialResultId
|
||||
: null;
|
||||
const spatialSemanticTaxonomy = useMemo<readonly E47SemanticClass[]>(
|
||||
() => semanticSpatialResultId && semantic
|
||||
? semantic.taxonomy.map((item) => ({
|
||||
classId: item.classId,
|
||||
label: item.label,
|
||||
disposition: item.disposition === "ambiguous" ? "ambiguous" : "labeled",
|
||||
colorRgb: item.colorRgb,
|
||||
}))
|
||||
: [],
|
||||
[semantic, semanticSpatialResultId],
|
||||
);
|
||||
const semanticTimeline = useE47SemanticTimelineFrame({
|
||||
resultId: semantic?.resultId ?? null,
|
||||
resultId: semanticSpatialResultId,
|
||||
activeSequence: frame?.sequence ?? timelineFrame.activeSequence,
|
||||
frameCount: metadata.timeline?.frameCount ?? 0,
|
||||
taxonomy: semantic?.taxonomy ?? [],
|
||||
taxonomy: spatialSemanticTaxonomy,
|
||||
});
|
||||
const displayingBufferedFrame = Boolean(
|
||||
frame
|
||||
@@ -339,7 +366,8 @@ export function M4ReplayThreatVisual({
|
||||
const staticObstacleBoxes = useMemo<readonly RecordedEvidenceBox[]>(() => {
|
||||
const timeline = metadata.timeline;
|
||||
if (
|
||||
!frame
|
||||
!showReferenceMediaLayers
|
||||
|| !frame
|
||||
|| !timeline?.cameraObstacleProjectionDelivery
|
||||
|| !showStaticObstacles
|
||||
) return [];
|
||||
@@ -348,14 +376,14 @@ export function M4ReplayThreatVisual({
|
||||
timeline.imageWidth,
|
||||
timeline.imageHeight,
|
||||
);
|
||||
}, [frame, metadata.timeline, showStaticObstacles]);
|
||||
}, [frame, metadata.timeline, showReferenceMediaLayers, showStaticObstacles]);
|
||||
const activeBoxes = useMemo(
|
||||
() => classifiedSpatialLayer ? [] : [
|
||||
() => classifiedSpatialLayer || !showReferenceMediaLayers ? [] : [
|
||||
...boxes(frame?.cameraProposals ?? []),
|
||||
...staticObstacleBoxes,
|
||||
...reviewAnchorBoxes,
|
||||
],
|
||||
[classifiedSpatialLayer, frame, reviewAnchorBoxes, staticObstacleBoxes],
|
||||
[classifiedSpatialLayer, frame, reviewAnchorBoxes, showReferenceMediaLayers, staticObstacleBoxes],
|
||||
);
|
||||
const semanticClasses = useMemo<readonly RecordedEvidenceSemanticClass[]>(
|
||||
() => semantic?.taxonomy.map((item) => ({
|
||||
@@ -367,10 +395,14 @@ export function M4ReplayThreatVisual({
|
||||
const semanticPalette = useMemo<readonly RecordedEvidenceSemanticPaletteEntry[]>(
|
||||
() => semantic?.taxonomy.map((item) => ({
|
||||
classId: item.classId,
|
||||
color: item.disposition === "ambiguous"
|
||||
color: item.disposition === "undefined"
|
||||
? { kind: "transparent" as const }
|
||||
: item.disposition === "ambiguous"
|
||||
? { kind: "token" as const, token: "--nodedc-warning-rgb" as const }
|
||||
: { kind: "diagnostic" as const, rgb: item.colorRgb },
|
||||
opacity: item.disposition === "ambiguous" ? 0.52 : 0.92,
|
||||
opacity: item.disposition === "undefined"
|
||||
? 0
|
||||
: item.disposition === "ambiguous" ? 0.52 : 0.92,
|
||||
})) ?? [],
|
||||
[semantic?.taxonomy],
|
||||
);
|
||||
@@ -580,15 +612,17 @@ export function M4ReplayThreatVisual({
|
||||
const semanticOverlay: RecordedEvidenceSemanticOverlay | undefined =
|
||||
semantic && showMediaSemantic && frame
|
||||
? {
|
||||
src: e47SemanticMaskUrl(semantic.resultId, frame.sequence),
|
||||
src: semantic.maskUrl?.(frame.sequence)
|
||||
?? e47SemanticMaskUrl(semantic.resultId, frame.sequence),
|
||||
prefetchSrcs: Array.from({ length: 12 }, (_, index) => index + 1)
|
||||
.map((offset) => frame.sequence + offset)
|
||||
.filter((sequence) => sequence < (metadata.timeline?.frameCount ?? 0))
|
||||
.map((sequence) => e47SemanticMaskUrl(semantic.resultId, sequence)),
|
||||
.map((sequence) => semantic.maskUrl?.(sequence)
|
||||
?? e47SemanticMaskUrl(semantic.resultId, sequence)),
|
||||
classes: semanticClasses,
|
||||
palette: semanticPalette,
|
||||
opacity: 0.9,
|
||||
ariaLabel: `E47 semantic mask frame ${frame.sequence + 1}`,
|
||||
ariaLabel: `${semantic.maskAriaLabel ?? "Semantic prediction"} frame ${frame.sequence + 1}`,
|
||||
}
|
||||
: undefined;
|
||||
const accumulatedCameraPoints = cameraPointOverlay.overlay?.sequence === frame?.sequence
|
||||
@@ -659,8 +693,8 @@ export function M4ReplayThreatVisual({
|
||||
);
|
||||
|
||||
const mediaLayerControls = semantic
|
||||
|| metadata.timeline?.cameraPointDelivery
|
||||
|| metadata.timeline?.cameraObstacleProjectionDelivery ? (
|
||||
|| (showReferenceMediaLayers && metadata.timeline?.cameraPointDelivery)
|
||||
|| (showReferenceMediaLayers && metadata.timeline?.cameraObstacleProjectionDelivery) ? (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-layer-controls"
|
||||
role="group"
|
||||
@@ -677,7 +711,7 @@ export function M4ReplayThreatVisual({
|
||||
SEMANTICS
|
||||
</Button>
|
||||
) : null}
|
||||
{metadata.timeline?.cameraPointDelivery ? (
|
||||
{showReferenceMediaLayers && metadata.timeline?.cameraPointDelivery ? (
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
@@ -689,7 +723,7 @@ export function M4ReplayThreatVisual({
|
||||
POINTS
|
||||
</Button>
|
||||
) : null}
|
||||
{metadata.timeline?.cameraObstacleProjectionDelivery ? (
|
||||
{showReferenceMediaLayers && metadata.timeline?.cameraObstacleProjectionDelivery ? (
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
@@ -738,7 +772,7 @@ export function M4ReplayThreatVisual({
|
||||
>
|
||||
{classifiedSpatialLayer.cellLayerLabel}
|
||||
</Button>
|
||||
{semantic ? (
|
||||
{semanticSpatialResultId ? (
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
@@ -796,7 +830,7 @@ export function M4ReplayThreatVisual({
|
||||
LOW-STEP
|
||||
</Button>
|
||||
) : null}
|
||||
{semantic ? (
|
||||
{semanticSpatialResultId ? (
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
@@ -901,9 +935,11 @@ export function M4ReplayThreatVisual({
|
||||
: playbackController.playback.playing ? "воспроизведение" : "пауза / seek"}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Spatial evidence</span>
|
||||
<strong>{classifiedSpatialLayer
|
||||
{showSpatialOverlaySummary ? (
|
||||
<>
|
||||
<div>
|
||||
<span>Spatial evidence</span>
|
||||
<strong>{classifiedSpatialLayer
|
||||
? classifiedSpatialFrame
|
||||
? replaceClassifiedPointCloud
|
||||
? `${classifiedSpatialFrame.pointsMapGravityLocalXyzM.length.toLocaleString("ru-RU")} TGS points · ${classifiedCellCount.toLocaleString("ru-RU")} cells`
|
||||
@@ -939,15 +975,15 @@ export function M4ReplayThreatVisual({
|
||||
: showMediaPoints && cameraPointOverlay.error
|
||||
? " · накопленное camera cloud недоступно"
|
||||
: ""}
|
||||
{semantic && spatialSemanticFrame
|
||||
{semanticSpatialResultId && spatialSemanticFrame
|
||||
? ` · semantic L ${spatialSemanticFrame.counts.labeled} · A ${spatialSemanticFrame.counts.ambiguous} · U ${spatialSemanticFrame.counts.unprojected} · Ø ${spatialSemanticFrame.counts.absent}`
|
||||
: semantic ? " · semantic buffer" : ""}
|
||||
: semanticSpatialResultId ? " · semantic buffer" : ""}
|
||||
</>
|
||||
)}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>{classifiedSpatialLayer ? "TGS fail-closed" : "Virtual corridor"}</span>
|
||||
<strong>{classifiedSpatialLayer
|
||||
</div>
|
||||
<div>
|
||||
<span>{classifiedSpatialLayer ? "TGS fail-closed" : "Virtual corridor"}</span>
|
||||
<strong>{classifiedSpatialLayer
|
||||
? classifiedSpatialFrame
|
||||
? `${classifiedCellCounts.occupied} occupied · ${classifiedCellCounts.rejected} rejected · ${classifiedCellCounts.unobserved} unobserved`
|
||||
: classifiedSpatialLayer.loading || displayingBufferedFrame ? "loading" : "unavailable"
|
||||
@@ -957,7 +993,9 @@ export function M4ReplayThreatVisual({
|
||||
? `${classifiedCellCounts.ground} ground-support · visual review only · navigation authority OFF`
|
||||
: "visual review only · navigation authority OFF"
|
||||
: `${metadata.timeline.corridor.forwardLengthM} м · body ${metadata.timeline.rig.lengthM}×${metadata.timeline.rig.widthM} м · REPLAY-SIMULATED`}</small>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
@@ -1153,13 +1191,13 @@ export function M4ReplayThreatVisual({
|
||||
<span>{timelineFrame.error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{semantic && semanticTimeline.loading ? (
|
||||
{semanticSpatialResultId && semanticTimeline.loading ? (
|
||||
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Догружаем semantic-point evidence E47</span>
|
||||
</div>
|
||||
) : null}
|
||||
{semanticTimeline.error ? (
|
||||
{semanticSpatialResultId && semanticTimeline.error ? (
|
||||
<div className="m4-replay-threat-visual__buffering" role="alert">
|
||||
<Icon name="alert" size={16} />
|
||||
<span>{semanticTimeline.error}</span>
|
||||
@@ -1201,7 +1239,7 @@ export function M4ReplayThreatVisual({
|
||||
<div className="l3-visual-audit m4-replay-threat-visual">
|
||||
<LaboratoryEvidenceViewer
|
||||
label={semantic
|
||||
? "E47 semantic + SLAM diagnostic replay"
|
||||
? semantic.label ?? "Semantic diagnostic replay"
|
||||
: `${evidenceLabel} recorded-realtime replay`}
|
||||
className="m4-replay-threat-evidence-viewer"
|
||||
mode={mediaMode ?? "none"}
|
||||
|
||||
@@ -4,11 +4,15 @@ import {
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { VegetationShadowResult } from "../../core/laboratory/vegetationShadow";
|
||||
import {
|
||||
vegetationVideoMaskUrl,
|
||||
type VegetationShadowResult,
|
||||
} from "../../core/laboratory/vegetationShadow";
|
||||
import {
|
||||
M48MaskComparisonVisual,
|
||||
type M48MaskComparisonCase,
|
||||
} from "./M48FailureAtlasVisual";
|
||||
import { M4ReplayThreatVisual } from "./M4ReplayThreatVisual";
|
||||
|
||||
function decimal(value: number, digits = 1): string {
|
||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
|
||||
@@ -63,20 +67,28 @@ export function VegetationShadowResultView({
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB V1 · готовые модели растительности"
|
||||
description="Штатный M4.8-инструмент сравнивает две готовые fine-64 модели на полном GOOSE validation split и на 12 truth-backed hard cases, выбранных только по наличию нужной растительности. Sealed evidence открывается локально без Worker 006."
|
||||
status="Truth-backed model comparison · route transfer не принят"
|
||||
description={result.routeVideo
|
||||
? "M4.8 сохраняет truth-backed сравнение моделей, а штатный M4.7 viewer показывает фактический DDRNet prediction на всей записи RAVNOVES00. Все 4489 масок запечатаны локально и открываются без Worker 006."
|
||||
: "Штатный M4.8-инструмент сравнивает две готовые fine-64 модели на полном GOOSE validation split и на 12 truth-backed hard cases, выбранных только по наличию нужной растительности. Sealed evidence открывается локально без Worker 006."}
|
||||
status={result.routeVideo
|
||||
? "DDRNet full-video prediction ready · route truth отсутствует"
|
||||
: "Truth-backed model comparison · route transfer не принят"}
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{ label: "Источник", value: "GOOSE validation · 962 размеченных кадра · 12 vegetation hard cases" },
|
||||
{ label: "Сравнение", value: "DDRNet-39 vs PPLiteSeg · official fine-64 weights" },
|
||||
{ label: "Кейсы", value: "трава · куст · ствол · крона · изгородь · лес · посевы" },
|
||||
...(result.routeVideo ? [{
|
||||
label: "Видео",
|
||||
value: "RAVNOVES00 · 4489/4489 DDRNet masks · exact recorded sequence",
|
||||
}] : []),
|
||||
{ label: "Authority", value: `${rigLabel} · MODEL QUALIFICATION ONLY · commands OFF` },
|
||||
]}
|
||||
brief={{
|
||||
question: "Какие готовые веса лучше различают проезжаемую траву, кусты и стволы на размеченных off-road кадрах?",
|
||||
approach: "Обе модели последовательно прогнаны в одном изолированном CUDA-runtime на 962 кадрах. 12 визуальных кейсов выбраны детерминированно по truth-поддержке восьми растительных классов; один M4.8 viewer показывает source, truth, prediction и material-error для выбранной модели.",
|
||||
principalResult: `${selected.loadedModelName} лидирует по vegetation IoU: ${decimal(selected.vegetationMeanIouPercent, 2)}% против ${decimal(alternative.vegetationMeanIouPercent, 2)}%. Ошибки по каждому типу теперь проверяются в одном штатном инструменте.`,
|
||||
limitation: "Это внешний GOOSE-домен, а не наш fisheye/off-road маршрут. Папоротник отдельным классом отсутствует; RAVNOVES00 не содержит truth-backed vegetation island и не используется как главное визуальное доказательство.",
|
||||
principalResult: `${selected.loadedModelName} лидирует по vegetation IoU: ${decimal(selected.vegetationMeanIouPercent, 2)}% против ${decimal(alternative.vegetationMeanIouPercent, 2)}%. ${result.routeVideo ? "Его фактическая temporal stability теперь видна на всех 4489 кадрах штатного recorded viewer." : "Ошибки по каждому типу проверяются в одном штатном инструменте."}`,
|
||||
limitation: "GOOSE — внешний размеченный домен; RAVNOVES00 — наш fisheye, но без ручной truth-разметки. Full-video слой показывает prediction, а не доказывает правильность. Папоротник отдельным классом отсутствует.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
@@ -93,17 +105,42 @@ export function VegetationShadowResultView({
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="M4.8 · GOOSE VEGETATION HARD CASES"
|
||||
title="ERROR: красный — пропуск · жёлтый — лишнее · фиолетовый — перепутан тип · зелёный — совпадение"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<M48MaskComparisonVisual
|
||||
cases={comparisonCases(result)}
|
||||
initialCandidate={result.selectedCandidate}
|
||||
/>
|
||||
</LaboratoryEvidence>
|
||||
<>
|
||||
<LaboratoryEvidence
|
||||
eyebrow="M4.8 · GOOSE VEGETATION HARD CASES"
|
||||
title="ERROR: красный — пропуск · жёлтый — лишнее · фиолетовый — перепутан тип · зелёный — совпадение"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<M48MaskComparisonVisual
|
||||
cases={comparisonCases(result)}
|
||||
initialCandidate={result.selectedCandidate}
|
||||
/>
|
||||
</LaboratoryEvidence>
|
||||
{result.routeVideo ? (
|
||||
<LaboratoryEvidence
|
||||
eyebrow="M4.7 · RAVNOVES00 FULL VIDEO"
|
||||
title="DDRNet PREDICTION · 4489/4489 кадров · TRUTH для этой записи отсутствует"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<M4ReplayThreatVisual
|
||||
resultId={result.routeVideo.baseM4ResultId}
|
||||
evidenceLabel="LAB V1 · DDRNet"
|
||||
showReferenceMediaLayers={false}
|
||||
showSpatialOverlaySummary={false}
|
||||
semantic={{
|
||||
resultId: result.routeVideo.workerResultId,
|
||||
spatialResultId: null,
|
||||
taxonomy: result.routeVideo.taxonomy,
|
||||
maskUrl: (sequence) => vegetationVideoMaskUrl(result.resultId, sequence),
|
||||
label: "DDRNet vegetation prediction · recorded video",
|
||||
maskAriaLabel: "DDRNet vegetation prediction",
|
||||
}}
|
||||
/>
|
||||
</LaboratoryEvidence>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
@@ -146,11 +183,16 @@ export function VegetationShadowResultView({
|
||||
value: "12 truth-backed cases",
|
||||
hint: "8 vegetation strata · Worker для открытия не требуется",
|
||||
},
|
||||
...(result.routeVideo ? [{
|
||||
label: "Route video",
|
||||
value: "4489/4489 masks",
|
||||
hint: "DDRNet prediction · exact sequence · Worker-independent playback",
|
||||
}] : []),
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "Обе официальные fine-64 модели воспроизводимо запускаются на Worker 006; DDRNet лучше по aggregate vegetation IoU. Truth-backed hard cases прямо показывают траву, кусты и стволы, а не случайные автомобили и здания.",
|
||||
notProved: "Не доказаны accuracy на нашем fisheye-домене, папоротник как отдельный материал, temporal stability, collision safety и physical-live поведение ровера.",
|
||||
decision: "Сохранить DDRNet как стартовый vegetation candidate. Mission-policy и автоматическое переключение пресетов подключать только после truth-backed island нашего офф-роуда; LiDAR/TGS fail-closed геометрию не ослаблять.",
|
||||
notProved: "Не доказаны accuracy на нашем fisheye-домене, папоротник как отдельный материал, collision safety и physical-live поведение ровера. Видео позволяет увидеть temporal stability, но без truth не превращает её в метрику качества.",
|
||||
decision: "Смотреть полный prediction на видео и собирать конкретные temporal/domain failure cases. DDRNet остаётся diagnostic candidate; LiDAR/TGS fail-closed геометрию не ослаблять.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -75,6 +75,35 @@ function visualCase(sourceKind, index) {
|
||||
};
|
||||
}
|
||||
|
||||
function routeVideo() {
|
||||
return {
|
||||
worker_result_id: `lab-v1-ravnoves-video-ddrnet-${"e".repeat(64)}`,
|
||||
m47_reference_graph_result_id: `m47-reference-graph-lab-${"f".repeat(64)}`,
|
||||
base_m4_result_id: `m4-threat-replay-${"1".repeat(64)}`,
|
||||
frame_count: 4489,
|
||||
width: 800,
|
||||
height: 600,
|
||||
center_crop_xyxy: [100, 0, 700, 600],
|
||||
outside_crop_state: "undefined",
|
||||
sequence_binding: "sequence-0-to-masks/frame-000001.png",
|
||||
taxonomy: {
|
||||
schema_version: "missioncore.lab-v1-vegetation-taxonomy/v1",
|
||||
classes: Array.from({ length: 64 }, (_, classId) => ({
|
||||
class_id: classId,
|
||||
label: classId === 0 ? "undefined" : `class-${classId}`,
|
||||
color_rgb: [classId, classId, classId],
|
||||
disposition: classId === 0 ? "undefined" : "prediction",
|
||||
})),
|
||||
},
|
||||
aggregate_prediction_pixels: Array(64).fill(0),
|
||||
mask_archive: {
|
||||
path: "video/ddrnet-semantic-masks.zip",
|
||||
sha256: "9".repeat(64),
|
||||
byte_length: 1024,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("vegetation LAB keeps autonomous assets and fail-closed authority", async () => {
|
||||
let requestedUrl = "";
|
||||
const result = await fetchVegetationShadowResult(resultId, {
|
||||
@@ -111,6 +140,7 @@ test("vegetation LAB keeps autonomous assets and fail-closed authority", async (
|
||||
goose: Array.from({ length: 12 }, (_, index) => visualCase("goose", index)),
|
||||
ravnoves: [],
|
||||
},
|
||||
route_video: routeVideo(),
|
||||
access: "read-only",
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
},
|
||||
@@ -123,6 +153,8 @@ test("vegetation LAB keeps autonomous assets and fail-closed authority", async (
|
||||
assert.equal(result.candidates[0].vegetationMeanIouPercent, 64);
|
||||
assert.equal(result.routeCases.length, 0);
|
||||
assert.equal(result.validationCases.length, 12);
|
||||
assert.equal(result.routeVideo.frameCount, 4489);
|
||||
assert.equal(result.routeVideo.taxonomy[0].disposition, "undefined");
|
||||
assert.equal(result.validationCases[0].focus.className, "high_grass");
|
||||
assert.match(result.validationCases[0].assets.ddrnet_error, /\/assets\/visual\/goose\//);
|
||||
assert.deepEqual(result.authority, {
|
||||
@@ -133,13 +165,15 @@ test("vegetation LAB keeps autonomous assets and fail-closed authority", async (
|
||||
});
|
||||
});
|
||||
|
||||
test("vegetation LAB reuses the admitted M4.8 instrument", async () => {
|
||||
test("vegetation LAB reuses the admitted M4.8 and M4.7 instruments", async () => {
|
||||
const resultSource = await readFile(
|
||||
new URL("../src/workspaces/laboratory/VegetationShadowResult.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(resultSource, /M48MaskComparisonVisual/);
|
||||
assert.equal(resultSource.match(/<LaboratoryEvidence\b/g)?.length, 1);
|
||||
assert.match(resultSource, /M4ReplayThreatVisual/);
|
||||
assert.equal(resultSource.match(/<LaboratoryEvidence\b/g)?.length, 2);
|
||||
assert.match(resultSource, /showReferenceMediaLayers=\{false\}/);
|
||||
assert.doesNotMatch(resultSource, /VegetationRouteVisual|urban\/rural\/off-road presets/);
|
||||
await assert.rejects(
|
||||
access(new URL("../src/workspaces/laboratory/VegetationShadowVisual.tsx", import.meta.url)),
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"ravnoves": {
|
||||
"source_id": "RAVNOVES00/right-cadd1696ff000904eb78633a0a8418104b8024f178b91f3421789021ccb160e8",
|
||||
"source_sha256": "cadd1696ff000904eb78633a0a8418104b8024f178b91f3421789021ccb160e8",
|
||||
"base_m4_result_id": "m4-threat-replay-2a953c5f27f2a5b1dddc5c658c1de2c323d7796084a099c024987a1da03aa324",
|
||||
"expected_width": 800,
|
||||
"expected_height": 600,
|
||||
"expected_frame_count": 4489,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet("Build", "Probe", "Validate", "Ravnoves", "Status")]
|
||||
[ValidateSet("Build", "Probe", "Validate", "Ravnoves", "RavnovesVideo", "Status")]
|
||||
[string]$Mode = "Status",
|
||||
|
||||
[ValidateSet("Ddrnet", "Ppliteseg")]
|
||||
@@ -104,12 +104,13 @@ function New-RunRoot {
|
||||
|
||||
function Invoke-IsolatedRun {
|
||||
param(
|
||||
[ValidateSet("goose", "ravnoves")][string]$RunMode,
|
||||
[ValidateSet("goose", "ravnoves", "ravnoves-video")][string]$RunMode,
|
||||
[string]$RunRoot,
|
||||
[int]$Limit,
|
||||
[string]$FramesRoot = ""
|
||||
)
|
||||
$containerName = "ndc-lab-v1-goose-$candidateKey-$([Guid]::NewGuid().ToString('N').Substring(0, 10))"
|
||||
$visualCount = if ($RunMode -eq "ravnoves-video") { 0 } else { 12 }
|
||||
$arguments = @(
|
||||
"run", "--rm", "--name", $containerName,
|
||||
"--gpus", "all",
|
||||
@@ -136,9 +137,9 @@ function Invoke-IsolatedRun {
|
||||
"--dataset-root", "/data/goose",
|
||||
"--output", "/output/result",
|
||||
"--limit", $Limit.ToString(),
|
||||
"--visual-count", "12"
|
||||
"--visual-count", $visualCount.ToString()
|
||||
)
|
||||
if ($RunMode -eq "ravnoves") {
|
||||
if ($RunMode -in @("ravnoves", "ravnoves-video")) {
|
||||
$arguments = @($arguments[0..($arguments.Count - 1)])
|
||||
$arguments += @("--frames-root", "/input")
|
||||
$mountIndex = [Array]::IndexOf($arguments, $image)
|
||||
@@ -172,6 +173,20 @@ function Export-RavnovesFrames {
|
||||
}
|
||||
}
|
||||
|
||||
function Export-RavnovesVideoFrames {
|
||||
param([string]$Destination)
|
||||
Assert-FileIdentity -Path $RavnovesVideo -ExpectedBytes (Get-Item -LiteralPath $RavnovesVideo).Length -ExpectedSha256 $ravnovesSha256
|
||||
New-Item -ItemType Directory -Path $Destination | Out-Null
|
||||
& ffmpeg -hide_banner -loglevel error -i $RavnovesVideo -map 0:v:0 -fps_mode passthrough (Join-Path $Destination "frame-%06d.png")
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "RAVNOVES full-video frame extraction failed"
|
||||
}
|
||||
$frames = @(Get-ChildItem -LiteralPath $Destination -File -Filter "frame-*.png" | Sort-Object Name)
|
||||
if ($frames.Count -ne 4489 -or $frames[0].Name -ne "frame-000001.png" -or $frames[-1].Name -ne "frame-004489.png") {
|
||||
throw "RAVNOVES full-video frame sequence changed"
|
||||
}
|
||||
}
|
||||
|
||||
if ($Mode -eq "Status") {
|
||||
$imageIdentity = & docker image inspect $image --format "{{.Id}}" 2>$null
|
||||
[ordered]@{
|
||||
@@ -221,6 +236,16 @@ try {
|
||||
Export-RavnovesFrames -Destination $framesRoot
|
||||
Invoke-IsolatedRun -RunMode "ravnoves" -RunRoot $runRoot -Limit 0 -FramesRoot $framesRoot
|
||||
}
|
||||
elseif ($Mode -eq "RavnovesVideo") {
|
||||
if ($candidateKey -ne "ddrnet") {
|
||||
throw "Full-video shadow is admitted only for the selected DDRNet candidate"
|
||||
}
|
||||
$runRoot = New-RunRoot -Kind "ravnoves-video"
|
||||
$framesRoot = Join-Path $runRoot "input-frames"
|
||||
Export-RavnovesVideoFrames -Destination $framesRoot
|
||||
Invoke-IsolatedRun -RunMode "ravnoves-video" -RunRoot $runRoot -Limit 0 -FramesRoot $framesRoot
|
||||
Remove-Item -LiteralPath $framesRoot -Recurse -Force
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$canonicalAfter = Get-CanonicalTritonIdentity
|
||||
|
||||
+103
-3
@@ -11,6 +11,7 @@ import os
|
||||
import platform
|
||||
import statistics
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -43,7 +44,11 @@ class RunnerError(RuntimeError):
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--mode", choices=("goose", "ravnoves"), required=True)
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=("goose", "ravnoves", "ravnoves-video"),
|
||||
required=True,
|
||||
)
|
||||
parser.add_argument("--candidate", choices=tuple(MODEL_NAMES), required=True)
|
||||
parser.add_argument("--config", type=Path, required=True)
|
||||
parser.add_argument("--policy", type=Path, required=True)
|
||||
@@ -338,6 +343,40 @@ def save_image(path: Path, value: Image.Image | np.ndarray, mode: str | None = N
|
||||
return sha256(path)
|
||||
|
||||
|
||||
def write_mask_archive(
|
||||
output: Path,
|
||||
masks_root: Path,
|
||||
frame_count: int,
|
||||
) -> dict[str, Any]:
|
||||
archive_path = output / "semantic-masks.zip"
|
||||
expected = [f"frame-{sequence + 1:06d}.png" for sequence in range(frame_count)]
|
||||
actual = sorted(path.name for path in masks_root.glob("frame-*.png"))
|
||||
if actual != expected:
|
||||
raise RunnerError("RAVNOVES video mask sequence is incomplete")
|
||||
with zipfile.ZipFile(
|
||||
archive_path,
|
||||
mode="x",
|
||||
compression=zipfile.ZIP_STORED,
|
||||
allowZip64=True,
|
||||
) as archive:
|
||||
for name in expected:
|
||||
archive.write(masks_root / name, arcname=f"masks/{name}")
|
||||
for path in masks_root.iterdir():
|
||||
path.unlink()
|
||||
masks_root.rmdir()
|
||||
return {
|
||||
"path": archive_path.name,
|
||||
"sha256": sha256(archive_path),
|
||||
"byte_length": archive_path.stat().st_size,
|
||||
"media_type": "application/zip",
|
||||
"frame_count": frame_count,
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"encoding": "uint8-class-id-png",
|
||||
"sequence_binding": "sequence-0-to-masks/frame-000001.png",
|
||||
}
|
||||
|
||||
|
||||
def expand_mask(
|
||||
mask: np.ndarray,
|
||||
original_size: tuple[int, int],
|
||||
@@ -517,7 +556,7 @@ def run() -> None:
|
||||
(image.stem.removesuffix("_windshield_vis"), image, label)
|
||||
for image, label in pairs
|
||||
]
|
||||
else:
|
||||
elif args.mode == "ravnoves":
|
||||
if args.frames_root is None or not args.frames_root.is_dir():
|
||||
raise RunnerError("frames-root is required for RAVNOVES mode")
|
||||
frames = sorted(args.frames_root.glob("frame-*.png"))
|
||||
@@ -525,6 +564,15 @@ def run() -> None:
|
||||
if {frame.stem for frame in frames} != expected:
|
||||
raise RunnerError("RAVNOVES frame island identity changed")
|
||||
items = [(frame.stem, frame, None) for frame in frames]
|
||||
else:
|
||||
if args.frames_root is None or not args.frames_root.is_dir():
|
||||
raise RunnerError("frames-root is required for RAVNOVES video mode")
|
||||
frames = sorted(args.frames_root.glob("frame-*.png"))
|
||||
expected_count = config["ravnoves"]["expected_frame_count"]
|
||||
expected_names = [f"frame-{sequence + 1:06d}.png" for sequence in range(expected_count)]
|
||||
if len(frames) != expected_count or [frame.name for frame in frames] != expected_names:
|
||||
raise RunnerError("RAVNOVES full-video frame sequence changed")
|
||||
items = [(frame.stem, frame, None) for frame in frames]
|
||||
|
||||
if args.limit:
|
||||
items = items[: args.limit]
|
||||
@@ -539,8 +587,12 @@ def run() -> None:
|
||||
if args.visual_count != configured_visual_count:
|
||||
raise RunnerError("GOOSE visual count differs from the truth-focused contract")
|
||||
selected_visuals = truth_focused_visuals(items, names, visual_contract)
|
||||
else:
|
||||
elif args.mode == "ravnoves":
|
||||
selected_visuals = visual_indices(len(items), args.visual_count)
|
||||
else:
|
||||
if args.visual_count != 0 or args.limit:
|
||||
raise RunnerError("RAVNOVES video mode requires the complete frame sequence")
|
||||
selected_visuals = {}
|
||||
|
||||
args.output.mkdir(parents=True, exist_ok=False)
|
||||
torch.cuda.empty_cache()
|
||||
@@ -552,12 +604,28 @@ def run() -> None:
|
||||
confusion = np.zeros((CLASS_COUNT, CLASS_COUNT), dtype=np.int64)
|
||||
latencies_ms: list[float] = []
|
||||
visuals: list[dict[str, Any]] = []
|
||||
mask_root = args.output / "masks" if args.mode == "ravnoves-video" else None
|
||||
if mask_root is not None:
|
||||
mask_root.mkdir()
|
||||
aggregate_prediction_pixels = np.zeros(CLASS_COUNT, dtype=np.int64)
|
||||
|
||||
for index, (case_id, source_path, label_path) in enumerate(items):
|
||||
source = Image.open(source_path).convert("RGB")
|
||||
if args.mode == "ravnoves-video" and source.size != (
|
||||
config["ravnoves"]["expected_width"],
|
||||
config["ravnoves"]["expected_height"],
|
||||
):
|
||||
raise RunnerError("RAVNOVES video frame dimensions changed")
|
||||
tensor, crop_box = preprocess(source)
|
||||
prediction, latency_ms = infer(model, tensor)
|
||||
latencies_ms.append(latency_ms)
|
||||
if mask_root is not None:
|
||||
expanded_prediction = expand_mask(prediction, source.size, crop_box)
|
||||
save_image(mask_root / f"frame-{index + 1:06d}.png", expanded_prediction, "L")
|
||||
aggregate_prediction_pixels += np.bincount(
|
||||
expanded_prediction.reshape(-1),
|
||||
minlength=CLASS_COUNT,
|
||||
)
|
||||
truth = preprocess_label(Image.open(label_path)) if label_path is not None else None
|
||||
if truth is not None:
|
||||
update_confusion(confusion, truth, prediction)
|
||||
@@ -579,6 +647,23 @@ def run() -> None:
|
||||
)
|
||||
)
|
||||
|
||||
mask_archive = (
|
||||
write_mask_archive(args.output, mask_root, len(items))
|
||||
if mask_root is not None
|
||||
else None
|
||||
)
|
||||
taxonomy = {
|
||||
"schema_version": "missioncore.lab-v1-vegetation-taxonomy/v1",
|
||||
"classes": [
|
||||
{
|
||||
"class_id": label_id,
|
||||
"label": names[label_id],
|
||||
"color_rgb": semantic_palette[label_id, :3].astype(int).tolist(),
|
||||
"disposition": "undefined" if label_id == 0 else "prediction",
|
||||
}
|
||||
for label_id in range(CLASS_COUNT)
|
||||
],
|
||||
}
|
||||
rows = class_metrics(confusion, names) if args.mode == "goose" else []
|
||||
valid_ious = [row["iou"] for row in rows if row["iou"] is not None]
|
||||
vegetation_names = set(config["vegetation_class_names"])
|
||||
@@ -615,6 +700,20 @@ def run() -> None:
|
||||
"ground_truth_available": args.mode == "goose",
|
||||
"mapping_sha256": dataset_config["mapping_sha256"],
|
||||
},
|
||||
"video_semantics": {
|
||||
"base_m4_result_id": config["ravnoves"].get("base_m4_result_id"),
|
||||
"mask_archive": mask_archive,
|
||||
"taxonomy": taxonomy,
|
||||
"aggregate_prediction_pixels": aggregate_prediction_pixels.tolist()
|
||||
if mask_archive is not None
|
||||
else None,
|
||||
"center_crop_xyxy": [100, 0, 700, 600]
|
||||
if mask_archive is not None
|
||||
else None,
|
||||
"outside_crop_state": "undefined" if mask_archive is not None else None,
|
||||
}
|
||||
if args.mode == "ravnoves-video"
|
||||
else None,
|
||||
"preprocessing": dataset_config["preprocessing"],
|
||||
"metrics": {
|
||||
"mean_iou": round(statistics.fmean(valid_ious), 8) if valid_ious else None,
|
||||
@@ -650,6 +749,7 @@ def run() -> None:
|
||||
"schema_version": result["schema_version"],
|
||||
"candidate": result["candidate"],
|
||||
"source": result["source"],
|
||||
"video_semantics": result["video_semantics"],
|
||||
"preprocessing": result["preprocessing"],
|
||||
"metrics": result["metrics"],
|
||||
"timing": result["timing"],
|
||||
|
||||
@@ -5,17 +5,28 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Final
|
||||
|
||||
from k1link.laboratory.m47_reference_graph import read_m47_reference_graph_lab
|
||||
|
||||
LAB_SCHEMA: Final = "missioncore.lab-v1-vegetation-shadow/v1"
|
||||
WORKER_SCHEMA: Final = "missioncore.lab-v1-goose-vegetation-run/v1"
|
||||
RESULT_PREFIX: Final = "lab-v1-vegetation-shadow-"
|
||||
_CANDIDATES: Final = ("ddrnet", "ppliteseg")
|
||||
_MODES: Final = ("goose", "ravnoves")
|
||||
_VIDEO_MODE: Final = "ravnoves-video"
|
||||
_VIDEO_FRAME_COUNT: Final = 4489
|
||||
_M4_RESULT_ID: Final = re.compile(r"^m4-threat-replay-[a-f0-9]{64}$")
|
||||
_VIDEO_WORKER_RESULT_ID: Final = re.compile(
|
||||
r"^lab-v1-ravnoves-video-ddrnet-[a-f0-9]{64}$",
|
||||
)
|
||||
_SHA256: Final = re.compile(r"^[a-f0-9]{64}$")
|
||||
_FOCUS_ORDER: Final = (
|
||||
"high_grass",
|
||||
"low_grass",
|
||||
@@ -195,6 +206,106 @@ def _validation_metric_summary(result: dict[str, Any], candidate: str) -> dict[s
|
||||
}
|
||||
|
||||
|
||||
def _validated_video_semantics(
|
||||
root: Path,
|
||||
result: dict[str, Any],
|
||||
) -> tuple[dict[str, object], Path]:
|
||||
source = _object(result.get("source"), "DDRNet video source")
|
||||
video = _object(result.get("video_semantics"), "DDRNet video semantics")
|
||||
archive = _object(video.get("mask_archive"), "DDRNet video mask archive")
|
||||
taxonomy = _object(video.get("taxonomy"), "DDRNet video taxonomy")
|
||||
classes = taxonomy.get("classes")
|
||||
base_m4_result_id = video.get("base_m4_result_id")
|
||||
worker_result_id = result.get("result_id")
|
||||
aggregate_prediction_pixels = video.get("aggregate_prediction_pixels")
|
||||
if (
|
||||
source.get("input_count") != _VIDEO_FRAME_COUNT
|
||||
or source.get("ground_truth_available") is not False
|
||||
or taxonomy.get("schema_version")
|
||||
!= "missioncore.lab-v1-vegetation-taxonomy/v1"
|
||||
or not isinstance(classes, list)
|
||||
or len(classes) != 64
|
||||
or not isinstance(base_m4_result_id, str)
|
||||
or _M4_RESULT_ID.fullmatch(base_m4_result_id) is None
|
||||
or not isinstance(worker_result_id, str)
|
||||
or _VIDEO_WORKER_RESULT_ID.fullmatch(worker_result_id) is None
|
||||
or not isinstance(aggregate_prediction_pixels, list)
|
||||
or len(aggregate_prediction_pixels) != 64
|
||||
or any(type(count) is not int or count < 0 for count in aggregate_prediction_pixels)
|
||||
or sum(aggregate_prediction_pixels) != _VIDEO_FRAME_COUNT * 800 * 600
|
||||
or video.get("center_crop_xyxy") != [100, 0, 700, 600]
|
||||
or video.get("outside_crop_state") != "undefined"
|
||||
or archive.get("path") != "semantic-masks.zip"
|
||||
or archive.get("frame_count") != _VIDEO_FRAME_COUNT
|
||||
or archive.get("width") != 800
|
||||
or archive.get("height") != 600
|
||||
or archive.get("encoding") != "uint8-class-id-png"
|
||||
or archive.get("media_type") != "application/zip"
|
||||
or archive.get("sequence_binding")
|
||||
!= "sequence-0-to-masks/frame-000001.png"
|
||||
):
|
||||
raise VegetationShadowLabError("DDRNet full-video contract changed")
|
||||
for expected_id, raw_class in enumerate(classes):
|
||||
row = _object(raw_class, "DDRNet taxonomy class")
|
||||
color = row.get("color_rgb")
|
||||
if (
|
||||
row.get("class_id") != expected_id
|
||||
or not isinstance(row.get("label"), str)
|
||||
or not row["label"]
|
||||
or row.get("disposition")
|
||||
not in ({"undefined"} if expected_id == 0 else {"prediction"})
|
||||
or not isinstance(color, list)
|
||||
or len(color) != 3
|
||||
or any(not isinstance(channel, int) or not 0 <= channel <= 255 for channel in color)
|
||||
):
|
||||
raise VegetationShadowLabError("DDRNet video taxonomy changed")
|
||||
archive_path = root / "semantic-masks.zip"
|
||||
expected_sha256 = archive.get("sha256")
|
||||
expected_bytes = archive.get("byte_length")
|
||||
if (
|
||||
archive_path.is_symlink()
|
||||
or not archive_path.is_file()
|
||||
or type(expected_bytes) is not int
|
||||
or expected_bytes <= 0
|
||||
or archive_path.stat().st_size != expected_bytes
|
||||
or not isinstance(expected_sha256, str)
|
||||
or _SHA256.fullmatch(expected_sha256) is None
|
||||
or sha256_path(archive_path) != expected_sha256
|
||||
):
|
||||
raise VegetationShadowLabError("DDRNet video mask archive proof changed")
|
||||
expected_members = [
|
||||
f"masks/frame-{sequence + 1:06d}.png"
|
||||
for sequence in range(_VIDEO_FRAME_COUNT)
|
||||
]
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path) as frozen:
|
||||
members = frozen.infolist()
|
||||
if (
|
||||
[member.filename for member in members] != expected_members
|
||||
or any(
|
||||
member.is_dir()
|
||||
or member.file_size < 8
|
||||
or member.file_size > 1024 * 1024
|
||||
for member in members
|
||||
)
|
||||
):
|
||||
raise VegetationShadowLabError("DDRNet video mask sequence changed")
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise VegetationShadowLabError("DDRNet video mask archive is invalid") from exc
|
||||
return {
|
||||
"worker_result_id": worker_result_id,
|
||||
"base_m4_result_id": base_m4_result_id,
|
||||
"frame_count": _VIDEO_FRAME_COUNT,
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"center_crop_xyxy": [100, 0, 700, 600],
|
||||
"outside_crop_state": "undefined",
|
||||
"sequence_binding": archive["sequence_binding"],
|
||||
"taxonomy": taxonomy,
|
||||
"aggregate_prediction_pixels": aggregate_prediction_pixels,
|
||||
}, archive_path
|
||||
|
||||
|
||||
def seal_vegetation_shadow_lab(
|
||||
*,
|
||||
ddrnet_goose_root: Path,
|
||||
@@ -202,6 +313,8 @@ def seal_vegetation_shadow_lab(
|
||||
ddrnet_ravnoves_root: Path,
|
||||
ppliteseg_ravnoves_root: Path,
|
||||
output_root: Path,
|
||||
ddrnet_ravnoves_video_root: Path | None = None,
|
||||
m47_reference_graph_lab_root: Path | None = None,
|
||||
) -> Path:
|
||||
roots = {
|
||||
("ddrnet", "goose"): ddrnet_goose_root.resolve(),
|
||||
@@ -218,6 +331,29 @@ def seal_vegetation_shadow_lab(
|
||||
if cases[("ddrnet", mode)].keys() != cases[("ppliteseg", mode)].keys():
|
||||
raise VegetationShadowLabError(f"{mode} candidate case islands differ")
|
||||
selected = _selected_candidate(results)
|
||||
if (ddrnet_ravnoves_video_root is None) != (m47_reference_graph_lab_root is None):
|
||||
raise VegetationShadowLabError("full-video Worker and M4.7 roots must be paired")
|
||||
route_video: dict[str, object] | None = None
|
||||
route_video_archive: Path | None = None
|
||||
video_result: dict[str, Any] | None = None
|
||||
if ddrnet_ravnoves_video_root is not None and m47_reference_graph_lab_root is not None:
|
||||
video_root = ddrnet_ravnoves_video_root.resolve()
|
||||
video_result = _read_worker_result(
|
||||
video_root,
|
||||
candidate="ddrnet",
|
||||
mode=_VIDEO_MODE,
|
||||
)
|
||||
route_video, route_video_archive = _validated_video_semantics(video_root, video_result)
|
||||
m47 = read_m47_reference_graph_lab(m47_reference_graph_lab_root)
|
||||
m47_source = _object(m47.report.get("source"), "M4.7 source")
|
||||
m47_visual = _object(m47.report.get("visual_evidence"), "M4.7 visual evidence")
|
||||
if (
|
||||
m47_source.get("source_id") != "RAVNOVES00"
|
||||
or m47_visual.get("linked_result_id") != route_video["base_m4_result_id"]
|
||||
or m47_visual.get("timeline_frames") != _VIDEO_FRAME_COUNT
|
||||
):
|
||||
raise VegetationShadowLabError("M4.7 video binding differs from DDRNet source")
|
||||
route_video["m47_reference_graph_result_id"] = m47.result_id
|
||||
|
||||
output_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
temporary = Path(tempfile.mkdtemp(prefix=".lab-v1-vegetation-", dir=output_root))
|
||||
@@ -315,6 +451,34 @@ def seal_vegetation_shadow_lab(
|
||||
"path": relative,
|
||||
"sha256": descriptor["sha256"],
|
||||
}
|
||||
if video_result is not None and route_video is not None and route_video_archive is not None:
|
||||
video_root = ddrnet_ravnoves_video_root.resolve() # type: ignore[union-attr]
|
||||
worker_descriptor = _copy_artifact(
|
||||
video_root / "result.json",
|
||||
temporary,
|
||||
"worker/ddrnet-ravnoves-video.json",
|
||||
artifacts,
|
||||
role="worker-result",
|
||||
media_type="application/json",
|
||||
)
|
||||
worker_proofs["ddrnet_ravnoves_video"] = {
|
||||
"result_id": video_result.get("result_id"),
|
||||
"path": worker_descriptor["path"],
|
||||
"sha256": worker_descriptor["sha256"],
|
||||
}
|
||||
archive_descriptor = _copy_artifact(
|
||||
route_video_archive,
|
||||
temporary,
|
||||
"video/ddrnet-semantic-masks.zip",
|
||||
artifacts,
|
||||
role="route-semantic-mask-archive",
|
||||
media_type="application/zip",
|
||||
)
|
||||
route_video["mask_archive"] = {
|
||||
"path": archive_descriptor["path"],
|
||||
"sha256": archive_descriptor["sha256"],
|
||||
"byte_length": archive_descriptor["byte_length"],
|
||||
}
|
||||
|
||||
candidate_metrics: dict[str, object] = {}
|
||||
for candidate in _CANDIDATES:
|
||||
@@ -348,11 +512,13 @@ def seal_vegetation_shadow_lab(
|
||||
"shadow_session": "RAVNOVES00",
|
||||
"shadow_camera": "sensor.camera.right",
|
||||
"shadow_frame_count": 12,
|
||||
"video_shadow_frame_count": _VIDEO_FRAME_COUNT if route_video else 0,
|
||||
},
|
||||
"selected_candidate": selected,
|
||||
"candidate_metrics": candidate_metrics,
|
||||
"worker_proofs": worker_proofs,
|
||||
"visual_catalog_sha256": hashlib.sha256(canonical_json(catalogs)).hexdigest(),
|
||||
"route_video": route_video,
|
||||
"authority": authority,
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(canonical_json(identity)).hexdigest()
|
||||
@@ -366,6 +532,7 @@ def seal_vegetation_shadow_lab(
|
||||
"status": "visual-shadow-ready-policy-not-authorized",
|
||||
"identity": identity,
|
||||
"source": identity["source"],
|
||||
"route_video": route_video,
|
||||
"method": {
|
||||
"completeness": "complete",
|
||||
"execution_class": "ai-inference",
|
||||
@@ -375,13 +542,14 @@ def seal_vegetation_shadow_lab(
|
||||
"decision": {
|
||||
"selected_candidate": selected,
|
||||
"visual_shadow_ready": True,
|
||||
"full_video_shadow_ready": route_video is not None,
|
||||
"mission_policy_ready_for_configuration": True,
|
||||
"navigation_accepted": False,
|
||||
"production_accepted": False,
|
||||
},
|
||||
"limitations": [
|
||||
"GOOSE validation is external-domain qualification, not RAVNOVES ground truth.",
|
||||
"The RAVNOVES shadow remains in Worker proofs and is not catalogued as vegetation evidence because it has no independent labels.",
|
||||
"The full RAVNOVES DDRNet playback is prediction-only and has no independent labels.",
|
||||
"Vegetation semantics never clears rigid LiDAR/TGS occupancy.",
|
||||
"Undefined pixels outside the 600x600 center crop remain fail-closed.",
|
||||
],
|
||||
@@ -407,6 +575,8 @@ def _parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--ddrnet-ravnoves-root", type=Path, required=True)
|
||||
parser.add_argument("--ppliteseg-ravnoves-root", type=Path, required=True)
|
||||
parser.add_argument("--output-root", type=Path, required=True)
|
||||
parser.add_argument("--ddrnet-ravnoves-video-root", type=Path)
|
||||
parser.add_argument("--m47-reference-graph-lab-root", type=Path)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -418,6 +588,8 @@ def main() -> None:
|
||||
ddrnet_ravnoves_root=args.ddrnet_ravnoves_root,
|
||||
ppliteseg_ravnoves_root=args.ppliteseg_ravnoves_root,
|
||||
output_root=args.output_root,
|
||||
ddrnet_ravnoves_video_root=args.ddrnet_ravnoves_video_root,
|
||||
m47_reference_graph_lab_root=args.m47_reference_graph_lab_root,
|
||||
)
|
||||
print(destination)
|
||||
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
from collections.abc import Callable
|
||||
from functools import lru_cache
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Final
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, Response
|
||||
|
||||
from k1link.laboratory.evidence_registry import LaboratoryEvidenceDefinition
|
||||
from k1link.laboratory.evidence_report import (
|
||||
@@ -86,6 +88,48 @@ def build_vegetation_shadow_lab_router(
|
||||
},
|
||||
)
|
||||
|
||||
@router.get("/{result_id}/masks/{sequence}")
|
||||
def get_video_mask(result_id: str, sequence: int) -> Response:
|
||||
candidate = _resolve_candidate(root_provider, result_id)
|
||||
manifest = _read_verified(candidate)
|
||||
route_video = manifest.get("route_video")
|
||||
if not isinstance(route_video, dict) or not 0 <= sequence < 4489:
|
||||
raise HTTPException(status_code=404, detail="Vegetation video mask not found")
|
||||
archive = route_video.get("mask_archive")
|
||||
if not isinstance(archive, dict) or archive.get("path") != "video/ddrnet-semantic-masks.zip":
|
||||
raise HTTPException(status_code=404, detail="Vegetation video mask not found")
|
||||
archive_path = candidate / "video" / "ddrnet-semantic-masks.zip"
|
||||
member = f"masks/frame-{sequence + 1:06d}.png"
|
||||
try:
|
||||
before = archive_path.stat()
|
||||
with zipfile.ZipFile(archive_path) as frozen:
|
||||
info = frozen.getinfo(member)
|
||||
if info.is_dir() or info.file_size < 8 or info.file_size > 1024 * 1024:
|
||||
raise ValueError("Vegetation video mask member is invalid")
|
||||
payload = frozen.read(info)
|
||||
after = archive_path.stat()
|
||||
if (
|
||||
before.st_size != after.st_size
|
||||
or before.st_mtime_ns != after.st_mtime_ns
|
||||
or len(payload) != info.file_size
|
||||
):
|
||||
raise ValueError("Vegetation video mask archive changed during read")
|
||||
except (KeyError, OSError, ValueError, zipfile.BadZipFile):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Vegetation video mask failed verification",
|
||||
) from None
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
return Response(
|
||||
content=payload,
|
||||
media_type="image/png",
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"ETag": f'"{digest}"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
|
||||
@@ -2,12 +2,15 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from k1link.laboratory import LaboratoryEvidenceRegistry
|
||||
import k1link.laboratory.vegetation_shadow_lab as vegetation_lab_module
|
||||
from k1link.laboratory.evidence_report import verify_laboratory_evidence_result
|
||||
from k1link.laboratory.vegetation_shadow_lab import seal_vegetation_shadow_lab
|
||||
from k1link.web.vegetation_shadow_lab_api import build_vegetation_shadow_lab_router
|
||||
@@ -87,19 +90,98 @@ def _worker_result(root: Path, *, candidate: str, mode: str, vegetation_iou: flo
|
||||
(root / "result.json").write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
|
||||
def test_vegetation_shadow_lab_seals_autonomous_visual_evidence(tmp_path: Path) -> None:
|
||||
def _video_worker_result(root: Path) -> None:
|
||||
root.mkdir(parents=True)
|
||||
archive = root / "semantic-masks.zip"
|
||||
mask = b"\x89PNG\r\n\x1a\n"
|
||||
with zipfile.ZipFile(archive, "x", compression=zipfile.ZIP_STORED) as frozen:
|
||||
for sequence in range(4489):
|
||||
frozen.writestr(f"masks/frame-{sequence + 1:06d}.png", mask)
|
||||
taxonomy = {
|
||||
"schema_version": "missioncore.lab-v1-vegetation-taxonomy/v1",
|
||||
"classes": [
|
||||
{
|
||||
"class_id": class_id,
|
||||
"label": "undefined" if class_id == 0 else f"class-{class_id}",
|
||||
"color_rgb": [class_id, class_id, class_id],
|
||||
"disposition": "undefined" if class_id == 0 else "prediction",
|
||||
}
|
||||
for class_id in range(64)
|
||||
],
|
||||
}
|
||||
payload = {
|
||||
"schema_version": "missioncore.lab-v1-goose-vegetation-run/v1",
|
||||
"result_id": f"lab-v1-ravnoves-video-ddrnet-{'e' * 64}",
|
||||
"mode": "ravnoves-video",
|
||||
"candidate": {"candidate_key": "ddrnet"},
|
||||
"source": {
|
||||
"input_count": 4489,
|
||||
"ground_truth_available": False,
|
||||
},
|
||||
"video_semantics": {
|
||||
"base_m4_result_id": f"m4-threat-replay-{'f' * 64}",
|
||||
"mask_archive": {
|
||||
"path": "semantic-masks.zip",
|
||||
"sha256": _sha256(archive),
|
||||
"byte_length": archive.stat().st_size,
|
||||
"frame_count": 4489,
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"encoding": "uint8-class-id-png",
|
||||
"media_type": "application/zip",
|
||||
"sequence_binding": "sequence-0-to-masks/frame-000001.png",
|
||||
},
|
||||
"taxonomy": taxonomy,
|
||||
"aggregate_prediction_pixels": [4489 * 800 * 600, *([0] * 63)],
|
||||
"center_crop_xyxy": [100, 0, 700, 600],
|
||||
"outside_crop_state": "undefined",
|
||||
},
|
||||
"authority": {
|
||||
"navigation_accepted": False,
|
||||
"safety_accepted": False,
|
||||
"actuation_accepted": False,
|
||||
"camera_semantics_can_clear_rigid_geometry": False,
|
||||
},
|
||||
}
|
||||
(root / "result.json").write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
|
||||
def test_vegetation_shadow_lab_seals_autonomous_visual_evidence(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
roots = {}
|
||||
for candidate, vegetation_iou in (("ddrnet", 0.64), ("ppliteseg", 0.61)):
|
||||
for mode in ("goose", "ravnoves"):
|
||||
root = tmp_path / "worker" / f"{candidate}-{mode}"
|
||||
_worker_result(root, candidate=candidate, mode=mode, vegetation_iou=vegetation_iou)
|
||||
roots[(candidate, mode)] = root
|
||||
video_root = tmp_path / "worker" / "ddrnet-ravnoves-video"
|
||||
_video_worker_result(video_root)
|
||||
m47_root = tmp_path / f"m47-reference-graph-lab-{'a' * 64}"
|
||||
m47_root.mkdir()
|
||||
monkeypatch.setattr(
|
||||
vegetation_lab_module,
|
||||
"read_m47_reference_graph_lab",
|
||||
lambda _root: SimpleNamespace(
|
||||
result_id=m47_root.name,
|
||||
report={
|
||||
"source": {"source_id": "RAVNOVES00"},
|
||||
"visual_evidence": {
|
||||
"linked_result_id": f"m4-threat-replay-{'f' * 64}",
|
||||
"timeline_frames": 4489,
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
result_root = seal_vegetation_shadow_lab(
|
||||
ddrnet_goose_root=roots[("ddrnet", "goose")],
|
||||
ppliteseg_goose_root=roots[("ppliteseg", "goose")],
|
||||
ddrnet_ravnoves_root=roots[("ddrnet", "ravnoves")],
|
||||
ppliteseg_ravnoves_root=roots[("ppliteseg", "ravnoves")],
|
||||
output_root=tmp_path / "results",
|
||||
ddrnet_ravnoves_video_root=video_root,
|
||||
m47_reference_graph_lab_root=m47_root,
|
||||
)
|
||||
manifest = json.loads((result_root / "result.json").read_text("utf-8"))
|
||||
assert manifest["decision"]["selected_candidate"] == "ddrnet"
|
||||
@@ -108,7 +190,9 @@ def test_vegetation_shadow_lab_seals_autonomous_visual_evidence(tmp_path: Path)
|
||||
assert manifest["authority"]["navigation_or_safety_accepted"] is False
|
||||
assert len(manifest["catalogs"]["ravnoves"]) == 0
|
||||
assert len(manifest["catalogs"]["goose"]) == 12
|
||||
assert len(manifest["artifacts"]) == 76
|
||||
assert len(manifest["artifacts"]) == 78
|
||||
assert manifest["route_video"]["frame_count"] == 4489
|
||||
assert manifest["route_video"]["outside_crop_state"] == "undefined"
|
||||
assert manifest["catalogs"]["goose"][0]["focus"]["class_name"] == "high_grass"
|
||||
assert "ddrnet_error" in manifest["catalogs"]["goose"][0]["assets"]
|
||||
assert "ppliteseg_error" in manifest["catalogs"]["goose"][0]["assets"]
|
||||
@@ -121,7 +205,7 @@ def test_vegetation_shadow_lab_seals_autonomous_visual_evidence(tmp_path: Path)
|
||||
)
|
||||
proof = verify_laboratory_evidence_result(definition, result_root)
|
||||
assert proof["result_id"] == result_root.name
|
||||
assert proof["artifact_count"] == 76
|
||||
assert proof["artifact_count"] == 78
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(build_vegetation_shadow_lab_router(root_provider=lambda: result_root.parent))
|
||||
@@ -135,6 +219,10 @@ def test_vegetation_shadow_lab_seals_autonomous_visual_evidence(tmp_path: Path)
|
||||
)
|
||||
assert asset.status_code == 200
|
||||
assert asset.headers["cache-control"].endswith("immutable")
|
||||
mask = client.get(f"/api/v1/laboratory/vegetation-shadow/{result_root.name}/masks/0")
|
||||
assert mask.status_code == 200
|
||||
assert mask.content == b"\x89PNG\r\n\x1a\n"
|
||||
assert mask.headers["cache-control"].endswith("immutable")
|
||||
|
||||
(result_root / asset_path).write_bytes(b"tampered")
|
||||
assert (
|
||||
|
||||
Reference in New Issue
Block a user