feat(lidar): add RAVNOVES field review
This commit is contained in:
parent
2dfb34ef21
commit
3333e9ac0f
|
|
@ -147,6 +147,106 @@ export interface LidarGroundFrame {
|
||||||
groundTruth: false;
|
groundTruth: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LidarFieldReviewWindowSummary {
|
||||||
|
index: number;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
startSeconds: number;
|
||||||
|
endSeconds: number;
|
||||||
|
midpointSeconds: number;
|
||||||
|
sourceLidarSamples: number;
|
||||||
|
sourcePointCount: number;
|
||||||
|
displayPointCount: number;
|
||||||
|
sourceFrameStart: number;
|
||||||
|
sourceFrameEnd: number;
|
||||||
|
previewSourceFrameIndex: number;
|
||||||
|
previewSessionSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LidarFieldReview {
|
||||||
|
reviewId: string;
|
||||||
|
displayName: string;
|
||||||
|
sessionId: string;
|
||||||
|
sourcePackId: string;
|
||||||
|
status: "diagnostic-only";
|
||||||
|
source: {
|
||||||
|
timelineStartSeconds: number;
|
||||||
|
timelineEndSeconds: number;
|
||||||
|
availableLidarFrames: number;
|
||||||
|
pointCount: number;
|
||||||
|
representation: "legacy-e10-vendor-map-with-pose";
|
||||||
|
intensityAvailable: false;
|
||||||
|
rawScanAccepted: false;
|
||||||
|
};
|
||||||
|
selection: {
|
||||||
|
purpose: "operator-readable-central-urban-field-review";
|
||||||
|
defaultWindowIndex: number;
|
||||||
|
accumulation: "per-source-frame masks accumulated in map frame";
|
||||||
|
maximumPointsPerWindow: number;
|
||||||
|
};
|
||||||
|
windows: LidarFieldReviewWindowSummary[];
|
||||||
|
metrics: {
|
||||||
|
sourceSamples: number;
|
||||||
|
current: {
|
||||||
|
provider: LidarGroundProviderSummary;
|
||||||
|
groundFraction: LidarDistribution;
|
||||||
|
latencyMs: LidarDistribution;
|
||||||
|
};
|
||||||
|
candidate: {
|
||||||
|
provider: LidarGroundProviderSummary;
|
||||||
|
groundFraction: LidarDistribution;
|
||||||
|
latencyMs: LidarDistribution;
|
||||||
|
};
|
||||||
|
comparison: {
|
||||||
|
algorithmGroundIou: LidarDistribution;
|
||||||
|
groundDisagreementFraction: LidarDistribution;
|
||||||
|
isAccuracyMetric: false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
decision: {
|
||||||
|
status: "visual-review-only";
|
||||||
|
productionPromotion: false;
|
||||||
|
reasons: string[];
|
||||||
|
};
|
||||||
|
createdAtUtc: string | null;
|
||||||
|
groundTruth: false;
|
||||||
|
authority: {
|
||||||
|
commandsEnabled: false;
|
||||||
|
navigationOrSafetyAccepted: false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LidarFieldReviewCatalog {
|
||||||
|
configured: boolean;
|
||||||
|
validTotal: number;
|
||||||
|
invalidTotal: number;
|
||||||
|
items: LidarFieldReview[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LidarFieldReviewWindow {
|
||||||
|
reviewId: string;
|
||||||
|
displayName: string;
|
||||||
|
sessionId: string;
|
||||||
|
sourcePackId: string;
|
||||||
|
windowIndex: number;
|
||||||
|
windowCount: number;
|
||||||
|
window: LidarFieldReviewWindowSummary;
|
||||||
|
pointCount: number;
|
||||||
|
coordinateFrame: "map";
|
||||||
|
distanceUnit: "m";
|
||||||
|
pointsXyzM: Array<[number, number, number]>;
|
||||||
|
intensity0To255: null;
|
||||||
|
intensity: {
|
||||||
|
available: false;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
masks: LidarGroundFrame["masks"];
|
||||||
|
counts: LidarGroundFrame["counts"];
|
||||||
|
previewUrl: string;
|
||||||
|
groundTruth: false;
|
||||||
|
authority: LidarFieldReview["authority"];
|
||||||
|
}
|
||||||
|
|
||||||
export class LidarReplayContractError extends Error {}
|
export class LidarReplayContractError extends Error {}
|
||||||
|
|
||||||
export class LidarReplayApiError extends Error {
|
export class LidarReplayApiError extends Error {
|
||||||
|
|
@ -162,7 +262,10 @@ type LidarFetch = (
|
||||||
|
|
||||||
const SAFE_PACK_ID = /^lidar-replay-pack-[a-f0-9]{64}$/;
|
const SAFE_PACK_ID = /^lidar-replay-pack-[a-f0-9]{64}$/;
|
||||||
const SAFE_GROUND_BENCHMARK_ID = /^ground-benchmark-[a-f0-9]{64}$/;
|
const SAFE_GROUND_BENCHMARK_ID = /^ground-benchmark-[a-f0-9]{64}$/;
|
||||||
|
const SAFE_FIELD_REVIEW_ID = /^lidar-field-review-[a-f0-9]{64}$/;
|
||||||
|
const SAFE_E10_PACK_ID = /^e10-lidar-pack-[a-f0-9]{64}$/;
|
||||||
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/;
|
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/;
|
||||||
|
const SAFE_FIELD_KEY = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||||
const SHA256 = /^[a-f0-9]{64}$/;
|
const SHA256 = /^[a-f0-9]{64}$/;
|
||||||
const GIT_SHA1 = /^[a-f0-9]{40}$/;
|
const GIT_SHA1 = /^[a-f0-9]{40}$/;
|
||||||
|
|
||||||
|
|
@ -641,6 +744,358 @@ export function parseLidarGroundFrame(value: unknown): LidarGroundFrame {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fieldReviewWindowSummary(
|
||||||
|
value: unknown,
|
||||||
|
expectedIndex: number,
|
||||||
|
): LidarFieldReviewWindowSummary {
|
||||||
|
const source = record(value, `field-review window ${expectedIndex}`);
|
||||||
|
const index = integer(source.index, "window.index");
|
||||||
|
const startSeconds = number(source.start_seconds, "window.start_seconds") ?? 0;
|
||||||
|
const endSeconds = number(source.end_seconds, "window.end_seconds") ?? 0;
|
||||||
|
if (index !== expectedIndex || startSeconds >= endSeconds) {
|
||||||
|
throw new LidarReplayContractError("LiDAR field-review window несовместим");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
key: string(source.key, "window.key", SAFE_FIELD_KEY),
|
||||||
|
label: string(source.label, "window.label"),
|
||||||
|
startSeconds,
|
||||||
|
endSeconds,
|
||||||
|
midpointSeconds:
|
||||||
|
number(source.midpoint_seconds, "window.midpoint_seconds") ?? 0,
|
||||||
|
sourceLidarSamples: integer(
|
||||||
|
source.source_lidar_samples,
|
||||||
|
"window.source_lidar_samples",
|
||||||
|
),
|
||||||
|
sourcePointCount: integer(
|
||||||
|
source.source_point_count,
|
||||||
|
"window.source_point_count",
|
||||||
|
),
|
||||||
|
displayPointCount: integer(
|
||||||
|
source.display_point_count,
|
||||||
|
"window.display_point_count",
|
||||||
|
),
|
||||||
|
sourceFrameStart: integer(
|
||||||
|
source.source_frame_start,
|
||||||
|
"window.source_frame_start",
|
||||||
|
),
|
||||||
|
sourceFrameEnd: integer(
|
||||||
|
source.source_frame_end,
|
||||||
|
"window.source_frame_end",
|
||||||
|
),
|
||||||
|
previewSourceFrameIndex: integer(
|
||||||
|
source.preview_source_frame_index,
|
||||||
|
"window.preview_source_frame_index",
|
||||||
|
),
|
||||||
|
previewSessionSeconds:
|
||||||
|
number(
|
||||||
|
source.preview_session_seconds,
|
||||||
|
"window.preview_session_seconds",
|
||||||
|
) ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldReviewBranch(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): LidarFieldReview["metrics"]["current"] {
|
||||||
|
const source = record(value, label);
|
||||||
|
return {
|
||||||
|
provider: groundProvider(source.provider, `${label}.provider`),
|
||||||
|
groundFraction: distribution(
|
||||||
|
source.ground_fraction,
|
||||||
|
`${label}.ground_fraction`,
|
||||||
|
),
|
||||||
|
latencyMs: distribution(source.latency_ms, `${label}.latency_ms`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldReview(value: unknown): LidarFieldReview {
|
||||||
|
const source = record(value, "LiDAR field review");
|
||||||
|
const sourceEvidence = record(source.source, "field-review source");
|
||||||
|
const selection = record(source.selection, "field-review selection");
|
||||||
|
const sampling = record(selection.sampling, "field-review sampling");
|
||||||
|
const metrics = record(source.metrics, "field-review metrics");
|
||||||
|
const comparison = record(metrics.comparison, "field-review comparison");
|
||||||
|
const decision = record(source.decision, "field-review decision");
|
||||||
|
const authority = record(source.authority, "field-review authority");
|
||||||
|
if (
|
||||||
|
source.status !== "diagnostic-only"
|
||||||
|
|| source.ground_truth !== false
|
||||||
|
|| sourceEvidence.representation !== "legacy-e10-vendor-map-with-pose"
|
||||||
|
|| sourceEvidence.intensity_available !== false
|
||||||
|
|| sourceEvidence.raw_scan_accepted !== false
|
||||||
|
|| selection.purpose !== "operator-readable-central-urban-field-review"
|
||||||
|
|| selection.accumulation !== "per-source-frame masks accumulated in map frame"
|
||||||
|
|| sampling.method !== "uniform-point-index-per-window"
|
||||||
|
|| comparison.is_accuracy_metric !== false
|
||||||
|
|| decision.status !== "visual-review-only"
|
||||||
|
|| decision.production_promotion !== false
|
||||||
|
|| authority.commands_enabled !== false
|
||||||
|
|| authority.navigation_or_safety_accepted !== false
|
||||||
|
) {
|
||||||
|
throw new LidarReplayContractError(
|
||||||
|
"LiDAR field review завышает readiness или меняет evidence",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const windows = array(source.windows, "field-review windows").map(
|
||||||
|
fieldReviewWindowSummary,
|
||||||
|
);
|
||||||
|
const defaultWindowIndex = integer(
|
||||||
|
selection.default_window_index,
|
||||||
|
"selection.default_window_index",
|
||||||
|
);
|
||||||
|
const maximumPointsPerWindow = integer(
|
||||||
|
sampling.maximum_points_per_window,
|
||||||
|
"sampling.maximum_points_per_window",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
windows.length < 1
|
||||||
|
|| defaultWindowIndex >= windows.length
|
||||||
|
|| maximumPointsPerWindow < 1
|
||||||
|
|| maximumPointsPerWindow > 80_000
|
||||||
|
|| windows.some(
|
||||||
|
(window) =>
|
||||||
|
window.sourceLidarSamples < 1
|
||||||
|
|| window.sourcePointCount < window.displayPointCount
|
||||||
|
|| window.displayPointCount < 1
|
||||||
|
|| window.displayPointCount > maximumPointsPerWindow,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new LidarReplayContractError("LiDAR field-review selection несовместим");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
reviewId: string(source.review_id, "review_id", SAFE_FIELD_REVIEW_ID),
|
||||||
|
displayName: string(source.display_name, "display_name"),
|
||||||
|
sessionId: string(source.session_id, "session_id", SAFE_ID),
|
||||||
|
sourcePackId: string(
|
||||||
|
source.source_pack_id,
|
||||||
|
"source_pack_id",
|
||||||
|
SAFE_E10_PACK_ID,
|
||||||
|
),
|
||||||
|
status: "diagnostic-only",
|
||||||
|
source: {
|
||||||
|
timelineStartSeconds:
|
||||||
|
number(
|
||||||
|
sourceEvidence.timeline_start_seconds,
|
||||||
|
"source.timeline_start_seconds",
|
||||||
|
) ?? 0,
|
||||||
|
timelineEndSeconds:
|
||||||
|
number(
|
||||||
|
sourceEvidence.timeline_end_seconds,
|
||||||
|
"source.timeline_end_seconds",
|
||||||
|
) ?? 0,
|
||||||
|
availableLidarFrames: integer(
|
||||||
|
sourceEvidence.available_lidar_frames,
|
||||||
|
"source.available_lidar_frames",
|
||||||
|
),
|
||||||
|
pointCount: integer(sourceEvidence.point_count, "source.point_count"),
|
||||||
|
representation: "legacy-e10-vendor-map-with-pose",
|
||||||
|
intensityAvailable: false,
|
||||||
|
rawScanAccepted: false,
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
purpose: "operator-readable-central-urban-field-review",
|
||||||
|
defaultWindowIndex,
|
||||||
|
accumulation: "per-source-frame masks accumulated in map frame",
|
||||||
|
maximumPointsPerWindow,
|
||||||
|
},
|
||||||
|
windows,
|
||||||
|
metrics: {
|
||||||
|
sourceSamples: integer(metrics.source_samples, "metrics.source_samples"),
|
||||||
|
current: fieldReviewBranch(metrics.current, "metrics.current"),
|
||||||
|
candidate: fieldReviewBranch(metrics.candidate, "metrics.candidate"),
|
||||||
|
comparison: {
|
||||||
|
algorithmGroundIou: distribution(
|
||||||
|
comparison.algorithm_to_algorithm_ground_iou,
|
||||||
|
"metrics.comparison.algorithm_ground_iou",
|
||||||
|
),
|
||||||
|
groundDisagreementFraction: distribution(
|
||||||
|
comparison.ground_disagreement_fraction,
|
||||||
|
"metrics.comparison.ground_disagreement_fraction",
|
||||||
|
),
|
||||||
|
isAccuracyMetric: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decision: {
|
||||||
|
status: "visual-review-only",
|
||||||
|
productionPromotion: false,
|
||||||
|
reasons: array(decision.reasons, "decision.reasons").map((reason) =>
|
||||||
|
string(reason, "decision.reason")
|
||||||
|
),
|
||||||
|
},
|
||||||
|
createdAtUtc:
|
||||||
|
source.created_at_utc === null || source.created_at_utc === undefined
|
||||||
|
? null
|
||||||
|
: string(source.created_at_utc, "created_at_utc"),
|
||||||
|
groundTruth: false,
|
||||||
|
authority: {
|
||||||
|
commandsEnabled: false,
|
||||||
|
navigationOrSafetyAccepted: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLidarFieldReviewCatalog(
|
||||||
|
value: unknown,
|
||||||
|
): LidarFieldReviewCatalog {
|
||||||
|
const source = record(value, "LiDAR field-review catalog");
|
||||||
|
if (
|
||||||
|
source.schema_version !== "missioncore.lidar-field-review-catalog/v1"
|
||||||
|
|| source.access !== "read-only"
|
||||||
|
) {
|
||||||
|
throw new LidarReplayContractError("LiDAR field-review catalog несовместим");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
configured: boolean(source.configured, "configured"),
|
||||||
|
validTotal: integer(source.valid_total, "valid_total"),
|
||||||
|
invalidTotal: integer(source.invalid_total, "invalid_total"),
|
||||||
|
items: array(source.items, "items").map(fieldReview),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLidarFieldReviewWindow(
|
||||||
|
value: unknown,
|
||||||
|
): LidarFieldReviewWindow {
|
||||||
|
const source = record(value, "LiDAR field-review window");
|
||||||
|
const reviewId = string(
|
||||||
|
source.review_id,
|
||||||
|
"review_id",
|
||||||
|
SAFE_FIELD_REVIEW_ID,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
source.schema_version !== "missioncore.lidar-field-review-window/v1"
|
||||||
|
|| source.access !== "read-only"
|
||||||
|
|| source.ground_truth !== false
|
||||||
|
|| source.coordinate_frame !== "map"
|
||||||
|
|| source.distance_unit !== "m"
|
||||||
|
) {
|
||||||
|
throw new LidarReplayContractError("LiDAR field-review window несовместим");
|
||||||
|
}
|
||||||
|
const authority = record(source.authority, "authority");
|
||||||
|
const intensity = record(source.intensity, "intensity");
|
||||||
|
if (
|
||||||
|
authority.commands_enabled !== false
|
||||||
|
|| authority.navigation_or_safety_accepted !== false
|
||||||
|
|| intensity.available !== false
|
||||||
|
) {
|
||||||
|
throw new LidarReplayContractError("LiDAR field-review authority несовместим");
|
||||||
|
}
|
||||||
|
const pointCount = integer(source.point_count, "point_count");
|
||||||
|
if (pointCount < 1 || pointCount > 80_000) {
|
||||||
|
throw new LidarReplayContractError("LiDAR field-review window слишком большой");
|
||||||
|
}
|
||||||
|
const points = array(source.points_xyz_m, "points_xyz_m");
|
||||||
|
if (points.length !== pointCount) {
|
||||||
|
throw new LidarReplayContractError("Количество field-review points не совпадает");
|
||||||
|
}
|
||||||
|
const pointsXyzM = points.map((value, index): [number, number, number] => {
|
||||||
|
const tuple = array(value, `points_xyz_m[${index}]`);
|
||||||
|
if (tuple.length !== 3) {
|
||||||
|
throw new LidarReplayContractError("LiDAR point должен содержать XYZ");
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
number(tuple[0], `points_xyz_m[${index}].x`) ?? 0,
|
||||||
|
number(tuple[1], `points_xyz_m[${index}].y`) ?? 0,
|
||||||
|
number(tuple[2], `points_xyz_m[${index}].z`) ?? 0,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const masks = record(source.masks, "masks");
|
||||||
|
const currentGround = groundMask(
|
||||||
|
masks.current_ground,
|
||||||
|
"masks.current_ground",
|
||||||
|
pointCount,
|
||||||
|
);
|
||||||
|
const currentAssigned = groundMask(
|
||||||
|
masks.current_assigned,
|
||||||
|
"masks.current_assigned",
|
||||||
|
pointCount,
|
||||||
|
);
|
||||||
|
const candidateGround = groundMask(
|
||||||
|
masks.candidate_ground,
|
||||||
|
"masks.candidate_ground",
|
||||||
|
pointCount,
|
||||||
|
);
|
||||||
|
const candidateAssigned = groundMask(
|
||||||
|
masks.candidate_assigned,
|
||||||
|
"masks.candidate_assigned",
|
||||||
|
pointCount,
|
||||||
|
);
|
||||||
|
const disagreement = groundMask(
|
||||||
|
masks.disagreement,
|
||||||
|
"masks.disagreement",
|
||||||
|
pointCount,
|
||||||
|
);
|
||||||
|
const counts = record(source.counts, "counts");
|
||||||
|
const parsedCounts = {
|
||||||
|
currentGround: integer(counts.current_ground, "counts.current_ground"),
|
||||||
|
candidateGround: integer(
|
||||||
|
counts.candidate_ground,
|
||||||
|
"counts.candidate_ground",
|
||||||
|
),
|
||||||
|
disagreement: integer(counts.disagreement, "counts.disagreement"),
|
||||||
|
};
|
||||||
|
const windowIndex = integer(source.window_index, "window_index");
|
||||||
|
const windowCount = integer(source.window_count, "window_count");
|
||||||
|
const window = fieldReviewWindowSummary(source.window, windowIndex);
|
||||||
|
const expectedPreviewUrl =
|
||||||
|
`/api/v1/lidar/field-reviews/${reviewId}/windows/${windowIndex}/preview`;
|
||||||
|
if (
|
||||||
|
windowCount < 1
|
||||||
|
|| windowIndex >= windowCount
|
||||||
|
|| window.displayPointCount !== pointCount
|
||||||
|
|| parsedCounts.currentGround
|
||||||
|
!== currentGround.reduce((sum, item) => sum + item, 0)
|
||||||
|
|| parsedCounts.candidateGround
|
||||||
|
!== candidateGround.reduce((sum, item) => sum + item, 0)
|
||||||
|
|| parsedCounts.disagreement
|
||||||
|
!== disagreement.reduce((sum, item) => sum + item, 0)
|
||||||
|
|| disagreement.some(
|
||||||
|
(item, index) =>
|
||||||
|
item !== Number(currentGround[index] !== candidateGround[index]),
|
||||||
|
)
|
||||||
|
|| source.preview_url !== expectedPreviewUrl
|
||||||
|
) {
|
||||||
|
throw new LidarReplayContractError("LiDAR field-review content несовместим");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
reviewId,
|
||||||
|
displayName: string(source.display_name, "display_name"),
|
||||||
|
sessionId: string(source.session_id, "session_id", SAFE_ID),
|
||||||
|
sourcePackId: string(
|
||||||
|
source.source_pack_id,
|
||||||
|
"source_pack_id",
|
||||||
|
SAFE_E10_PACK_ID,
|
||||||
|
),
|
||||||
|
windowIndex,
|
||||||
|
windowCount,
|
||||||
|
window,
|
||||||
|
pointCount,
|
||||||
|
coordinateFrame: "map",
|
||||||
|
distanceUnit: "m",
|
||||||
|
pointsXyzM,
|
||||||
|
intensity0To255: null,
|
||||||
|
intensity: {
|
||||||
|
available: false,
|
||||||
|
reason: string(intensity.reason, "intensity.reason"),
|
||||||
|
},
|
||||||
|
masks: {
|
||||||
|
currentGround,
|
||||||
|
currentAssigned,
|
||||||
|
candidateGround,
|
||||||
|
candidateAssigned,
|
||||||
|
disagreement,
|
||||||
|
},
|
||||||
|
counts: parsedCounts,
|
||||||
|
previewUrl: expectedPreviewUrl,
|
||||||
|
groundTruth: false,
|
||||||
|
authority: {
|
||||||
|
commandsEnabled: false,
|
||||||
|
navigationOrSafetyAccepted: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function responseJson(
|
async function responseJson(
|
||||||
response: Response,
|
response: Response,
|
||||||
fallback: string,
|
fallback: string,
|
||||||
|
|
@ -739,3 +1194,48 @@ export async function fetchLidarGroundFrame(
|
||||||
await responseJson(response, "Не удалось получить LiDAR ground frame."),
|
await responseJson(response, "Не удалось получить LiDAR ground frame."),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchLidarFieldReviews(
|
||||||
|
options: { signal?: AbortSignal; fetcher?: LidarFetch } = {},
|
||||||
|
): Promise<LidarFieldReviewCatalog> {
|
||||||
|
const fetcher = options.fetcher ?? fetch;
|
||||||
|
const response = await fetcher("/api/v1/lidar/field-reviews?limit=10", {
|
||||||
|
method: "GET",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
return parseLidarFieldReviewCatalog(
|
||||||
|
await responseJson(response, "Не удалось получить полевой LiDAR review."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLidarFieldReviewWindow(
|
||||||
|
reviewId: string,
|
||||||
|
windowIndex: number,
|
||||||
|
options: { signal?: AbortSignal; fetcher?: LidarFetch } = {},
|
||||||
|
): Promise<LidarFieldReviewWindow> {
|
||||||
|
if (
|
||||||
|
!SAFE_FIELD_REVIEW_ID.test(reviewId)
|
||||||
|
|| !Number.isInteger(windowIndex)
|
||||||
|
|| windowIndex < 0
|
||||||
|
) {
|
||||||
|
throw new LidarReplayContractError(
|
||||||
|
"Некорректное окно полевого LiDAR review",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const fetcher = options.fetcher ?? fetch;
|
||||||
|
const response = await fetcher(
|
||||||
|
`/api/v1/lidar/field-reviews/${reviewId}/windows/${windowIndex}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal: options.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return parseLidarFieldReviewWindow(
|
||||||
|
await responseJson(
|
||||||
|
response,
|
||||||
|
"Не удалось получить окно полевого LiDAR review.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,23 @@
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lidar-field-review__source {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-window-list {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-stage {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-camera img {
|
||||||
|
min-height: 0;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
.polygon-run-providers {
|
.polygon-run-providers {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
@ -202,6 +219,21 @@
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lidar-field-review__source,
|
||||||
|
.lidar-field-window-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-cloud > header,
|
||||||
|
.lidar-field-review__explanation {
|
||||||
|
align-items: stretch;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-cloud > header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.polygon-run-identity dl {
|
.polygon-run-identity dl {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -954,6 +954,218 @@
|
||||||
margin-top: 0.8rem;
|
margin-top: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lidar-field-review {
|
||||||
|
min-width: 0;
|
||||||
|
border-color: rgb(74 215 255 / 0.22);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 8% 0%, rgb(56 124 255 / 0.13), transparent 30rem),
|
||||||
|
var(--station-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-review__heading p,
|
||||||
|
.lidar-field-review__explanation p,
|
||||||
|
.lidar-field-review__empty p {
|
||||||
|
margin: 0.28rem 0 0;
|
||||||
|
max-width: 49rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.64rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-review__source {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(13rem, 1.4fr) repeat(3, minmax(0, 0.72fr));
|
||||||
|
gap: 0.55rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-review__source > div {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.28rem;
|
||||||
|
border: 1px solid var(--station-hairline);
|
||||||
|
border-radius: 0.72rem;
|
||||||
|
background: rgb(255 255 255 / 0.025);
|
||||||
|
padding: 0.64rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-review__source span,
|
||||||
|
.lidar-field-window-list small,
|
||||||
|
.lidar-field-stage__label span,
|
||||||
|
.lidar-field-camera footer,
|
||||||
|
.lidar-field-stage__pending {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.59rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-review__source strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-window-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 0.48rem;
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-window-list > button {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: 1px solid var(--station-hairline);
|
||||||
|
border-radius: 0.78rem;
|
||||||
|
background: rgb(255 255 255 / 0.02);
|
||||||
|
padding: 0.62rem;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-window-list > button:hover,
|
||||||
|
.lidar-field-window-list > button[data-selected="true"] {
|
||||||
|
border-color: rgb(74 215 255 / 0.48);
|
||||||
|
background: rgb(74 215 255 / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-window-list > button > span {
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 1.35rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(74 215 255 / 0.12);
|
||||||
|
color: #78e3ff;
|
||||||
|
font-size: 0.59rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-window-list > button > div {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-window-list strong {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.61rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-stage {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(18rem, 0.68fr) minmax(0, 1.32fr);
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-camera,
|
||||||
|
.lidar-field-cloud {
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.09);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: #071018;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-camera {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-camera > .lidar-field-stage__label,
|
||||||
|
.lidar-field-cloud > header {
|
||||||
|
min-height: 3.45rem;
|
||||||
|
border-bottom: 1px solid rgb(255 255 255 / 0.08);
|
||||||
|
padding: 0.66rem 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-stage__label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-stage__label strong {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-camera img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 25rem;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-camera footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-top: 1px solid rgb(255 255 255 / 0.08);
|
||||||
|
padding: 0.52rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-stage__pending {
|
||||||
|
display: grid;
|
||||||
|
min-height: 25rem;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-cloud > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-cloud .lidar-ground-scene {
|
||||||
|
min-height: 28rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-cloud .lidar-ground-scene-placeholder {
|
||||||
|
min-height: 28rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-review__explanation {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(15rem, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-top: 0.68rem;
|
||||||
|
border-top: 1px solid var(--station-hairline);
|
||||||
|
padding-top: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-review__explanation p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-review__empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-field-review__empty p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.lidar-ground-benchmark {
|
.lidar-ground-benchmark {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,27 @@ import { useEffect, useRef, useState } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||||
|
|
||||||
import type { LidarGroundFrame } from "../core/lidar/replayQuality";
|
|
||||||
|
|
||||||
export type LidarGroundViewMode =
|
export type LidarGroundViewMode =
|
||||||
| "intensity"
|
| "intensity"
|
||||||
| "current"
|
| "current"
|
||||||
| "candidate"
|
| "candidate"
|
||||||
| "disagreement";
|
| "disagreement";
|
||||||
|
|
||||||
|
export interface LidarGroundPointCloudFrame {
|
||||||
|
pointCount: number;
|
||||||
|
pointsXyzM: Array<[number, number, number]>;
|
||||||
|
intensity0To255: number[] | null;
|
||||||
|
masks: {
|
||||||
|
currentGround: number[];
|
||||||
|
currentAssigned: number[];
|
||||||
|
candidateGround: number[];
|
||||||
|
candidateAssigned: number[];
|
||||||
|
disagreement: number[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface LidarGroundPointCloudProps {
|
interface LidarGroundPointCloudProps {
|
||||||
frame: LidarGroundFrame;
|
frame: LidarGroundPointCloudFrame;
|
||||||
mode: LidarGroundViewMode;
|
mode: LidarGroundViewMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,7 +39,7 @@ function setRgb(
|
||||||
}
|
}
|
||||||
|
|
||||||
function frameColors(
|
function frameColors(
|
||||||
frame: LidarGroundFrame,
|
frame: LidarGroundPointCloudFrame,
|
||||||
mode: LidarGroundViewMode,
|
mode: LidarGroundViewMode,
|
||||||
): Float32Array {
|
): Float32Array {
|
||||||
const colors = new Float32Array(frame.pointCount * 3);
|
const colors = new Float32Array(frame.pointCount * 3);
|
||||||
|
|
@ -38,7 +49,7 @@ function frameColors(
|
||||||
const candidate = frame.masks.candidateGround[index] === 1;
|
const candidate = frame.masks.candidateGround[index] === 1;
|
||||||
const candidateAssigned = frame.masks.candidateAssigned[index] === 1;
|
const candidateAssigned = frame.masks.candidateAssigned[index] === 1;
|
||||||
if (mode === "intensity") {
|
if (mode === "intensity") {
|
||||||
const intensity = frame.intensity0To255[index] / 255;
|
const intensity = (frame.intensity0To255?.[index] ?? 96) / 255;
|
||||||
setRgb(
|
setRgb(
|
||||||
colors,
|
colors,
|
||||||
offset,
|
offset,
|
||||||
|
|
@ -88,6 +99,8 @@ export function LidarGroundPointCloud({
|
||||||
const materialRef = useRef<THREE.PointsMaterial | null>(null);
|
const materialRef = useRef<THREE.PointsMaterial | null>(null);
|
||||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||||
const controlsRef = useRef<OrbitControls | null>(null);
|
const controlsRef = useRef<OrbitControls | null>(null);
|
||||||
|
const fogRef = useRef<THREE.FogExp2 | null>(null);
|
||||||
|
const gridRef = useRef<THREE.GridHelper | null>(null);
|
||||||
const [renderError, setRenderError] = useState<string | null>(null);
|
const [renderError, setRenderError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -115,7 +128,9 @@ export function LidarGroundPointCloud({
|
||||||
host.prepend(renderer.domElement);
|
host.prepend(renderer.domElement);
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
const scene = new THREE.Scene();
|
||||||
scene.fog = new THREE.FogExp2(0x071018, 0.035);
|
const fog = new THREE.FogExp2(0x071018, 0.035);
|
||||||
|
scene.fog = fog;
|
||||||
|
fogRef.current = fog;
|
||||||
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 1_000);
|
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 1_000);
|
||||||
camera.position.set(6, 4.5, 6);
|
camera.position.set(6, 4.5, 6);
|
||||||
cameraRef.current = camera;
|
cameraRef.current = camera;
|
||||||
|
|
@ -147,6 +162,7 @@ export function LidarGroundPointCloud({
|
||||||
scene.add(new THREE.Points(geometry, material));
|
scene.add(new THREE.Points(geometry, material));
|
||||||
|
|
||||||
const grid = new THREE.GridHelper(24, 48, 0x3c7cff, 0x233747);
|
const grid = new THREE.GridHelper(24, 48, 0x3c7cff, 0x233747);
|
||||||
|
gridRef.current = grid;
|
||||||
const gridMaterials = Array.isArray(grid.material)
|
const gridMaterials = Array.isArray(grid.material)
|
||||||
? grid.material
|
? grid.material
|
||||||
: [grid.material];
|
: [grid.material];
|
||||||
|
|
@ -198,6 +214,8 @@ export function LidarGroundPointCloud({
|
||||||
materialRef.current = null;
|
materialRef.current = null;
|
||||||
cameraRef.current = null;
|
cameraRef.current = null;
|
||||||
controlsRef.current = null;
|
controlsRef.current = null;
|
||||||
|
fogRef.current = null;
|
||||||
|
gridRef.current = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -206,7 +224,9 @@ export function LidarGroundPointCloud({
|
||||||
const material = materialRef.current;
|
const material = materialRef.current;
|
||||||
const camera = cameraRef.current;
|
const camera = cameraRef.current;
|
||||||
const controls = controlsRef.current;
|
const controls = controlsRef.current;
|
||||||
if (!geometry || !material || !camera || !controls) return;
|
const fog = fogRef.current;
|
||||||
|
const grid = gridRef.current;
|
||||||
|
if (!geometry || !material || !camera || !controls || !fog || !grid) return;
|
||||||
|
|
||||||
const positions = new Float32Array(frame.pointCount * 3);
|
const positions = new Float32Array(frame.pointCount * 3);
|
||||||
let minimumX = Number.POSITIVE_INFINITY;
|
let minimumX = Number.POSITIVE_INFINITY;
|
||||||
|
|
@ -235,6 +255,8 @@ export function LidarGroundPointCloud({
|
||||||
geometry.computeBoundingSphere();
|
geometry.computeBoundingSphere();
|
||||||
const radius = Math.max(geometry.boundingSphere?.radius ?? 1, 0.2);
|
const radius = Math.max(geometry.boundingSphere?.radius ?? 1, 0.2);
|
||||||
material.size = THREE.MathUtils.clamp(radius / 155, 0.014, 0.075);
|
material.size = THREE.MathUtils.clamp(radius / 155, 0.014, 0.075);
|
||||||
|
fog.density = THREE.MathUtils.clamp(0.18 / radius, 0.0008, 0.035);
|
||||||
|
grid.scale.setScalar(Math.max(radius / 12, 1));
|
||||||
|
|
||||||
const targetHeight = Math.max((maximumZ - minimumZ) * 0.35, 0.15);
|
const targetHeight = Math.max((maximumZ - minimumZ) * 0.35, 0.15);
|
||||||
const distance = Math.max(radius * 1.8, 1.2);
|
const distance = Math.max(radius * 1.8, 1.2);
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,14 @@ import {
|
||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
fetchLidarFieldReviews,
|
||||||
|
fetchLidarFieldReviewWindow,
|
||||||
fetchLidarGroundFrame,
|
fetchLidarGroundFrame,
|
||||||
fetchLidarGroundBenchmarks,
|
fetchLidarGroundBenchmarks,
|
||||||
fetchLidarReplayCatalog,
|
fetchLidarReplayCatalog,
|
||||||
fetchLidarReplayDetail,
|
fetchLidarReplayDetail,
|
||||||
|
type LidarFieldReview,
|
||||||
|
type LidarFieldReviewWindow,
|
||||||
type LidarGroundBenchmark,
|
type LidarGroundBenchmark,
|
||||||
type LidarGroundFrame,
|
type LidarGroundFrame,
|
||||||
type LidarReplayCatalog,
|
type LidarReplayCatalog,
|
||||||
|
|
@ -69,6 +73,14 @@ export function LidarQualityWorkspace({
|
||||||
const [groundFrameError, setGroundFrameError] = useState<string | null>(null);
|
const [groundFrameError, setGroundFrameError] = useState<string | null>(null);
|
||||||
const [groundViewMode, setGroundViewMode] =
|
const [groundViewMode, setGroundViewMode] =
|
||||||
useState<LidarGroundViewMode>("disagreement");
|
useState<LidarGroundViewMode>("disagreement");
|
||||||
|
const [fieldReview, setFieldReview] = useState<LidarFieldReview | null>(null);
|
||||||
|
const [fieldWindow, setFieldWindow] =
|
||||||
|
useState<LidarFieldReviewWindow | null>(null);
|
||||||
|
const [fieldWindowIndex, setFieldWindowIndex] = useState(0);
|
||||||
|
const [fieldLoading, setFieldLoading] = useState(true);
|
||||||
|
const [fieldError, setFieldError] = useState<string | null>(null);
|
||||||
|
const [fieldViewMode, setFieldViewMode] =
|
||||||
|
useState<LidarGroundViewMode>("disagreement");
|
||||||
const [selectedPackId, setSelectedPackId] = useState<string | null>(null);
|
const [selectedPackId, setSelectedPackId] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -146,7 +158,56 @@ export function LidarQualityWorkspace({
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [groundBenchmark, groundFrameIndex]);
|
}, [groundBenchmark, groundFrameIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setFieldLoading(true);
|
||||||
|
setFieldError(null);
|
||||||
|
void fetchLidarFieldReviews({ signal: controller.signal })
|
||||||
|
.then((nextCatalog) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
const nextReview = nextCatalog.items[0] ?? null;
|
||||||
|
setFieldReview(nextReview);
|
||||||
|
setFieldWindow(null);
|
||||||
|
setFieldWindowIndex(nextReview?.selection.defaultWindowIndex ?? 0);
|
||||||
|
})
|
||||||
|
.catch((loadError) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setFieldReview(null);
|
||||||
|
setFieldWindow(null);
|
||||||
|
setFieldError(errorMessage(loadError));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) setFieldLoading(false);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [reloadGeneration]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fieldReview) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
setFieldLoading(true);
|
||||||
|
setFieldError(null);
|
||||||
|
void fetchLidarFieldReviewWindow(
|
||||||
|
fieldReview.reviewId,
|
||||||
|
fieldWindowIndex,
|
||||||
|
{ signal: controller.signal },
|
||||||
|
)
|
||||||
|
.then((window) => {
|
||||||
|
if (!controller.signal.aborted) setFieldWindow(window);
|
||||||
|
})
|
||||||
|
.catch((loadError) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setFieldWindow(null);
|
||||||
|
setFieldError(errorMessage(loadError));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) setFieldLoading(false);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [fieldReview, fieldWindowIndex]);
|
||||||
|
|
||||||
const groundNormalization = groundBenchmark?.inputDomain.normalization ?? null;
|
const groundNormalization = groundBenchmark?.inputDomain.normalization ?? null;
|
||||||
|
const selectedFieldWindow = fieldReview?.windows[fieldWindowIndex] ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="standard-workspace lidar-quality-workspace">
|
<div className="standard-workspace lidar-quality-workspace">
|
||||||
|
|
@ -191,12 +252,213 @@ export function LidarQualityWorkspace({
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<GlassSurface className="lidar-field-review" padding="lg">
|
||||||
|
<header className="panel-heading lidar-field-review__heading">
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">ПОЛЕВОЙ REVIEW · RAVNOVES00</span>
|
||||||
|
<h2>
|
||||||
|
{fieldReview?.displayName
|
||||||
|
?? "Центральный городской интервал"}
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Дорога, дома, автомобили и растительность. Облако накоплено
|
||||||
|
по исходным LiDAR-сэмплам выбранного окна, а кадр камеры
|
||||||
|
фиксирует контекст сцены.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge tone={fieldError ? "danger" : "warning"}>
|
||||||
|
{fieldError ? "Review недоступен" : "Visual review only"}
|
||||||
|
</StatusBadge>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{fieldReview ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="lidar-field-review__source"
|
||||||
|
aria-label="Источник полевого LiDAR review"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span>Запись</span>
|
||||||
|
<strong>{fieldReview.sessionId}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Интервал записи</span>
|
||||||
|
<strong>
|
||||||
|
{formatNumber(fieldReview.source.timelineStartSeconds, 2)}
|
||||||
|
{"–"}
|
||||||
|
{formatNumber(fieldReview.source.timelineEndSeconds, 2)} с
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>LiDAR-сэмплов</span>
|
||||||
|
<strong>
|
||||||
|
{fieldReview.source.availableLidarFrames.toLocaleString("ru-RU")}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Исходных точек</span>
|
||||||
|
<strong>
|
||||||
|
{fieldReview.source.pointCount.toLocaleString("ru-RU")}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="lidar-field-window-list"
|
||||||
|
role="group"
|
||||||
|
aria-label="Полевые сцены RAVNOVES00"
|
||||||
|
>
|
||||||
|
{fieldReview.windows.map((window) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={window.key}
|
||||||
|
data-selected={
|
||||||
|
window.index === fieldWindowIndex ? "true" : undefined
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setFieldWindow(null);
|
||||||
|
setFieldWindowIndex(window.index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{window.index + 1}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{window.label}</strong>
|
||||||
|
<small>
|
||||||
|
{formatNumber(window.startSeconds, 0)}
|
||||||
|
{"–"}
|
||||||
|
{formatNumber(window.endSeconds, 0)} с ·{" "}
|
||||||
|
{window.sourceLidarSamples} сканов
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="lidar-field-stage">
|
||||||
|
<div className="lidar-field-camera">
|
||||||
|
<div className="lidar-field-stage__label">
|
||||||
|
<span>КОНТЕКСТ КАМЕРЫ</span>
|
||||||
|
<strong>
|
||||||
|
{selectedFieldWindow?.label ?? "Выбранная сцена"}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
{fieldWindow ? (
|
||||||
|
<img
|
||||||
|
src={fieldWindow.previewUrl}
|
||||||
|
alt={`Кадр камеры: ${fieldWindow.window.label}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="lidar-field-stage__pending">
|
||||||
|
{fieldError ?? "Загружаем кадр выбранной сцены…"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<footer>
|
||||||
|
<span>
|
||||||
|
Кадр {selectedFieldWindow?.previewSourceFrameIndex ?? "—"}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
t = {formatNumber(
|
||||||
|
selectedFieldWindow?.previewSessionSeconds ?? null,
|
||||||
|
2,
|
||||||
|
)}{" "}
|
||||||
|
с
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lidar-field-cloud">
|
||||||
|
<header>
|
||||||
|
<div className="lidar-field-stage__label">
|
||||||
|
<span>НАКОПЛЕННОЕ MAP-ОБЛАКО</span>
|
||||||
|
<strong>
|
||||||
|
{selectedFieldWindow
|
||||||
|
? `${selectedFieldWindow.sourcePointCount.toLocaleString(
|
||||||
|
"ru-RU",
|
||||||
|
)} исходных · ${selectedFieldWindow.displayPointCount.toLocaleString(
|
||||||
|
"ru-RU",
|
||||||
|
)} показано`
|
||||||
|
: "Ожидание данных"}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="lidar-ground-modes"
|
||||||
|
role="group"
|
||||||
|
aria-label="Режим окраски полевого LiDAR"
|
||||||
|
>
|
||||||
|
{([
|
||||||
|
["current", "Current"],
|
||||||
|
["candidate", "Patchwork++"],
|
||||||
|
["disagreement", "Расхождения"],
|
||||||
|
] as const).map(([mode, label]) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={mode}
|
||||||
|
data-active={
|
||||||
|
fieldViewMode === mode ? "true" : undefined
|
||||||
|
}
|
||||||
|
onClick={() => setFieldViewMode(mode)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{fieldWindow ? (
|
||||||
|
<LidarGroundPointCloud
|
||||||
|
frame={fieldWindow}
|
||||||
|
mode={fieldViewMode}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="lidar-ground-scene-placeholder">
|
||||||
|
<StatusBadge tone={fieldError ? "danger" : "accent"}>
|
||||||
|
{fieldError ? "Ошибка" : "Загрузка"}
|
||||||
|
</StatusBadge>
|
||||||
|
<span>
|
||||||
|
{fieldError ?? "Готовим накопленное облако…"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="lidar-field-review__explanation">
|
||||||
|
<StatusBadge tone="warning">Не accuracy</StatusBadge>
|
||||||
|
<p>
|
||||||
|
Маски вычислены отдельно на каждом исходном скане и только
|
||||||
|
затем сведены в map frame. Это legacy E10 vendor-map
|
||||||
|
derivative без intensity и независимой ground-разметки,
|
||||||
|
поэтому результат предназначен для визуального разбора, а
|
||||||
|
не для production-gate.
|
||||||
|
</p>
|
||||||
|
<div className="lidar-ground-legend">
|
||||||
|
<span><i data-color="shared" />Ground у обоих</span>
|
||||||
|
<span><i data-color="current" />Только current</span>
|
||||||
|
<span><i data-color="candidate" />Только Patchwork++</span>
|
||||||
|
<span><i data-color="non-ground" />Оба non-ground</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="lidar-field-review__empty">
|
||||||
|
<StatusBadge tone={fieldError ? "danger" : "accent"}>
|
||||||
|
{fieldError ? "Ошибка загрузки" : "Проверка evidence"}
|
||||||
|
</StatusBadge>
|
||||||
|
<p>
|
||||||
|
{fieldError
|
||||||
|
?? (fieldLoading
|
||||||
|
? "Читаем RAVNOVES00 field-review артефакт…"
|
||||||
|
: "Field-review артефакт ещё не опубликован.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</GlassSurface>
|
||||||
|
|
||||||
<section className="metrics-grid" aria-label="Качество LiDAR replay">
|
<section className="metrics-grid" aria-label="Качество LiDAR replay">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
featured
|
featured
|
||||||
eyebrow="ТОЧЕЧНЫХ КАДРОВ"
|
eyebrow="ТЕХНИЧЕСКИХ КАДРОВ"
|
||||||
value={detail.pack.pointFrames.toLocaleString("ru-RU")}
|
value={detail.pack.pointFrames.toLocaleString("ru-RU")}
|
||||||
detail={`${detail.pack.points.toLocaleString("ru-RU")} точек сохранено`}
|
detail={`${detail.pack.points.toLocaleString("ru-RU")} точек · indoor contract slice`}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
eyebrow="СРЕДНЕЕ ТОЧЕК"
|
eyebrow="СРЕДНЕЕ ТОЧЕК"
|
||||||
|
|
@ -358,12 +620,13 @@ export function LidarQualityWorkspace({
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<span className="section-eyebrow">
|
<span className="section-eyebrow">
|
||||||
POINT-ALIGNED REVIEW
|
ТЕХНИЧЕСКИЙ CONTRACT SLICE · VIEWER_LIVE
|
||||||
</span>
|
</span>
|
||||||
<h3>Покадровое облако и маски</h3>
|
<h3>Indoor-проверка point-aligned контракта</h3>
|
||||||
<p>
|
<p>
|
||||||
Один и тот же map-frame XYZ, разные диагностические
|
Этот короткий indoor-срез подтверждает выравнивание XYZ
|
||||||
раскраски. Маски не изменяют replay.
|
и масок, но не является полевой оценкой качества. Для
|
||||||
|
улицы используйте RAVNOVES00 выше.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="lidar-ground-frame-status">
|
<div className="lidar-ground-frame-status">
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,14 @@ let parseLidarReplayCatalog;
|
||||||
let parseLidarReplayDetail;
|
let parseLidarReplayDetail;
|
||||||
let parseLidarGroundBenchmarkCatalog;
|
let parseLidarGroundBenchmarkCatalog;
|
||||||
let parseLidarGroundFrame;
|
let parseLidarGroundFrame;
|
||||||
|
let parseLidarFieldReviewCatalog;
|
||||||
|
let parseLidarFieldReviewWindow;
|
||||||
let fetchLidarReplayCatalog;
|
let fetchLidarReplayCatalog;
|
||||||
let fetchLidarReplayDetail;
|
let fetchLidarReplayDetail;
|
||||||
let fetchLidarGroundBenchmarks;
|
let fetchLidarGroundBenchmarks;
|
||||||
let fetchLidarGroundFrame;
|
let fetchLidarGroundFrame;
|
||||||
|
let fetchLidarFieldReviews;
|
||||||
|
let fetchLidarFieldReviewWindow;
|
||||||
let LidarReplayContractError;
|
let LidarReplayContractError;
|
||||||
let workspaceById;
|
let workspaceById;
|
||||||
|
|
||||||
|
|
@ -26,10 +30,14 @@ before(async () => {
|
||||||
parseLidarReplayDetail,
|
parseLidarReplayDetail,
|
||||||
parseLidarGroundBenchmarkCatalog,
|
parseLidarGroundBenchmarkCatalog,
|
||||||
parseLidarGroundFrame,
|
parseLidarGroundFrame,
|
||||||
|
parseLidarFieldReviewCatalog,
|
||||||
|
parseLidarFieldReviewWindow,
|
||||||
fetchLidarReplayCatalog,
|
fetchLidarReplayCatalog,
|
||||||
fetchLidarReplayDetail,
|
fetchLidarReplayDetail,
|
||||||
fetchLidarGroundBenchmarks,
|
fetchLidarGroundBenchmarks,
|
||||||
fetchLidarGroundFrame,
|
fetchLidarGroundFrame,
|
||||||
|
fetchLidarFieldReviews,
|
||||||
|
fetchLidarFieldReviewWindow,
|
||||||
LidarReplayContractError,
|
LidarReplayContractError,
|
||||||
} = await server.ssrLoadModule("/src/core/lidar/replayQuality.ts"));
|
} = await server.ssrLoadModule("/src/core/lidar/replayQuality.ts"));
|
||||||
({ workspaceById } = await server.ssrLoadModule("/src/productModel.ts"));
|
({ workspaceById } = await server.ssrLoadModule("/src/productModel.ts"));
|
||||||
|
|
@ -40,6 +48,8 @@ after(async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const packId = `lidar-replay-pack-${"a".repeat(64)}`;
|
const packId = `lidar-replay-pack-${"a".repeat(64)}`;
|
||||||
|
const fieldReviewId = `lidar-field-review-${"d".repeat(64)}`;
|
||||||
|
const e10PackId = `e10-lidar-pack-${"e".repeat(64)}`;
|
||||||
|
|
||||||
function summary(overrides = {}) {
|
function summary(overrides = {}) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -267,6 +277,145 @@ function groundFrame(overrides = {}) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fieldReviewWindowSummary(overrides = {}) {
|
||||||
|
return {
|
||||||
|
index: 0,
|
||||||
|
key: "intersection-facades",
|
||||||
|
label: "Перекрёсток, дорога и фасады",
|
||||||
|
start_seconds: 145,
|
||||||
|
end_seconds: 151,
|
||||||
|
midpoint_seconds: 148,
|
||||||
|
source_lidar_samples: 56,
|
||||||
|
source_point_count: 98538,
|
||||||
|
display_point_count: 3,
|
||||||
|
source_frame_start: 1097,
|
||||||
|
source_frame_end: 1156,
|
||||||
|
preview_source_frame_index: 1120,
|
||||||
|
preview_session_seconds: 147.451857292,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldReviewCatalog(overrides = {}) {
|
||||||
|
const provider = (providerId, sourceCommit = undefined) => ({
|
||||||
|
provider_id: providerId,
|
||||||
|
source_commit: sourceCommit,
|
||||||
|
});
|
||||||
|
const branch = (providerValue) => ({
|
||||||
|
provider: providerValue,
|
||||||
|
ground_fraction: distribution(),
|
||||||
|
latency_ms: distribution(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.lidar-field-review-catalog/v1",
|
||||||
|
configured: true,
|
||||||
|
valid_total: 1,
|
||||||
|
invalid_total: 0,
|
||||||
|
access: "read-only",
|
||||||
|
items: [{
|
||||||
|
review_id: fieldReviewId,
|
||||||
|
display_name: "RAVNOVES00 · центральный городской интервал",
|
||||||
|
session_id: "20260720T065719Z_viewer_live",
|
||||||
|
source_pack_id: e10PackId,
|
||||||
|
status: "diagnostic-only",
|
||||||
|
source: {
|
||||||
|
timeline_start_seconds: 135.365857292,
|
||||||
|
timeline_end_seconds: 195.334857292,
|
||||||
|
available_lidar_frames: 526,
|
||||||
|
point_count: 1182292,
|
||||||
|
representation: "legacy-e10-vendor-map-with-pose",
|
||||||
|
intensity_available: false,
|
||||||
|
raw_scan_accepted: false,
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
purpose: "operator-readable-central-urban-field-review",
|
||||||
|
default_window_index: 0,
|
||||||
|
accumulation: "per-source-frame masks accumulated in map frame",
|
||||||
|
sampling: {
|
||||||
|
method: "uniform-point-index-per-window",
|
||||||
|
maximum_points_per_window: 80000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
windows: [fieldReviewWindowSummary()],
|
||||||
|
metrics: {
|
||||||
|
source_samples: 56,
|
||||||
|
current: branch(
|
||||||
|
provider("missioncore-local-percentile-ground/v1"),
|
||||||
|
),
|
||||||
|
candidate: branch(
|
||||||
|
provider(
|
||||||
|
"patchworkpp/v1.4.1",
|
||||||
|
"3e6903a1d5537a4cc2ace897b0bbb98a92d6014c",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
comparison: {
|
||||||
|
algorithm_to_algorithm_ground_iou: distribution(),
|
||||||
|
ground_disagreement_fraction: distribution(),
|
||||||
|
is_accuracy_metric: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decision: {
|
||||||
|
status: "visual-review-only",
|
||||||
|
production_promotion: false,
|
||||||
|
reasons: ["legacy derivative does not retain intensity"],
|
||||||
|
},
|
||||||
|
created_at_utc: "2026-07-25T02:00:00Z",
|
||||||
|
ground_truth: false,
|
||||||
|
authority: {
|
||||||
|
commands_enabled: false,
|
||||||
|
navigation_or_safety_accepted: false,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldReviewWindow(overrides = {}) {
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.lidar-field-review-window/v1",
|
||||||
|
review_id: fieldReviewId,
|
||||||
|
display_name: "RAVNOVES00 · центральный городской интервал",
|
||||||
|
session_id: "20260720T065719Z_viewer_live",
|
||||||
|
source_pack_id: e10PackId,
|
||||||
|
window_index: 0,
|
||||||
|
window_count: 1,
|
||||||
|
window: fieldReviewWindowSummary(),
|
||||||
|
point_count: 3,
|
||||||
|
coordinate_frame: "map",
|
||||||
|
distance_unit: "m",
|
||||||
|
intensity: {
|
||||||
|
available: false,
|
||||||
|
reason: "E10 derivative did not retain rgbi/intensity",
|
||||||
|
},
|
||||||
|
points_xyz_m: [
|
||||||
|
[0, 0, 0],
|
||||||
|
[1, 0, 0.1],
|
||||||
|
[0, 1, 0.5],
|
||||||
|
],
|
||||||
|
masks: {
|
||||||
|
current_ground: [1, 1, 0],
|
||||||
|
current_assigned: [1, 1, 1],
|
||||||
|
candidate_ground: [1, 0, 0],
|
||||||
|
candidate_assigned: [1, 1, 1],
|
||||||
|
disagreement: [0, 1, 0],
|
||||||
|
},
|
||||||
|
counts: {
|
||||||
|
current_ground: 2,
|
||||||
|
candidate_ground: 1,
|
||||||
|
disagreement: 1,
|
||||||
|
},
|
||||||
|
preview_url:
|
||||||
|
`/api/v1/lidar/field-reviews/${fieldReviewId}/windows/0/preview`,
|
||||||
|
access: "read-only",
|
||||||
|
ground_truth: false,
|
||||||
|
authority: {
|
||||||
|
commands_enabled: false,
|
||||||
|
navigation_or_safety_accepted: false,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function jsonResponse(payload, status = 200) {
|
function jsonResponse(payload, status = 200) {
|
||||||
return new Response(JSON.stringify(payload), {
|
return new Response(JSON.stringify(payload), {
|
||||||
status,
|
status,
|
||||||
|
|
@ -359,6 +508,49 @@ test("ground frame stays point-aligned, bounded and path-free", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("field review identifies RAVNOVES00 source and stays visual-only", () => {
|
||||||
|
const parsedCatalog = parseLidarFieldReviewCatalog(fieldReviewCatalog());
|
||||||
|
const parsedWindow = parseLidarFieldReviewWindow(fieldReviewWindow());
|
||||||
|
|
||||||
|
assert.equal(parsedCatalog.items[0].sourcePackId, e10PackId);
|
||||||
|
assert.equal(parsedCatalog.items[0].source.availableLidarFrames, 526);
|
||||||
|
assert.equal(parsedCatalog.items[0].selection.defaultWindowIndex, 0);
|
||||||
|
assert.equal(parsedWindow.window.label, "Перекрёсток, дорога и фасады");
|
||||||
|
assert.equal(parsedWindow.intensity0To255, null);
|
||||||
|
assert.equal(parsedWindow.counts.disagreement, 1);
|
||||||
|
assert.equal("path" in parsedWindow, false);
|
||||||
|
|
||||||
|
const promoted = fieldReviewCatalog();
|
||||||
|
promoted.items[0].decision.production_promotion = true;
|
||||||
|
assert.throws(
|
||||||
|
() => parseLidarFieldReviewCatalog(promoted),
|
||||||
|
LidarReplayContractError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("field-review window refuses forged preview and point masks", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => parseLidarFieldReviewWindow(fieldReviewWindow({
|
||||||
|
preview_url: "https://example.invalid/frame.jpg",
|
||||||
|
})),
|
||||||
|
LidarReplayContractError,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => parseLidarFieldReviewWindow(fieldReviewWindow({
|
||||||
|
masks: {
|
||||||
|
...fieldReviewWindow().masks,
|
||||||
|
disagreement: [0, 0, 0],
|
||||||
|
},
|
||||||
|
counts: {
|
||||||
|
current_ground: 2,
|
||||||
|
candidate_ground: 1,
|
||||||
|
disagreement: 0,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
LidarReplayContractError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("LiDAR fetchers use read-only endpoints and workspace is registered", async () => {
|
test("LiDAR fetchers use read-only endpoints and workspace is registered", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const fetcher = async (input, init) => {
|
const fetcher = async (input, init) => {
|
||||||
|
|
@ -368,6 +560,11 @@ test("LiDAR fetchers use read-only endpoints and workspace is registered", async
|
||||||
? jsonResponse(groundFrame())
|
? jsonResponse(groundFrame())
|
||||||
: jsonResponse(groundCatalog());
|
: jsonResponse(groundCatalog());
|
||||||
}
|
}
|
||||||
|
if (String(input).includes("field-reviews")) {
|
||||||
|
return String(input).includes("/windows/")
|
||||||
|
? jsonResponse(fieldReviewWindow())
|
||||||
|
: jsonResponse(fieldReviewCatalog());
|
||||||
|
}
|
||||||
return String(input).includes(packId)
|
return String(input).includes(packId)
|
||||||
? jsonResponse(detail())
|
? jsonResponse(detail())
|
||||||
: jsonResponse(catalog());
|
: jsonResponse(catalog());
|
||||||
|
|
@ -380,11 +577,19 @@ test("LiDAR fetchers use read-only endpoints and workspace is registered", async
|
||||||
0,
|
0,
|
||||||
{ fetcher },
|
{ fetcher },
|
||||||
);
|
);
|
||||||
|
const fieldCatalog = await fetchLidarFieldReviews({ fetcher });
|
||||||
|
const fieldWindow = await fetchLidarFieldReviewWindow(
|
||||||
|
fieldReviewId,
|
||||||
|
0,
|
||||||
|
{ fetcher },
|
||||||
|
);
|
||||||
|
|
||||||
assert.equal(parsedCatalog.validTotal, 1);
|
assert.equal(parsedCatalog.validTotal, 1);
|
||||||
assert.equal(parsedDetail.pack.packId, packId);
|
assert.equal(parsedDetail.pack.packId, packId);
|
||||||
assert.equal(ground.validTotal, 1);
|
assert.equal(ground.validTotal, 1);
|
||||||
assert.equal(frame.pointCount, 3);
|
assert.equal(frame.pointCount, 3);
|
||||||
|
assert.equal(fieldCatalog.items[0].windows[0].sourceLidarSamples, 56);
|
||||||
|
assert.equal(fieldWindow.previewUrl.endsWith("/preview"), true);
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
{ input: "/api/v1/lidar/replay-packs?limit=50", method: "GET" },
|
{ input: "/api/v1/lidar/replay-packs?limit=50", method: "GET" },
|
||||||
{ input: `/api/v1/lidar/replay-packs/${packId}`, method: "GET" },
|
{ input: `/api/v1/lidar/replay-packs/${packId}`, method: "GET" },
|
||||||
|
|
@ -396,6 +601,14 @@ test("LiDAR fetchers use read-only endpoints and workspace is registered", async
|
||||||
input: `/api/v1/lidar/ground-benchmarks/ground-benchmark-${"c".repeat(64)}/frames/0`,
|
input: `/api/v1/lidar/ground-benchmarks/ground-benchmark-${"c".repeat(64)}/frames/0`,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
input: "/api/v1/lidar/field-reviews?limit=10",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `/api/v1/lidar/field-reviews/${fieldReviewId}/windows/0`,
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
assert.equal(workspaceById("lidar-quality").root, "data");
|
assert.equal(workspaceById("lidar-quality").root, "data");
|
||||||
assert.equal(workspaceById("lidar-quality").kind, "lidar-quality");
|
assert.equal(workspaceById("lidar-quality").kind, "lidar-quality");
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,17 @@ that evidence in Three.js with unrestricted orbit, pan and zoom plus
|
||||||
intensity/current/candidate/disagreement color modes. The browser never runs
|
intensity/current/candidate/disagreement color modes. The browser never runs
|
||||||
Patchwork++, parses private firmware or receives a filesystem path.
|
Patchwork++, parses private firmware or receives a filesystem path.
|
||||||
|
|
||||||
|
`missioncore.lidar-field-review/v1` is the separate operator-readable field
|
||||||
|
surface. The read-only `/api/v1/lidar/field-reviews` catalog names the exact
|
||||||
|
RAVNOVES00 source, timeline and five central urban windows. The bounded
|
||||||
|
`/api/v1/lidar/field-reviews/{review_id}/windows/{window_index}` response
|
||||||
|
contains a deterministic accumulated map cloud plus masks that were computed
|
||||||
|
per original LiDAR sample before accumulation. Its sibling `/preview` endpoint
|
||||||
|
returns the content-bound camera JPEG for that window. No source path,
|
||||||
|
Patchwork++ binary, worker control or command authority crosses this boundary.
|
||||||
|
The legacy E10 source has no intensity, so the API declares it unavailable
|
||||||
|
instead of synthesizing a value.
|
||||||
|
|
||||||
Static K1 3.0.2 firmware evidence confirms that the appliance internally uses
|
Static K1 3.0.2 firmware evidence confirms that the appliance internally uses
|
||||||
a Livox MID-360 point/IMU path with richer timestamp/ring semantics and a
|
a Livox MID-360 point/IMU path with richer timestamp/ring semantics and a
|
||||||
configured MQTT point-cloud downsample factor of four. This creates a concrete
|
configured MQTT point-cloud downsample factor of four. This creates a concrete
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,8 @@ quality line. It is not repaired or hidden by replay.
|
||||||
source point; raw replay remains unchanged.
|
source point; raw replay remains unchanged.
|
||||||
- [x] Add a bounded point-aligned frame API and browser 3D review for
|
- [x] Add a bounded point-aligned frame API and browser 3D review for
|
||||||
intensity, current ground, candidate ground and disagreement.
|
intensity, current ground, candidate ground and disagreement.
|
||||||
|
- [x] Separate the 66-frame indoor contract slice from a named RAVNOVES00
|
||||||
|
field review with five camera-bound urban windows and accumulated map clouds.
|
||||||
- [x] Record the K1 3.0.2 internal MID-360/LIO field path from static,
|
- [x] Record the K1 3.0.2 internal MID-360/LIO field path from static,
|
||||||
redacted firmware evidence without changing device state.
|
redacted firmware evidence without changing device state.
|
||||||
- [x] Bind physical height, applied vertical-origin offset and evidence class
|
- [x] Bind physical height, applied vertical-origin offset and evidence class
|
||||||
|
|
@ -255,6 +257,26 @@ latency remains 0.42 ms. Algorithm IoU is 4.07% p50 and disagreement is 19.13%
|
||||||
p50. The run is useful for visual review, but its evidence class is
|
p50. The run is useful for visual review, but its evidence class is
|
||||||
`operator-estimated`, so input acceptance and production promotion stay false.
|
`operator-estimated`, so input acceptance and production promotion stay false.
|
||||||
|
|
||||||
|
The operator-facing field generation is
|
||||||
|
`lidar-field-review-57f359dae336f06962e3a29e69e2da1bb8365f9af46d5d3173609f92d43db1ef`.
|
||||||
|
It uses the immutable RAVNOVES00 E10 derivative
|
||||||
|
`e10-lidar-pack-5da0396d32a27f9d1ca537cc2e8a371d386078d6f0dc71737b78620992af9625`
|
||||||
|
over `135.365857292–195.334857292` session seconds. The source has 526
|
||||||
|
available LiDAR samples and 1,182,292 points. Five central urban windows cover
|
||||||
|
roads, facades, sidewalks, parked vehicles and vegetation. Each window:
|
||||||
|
|
||||||
|
- retains the exact source interval and camera preview identity;
|
||||||
|
- computes both ground masks independently on every original LiDAR sample;
|
||||||
|
- accumulates those point-aligned results only afterwards in the map frame;
|
||||||
|
- reports 53–68 source samples and 98,538–172,027 source points;
|
||||||
|
- publishes a deterministic uniform display sample of at most 80,000 points.
|
||||||
|
|
||||||
|
This field review fixes the operator-context problem of the indoor technical
|
||||||
|
slice; it does not upgrade evidence. The E10 derivative remains intensity-free,
|
||||||
|
vendor-mapped and without independent ground labels. Its contract is therefore
|
||||||
|
`missioncore.lidar-field-review/v1`, `status=diagnostic-only`,
|
||||||
|
`decision.status=visual-review-only` and `production_promotion=false`.
|
||||||
|
|
||||||
The result is an evidence-backed **do-not-promote** decision for the current K1
|
The result is an evidence-backed **do-not-promote** decision for the current K1
|
||||||
feed. Patchwork++ is fast, but its input model assumes a sensor-centric scan
|
feed. Patchwork++ is fast, but its input model assumes a sensor-centric scan
|
||||||
and physical sensor height. K1 `lio_pcl` is a vendor-mapped increment, its
|
and physical sensor height. K1 `lio_pcl` is a vendor-mapped increment, its
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from k1link.compute import (
|
||||||
|
PATCHWORKPP_SOURCE_COMMIT,
|
||||||
|
PATCHWORKPP_SOURCE_TAG,
|
||||||
|
RAVNOVES00_CENTRAL_WINDOWS,
|
||||||
|
E10LidarFieldSource,
|
||||||
|
GroundBenchmarkProfile,
|
||||||
|
LidarFieldReviewV1,
|
||||||
|
PatchworkPPGroundSegmenter,
|
||||||
|
build_lidar_field_review,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _arguments() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Build accumulated RAVNOVES00 central-urban LiDAR ground review "
|
||||||
|
"with content-bound camera context previews."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument("source_pack", type=Path)
|
||||||
|
parser.add_argument("camera_epoch", type=Path)
|
||||||
|
parser.add_argument("output_root", type=Path)
|
||||||
|
parser.add_argument("--ffmpeg", default="ffmpeg")
|
||||||
|
parser.add_argument("--patchwork-module", default="pypatchworkpp")
|
||||||
|
parser.add_argument("--patchwork-source-tag", default=PATCHWORKPP_SOURCE_TAG)
|
||||||
|
parser.add_argument(
|
||||||
|
"--patchwork-source-commit",
|
||||||
|
default=PATCHWORKPP_SOURCE_COMMIT,
|
||||||
|
)
|
||||||
|
parser.add_argument("--sensor-height-m", type=float, default=1.27)
|
||||||
|
parser.add_argument("--map-vertical-origin-offset-m", type=float, default=1.27)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_segments(epoch: Path) -> dict[str, Path]:
|
||||||
|
resolved = epoch.expanduser().resolve(strict=True)
|
||||||
|
init = resolved / "init.mp4"
|
||||||
|
index_path = resolved / "index.jsonl"
|
||||||
|
if not init.is_file() or init.is_symlink() or not index_path.is_file():
|
||||||
|
raise RuntimeError("Camera epoch is incomplete")
|
||||||
|
required = {
|
||||||
|
window.preview_source_frame_index + 1: window.key for window in RAVNOVES00_CENTRAL_WINDOWS
|
||||||
|
}
|
||||||
|
found: dict[str, Path] = {}
|
||||||
|
with index_path.open(encoding="utf-8") as stream:
|
||||||
|
for line in stream:
|
||||||
|
value = json.loads(line)
|
||||||
|
sequence = value.get("sequence") if isinstance(value, dict) else None
|
||||||
|
if sequence not in required:
|
||||||
|
continue
|
||||||
|
relative = value.get("path")
|
||||||
|
expected_sha256 = value.get("sha256")
|
||||||
|
expected_length = value.get("length")
|
||||||
|
if (
|
||||||
|
value.get("schema_version") != "missioncore.camera-recording-index/v1"
|
||||||
|
or relative != f"segments/{sequence}.m4s"
|
||||||
|
or not isinstance(expected_sha256, str)
|
||||||
|
or not isinstance(expected_length, int)
|
||||||
|
):
|
||||||
|
raise RuntimeError("Camera preview segment index is invalid")
|
||||||
|
segment = resolved / relative
|
||||||
|
if (
|
||||||
|
segment.is_symlink()
|
||||||
|
or not segment.is_file()
|
||||||
|
or segment.stat().st_size != expected_length
|
||||||
|
or _sha256(segment) != expected_sha256
|
||||||
|
):
|
||||||
|
raise RuntimeError("Camera preview segment failed integrity")
|
||||||
|
found[required[sequence]] = segment
|
||||||
|
if set(found) != set(required.values()):
|
||||||
|
raise RuntimeError("Camera preview segments are incomplete")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_previews(
|
||||||
|
ffmpeg: str,
|
||||||
|
epoch: Path,
|
||||||
|
output: Path,
|
||||||
|
) -> dict[str, Path]:
|
||||||
|
init = epoch.expanduser().resolve(strict=True) / "init.mp4"
|
||||||
|
segments = _preview_segments(epoch)
|
||||||
|
previews: dict[str, Path] = {}
|
||||||
|
for key, segment in segments.items():
|
||||||
|
target = output / f"{key}.jpg"
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
ffmpeg,
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-i",
|
||||||
|
f"concat:{init}|{segment}",
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-vf",
|
||||||
|
"scale=640:480",
|
||||||
|
"-q:v",
|
||||||
|
"3",
|
||||||
|
str(target),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
if not target.is_file() or target.stat().st_size < 1_000:
|
||||||
|
raise RuntimeError("Camera preview extraction failed")
|
||||||
|
previews[key] = target
|
||||||
|
return previews
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
arguments = _arguments()
|
||||||
|
profile = GroundBenchmarkProfile(
|
||||||
|
profile_id="ravnoves00-central-urban-operator-height-ground-review/v1",
|
||||||
|
patchwork_sensor_height_proxy_m=arguments.sensor_height_m,
|
||||||
|
patchwork_map_vertical_origin_offset_m=(arguments.map_vertical_origin_offset_m),
|
||||||
|
patchwork_height_evidence="operator-estimated",
|
||||||
|
)
|
||||||
|
source = E10LidarFieldSource(arguments.source_pack)
|
||||||
|
try:
|
||||||
|
patchwork = PatchworkPPGroundSegmenter.load(
|
||||||
|
profile=profile,
|
||||||
|
module_name=arguments.patchwork_module,
|
||||||
|
source_tag=arguments.patchwork_source_tag,
|
||||||
|
source_commit=arguments.patchwork_source_commit,
|
||||||
|
)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="missioncore-lidar-field-review-") as value:
|
||||||
|
previews = _extract_previews(
|
||||||
|
arguments.ffmpeg,
|
||||||
|
arguments.camera_epoch,
|
||||||
|
Path(value),
|
||||||
|
)
|
||||||
|
output = build_lidar_field_review(
|
||||||
|
source,
|
||||||
|
arguments.output_root,
|
||||||
|
patchwork=patchwork,
|
||||||
|
profile=profile,
|
||||||
|
preview_paths=previews,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
source.close()
|
||||||
|
review = LidarFieldReviewV1(output)
|
||||||
|
try:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"review_id": review.review_id,
|
||||||
|
"display_name": review.report["display_name"],
|
||||||
|
"session_id": review.report["session_id"],
|
||||||
|
"source": review.report["source"],
|
||||||
|
"selection": review.report["selection"],
|
||||||
|
"windows": review.report["windows"],
|
||||||
|
"metrics": review.report["metrics"],
|
||||||
|
"decision": review.report["decision"],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
review.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
while chunk := stream.read(1024 * 1024):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -69,6 +69,22 @@ from .lidar_contract import (
|
||||||
lidar_readiness_document,
|
lidar_readiness_document,
|
||||||
sensor_frame_xyzi,
|
sensor_frame_xyzi,
|
||||||
)
|
)
|
||||||
|
from .lidar_field_review import (
|
||||||
|
E10_LIDAR_PACK_SCHEMA,
|
||||||
|
FIELD_REVIEW_ARRAYS_NAME,
|
||||||
|
FIELD_REVIEW_MANIFEST_NAME,
|
||||||
|
FIELD_REVIEW_REPORT_NAME,
|
||||||
|
LIDAR_FIELD_REVIEW_REPORT_SCHEMA,
|
||||||
|
LIDAR_FIELD_REVIEW_SCHEMA,
|
||||||
|
LIDAR_FIELD_REVIEW_WINDOW_SCHEMA,
|
||||||
|
MAX_FIELD_REVIEW_WINDOW_POINTS,
|
||||||
|
RAVNOVES00_CENTRAL_WINDOWS,
|
||||||
|
E10LidarFieldSource,
|
||||||
|
FieldReviewWindowSpec,
|
||||||
|
LidarFieldReviewV1,
|
||||||
|
build_lidar_field_review,
|
||||||
|
lidar_field_review_catalog_item,
|
||||||
|
)
|
||||||
from .lidar_ground import (
|
from .lidar_ground import (
|
||||||
DEFAULT_GROUND_BENCHMARK_PROFILE,
|
DEFAULT_GROUND_BENCHMARK_PROFILE,
|
||||||
LIDAR_GROUND_ANNOTATION_TEMPLATE_SCHEMA,
|
LIDAR_GROUND_ANNOTATION_TEMPLATE_SCHEMA,
|
||||||
|
|
@ -179,6 +195,9 @@ __all__ = [
|
||||||
"LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA",
|
"LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA",
|
||||||
"LIDAR_GROUND_BENCHMARK_SCHEMA",
|
"LIDAR_GROUND_BENCHMARK_SCHEMA",
|
||||||
"LIDAR_GROUND_FRAME_SCHEMA",
|
"LIDAR_GROUND_FRAME_SCHEMA",
|
||||||
|
"LIDAR_FIELD_REVIEW_REPORT_SCHEMA",
|
||||||
|
"LIDAR_FIELD_REVIEW_SCHEMA",
|
||||||
|
"LIDAR_FIELD_REVIEW_WINDOW_SCHEMA",
|
||||||
"LIDAR_EVIDENCE_PROFILE_SCHEMA",
|
"LIDAR_EVIDENCE_PROFILE_SCHEMA",
|
||||||
"LIDAR_EQUIVALENCE_REPORT_SCHEMA",
|
"LIDAR_EQUIVALENCE_REPORT_SCHEMA",
|
||||||
"LIDAR_QUALITY_REPORT_SCHEMA",
|
"LIDAR_QUALITY_REPORT_SCHEMA",
|
||||||
|
|
@ -262,6 +281,17 @@ __all__ = [
|
||||||
"build_lidar_ground_annotation_template",
|
"build_lidar_ground_annotation_template",
|
||||||
"build_lidar_ground_benchmark",
|
"build_lidar_ground_benchmark",
|
||||||
"lidar_ground_frame_detail",
|
"lidar_ground_frame_detail",
|
||||||
|
"E10_LIDAR_PACK_SCHEMA",
|
||||||
|
"FIELD_REVIEW_ARRAYS_NAME",
|
||||||
|
"FIELD_REVIEW_MANIFEST_NAME",
|
||||||
|
"FIELD_REVIEW_REPORT_NAME",
|
||||||
|
"MAX_FIELD_REVIEW_WINDOW_POINTS",
|
||||||
|
"RAVNOVES00_CENTRAL_WINDOWS",
|
||||||
|
"E10LidarFieldSource",
|
||||||
|
"FieldReviewWindowSpec",
|
||||||
|
"LidarFieldReviewV1",
|
||||||
|
"build_lidar_field_review",
|
||||||
|
"lidar_field_review_catalog_item",
|
||||||
"DetectionFrame",
|
"DetectionFrame",
|
||||||
"ObjectDetection",
|
"ObjectDetection",
|
||||||
"RecordedPerceptionOverlayError",
|
"RecordedPerceptionOverlayError",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,891 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Final
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import numpy.typing as npt
|
||||||
|
|
||||||
|
from k1link.device_plugins.xgrids_k1.analyze import (
|
||||||
|
CalibratedProjectionError,
|
||||||
|
map_points_to_lidar,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .lidar_ground import (
|
||||||
|
GroundBenchmarkProfile,
|
||||||
|
GroundSegmenter,
|
||||||
|
LidarGroundError,
|
||||||
|
LocalPercentileGroundSegmenter,
|
||||||
|
)
|
||||||
|
|
||||||
|
LIDAR_FIELD_REVIEW_SCHEMA: Final = "missioncore.lidar-field-review/v1"
|
||||||
|
LIDAR_FIELD_REVIEW_REPORT_SCHEMA: Final = "missioncore.lidar-field-review-report/v1"
|
||||||
|
LIDAR_FIELD_REVIEW_WINDOW_SCHEMA: Final = "missioncore.lidar-field-review-window/v1"
|
||||||
|
E10_LIDAR_PACK_SCHEMA: Final = "missioncore.e10-lidar-replay-pack/v1"
|
||||||
|
FIELD_REVIEW_ARRAYS_NAME: Final = "field-review.npz"
|
||||||
|
FIELD_REVIEW_REPORT_NAME: Final = "field-review.json"
|
||||||
|
FIELD_REVIEW_MANIFEST_NAME: Final = "manifest.json"
|
||||||
|
MAX_FIELD_REVIEW_WINDOW_POINTS: Final = 80_000
|
||||||
|
|
||||||
|
_E10_PACK_ID = re.compile(r"^e10-lidar-pack-[a-f0-9]{64}$")
|
||||||
|
_FIELD_REVIEW_ID = re.compile(r"^lidar-field-review-[a-f0-9]{64}$")
|
||||||
|
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||||
|
_SAFE_KEY = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FieldReviewWindowSpec:
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
start_seconds: float
|
||||||
|
end_seconds: float
|
||||||
|
preview_source_frame_index: int
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if (
|
||||||
|
_SAFE_KEY.fullmatch(self.key) is None
|
||||||
|
or not self.label.strip()
|
||||||
|
or len(self.label) > 120
|
||||||
|
or not np.isfinite((self.start_seconds, self.end_seconds)).all()
|
||||||
|
or not 0 <= self.start_seconds < self.end_seconds
|
||||||
|
or self.preview_source_frame_index < 0
|
||||||
|
):
|
||||||
|
raise LidarGroundError("LiDAR field-review window is invalid")
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"key": self.key,
|
||||||
|
"label": self.label,
|
||||||
|
"start_seconds": self.start_seconds,
|
||||||
|
"end_seconds": self.end_seconds,
|
||||||
|
"preview_source_frame_index": self.preview_source_frame_index,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
RAVNOVES00_CENTRAL_WINDOWS: Final = (
|
||||||
|
FieldReviewWindowSpec(
|
||||||
|
key="intersection-facades",
|
||||||
|
label="Перекрёсток, дорога и фасады",
|
||||||
|
start_seconds=145.0,
|
||||||
|
end_seconds=151.0,
|
||||||
|
preview_source_frame_index=1120,
|
||||||
|
),
|
||||||
|
FieldReviewWindowSpec(
|
||||||
|
key="crossing-parked-vehicles",
|
||||||
|
label="Переход и припаркованные машины",
|
||||||
|
start_seconds=153.0,
|
||||||
|
end_seconds=159.0,
|
||||||
|
preview_source_frame_index=1200,
|
||||||
|
),
|
||||||
|
FieldReviewWindowSpec(
|
||||||
|
key="long-street",
|
||||||
|
label="Длинный фасад, тротуар и улица",
|
||||||
|
start_seconds=167.0,
|
||||||
|
end_seconds=175.0,
|
||||||
|
preview_source_frame_index=1350,
|
||||||
|
),
|
||||||
|
FieldReviewWindowSpec(
|
||||||
|
key="sidewalk-vehicles",
|
||||||
|
label="Тротуар, дома и автомобили",
|
||||||
|
start_seconds=180.0,
|
||||||
|
end_seconds=187.0,
|
||||||
|
preview_source_frame_index=1480,
|
||||||
|
),
|
||||||
|
FieldReviewWindowSpec(
|
||||||
|
key="street-vegetation",
|
||||||
|
label="Продолжение улицы и растительность",
|
||||||
|
start_seconds=188.0,
|
||||||
|
end_seconds=194.0,
|
||||||
|
preview_source_frame_index=1550,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class E10LidarFieldSource:
|
||||||
|
"""Strict reader for the immutable, intensity-free RAVNOVES00 E10 pack."""
|
||||||
|
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
candidate = root.expanduser().absolute()
|
||||||
|
if candidate.is_symlink():
|
||||||
|
raise LidarGroundError("E10 LiDAR source cannot be a symlink")
|
||||||
|
self.root = candidate.resolve(strict=True)
|
||||||
|
if not self.root.is_dir() or _E10_PACK_ID.fullmatch(self.root.name) is None:
|
||||||
|
raise LidarGroundError("E10 LiDAR source id is invalid")
|
||||||
|
self.manifest = _read_json(self.root / "manifest.json")
|
||||||
|
self.identity = _object(self.manifest.get("identity"), "E10 LiDAR identity")
|
||||||
|
identity_sha256 = self.manifest.get("identity_sha256")
|
||||||
|
artifact = _object(self.manifest.get("artifact"), "E10 LiDAR artifact")
|
||||||
|
artifact_path = artifact.get("path")
|
||||||
|
if (
|
||||||
|
self.manifest.get("schema_version") != E10_LIDAR_PACK_SCHEMA
|
||||||
|
or self.identity.get("schema_version") != E10_LIDAR_PACK_SCHEMA
|
||||||
|
or not isinstance(identity_sha256, str)
|
||||||
|
or _SHA256.fullmatch(identity_sha256) is None
|
||||||
|
or hashlib.sha256(_canonical_json(self.identity)).hexdigest() != identity_sha256
|
||||||
|
or self.root.name != f"e10-lidar-pack-{identity_sha256}"
|
||||||
|
or self.manifest.get("pack_id") != self.root.name
|
||||||
|
or artifact_path != "lidar-pack.npz"
|
||||||
|
):
|
||||||
|
raise LidarGroundError("E10 LiDAR source identity is invalid")
|
||||||
|
arrays_path = self.root / artifact_path
|
||||||
|
if (
|
||||||
|
arrays_path.is_symlink()
|
||||||
|
or not arrays_path.is_file()
|
||||||
|
or arrays_path.stat().st_size != artifact.get("byte_length")
|
||||||
|
or _sha256(arrays_path) != artifact.get("sha256")
|
||||||
|
):
|
||||||
|
raise LidarGroundError("E10 LiDAR source artifact is invalid")
|
||||||
|
self.arrays = np.load(arrays_path, allow_pickle=False)
|
||||||
|
try:
|
||||||
|
self._validate_arrays()
|
||||||
|
except BaseException:
|
||||||
|
self.close()
|
||||||
|
raise
|
||||||
|
self.pack_id = self.root.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frame_count(self) -> int:
|
||||||
|
return int(self.identity["frame_count"])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def point_count(self) -> int:
|
||||||
|
return int(self.identity["point_count"])
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.arrays.close()
|
||||||
|
|
||||||
|
def _validate_arrays(self) -> None:
|
||||||
|
required = {
|
||||||
|
"frame_indices",
|
||||||
|
"source_frame_indices",
|
||||||
|
"session_seconds",
|
||||||
|
"sample_available",
|
||||||
|
"cloud_offsets",
|
||||||
|
"cloud_points_map",
|
||||||
|
"pose_positions_map",
|
||||||
|
"pose_quaternions_map_from_lidar",
|
||||||
|
"lidar_camera_delta_ms",
|
||||||
|
"pose_point_delta_ms",
|
||||||
|
"intrinsic_fx_fy_cx_cy",
|
||||||
|
"distortion_kb4",
|
||||||
|
"t_camera_from_lidar",
|
||||||
|
}
|
||||||
|
frame_count = _nonnegative_int(self.identity.get("frame_count"), "E10 frame count")
|
||||||
|
point_count = _nonnegative_int(self.identity.get("point_count"), "E10 point count")
|
||||||
|
available = self.arrays["sample_available"]
|
||||||
|
offsets = self.arrays["cloud_offsets"]
|
||||||
|
points = self.arrays["cloud_points_map"]
|
||||||
|
positions = self.arrays["pose_positions_map"]
|
||||||
|
quaternions = self.arrays["pose_quaternions_map_from_lidar"]
|
||||||
|
if (
|
||||||
|
set(self.arrays.files) != required
|
||||||
|
or self.arrays["frame_indices"].shape != (frame_count,)
|
||||||
|
or self.arrays["source_frame_indices"].shape != (frame_count,)
|
||||||
|
or self.arrays["session_seconds"].shape != (frame_count,)
|
||||||
|
or available.shape != (frame_count,)
|
||||||
|
or offsets.shape != (frame_count + 1,)
|
||||||
|
or points.shape != (point_count, 3)
|
||||||
|
or positions.shape != (frame_count, 3)
|
||||||
|
or quaternions.shape != (frame_count, 4)
|
||||||
|
or self.arrays["frame_indices"].dtype != np.dtype("<i8")
|
||||||
|
or self.arrays["source_frame_indices"].dtype != np.dtype("<i8")
|
||||||
|
or self.arrays["session_seconds"].dtype != np.dtype("<f8")
|
||||||
|
or available.dtype != np.dtype("?")
|
||||||
|
or offsets.dtype != np.dtype("<i8")
|
||||||
|
or points.dtype != np.dtype("<f4")
|
||||||
|
or positions.dtype != np.dtype("<f8")
|
||||||
|
or quaternions.dtype != np.dtype("<f8")
|
||||||
|
or not np.array_equal(
|
||||||
|
self.arrays["frame_indices"],
|
||||||
|
np.arange(frame_count, dtype=np.int64),
|
||||||
|
)
|
||||||
|
or not np.all(np.diff(self.arrays["source_frame_indices"]) > 0)
|
||||||
|
or not np.isfinite(self.arrays["session_seconds"]).all()
|
||||||
|
or not np.all(np.diff(self.arrays["session_seconds"]) > 0)
|
||||||
|
or offsets[0] != 0
|
||||||
|
or offsets[-1] != point_count
|
||||||
|
or np.any(np.diff(offsets) < 0)
|
||||||
|
or not np.isfinite(points).all()
|
||||||
|
or int(np.count_nonzero(available)) != self.identity.get("available_lidar_frames")
|
||||||
|
):
|
||||||
|
raise LidarGroundError("E10 LiDAR source arrays are invalid")
|
||||||
|
counts = np.diff(offsets)
|
||||||
|
if (
|
||||||
|
np.any(counts[available] <= 0)
|
||||||
|
or np.any(counts[~available] != 0)
|
||||||
|
or not np.isfinite(positions[available]).all()
|
||||||
|
or not np.isfinite(quaternions[available]).all()
|
||||||
|
):
|
||||||
|
raise LidarGroundError("E10 LiDAR source availability is invalid")
|
||||||
|
|
||||||
|
|
||||||
|
class LidarFieldReviewV1:
|
||||||
|
"""Strict reader for accumulated, path-free LiDAR field-review evidence."""
|
||||||
|
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
candidate = root.expanduser().absolute()
|
||||||
|
if candidate.is_symlink():
|
||||||
|
raise LidarGroundError("LiDAR field review cannot be a symlink")
|
||||||
|
self.root = candidate.resolve(strict=True)
|
||||||
|
if not self.root.is_dir() or _FIELD_REVIEW_ID.fullmatch(self.root.name) is None:
|
||||||
|
raise LidarGroundError("LiDAR field-review id is invalid")
|
||||||
|
self.manifest = _read_json(self.root / FIELD_REVIEW_MANIFEST_NAME)
|
||||||
|
self.identity = _object(self.manifest.get("identity"), "field-review identity")
|
||||||
|
identity_sha256 = self.manifest.get("identity_sha256")
|
||||||
|
if (
|
||||||
|
self.manifest.get("schema_version") != LIDAR_FIELD_REVIEW_SCHEMA
|
||||||
|
or self.identity.get("schema_version") != LIDAR_FIELD_REVIEW_SCHEMA
|
||||||
|
or not isinstance(identity_sha256, str)
|
||||||
|
or _SHA256.fullmatch(identity_sha256) is None
|
||||||
|
or hashlib.sha256(_canonical_json(self.identity)).hexdigest() != identity_sha256
|
||||||
|
or self.root.name != f"lidar-field-review-{identity_sha256}"
|
||||||
|
or self.manifest.get("review_id") != self.root.name
|
||||||
|
):
|
||||||
|
raise LidarGroundError("LiDAR field-review identity is invalid")
|
||||||
|
artifacts = _validate_artifacts(self.root, self.manifest.get("artifacts"))
|
||||||
|
self.arrays = np.load(artifacts["field-review"], allow_pickle=False)
|
||||||
|
self.report = _read_json(artifacts["field-review-report"])
|
||||||
|
self.preview_paths = {
|
||||||
|
role.removeprefix("preview-"): path
|
||||||
|
for role, path in artifacts.items()
|
||||||
|
if role.startswith("preview-")
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
self._validate()
|
||||||
|
except BaseException:
|
||||||
|
self.close()
|
||||||
|
raise
|
||||||
|
self.review_id = self.root.name
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.arrays.close()
|
||||||
|
|
||||||
|
def _validate(self) -> None:
|
||||||
|
windows = _list(self.report.get("windows"), "field-review windows")
|
||||||
|
parsed_windows = [
|
||||||
|
_object(item, f"field-review window {index}") for index, item in enumerate(windows)
|
||||||
|
]
|
||||||
|
source = _object(self.report.get("source"), "field-review source")
|
||||||
|
decision = _object(self.report.get("decision"), "field-review decision")
|
||||||
|
authority = _object(self.report.get("authority"), "field-review authority")
|
||||||
|
offsets = self.arrays["window_offsets"]
|
||||||
|
points = self.arrays["points_xyz_map"]
|
||||||
|
point_count = _nonnegative_int(
|
||||||
|
self.identity.get("display_point_count"),
|
||||||
|
"field-review point count",
|
||||||
|
)
|
||||||
|
required = {
|
||||||
|
"window_offsets",
|
||||||
|
"points_xyz_map",
|
||||||
|
"current_ground",
|
||||||
|
"current_assigned",
|
||||||
|
"candidate_ground",
|
||||||
|
"candidate_assigned",
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
self.report.get("schema_version") != LIDAR_FIELD_REVIEW_REPORT_SCHEMA
|
||||||
|
or self.report.get("review_id") != self.root.name
|
||||||
|
or self.report.get("status") != "diagnostic-only"
|
||||||
|
or self.report.get("ground_truth") is not False
|
||||||
|
or source.get("representation") != "legacy-e10-vendor-map-with-pose"
|
||||||
|
or source.get("intensity_available") is not False
|
||||||
|
or source.get("raw_scan_accepted") is not False
|
||||||
|
or decision.get("status") != "visual-review-only"
|
||||||
|
or decision.get("production_promotion") is not False
|
||||||
|
or authority.get("commands_enabled") is not False
|
||||||
|
or authority.get("navigation_or_safety_accepted") is not False
|
||||||
|
or len(windows) != self.identity.get("window_count")
|
||||||
|
or set(self.preview_paths) != {str(item.get("key")) for item in parsed_windows}
|
||||||
|
or set(self.arrays.files) != required
|
||||||
|
or offsets.shape != (len(windows) + 1,)
|
||||||
|
or offsets.dtype != np.dtype("<i8")
|
||||||
|
or offsets[0] != 0
|
||||||
|
or offsets[-1] != point_count
|
||||||
|
or np.any(np.diff(offsets) <= 0)
|
||||||
|
or points.shape != (point_count, 3)
|
||||||
|
or points.dtype != np.dtype("<f4")
|
||||||
|
or not np.isfinite(points).all()
|
||||||
|
or _logical_sha256(self.arrays) != self.identity.get("logical_content_sha256")
|
||||||
|
):
|
||||||
|
raise LidarGroundError("LiDAR field-review content is invalid")
|
||||||
|
for name in (
|
||||||
|
"current_ground",
|
||||||
|
"current_assigned",
|
||||||
|
"candidate_ground",
|
||||||
|
"candidate_assigned",
|
||||||
|
):
|
||||||
|
value = self.arrays[name]
|
||||||
|
if value.shape != (point_count,) or value.dtype != np.dtype("u1") or np.any(value > 1):
|
||||||
|
raise LidarGroundError("LiDAR field-review mask is invalid")
|
||||||
|
for index, window in enumerate(parsed_windows):
|
||||||
|
start = int(offsets[index])
|
||||||
|
end = int(offsets[index + 1])
|
||||||
|
if (
|
||||||
|
window.get("index") != index
|
||||||
|
or _SAFE_KEY.fullmatch(str(window.get("key"))) is None
|
||||||
|
or window.get("display_point_count") != end - start
|
||||||
|
or not 0 < end - start <= MAX_FIELD_REVIEW_WINDOW_POINTS
|
||||||
|
or _nonnegative_int(
|
||||||
|
window.get("source_lidar_samples"),
|
||||||
|
"field-review source samples",
|
||||||
|
)
|
||||||
|
< 1
|
||||||
|
or _nonnegative_int(
|
||||||
|
window.get("source_point_count"),
|
||||||
|
"field-review source points",
|
||||||
|
)
|
||||||
|
< end - start
|
||||||
|
or not isinstance(window.get("label"), str)
|
||||||
|
or not window["label"].strip()
|
||||||
|
):
|
||||||
|
raise LidarGroundError("LiDAR field-review window is invalid")
|
||||||
|
|
||||||
|
def window_detail(self, window_index: int) -> dict[str, object]:
|
||||||
|
windows = _list(self.report["windows"], "field-review windows")
|
||||||
|
if not 0 <= window_index < len(windows):
|
||||||
|
raise IndexError(window_index)
|
||||||
|
window = _object(windows[window_index], "field-review window")
|
||||||
|
offsets = self.arrays["window_offsets"]
|
||||||
|
start = int(offsets[window_index])
|
||||||
|
end = int(offsets[window_index + 1])
|
||||||
|
current_ground = self.arrays["current_ground"][start:end]
|
||||||
|
candidate_ground = self.arrays["candidate_ground"][start:end]
|
||||||
|
disagreement = (current_ground != candidate_ground).astype(np.uint8)
|
||||||
|
return {
|
||||||
|
"schema_version": LIDAR_FIELD_REVIEW_WINDOW_SCHEMA,
|
||||||
|
"review_id": self.review_id,
|
||||||
|
"display_name": self.report["display_name"],
|
||||||
|
"session_id": self.report["session_id"],
|
||||||
|
"source_pack_id": self.report["source_pack_id"],
|
||||||
|
"window_index": window_index,
|
||||||
|
"window_count": len(windows),
|
||||||
|
"window": window,
|
||||||
|
"point_count": end - start,
|
||||||
|
"coordinate_frame": "map",
|
||||||
|
"distance_unit": "m",
|
||||||
|
"intensity": {
|
||||||
|
"available": False,
|
||||||
|
"reason": "E10 derivative did not retain rgbi/intensity",
|
||||||
|
},
|
||||||
|
"points_xyz_m": self.arrays["points_xyz_map"][start:end].astype(np.float64).tolist(),
|
||||||
|
"masks": {
|
||||||
|
"current_ground": current_ground.astype(np.int64).tolist(),
|
||||||
|
"current_assigned": self.arrays["current_assigned"][start:end]
|
||||||
|
.astype(np.int64)
|
||||||
|
.tolist(),
|
||||||
|
"candidate_ground": candidate_ground.astype(np.int64).tolist(),
|
||||||
|
"candidate_assigned": self.arrays["candidate_assigned"][start:end]
|
||||||
|
.astype(np.int64)
|
||||||
|
.tolist(),
|
||||||
|
"disagreement": disagreement.astype(np.int64).tolist(),
|
||||||
|
},
|
||||||
|
"counts": {
|
||||||
|
"current_ground": int(np.count_nonzero(current_ground)),
|
||||||
|
"candidate_ground": int(np.count_nonzero(candidate_ground)),
|
||||||
|
"disagreement": int(np.count_nonzero(disagreement)),
|
||||||
|
},
|
||||||
|
"preview_url": (
|
||||||
|
f"/api/v1/lidar/field-reviews/{self.review_id}/windows/{window_index}/preview"
|
||||||
|
),
|
||||||
|
"access": "read-only",
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": {
|
||||||
|
"commands_enabled": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_lidar_field_review(
|
||||||
|
source: E10LidarFieldSource,
|
||||||
|
output_root: Path,
|
||||||
|
*,
|
||||||
|
patchwork: GroundSegmenter,
|
||||||
|
profile: GroundBenchmarkProfile,
|
||||||
|
preview_paths: Mapping[str, Path],
|
||||||
|
windows: Sequence[FieldReviewWindowSpec] = RAVNOVES00_CENTRAL_WINDOWS,
|
||||||
|
display_name: str = "RAVNOVES00 · центральный городской интервал",
|
||||||
|
default_window_index: int = 2,
|
||||||
|
maximum_display_points: int = MAX_FIELD_REVIEW_WINDOW_POINTS,
|
||||||
|
) -> Path:
|
||||||
|
"""Build accumulated field windows without upgrading legacy input evidence."""
|
||||||
|
|
||||||
|
if (
|
||||||
|
not windows
|
||||||
|
or not 0 <= default_window_index < len(windows)
|
||||||
|
or not 1 <= maximum_display_points <= MAX_FIELD_REVIEW_WINDOW_POINTS
|
||||||
|
or set(preview_paths) != {window.key for window in windows}
|
||||||
|
):
|
||||||
|
raise LidarGroundError("LiDAR field-review build configuration is invalid")
|
||||||
|
times = source.arrays["session_seconds"]
|
||||||
|
available = source.arrays["sample_available"]
|
||||||
|
source_offsets = source.arrays["cloud_offsets"]
|
||||||
|
points_map = source.arrays["cloud_points_map"]
|
||||||
|
positions = source.arrays["pose_positions_map"]
|
||||||
|
quaternions = source.arrays["pose_quaternions_map_from_lidar"]
|
||||||
|
source_frame_indices = source.arrays["source_frame_indices"]
|
||||||
|
current = LocalPercentileGroundSegmenter(profile)
|
||||||
|
window_arrays: list[dict[str, npt.NDArray[Any]]] = []
|
||||||
|
window_reports: list[dict[str, object]] = []
|
||||||
|
current_latency: list[float] = []
|
||||||
|
candidate_latency: list[float] = []
|
||||||
|
current_fraction: list[float] = []
|
||||||
|
candidate_fraction: list[float] = []
|
||||||
|
disagreement_fraction: list[float] = []
|
||||||
|
algorithm_iou: list[float] = []
|
||||||
|
selected_source_point_count = 0
|
||||||
|
|
||||||
|
for window_index, spec in enumerate(windows):
|
||||||
|
source_rows = np.flatnonzero(
|
||||||
|
available & (times >= spec.start_seconds) & (times <= spec.end_seconds)
|
||||||
|
)
|
||||||
|
if source_rows.size == 0:
|
||||||
|
raise LidarGroundError("LiDAR field-review window has no source samples")
|
||||||
|
collected_points: list[npt.NDArray[np.float32]] = []
|
||||||
|
collected_current: list[npt.NDArray[np.uint8]] = []
|
||||||
|
collected_current_assigned: list[npt.NDArray[np.uint8]] = []
|
||||||
|
collected_candidate: list[npt.NDArray[np.uint8]] = []
|
||||||
|
collected_candidate_assigned: list[npt.NDArray[np.uint8]] = []
|
||||||
|
source_point_count = 0
|
||||||
|
for source_row in source_rows:
|
||||||
|
start = int(source_offsets[source_row])
|
||||||
|
end = int(source_offsets[source_row + 1])
|
||||||
|
cloud = np.asarray(points_map[start:end], dtype=np.float32)
|
||||||
|
source_point_count += cloud.shape[0]
|
||||||
|
current_input = np.zeros((cloud.shape[0], 4), dtype=np.float32)
|
||||||
|
current_input[:, :3] = cloud
|
||||||
|
current_result = current.segment(current_input)
|
||||||
|
try:
|
||||||
|
position = positions[source_row]
|
||||||
|
orientation = quaternions[source_row]
|
||||||
|
points_sensor = map_points_to_lidar(
|
||||||
|
cloud,
|
||||||
|
position_map_xyz=(
|
||||||
|
float(position[0]),
|
||||||
|
float(position[1]),
|
||||||
|
float(position[2]),
|
||||||
|
),
|
||||||
|
orientation_map_from_lidar_xyzw=(
|
||||||
|
float(orientation[0]),
|
||||||
|
float(orientation[1]),
|
||||||
|
float(orientation[2]),
|
||||||
|
float(orientation[3]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except CalibratedProjectionError as exc:
|
||||||
|
raise LidarGroundError("LiDAR field-review pose conversion failed") from exc
|
||||||
|
candidate_input = np.zeros((cloud.shape[0], 4), dtype=np.float32)
|
||||||
|
candidate_input[:, :3] = points_sensor.astype(np.float32)
|
||||||
|
if profile.patchwork_map_vertical_origin_offset_m:
|
||||||
|
candidate_input[:, 2] -= profile.patchwork_map_vertical_origin_offset_m
|
||||||
|
candidate_result = patchwork.segment(candidate_input)
|
||||||
|
_segmentation(
|
||||||
|
current_result.ground_mask,
|
||||||
|
current_result.assigned_mask,
|
||||||
|
cloud.shape[0],
|
||||||
|
"current",
|
||||||
|
)
|
||||||
|
_segmentation(
|
||||||
|
candidate_result.ground_mask,
|
||||||
|
candidate_result.assigned_mask,
|
||||||
|
cloud.shape[0],
|
||||||
|
"candidate",
|
||||||
|
)
|
||||||
|
collected_points.append(cloud)
|
||||||
|
collected_current.append(current_result.ground_mask.astype(np.uint8))
|
||||||
|
collected_current_assigned.append(current_result.assigned_mask.astype(np.uint8))
|
||||||
|
collected_candidate.append(candidate_result.ground_mask.astype(np.uint8))
|
||||||
|
collected_candidate_assigned.append(candidate_result.assigned_mask.astype(np.uint8))
|
||||||
|
current_latency.append(current_result.latency_ms)
|
||||||
|
candidate_latency.append(candidate_result.latency_ms)
|
||||||
|
current_fraction.append(float(np.mean(current_result.ground_mask)))
|
||||||
|
candidate_fraction.append(float(np.mean(candidate_result.ground_mask)))
|
||||||
|
disagreement_fraction.append(
|
||||||
|
float(np.mean(current_result.ground_mask != candidate_result.ground_mask))
|
||||||
|
)
|
||||||
|
intersection = int(
|
||||||
|
np.count_nonzero(current_result.ground_mask & candidate_result.ground_mask)
|
||||||
|
)
|
||||||
|
union = int(np.count_nonzero(current_result.ground_mask | candidate_result.ground_mask))
|
||||||
|
algorithm_iou.append(float(intersection / union) if union else 1.0)
|
||||||
|
|
||||||
|
combined_points = np.concatenate(collected_points)
|
||||||
|
combined_current = np.concatenate(collected_current)
|
||||||
|
combined_current_assigned = np.concatenate(collected_current_assigned)
|
||||||
|
combined_candidate = np.concatenate(collected_candidate)
|
||||||
|
combined_candidate_assigned = np.concatenate(collected_candidate_assigned)
|
||||||
|
selected_source_point_count += source_point_count
|
||||||
|
selected = _uniform_indices(combined_points.shape[0], maximum_display_points)
|
||||||
|
displayed = {
|
||||||
|
"points_xyz_map": combined_points[selected].astype("<f4"),
|
||||||
|
"current_ground": combined_current[selected].astype("u1"),
|
||||||
|
"current_assigned": combined_current_assigned[selected].astype("u1"),
|
||||||
|
"candidate_ground": combined_candidate[selected].astype("u1"),
|
||||||
|
"candidate_assigned": combined_candidate_assigned[selected].astype("u1"),
|
||||||
|
}
|
||||||
|
window_arrays.append(displayed)
|
||||||
|
preview_row = int(
|
||||||
|
np.argmin(
|
||||||
|
np.abs(source_frame_indices.astype(np.int64) - spec.preview_source_frame_index)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
window_reports.append(
|
||||||
|
{
|
||||||
|
"index": window_index,
|
||||||
|
"key": spec.key,
|
||||||
|
"label": spec.label,
|
||||||
|
"start_seconds": spec.start_seconds,
|
||||||
|
"end_seconds": spec.end_seconds,
|
||||||
|
"midpoint_seconds": (spec.start_seconds + spec.end_seconds) / 2,
|
||||||
|
"source_lidar_samples": int(source_rows.shape[0]),
|
||||||
|
"source_point_count": source_point_count,
|
||||||
|
"display_point_count": int(selected.shape[0]),
|
||||||
|
"source_frame_start": int(source_frame_indices[source_rows[0]]),
|
||||||
|
"source_frame_end": int(source_frame_indices[source_rows[-1]]),
|
||||||
|
"preview_source_frame_index": int(source_frame_indices[preview_row]),
|
||||||
|
"preview_session_seconds": float(times[preview_row]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
offsets = np.concatenate(
|
||||||
|
(
|
||||||
|
np.asarray([0], dtype="<i8"),
|
||||||
|
np.cumsum(
|
||||||
|
[item["points_xyz_map"].shape[0] for item in window_arrays],
|
||||||
|
dtype=np.int64,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).astype("<i8")
|
||||||
|
arrays: dict[str, npt.NDArray[Any]] = {
|
||||||
|
"window_offsets": offsets,
|
||||||
|
"points_xyz_map": np.concatenate([item["points_xyz_map"] for item in window_arrays]).astype(
|
||||||
|
"<f4"
|
||||||
|
),
|
||||||
|
"current_ground": np.concatenate([item["current_ground"] for item in window_arrays]).astype(
|
||||||
|
"u1"
|
||||||
|
),
|
||||||
|
"current_assigned": np.concatenate(
|
||||||
|
[item["current_assigned"] for item in window_arrays]
|
||||||
|
).astype("u1"),
|
||||||
|
"candidate_ground": np.concatenate(
|
||||||
|
[item["candidate_ground"] for item in window_arrays]
|
||||||
|
).astype("u1"),
|
||||||
|
"candidate_assigned": np.concatenate(
|
||||||
|
[item["candidate_assigned"] for item in window_arrays]
|
||||||
|
).astype("u1"),
|
||||||
|
}
|
||||||
|
logical_content_sha256 = _logical_sha256(arrays)
|
||||||
|
identity = {
|
||||||
|
"schema_version": LIDAR_FIELD_REVIEW_SCHEMA,
|
||||||
|
"source_pack_id": source.pack_id,
|
||||||
|
"source_pack_identity_sha256": source.manifest["identity_sha256"],
|
||||||
|
"source_artifact_sha256": source.manifest["artifact"]["sha256"],
|
||||||
|
"session_id": source.identity["session_id"],
|
||||||
|
"display_name": display_name,
|
||||||
|
"windows": [window.to_dict() for window in windows],
|
||||||
|
"window_count": len(windows),
|
||||||
|
"default_window_index": default_window_index,
|
||||||
|
"source_point_count": selected_source_point_count,
|
||||||
|
"display_point_count": int(arrays["points_xyz_map"].shape[0]),
|
||||||
|
"sampling": {
|
||||||
|
"method": "uniform-point-index-per-window",
|
||||||
|
"maximum_points_per_window": maximum_display_points,
|
||||||
|
},
|
||||||
|
"profile": profile.to_dict(),
|
||||||
|
"providers": {
|
||||||
|
"current": dict(current.identity),
|
||||||
|
"candidate": dict(patchwork.identity),
|
||||||
|
},
|
||||||
|
"logical_content_sha256": logical_content_sha256,
|
||||||
|
"producer_sha256": _sha256(Path(__file__).resolve(strict=True)),
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": {
|
||||||
|
"commands_enabled": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||||
|
review_id = f"lidar-field-review-{identity_sha256}"
|
||||||
|
output_parent = output_root.expanduser().resolve()
|
||||||
|
output_parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
output = output_parent / review_id
|
||||||
|
if output.exists():
|
||||||
|
existing = LidarFieldReviewV1(output)
|
||||||
|
existing.close()
|
||||||
|
return output
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"schema_version": LIDAR_FIELD_REVIEW_REPORT_SCHEMA,
|
||||||
|
"review_id": review_id,
|
||||||
|
"display_name": display_name,
|
||||||
|
"session_id": source.identity["session_id"],
|
||||||
|
"source_pack_id": source.pack_id,
|
||||||
|
"status": "diagnostic-only",
|
||||||
|
"source": {
|
||||||
|
"timeline_start_seconds": source.identity["timeline_start_seconds"],
|
||||||
|
"timeline_end_seconds": source.identity["timeline_end_seconds"],
|
||||||
|
"available_lidar_frames": source.identity["available_lidar_frames"],
|
||||||
|
"point_count": source.identity["point_count"],
|
||||||
|
"representation": "legacy-e10-vendor-map-with-pose",
|
||||||
|
"intensity_available": False,
|
||||||
|
"raw_scan_accepted": False,
|
||||||
|
},
|
||||||
|
"selection": {
|
||||||
|
"purpose": "operator-readable-central-urban-field-review",
|
||||||
|
"default_window_index": default_window_index,
|
||||||
|
"accumulation": "per-source-frame masks accumulated in map frame",
|
||||||
|
"sampling": identity["sampling"],
|
||||||
|
},
|
||||||
|
"windows": window_reports,
|
||||||
|
"metrics": {
|
||||||
|
"source_samples": len(current_latency),
|
||||||
|
"current": {
|
||||||
|
"provider": dict(current.identity),
|
||||||
|
"ground_fraction": _distribution(current_fraction),
|
||||||
|
"latency_ms": _distribution(current_latency),
|
||||||
|
},
|
||||||
|
"candidate": {
|
||||||
|
"provider": dict(patchwork.identity),
|
||||||
|
"ground_fraction": _distribution(candidate_fraction),
|
||||||
|
"latency_ms": _distribution(candidate_latency),
|
||||||
|
},
|
||||||
|
"comparison": {
|
||||||
|
"algorithm_to_algorithm_ground_iou": _distribution(algorithm_iou),
|
||||||
|
"ground_disagreement_fraction": _distribution(disagreement_fraction),
|
||||||
|
"is_accuracy_metric": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"decision": {
|
||||||
|
"status": "visual-review-only",
|
||||||
|
"production_promotion": False,
|
||||||
|
"reasons": [
|
||||||
|
"legacy E10 derivative does not retain intensity",
|
||||||
|
"source remains a vendor-mapped LIO product",
|
||||||
|
"independent ground labels are missing",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": identity["authority"],
|
||||||
|
}
|
||||||
|
staging = output_parent / f".{review_id}.{os.getpid()}.incomplete"
|
||||||
|
staging.mkdir(mode=0o700, exist_ok=False)
|
||||||
|
try:
|
||||||
|
arrays_path = staging / FIELD_REVIEW_ARRAYS_NAME
|
||||||
|
np.savez_compressed(arrays_path, **arrays) # type: ignore[arg-type]
|
||||||
|
report_path = staging / FIELD_REVIEW_REPORT_NAME
|
||||||
|
_write_json(report_path, report)
|
||||||
|
artifacts = [
|
||||||
|
_artifact("field-review", arrays_path, "application/x-npz"),
|
||||||
|
_artifact("field-review-report", report_path, "application/json"),
|
||||||
|
]
|
||||||
|
for spec in windows:
|
||||||
|
preview_source = preview_paths[spec.key].expanduser().resolve(strict=True)
|
||||||
|
if not preview_source.is_file() or preview_source.is_symlink():
|
||||||
|
raise LidarGroundError("LiDAR field-review preview is invalid")
|
||||||
|
preview_target = staging / f"preview-{spec.key}.jpg"
|
||||||
|
shutil.copy2(preview_source, preview_target)
|
||||||
|
artifacts.append(
|
||||||
|
_artifact(
|
||||||
|
f"preview-{spec.key}",
|
||||||
|
preview_target,
|
||||||
|
"image/jpeg",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
manifest = {
|
||||||
|
"schema_version": LIDAR_FIELD_REVIEW_SCHEMA,
|
||||||
|
"review_id": review_id,
|
||||||
|
"identity_sha256": identity_sha256,
|
||||||
|
"identity": identity,
|
||||||
|
"created_at_utc": _utc_now(),
|
||||||
|
"classification": "private-derived-lidar-diagnostic",
|
||||||
|
"ground_truth": False,
|
||||||
|
"artifacts": artifacts,
|
||||||
|
}
|
||||||
|
_write_json(staging / FIELD_REVIEW_MANIFEST_NAME, manifest)
|
||||||
|
os.replace(staging, output)
|
||||||
|
except BaseException:
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
validation = LidarFieldReviewV1(output)
|
||||||
|
validation.close()
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def lidar_field_review_catalog_item(review: LidarFieldReviewV1) -> dict[str, object]:
|
||||||
|
report = review.report
|
||||||
|
metrics = _object(report["metrics"], "field-review metrics")
|
||||||
|
return {
|
||||||
|
"review_id": review.review_id,
|
||||||
|
"display_name": report["display_name"],
|
||||||
|
"session_id": report["session_id"],
|
||||||
|
"source_pack_id": report["source_pack_id"],
|
||||||
|
"status": report["status"],
|
||||||
|
"source": report["source"],
|
||||||
|
"selection": report["selection"],
|
||||||
|
"windows": report["windows"],
|
||||||
|
"metrics": metrics,
|
||||||
|
"decision": report["decision"],
|
||||||
|
"created_at_utc": review.manifest.get("created_at_utc"),
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": report["authority"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _uniform_indices(count: int, maximum: int) -> npt.NDArray[np.int64]:
|
||||||
|
if count <= maximum:
|
||||||
|
return np.arange(count, dtype=np.int64)
|
||||||
|
return np.linspace(0, count - 1, maximum, dtype=np.int64)
|
||||||
|
|
||||||
|
|
||||||
|
def _segmentation(
|
||||||
|
ground_mask: npt.NDArray[np.bool_],
|
||||||
|
assigned_mask: npt.NDArray[np.bool_],
|
||||||
|
point_count: int,
|
||||||
|
label: str,
|
||||||
|
) -> None:
|
||||||
|
if (
|
||||||
|
ground_mask.shape != (point_count,)
|
||||||
|
or ground_mask.dtype != np.dtype("?")
|
||||||
|
or assigned_mask.shape != (point_count,)
|
||||||
|
or assigned_mask.dtype != np.dtype("?")
|
||||||
|
or np.any(ground_mask & ~assigned_mask)
|
||||||
|
):
|
||||||
|
raise LidarGroundError(f"LiDAR field-review {label} mask is invalid")
|
||||||
|
|
||||||
|
|
||||||
|
def _distribution(values: Sequence[float]) -> dict[str, float | int]:
|
||||||
|
array = np.asarray(values, dtype=np.float64)
|
||||||
|
if array.size == 0 or not np.isfinite(array).all():
|
||||||
|
raise LidarGroundError("LiDAR field-review distribution is invalid")
|
||||||
|
return {
|
||||||
|
"sample_count": int(array.shape[0]),
|
||||||
|
"minimum": float(np.min(array)),
|
||||||
|
"mean": float(np.mean(array)),
|
||||||
|
"p50": float(np.percentile(array, 50)),
|
||||||
|
"p95": float(np.percentile(array, 95)),
|
||||||
|
"maximum": float(np.max(array)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _logical_sha256(arrays: Mapping[str, npt.NDArray[Any]]) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
for name in sorted(arrays):
|
||||||
|
array = np.ascontiguousarray(arrays[name])
|
||||||
|
digest.update(name.encode())
|
||||||
|
digest.update(array.dtype.str.encode())
|
||||||
|
digest.update(_canonical_json(list(array.shape)))
|
||||||
|
digest.update(memoryview(array).cast("B"))
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_artifacts(root: Path, value: object) -> dict[str, Path]:
|
||||||
|
artifacts = _list(value, "field-review artifacts")
|
||||||
|
resolved: dict[str, Path] = {}
|
||||||
|
for value in artifacts:
|
||||||
|
item = _object(value, "field-review artifact")
|
||||||
|
role = item.get("role")
|
||||||
|
relative = item.get("path")
|
||||||
|
if (
|
||||||
|
not isinstance(role, str)
|
||||||
|
or role in resolved
|
||||||
|
or not isinstance(relative, str)
|
||||||
|
or Path(relative).name != relative
|
||||||
|
or not isinstance(item.get("byte_length"), int)
|
||||||
|
or isinstance(item.get("byte_length"), bool)
|
||||||
|
or item["byte_length"] < 1
|
||||||
|
or _SHA256.fullmatch(str(item.get("sha256"))) is None
|
||||||
|
):
|
||||||
|
raise LidarGroundError("LiDAR field-review artifact descriptor is invalid")
|
||||||
|
path = root / relative
|
||||||
|
if (
|
||||||
|
path.is_symlink()
|
||||||
|
or not path.is_file()
|
||||||
|
or path.stat().st_size != item["byte_length"]
|
||||||
|
or _sha256(path) != item["sha256"]
|
||||||
|
):
|
||||||
|
raise LidarGroundError("LiDAR field-review artifact is invalid")
|
||||||
|
resolved[role] = path
|
||||||
|
if "field-review" not in resolved or "field-review-report" not in resolved:
|
||||||
|
raise LidarGroundError("LiDAR field-review artifacts are incomplete")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact(role: str, path: Path, media_type: str) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"role": role,
|
||||||
|
"path": path.name,
|
||||||
|
"media_type": media_type,
|
||||||
|
"byte_length": path.stat().st_size,
|
||||||
|
"sha256": _sha256(path),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path) -> dict[str, Any]:
|
||||||
|
if path.is_symlink() or not path.is_file() or path.stat().st_size > 4_000_000:
|
||||||
|
raise LidarGroundError("LiDAR field-review JSON artifact is invalid")
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise LidarGroundError("LiDAR field-review JSON artifact is unreadable") from exc
|
||||||
|
return _object(value, "LiDAR field-review JSON")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json(path: Path, value: Mapping[str, object]) -> None:
|
||||||
|
path.write_bytes(
|
||||||
|
json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
indent=2,
|
||||||
|
allow_nan=False,
|
||||||
|
).encode()
|
||||||
|
+ b"\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _object(value: object, label: str) -> dict[str, Any]:
|
||||||
|
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
||||||
|
raise LidarGroundError(f"{label} must be an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _list(value: object, label: str) -> list[Any]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise LidarGroundError(f"{label} must be a list")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _nonnegative_int(value: object, label: str) -> int:
|
||||||
|
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||||
|
raise LidarGroundError(f"{label} must be a non-negative integer")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
while chunk := stream.read(1024 * 1024):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> str:
|
||||||
|
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||||
|
|
@ -6,13 +6,15 @@ from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Final
|
from typing import Any, Final
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query, Response
|
||||||
|
|
||||||
from k1link.compute import (
|
from k1link.compute import (
|
||||||
|
LidarFieldReviewV1,
|
||||||
LidarGroundBenchmarkV1,
|
LidarGroundBenchmarkV1,
|
||||||
LidarGroundError,
|
LidarGroundError,
|
||||||
LidarReplayError,
|
LidarReplayError,
|
||||||
LidarReplayPackV2,
|
LidarReplayPackV2,
|
||||||
|
lidar_field_review_catalog_item,
|
||||||
lidar_ground_benchmark_catalog_item,
|
lidar_ground_benchmark_catalog_item,
|
||||||
lidar_ground_frame_detail,
|
lidar_ground_frame_detail,
|
||||||
lidar_pack_catalog_item,
|
lidar_pack_catalog_item,
|
||||||
|
|
@ -21,8 +23,10 @@ from k1link.compute import (
|
||||||
|
|
||||||
LIDAR_CATALOG_SCHEMA: Final = "missioncore.lidar-replay-pack-catalog/v1"
|
LIDAR_CATALOG_SCHEMA: Final = "missioncore.lidar-replay-pack-catalog/v1"
|
||||||
LIDAR_GROUND_CATALOG_SCHEMA: Final = "missioncore.lidar-ground-benchmark-catalog/v1"
|
LIDAR_GROUND_CATALOG_SCHEMA: Final = "missioncore.lidar-ground-benchmark-catalog/v1"
|
||||||
|
LIDAR_FIELD_REVIEW_CATALOG_SCHEMA: Final = "missioncore.lidar-field-review-catalog/v1"
|
||||||
_PACK_ID = re.compile(r"^lidar-replay-pack-[a-f0-9]{64}$")
|
_PACK_ID = re.compile(r"^lidar-replay-pack-[a-f0-9]{64}$")
|
||||||
_BENCHMARK_ID = re.compile(r"^ground-benchmark-[a-f0-9]{64}$")
|
_BENCHMARK_ID = re.compile(r"^ground-benchmark-[a-f0-9]{64}$")
|
||||||
|
_FIELD_REVIEW_ID = re.compile(r"^lidar-field-review-[a-f0-9]{64}$")
|
||||||
RootProvider = Callable[[], Path | None]
|
RootProvider = Callable[[], Path | None]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -36,10 +40,16 @@ def configured_lidar_ground_root() -> Path | None:
|
||||||
return Path(value).expanduser().absolute() if value else None
|
return Path(value).expanduser().absolute() if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def configured_lidar_field_review_root() -> Path | None:
|
||||||
|
value = os.environ.get("MISSIONCORE_LIDAR_FIELD_REVIEW_ROOT", "").strip()
|
||||||
|
return Path(value).expanduser().absolute() if value else None
|
||||||
|
|
||||||
|
|
||||||
def build_lidar_router(
|
def build_lidar_router(
|
||||||
*,
|
*,
|
||||||
root_provider: RootProvider = configured_lidar_replay_root,
|
root_provider: RootProvider = configured_lidar_replay_root,
|
||||||
ground_root_provider: RootProvider = configured_lidar_ground_root,
|
ground_root_provider: RootProvider = configured_lidar_ground_root,
|
||||||
|
field_review_root_provider: RootProvider = configured_lidar_field_review_root,
|
||||||
) -> APIRouter:
|
) -> APIRouter:
|
||||||
router = APIRouter(prefix="/api/v1/lidar", tags=["lidar"])
|
router = APIRouter(prefix="/api/v1/lidar", tags=["lidar"])
|
||||||
|
|
||||||
|
|
@ -269,4 +279,139 @@ def build_lidar_router(
|
||||||
detail="LiDAR ground frame не прошёл проверку целостности",
|
detail="LiDAR ground frame не прошёл проверку целостности",
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
@router.get("/field-reviews")
|
||||||
|
def list_lidar_field_reviews(
|
||||||
|
limit: int = Query(default=10, ge=1, le=50),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
root = field_review_root_provider()
|
||||||
|
if root is None or not root.is_dir():
|
||||||
|
return {
|
||||||
|
"schema_version": LIDAR_FIELD_REVIEW_CATALOG_SCHEMA,
|
||||||
|
"configured": root is not None,
|
||||||
|
"items": [],
|
||||||
|
"valid_total": 0,
|
||||||
|
"invalid_total": 0,
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
items: list[dict[str, object]] = []
|
||||||
|
invalid_total = 0
|
||||||
|
candidates = sorted(
|
||||||
|
(
|
||||||
|
candidate
|
||||||
|
for candidate in root.iterdir()
|
||||||
|
if candidate.is_dir() and _FIELD_REVIEW_ID.fullmatch(candidate.name) is not None
|
||||||
|
),
|
||||||
|
key=lambda candidate: candidate.stat().st_mtime_ns,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
for candidate in candidates:
|
||||||
|
try:
|
||||||
|
review = LidarFieldReviewV1(candidate)
|
||||||
|
try:
|
||||||
|
items.append(lidar_field_review_catalog_item(review))
|
||||||
|
finally:
|
||||||
|
review.close()
|
||||||
|
except (LidarGroundError, OSError):
|
||||||
|
invalid_total += 1
|
||||||
|
return {
|
||||||
|
"schema_version": LIDAR_FIELD_REVIEW_CATALOG_SCHEMA,
|
||||||
|
"configured": True,
|
||||||
|
"items": items[:limit],
|
||||||
|
"valid_total": len(items),
|
||||||
|
"invalid_total": invalid_total,
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/field-reviews/{review_id}/windows/{window_index}")
|
||||||
|
def get_lidar_field_review_window(
|
||||||
|
review_id: str,
|
||||||
|
window_index: int,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if _FIELD_REVIEW_ID.fullmatch(review_id) is None or window_index < 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="LiDAR field-review window не найден",
|
||||||
|
)
|
||||||
|
root = field_review_root_provider()
|
||||||
|
if root is None or not root.is_dir():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="LiDAR field-review storage не настроен",
|
||||||
|
)
|
||||||
|
candidate = root / review_id
|
||||||
|
if not candidate.is_dir():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="LiDAR field review не найден",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
review = LidarFieldReviewV1(candidate)
|
||||||
|
try:
|
||||||
|
return review.window_detail(window_index)
|
||||||
|
finally:
|
||||||
|
review.close()
|
||||||
|
except IndexError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="LiDAR field-review window не найден",
|
||||||
|
) from exc
|
||||||
|
except (LidarGroundError, OSError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="LiDAR field review не прошёл проверку целостности",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
@router.get("/field-reviews/{review_id}/windows/{window_index}/preview")
|
||||||
|
def get_lidar_field_review_preview(
|
||||||
|
review_id: str,
|
||||||
|
window_index: int,
|
||||||
|
) -> Response:
|
||||||
|
if _FIELD_REVIEW_ID.fullmatch(review_id) is None or window_index < 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="LiDAR field-review preview не найден",
|
||||||
|
)
|
||||||
|
root = field_review_root_provider()
|
||||||
|
if root is None or not root.is_dir():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="LiDAR field-review storage не настроен",
|
||||||
|
)
|
||||||
|
candidate = root / review_id
|
||||||
|
if not candidate.is_dir():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="LiDAR field review не найден",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
review = LidarFieldReviewV1(candidate)
|
||||||
|
try:
|
||||||
|
windows = review.report.get("windows")
|
||||||
|
if not isinstance(windows, list) or not 0 <= window_index < len(windows):
|
||||||
|
raise IndexError(window_index)
|
||||||
|
window = windows[window_index]
|
||||||
|
if not isinstance(window, dict) or not isinstance(window.get("key"), str):
|
||||||
|
raise LidarGroundError("LiDAR field-review preview key is invalid")
|
||||||
|
preview = review.preview_paths.get(window["key"])
|
||||||
|
if preview is None:
|
||||||
|
raise LidarGroundError("LiDAR field-review preview is missing")
|
||||||
|
content = preview.read_bytes()
|
||||||
|
finally:
|
||||||
|
review.close()
|
||||||
|
except IndexError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="LiDAR field-review preview не найден",
|
||||||
|
) from exc
|
||||||
|
except (LidarGroundError, OSError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="LiDAR field-review preview не прошёл проверку",
|
||||||
|
) from exc
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type="image/jpeg",
|
||||||
|
headers={"Cache-Control": "private, max-age=31536000, immutable"},
|
||||||
|
)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,238 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
|
||||||
|
from k1link.compute import (
|
||||||
|
E10_LIDAR_PACK_SCHEMA,
|
||||||
|
E10LidarFieldSource,
|
||||||
|
FieldReviewWindowSpec,
|
||||||
|
GroundBenchmarkProfile,
|
||||||
|
GroundSegmentation,
|
||||||
|
LidarFieldReviewV1,
|
||||||
|
LidarGroundError,
|
||||||
|
build_lidar_field_review,
|
||||||
|
)
|
||||||
|
from k1link.web.lidar_api import build_lidar_router
|
||||||
|
|
||||||
|
|
||||||
|
class _Candidate:
|
||||||
|
@property
|
||||||
|
def identity(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"provider_id": "test-patchwork/v1",
|
||||||
|
"source_commit": "c" * 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
def segment(self, xyzi: np.ndarray) -> GroundSegmentation:
|
||||||
|
ground = xyzi[:, 0] <= 0.25
|
||||||
|
assigned = np.ones(xyzi.shape[0], dtype=np.bool_)
|
||||||
|
return GroundSegmentation(ground, assigned, 0.25)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _source_pack(root: Path) -> Path:
|
||||||
|
base_points = np.asarray(
|
||||||
|
[
|
||||||
|
[-1.0, -1.0, 0.00],
|
||||||
|
[-0.5, -1.0, 0.02],
|
||||||
|
[0.0, -1.0, 0.01],
|
||||||
|
[0.5, -1.0, 0.03],
|
||||||
|
[1.0, -1.0, 0.00],
|
||||||
|
[-1.0, 0.0, 0.01],
|
||||||
|
[-0.5, 0.0, 0.02],
|
||||||
|
[0.0, 0.0, 0.04],
|
||||||
|
[0.5, 0.0, 0.35],
|
||||||
|
[1.0, 0.0, 0.70],
|
||||||
|
],
|
||||||
|
dtype=np.float32,
|
||||||
|
)
|
||||||
|
points = np.concatenate(
|
||||||
|
[base_points + np.asarray([index * 0.1, index, 0], dtype=np.float32) for index in range(4)]
|
||||||
|
).astype("<f4")
|
||||||
|
arrays = {
|
||||||
|
"frame_indices": np.arange(4, dtype="<i8"),
|
||||||
|
"source_frame_indices": np.asarray([100, 110, 120, 130], dtype="<i8"),
|
||||||
|
"session_seconds": np.asarray([1.0, 2.0, 3.0, 4.0], dtype="<f8"),
|
||||||
|
"sample_available": np.ones(4, dtype="?"),
|
||||||
|
"cloud_offsets": np.asarray([0, 10, 20, 30, 40], dtype="<i8"),
|
||||||
|
"cloud_points_map": points,
|
||||||
|
"pose_positions_map": np.zeros((4, 3), dtype="<f8"),
|
||||||
|
"pose_quaternions_map_from_lidar": np.tile(
|
||||||
|
np.asarray([0.0, 0.0, 0.0, 1.0], dtype="<f8"),
|
||||||
|
(4, 1),
|
||||||
|
),
|
||||||
|
"lidar_camera_delta_ms": np.zeros(4, dtype="<f8"),
|
||||||
|
"pose_point_delta_ms": np.zeros(4, dtype="<f8"),
|
||||||
|
"intrinsic_fx_fy_cx_cy": np.asarray(
|
||||||
|
[100.0, 100.0, 50.0, 50.0],
|
||||||
|
dtype="<f8",
|
||||||
|
),
|
||||||
|
"distortion_kb4": np.zeros(4, dtype="<f8"),
|
||||||
|
"t_camera_from_lidar": np.eye(4, dtype="<f8"),
|
||||||
|
}
|
||||||
|
identity = {
|
||||||
|
"schema_version": E10_LIDAR_PACK_SCHEMA,
|
||||||
|
"session_id": "synthetic-ravnoves00",
|
||||||
|
"frame_count": 4,
|
||||||
|
"available_lidar_frames": 4,
|
||||||
|
"point_count": 40,
|
||||||
|
"timeline_start_seconds": 1.0,
|
||||||
|
"timeline_end_seconds": 4.0,
|
||||||
|
}
|
||||||
|
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||||
|
pack = root / f"e10-lidar-pack-{identity_sha256}"
|
||||||
|
pack.mkdir(parents=True)
|
||||||
|
arrays_path = pack / "lidar-pack.npz"
|
||||||
|
np.savez_compressed(arrays_path, **arrays) # type: ignore[arg-type]
|
||||||
|
manifest = {
|
||||||
|
"schema_version": E10_LIDAR_PACK_SCHEMA,
|
||||||
|
"pack_id": pack.name,
|
||||||
|
"identity_sha256": identity_sha256,
|
||||||
|
"identity": identity,
|
||||||
|
"artifact": {
|
||||||
|
"path": arrays_path.name,
|
||||||
|
"media_type": "application/x-npz",
|
||||||
|
"byte_length": arrays_path.stat().st_size,
|
||||||
|
"sha256": _sha256(arrays_path),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(pack / "manifest.json").write_bytes(json.dumps(manifest, sort_keys=True).encode())
|
||||||
|
return pack
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint(router: APIRouter, path: str) -> object:
|
||||||
|
for route in router.routes:
|
||||||
|
if (
|
||||||
|
isinstance(route, APIRoute)
|
||||||
|
and route.path == path
|
||||||
|
and route.methods is not None
|
||||||
|
and "GET" in route.methods
|
||||||
|
):
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError(f"GET {path} route is missing")
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_review_is_accumulated_path_free_visual_evidence(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
source = E10LidarFieldSource(_source_pack(tmp_path / "source"))
|
||||||
|
windows = (
|
||||||
|
FieldReviewWindowSpec("street-a", "Улица A", 0.5, 2.1, 100),
|
||||||
|
FieldReviewWindowSpec("street-b", "Улица B", 2.5, 4.1, 120),
|
||||||
|
)
|
||||||
|
previews = {}
|
||||||
|
for window in windows:
|
||||||
|
preview = tmp_path / f"{window.key}.jpg"
|
||||||
|
preview.write_bytes(b"\xff\xd8synthetic-jpeg\xff\xd9")
|
||||||
|
previews[window.key] = preview
|
||||||
|
profile = GroundBenchmarkProfile(
|
||||||
|
profile_id="synthetic-ravnoves00-field-review/v1",
|
||||||
|
patchwork_sensor_height_proxy_m=1.27,
|
||||||
|
patchwork_map_vertical_origin_offset_m=1.27,
|
||||||
|
patchwork_height_evidence="operator-estimated",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
output = build_lidar_field_review(
|
||||||
|
source,
|
||||||
|
tmp_path / "reviews",
|
||||||
|
patchwork=_Candidate(),
|
||||||
|
profile=profile,
|
||||||
|
preview_paths=previews,
|
||||||
|
windows=windows,
|
||||||
|
default_window_index=1,
|
||||||
|
maximum_display_points=12,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
source.close()
|
||||||
|
|
||||||
|
review = LidarFieldReviewV1(output)
|
||||||
|
try:
|
||||||
|
detail = review.window_detail(1)
|
||||||
|
assert review.report["status"] == "diagnostic-only"
|
||||||
|
assert review.report["source"]["intensity_available"] is False
|
||||||
|
assert review.report["selection"]["default_window_index"] == 1
|
||||||
|
assert len(review.report["windows"]) == 2
|
||||||
|
assert review.report["windows"][0]["source_lidar_samples"] == 2
|
||||||
|
assert detail["point_count"] == 12
|
||||||
|
intensity = detail["intensity"]
|
||||||
|
authority = detail["authority"]
|
||||||
|
assert isinstance(intensity, dict)
|
||||||
|
assert isinstance(authority, dict)
|
||||||
|
assert intensity["available"] is False
|
||||||
|
assert detail["ground_truth"] is False
|
||||||
|
assert authority["commands_enabled"] is False
|
||||||
|
assert str(tmp_path) not in repr(detail)
|
||||||
|
finally:
|
||||||
|
review.close()
|
||||||
|
|
||||||
|
router = build_lidar_router(
|
||||||
|
root_provider=lambda: None,
|
||||||
|
ground_root_provider=lambda: None,
|
||||||
|
field_review_root_provider=lambda: output.parent,
|
||||||
|
)
|
||||||
|
catalog_route = _endpoint(router, "/api/v1/lidar/field-reviews")
|
||||||
|
window_route = _endpoint(
|
||||||
|
router,
|
||||||
|
"/api/v1/lidar/field-reviews/{review_id}/windows/{window_index}",
|
||||||
|
)
|
||||||
|
preview_route = _endpoint(
|
||||||
|
router,
|
||||||
|
"/api/v1/lidar/field-reviews/{review_id}/windows/{window_index}/preview",
|
||||||
|
)
|
||||||
|
catalog = catalog_route(limit=10) # type: ignore[operator]
|
||||||
|
window = window_route(review_id=output.name, window_index=0) # type: ignore[operator]
|
||||||
|
preview = preview_route(review_id=output.name, window_index=0) # type: ignore[operator]
|
||||||
|
|
||||||
|
assert catalog["valid_total"] == 1
|
||||||
|
assert catalog["items"][0]["display_name"]
|
||||||
|
assert window["preview_url"].endswith("/windows/0/preview")
|
||||||
|
assert preview.media_type == "image/jpeg"
|
||||||
|
assert preview.body.startswith(b"\xff\xd8")
|
||||||
|
assert str(tmp_path) not in repr({"catalog": catalog, "window": window})
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_review_reader_rejects_tampered_identity(tmp_path: Path) -> None:
|
||||||
|
source = E10LidarFieldSource(_source_pack(tmp_path / "source"))
|
||||||
|
window = FieldReviewWindowSpec("street-a", "Улица A", 0.5, 4.1, 100)
|
||||||
|
preview = tmp_path / "street-a.jpg"
|
||||||
|
preview.write_bytes(b"\xff\xd8synthetic-jpeg\xff\xd9")
|
||||||
|
try:
|
||||||
|
output = build_lidar_field_review(
|
||||||
|
source,
|
||||||
|
tmp_path / "reviews",
|
||||||
|
patchwork=_Candidate(),
|
||||||
|
profile=GroundBenchmarkProfile(),
|
||||||
|
preview_paths={window.key: preview},
|
||||||
|
windows=(window,),
|
||||||
|
default_window_index=0,
|
||||||
|
maximum_display_points=12,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
source.close()
|
||||||
|
|
||||||
|
manifest_path = output / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
manifest["identity"]["display_name"] = "tampered"
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
with pytest.raises(LidarGroundError, match="identity"):
|
||||||
|
LidarFieldReviewV1(output)
|
||||||
Loading…
Reference in New Issue