From 67e6ae98e2cd555c5270dac0339ba47cb73e2188 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 28 Aug 2026 00:13:58 +0300 Subject: [PATCH] fix(perception): reuse M4.8 for vegetation evidence --- .../src/core/laboratory/vegetationShadow.ts | 43 ++++- .../laboratory/M48FailureAtlasVisual.tsx | 113 +++++++++++++ .../laboratory/VegetationShadowResult.tsx | 106 +++++++----- .../laboratory/VegetationShadowVisual.tsx | 157 ------------------ .../laboratory/laboratoryArchiveProfiles.ts | 6 +- .../test/vegetationShadow.test.mjs | 31 +++- config/laboratory-value-review.json | 4 +- .../lab-v1-goose-vegetation-benchmark-v1.json | 21 +++ .../run_goose_vegetation_benchmark.py | 131 ++++++++++++++- .../laboratory/vegetation_shadow_lab.py | 61 ++++++- .../test_lab_v1_goose_vegetation_benchmark.py | 15 ++ tests/test_vegetation_shadow_lab.py | 20 ++- 12 files changed, 482 insertions(+), 226 deletions(-) delete mode 100644 apps/control-station/src/workspaces/laboratory/VegetationShadowVisual.tsx diff --git a/apps/control-station/src/core/laboratory/vegetationShadow.ts b/apps/control-station/src/core/laboratory/vegetationShadow.ts index 23efd3c..dcd8539 100644 --- a/apps/control-station/src/core/laboratory/vegetationShadow.ts +++ b/apps/control-station/src/core/laboratory/vegetationShadow.ts @@ -4,11 +4,25 @@ const RESULT_ID = /^lab-v1-vegetation-shadow-[a-f0-9]{64}$/; const SHA256 = /^[a-f0-9]{64}$/; const CANDIDATES = ["ddrnet", "ppliteseg"] as const; const ROUTE_MODES = ["source", "ddrnet", "ppliteseg", "urban", "rural", "offroad"] as const; -const VALIDATION_MODES = ["source", "truth", "ddrnet", "ppliteseg"] as const; +const VALIDATION_ASSETS = [ + "source", + "truth", + "ddrnet", + "ppliteseg", + "ddrnet_error", + "ppliteseg_error", +] as const; export type VegetationCandidateKey = typeof CANDIDATES[number]; export type VegetationRouteMode = typeof ROUTE_MODES[number]; -export type VegetationValidationMode = typeof VALIDATION_MODES[number]; + +export interface VegetationVisualFocus { + className: string; + labelId: number; + truthPixels: number; + truthFraction: number; + stratumRank: number; +} export interface VegetationCandidateMetrics { candidate: VegetationCandidateKey; @@ -33,6 +47,7 @@ export interface VegetationVisualCase { height: number; centerCropXyxy: readonly [number, number, number, number]; outsideCropState: "undefined" | "not-applicable"; + focus: VegetationVisualFocus | null; assets: Readonly>; } @@ -174,7 +189,7 @@ function visualCaseValue( .join("/")}`; } const expectedAssets = expectedKind === "goose" - ? VALIDATION_MODES + ? VALIDATION_ASSETS : ROUTE_MODES; if (expectedAssets.some((key) => !projected[key])) { throw new VegetationShadowContractError(`vegetation.case.assets: ${expectedKind} набор неполон.`); @@ -183,6 +198,23 @@ function visualCaseValue( if (outsideCropState !== "undefined" && outsideCropState !== "not-applicable") { throw new VegetationShadowContractError("vegetation.case.outside_crop_state: контракт изменён."); } + let focus: VegetationVisualFocus | null = null; + if (expectedKind === "goose") { + const rawFocus = objectValue(row.focus, "vegetation.case.focus"); + const truthFraction = numberValue(rawFocus.truth_fraction, "vegetation.case.focus.truth_fraction"); + if (truthFraction <= 0 || truthFraction > 1) { + throw new VegetationShadowContractError("vegetation.case.focus.truth_fraction: диапазон изменён."); + } + focus = { + className: textValue(rawFocus.class_name, "vegetation.case.focus.class_name"), + labelId: integerValue(rawFocus.label_id, "vegetation.case.focus.label_id"), + truthPixels: integerValue(rawFocus.truth_pixels, "vegetation.case.focus.truth_pixels"), + truthFraction, + stratumRank: integerValue(rawFocus.stratum_rank, "vegetation.case.focus.stratum_rank"), + }; + } else if (row.focus !== null) { + throw new VegetationShadowContractError("vegetation.case.focus: RAVNOVES focus отсутствует."); + } return { caseId, sourceKind: expectedKind, @@ -190,6 +222,7 @@ function visualCaseValue( height: integerValue(row.height, "vegetation.case.height"), centerCropXyxy: crop as unknown as readonly [number, number, number, number], outsideCropState, + focus, assets: projected, }; } @@ -233,8 +266,8 @@ function parseResult(value: unknown, resultId: string): VegetationShadowResult { .map((item) => visualCaseValue(item, resultId, "ravnoves")); const validationCases = arrayValue(catalogs.goose, "vegetation.catalogs.goose") .map((item) => visualCaseValue(item, resultId, "goose")); - if (routeCases.length !== 12 || validationCases.length !== 12) { - throw new VegetationShadowContractError("vegetation.catalogs: ожидалось 12 + 12 случаев."); + if (routeCases.length !== 0 || validationCases.length !== 12) { + throw new VegetationShadowContractError("vegetation.catalogs: ожидалось 12 truth-backed GOOSE случаев без route viewer."); } return { resultId, diff --git a/apps/control-station/src/workspaces/laboratory/M48FailureAtlasVisual.tsx b/apps/control-station/src/workspaces/laboratory/M48FailureAtlasVisual.tsx index b076394..f57d5bd 100644 --- a/apps/control-station/src/workspaces/laboratory/M48FailureAtlasVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M48FailureAtlasVisual.tsx @@ -28,6 +28,119 @@ const ATLAS_MODES = [ ] as const; type AtlasMode = typeof ATLAS_MODES[number]["value"]; +const MASK_COMPARISON_MODES = [ + { value: "source", label: "SOURCE" }, + { value: "truth", label: "TRUTH" }, + { value: "prediction", label: "PREDICTION" }, + { value: "error", label: "ERROR" }, +] as const; +const MASK_COMPARISON_CANDIDATES = [ + { value: "ddrnet", label: "DDRNET" }, + { value: "ppliteseg", label: "PPLITE" }, +] as const; +type MaskComparisonMode = typeof MASK_COMPARISON_MODES[number]["value"]; +type MaskComparisonCandidate = typeof MASK_COMPARISON_CANDIDATES[number]["value"]; + +export interface M48MaskComparisonCase { + caseId: string; + title: string; + context?: string; + sourceUrl: string; + truthUrl: string; + predictions: Readonly>; + errors: Readonly>; +} + +function M48MaskComparisonScene({ + item, + mode, + candidate, +}: { + item: M48MaskComparisonCase; + mode: MaskComparisonMode; + candidate: MaskComparisonCandidate; +}) { + const overlay = mode === "truth" + ? item.truthUrl + : mode === "prediction" + ? item.predictions[candidate] + : mode === "error" + ? item.errors[candidate] + : null; + return ( +
+ + {overlay ? : null} +
+ ); +} + +export function M48MaskComparisonVisual({ + cases, + initialCandidate, +}: { + cases: readonly M48MaskComparisonCase[]; + initialCandidate: MaskComparisonCandidate; +}) { + const [index, setIndex] = useState(0); + const [mode, setMode] = useState("error"); + const [candidate, setCandidate] = useState(initialCandidate); + const [expanded, setExpanded] = useState(false); + const item = cases[index] ?? null; + return ( + + setIndex((current) => (current - 1 + cases.length) % cases.length)} + > + + + setIndex((current) => (current + 1) % cases.length)} + > + + + + )} + overlay={item ? ( +
+ GOOSE TRUTH · {index + 1}/{cases.length} + {item.title} + {item.context ? {item.context} : null} +
+ ) : null} + > + {item ? ( + + ) : ( +
+ + Vegetation hard-case каталог пуст. +
+ )} +
+ ); +} + function message(error: unknown): string { return error instanceof Error && error.message.trim() ? error.message : "M4.8 evidence недоступно."; } diff --git a/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx b/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx index 63c64ce..af0c1fc 100644 --- a/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx +++ b/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx @@ -6,14 +6,45 @@ import { } from "../../components/laboratory/LaboratoryPresentation"; import type { VegetationShadowResult } from "../../core/laboratory/vegetationShadow"; import { - VegetationRouteVisual, - VegetationValidationVisual, -} from "./VegetationShadowVisual"; + M48MaskComparisonVisual, + type M48MaskComparisonCase, +} from "./M48FailureAtlasVisual"; function decimal(value: number, digits = 1): string { return value.toLocaleString("ru-RU", { maximumFractionDigits: digits }); } +const VEGETATION_LABELS: Readonly> = { + high_grass: "Высокая трава", + low_grass: "Низкая трава", + bush: "Куст", + tree_trunk: "Ствол дерева", + tree_crown: "Крона дерева", + hedge: "Живая изгородь", + forest: "Лесная растительность", + crops: "Посевы", +}; + +function comparisonCases(result: VegetationShadowResult): readonly M48MaskComparisonCase[] { + return result.validationCases.map((item) => { + const focus = item.focus!; + return { + caseId: item.caseId, + title: `${VEGETATION_LABELS[focus.className] ?? focus.className} · truth ${decimal(focus.truthFraction * 100, 1)}% кадра`, + sourceUrl: item.assets.source, + truthUrl: item.assets.truth, + predictions: { + ddrnet: item.assets.ddrnet, + ppliteseg: item.assets.ppliteseg, + }, + errors: { + ddrnet: item.assets.ddrnet_error, + ppliteseg: item.assets.ppliteseg_error, + }, + }; + }); +} + export function VegetationShadowResultView({ rigLabel, result, @@ -31,21 +62,21 @@ export function VegetationShadowResultView({ )} evidence={( - <> - - - - - - - + + + )} result={( )} diff --git a/apps/control-station/src/workspaces/laboratory/VegetationShadowVisual.tsx b/apps/control-station/src/workspaces/laboratory/VegetationShadowVisual.tsx deleted file mode 100644 index e764bf9..0000000 --- a/apps/control-station/src/workspaces/laboratory/VegetationShadowVisual.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { useState } from "react"; -import { Icon, IconButton, StatusBadge } from "@nodedc/ui-react"; - -import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer"; -import type { - VegetationRouteMode, - VegetationShadowResult, - VegetationValidationMode, - VegetationVisualCase, -} from "../../core/laboratory/vegetationShadow"; - -const ROUTE_MODES = [ - { value: "source", label: "SOURCE" }, - { value: "ddrnet", label: "DDRNET" }, - { value: "ppliteseg", label: "PPLITE" }, - { value: "urban", label: "URBAN" }, - { value: "rural", label: "RURAL" }, - { value: "offroad", label: "OFF-ROAD" }, -] as const; - -const VALIDATION_MODES = [ - { value: "source", label: "SOURCE" }, - { value: "truth", label: "TRUTH" }, - { value: "ddrnet", label: "DDRNET" }, - { value: "ppliteseg", label: "PPLITE" }, -] as const; - -function VegetationScene({ - item, - mode, -}: { - item: VegetationVisualCase; - mode: string; -}) { - const overlay = mode === "source" ? null : item.assets[mode]; - return ( -
- - {overlay ? : null} -
- ); -} - -export function VegetationRouteVisual({ result }: { result: VegetationShadowResult }) { - const [index, setIndex] = useState(0); - const [mode, setMode] = useState("offroad"); - const [expanded, setExpanded] = useState(false); - const item = result.routeCases[index] ?? null; - const selected = result.candidates.find( - (candidate) => candidate.candidate === result.selectedCandidate, - ); - return ( - - setIndex((current) => ( - current - 1 + result.routeCases.length - ) % result.routeCases.length)} - > - - - setIndex((current) => (current + 1) % result.routeCases.length)} - > - - - - )} - overlay={item ? ( -
- SHADOW ONLY - RAVNOVES00 · {item.caseId} · {mode.toUpperCase()} - - {selected?.loadedModelName ?? result.selectedCandidate} policy provider - {" · "}center crop 600×600 - {" · "}outside crop UNKNOWN - -
- ) : null} - > - {item ? ( - - ) : ( -
- - RAVNOVES vegetation shadow каталог пуст. -
- )} -
- ); -} - -export function VegetationValidationVisual({ result }: { result: VegetationShadowResult }) { - const [index, setIndex] = useState(0); - const [mode, setMode] = useState("truth"); - const [expanded, setExpanded] = useState(false); - const item = result.validationCases[index] ?? null; - return ( - - setIndex((current) => ( - current - 1 + result.validationCases.length - ) % result.validationCases.length)} - > - - - setIndex((current) => (current + 1) % result.validationCases.length)} - > - - - - )} - overlay={item ? ( -
- GOOSE VALIDATION - {item.caseId} · {mode.toUpperCase()} - official fine-64 labels · fixed 512×512 preprocessing · visual sample -
- ) : null} - > - {item ? ( - - ) : ( -
- - GOOSE validation каталог пуст. -
- )} -
- ); -} diff --git a/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts index 8af579f..292045f 100644 --- a/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts +++ b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts @@ -65,10 +65,10 @@ const rig = (rigLabel: string): string => rigLabel.trim() || "Сенсорный const KNOWN_WORKS: Readonly, KnownWorkDefinition>> = { "lab-v1-vegetation-shadow": { profileId: "rig-ravnoves-perception-gate-v1", - profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · RAVNOVES00 vegetation shadow`, + profileName: (rigLabel) => `${rig(rigLabel)} · GOOSE vegetation qualification`, experimentId: "lab-v1-vegetation-mission-policy", - experimentName: "GOOSE ready weights → RAVNOVES vegetation policy", - variantName: "LAB V1 · DDRNet vs PPLiteSeg · urban/rural/off-road presets", + experimentName: "DDRNet vs PPLiteSeg · truth-backed vegetation hard cases", + variantName: "LAB V1 · готовые vegetation weights · GOOSE truth", }, "m48-object-centric-quality": { profileId: "rig-dual-evidence-virtual-corridor-v1", diff --git a/apps/control-station/test/vegetationShadow.test.mjs b/apps/control-station/test/vegetationShadow.test.mjs index 345b661..8066642 100644 --- a/apps/control-station/test/vegetationShadow.test.mjs +++ b/apps/control-station/test/vegetationShadow.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { access, readFile } from "node:fs/promises"; import { after, before, test } from "node:test"; import { createServer } from "vite"; @@ -51,7 +52,7 @@ function candidate(candidateKey, vegetationIou) { function visualCase(sourceKind, index) { const caseId = `case-${index}`; const keys = sourceKind === "goose" - ? ["source", "truth", "ddrnet", "ppliteseg"] + ? ["source", "truth", "ddrnet", "ppliteseg", "ddrnet_error", "ppliteseg_error"] : ["source", "ddrnet", "ppliteseg", "urban", "rural", "offroad"]; return { case_id: caseId, @@ -60,6 +61,13 @@ function visualCase(sourceKind, index) { height: sourceKind === "goose" ? 512 : 600, center_crop_xyxy: sourceKind === "goose" ? [0, 0, 512, 512] : [100, 0, 700, 600], outside_crop_state: sourceKind === "goose" ? "not-applicable" : "undefined", + focus: sourceKind === "goose" ? { + class_name: "high_grass", + label_id: 51, + truth_pixels: 16384, + truth_fraction: 0.0625, + stratum_rank: index + 1, + } : null, assets: Object.fromEntries(keys.map((key) => [key, { path: `visual/${sourceKind}/${caseId}/${key}.png`, sha256: "d".repeat(64), @@ -101,7 +109,7 @@ test("vegetation LAB keeps autonomous assets and fail-closed authority", async ( }, catalogs: { goose: Array.from({ length: 12 }, (_, index) => visualCase("goose", index)), - ravnoves: Array.from({ length: 12 }, (_, index) => visualCase("ravnoves", index)), + ravnoves: [], }, access: "read-only", }), { status: 200, headers: { "Content-Type": "application/json" } }); @@ -113,9 +121,10 @@ test("vegetation LAB keeps autonomous assets and fail-closed authority", async ( ); assert.equal(result.selectedCandidate, "ddrnet"); assert.equal(result.candidates[0].vegetationMeanIouPercent, 64); - assert.equal(result.routeCases.length, 12); + assert.equal(result.routeCases.length, 0); assert.equal(result.validationCases.length, 12); - assert.match(result.routeCases[0].assets.offroad, /\/assets\/visual\/ravnoves\//); + assert.equal(result.validationCases[0].focus.className, "high_grass"); + assert.match(result.validationCases[0].assets.ddrnet_error, /\/assets\/visual\/goose\//); assert.deepEqual(result.authority, { commandsEnabled: false, navigationOrSafetyAccepted: false, @@ -123,3 +132,17 @@ test("vegetation LAB keeps autonomous assets and fail-closed authority", async ( cameraSemanticsCanClearRigidGeometry: false, }); }); + +test("vegetation LAB reuses the admitted M4.8 instrument", async () => { + const resultSource = await readFile( + new URL("../src/workspaces/laboratory/VegetationShadowResult.tsx", import.meta.url), + "utf8", + ); + assert.match(resultSource, /M48MaskComparisonVisual/); + assert.equal(resultSource.match(/ list[tuple[Path, Path]]: return pairs -def visual_indices(count: int, visual_count: int) -> set[int]: +def visual_indices(count: int, visual_count: int) -> dict[int, dict[str, Any]]: if count <= 0 or visual_count <= 0: - return set() + return {} selected_count = min(count, visual_count) if selected_count == 1: - return {0} + return {0: {}} return { - round(index * (count - 1) / (selected_count - 1)) + round(index * (count - 1) / (selected_count - 1)): {} for index in range(selected_count) } +def truth_focused_visuals( + items: list[tuple[str, Path, Path | None]], + names: dict[int, str], + contract: dict[str, Any], +) -> dict[int, dict[str, Any]]: + if contract.get("selection_basis") != "ground-truth-class-support-only": + raise RunnerError("visual selection basis changed") + case_count = contract.get("case_count") + minimum_pixels = contract.get("minimum_focus_pixels") + strata = contract.get("strata") + if ( + not isinstance(case_count, int) + or case_count <= 0 + or not isinstance(minimum_pixels, int) + or minimum_pixels <= 0 + or not isinstance(strata, list) + or sum(row.get("count", 0) for row in strata if isinstance(row, dict)) != case_count + ): + raise RunnerError("visual case contract is invalid") + ids_by_name = {class_name: label_id for label_id, class_name in names.items()} + supports: list[dict[int, int]] = [] + for _, _, label_path in items: + if label_path is None: + raise RunnerError("truth-focused selection requires labels") + truth = preprocess_label(Image.open(label_path)) + values, counts = np.unique(truth, return_counts=True) + supports.append({int(value): int(count) for value, count in zip(values, counts)}) + + selected: dict[int, dict[str, Any]] = {} + for raw in strata: + if not isinstance(raw, dict): + raise RunnerError("visual stratum is invalid") + class_name = raw.get("class_name") + count = raw.get("count") + if class_name not in ids_by_name or not isinstance(count, int) or count <= 0: + raise RunnerError("visual stratum identity changed") + label_id = ids_by_name[class_name] + ranked = sorted( + ( + (support.get(label_id, 0), items[index][0], index) + for index, support in enumerate(supports) + if index not in selected and support.get(label_id, 0) > 0 + ), + key=lambda row: (-row[0], row[1]), + ) + admitted = [row for row in ranked if row[0] >= minimum_pixels] + if len(admitted) < count: + admitted = ranked + if len(admitted) < count: + raise RunnerError(f"visual stratum {class_name} has fewer than {count} cases") + for rank, (truth_pixels, _, index) in enumerate(admitted[:count], start=1): + selected[index] = { + "class_name": class_name, + "label_id": label_id, + "truth_pixels": truth_pixels, + "truth_fraction": round(truth_pixels / float(512 * 512), 8), + "stratum_rank": rank, + } + if len(selected) != case_count: + raise RunnerError("truth-focused visual selection did not produce the frozen case count") + return selected + + def load_model(candidate: str, checkpoint: Path) -> tuple[torch.nn.Module, str, list[str]]: failures: list[str] = [] for model_name in MODEL_NAMES[candidate]: @@ -297,6 +360,9 @@ def write_visual_case( policy_palettes: dict[str, np.ndarray], crop_box: tuple[int, int, int, int], truth: np.ndarray | None = None, + focus: dict[str, Any] | None = None, + material_codes: np.ndarray | None = None, + error_colors: dict[str, list[int]] | None = None, preserve_source_size: bool = False, ) -> dict[str, Any]: case_root = output / "cases" / case_id @@ -343,6 +409,26 @@ def write_visual_case( "relative_path": truth_semantic_path.relative_to(output).as_posix(), "sha256": save_image(truth_semantic_path, semantic_palette[truth_image], "RGBA"), } + if material_codes is None or error_colors is None: + raise RunnerError("truth visual case requires the material-error contract") + truth_material = material_codes[truth_image] + predicted_material = material_codes[prediction_image] + truth_vegetation = truth_material > 0 + predicted_vegetation = predicted_material > 0 + error_overlay = np.zeros((*truth_image.shape, 4), dtype=np.uint8) + correct = truth_vegetation & (truth_material == predicted_material) + missed = truth_vegetation & ~predicted_vegetation + false_positive = ~truth_vegetation & predicted_vegetation + wrong_material = truth_vegetation & predicted_vegetation & (truth_material != predicted_material) + error_overlay[correct] = error_colors["correct_material_rgba"] + error_overlay[missed] = error_colors["missed_vegetation_rgba"] + error_overlay[false_positive] = error_colors["false_vegetation_rgba"] + error_overlay[wrong_material] = error_colors["wrong_vegetation_material_rgba"] + error_path = case_root / "vegetation-material-error.png" + files["vegetation_material_error"] = { + "relative_path": error_path.relative_to(output).as_posix(), + "sha256": save_image(error_path, error_overlay, "RGBA"), + } return { "schema_version": VISUAL_SCHEMA, "case_id": case_id, @@ -350,6 +436,7 @@ def write_visual_case( "source_height": source_image.height, "center_crop_xyxy": list(crop_box), "outside_crop_state": "undefined" if preserve_source_size else "not-applicable", + "focus": focus, "files": files, } @@ -400,6 +487,27 @@ def run() -> None: ) for preset in ("urban", "rural", "offroad") } + provider_labels = provider_map["providers"]["goose-fine-64"]["labels"] + vegetation_materials = sorted( + { + material + for material in provider_labels.values() + if material in { + "grass", + "herbaceous_vegetation", + "cultivated_vegetation", + "woody_shrub", + "tree_or_trunk", + "vegetation_unknown", + } + } + ) + material_code_by_name = { + material: index for index, material in enumerate(vegetation_materials, start=1) + } + material_codes = np.zeros(CLASS_COUNT, dtype=np.uint8) + for label_id, class_name in names.items(): + material_codes[label_id] = material_code_by_name.get(provider_labels.get(class_name), 0) if args.mode == "goose": pairs = find_goose_pairs(mapping_root) @@ -423,6 +531,17 @@ def run() -> None: if not items: raise RunnerError("no inputs were selected") + visual_contract = config.get("visual_case_contract") + if not isinstance(visual_contract, dict): + raise RunnerError("visual case contract is unavailable") + configured_visual_count = visual_contract.get("case_count") + if args.mode == "goose": + 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: + selected_visuals = visual_indices(len(items), args.visual_count) + args.output.mkdir(parents=True, exist_ok=False) torch.cuda.empty_cache() model, model_name, architecture_failures = load_model(args.candidate, args.checkpoint) @@ -430,7 +549,6 @@ def run() -> None: warmup_tensor, _ = preprocess(warmup_source) warmup_latencies_ms = [infer(model, warmup_tensor)[1] for _ in range(3)] torch.cuda.reset_peak_memory_stats() - selected_visuals = visual_indices(len(items), args.visual_count) confusion = np.zeros((CLASS_COUNT, CLASS_COUNT), dtype=np.int64) latencies_ms: list[float] = [] visuals: list[dict[str, Any]] = [] @@ -454,6 +572,9 @@ def run() -> None: policy_palettes, crop_box, truth=truth, + focus=selected_visuals[index] or None, + material_codes=material_codes, + error_colors=visual_contract["error_overlay"], preserve_source_size=args.mode == "ravnoves", ) ) diff --git a/src/k1link/laboratory/vegetation_shadow_lab.py b/src/k1link/laboratory/vegetation_shadow_lab.py index a378a90..dbae493 100644 --- a/src/k1link/laboratory/vegetation_shadow_lab.py +++ b/src/k1link/laboratory/vegetation_shadow_lab.py @@ -16,6 +16,16 @@ 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") +_FOCUS_ORDER: Final = ( + "high_grass", + "low_grass", + "bush", + "tree_trunk", + "tree_crown", + "hedge", + "forest", + "crops", +) _IMAGE_KEYS: Final = ( "source", "prediction_semantic", @@ -93,6 +103,22 @@ def _case_map(result: dict[str, Any], label: str) -> dict[str, dict[str, Any]]: return rows +def _visual_case_order(row: dict[str, Any]) -> tuple[int, int, str]: + focus = _object(row.get("focus"), "GOOSE visual focus") + class_name = focus.get("class_name") + stratum_rank = focus.get("stratum_rank") + case_id = row.get("case_id") + if ( + not isinstance(class_name, str) + or class_name not in _FOCUS_ORDER + or not isinstance(stratum_rank, int) + or stratum_rank <= 0 + or not isinstance(case_id, str) + ): + raise VegetationShadowLabError("GOOSE visual focus ordering is invalid") + return _FOCUS_ORDER.index(class_name), stratum_rank, case_id + + def _file_from_case( root: Path, case: dict[str, Any], @@ -198,8 +224,14 @@ def seal_vegetation_shadow_lab( artifacts: list[dict[str, object]] = [] catalogs: dict[str, list[dict[str, object]]] = {"goose": [], "ravnoves": []} try: - for mode in _MODES: - for case_id in sorted(cases[("ddrnet", mode)]): + # RAVNOVES is retained in the immutable Worker proof and timing summary, + # but it has no vegetation truth island. Publishing those urban frames + # as primary visual cases would misrepresent the operator question. + for mode in ("goose",): + for case_id in sorted( + cases[("ddrnet", mode)], + key=lambda value: _visual_case_order(cases[("ddrnet", mode)][value]), + ): ddr_case = cases[("ddrnet", mode)][case_id] pplite_case = cases[("ppliteseg", mode)][case_id] row: dict[str, object] = { @@ -209,8 +241,13 @@ def seal_vegetation_shadow_lab( "height": ddr_case.get("source_height"), "center_crop_xyxy": ddr_case.get("center_crop_xyxy"), "outside_crop_state": ddr_case.get("outside_crop_state"), + "focus": ddr_case.get("focus"), "assets": {}, } + if ddr_case.get("focus") != pplite_case.get("focus"): + raise VegetationShadowLabError( + f"{mode} case {case_id} focus contract differs between candidates" + ) asset_map = _object(row["assets"], "sealed assets") sources: list[tuple[str, str, dict[str, Any], str]] = [ ("source", "ddrnet", ddr_case, "source"), @@ -218,7 +255,23 @@ def seal_vegetation_shadow_lab( ("ppliteseg", "ppliteseg", pplite_case, "prediction_semantic"), ] if mode == "goose": - sources.append(("truth", "ddrnet", ddr_case, "truth_semantic")) + sources.extend( + ( + ("truth", "ddrnet", ddr_case, "truth_semantic"), + ( + "ddrnet_error", + "ddrnet", + ddr_case, + "vegetation_material_error", + ), + ( + "ppliteseg_error", + "ppliteseg", + pplite_case, + "vegetation_material_error", + ), + ) + ) else: selected_case = cases[(selected, mode)][case_id] sources.extend( @@ -328,7 +381,7 @@ def seal_vegetation_shadow_lab( }, "limitations": [ "GOOSE validation is external-domain qualification, not RAVNOVES ground truth.", - "The RAVNOVES island is visual shadow evidence without independent labels.", + "The RAVNOVES shadow remains in Worker proofs and is not catalogued as vegetation evidence because it has no independent labels.", "Vegetation semantics never clears rigid LiDAR/TGS occupancy.", "Undefined pixels outside the 600x600 center crop remain fail-closed.", ], diff --git a/tests/test_lab_v1_goose_vegetation_benchmark.py b/tests/test_lab_v1_goose_vegetation_benchmark.py index d8a22ee..00bc275 100644 --- a/tests/test_lab_v1_goose_vegetation_benchmark.py +++ b/tests/test_lab_v1_goose_vegetation_benchmark.py @@ -44,6 +44,19 @@ def test_benchmark_contract_is_bounded_and_fail_closed() -> None: ) assert config["ravnoves"]["expected_frame_count"] == 4489 assert len(config["ravnoves"]["frame_indices"]) == 12 + assert config["visual_case_contract"]["selection_basis"] == ( + "ground-truth-class-support-only" + ) + assert config["visual_case_contract"]["case_count"] == 12 + assert sum( + row["count"] for row in config["visual_case_contract"]["strata"] + ) == 12 + assert {row["class_name"] for row in config["visual_case_contract"]["strata"]} >= { + "high_grass", + "low_grass", + "bush", + "tree_trunk", + } assert config["invariants"] == { "one_heavy_candidate_at_a_time": True, "raw_fisheye_is_immutable": True, @@ -68,6 +81,8 @@ def test_runner_uses_exact_visible_pairs_and_never_grants_authority() -> None: assert '"prewarm_inference_count": len(warmup_latencies_ms)' in source assert '"prewarm_latency_ms": round(warmup_latencies_ms[0], 4)' in source assert '"prewarm_latency_ms_last": round(warmup_latencies_ms[-1], 4)' in source + assert "truth_focused_visuals(items, names, visual_contract)" in source + assert 'files["vegetation_material_error"]' in source def test_worker_wrapper_is_isolated_from_canonical_triton() -> None: diff --git a/tests/test_vegetation_shadow_lab.py b/tests/test_vegetation_shadow_lab.py index c672de2..6366186 100644 --- a/tests/test_vegetation_shadow_lab.py +++ b/tests/test_vegetation_shadow_lab.py @@ -28,7 +28,7 @@ def _worker_result(root: Path, *, candidate: str, mode: str, vegetation_iou: flo case_root.mkdir(parents=True) keys = ["source", "prediction_semantic", "policy_urban", "policy_rural", "policy_offroad"] if mode == "goose": - keys.append("truth_semantic") + keys.extend(("truth_semantic", "vegetation_material_error")) files = {} for key in keys: path = case_root / f"{key}.png" @@ -44,6 +44,13 @@ def _worker_result(root: Path, *, candidate: str, mode: str, vegetation_iou: flo "source_height": 600 if mode == "ravnoves" else 512, "center_crop_xyxy": [100, 0, 700, 600] if mode == "ravnoves" else [0, 0, 512, 512], "outside_crop_state": "undefined" if mode == "ravnoves" else "not-applicable", + "focus": { + "class_name": "high_grass", + "label_id": 51, + "truth_pixels": 16384, + "truth_fraction": 0.0625, + "stratum_rank": index + 1, + } if mode == "goose" else None, "files": files, } ) @@ -99,9 +106,12 @@ def test_vegetation_shadow_lab_seals_autonomous_visual_evidence(tmp_path: Path) assert manifest["ground_truth"] is False assert manifest["authority"]["commands_enabled"] is False assert manifest["authority"]["navigation_or_safety_accepted"] is False - assert len(manifest["catalogs"]["ravnoves"]) == 12 + assert len(manifest["catalogs"]["ravnoves"]) == 0 assert len(manifest["catalogs"]["goose"]) == 12 - assert len(manifest["artifacts"]) == 124 + assert len(manifest["artifacts"]) == 76 + 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"] assert "all_classes" not in manifest["metrics"]["candidates"]["ddrnet"]["validation_metrics"] assert (result_root / "result.json").stat().st_size <= 64 * 1024 @@ -111,7 +121,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"] == 124 + assert proof["artifact_count"] == 76 app = FastAPI() app.include_router(build_vegetation_shadow_lab_router(root_provider=lambda: result_root.parent)) @@ -119,7 +129,7 @@ def test_vegetation_shadow_lab_seals_autonomous_visual_evidence(tmp_path: Path) response = client.get(f"/api/v1/laboratory/vegetation-shadow/{result_root.name}") assert response.status_code == 200 assert response.json()["access"] == "read-only" - asset_path = manifest["catalogs"]["ravnoves"][0]["assets"]["offroad"]["path"] + asset_path = manifest["catalogs"]["goose"][0]["assets"]["ddrnet_error"]["path"] asset = client.get( f"/api/v1/laboratory/vegetation-shadow/{result_root.name}/assets/{asset_path}" )