From 9dec37a8a9cb0f44a41b6cf2133ad7ff6fe2eb8a Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 29 Aug 2026 13:36:37 +0300 Subject: [PATCH] feat(lab): seal full RAVNOVES004TREE semantic review --- .../src/core/laboratory/vegetationShadow.ts | 295 ++++++++++- .../laboratory/VegetationShadowResult.tsx | 234 ++++++++- .../test/vegetationShadow.test.mjs | 91 +++- ...-ravnoves004tree-full-video-source-v1.json | 19 + .../Invoke-E4FullSessionSegmentation.ps1 | 74 ++- .../Invoke-LabV1VegetationGooseBenchmark.ps1 | 136 ++++- scripts/seal_mixed_route_full_video_review.py | 38 ++ .../mixed_route_vegetation_review.py | 467 +++++++++++++++++- src/k1link/web/vegetation_shadow_lab_api.py | 68 +++ tests/test_e4_full_session_segmentation.py | 17 + .../test_lab_v1_goose_vegetation_benchmark.py | 24 + tests/test_vegetation_shadow_lab.py | 61 +++ 12 files changed, 1494 insertions(+), 30 deletions(-) create mode 100644 config/perception/lab-v1-ravnoves004tree-full-video-source-v1.json create mode 100644 scripts/seal_mixed_route_full_video_review.py diff --git a/apps/control-station/src/core/laboratory/vegetationShadow.ts b/apps/control-station/src/core/laboratory/vegetationShadow.ts index 08d8669..0b7cf5d 100644 --- a/apps/control-station/src/core/laboratory/vegetationShadow.ts +++ b/apps/control-station/src/core/laboratory/vegetationShadow.ts @@ -106,6 +106,39 @@ export interface VegetationMixedRouteReview { cases: readonly VegetationMixedRouteCase[]; } +export interface VegetationFullRouteLayer { + name: string; + resultId: string; + frameCount: 6830; + taxonomy: readonly VegetationVideoSemanticClass[]; + inferenceFps: number; + latencyP95Ms: number; + peakReservedVramBytes: number; +} + +export interface VegetationFullRouteReview { + sourceId: "RAVNOVES004TREE"; + sessionId: "20260828T130511Z_viewer_live"; + sourceJobId: "recorded-camera-eb2783c5480d56bda07c8af0"; + sourceJobInputSha256: string; + sourceStreamSha256: string; + recordedMediaSourceId: "recorded.camera.6a3945242828a038"; + recordedMediaGenerationSha256: string; + frameCount: 6830; + width: 800; + height: 600; + timelineStartSeconds: number; + timelineEndSeconds: number; + frameSourceTimesNs: readonly number[]; + decodeRepair: { + repairedFrameCount: 1; + sequence: 6092; + method: "duplicate-previous-decoded-frame"; + }; + city: VegetationFullRouteLayer; + vegetation: VegetationFullRouteLayer; +} + export interface VegetationShadowResult { resultId: string; createdAtUtc: string; @@ -116,6 +149,7 @@ export interface VegetationShadowResult { validationCases: readonly VegetationVisualCase[]; routeVideo: VegetationRouteVideo | null; routeReview: VegetationMixedRouteReview | null; + routeFullReview: VegetationFullRouteReview | null; limitations: readonly string[]; visualShadowReady: true; missionPolicyReadyForConfiguration: true; @@ -545,6 +579,246 @@ function mixedRouteReviewValue( }; } +function fullRouteTaxonomyValue( + value: unknown, + label: string, + schema: string, + classCount: number, +): readonly VegetationVideoSemanticClass[] { + const taxonomy = objectValue(value, `${label}.taxonomy`); + exact(taxonomy.schema_version, schema, `${label}.taxonomy.schema`); + const classes = arrayValue(taxonomy.classes, `${label}.taxonomy.classes`).map( + (raw, expectedId): VegetationVideoSemanticClass => { + const item = objectValue(raw, `${label}.taxonomy[${expectedId}]`); + const classId = integerValue(item.class_id, `${label}.class_id[${expectedId}]`); + if (classId !== expectedId) { + throw new VegetationShadowContractError(`${label}: taxonomy order changed.`); + } + const color = arrayValue(item.color_rgb, `${label}.color[${expectedId}]`) + .map((channel, index) => integerValue(channel, `${label}.color[${expectedId}][${index}]`)); + if (color.length !== 3 || color.some((channel) => channel > 255)) { + throw new VegetationShadowContractError(`${label}: taxonomy color invalid.`); + } + const disposition = item.disposition; + if ( + disposition !== "labeled" + && disposition !== "ambiguous" + && disposition !== "prediction" + && disposition !== "undefined" + ) { + throw new VegetationShadowContractError(`${label}: taxonomy disposition changed.`); + } + return { + classId, + label: textValue(item.label, `${label}.label[${expectedId}]`), + colorRgb: color as unknown as readonly [number, number, number], + disposition, + materialClass: item.material_class === null || item.material_class === undefined + ? null + : textValue(item.material_class, `${label}.material[${expectedId}]`), + evidenceState: item.evidence_state === null || item.evidence_state === undefined + ? null + : textValue(item.evidence_state, `${label}.evidence[${expectedId}]`), + }; + }, + ); + if (classes.length !== classCount) { + throw new VegetationShadowContractError(`${label}: taxonomy size changed.`); + } + return classes; +} + +function fullRouteLayerValue( + value: unknown, + layer: "city" | "vegetation", +): VegetationFullRouteLayer { + const label = `vegetation.route_full_review.layers.${layer}`; + const row = objectValue(value, label); + const resultId = textValue(row.result_id, `${label}.result_id`); + const identity = layer === "city" + ? /^result-[a-f0-9]{64}$/ + : /^lab-v1-ravnoves-video-ddrnet-[a-f0-9]{64}$/; + if (!identity.test(resultId)) { + throw new VegetationShadowContractError(`${label}: identity invalid.`); + } + exact(row.frame_count, 6830, `${label}.frame_count`); + const archive = objectValue(row.mask_archive, `${label}.mask_archive`); + exact( + archive.path, + layer === "city" ? "video/eomt-semantic-masks.zip" : "video/ddrnet-semantic-masks.zip", + `${label}.mask_archive.path`, + ); + const digest = textValue(archive.sha256, `${label}.mask_archive.sha256`); + if (!SHA256.test(digest)) { + throw new VegetationShadowContractError(`${label}: archive digest invalid.`); + } + integerValue(archive.byte_length, `${label}.mask_archive.byte_length`); + return { + name: textValue(row.name, `${label}.name`), + resultId, + frameCount: 6830, + taxonomy: fullRouteTaxonomyValue( + row.taxonomy, + label, + layer === "city" + ? "missioncore.recorded-eomt-taxonomy/v1" + : "missioncore.lab-v1-vegetation-taxonomy/v1", + layer === "city" ? 16 : 64, + ), + inferenceFps: numberValue(row.inference_fps, `${label}.inference_fps`), + latencyP95Ms: numberValue(row.latency_p95_ms, `${label}.latency_p95_ms`), + peakReservedVramBytes: integerValue( + row.peak_reserved_vram_bytes, + `${label}.peak_reserved_vram_bytes`, + ), + }; +} + +function fullRouteReviewValue(value: unknown): VegetationFullRouteReview | null { + if (value === null || value === undefined) return null; + const row = objectValue(value, "vegetation.route_full_review"); + exact(row.source_id, "RAVNOVES004TREE", "vegetation.route_full_review.source_id"); + exact( + row.session_id, + "20260828T130511Z_viewer_live", + "vegetation.route_full_review.session_id", + ); + exact( + row.source_job_id, + "recorded-camera-eb2783c5480d56bda07c8af0", + "vegetation.route_full_review.source_job_id", + ); + exact(row.frame_count, 6830, "vegetation.route_full_review.frame_count"); + exact(row.width, 800, "vegetation.route_full_review.width"); + exact(row.height, 600, "vegetation.route_full_review.height"); + exact(row.ground_truth, false, "vegetation.route_full_review.ground_truth"); + const sourceJobInputSha256 = textValue( + row.source_job_input_sha256, + "vegetation.route_full_review.source_job_input_sha256", + ); + const sourceStreamSha256 = textValue( + row.source_stream_sha256, + "vegetation.route_full_review.source_stream_sha256", + ); + exact( + sourceJobInputSha256, + "eb2783c5480d56bda07c8af008dff5344d19dc550ef70fe2075d6f098f7cc715", + "vegetation.route_full_review.source_job_input_sha256", + ); + exact( + sourceStreamSha256, + "e5eb017e2cc0f546736eda5235ca157b501913093cb64af5e548e335417e1bac", + "vegetation.route_full_review.source_stream_sha256", + ); + exact( + row.recorded_media_source_id, + "recorded.camera.6a3945242828a038", + "vegetation.route_full_review.recorded_media_source_id", + ); + const recordedMediaGenerationSha256 = textValue( + row.recorded_media_generation_sha256, + "vegetation.route_full_review.recorded_media_generation_sha256", + ); + exact( + recordedMediaGenerationSha256, + "b073ea1e7babf1c77a664e1a5b95e3702d0e05b0e34c1e85a7c67a6f8b392ded", + "vegetation.route_full_review.recorded_media_generation_sha256", + ); + if ( + !SHA256.test(sourceJobInputSha256) + || !SHA256.test(sourceStreamSha256) + || !SHA256.test(recordedMediaGenerationSha256) + ) { + throw new VegetationShadowContractError("vegetation.route_full_review: source digest invalid."); + } + const timelineStartSeconds = numberValue( + row.timeline_start_seconds, + "vegetation.route_full_review.timeline_start_seconds", + ); + const timelineEndSeconds = numberValue( + row.timeline_end_seconds, + "vegetation.route_full_review.timeline_end_seconds", + ); + if (timelineEndSeconds <= timelineStartSeconds) { + throw new VegetationShadowContractError("vegetation.route_full_review: timeline invalid."); + } + const frameSourceTimesNs = arrayValue( + row.frame_source_times_ns, + "vegetation.route_full_review.frame_source_times_ns", + ).map((raw, index) => { + const time = integerValue(raw, `vegetation.route_full_review.frame_source_times_ns[${index}]`); + if (!Number.isSafeInteger(time)) { + throw new VegetationShadowContractError("vegetation.route_full_review: unsafe frame time."); + } + return time; + }); + if ( + frameSourceTimesNs.length !== 6830 + || frameSourceTimesNs.some((time, index) => index > 0 && time <= frameSourceTimesNs[index - 1]!) + ) { + throw new VegetationShadowContractError("vegetation.route_full_review: frame timeline changed."); + } + const decodeRepair = objectValue( + row.decode_repair, + "vegetation.route_full_review.decode_repair", + ); + exact(decodeRepair.repaired_frame_count, 1, "vegetation.route_full_review.decode_repair.count"); + exact(decodeRepair.sequence, 6092, "vegetation.route_full_review.decode_repair.sequence"); + exact( + decodeRepair.method, + "duplicate-previous-decoded-frame", + "vegetation.route_full_review.decode_repair.method", + ); + const repairProofs = objectValue( + decodeRepair.proofs, + "vegetation.route_full_review.decode_repair.proofs", + ); + for (const [key, expectedPath] of Object.entries({ + eomt: "proofs/decode_repair.json", + ddrnet: "proofs/ddrnet_decode_repair.json", + })) { + const proof = objectValue( + repairProofs[key], + `vegetation.route_full_review.decode_repair.proofs.${key}`, + ); + exact( + proof.path, + expectedPath, + `vegetation.route_full_review.decode_repair.proofs.${key}.path`, + ); + const digest = textValue( + proof.sha256, + `vegetation.route_full_review.decode_repair.proofs.${key}.sha256`, + ); + if (!SHA256.test(digest)) { + throw new VegetationShadowContractError("vegetation.route_full_review: repair proof invalid."); + } + } + const layers = objectValue(row.layers, "vegetation.route_full_review.layers"); + return { + sourceId: "RAVNOVES004TREE", + sessionId: "20260828T130511Z_viewer_live", + sourceJobId: "recorded-camera-eb2783c5480d56bda07c8af0", + sourceJobInputSha256, + sourceStreamSha256, + recordedMediaSourceId: "recorded.camera.6a3945242828a038", + recordedMediaGenerationSha256, + frameCount: 6830, + width: 800, + height: 600, + timelineStartSeconds, + timelineEndSeconds, + frameSourceTimesNs, + decodeRepair: { + repairedFrameCount: 1, + sequence: 6092, + method: "duplicate-previous-decoded-frame", + }, + city: fullRouteLayerValue(layers.city, "city"), + vegetation: fullRouteLayerValue(layers.vegetation, "vegetation"), + }; +} + function parseResult( value: unknown, resultId: string, @@ -589,9 +863,10 @@ function parseResult( const validationCases = arrayValue(catalogs.goose, "vegetation.catalogs.goose") .map((item) => visualCaseValue(item, resultId, "goose", endpointRoot)); const routeReview = mixedRouteReviewValue(payload.route_review, resultId, endpointRoot); + const routeFullReview = fullRouteReviewValue(payload.route_full_review); if ( routeCases.length !== 0 - || (routeReview ? validationCases.length !== 0 : validationCases.length !== 12) + || (routeReview || routeFullReview ? validationCases.length !== 0 : validationCases.length !== 12) ) { throw new VegetationShadowContractError("vegetation.catalogs: ожидалось 12 truth-backed GOOSE случаев без route viewer."); } @@ -605,6 +880,7 @@ function parseResult( validationCases, routeVideo: routeVideoValue(payload.route_video), routeReview, + routeFullReview, limitations: arrayValue(payload.limitations, "vegetation.limitations") .map((item, index) => textValue(item, `vegetation.limitations[${index}]`)), visualShadowReady: true, @@ -625,6 +901,23 @@ export function vegetationVideoMaskUrl(resultId: string, sequence: number): stri return `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/masks/${sequence}`; } +export function vegetationFullRouteMaskUrl( + resultId: string, + layer: "city" | "vegetation", + sequence: number, +): string { + if ( + !RESULT_ID.test(resultId) + || (layer !== "city" && layer !== "vegetation") + || !Number.isInteger(sequence) + || sequence < 0 + || sequence >= 6830 + ) { + throw new VegetationShadowContractError("Vegetation full-route mask identity недопустима."); + } + return `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/route-masks/${layer}/${sequence}`; +} + export async function fetchVegetationShadowResult( resultId: string, { diff --git a/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx b/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx index 3243ccb..642e3e2 100644 --- a/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx +++ b/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx @@ -1,7 +1,8 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Icon, IconButton, StatusBadge } from "@nodedc/ui-react"; import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer"; +import { LaboratoryRecordedClipPlayer } from "../../components/laboratory/LaboratoryRecordedClipPlayer"; import { LaboratoryEvidence, LaboratoryResultSummary, @@ -9,10 +10,21 @@ import { LaboratoryWorkTemplate, } from "../../components/laboratory/LaboratoryPresentation"; import { + RecordedEvidenceSemanticMaskOverlay, + type RecordedEvidenceSemanticClass, + type RecordedEvidenceSemanticPaletteEntry, +} from "../../components/laboratory/RecordedEvidenceSemanticMaskOverlay"; +import { + vegetationFullRouteMaskUrl, vegetationVideoMaskUrl, + type VegetationFullRouteLayer, + type VegetationFullRouteReview, type VegetationMixedRouteReview, type VegetationShadowResult, } from "../../core/laboratory/vegetationShadow"; +import { recordedObservationSources } from "../../core/observation/recordedObservationSources"; +import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions"; +import type { ObservationSourceDescriptor } from "../../core/runtime/contracts"; import { fetchM49TgsFullShadowResult, type M49TgsFullShadowResult, @@ -31,6 +43,217 @@ const MIXED_ROUTE_MODES = [ { value: "tgs", label: "TGS" }, ] as const; +const FULL_ROUTE_MODES = [ + { value: "source", label: "SOURCE" }, + { value: "city", label: "ГОРОД · EoMT" }, + { value: "vegetation", label: "ПРИРОДА · DDRNet" }, +] as const; + +function semanticPresentation(layer: VegetationFullRouteLayer): { + classes: readonly RecordedEvidenceSemanticClass[]; + palette: readonly RecordedEvidenceSemanticPaletteEntry[]; +} { + return { + classes: layer.taxonomy.map((item) => ({ id: item.classId, label: item.label })), + palette: layer.taxonomy.map((item) => ({ + classId: item.classId, + color: item.classId === 0 + ? { kind: "transparent" as const } + : { kind: "diagnostic" as const, rgb: item.colorRgb }, + })), + }; +} + +function FullRouteReviewEvidence({ + resultId, + review, +}: { + resultId: string; + review: VegetationFullRouteReview; +}) { + const [sequence, setSequence] = useState(1); + const [playing, setPlaying] = useState(false); + const [playbackRate, setPlaybackRate] = useState(1); + const [mode, setMode] = useState("vegetation"); + const [expanded, setExpanded] = useState(false); + const [videoSource, setVideoSource] = useState(null); + const [videoError, setVideoError] = useState(null); + const frames = useMemo( + () => review.frameSourceTimesNs.map((sourceTimeNs, index) => ({ + sequence: index + 1, + sourceTimeNs, + })), + [review.frameSourceTimesNs], + ); + const layer = mode === "source" ? null : review[mode]; + const semantic = useMemo(() => layer ? semanticPresentation(layer) : null, [layer]); + const maskSequence = sequence - 1; + const prefetchSrcs = useMemo(() => layer + ? Array.from({ length: 8 }, (_, offset) => maskSequence + offset + 1) + .filter((candidate) => candidate < review.frameCount) + .map((candidate) => vegetationFullRouteMaskUrl(resultId, mode as "city" | "vegetation", candidate)) + : [], [layer, maskSequence, mode, resultId, review.frameCount]); + + useEffect(() => { + const controller = new AbortController(); + setVideoSource(null); + setVideoError(null); + void resolveObservationSessionReplay(review.sessionId, { signal: controller.signal }) + .then((launch) => { + const source = recordedObservationSources(launch).find((candidate) => ( + candidate.id === review.recordedMediaSourceId + && candidate.modality === "video" + && candidate.semanticChannelId === "camera.video.recorded" + && candidate.delivery?.kind === "recorded-fmp4-manifest" + && candidate.delivery.manifestGenerationSha256 === review.recordedMediaGenerationSha256 + && candidate.delivery.timelineStartSeconds === review.timelineStartSeconds + && candidate.delivery.timelineEndSeconds >= review.timelineEndSeconds + )); + if (!source) { + throw new Error("RIGHT-видео не совпало с sealed RAVNOVES004TREE timeline."); + } + if (!controller.signal.aborted) setVideoSource(source); + }) + .catch((caught: unknown) => { + if (!controller.signal.aborted) { + setVideoError(caught instanceof Error ? caught.message : "Записанное видео недоступно."); + } + }); + return () => controller.abort(); + }, [ + review.recordedMediaGenerationSha256, + review.recordedMediaSourceId, + review.sessionId, + review.timelineEndSeconds, + review.timelineStartSeconds, + ]); + + return ( + + {videoSource ? ( + +
+ {mode === "source" ? "SOURCE" : `${mode === "city" ? "EoMT CITY" : "DDRNet NATURE"} · КАДР ${sequence}/${review.frameCount}`} +
+ {layer && semantic ? ( + + ) : null} + + )} + /> + ) : ( +
+ {videoError ?? "Открываем автономный recorded source…"} +
+ )} +
+ ); +} + +function FullRouteReviewResult({ + rigLabel, + resultId, + review, +}: { + rigLabel: string; + resultId: string; + review: VegetationFullRouteReview; +}) { + return ( + + )} + evidence={( + + + + )} + result={( + + )} + /> + ); +} + function MixedRouteReviewEvidence({ review }: { review: VegetationMixedRouteReview }) { const [index, setIndex] = useState(0); const [mode, setMode] = useState("vegetation"); @@ -224,6 +447,15 @@ export function VegetationShadowResultView({ rigLabel: string; result: VegetationShadowResult; }) { + if (result.routeFullReview) { + return ( + + ); + } if (result.routeReview) { return ; } diff --git a/apps/control-station/test/vegetationShadow.test.mjs b/apps/control-station/test/vegetationShadow.test.mjs index cb12e72..5c61cb7 100644 --- a/apps/control-station/test/vegetationShadow.test.mjs +++ b/apps/control-station/test/vegetationShadow.test.mjs @@ -7,6 +7,7 @@ import { createServer } from "vite"; let server; let fetchVegetationBenchmarkResult; let fetchVegetationShadowResult; +let vegetationFullRouteMaskUrl; before(async () => { server = await createServer({ @@ -14,7 +15,11 @@ before(async () => { logLevel: "silent", server: { middlewareMode: true }, }); - ({ fetchVegetationBenchmarkResult, fetchVegetationShadowResult } = await server.ssrLoadModule( + ({ + fetchVegetationBenchmarkResult, + fetchVegetationShadowResult, + vegetationFullRouteMaskUrl, + } = await server.ssrLoadModule( "/src/core/laboratory/vegetationShadow.ts", )); }); @@ -148,6 +153,63 @@ function coarseRouteVideo() { }; } +function fullRouteReview() { + const layer = (kind) => ({ + name: kind === "city" ? "EoMT Cityscapes" : "ddrnet_39", + result_id: kind === "city" + ? `result-${"2".repeat(64)}` + : `lab-v1-ravnoves-video-ddrnet-${"3".repeat(64)}`, + frame_count: 6830, + taxonomy: { + schema_version: kind === "city" + ? "missioncore.recorded-eomt-taxonomy/v1" + : "missioncore.lab-v1-vegetation-taxonomy/v1", + classes: Array.from({ length: kind === "city" ? 16 : 64 }, (_, classId) => ({ + class_id: classId, + label: classId === 0 ? "undefined" : `${kind}-${classId}`, + color_rgb: [classId, classId, classId], + disposition: classId === 0 ? "undefined" : "prediction", + })), + }, + mask_archive: { + path: kind === "city" + ? "video/eomt-semantic-masks.zip" + : "video/ddrnet-semantic-masks.zip", + sha256: "4".repeat(64), + byte_length: 4096, + }, + inference_fps: 9.5, + latency_p95_ms: 101.2, + peak_reserved_vram_bytes: 3_000_000_000, + }); + return { + source_id: "RAVNOVES004TREE", + session_id: "20260828T130511Z_viewer_live", + source_job_id: "recorded-camera-eb2783c5480d56bda07c8af0", + source_job_input_sha256: "eb2783c5480d56bda07c8af008dff5344d19dc550ef70fe2075d6f098f7cc715", + source_stream_sha256: "e5eb017e2cc0f546736eda5235ca157b501913093cb64af5e548e335417e1bac", + recorded_media_source_id: "recorded.camera.6a3945242828a038", + recorded_media_generation_sha256: "b073ea1e7babf1c77a664e1a5b95e3702d0e05b0e34c1e85a7c67a6f8b392ded", + frame_count: 6830, + width: 800, + height: 600, + timeline_start_seconds: 39.215263458, + timeline_end_seconds: 757.260263458, + frame_source_times_ns: Array.from({ length: 6830 }, (_, index) => 39_215_263_458 + index * 100_000_000), + ground_truth: false, + decode_repair: { + repaired_frame_count: 1, + sequence: 6092, + method: "duplicate-previous-decoded-frame", + proofs: { + eomt: { path: "proofs/decode_repair.json", sha256: "7".repeat(64) }, + ddrnet: { path: "proofs/ddrnet_decode_repair.json", sha256: "8".repeat(64) }, + }, + }, + layers: { city: layer("city"), vegetation: layer("vegetation") }, + }; +} + function labPayload(route = routeVideo()) { return { schema_version: "missioncore.lab-v1-vegetation-shadow/v1", @@ -233,6 +295,29 @@ test("vegetation LAB parses coarse material policy and sealed TGS binding", asyn assert.equal(result.routeVideo.fusionMode, "synchronised-multilayer-review"); }); +test("vegetation LAB parses the full 004 pass inside the existing result contract", async () => { + const payload = { + ...labPayload(null), + catalogs: { goose: [], ravnoves: [] }, + route_full_review: fullRouteReview(), + }; + const result = await fetchVegetationShadowResult(resultId, { + fetcher: async () => new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + }); + assert.equal(result.routeVideo, null); + assert.equal(result.routeFullReview.frameCount, 6830); + assert.equal(result.routeFullReview.city.taxonomy.length, 16); + assert.equal(result.routeFullReview.vegetation.taxonomy.length, 64); + assert.equal(result.routeFullReview.decodeRepair.sequence, 6092); + assert.equal( + vegetationFullRouteMaskUrl(resultId, "vegetation", 6829), + `/api/v1/laboratory/vegetation-shadow/${resultId}/route-masks/vegetation/6829`, + ); +}); + test("vegetation GOOSE benchmark opens through its separate archival endpoint", async () => { let requestedUrl = ""; const result = await fetchVegetationBenchmarkResult(benchmarkResultId, { @@ -271,8 +356,10 @@ test("vegetation realtime LAB and archival benchmark use separate admitted instr assert.match(resultSource, /M49TgsFullShadowEvidence/); assert.match(resultSource, /semanticOverride/); assert.match(resultSource, /EoMT CITY \/ DDRNet VEGETATION/); - assert.equal(resultSource.match(/&1 | ForEach-Object { Write-Output $_ } + $dockerExitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $previousErrorActionPreference + } + if ($dockerExitCode -ne 0) { + throw "LAB V1 container failed with exit code $dockerExitCode" } } function Export-RavnovesFrames { param([string]$Destination) - Assert-FileIdentity -Path $RavnovesVideo -ExpectedBytes (Get-Item -LiteralPath $RavnovesVideo).Length -ExpectedSha256 $ravnovesSha256 + Assert-FileIdentity -Path $RavnovesVideo -ExpectedBytes (Get-Item -LiteralPath $RavnovesVideo).Length -ExpectedSha256 $RavnovesSha256 New-Item -ItemType Directory -Path $Destination | Out-Null $expression = ($frameIndices | ForEach-Object { "eq(n\,$_ )" }) -join "+" $temporaryPattern = Join-Path $Destination "selected-%03d.png" @@ -175,14 +234,68 @@ function Export-RavnovesFrames { function Export-RavnovesVideoFrames { param([string]$Destination) - Assert-FileIdentity -Path $RavnovesVideo -ExpectedBytes (Get-Item -LiteralPath $RavnovesVideo).Length -ExpectedSha256 $ravnovesSha256 + 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") + $decodedRoot = "{0}-decoded-by-pts" -f $Destination + $packetsPath = "{0}-packets.csv" -f $Destination + New-Item -ItemType Directory -Path $decodedRoot | Out-Null + & ffprobe -v error -select_streams v:0 -show_packets -show_entries packet=pts,flags -of csv=p=0 -o $packetsPath $RavnovesVideo + if ($LASTEXITCODE -ne 0) { + throw "RAVNOVES full-video packet probe failed" + } + $packetRows = @(Get-Content -LiteralPath $packetsPath | Select-Object -First $RavnovesExpectedFrameCount) + if ($packetRows.Count -ne $RavnovesExpectedFrameCount) { + throw "RAVNOVES full-video packet sequence changed" + } + & ffmpeg -hide_banner -loglevel error ` + -hwaccel cuda -hwaccel_output_format cuda -c:v h264_cuvid ` + -err_detect ignore_err -flags +output_corrupt -copyts ` + -i $RavnovesVideo -map 0:v:0 -vf "hwdownload,format=nv12" ` + -fps_mode passthrough -enc_time_base demux -frames:v $RavnovesExpectedFrameCount ` + -frame_pts 1 (Join-Path $decodedRoot "frame-%d.png") if ($LASTEXITCODE -ne 0) { throw "RAVNOVES full-video frame extraction failed" } + $decodedCount = @(Get-ChildItem -LiteralPath $decodedRoot -File -Filter "frame-*.png").Count + $repairs = @() + for ($index = 0; $index -lt $RavnovesExpectedFrameCount; $index++) { + $columns = ([string]$packetRows[$index]).Split(",") + if ($columns.Count -lt 2) { + throw "RAVNOVES full-video packet row is malformed" + } + $pts = [int64]::Parse($columns[0].Trim(), [Globalization.CultureInfo]::InvariantCulture) + $decodedPath = Join-Path $decodedRoot ("frame-{0}.png" -f $pts) + $canonicalPath = Join-Path $Destination ("frame-{0:D6}.png" -f ($index + 1)) + if (Test-Path -LiteralPath $decodedPath -PathType Leaf) { + Move-Item -LiteralPath $decodedPath -Destination $canonicalPath + continue + } + if ($index -eq 0 -or $repairs.Count -ge 1) { + throw "RAVNOVES source contains more than one recoverable decoder gap" + } + $previousPath = Join-Path $Destination ("frame-{0:D6}.png" -f $index) + Copy-Item -LiteralPath $previousPath -Destination $canonicalPath + $repairs += [ordered]@{ + sequence = $index + 1 + packet_pts = $pts + method = "duplicate-previous-decoded-frame" + } + } + Remove-Item -LiteralPath $decodedRoot -Recurse -Force + Remove-Item -LiteralPath $packetsPath -Force + [ordered]@{ + schema_version = "missioncore.recorded-video-decode-repair/v1" + decoder = "ffmpeg-h264_cuvid-output-corrupt" + packets_requested = $RavnovesExpectedFrameCount + frames_decoded = $decodedCount + repaired_frame_count = $repairs.Count + repairs = $repairs + } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath ( + Join-Path (Split-Path $Destination -Parent) "decode-repair.json" + ) -Encoding utf8 $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") { + $lastFrameName = "frame-{0:D6}.png" -f $RavnovesExpectedFrameCount + if ($frames.Count -ne $RavnovesExpectedFrameCount -or $frames[0].Name -ne "frame-000001.png" -or $frames[-1].Name -ne $lastFrameName) { throw "RAVNOVES full-video frame sequence changed" } } @@ -244,6 +357,9 @@ try { $framesRoot = Join-Path $runRoot "input-frames" Export-RavnovesVideoFrames -Destination $framesRoot Invoke-IsolatedRun -RunMode "ravnoves-video" -RunRoot $runRoot -Limit 0 -FramesRoot $framesRoot + Copy-Item -LiteralPath (Join-Path $runRoot "decode-repair.json") -Destination ( + Join-Path $runRoot "result\decode-repair.json" + ) Remove-Item -LiteralPath $framesRoot -Recurse -Force } } diff --git a/scripts/seal_mixed_route_full_video_review.py b/scripts/seal_mixed_route_full_video_review.py new file mode 100644 index 0000000..1f67a71 --- /dev/null +++ b/scripts/seal_mixed_route_full_video_review.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Seal the complete RAVNOVES004TREE semantic pass into existing LAB V1.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from k1link.laboratory.mixed_route_vegetation_review import ( + seal_mixed_route_full_video_review, +) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-lab-root", type=Path, required=True) + parser.add_argument("--job-root", type=Path, required=True) + parser.add_argument("--recorded-media-preparation", type=Path, required=True) + parser.add_argument("--eomt-root", type=Path, required=True) + parser.add_argument("--eomt-profile", type=Path, required=True) + parser.add_argument("--ddrnet-root", type=Path, required=True) + parser.add_argument("--output-root", type=Path, required=True) + args = parser.parse_args() + print( + seal_mixed_route_full_video_review( + base_lab_root=args.base_lab_root, + job_root=args.job_root, + recorded_media_preparation_path=args.recorded_media_preparation, + eomt_root=args.eomt_root, + eomt_profile_path=args.eomt_profile, + ddrnet_root=args.ddrnet_root, + output_root=args.output_root, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/src/k1link/laboratory/mixed_route_vegetation_review.py b/src/k1link/laboratory/mixed_route_vegetation_review.py index ec30939..fc67c36 100644 --- a/src/k1link/laboratory/mixed_route_vegetation_review.py +++ b/src/k1link/laboratory/mixed_route_vegetation_review.py @@ -7,7 +7,9 @@ import hashlib import json import os import shutil +import tarfile import tempfile +import zipfile from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -15,6 +17,8 @@ from typing import Any import numpy as np from PIL import Image, ImageDraw +from k1link.compute.jobs import validate_camera_compute_job + from k1link.laboratory.vegetation_shadow_lab import ( LAB_SCHEMA, RESULT_PREFIX, @@ -45,11 +49,20 @@ TGS_COLORS = { 2: (235, 112, 122), 3: (150, 154, 163), } +FULL_ROUTE_SOURCE_ID = "RAVNOVES004TREE" +FULL_ROUTE_FRAME_COUNT = 6830 +FULL_ROUTE_JOB_ID = "recorded-camera-eb2783c5480d56bda07c8af0" +FULL_ROUTE_INPUT_SHA256 = ( + "eb2783c5480d56bda07c8af008dff5344d19dc550ef70fe2075d6f098f7cc715" +) +FULL_ROUTE_STREAM_SHA256 = ( + "e5eb017e2cc0f546736eda5235ca157b501913093cb64af5e548e335417e1bac" +) def _read_json(path: Path, label: str) -> dict[str, Any]: try: - value = json.loads(path.read_text(encoding="utf-8")) + value = json.loads(path.read_text(encoding="utf-8-sig")) except (OSError, json.JSONDecodeError) as exc: raise VegetationShadowLabError(f"{label} is invalid") from exc if not isinstance(value, dict): @@ -86,6 +99,126 @@ def _image_proof(descriptor: dict[str, object]) -> dict[str, object]: return {"path": descriptor["path"], "sha256": descriptor["sha256"]} +def _mask_archive_descriptor( + path: Path, + relative: str, + artifacts: list[dict[str, object]], + *, + role: str, +) -> dict[str, object]: + descriptor = { + "role": role, + "path": relative, + "byte_length": path.stat().st_size, + "sha256": sha256_path(path), + "media_type": "application/zip", + } + artifacts.append(descriptor) + return descriptor + + +def _repack_eomt_masks(source: Path, destination: Path, frame_count: int) -> None: + destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + expected = [f"semantic-masks/frame-{sequence + 1:06d}.png" for sequence in range(frame_count)] + try: + with ( + tarfile.open(source, mode="r:gz") as archive, + zipfile.ZipFile( + destination, + mode="x", + compression=zipfile.ZIP_STORED, + allowZip64=True, + ) as output, + ): + members = [member for member in archive.getmembers() if member.isfile()] + if [member.name.removeprefix("./") for member in members] != expected: + raise VegetationShadowLabError("full-route EoMT mask sequence changed") + for member, expected_name in zip(members, expected, strict=True): + if member.size < 8 or member.size > 1024 * 1024: + raise VegetationShadowLabError("full-route EoMT mask size changed") + stream = archive.extractfile(member) + if stream is None: + raise VegetationShadowLabError("full-route EoMT mask is unavailable") + output.writestr( + f"masks/{Path(expected_name).name}", + stream.read(), + ) + except (OSError, tarfile.TarError, zipfile.BadZipFile) as exc: + destination.unlink(missing_ok=True) + raise VegetationShadowLabError("full-route EoMT archive is invalid") from exc + + +def _validate_zip_masks(path: Path, frame_count: int) -> None: + expected = [f"masks/frame-{sequence + 1:06d}.png" for sequence in range(frame_count)] + try: + with zipfile.ZipFile(path) as archive: + members = archive.infolist() + if ( + [member.filename for member in members] != expected + or any( + member.is_dir() or member.file_size < 8 or member.file_size > 1024 * 1024 + for member in members + ) + ): + raise VegetationShadowLabError("full-route semantic mask sequence changed") + except (OSError, zipfile.BadZipFile) as exc: + raise VegetationShadowLabError("full-route semantic archive is invalid") from exc + + +def _full_route_frame_times(media: dict[str, Any], frame_count: int) -> list[int]: + epochs = media.get("epochs") + start = media.get("timeline_start_seconds") + end = media.get("timeline_end_seconds") + if ( + not isinstance(epochs, list) + or len(epochs) != 1 + or not isinstance(start, (int, float)) + or not isinstance(end, (int, float)) + ): + raise VegetationShadowLabError("recorded media timeline changed") + epoch = epochs[0] + segments = epoch.get("segments") if isinstance(epoch, dict) else None + if not isinstance(segments, list) or len(segments) != frame_count: + raise VegetationShadowLabError("recorded media segment count changed") + starts = [float(start)] + previous_end = 0.0 + for sequence, raw in enumerate(segments, start=1): + if ( + not isinstance(raw, dict) + or raw.get("sequence") != sequence + or not isinstance(raw.get("end_time_seconds"), (int, float)) + or float(raw["end_time_seconds"]) <= previous_end + ): + raise VegetationShadowLabError("recorded media segment timeline changed") + if sequence < frame_count: + starts.append(float(start) + float(raw["end_time_seconds"])) + previous_end = float(raw["end_time_seconds"]) + if abs((float(start) + previous_end) - float(end)) > 0.001: + raise VegetationShadowLabError("recorded media duration changed") + return [round(value * 1_000_000_000) for value in starts] + + +def _eomt_taxonomy(profile: dict[str, Any]) -> dict[str, object]: + taxonomy = profile.get("target_taxonomy") + if not isinstance(taxonomy, dict) or set(taxonomy) != {str(index) for index in range(16)}: + raise VegetationShadowLabError("EoMT target taxonomy changed") + classes = [] + for class_id in range(16): + digest = hashlib.sha256(f"mission-core-segment-{class_id}".encode()).digest() + classes.append( + { + "class_id": class_id, + "label": taxonomy[str(class_id)], + "color_rgb": [64 + digest[index] % 176 for index in range(3)], + "disposition": "undefined" if class_id == 0 else "prediction", + } + ) + return { + "schema_version": "missioncore.recorded-eomt-taxonomy/v1", + "classes": classes, + } + + 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") @@ -392,6 +525,338 @@ def seal_mixed_route_vegetation_review( raise +def seal_mixed_route_full_video_review( + *, + base_lab_root: Path, + job_root: Path, + recorded_media_preparation_path: Path, + eomt_root: Path, + eomt_profile_path: Path, + ddrnet_root: Path, + output_root: Path, +) -> Path: + """Publish the complete 004 city/nature pass in the existing M4.7 LAB.""" + + 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") + + job = validate_camera_compute_job(job_root) + if ( + job.job_id != FULL_ROUTE_JOB_ID + or job.input_sha256 != FULL_ROUTE_INPUT_SHA256 + or job.session_id != "20260828T130511Z_viewer_live" + or job.source_id != "sensor.camera.right" + or job.segment_count != FULL_ROUTE_FRAME_COUNT + ): + raise VegetationShadowLabError("full-route camera job changed") + + eomt = _read_json(eomt_root / "result.json", "full-route EoMT result") + eomt_report = _read_json(eomt_root / "run-report.json", "full-route EoMT report") + decode_repair = _read_json( + eomt_root / "decode-repair.json", + "full-route video decode repair", + ) + eomt_input = eomt_report.get("input") + eomt_metrics = eomt_report.get("metrics") + if ( + eomt.get("schema_version") != "missioncore.recorded-perception-result/v2" + or eomt.get("ground_truth") is not False + or eomt.get("frames_processed") != FULL_ROUTE_FRAME_COUNT + or not isinstance(eomt_input, dict) + or eomt_input.get("job_id") != job.job_id + or eomt_input.get("input_sha256") != job.input_sha256 + or eomt_input.get("frames_admitted") != FULL_ROUTE_FRAME_COUNT + or not isinstance(eomt_metrics, dict) + or eomt_metrics.get("frames_processed") != FULL_ROUTE_FRAME_COUNT + ): + raise VegetationShadowLabError("full-route EoMT contract changed") + if ( + decode_repair.get("schema_version") + != "missioncore.recorded-video-decode-repair/v1" + or decode_repair.get("decoder") != "ffmpeg-h264_cuvid-output-corrupt" + or decode_repair.get("packets_requested") != FULL_ROUTE_FRAME_COUNT + or decode_repair.get("frames_decoded") != FULL_ROUTE_FRAME_COUNT - 1 + or decode_repair.get("repaired_frame_count") != 1 + or decode_repair.get("repairs") + != [ + { + "sequence": 6092, + "packet_pts": 55656450, + "method": "duplicate-previous-decoded-frame", + } + ] + ): + raise VegetationShadowLabError("full-route video decode repair changed") + eomt_artifacts = { + item.get("kind"): item + for item in eomt.get("artifacts", []) + if isinstance(item, dict) + } + eomt_archive_proof = eomt_artifacts.get("panoptic-mask-archive") + if not isinstance(eomt_archive_proof, dict): + raise VegetationShadowLabError("full-route EoMT mask proof is missing") + eomt_archive = eomt_root / str(eomt_archive_proof.get("path")) + if ( + not eomt_archive.is_file() + or eomt_archive.stat().st_size != eomt_archive_proof.get("byte_length") + or sha256_path(eomt_archive) != eomt_archive_proof.get("sha256") + ): + raise VegetationShadowLabError("full-route EoMT mask proof changed") + + ddrnet = _read_json(ddrnet_root / "result.json", "full-route DDRNet result") + ddrnet_decode_repair = _read_json( + ddrnet_root / "decode-repair.json", + "full-route DDRNet video decode repair", + ) + ddrnet_source = ddrnet.get("source") + ddrnet_video = ddrnet.get("video_semantics") + if ( + ddrnet.get("schema_version") != "missioncore.lab-v1-goose-vegetation-run/v1" + or ddrnet.get("mode") != "ravnoves-video" + or ddrnet.get("candidate", {}).get("candidate_key") != "ddrnet" + or not isinstance(ddrnet_source, dict) + or ddrnet_source.get("source_id") + != f"{FULL_ROUTE_SOURCE_ID}/right-{FULL_ROUTE_STREAM_SHA256}" + or ddrnet_source.get("input_count") != FULL_ROUTE_FRAME_COUNT + or ddrnet_source.get("ground_truth_available") is not False + or not isinstance(ddrnet_video, dict) + or ddrnet_video.get("base_m4_result_id") is not None + or ddrnet.get("authority", {}).get("navigation_accepted") is not False + or ddrnet.get("authority", {}).get("actuation_accepted") is not False + ): + raise VegetationShadowLabError("full-route DDRNet contract changed") + if ddrnet_decode_repair != decode_repair: + raise VegetationShadowLabError("full-route model decoders disagree") + ddrnet_archive_proof = ddrnet_video.get("mask_archive") + ddrnet_taxonomy = ddrnet_video.get("taxonomy") + if ( + not isinstance(ddrnet_archive_proof, dict) + or ddrnet_archive_proof.get("frame_count") != FULL_ROUTE_FRAME_COUNT + or not isinstance(ddrnet_taxonomy, dict) + ): + raise VegetationShadowLabError("full-route DDRNet mask proof changed") + ddrnet_archive = ddrnet_root / str(ddrnet_archive_proof.get("path")) + if ( + not ddrnet_archive.is_file() + or ddrnet_archive.stat().st_size != ddrnet_archive_proof.get("byte_length") + or sha256_path(ddrnet_archive) != ddrnet_archive_proof.get("sha256") + ): + raise VegetationShadowLabError("full-route DDRNet archive changed") + _validate_zip_masks(ddrnet_archive, FULL_ROUTE_FRAME_COUNT) + + media_document = _read_json( + recorded_media_preparation_path.resolve(strict=True), + "recorded media preparation", + ) + media = media_document.get("manifest") + if ( + media_document.get("schema_version") != "missioncore.recorded-media-preparation/v3" + or media_document.get("session_id") != job.session_id + or media_document.get("artifact_id") != "recorded-video-6a3945242828a038" + or media_document.get("checksum_sha256") + != "557e61f2839140dc9f97b5aea855c576b0616573080dff5d2852ab1df0558665" + or not isinstance(media, dict) + or media.get("source_id") != "recorded.camera.6a3945242828a038" + or media.get("generation_sha256") + != "b073ea1e7babf1c77a664e1a5b95e3702d0e05b0e34c1e85a7c67a6f8b392ded" + or media.get("byte_length") != 551674491 + or media.get("timeline_start_seconds") != job.timeline_start_seconds + or media.get("timeline_end_seconds") != job.timeline_end_seconds + or media.get("synchronization") != "host-arrival-best-effort" + ): + raise VegetationShadowLabError("recorded media preparation changed") + frame_times_ns = _full_route_frame_times(media, FULL_ROUTE_FRAME_COUNT) + eomt_profile = _read_json(eomt_profile_path.resolve(strict=True), "EoMT profile") + eomt_taxonomy = _eomt_taxonomy(eomt_profile) + + output_root.mkdir(mode=0o700, parents=True, exist_ok=True) + temporary = Path(tempfile.mkdtemp(prefix=".mixed-route-full-video-", dir=output_root)) + artifacts: list[dict[str, object]] = [] + try: + eomt_destination = temporary / "video" / "eomt-semantic-masks.zip" + _repack_eomt_masks(eomt_archive, eomt_destination, FULL_ROUTE_FRAME_COUNT) + _validate_zip_masks(eomt_destination, FULL_ROUTE_FRAME_COUNT) + eomt_descriptor = _mask_archive_descriptor( + eomt_destination, + "video/eomt-semantic-masks.zip", + artifacts, + role="full-route-eomt-semantic-mask-archive", + ) + ddrnet_descriptor = _artifact( + ddrnet_archive, + temporary, + "video/ddrnet-semantic-masks.zip", + artifacts, + role="full-route-ddrnet-semantic-mask-archive", + media_type="application/zip", + ) + _validate_zip_masks( + temporary / "video" / "ddrnet-semantic-masks.zip", + FULL_ROUTE_FRAME_COUNT, + ) + proof_descriptors: dict[str, dict[str, object]] = {} + for key, path in ( + ("base", base_root / "result.json"), + ("job", job.manifest_path), + ("media", recorded_media_preparation_path.resolve(strict=True)), + ("eomt", eomt_root / "result.json"), + ("eomt_report", eomt_root / "run-report.json"), + ("decode_repair", eomt_root / "decode-repair.json"), + ("ddrnet", ddrnet_root / "result.json"), + ("ddrnet_decode_repair", ddrnet_root / "decode-repair.json"), + ): + descriptor = _artifact( + path, + temporary, + f"proofs/{key}.json", + artifacts, + role="full-route-proof", + media_type="application/json", + ) + proof_descriptors[key] = _image_proof(descriptor) + + full_route = { + "source_id": FULL_ROUTE_SOURCE_ID, + "session_id": job.session_id, + "source_job_id": job.job_id, + "source_job_input_sha256": job.input_sha256, + "source_stream_sha256": FULL_ROUTE_STREAM_SHA256, + "recorded_media_source_id": media["source_id"], + "recorded_media_generation_sha256": media["generation_sha256"], + "frame_count": FULL_ROUTE_FRAME_COUNT, + "width": 800, + "height": 600, + "timeline_start_seconds": job.timeline_start_seconds, + "timeline_end_seconds": job.timeline_end_seconds, + "frame_source_times_ns": frame_times_ns, + "ground_truth": False, + "decode_repair": { + "repaired_frame_count": 1, + "sequence": 6092, + "method": "duplicate-previous-decoded-frame", + "proofs": { + "eomt": proof_descriptors["decode_repair"], + "ddrnet": proof_descriptors["ddrnet_decode_repair"], + }, + }, + "layers": { + "city": { + "name": "EoMT Cityscapes", + "result_id": eomt["result_id"], + "frame_count": FULL_ROUTE_FRAME_COUNT, + "taxonomy": eomt_taxonomy, + "mask_archive": { + "path": eomt_descriptor["path"], + "sha256": eomt_descriptor["sha256"], + "byte_length": eomt_descriptor["byte_length"], + }, + "inference_fps": eomt_metrics["inference_frames_per_second"], + "latency_p95_ms": eomt_metrics["latency_ms"]["end_to_end_ms"]["p95"], + "peak_reserved_vram_bytes": int( + float(eomt_metrics["cuda_peak_memory_reserved_mib"]) * 1024 * 1024 + ), + }, + "vegetation": { + "name": ddrnet["candidate"]["loaded_model_name"], + "result_id": ddrnet["result_id"], + "frame_count": FULL_ROUTE_FRAME_COUNT, + "taxonomy": ddrnet_taxonomy, + "mask_archive": { + "path": ddrnet_descriptor["path"], + "sha256": ddrnet_descriptor["sha256"], + "byte_length": ddrnet_descriptor["byte_length"], + }, + "inference_fps": ddrnet["timing"]["throughput_fps_from_mean_inference"], + "latency_p95_ms": ddrnet["timing"]["latency_ms_p95"], + "peak_reserved_vram_bytes": ddrnet["resource"]["peak_reserved_vram_bytes"], + }, + }, + "proofs": proof_descriptors, + "limitations": [ + "RAVNOVES004TREE has no manual route truth.", + "One corrupt H.264 packet at sequence 6092 was represented by the previous decoded frame; the repair is sealed as evidence.", + "EoMT and DDRNet were executed sequentially, not as a concurrent realtime stack.", + "DDRNet vegetation subtypes remain prediction-only and are not planner authority.", + "This full-video pass does not add full-route TGS, ditch or negative-obstacle proof.", + "People and vehicles still 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": FULL_ROUTE_SOURCE_ID, + "shadow_camera": job.source_id, + "shadow_frame_count": FULL_ROUTE_FRAME_COUNT, + "video_shadow_frame_count": FULL_ROUTE_FRAME_COUNT, + }, + "route_full_review": full_route, + "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": None, + "route_full_review": full_route, + "method": { + "completeness": "complete", + "execution_class": "ai-inference", + "pipeline_id": "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1", + }, + "metrics": {"candidates": base["metrics"]["candidates"]}, + "decision": { + "selected_candidate": base_identity["selected_candidate"], + "visual_shadow_ready": True, + "full_video_shadow_ready": True, + "mission_policy_ready_for_configuration": True, + "multilayer_policy_review_ready": True, + "navigation_accepted": False, + "production_accepted": False, + }, + "limitations": full_route["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 full-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) diff --git a/src/k1link/web/vegetation_shadow_lab_api.py b/src/k1link/web/vegetation_shadow_lab_api.py index b459968..dbb3be7 100644 --- a/src/k1link/web/vegetation_shadow_lab_api.py +++ b/src/k1link/web/vegetation_shadow_lab_api.py @@ -179,9 +179,77 @@ def _build_vegetation_lab_router( }, ) + @router.get("/{result_id}/route-masks/{layer}/{sequence}") + def get_full_route_mask(result_id: str, layer: str, sequence: int) -> Response: + candidate = _resolve_candidate(root_provider, definition, result_id) + manifest = _read_verified(candidate, definition) + route = manifest.get("route_full_review") + layers = route.get("layers") if isinstance(route, dict) else None + frame_count = route.get("frame_count") if isinstance(route, dict) else None + selected = layers.get(layer) if isinstance(layers, dict) else None + archive = selected.get("mask_archive") if isinstance(selected, dict) else None + archive_relative = archive.get("path") if isinstance(archive, dict) else None + if ( + layer not in {"city", "vegetation"} + or not isinstance(frame_count, int) + or not 0 <= sequence < frame_count + or not isinstance(archive_relative, str) + ): + raise HTTPException(status_code=404, detail="Full-route semantic mask not found") + relative = PurePosixPath(archive_relative) + artifacts = manifest.get("artifacts") + if ( + relative.is_absolute() + or str(relative) != archive_relative + or any(part in {"", ".", ".."} for part in relative.parts) + or relative.suffix != ".zip" + or not isinstance(artifacts, list) + or not any( + isinstance(item, dict) + and item.get("path") == archive_relative + and item.get("media_type") == "application/zip" + for item in artifacts + ) + ): + raise HTTPException(status_code=404, detail="Full-route semantic mask not found") + return _zip_mask_response(candidate.joinpath(*relative.parts), sequence) + return router +def _zip_mask_response(archive_path: Path, sequence: int) -> Response: + 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("Semantic 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("Semantic mask archive changed during read") + except (KeyError, OSError, ValueError, zipfile.BadZipFile): + raise HTTPException( + status_code=503, + detail="Semantic 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", + }, + ) + + def _configured_root(provider: RootProvider) -> Path | None: candidate = provider() if candidate is None: diff --git a/tests/test_e4_full_session_segmentation.py b/tests/test_e4_full_session_segmentation.py index 7b2d911..ab9a605 100644 --- a/tests/test_e4_full_session_segmentation.py +++ b/tests/test_e4_full_session_segmentation.py @@ -104,3 +104,20 @@ def test_e4_class_fractions_use_only_valid_fov_pixels() -> None: assert {item["id"]: item["pixels"] for item in classes} == {1: 1, 4: 2, 7: 1} assert sum(float(item["fraction_of_valid_fov"]) for item in classes) == 1.0 + + +def test_e4_orchestrator_seals_a_single_decoder_gap_without_frame_shift() -> None: + path = ( + Path(__file__).parents[1] + / "experiments" + / "perception" + / "worker" + / "Invoke-E4FullSessionSegmentation.ps1" + ) + source = path.read_text(encoding="utf-8") + assert "-c:v h264_cuvid" in source + assert "-frame_pts 1" in source + assert '$decodedPath = Join-Path $decodedFramesRoot ("frame-{0}.png" -f $pts)' in source + assert "$repairs.Count -ge 1" in source + assert 'method = "duplicate-previous-decoded-frame"' in source + assert 'schema_version = "missioncore.recorded-video-decode-repair/v1"' in source diff --git a/tests/test_lab_v1_goose_vegetation_benchmark.py b/tests/test_lab_v1_goose_vegetation_benchmark.py index 00bc275..fe59cde 100644 --- a/tests/test_lab_v1_goose_vegetation_benchmark.py +++ b/tests/test_lab_v1_goose_vegetation_benchmark.py @@ -25,6 +25,12 @@ POWERSHELL_PATH = ( / "worker" / "Invoke-LabV1VegetationGooseBenchmark.ps1" ) +RAV004_SOURCE_PATH = ( + REPOSITORY_ROOT + / "config" + / "perception" + / "lab-v1-ravnoves004tree-full-video-source-v1.json" +) def test_benchmark_contract_is_bounded_and_fail_closed() -> None: @@ -93,3 +99,21 @@ def test_worker_wrapper_is_isolated_from_canonical_triton() -> None: assert '"--cap-drop", "ALL"' in source assert '"--security-opt", "no-new-privileges"' in source assert "if ($canonicalAfter -ne $canonicalBefore)" in source + + +def test_rav004_full_video_profile_and_decoder_gap_are_explicit() -> None: + profile = json.loads(RAV004_SOURCE_PATH.read_text(encoding="utf-8")) + source_profile = profile["source"] + assert profile["schema_version"] == "missioncore.lab-v1-ravnoves-source/v1" + assert source_profile["source_job_id"] == ( + "recorded-camera-eb2783c5480d56bda07c8af0" + ) + assert source_profile["expected_frame_count"] == 6830 + assert source_profile["base_m4_result_id"] is None + source = POWERSHELL_PATH.read_text(encoding="utf-8") + assert "-c:v h264_cuvid" in source + assert 'schema_version = "missioncore.recorded-video-decode-repair/v1"' in source + assert 'method = "duplicate-previous-decoded-frame"' in source + assert "$repairs.Count -ge 1" in source + assert '& docker @arguments 2>&1 | ForEach-Object { Write-Output $_ }' in source + assert 'if ($dockerExitCode -ne 0)' in source diff --git a/tests/test_vegetation_shadow_lab.py b/tests/test_vegetation_shadow_lab.py index 7d465bc..f986067 100644 --- a/tests/test_vegetation_shadow_lab.py +++ b/tests/test_vegetation_shadow_lab.py @@ -270,6 +270,67 @@ def test_vegetation_shadow_lab_seals_autonomous_visual_evidence( assert mask.content == b"\x89PNG\r\n\x1a\n" assert mask.headers["cache-control"].endswith("immutable") + full_archive_payloads = (b"\x89PNG\r\n\x1a\ncity", b"\x89PNG\r\n\x1a\nvegetation") + full_identity = dict(manifest["identity"]) + full_route = { + "frame_count": 2, + "layers": { + layer: {"mask_archive": {"path": "video/full-route-masks.zip"}} + for layer in ("city", "vegetation") + }, + } + full_identity["route_full_review"] = full_route + full_identity_sha = hashlib.sha256( + json.dumps( + full_identity, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + full_result_id = f"lab-v1-vegetation-shadow-{full_identity_sha}" + full_root = result_root.parent / full_result_id + shutil.copytree(result_root, full_root) + full_archive = full_root / "video" / "full-route-masks.zip" + full_archive.parent.mkdir(exist_ok=True) + with zipfile.ZipFile(full_archive, "x", compression=zipfile.ZIP_STORED) as frozen: + for sequence, payload in enumerate(full_archive_payloads, start=1): + frozen.writestr(f"masks/frame-{sequence:06d}.png", payload) + full_manifest = dict(manifest) + full_manifest["result_id"] = full_result_id + full_manifest["identity"] = full_identity + full_manifest["identity_sha256"] = full_identity_sha + full_manifest["route_full_review"] = full_route + full_manifest["artifacts"] = [ + *manifest["artifacts"], + { + "role": "full-route-mask-fixture", + "path": "video/full-route-masks.zip", + "byte_length": full_archive.stat().st_size, + "sha256": _sha256(full_archive), + "media_type": "application/zip", + }, + ] + (full_root / "result.json").write_text( + json.dumps(full_manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + "\n", + encoding="utf-8", + ) + for layer, sequence, expected in ( + ("city", 0, full_archive_payloads[0]), + ("vegetation", 1, full_archive_payloads[1]), + ): + response = client.get( + f"/api/v1/laboratory/vegetation-shadow/{full_result_id}" + f"/route-masks/{layer}/{sequence}" + ) + assert response.status_code == 200 + assert response.content == expected + assert response.headers["cache-control"].endswith("immutable") + assert client.get( + f"/api/v1/laboratory/vegetation-shadow/{full_result_id}/route-masks/city/2" + ).status_code == 404 + (result_root / asset_path).write_bytes(b"tampered") assert ( client.get(f"/api/v1/laboratory/vegetation-shadow/{result_root.name}").status_code