feat(lidar): add RAVNOVES field review
This commit is contained in:
parent
2dfb34ef21
commit
3333e9ac0f
|
|
@ -147,6 +147,106 @@ export interface LidarGroundFrame {
|
|||
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 LidarReplayApiError extends Error {
|
||||
|
|
@ -162,7 +262,10 @@ type LidarFetch = (
|
|||
|
||||
const SAFE_PACK_ID = /^lidar-replay-pack-[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_FIELD_KEY = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
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(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
|
|
@ -739,3 +1194,48 @@ export async function fetchLidarGroundFrame(
|
|||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
|
@ -202,6 +219,21 @@
|
|||
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 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -954,6 +954,218 @@
|
|||
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 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,27 @@ import { useEffect, useRef, useState } from "react";
|
|||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
|
||||
import type { LidarGroundFrame } from "../core/lidar/replayQuality";
|
||||
|
||||
export type LidarGroundViewMode =
|
||||
| "intensity"
|
||||
| "current"
|
||||
| "candidate"
|
||||
| "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 {
|
||||
frame: LidarGroundFrame;
|
||||
frame: LidarGroundPointCloudFrame;
|
||||
mode: LidarGroundViewMode;
|
||||
}
|
||||
|
||||
|
|
@ -28,7 +39,7 @@ function setRgb(
|
|||
}
|
||||
|
||||
function frameColors(
|
||||
frame: LidarGroundFrame,
|
||||
frame: LidarGroundPointCloudFrame,
|
||||
mode: LidarGroundViewMode,
|
||||
): Float32Array {
|
||||
const colors = new Float32Array(frame.pointCount * 3);
|
||||
|
|
@ -38,7 +49,7 @@ function frameColors(
|
|||
const candidate = frame.masks.candidateGround[index] === 1;
|
||||
const candidateAssigned = frame.masks.candidateAssigned[index] === 1;
|
||||
if (mode === "intensity") {
|
||||
const intensity = frame.intensity0To255[index] / 255;
|
||||
const intensity = (frame.intensity0To255?.[index] ?? 96) / 255;
|
||||
setRgb(
|
||||
colors,
|
||||
offset,
|
||||
|
|
@ -88,6 +99,8 @@ export function LidarGroundPointCloud({
|
|||
const materialRef = useRef<THREE.PointsMaterial | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | 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);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -115,7 +128,9 @@ export function LidarGroundPointCloud({
|
|||
host.prepend(renderer.domElement);
|
||||
|
||||
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);
|
||||
camera.position.set(6, 4.5, 6);
|
||||
cameraRef.current = camera;
|
||||
|
|
@ -147,6 +162,7 @@ export function LidarGroundPointCloud({
|
|||
scene.add(new THREE.Points(geometry, material));
|
||||
|
||||
const grid = new THREE.GridHelper(24, 48, 0x3c7cff, 0x233747);
|
||||
gridRef.current = grid;
|
||||
const gridMaterials = Array.isArray(grid.material)
|
||||
? grid.material
|
||||
: [grid.material];
|
||||
|
|
@ -198,6 +214,8 @@ export function LidarGroundPointCloud({
|
|||
materialRef.current = null;
|
||||
cameraRef.current = null;
|
||||
controlsRef.current = null;
|
||||
fogRef.current = null;
|
||||
gridRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
@ -206,7 +224,9 @@ export function LidarGroundPointCloud({
|
|||
const material = materialRef.current;
|
||||
const camera = cameraRef.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);
|
||||
let minimumX = Number.POSITIVE_INFINITY;
|
||||
|
|
@ -235,6 +255,8 @@ export function LidarGroundPointCloud({
|
|||
geometry.computeBoundingSphere();
|
||||
const radius = Math.max(geometry.boundingSphere?.radius ?? 1, 0.2);
|
||||
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 distance = Math.max(radius * 1.8, 1.2);
|
||||
|
|
|
|||
|
|
@ -6,10 +6,14 @@ import {
|
|||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
fetchLidarFieldReviews,
|
||||
fetchLidarFieldReviewWindow,
|
||||
fetchLidarGroundFrame,
|
||||
fetchLidarGroundBenchmarks,
|
||||
fetchLidarReplayCatalog,
|
||||
fetchLidarReplayDetail,
|
||||
type LidarFieldReview,
|
||||
type LidarFieldReviewWindow,
|
||||
type LidarGroundBenchmark,
|
||||
type LidarGroundFrame,
|
||||
type LidarReplayCatalog,
|
||||
|
|
@ -69,6 +73,14 @@ export function LidarQualityWorkspace({
|
|||
const [groundFrameError, setGroundFrameError] = useState<string | null>(null);
|
||||
const [groundViewMode, setGroundViewMode] =
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -146,7 +158,56 @@ export function LidarQualityWorkspace({
|
|||
return () => controller.abort();
|
||||
}, [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 selectedFieldWindow = fieldReview?.windows[fieldWindowIndex] ?? null;
|
||||
|
||||
return (
|
||||
<div className="standard-workspace lidar-quality-workspace">
|
||||
|
|
@ -191,12 +252,213 @@ export function LidarQualityWorkspace({
|
|||
</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">
|
||||
<MetricCard
|
||||
featured
|
||||
eyebrow="ТОЧЕЧНЫХ КАДРОВ"
|
||||
eyebrow="ТЕХНИЧЕСКИХ КАДРОВ"
|
||||
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
|
||||
eyebrow="СРЕДНЕЕ ТОЧЕК"
|
||||
|
|
@ -358,12 +620,13 @@ export function LidarQualityWorkspace({
|
|||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">
|
||||
POINT-ALIGNED REVIEW
|
||||
ТЕХНИЧЕСКИЙ CONTRACT SLICE · VIEWER_LIVE
|
||||
</span>
|
||||
<h3>Покадровое облако и маски</h3>
|
||||
<h3>Indoor-проверка point-aligned контракта</h3>
|
||||
<p>
|
||||
Один и тот же map-frame XYZ, разные диагностические
|
||||
раскраски. Маски не изменяют replay.
|
||||
Этот короткий indoor-срез подтверждает выравнивание XYZ
|
||||
и масок, но не является полевой оценкой качества. Для
|
||||
улицы используйте RAVNOVES00 выше.
|
||||
</p>
|
||||
</div>
|
||||
<div className="lidar-ground-frame-status">
|
||||
|
|
|
|||
|
|
@ -8,10 +8,14 @@ let parseLidarReplayCatalog;
|
|||
let parseLidarReplayDetail;
|
||||
let parseLidarGroundBenchmarkCatalog;
|
||||
let parseLidarGroundFrame;
|
||||
let parseLidarFieldReviewCatalog;
|
||||
let parseLidarFieldReviewWindow;
|
||||
let fetchLidarReplayCatalog;
|
||||
let fetchLidarReplayDetail;
|
||||
let fetchLidarGroundBenchmarks;
|
||||
let fetchLidarGroundFrame;
|
||||
let fetchLidarFieldReviews;
|
||||
let fetchLidarFieldReviewWindow;
|
||||
let LidarReplayContractError;
|
||||
let workspaceById;
|
||||
|
||||
|
|
@ -26,10 +30,14 @@ before(async () => {
|
|||
parseLidarReplayDetail,
|
||||
parseLidarGroundBenchmarkCatalog,
|
||||
parseLidarGroundFrame,
|
||||
parseLidarFieldReviewCatalog,
|
||||
parseLidarFieldReviewWindow,
|
||||
fetchLidarReplayCatalog,
|
||||
fetchLidarReplayDetail,
|
||||
fetchLidarGroundBenchmarks,
|
||||
fetchLidarGroundFrame,
|
||||
fetchLidarFieldReviews,
|
||||
fetchLidarFieldReviewWindow,
|
||||
LidarReplayContractError,
|
||||
} = await server.ssrLoadModule("/src/core/lidar/replayQuality.ts"));
|
||||
({ workspaceById } = await server.ssrLoadModule("/src/productModel.ts"));
|
||||
|
|
@ -40,6 +48,8 @@ after(async () => {
|
|||
});
|
||||
|
||||
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 = {}) {
|
||||
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) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
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 () => {
|
||||
const calls = [];
|
||||
const fetcher = async (input, init) => {
|
||||
|
|
@ -368,6 +560,11 @@ test("LiDAR fetchers use read-only endpoints and workspace is registered", async
|
|||
? jsonResponse(groundFrame())
|
||||
: jsonResponse(groundCatalog());
|
||||
}
|
||||
if (String(input).includes("field-reviews")) {
|
||||
return String(input).includes("/windows/")
|
||||
? jsonResponse(fieldReviewWindow())
|
||||
: jsonResponse(fieldReviewCatalog());
|
||||
}
|
||||
return String(input).includes(packId)
|
||||
? jsonResponse(detail())
|
||||
: jsonResponse(catalog());
|
||||
|
|
@ -380,11 +577,19 @@ test("LiDAR fetchers use read-only endpoints and workspace is registered", async
|
|||
0,
|
||||
{ fetcher },
|
||||
);
|
||||
const fieldCatalog = await fetchLidarFieldReviews({ fetcher });
|
||||
const fieldWindow = await fetchLidarFieldReviewWindow(
|
||||
fieldReviewId,
|
||||
0,
|
||||
{ fetcher },
|
||||
);
|
||||
|
||||
assert.equal(parsedCatalog.validTotal, 1);
|
||||
assert.equal(parsedDetail.pack.packId, packId);
|
||||
assert.equal(ground.validTotal, 1);
|
||||
assert.equal(frame.pointCount, 3);
|
||||
assert.equal(fieldCatalog.items[0].windows[0].sourceLidarSamples, 56);
|
||||
assert.equal(fieldWindow.previewUrl.endsWith("/preview"), true);
|
||||
assert.deepEqual(calls, [
|
||||
{ input: "/api/v1/lidar/replay-packs?limit=50", 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`,
|
||||
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").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
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -226,6 +226,8 @@ quality line. It is not repaired or hidden by replay.
|
|||
source point; raw replay remains unchanged.
|
||||
- [x] Add a bounded point-aligned frame API and browser 3D review for
|
||||
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,
|
||||
redacted firmware evidence without changing device state.
|
||||
- [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
|
||||
`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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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 (
|
||||
DEFAULT_GROUND_BENCHMARK_PROFILE,
|
||||
LIDAR_GROUND_ANNOTATION_TEMPLATE_SCHEMA,
|
||||
|
|
@ -179,6 +195,9 @@ __all__ = [
|
|||
"LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA",
|
||||
"LIDAR_GROUND_BENCHMARK_SCHEMA",
|
||||
"LIDAR_GROUND_FRAME_SCHEMA",
|
||||
"LIDAR_FIELD_REVIEW_REPORT_SCHEMA",
|
||||
"LIDAR_FIELD_REVIEW_SCHEMA",
|
||||
"LIDAR_FIELD_REVIEW_WINDOW_SCHEMA",
|
||||
"LIDAR_EVIDENCE_PROFILE_SCHEMA",
|
||||
"LIDAR_EQUIVALENCE_REPORT_SCHEMA",
|
||||
"LIDAR_QUALITY_REPORT_SCHEMA",
|
||||
|
|
@ -262,6 +281,17 @@ __all__ = [
|
|||
"build_lidar_ground_annotation_template",
|
||||
"build_lidar_ground_benchmark",
|
||||
"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",
|
||||
"ObjectDetection",
|
||||
"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 typing import Any, Final
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
|
||||
from k1link.compute import (
|
||||
LidarFieldReviewV1,
|
||||
LidarGroundBenchmarkV1,
|
||||
LidarGroundError,
|
||||
LidarReplayError,
|
||||
LidarReplayPackV2,
|
||||
lidar_field_review_catalog_item,
|
||||
lidar_ground_benchmark_catalog_item,
|
||||
lidar_ground_frame_detail,
|
||||
lidar_pack_catalog_item,
|
||||
|
|
@ -21,8 +23,10 @@ from k1link.compute import (
|
|||
|
||||
LIDAR_CATALOG_SCHEMA: Final = "missioncore.lidar-replay-pack-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}$")
|
||||
_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]
|
||||
|
||||
|
||||
|
|
@ -36,10 +40,16 @@ def configured_lidar_ground_root() -> Path | 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(
|
||||
*,
|
||||
root_provider: RootProvider = configured_lidar_replay_root,
|
||||
ground_root_provider: RootProvider = configured_lidar_ground_root,
|
||||
field_review_root_provider: RootProvider = configured_lidar_field_review_root,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/lidar", tags=["lidar"])
|
||||
|
||||
|
|
@ -269,4 +279,139 @@ def build_lidar_router(
|
|||
detail="LiDAR ground frame не прошёл проверку целостности",
|
||||
) 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
|
||||
|
|
|
|||
|
|
@ -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