feat(lidar): add RAVNOVES field review
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user