|
|
|
@@ -0,0 +1,278 @@
|
|
|
|
|
import type { LaboratoryFetch } from "./advancedResults";
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
export type VegetationCandidateKey = typeof CANDIDATES[number];
|
|
|
|
|
export type VegetationRouteMode = typeof ROUTE_MODES[number];
|
|
|
|
|
export type VegetationValidationMode = typeof VALIDATION_MODES[number];
|
|
|
|
|
|
|
|
|
|
export interface VegetationCandidateMetrics {
|
|
|
|
|
candidate: VegetationCandidateKey;
|
|
|
|
|
loadedModelName: string;
|
|
|
|
|
checkpointSha256: string;
|
|
|
|
|
meanIouPercent: number;
|
|
|
|
|
publishedMeanIouPercent: number;
|
|
|
|
|
vegetationMeanIouPercent: number;
|
|
|
|
|
validationLatencyP95Ms: number;
|
|
|
|
|
validationThroughputFps: number;
|
|
|
|
|
shadowLatencyP95Ms: number;
|
|
|
|
|
shadowThroughputFps: number;
|
|
|
|
|
shadowPrewarmLatencyMs: number;
|
|
|
|
|
peakReservedVramBytes: number;
|
|
|
|
|
gpuName: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface VegetationVisualCase {
|
|
|
|
|
caseId: string;
|
|
|
|
|
sourceKind: "goose" | "ravnoves";
|
|
|
|
|
width: number;
|
|
|
|
|
height: number;
|
|
|
|
|
centerCropXyxy: readonly [number, number, number, number];
|
|
|
|
|
outsideCropState: "undefined" | "not-applicable";
|
|
|
|
|
assets: Readonly<Record<string, string>>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface VegetationShadowResult {
|
|
|
|
|
resultId: string;
|
|
|
|
|
createdAtUtc: string;
|
|
|
|
|
status: "visual-shadow-ready-policy-not-authorized";
|
|
|
|
|
selectedCandidate: VegetationCandidateKey;
|
|
|
|
|
candidates: readonly VegetationCandidateMetrics[];
|
|
|
|
|
routeCases: readonly VegetationVisualCase[];
|
|
|
|
|
validationCases: readonly VegetationVisualCase[];
|
|
|
|
|
limitations: readonly string[];
|
|
|
|
|
visualShadowReady: true;
|
|
|
|
|
missionPolicyReadyForConfiguration: true;
|
|
|
|
|
authority: {
|
|
|
|
|
commandsEnabled: false;
|
|
|
|
|
navigationOrSafetyAccepted: false;
|
|
|
|
|
actuationAccepted: false;
|
|
|
|
|
cameraSemanticsCanClearRigidGeometry: false;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class VegetationShadowContractError extends Error {}
|
|
|
|
|
|
|
|
|
|
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
|
|
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
|
|
|
throw new VegetationShadowContractError(`${label}: ожидался объект.`);
|
|
|
|
|
}
|
|
|
|
|
return value as Record<string, unknown>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function arrayValue(value: unknown, label: string): readonly unknown[] {
|
|
|
|
|
if (!Array.isArray(value)) throw new VegetationShadowContractError(`${label}: ожидался массив.`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function textValue(value: unknown, label: string): string {
|
|
|
|
|
if (typeof value !== "string" || !value.trim()) {
|
|
|
|
|
throw new VegetationShadowContractError(`${label}: ожидалась строка.`);
|
|
|
|
|
}
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function numberValue(value: unknown, label: string): number {
|
|
|
|
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
|
|
|
throw new VegetationShadowContractError(`${label}: ожидалось число.`);
|
|
|
|
|
}
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function integerValue(value: unknown, label: string): number {
|
|
|
|
|
const parsed = numberValue(value, label);
|
|
|
|
|
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
|
|
|
throw new VegetationShadowContractError(`${label}: ожидалось неотрицательное целое.`);
|
|
|
|
|
}
|
|
|
|
|
return parsed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function exact(value: unknown, expected: unknown, label: string): void {
|
|
|
|
|
if (value !== expected) {
|
|
|
|
|
throw new VegetationShadowContractError(`${label}: контракт изменён.`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function candidateKey(value: unknown, label: string): VegetationCandidateKey {
|
|
|
|
|
if (value !== "ddrnet" && value !== "ppliteseg") {
|
|
|
|
|
throw new VegetationShadowContractError(`${label}: неизвестная модель.`);
|
|
|
|
|
}
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function candidateMetricsValue(
|
|
|
|
|
value: unknown,
|
|
|
|
|
candidate: VegetationCandidateKey,
|
|
|
|
|
): VegetationCandidateMetrics {
|
|
|
|
|
const row = objectValue(value, `vegetation.metrics.${candidate}`);
|
|
|
|
|
const validation = objectValue(row.validation_metrics, `${candidate}.validation_metrics`);
|
|
|
|
|
const validationTiming = objectValue(row.validation_timing, `${candidate}.validation_timing`);
|
|
|
|
|
const shadowTiming = objectValue(row.shadow_timing, `${candidate}.shadow_timing`);
|
|
|
|
|
const resource = objectValue(row.resource, `${candidate}.resource`);
|
|
|
|
|
const checkpointSha256 = textValue(row.checkpoint_sha256, `${candidate}.checkpoint_sha256`);
|
|
|
|
|
if (!SHA256.test(checkpointSha256)) {
|
|
|
|
|
throw new VegetationShadowContractError(`${candidate}.checkpoint_sha256: digest invalid.`);
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
candidate,
|
|
|
|
|
loadedModelName: textValue(row.loaded_model_name, `${candidate}.loaded_model_name`),
|
|
|
|
|
checkpointSha256,
|
|
|
|
|
meanIouPercent: numberValue(validation.mean_iou_percent, `${candidate}.mean_iou_percent`),
|
|
|
|
|
publishedMeanIouPercent: numberValue(
|
|
|
|
|
validation.published_mean_iou_percent,
|
|
|
|
|
`${candidate}.published_mean_iou_percent`,
|
|
|
|
|
),
|
|
|
|
|
vegetationMeanIouPercent: numberValue(validation.vegetation_mean_iou, `${candidate}.vegetation_mean_iou`) * 100,
|
|
|
|
|
validationLatencyP95Ms: numberValue(validationTiming.latency_ms_p95, `${candidate}.validation_latency_p95`),
|
|
|
|
|
validationThroughputFps: numberValue(
|
|
|
|
|
validationTiming.throughput_fps_from_mean_inference,
|
|
|
|
|
`${candidate}.validation_throughput`,
|
|
|
|
|
),
|
|
|
|
|
shadowLatencyP95Ms: numberValue(shadowTiming.latency_ms_p95, `${candidate}.shadow_latency_p95`),
|
|
|
|
|
shadowThroughputFps: numberValue(
|
|
|
|
|
shadowTiming.throughput_fps_from_mean_inference,
|
|
|
|
|
`${candidate}.shadow_throughput`,
|
|
|
|
|
),
|
|
|
|
|
shadowPrewarmLatencyMs: numberValue(
|
|
|
|
|
shadowTiming.prewarm_latency_ms,
|
|
|
|
|
`${candidate}.shadow_prewarm_latency`,
|
|
|
|
|
),
|
|
|
|
|
peakReservedVramBytes: integerValue(resource.peak_reserved_vram_bytes, `${candidate}.vram`),
|
|
|
|
|
gpuName: textValue(resource.gpu_name, `${candidate}.gpu_name`),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function visualCaseValue(
|
|
|
|
|
value: unknown,
|
|
|
|
|
resultId: string,
|
|
|
|
|
expectedKind: "goose" | "ravnoves",
|
|
|
|
|
): VegetationVisualCase {
|
|
|
|
|
const row = objectValue(value, `vegetation.${expectedKind}.case`);
|
|
|
|
|
exact(row.source_kind, expectedKind, "vegetation.case.source_kind");
|
|
|
|
|
const caseId = textValue(row.case_id, "vegetation.case.case_id");
|
|
|
|
|
const crop = arrayValue(row.center_crop_xyxy, "vegetation.case.center_crop_xyxy")
|
|
|
|
|
.map((item, index) => integerValue(item, `vegetation.case.crop[${index}]`));
|
|
|
|
|
if (crop.length !== 4) {
|
|
|
|
|
throw new VegetationShadowContractError("vegetation.case.center_crop_xyxy: размер изменён.");
|
|
|
|
|
}
|
|
|
|
|
const assets = objectValue(row.assets, "vegetation.case.assets");
|
|
|
|
|
const projected: Record<string, string> = {};
|
|
|
|
|
for (const [key, raw] of Object.entries(assets)) {
|
|
|
|
|
const descriptor = objectValue(raw, `vegetation.case.assets.${key}`);
|
|
|
|
|
const path = textValue(descriptor.path, `vegetation.case.assets.${key}.path`);
|
|
|
|
|
const sha256 = textValue(descriptor.sha256, `vegetation.case.assets.${key}.sha256`);
|
|
|
|
|
if (!SHA256.test(sha256) || !path.startsWith(`visual/${expectedKind}/${caseId}/`)) {
|
|
|
|
|
throw new VegetationShadowContractError(`vegetation.case.assets.${key}: proof invalid.`);
|
|
|
|
|
}
|
|
|
|
|
projected[key] = `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/assets/${path
|
|
|
|
|
.split("/")
|
|
|
|
|
.map(encodeURIComponent)
|
|
|
|
|
.join("/")}`;
|
|
|
|
|
}
|
|
|
|
|
const expectedAssets = expectedKind === "goose"
|
|
|
|
|
? VALIDATION_MODES
|
|
|
|
|
: ROUTE_MODES;
|
|
|
|
|
if (expectedAssets.some((key) => !projected[key])) {
|
|
|
|
|
throw new VegetationShadowContractError(`vegetation.case.assets: ${expectedKind} набор неполон.`);
|
|
|
|
|
}
|
|
|
|
|
const outsideCropState = row.outside_crop_state;
|
|
|
|
|
if (outsideCropState !== "undefined" && outsideCropState !== "not-applicable") {
|
|
|
|
|
throw new VegetationShadowContractError("vegetation.case.outside_crop_state: контракт изменён.");
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
caseId,
|
|
|
|
|
sourceKind: expectedKind,
|
|
|
|
|
width: integerValue(row.width, "vegetation.case.width"),
|
|
|
|
|
height: integerValue(row.height, "vegetation.case.height"),
|
|
|
|
|
centerCropXyxy: crop as unknown as readonly [number, number, number, number],
|
|
|
|
|
outsideCropState,
|
|
|
|
|
assets: projected,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
exact(payload.result_id, resultId, "vegetation.result_id");
|
|
|
|
|
exact(payload.status, "visual-shadow-ready-policy-not-authorized", "vegetation.status");
|
|
|
|
|
exact(payload.ground_truth, false, "vegetation.ground_truth");
|
|
|
|
|
exact(payload.access, "read-only", "vegetation.access");
|
|
|
|
|
const identity = objectValue(payload.identity, "vegetation.identity");
|
|
|
|
|
const metrics = objectValue(payload.metrics, "vegetation.metrics");
|
|
|
|
|
const candidates = objectValue(metrics.candidates, "vegetation.metrics.candidates");
|
|
|
|
|
const decision = objectValue(payload.decision, "vegetation.decision");
|
|
|
|
|
const authority = objectValue(payload.authority, "vegetation.authority");
|
|
|
|
|
const catalogs = objectValue(payload.catalogs, "vegetation.catalogs");
|
|
|
|
|
const selectedCandidate = candidateKey(identity.selected_candidate, "vegetation.selected_candidate");
|
|
|
|
|
exact(decision.selected_candidate, selectedCandidate, "vegetation.decision.selected_candidate");
|
|
|
|
|
exact(decision.visual_shadow_ready, true, "vegetation.decision.visual_shadow_ready");
|
|
|
|
|
exact(
|
|
|
|
|
decision.mission_policy_ready_for_configuration,
|
|
|
|
|
true,
|
|
|
|
|
"vegetation.decision.mission_policy_ready_for_configuration",
|
|
|
|
|
);
|
|
|
|
|
exact(decision.navigation_accepted, false, "vegetation.decision.navigation_accepted");
|
|
|
|
|
exact(decision.production_accepted, false, "vegetation.decision.production_accepted");
|
|
|
|
|
exact(authority.commands_enabled, false, "vegetation.authority.commands_enabled");
|
|
|
|
|
exact(
|
|
|
|
|
authority.navigation_or_safety_accepted,
|
|
|
|
|
false,
|
|
|
|
|
"vegetation.authority.navigation_or_safety_accepted",
|
|
|
|
|
);
|
|
|
|
|
exact(authority.actuation_accepted, false, "vegetation.authority.actuation_accepted");
|
|
|
|
|
exact(
|
|
|
|
|
authority.camera_semantics_can_clear_rigid_geometry,
|
|
|
|
|
false,
|
|
|
|
|
"vegetation.authority.camera_semantics_can_clear_rigid_geometry",
|
|
|
|
|
);
|
|
|
|
|
const routeCases = arrayValue(catalogs.ravnoves, "vegetation.catalogs.ravnoves")
|
|
|
|
|
.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 случаев.");
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
resultId,
|
|
|
|
|
createdAtUtc: textValue(payload.created_at_utc, "vegetation.created_at_utc"),
|
|
|
|
|
status: "visual-shadow-ready-policy-not-authorized",
|
|
|
|
|
selectedCandidate,
|
|
|
|
|
candidates: CANDIDATES.map((candidate) => candidateMetricsValue(candidates[candidate], candidate)),
|
|
|
|
|
routeCases,
|
|
|
|
|
validationCases,
|
|
|
|
|
limitations: arrayValue(payload.limitations, "vegetation.limitations")
|
|
|
|
|
.map((item, index) => textValue(item, `vegetation.limitations[${index}]`)),
|
|
|
|
|
visualShadowReady: true,
|
|
|
|
|
missionPolicyReadyForConfiguration: true,
|
|
|
|
|
authority: {
|
|
|
|
|
commandsEnabled: false,
|
|
|
|
|
navigationOrSafetyAccepted: false,
|
|
|
|
|
actuationAccepted: false,
|
|
|
|
|
cameraSemanticsCanClearRigidGeometry: false,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function fetchVegetationShadowResult(
|
|
|
|
|
resultId: string,
|
|
|
|
|
{
|
|
|
|
|
fetcher = fetch,
|
|
|
|
|
signal,
|
|
|
|
|
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
|
|
|
|
): Promise<VegetationShadowResult> {
|
|
|
|
|
if (!RESULT_ID.test(resultId)) {
|
|
|
|
|
throw new VegetationShadowContractError("Vegetation LAB identity недопустима.");
|
|
|
|
|
}
|
|
|
|
|
const response = await fetcher(
|
|
|
|
|
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}`,
|
|
|
|
|
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
|
|
|
|
);
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new VegetationShadowContractError(`Vegetation LAB недоступна: HTTP ${response.status}.`);
|
|
|
|
|
}
|
|
|
|
|
return parseResult(await response.json(), resultId);
|
|
|
|
|
}
|