refactor(lab): restore canonical RAV004 replay
This commit is contained in:
@@ -182,6 +182,27 @@ function recordedMediaTimeRangesContain(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function recordedMediaTimestampStallRecoveryTarget(
|
||||||
|
currentSeconds: number,
|
||||||
|
bufferedRanges: readonly (readonly [number, number])[],
|
||||||
|
skipSeconds = 0.18,
|
||||||
|
): number | null {
|
||||||
|
if (!Number.isFinite(currentSeconds) || !Number.isFinite(skipSeconds) || skipSeconds <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const [startSeconds, endSeconds] of bufferedRanges) {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(startSeconds)
|
||||||
|
|| !Number.isFinite(endSeconds)
|
||||||
|
|| currentSeconds < startSeconds - 0.05
|
||||||
|
|| currentSeconds > endSeconds
|
||||||
|
) continue;
|
||||||
|
const targetSeconds = Math.min(currentSeconds + skipSeconds, endSeconds - 0.05);
|
||||||
|
return targetSeconds >= currentSeconds + 0.04 ? targetSeconds : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function recordedMediaPresentationState(
|
export function recordedMediaPresentationState(
|
||||||
state: "loading" | "ready" | "error",
|
state: "loading" | "ready" | "error",
|
||||||
readyGeneration: string | null,
|
readyGeneration: string | null,
|
||||||
@@ -783,6 +804,7 @@ export function RecordedFmp4Player({
|
|||||||
onPlayingRejected,
|
onPlayingRejected,
|
||||||
playbackAuthority = "media",
|
playbackAuthority = "media",
|
||||||
playbackTransport = "segmented",
|
playbackTransport = "segmented",
|
||||||
|
recoverTimestampStalls = false,
|
||||||
}: {
|
}: {
|
||||||
source: ObservationSourceDescriptor;
|
source: ObservationSourceDescriptor;
|
||||||
playback?: RecordedObservationPlayback | null;
|
playback?: RecordedObservationPlayback | null;
|
||||||
@@ -797,6 +819,7 @@ export function RecordedFmp4Player({
|
|||||||
onPlayingRejected?: () => void;
|
onPlayingRejected?: () => void;
|
||||||
playbackAuthority?: "media" | "host";
|
playbackAuthority?: "media" | "host";
|
||||||
playbackTransport?: "segmented" | "epoch-stream";
|
playbackTransport?: "segmented" | "epoch-stream";
|
||||||
|
recoverTimestampStalls?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const onAdmissionChangeRef = useRef(onAdmissionChange);
|
const onAdmissionChangeRef = useRef(onAdmissionChange);
|
||||||
@@ -1450,6 +1473,51 @@ export function RecordedFmp4Player({
|
|||||||
visualState,
|
visualState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !recoverTimestampStalls || !playback?.playing || visualState !== "ready") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let lastSeconds = video.currentTime;
|
||||||
|
let lastProgressAtMs = performance.now();
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
if (
|
||||||
|
!playbackPlayingRef.current
|
||||||
|
|| video.paused
|
||||||
|
|| video.ended
|
||||||
|
|| video.seeking
|
||||||
|
|| video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
|
||||||
|
) {
|
||||||
|
lastSeconds = video.currentTime;
|
||||||
|
lastProgressAtMs = performance.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nowMs = performance.now();
|
||||||
|
if (video.currentTime >= lastSeconds + 0.02) {
|
||||||
|
lastSeconds = video.currentTime;
|
||||||
|
lastProgressAtMs = nowMs;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nowMs - lastProgressAtMs < 1_250) return;
|
||||||
|
const bufferedRanges = Array.from(
|
||||||
|
{ length: video.buffered.length },
|
||||||
|
(_, index) => [video.buffered.start(index), video.buffered.end(index)] as const,
|
||||||
|
);
|
||||||
|
const targetSeconds = recordedMediaTimestampStallRecoveryTarget(
|
||||||
|
video.currentTime,
|
||||||
|
bufferedRanges,
|
||||||
|
);
|
||||||
|
lastProgressAtMs = nowMs;
|
||||||
|
if (targetSeconds === null) return;
|
||||||
|
// Field recordings can contain non-monotonic or corrupt H.264 timestamps.
|
||||||
|
// If decoded time is frozen despite proven buffered media ahead, skip only
|
||||||
|
// the broken timestamp interval and return authority to the media clock.
|
||||||
|
video.currentTime = targetSeconds;
|
||||||
|
lastSeconds = targetSeconds;
|
||||||
|
}, 250);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [playback?.playing, recoverTimestampStalls, visualState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video || !segmented || !playback?.playing || visualState !== "ready") return;
|
if (!video || !segmented || !playback?.playing || visualState !== "ready") return;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export function RecordedEvidenceVideoScene({
|
|||||||
onAdmissionChange,
|
onAdmissionChange,
|
||||||
playbackAuthority = "media",
|
playbackAuthority = "media",
|
||||||
playbackTransport = "segmented",
|
playbackTransport = "segmented",
|
||||||
|
recoverTimestampStalls = false,
|
||||||
}: {
|
}: {
|
||||||
source: ObservationSourceDescriptor;
|
source: ObservationSourceDescriptor;
|
||||||
playback: RecordedObservationPlayback;
|
playback: RecordedObservationPlayback;
|
||||||
@@ -56,6 +57,7 @@ export function RecordedEvidenceVideoScene({
|
|||||||
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
|
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
|
||||||
playbackAuthority?: "media" | "host";
|
playbackAuthority?: "media" | "host";
|
||||||
playbackTransport?: "segmented" | "epoch-stream";
|
playbackTransport?: "segmented" | "epoch-stream";
|
||||||
|
recoverTimestampStalls?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const generation = source.delivery?.kind === "recorded-fmp4-manifest"
|
const generation = source.delivery?.kind === "recorded-fmp4-manifest"
|
||||||
? source.delivery.manifestGenerationSha256
|
? source.delivery.manifestGenerationSha256
|
||||||
@@ -63,12 +65,29 @@ export function RecordedEvidenceVideoScene({
|
|||||||
const [admissionPhase, setAdmissionPhase] = useState<RecordedCameraAdmissionState["phase"]>(
|
const [admissionPhase, setAdmissionPhase] = useState<RecordedCameraAdmissionState["phase"]>(
|
||||||
"loading",
|
"loading",
|
||||||
);
|
);
|
||||||
useEffect(() => setAdmissionPhase("loading"), [generation, source.id]);
|
const [presentedSeconds, setPresentedSeconds] = useState<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
setAdmissionPhase("loading");
|
||||||
|
setPresentedSeconds(null);
|
||||||
|
}, [generation, source.id]);
|
||||||
const handleAdmissionChange = (next: RecordedCameraAdmissionState) => {
|
const handleAdmissionChange = (next: RecordedCameraAdmissionState) => {
|
||||||
setAdmissionPhase(next.phase);
|
setAdmissionPhase(next.phase);
|
||||||
onAdmissionChange?.(next);
|
onAdmissionChange?.(next);
|
||||||
};
|
};
|
||||||
const sourceReady = admissionPhase === "ready";
|
const sourceReady = admissionPhase === "ready";
|
||||||
|
const overlaysPresented = sourceReady
|
||||||
|
&& presentedSeconds !== null
|
||||||
|
&& Math.abs(presentedSeconds - playback.currentSeconds) <= 0.25;
|
||||||
|
const handlePlaybackChange = (next: RecordedObservationPlayback) => {
|
||||||
|
setPresentedSeconds(next.currentSeconds);
|
||||||
|
// During a paused operator seek the existing media element can emit its old
|
||||||
|
// timestamp while the requested MSE window is being rebuilt. That stale
|
||||||
|
// callback must not undo the host target before the decoder reaches it.
|
||||||
|
if (!playback.playing && Math.abs(next.currentSeconds - playback.currentSeconds) > 0.35) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPlaybackChange?.(next);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="recorded-evidence-video-scene">
|
<div className="recorded-evidence-video-scene">
|
||||||
<RecordedFmp4Player
|
<RecordedFmp4Player
|
||||||
@@ -78,27 +97,28 @@ export function RecordedEvidenceVideoScene({
|
|||||||
prepare
|
prepare
|
||||||
segmentSequence={segmentSequence}
|
segmentSequence={segmentSequence}
|
||||||
segmentCount={segmentCount}
|
segmentCount={segmentCount}
|
||||||
onPlaybackChange={onPlaybackChange}
|
onPlaybackChange={handlePlaybackChange}
|
||||||
onPlayingRejected={onPlayingRejected}
|
onPlayingRejected={onPlayingRejected}
|
||||||
onAdmissionChange={handleAdmissionChange}
|
onAdmissionChange={handleAdmissionChange}
|
||||||
playbackAuthority={playbackAuthority}
|
playbackAuthority={playbackAuthority}
|
||||||
playbackTransport={playbackTransport}
|
playbackTransport={playbackTransport}
|
||||||
|
recoverTimestampStalls={recoverTimestampStalls}
|
||||||
/>
|
/>
|
||||||
{sourceReady && semanticOverlay ? (
|
{overlaysPresented && semanticOverlay ? (
|
||||||
<RecordedEvidenceSemanticMaskOverlay
|
<RecordedEvidenceSemanticMaskOverlay
|
||||||
{...semanticOverlay}
|
{...semanticOverlay}
|
||||||
imageWidth={imageWidth}
|
imageWidth={imageWidth}
|
||||||
imageHeight={imageHeight}
|
imageHeight={imageHeight}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{sourceReady && pointCloudOverlay ? (
|
{overlaysPresented && pointCloudOverlay ? (
|
||||||
<RecordedEvidencePointCloudOverlay
|
<RecordedEvidencePointCloudOverlay
|
||||||
imageWidth={imageWidth}
|
imageWidth={imageWidth}
|
||||||
imageHeight={imageHeight}
|
imageHeight={imageHeight}
|
||||||
overlay={pointCloudOverlay}
|
overlay={pointCloudOverlay}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{sourceReady ? (
|
{overlaysPresented ? (
|
||||||
<RecordedEvidenceBoxOverlay
|
<RecordedEvidenceBoxOverlay
|
||||||
imageWidth={imageWidth}
|
imageWidth={imageWidth}
|
||||||
imageHeight={imageHeight}
|
imageHeight={imageHeight}
|
||||||
|
|||||||
@@ -130,9 +130,9 @@ export function useRecordedEvidencePlayback(
|
|||||||
|
|
||||||
const synchronize = useCallback((next: RecordedObservationPlayback) => {
|
const synchronize = useCallback((next: RecordedObservationPlayback) => {
|
||||||
if (!validRange(range) || !Number.isFinite(next.currentSeconds)) return;
|
if (!validRange(range) || !Number.isFinite(next.currentSeconds)) return;
|
||||||
// The canonical LAB host clock is authoritative. Native media callbacks
|
// Animation-clock mode is retained only for non-media diagnostics. A
|
||||||
// are observational only in this mode: a stalled decoder must never stop
|
// recorded LAB with video uses the external media clock so spatial and
|
||||||
// the common timeline or let an independently playing spatial view drift.
|
// overlays never advance past the frame the decoder actually presented.
|
||||||
if (clock === "animation") return;
|
if (clock === "animation") return;
|
||||||
setPlayback((current) => synchronizeRecordedEvidencePlayback(current, next, range));
|
setPlayback((current) => synchronizeRecordedEvidencePlayback(current, next, range));
|
||||||
}, [clock, range]);
|
}, [clock, range]);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const CANONICAL_RECORDED_LAB_TGS_HISTORY_SECONDS = 1;
|
export const CANONICAL_RECORDED_LAB_TGS_HISTORY_SECONDS = 1;
|
||||||
export const CANONICAL_RECORDED_LAB_SPATIAL_PROFILE = "source-paced-ground-v2";
|
export const CANONICAL_RECORDED_LAB_SPATIAL_PROFILE = "source-paced-ground-v3";
|
||||||
|
|
||||||
export interface CanonicalRecordedLabPackedCellEvidence {
|
export interface CanonicalRecordedLabPackedCellEvidence {
|
||||||
centersBodyXyM: Float32Array;
|
centersBodyXyM: Float32Array;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export interface CanonicalRecordedLabSpatialFrame {
|
|||||||
coordinateFrame: "body-ground";
|
coordinateFrame: "body-ground";
|
||||||
sensorHeight: {
|
sensorHeight: {
|
||||||
meters: number;
|
meters: number;
|
||||||
source: "initial-source-cloud-lower-quantile-median";
|
source: "local-source-cloud-ground-quantile-median" | "session-source-cloud-fallback";
|
||||||
sampleCount: number;
|
sampleCount: number;
|
||||||
madM: number;
|
madM: number;
|
||||||
authority: "visual-derived";
|
authority: "visual-derived";
|
||||||
@@ -129,7 +129,7 @@ export async function fetchCanonicalRecordedLabSpatialFrame(
|
|||||||
const payload = objectValue(await response.json(), "canonical_lab.spatial_frame");
|
const payload = objectValue(await response.json(), "canonical_lab.spatial_frame");
|
||||||
exact(
|
exact(
|
||||||
payload.schema_version,
|
payload.schema_version,
|
||||||
"missioncore.canonical-recorded-lab-spatial-frame/v2",
|
"missioncore.canonical-recorded-lab-spatial-frame/v3",
|
||||||
"canonical_lab.spatial_frame.schema_version",
|
"canonical_lab.spatial_frame.schema_version",
|
||||||
);
|
);
|
||||||
exact(payload.coordinate_frame, "body-ground", "canonical_lab.spatial_frame.coordinate_frame");
|
exact(payload.coordinate_frame, "body-ground", "canonical_lab.spatial_frame.coordinate_frame");
|
||||||
@@ -179,11 +179,14 @@ export async function fetchCanonicalRecordedLabSpatialFrame(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const sensorHeight = objectValue(payload.sensor_height, "canonical_lab.spatial_frame.sensor_height");
|
const sensorHeight = objectValue(payload.sensor_height, "canonical_lab.spatial_frame.sensor_height");
|
||||||
exact(
|
if (
|
||||||
sensorHeight.source,
|
sensorHeight.source !== "local-source-cloud-ground-quantile-median"
|
||||||
"initial-source-cloud-lower-quantile-median",
|
&& sensorHeight.source !== "session-source-cloud-fallback"
|
||||||
"canonical_lab.spatial_frame.sensor_height.source",
|
) {
|
||||||
);
|
throw new CanonicalRecordedLabSpatialContractError(
|
||||||
|
"canonical_lab.spatial_frame.sensor_height.source: контракт изменён.",
|
||||||
|
);
|
||||||
|
}
|
||||||
exact(
|
exact(
|
||||||
sensorHeight.authority,
|
sensorHeight.authority,
|
||||||
"visual-derived",
|
"visual-derived",
|
||||||
@@ -210,7 +213,7 @@ export async function fetchCanonicalRecordedLabSpatialFrame(
|
|||||||
coordinateFrame: "body-ground",
|
coordinateFrame: "body-ground",
|
||||||
sensorHeight: {
|
sensorHeight: {
|
||||||
meters: numberValue(sensorHeight.meters, "canonical_lab.spatial_frame.sensor_height.meters"),
|
meters: numberValue(sensorHeight.meters, "canonical_lab.spatial_frame.sensor_height.meters"),
|
||||||
source: "initial-source-cloud-lower-quantile-median",
|
source: sensorHeight.source,
|
||||||
sampleCount: integerValue(
|
sampleCount: integerValue(
|
||||||
sensorHeight.sample_count,
|
sensorHeight.sample_count,
|
||||||
"canonical_lab.spatial_frame.sensor_height.sample_count",
|
"canonical_lab.spatial_frame.sensor_height.sample_count",
|
||||||
|
|||||||
@@ -154,6 +154,9 @@ export interface M4ThreatTimelineFrame {
|
|||||||
pointCloudSourceCount: number;
|
pointCloudSourceCount: number;
|
||||||
pointCloudSampleCount: number;
|
pointCloudSampleCount: number;
|
||||||
pointCloudLayer: "current-increment";
|
pointCloudLayer: "current-increment";
|
||||||
|
localSlamBodyXyzM?: readonly M4Point3[];
|
||||||
|
localSlamSourceFrameCount?: number;
|
||||||
|
localSlamSourcePointCount?: number;
|
||||||
cameraProjectedPointsXyd: readonly (readonly [number, number, number])[];
|
cameraProjectedPointsXyd: readonly (readonly [number, number, number])[];
|
||||||
cameraProjectedSourceCount: number;
|
cameraProjectedSourceCount: number;
|
||||||
cameraProjectedPointCount: number;
|
cameraProjectedPointCount: number;
|
||||||
@@ -168,10 +171,11 @@ export interface M4ThreatTimelineFrame {
|
|||||||
|
|
||||||
export interface M4ThreatTimeline {
|
export interface M4ThreatTimeline {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
recordedSourceSessionId: "20260720T065719Z_viewer_live";
|
recordedSourceSessionId: string;
|
||||||
|
recordedSourceId: string;
|
||||||
imageWidth: 800;
|
imageWidth: 800;
|
||||||
imageHeight: 600;
|
imageHeight: 600;
|
||||||
frameCount: 4489;
|
frameCount: number;
|
||||||
frameTimesNs: readonly number[];
|
frameTimesNs: readonly number[];
|
||||||
timelineStartSeconds: number;
|
timelineStartSeconds: number;
|
||||||
timelineEndSeconds: number;
|
timelineEndSeconds: number;
|
||||||
@@ -704,12 +708,8 @@ export async function fetchM4ThreatTimeline(
|
|||||||
exact(payload.result_id, result, "M4.6 timeline result");
|
exact(payload.result_id, result, "M4.6 timeline result");
|
||||||
exact(payload.authority, "replay-simulated", "M4.6 timeline authority");
|
exact(payload.authority, "replay-simulated", "M4.6 timeline authority");
|
||||||
const recorded = object(payload.recorded_source, "M4.6 recorded source");
|
const recorded = object(payload.recorded_source, "M4.6 recorded source");
|
||||||
exact(
|
const recordedSessionId = text(recorded.session_id, "M4.6 recorded session");
|
||||||
recorded.session_id,
|
const recordedSourceId = text(recorded.source_id, "M4.6 recorded source id");
|
||||||
"20260720T065719Z_viewer_live",
|
|
||||||
"M4.6 recorded session",
|
|
||||||
);
|
|
||||||
exact(recorded.source_id, "RAVNOVES00", "M4.6 recorded source id");
|
|
||||||
exact(
|
exact(
|
||||||
recorded.representation_id,
|
recorded.representation_id,
|
||||||
"registered-map-increment-v1",
|
"registered-map-increment-v1",
|
||||||
@@ -720,7 +720,10 @@ export async function fetchM4ThreatTimeline(
|
|||||||
"host-arrival-best-effort",
|
"host-arrival-best-effort",
|
||||||
"M4.6 recorded synchronization",
|
"M4.6 recorded synchronization",
|
||||||
);
|
);
|
||||||
const frameCount = exact(payload.frame_count, 4489, "M4.6 timeline frame count");
|
const frameCount = integer(payload.frame_count, "M4.6 timeline frame count");
|
||||||
|
if (frameCount < 1) {
|
||||||
|
throw new M4ThreatContractError("M4.6 timeline frame count: пустой timeline.");
|
||||||
|
}
|
||||||
const frameTimesNs = array(payload.frame_times_ns, "M4.6 timeline index").map(
|
const frameTimesNs = array(payload.frame_times_ns, "M4.6 timeline index").map(
|
||||||
(value) => integer(value, "M4.6 timeline time"),
|
(value) => integer(value, "M4.6 timeline time"),
|
||||||
);
|
);
|
||||||
@@ -738,7 +741,8 @@ export async function fetchM4ThreatTimeline(
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
resultId: result,
|
resultId: result,
|
||||||
recordedSourceSessionId: "20260720T065719Z_viewer_live",
|
recordedSourceSessionId: recordedSessionId,
|
||||||
|
recordedSourceId,
|
||||||
imageWidth: exact(payload.image_width, 800, "M4.6 image width"),
|
imageWidth: exact(payload.image_width, 800, "M4.6 image width"),
|
||||||
imageHeight: exact(payload.image_height, 600, "M4.6 image height"),
|
imageHeight: exact(payload.image_height, 600, "M4.6 image height"),
|
||||||
frameCount,
|
frameCount,
|
||||||
@@ -1334,6 +1338,17 @@ function parseTimelineFrame(
|
|||||||
"current-increment",
|
"current-increment",
|
||||||
"M4.6 timeline point layer",
|
"M4.6 timeline point layer",
|
||||||
),
|
),
|
||||||
|
localSlamBodyXyzM: item.local_slam_body_xyz_m === undefined
|
||||||
|
? []
|
||||||
|
: array(item.local_slam_body_xyz_m, "M4.6 local SLAM points").map(
|
||||||
|
(point) => vector(point, 3, "M4.6 local SLAM point") as [number, number, number],
|
||||||
|
),
|
||||||
|
localSlamSourceFrameCount: item.local_slam_source_frame_count === undefined
|
||||||
|
? undefined
|
||||||
|
: integer(item.local_slam_source_frame_count, "M4.6 local SLAM source frames"),
|
||||||
|
localSlamSourcePointCount: item.local_slam_source_point_count === undefined
|
||||||
|
? undefined
|
||||||
|
: integer(item.local_slam_source_point_count, "M4.6 local SLAM source points"),
|
||||||
cameraProjectedPointsXyd: item.camera_projected_points_xyd === undefined
|
cameraProjectedPointsXyd: item.camera_projected_points_xyd === undefined
|
||||||
? []
|
? []
|
||||||
: array(item.camera_projected_points_xyd, "M4.6 camera points").map(
|
: array(item.camera_projected_points_xyd, "M4.6 camera points").map(
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export interface M4ReplayClassifiedSpatialLayer {
|
|||||||
label: string;
|
label: string;
|
||||||
pointLayerLabel: string;
|
pointLayerLabel: string;
|
||||||
cellLayerLabel: string;
|
cellLayerLabel: string;
|
||||||
|
cellLayerAvailable?: boolean;
|
||||||
expectedAtSequence: boolean;
|
expectedAtSequence: boolean;
|
||||||
frame: M4ReplayClassifiedSpatialFrame | null;
|
frame: M4ReplayClassifiedSpatialFrame | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -178,6 +179,8 @@ export function M4ReplayThreatVisual({
|
|||||||
classifiedSpatialLayer,
|
classifiedSpatialLayer,
|
||||||
showReferenceMediaLayers = true,
|
showReferenceMediaLayers = true,
|
||||||
showSpatialOverlaySummary = true,
|
showSpatialOverlaySummary = true,
|
||||||
|
playbackTransport = "epoch-stream",
|
||||||
|
recoverTimestampStalls = false,
|
||||||
onActiveSequenceChange,
|
onActiveSequenceChange,
|
||||||
}: {
|
}: {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
@@ -194,6 +197,8 @@ export function M4ReplayThreatVisual({
|
|||||||
classifiedSpatialLayer?: M4ReplayClassifiedSpatialLayer;
|
classifiedSpatialLayer?: M4ReplayClassifiedSpatialLayer;
|
||||||
showReferenceMediaLayers?: boolean;
|
showReferenceMediaLayers?: boolean;
|
||||||
showSpatialOverlaySummary?: boolean;
|
showSpatialOverlaySummary?: boolean;
|
||||||
|
playbackTransport?: "segmented" | "epoch-stream";
|
||||||
|
recoverTimestampStalls?: boolean;
|
||||||
onActiveSequenceChange?: (sequence: number | null) => void;
|
onActiveSequenceChange?: (sequence: number | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
@@ -256,7 +261,7 @@ export function M4ReplayThreatVisual({
|
|||||||
showMediaSemantic: Boolean(activeSemantic) && showMediaSemantic,
|
showMediaSemantic: Boolean(activeSemantic) && showMediaSemantic,
|
||||||
showSpatialSemantic: Boolean(activeSpatialSemantic) && showSpatialSemantic,
|
showSpatialSemantic: Boolean(activeSpatialSemantic) && showSpatialSemantic,
|
||||||
showMediaPoints,
|
showMediaPoints,
|
||||||
classifiedSpatialMode: !classifiedSpatialLayer
|
classifiedSpatialMode: !classifiedSpatialLayer || classifiedSpatialLayer.cellLayerAvailable === false
|
||||||
? "none"
|
? "none"
|
||||||
: classifiedSpatialLayer.replacePointCloud
|
: classifiedSpatialLayer.replacePointCloud
|
||||||
? "replace-source"
|
? "replace-source"
|
||||||
@@ -278,7 +283,7 @@ export function M4ReplayThreatVisual({
|
|||||||
endSeconds: metadata.timeline.timelineEndSeconds,
|
endSeconds: metadata.timeline.timelineEndSeconds,
|
||||||
}) : null, [metadata.timeline]);
|
}) : null, [metadata.timeline]);
|
||||||
const playbackController = useRecordedEvidencePlayback(playbackRange, {
|
const playbackController = useRecordedEvidencePlayback(playbackRange, {
|
||||||
clock: "animation",
|
clock: "external",
|
||||||
});
|
});
|
||||||
const seekPlayback = playbackController.seek;
|
const seekPlayback = playbackController.seek;
|
||||||
const setPlaybackPlaying = playbackController.setPlaying;
|
const setPlaybackPlaying = playbackController.setPlaying;
|
||||||
@@ -356,11 +361,19 @@ export function M4ReplayThreatVisual({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
lastSpatialFrameRef.current = null;
|
lastSpatialFrameRef.current = null;
|
||||||
}, [evidenceDemand.sourceSpatialPoints, resultId]);
|
}, [evidenceDemand.sourceSpatialPoints, resultId]);
|
||||||
if (frame?.spatialAvailable) {
|
const latestAvailableSpatialFrame = [...timelineFrame.availableFrames]
|
||||||
lastSpatialFrameRef.current = { resultId, frame };
|
.reverse()
|
||||||
|
.find((candidate) => (
|
||||||
|
candidate.spatialAvailable
|
||||||
|
&& (timelineFrame.activeSequence === null
|
||||||
|
|| candidate.sequence <= timelineFrame.activeSequence)
|
||||||
|
)) ?? null;
|
||||||
|
const currentSpatialFrame = frame?.spatialAvailable ? frame : latestAvailableSpatialFrame;
|
||||||
|
if (currentSpatialFrame) {
|
||||||
|
lastSpatialFrameRef.current = { resultId, frame: currentSpatialFrame };
|
||||||
}
|
}
|
||||||
const spatialFrame = frame?.spatialAvailable
|
const spatialFrame = currentSpatialFrame
|
||||||
? frame
|
? currentSpatialFrame
|
||||||
: lastSpatialFrameRef.current?.resultId === resultId
|
: lastSpatialFrameRef.current?.resultId === resultId
|
||||||
? lastSpatialFrameRef.current.frame
|
? lastSpatialFrameRef.current.frame
|
||||||
: null;
|
: null;
|
||||||
@@ -541,14 +554,20 @@ export function M4ReplayThreatVisual({
|
|||||||
const activeSpatialFrame = spatialFrame?.sequence === timelineFrame.activeSequence
|
const activeSpatialFrame = spatialFrame?.sequence === timelineFrame.activeSequence
|
||||||
? spatialFrame
|
? spatialFrame
|
||||||
: null;
|
: null;
|
||||||
const classifiedSpatialFrame = classifiedSpatialLayer?.frame?.sourceSequence === timelineFrame.activeSequence
|
const hasClassifiedSpatialOutput = Boolean(
|
||||||
|
classifiedSpatialLayer && classifiedSpatialLayer.cellLayerAvailable !== false,
|
||||||
|
);
|
||||||
|
const classifiedSpatialFrame = hasClassifiedSpatialOutput
|
||||||
|
&& classifiedSpatialLayer?.frame?.sourceSequence === timelineFrame.activeSequence
|
||||||
? classifiedSpatialLayer?.frame ?? null
|
? classifiedSpatialLayer?.frame ?? null
|
||||||
: null;
|
: null;
|
||||||
const lastClassifiedSpatialFrameRef = useRef<{
|
const lastClassifiedSpatialFrameRef = useRef<{
|
||||||
resultId: string;
|
resultId: string;
|
||||||
frame: M4ReplayClassifiedSpatialFrame;
|
frame: M4ReplayClassifiedSpatialFrame;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const incomingClassifiedSpatialFrame = classifiedSpatialLayer?.frame ?? null;
|
const incomingClassifiedSpatialFrame = hasClassifiedSpatialOutput
|
||||||
|
? classifiedSpatialLayer?.frame ?? null
|
||||||
|
: null;
|
||||||
if (incomingClassifiedSpatialFrame && incomingClassifiedSpatialFrame.sampleAvailable !== false) {
|
if (incomingClassifiedSpatialFrame && incomingClassifiedSpatialFrame.sampleAvailable !== false) {
|
||||||
lastClassifiedSpatialFrameRef.current = { resultId, frame: incomingClassifiedSpatialFrame };
|
lastClassifiedSpatialFrameRef.current = { resultId, frame: incomingClassifiedSpatialFrame };
|
||||||
}
|
}
|
||||||
@@ -577,7 +596,9 @@ export function M4ReplayThreatVisual({
|
|||||||
? spatialFrame
|
? spatialFrame
|
||||||
: null)
|
: null)
|
||||||
: null;
|
: null;
|
||||||
const replaceClassifiedPointCloud = classifiedSpatialLayer?.replacePointCloud ?? true;
|
const replaceClassifiedPointCloud = hasClassifiedSpatialOutput
|
||||||
|
? classifiedSpatialLayer?.replacePointCloud ?? true
|
||||||
|
: false;
|
||||||
const nominalSensorHeightM = metadata.timeline?.rig.nominalSensorHeightM ?? 0;
|
const nominalSensorHeightM = metadata.timeline?.rig.nominalSensorHeightM ?? 0;
|
||||||
const mapGravityLocalSensorToBodyGround = useCallback((
|
const mapGravityLocalSensorToBodyGround = useCallback((
|
||||||
point: readonly [number, number, number],
|
point: readonly [number, number, number],
|
||||||
@@ -695,7 +716,12 @@ export function M4ReplayThreatVisual({
|
|||||||
.map((item) => item.assessment.closestApproachM)
|
.map((item) => item.assessment.closestApproachM)
|
||||||
.filter((value): value is number => value !== null)
|
.filter((value): value is number => value !== null)
|
||||||
.sort((left, right) => left - right)[0] ?? null;
|
.sort((left, right) => left - right)[0] ?? null;
|
||||||
const localSurface = useMemo(() => buildM4LocalSurface(
|
const localSurface = useMemo(() => spatialFrame?.localSlamBodyXyzM?.length ? ({
|
||||||
|
pointsBodyXyzM: spatialFrame.localSlamBodyXyzM,
|
||||||
|
sourceFrameCount: spatialFrame.localSlamSourceFrameCount ?? 0,
|
||||||
|
sourcePointCount: spatialFrame.localSlamSourcePointCount ?? 0,
|
||||||
|
voxelCount: spatialFrame.localSlamBodyXyzM.length,
|
||||||
|
}) : buildM4LocalSurface(
|
||||||
timelineFrame.availableFrames,
|
timelineFrame.availableFrames,
|
||||||
spatialFrame,
|
spatialFrame,
|
||||||
metadata.timeline?.localSurfaceVisualization ?? {
|
metadata.timeline?.localSurfaceVisualization ?? {
|
||||||
@@ -843,16 +869,24 @@ export function M4ReplayThreatVisual({
|
|||||||
shape="pill"
|
shape="pill"
|
||||||
variant={showRollingMap ? "primary" : "secondary"}
|
variant={showRollingMap ? "primary" : "secondary"}
|
||||||
aria-pressed={showRollingMap}
|
aria-pressed={showRollingMap}
|
||||||
|
disabled={classifiedSpatialLayer.cellLayerAvailable === false}
|
||||||
|
title={classifiedSpatialLayer.cellLayerAvailable === false
|
||||||
|
? `${classifiedSpatialLayer.cellLayerLabel} недоступен: для этой записи нет запечатанного полного результата`
|
||||||
|
: undefined}
|
||||||
onClick={() => setShowRollingMap((visible) => !visible)}
|
onClick={() => setShowRollingMap((visible) => !visible)}
|
||||||
>
|
>
|
||||||
{classifiedSpatialLayer.cellLayerLabel}
|
{classifiedSpatialLayer.cellLayerLabel}
|
||||||
</Button>
|
</Button>
|
||||||
{semanticSpatialResultId ? (
|
{activeSpatialSemantic ? (
|
||||||
<Button
|
<Button
|
||||||
size="compact"
|
size="compact"
|
||||||
shape="pill"
|
shape="pill"
|
||||||
variant={showSpatialSemantic ? "primary" : "secondary"}
|
variant={showSpatialSemantic ? "primary" : "secondary"}
|
||||||
aria-pressed={showSpatialSemantic}
|
aria-pressed={showSpatialSemantic}
|
||||||
|
disabled={!semanticSpatialResultId}
|
||||||
|
title={semanticSpatialResultId
|
||||||
|
? "Point-aligned semantic evidence"
|
||||||
|
: "Point-aligned 3D semantics отсутствует в запечатанном результате"}
|
||||||
onClick={() => setShowSpatialSemantic((visible) => !visible)}
|
onClick={() => setShowSpatialSemantic((visible) => !visible)}
|
||||||
>
|
>
|
||||||
SEMANTICS
|
SEMANTICS
|
||||||
@@ -905,12 +939,16 @@ export function M4ReplayThreatVisual({
|
|||||||
LOW-STEP
|
LOW-STEP
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{semanticSpatialResultId ? (
|
{activeSpatialSemantic ? (
|
||||||
<Button
|
<Button
|
||||||
size="compact"
|
size="compact"
|
||||||
shape="pill"
|
shape="pill"
|
||||||
variant={showSpatialSemantic ? "primary" : "secondary"}
|
variant={showSpatialSemantic ? "primary" : "secondary"}
|
||||||
aria-pressed={showSpatialSemantic}
|
aria-pressed={showSpatialSemantic}
|
||||||
|
disabled={!semanticSpatialResultId}
|
||||||
|
title={semanticSpatialResultId
|
||||||
|
? "Point-aligned semantic evidence"
|
||||||
|
: "Point-aligned 3D semantics отсутствует в запечатанном результате"}
|
||||||
onClick={() => setShowSpatialSemantic((visible) => !visible)}
|
onClick={() => setShowSpatialSemantic((visible) => !visible)}
|
||||||
>
|
>
|
||||||
SEMANTICS
|
SEMANTICS
|
||||||
@@ -1014,14 +1052,14 @@ export function M4ReplayThreatVisual({
|
|||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<span>Spatial evidence</span>
|
<span>Spatial evidence</span>
|
||||||
<strong>{classifiedSpatialLayer
|
<strong>{hasClassifiedSpatialOutput
|
||||||
? classifiedSpatialFrame
|
? classifiedSpatialFrame
|
||||||
? replaceClassifiedPointCloud
|
? replaceClassifiedPointCloud
|
||||||
? `${classifiedSpatialFrame.pointsMapGravityLocalXyzM.length.toLocaleString("ru-RU")} TGS points · ${classifiedCellCount.toLocaleString("ru-RU")} cells`
|
? `${classifiedSpatialFrame.pointsMapGravityLocalXyzM.length.toLocaleString("ru-RU")} TGS points · ${classifiedCellCount.toLocaleString("ru-RU")} cells`
|
||||||
: `${(activeSpatialFrame?.pointCloudSourceCount ?? classifiedSpatialFrame.sourcePointCount ?? 0).toLocaleString("ru-RU")} source points · ${classifiedCellCount.toLocaleString("ru-RU")} TGS cells`
|
: `${(activeSpatialFrame?.pointCloudSourceCount ?? classifiedSpatialFrame.sourcePointCount ?? 0).toLocaleString("ru-RU")} source points · ${classifiedCellCount.toLocaleString("ru-RU")} TGS cells`
|
||||||
: "TGS spatial buffer"
|
: "TGS spatial buffer"
|
||||||
: `${currentIncrementObstacles.length} current · ${rollingMapObstacles.length} rolling${metadata.timeline.occupancyProvenanceDelivery ? ` · ${lowStepObstacles.length} low-step` : ""}`}</strong>
|
: `${currentIncrementObstacles.length} current · ${rollingMapObstacles.length} rolling${metadata.timeline.occupancyProvenanceDelivery ? ` · ${lowStepObstacles.length} low-step` : ""}`}</strong>
|
||||||
<small>{classifiedSpatialLayer
|
<small>{hasClassifiedSpatialOutput
|
||||||
? classifiedSpatialFrame
|
? classifiedSpatialFrame
|
||||||
? classifiedSpatialFrame.sampleAvailable === false
|
? classifiedSpatialFrame.sampleAvailable === false
|
||||||
? displayedClassifiedFrameHeld && displayedClassifiedSpatialFrame
|
? displayedClassifiedFrameHeld && displayedClassifiedSpatialFrame
|
||||||
@@ -1030,9 +1068,9 @@ export function M4ReplayThreatVisual({
|
|||||||
: activeSpatialFrame
|
: activeSpatialFrame
|
||||||
? "map-gravity-local · all eligible points accounted · causal rolling 1 s"
|
? "map-gravity-local · all eligible points accounted · causal rolling 1 s"
|
||||||
: "TGS рассчитан · linked source cloud недоступен для этого кадра"
|
: "TGS рассчитан · linked source cloud недоступен для этого кадра"
|
||||||
: classifiedSpatialLayer.error
|
: classifiedSpatialLayer?.error
|
||||||
?? classifiedSpatialLayer.loadingLabel
|
?? classifiedSpatialLayer?.loadingLabel
|
||||||
?? `Открываем ${classifiedSpatialLayer.label}`
|
?? `Открываем ${classifiedSpatialLayer?.label ?? "spatial evidence"}`
|
||||||
: (
|
: (
|
||||||
<>
|
<>
|
||||||
{spatialFrame
|
{spatialFrame
|
||||||
@@ -1057,13 +1095,13 @@ export function M4ReplayThreatVisual({
|
|||||||
)}</small>
|
)}</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>{classifiedSpatialLayer ? "TGS fail-closed" : "Virtual corridor"}</span>
|
<span>{hasClassifiedSpatialOutput ? "TGS fail-closed" : "Virtual corridor"}</span>
|
||||||
<strong>{classifiedSpatialLayer
|
<strong>{hasClassifiedSpatialOutput
|
||||||
? classifiedSpatialFrame
|
? classifiedSpatialFrame
|
||||||
? `${classifiedCellCounts.occupied} occupied · ${classifiedCellCounts.rejected} rejected · ${classifiedCellCounts.unobserved} unobserved`
|
? `${classifiedCellCounts.occupied} occupied · ${classifiedCellCounts.rejected} rejected · ${classifiedCellCounts.unobserved} unobserved`
|
||||||
: classifiedSpatialLayer.loading || displayingBufferedFrame ? "loading" : "unavailable"
|
: classifiedSpatialLayer?.loading || displayingBufferedFrame ? "loading" : "unavailable"
|
||||||
: `${spatialFrame?.decisionCounts.threat ?? 0} threat · nearest ${nearest === null ? "—" : `${nearest.toFixed(2)} м`}`}</strong>
|
: `${spatialFrame?.decisionCounts.threat ?? 0} threat · nearest ${nearest === null ? "—" : `${nearest.toFixed(2)} м`}`}</strong>
|
||||||
<small>{classifiedSpatialLayer
|
<small>{hasClassifiedSpatialOutput
|
||||||
? classifiedSpatialFrame
|
? classifiedSpatialFrame
|
||||||
? `${classifiedCellCounts.ground} ground-support · visual review only · navigation authority OFF`
|
? `${classifiedCellCounts.ground} ground-support · visual review only · navigation authority OFF`
|
||||||
: "visual review only · navigation authority OFF"
|
: "visual review only · navigation authority OFF"
|
||||||
@@ -1116,8 +1154,9 @@ export function M4ReplayThreatVisual({
|
|||||||
}
|
}
|
||||||
segmentCount={timeline.frameCount}
|
segmentCount={timeline.frameCount}
|
||||||
onPlaybackChange={playbackController.synchronize}
|
onPlaybackChange={playbackController.synchronize}
|
||||||
playbackAuthority="host"
|
playbackAuthority="media"
|
||||||
playbackTransport="epoch-stream"
|
playbackTransport={playbackTransport}
|
||||||
|
recoverTimestampStalls={recoverTimestampStalls}
|
||||||
/>
|
/>
|
||||||
) : videoError ? (
|
) : videoError ? (
|
||||||
<SpatialState message={videoError} />
|
<SpatialState message={videoError} />
|
||||||
@@ -1148,9 +1187,11 @@ export function M4ReplayThreatVisual({
|
|||||||
ref={metricSceneRef}
|
ref={metricSceneRef}
|
||||||
pointCloudBodyXyzM={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
pointCloudBodyXyzM={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||||
? classifiedPointsBody
|
? classifiedPointsBody
|
||||||
: classifiedContextSpatialFrame?.pointCloudBodyXyzM ?? []}
|
: classifiedContextSpatialFrame?.pointCloudBodyXyzM
|
||||||
|
?? activeSpatialFrame?.pointCloudBodyXyzM
|
||||||
|
?? []}
|
||||||
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
|
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
|
||||||
obstacles={classifiedSpatialLayer ? [] : sceneObstacles}
|
obstacles={hasClassifiedSpatialOutput ? [] : sceneObstacles}
|
||||||
rig={timeline.rig}
|
rig={timeline.rig}
|
||||||
corridor={timeline.corridor}
|
corridor={timeline.corridor}
|
||||||
occupiedVoxelSizeM={displayedClassifiedSpatialFrame?.cellSizeM ?? timeline.occupiedVoxelSizeM}
|
occupiedVoxelSizeM={displayedClassifiedSpatialFrame?.cellSizeM ?? timeline.occupiedVoxelSizeM}
|
||||||
@@ -1159,7 +1200,7 @@ export function M4ReplayThreatVisual({
|
|||||||
showCurrentIncrement={showCurrentIncrement}
|
showCurrentIncrement={showCurrentIncrement}
|
||||||
showLocalSurface={showLocalSurface}
|
showLocalSurface={showLocalSurface}
|
||||||
showRollingMap={showRollingMap}
|
showRollingMap={showRollingMap}
|
||||||
showLowStep={classifiedSpatialLayer ? false : showLowStep}
|
showLowStep={hasClassifiedSpatialOutput ? false : showLowStep}
|
||||||
pointSemanticClassIds={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
pointSemanticClassIds={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||||
? displayedClassifiedSpatialFrame.pointClassIds
|
? displayedClassifiedSpatialFrame.pointClassIds
|
||||||
: alignedSemanticPointIds}
|
: alignedSemanticPointIds}
|
||||||
@@ -1174,7 +1215,7 @@ export function M4ReplayThreatVisual({
|
|||||||
classifiedCellSizeM={displayedClassifiedSpatialFrame?.cellSizeM}
|
classifiedCellSizeM={displayedClassifiedSpatialFrame?.cellSizeM}
|
||||||
showClassifiedCells={showRollingMap}
|
showClassifiedCells={showRollingMap}
|
||||||
/>
|
/>
|
||||||
{classifiedSpatialLayer && !displayedClassifiedSpatialFrame ? (
|
{hasClassifiedSpatialOutput && classifiedSpatialLayer && !displayedClassifiedSpatialFrame ? (
|
||||||
<div className="l3-visual-audit__state" role={classifiedSpatialLayer.error ? "alert" : "status"}>
|
<div className="l3-visual-audit__state" role={classifiedSpatialLayer.error ? "alert" : "status"}>
|
||||||
{classifiedSpatialLayer.loading || displayingBufferedFrame
|
{classifiedSpatialLayer.loading || displayingBufferedFrame
|
||||||
? <span className="busy-indicator" aria-hidden="true" />
|
? <span className="busy-indicator" aria-hidden="true" />
|
||||||
|
|||||||
@@ -1,30 +1,5 @@
|
|||||||
import {
|
import { useEffect, useMemo, useState } from "react";
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type CSSProperties,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
SegmentedControl,
|
|
||||||
} from "@nodedc/ui-react";
|
|
||||||
|
|
||||||
import { ObservationTimeline } from "../../components/ObservationTimeline";
|
|
||||||
import {
|
|
||||||
CanonicalRecordedLabReplay,
|
|
||||||
useCanonicalRecordedLabReplayState,
|
|
||||||
} from "../../components/laboratory/CanonicalRecordedLabReplay";
|
|
||||||
import {
|
|
||||||
LaboratoryMetricEvidenceScene,
|
|
||||||
type LaboratoryMetricEvidenceSceneHandle,
|
|
||||||
type LaboratoryMetricPackedCellEvidence,
|
|
||||||
} from "../../components/laboratory/LaboratoryMetricEvidenceScene";
|
|
||||||
import { RecordedEvidenceVideoScene } from "../../components/laboratory/RecordedEvidenceVideoScene";
|
|
||||||
import { useCanonicalRecordedLabSpatialFrame } from "../../components/laboratory/useCanonicalRecordedLabSpatialFrame";
|
|
||||||
import { useRecordedEvidencePlayback } from "../../components/laboratory/useRecordedEvidencePlayback";
|
|
||||||
import {
|
import {
|
||||||
LaboratoryEvidence,
|
LaboratoryEvidence,
|
||||||
LaboratoryResultSummary,
|
LaboratoryResultSummary,
|
||||||
@@ -32,23 +7,9 @@ import {
|
|||||||
LaboratoryWorkTemplate,
|
LaboratoryWorkTemplate,
|
||||||
} from "../../components/laboratory/LaboratoryPresentation";
|
} from "../../components/laboratory/LaboratoryPresentation";
|
||||||
import {
|
import {
|
||||||
type RecordedEvidenceSemanticClass,
|
|
||||||
type RecordedEvidenceSemanticPaletteEntry,
|
|
||||||
} from "../../components/laboratory/RecordedEvidenceSemanticMaskOverlay";
|
|
||||||
import {
|
|
||||||
canonicalRecordedLabPackedTgsCells,
|
|
||||||
canonicalRecordedLabTgsIsCurrent,
|
|
||||||
} from "../../core/laboratory/canonicalRecordedLab";
|
|
||||||
import {
|
|
||||||
fetchVegetationShadowResult,
|
|
||||||
fetchVegetationRouteTgsAnchor,
|
|
||||||
vegetationFullRouteMaskUrl,
|
vegetationFullRouteMaskUrl,
|
||||||
vegetationVideoMaskUrl,
|
vegetationVideoMaskUrl,
|
||||||
type VegetationFullRouteLayer,
|
|
||||||
type VegetationFullRouteReview,
|
type VegetationFullRouteReview,
|
||||||
type VegetationMixedRouteCase,
|
|
||||||
type VegetationMixedRouteReview,
|
|
||||||
type VegetationRouteTgsAnchor,
|
|
||||||
type VegetationShadowResult,
|
type VegetationShadowResult,
|
||||||
} from "../../core/laboratory/vegetationShadow";
|
} from "../../core/laboratory/vegetationShadow";
|
||||||
import {
|
import {
|
||||||
@@ -56,81 +17,18 @@ import {
|
|||||||
type M49TgsFullShadowResult,
|
type M49TgsFullShadowResult,
|
||||||
} from "../../core/laboratory/m49TgsFullShadow";
|
} from "../../core/laboratory/m49TgsFullShadow";
|
||||||
import { M49TgsFullShadowEvidence } from "./M49TgsFullShadowEvidence";
|
import { M49TgsFullShadowEvidence } from "./M49TgsFullShadowEvidence";
|
||||||
import { recordedObservationSources } from "../../core/observation/recordedObservationSources";
|
import {
|
||||||
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
M4ReplayThreatVisual,
|
||||||
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
type M4ReplayClassifiedSpatialLayer,
|
||||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
type M4ReplayThreatSemanticLayer,
|
||||||
|
} from "./M4ReplayThreatVisual";
|
||||||
|
|
||||||
|
const VEGETATION_TIMELINE_ENDPOINT = "/api/v1/laboratory/vegetation-shadow";
|
||||||
|
|
||||||
function decimal(value: number, digits = 1): string {
|
function decimal(value: number, digits = 1): string {
|
||||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
|
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
|
||||||
}
|
}
|
||||||
|
|
||||||
const FULL_ROUTE_SEMANTIC_MODES = [
|
|
||||||
{ value: "city", label: "ГОРОД · EoMT" },
|
|
||||||
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type FullRouteMediaMode = "video" | "camera";
|
|
||||||
type FullRouteSpatialMode = "3d" | "plan";
|
|
||||||
|
|
||||||
const FULL_ROUTE_MEDIA_MODES = [
|
|
||||||
{ value: "video", label: "VIDEO" },
|
|
||||||
{ value: "camera", label: "CAMERA" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const FULL_ROUTE_SPATIAL_MODES = [
|
|
||||||
{ value: "3d", label: "3D" },
|
|
||||||
{ value: "plan", label: "PLAN" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function semanticPresentation(layer: VegetationFullRouteLayer): {
|
|
||||||
classes: readonly RecordedEvidenceSemanticClass[];
|
|
||||||
palette: readonly RecordedEvidenceSemanticPaletteEntry[];
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
classes: layer.taxonomy.map((item) => ({ id: item.classId, label: item.label })),
|
|
||||||
palette: layer.taxonomy.map((item) => ({
|
|
||||||
classId: item.classId,
|
|
||||||
color: item.classId === 0
|
|
||||||
? { kind: "transparent" as const }
|
|
||||||
: { kind: "diagnostic" as const, rgb: item.colorRgb },
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function causalTgsCase(
|
|
||||||
cases: readonly VegetationMixedRouteCase[],
|
|
||||||
sequence: number,
|
|
||||||
): VegetationMixedRouteCase | null {
|
|
||||||
if (!cases.length) return null;
|
|
||||||
return cases.reduce<VegetationMixedRouteCase | null>((latest, candidate) => (
|
|
||||||
candidate.sourceSequence <= sequence
|
|
||||||
&& (!latest || candidate.sourceSequence > latest.sourceSequence)
|
|
||||||
? candidate
|
|
||||||
: latest
|
|
||||||
), null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nearestFullRouteFrameIndex(
|
|
||||||
frameSourceTimesNs: readonly number[],
|
|
||||||
sourceTimeNs: number,
|
|
||||||
): number {
|
|
||||||
if (!frameSourceTimesNs.length) return 0;
|
|
||||||
let low = 0;
|
|
||||||
let high = frameSourceTimesNs.length - 1;
|
|
||||||
while (low < high) {
|
|
||||||
const middle = Math.floor((low + high) / 2);
|
|
||||||
if ((frameSourceTimesNs[middle] ?? 0) < sourceTimeNs) low = middle + 1;
|
|
||||||
else high = middle;
|
|
||||||
}
|
|
||||||
if (low === 0) return 0;
|
|
||||||
const previous = frameSourceTimesNs[low - 1] ?? frameSourceTimesNs[0] ?? 0;
|
|
||||||
const current = frameSourceTimesNs[low] ?? previous;
|
|
||||||
return Math.abs(sourceTimeNs - previous) <= Math.abs(current - sourceTimeNs)
|
|
||||||
? low - 1
|
|
||||||
: low;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FullRouteReviewEvidence({
|
function FullRouteReviewEvidence({
|
||||||
resultId,
|
resultId,
|
||||||
review,
|
review,
|
||||||
@@ -138,425 +36,53 @@ function FullRouteReviewEvidence({
|
|||||||
resultId: string;
|
resultId: string;
|
||||||
review: VegetationFullRouteReview;
|
review: VegetationFullRouteReview;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const semanticLayers = useMemo<readonly M4ReplayThreatSemanticLayer[]>(() => ([
|
||||||
mediaMode,
|
{
|
||||||
spatialMode,
|
id: "city",
|
||||||
splitView,
|
controlLabel: "ГОРОД · EoMT",
|
||||||
splitPrimarySize,
|
resultId,
|
||||||
splitOrientation,
|
spatialResultId: null,
|
||||||
expanded,
|
taxonomy: review.city.taxonomy,
|
||||||
onMediaModeChange: handleMediaModeChange,
|
maskUrl: (sequence) => vegetationFullRouteMaskUrl(resultId, "city", sequence),
|
||||||
onSpatialModeChange: handleSpatialModeChange,
|
label: review.city.name,
|
||||||
onSplitPrimarySizeChange: setSplitPrimarySize,
|
maskAriaLabel: "EoMT city semantic prediction",
|
||||||
onExpandedChange: setExpanded,
|
},
|
||||||
} = useCanonicalRecordedLabReplayState<FullRouteMediaMode, FullRouteSpatialMode>({
|
{
|
||||||
initialMediaMode: "video",
|
id: "vegetation",
|
||||||
initialSpatialMode: "3d",
|
controlLabel: "ПРИРОДА · DDRNet",
|
||||||
});
|
resultId,
|
||||||
const [semanticLayer, setSemanticLayer] = useState<"city" | "vegetation">("vegetation");
|
spatialResultId: null,
|
||||||
const [showCameraSemantic, setShowCameraSemantic] = useState(true);
|
taxonomy: review.vegetation.taxonomy,
|
||||||
const [showSourcePoints, setShowSourcePoints] = useState(true);
|
maskUrl: (sequence) => vegetationFullRouteMaskUrl(resultId, "vegetation", sequence),
|
||||||
const [showLocalSlam, setShowLocalSlam] = useState(true);
|
label: review.vegetation.name,
|
||||||
const [showTgs, setShowTgs] = useState(true);
|
maskAriaLabel: "DDRNet nature semantic prediction",
|
||||||
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
|
},
|
||||||
const [replayLaunch, setReplayLaunch] = useState<ObservationSessionReplayLaunch | null>(null);
|
]), [resultId, review.city, review.vegetation]);
|
||||||
const [videoError, setVideoError] = useState<string | null>(null);
|
const sealedSpatialGap = useMemo<M4ReplayClassifiedSpatialLayer>(() => ({
|
||||||
const [linkedReview, setLinkedReview] = useState<VegetationMixedRouteReview | null>(null);
|
label: "RAVNOVES004TREE",
|
||||||
const [linkedReviewError, setLinkedReviewError] = useState<string | null>(null);
|
pointLayerLabel: "SOURCE POINTS",
|
||||||
const [tgsAnchor, setTgsAnchor] = useState<VegetationRouteTgsAnchor | null>(null);
|
cellLayerLabel: "TGS COSTMAP",
|
||||||
const [tgsAnchorLoading, setTgsAnchorLoading] = useState(false);
|
cellLayerAvailable: false,
|
||||||
const [tgsAnchorError, setTgsAnchorError] = useState<string | null>(null);
|
expectedAtSequence: false,
|
||||||
const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null);
|
frame: null,
|
||||||
const playbackRange = useMemo(() => ({
|
loading: false,
|
||||||
startSeconds: review.timelineStartSeconds,
|
error: null,
|
||||||
endSeconds: review.timelineEndSeconds,
|
replacePointCloud: false,
|
||||||
}), [review.timelineEndSeconds, review.timelineStartSeconds]);
|
}), []);
|
||||||
const playbackController = useRecordedEvidencePlayback(playbackRange, { clock: "animation" });
|
|
||||||
const sequenceIndex = nearestFullRouteFrameIndex(
|
|
||||||
review.frameSourceTimesNs,
|
|
||||||
Math.round(playbackController.playback.currentSeconds * 1_000_000_000),
|
|
||||||
);
|
|
||||||
const sequence = sequenceIndex + 1;
|
|
||||||
const spatialRequestIndex = Math.floor(sequenceIndex / 5) * 5;
|
|
||||||
const spatialRequestTimeNs = review.frameSourceTimesNs[spatialRequestIndex]
|
|
||||||
?? review.frameSourceTimesNs[sequenceIndex]
|
|
||||||
?? Math.round(playbackController.playback.currentSeconds * 1_000_000_000);
|
|
||||||
const spatialEvidence = useCanonicalRecordedLabSpatialFrame({
|
|
||||||
sessionId: review.sessionId,
|
|
||||||
generationSha256: replayLaunch?.sha256 ?? null,
|
|
||||||
targetTimeNs: spatialRequestTimeNs,
|
|
||||||
});
|
|
||||||
const layer = review[semanticLayer];
|
|
||||||
const semantic = useMemo(() => semanticPresentation(layer), [layer]);
|
|
||||||
const prefetchSrcs = useMemo(() => showCameraSemantic
|
|
||||||
? Array.from({ length: 8 }, (_, offset) => sequenceIndex + offset + 1)
|
|
||||||
.filter((candidate) => candidate < review.frameCount)
|
|
||||||
.map((candidate) => vegetationFullRouteMaskUrl(resultId, semanticLayer, candidate))
|
|
||||||
: [], [resultId, review.frameCount, semanticLayer, sequenceIndex, showCameraSemantic]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
setVideoSource(null);
|
|
||||||
setReplayLaunch(null);
|
|
||||||
setVideoError(null);
|
|
||||||
void resolveObservationSessionReplay(review.sessionId, { signal: controller.signal })
|
|
||||||
.then((launch) => {
|
|
||||||
const source = recordedObservationSources(launch).find((candidate) => (
|
|
||||||
candidate.id === review.recordedMediaSourceId
|
|
||||||
&& candidate.modality === "video"
|
|
||||||
&& candidate.semanticChannelId === "camera.video.recorded"
|
|
||||||
&& candidate.delivery?.kind === "recorded-fmp4-manifest"
|
|
||||||
&& candidate.delivery.manifestGenerationSha256 === review.recordedMediaGenerationSha256
|
|
||||||
&& candidate.delivery.timelineStartSeconds === review.timelineStartSeconds
|
|
||||||
&& candidate.delivery.timelineEndSeconds >= review.timelineEndSeconds
|
|
||||||
));
|
|
||||||
if (!source) {
|
|
||||||
throw new Error("RIGHT-видео не совпало с sealed RAVNOVES004TREE timeline.");
|
|
||||||
}
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
setVideoSource(source);
|
|
||||||
setReplayLaunch(launch);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((caught: unknown) => {
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
setVideoError(caught instanceof Error ? caught.message : "Записанное видео недоступно.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [
|
|
||||||
review.recordedMediaGenerationSha256,
|
|
||||||
review.recordedMediaSourceId,
|
|
||||||
review.sessionId,
|
|
||||||
review.timelineEndSeconds,
|
|
||||||
review.timelineStartSeconds,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
setLinkedReview(null);
|
|
||||||
setLinkedReviewError(null);
|
|
||||||
void fetchVegetationShadowResult(review.linkedRouteReviewResultId, {
|
|
||||||
signal: controller.signal,
|
|
||||||
}).then((result) => {
|
|
||||||
if (
|
|
||||||
!result.routeReview
|
|
||||||
|| result.routeReview.sourceId !== review.sourceId
|
|
||||||
|| result.routeReview.sessionId !== review.sessionId
|
|
||||||
) {
|
|
||||||
throw new Error("TGS anchors имеют другую source identity.");
|
|
||||||
}
|
|
||||||
if (!controller.signal.aborted) setLinkedReview(result.routeReview);
|
|
||||||
}).catch((caught: unknown) => {
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
setLinkedReviewError(caught instanceof Error ? caught.message : "TGS anchors недоступны.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [review.linkedRouteReviewResultId, review.sessionId, review.sourceId]);
|
|
||||||
|
|
||||||
const selectedTgsCase = linkedReview
|
|
||||||
? causalTgsCase(linkedReview.cases, sequence)
|
|
||||||
: null;
|
|
||||||
const selectedTgsTimeNs = selectedTgsCase
|
|
||||||
? review.frameSourceTimesNs[selectedTgsCase.sourceSequence - 1]
|
|
||||||
?? Math.round(selectedTgsCase.sessionSeconds * 1_000_000_000)
|
|
||||||
: spatialRequestTimeNs;
|
|
||||||
const currentFrameTimeNs = review.frameSourceTimesNs[sequenceIndex]
|
|
||||||
?? Math.round(playbackController.playback.currentSeconds * 1_000_000_000);
|
|
||||||
const tgsWithinEvidenceWindow = Boolean(
|
|
||||||
selectedTgsCase
|
|
||||||
&& canonicalRecordedLabTgsIsCurrent(currentFrameTimeNs, selectedTgsTimeNs),
|
|
||||||
);
|
|
||||||
const tgsReferenceEvidence = useCanonicalRecordedLabSpatialFrame({
|
|
||||||
sessionId: review.sessionId,
|
|
||||||
generationSha256: replayLaunch?.sha256 ?? null,
|
|
||||||
targetTimeNs: selectedTgsTimeNs,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showTgs || !selectedTgsCase) {
|
|
||||||
setTgsAnchor(null);
|
|
||||||
setTgsAnchorLoading(false);
|
|
||||||
setTgsAnchorError(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const controller = new AbortController();
|
|
||||||
setTgsAnchorLoading(true);
|
|
||||||
setTgsAnchorError(null);
|
|
||||||
void fetchVegetationRouteTgsAnchor(
|
|
||||||
review.linkedRouteReviewResultId,
|
|
||||||
selectedTgsCase.sourceSequence,
|
|
||||||
{ signal: controller.signal },
|
|
||||||
).then((anchor) => {
|
|
||||||
if (!controller.signal.aborted) setTgsAnchor(anchor);
|
|
||||||
}).catch((caught: unknown) => {
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
setTgsAnchorError(caught instanceof Error ? caught.message : "TGS anchor недоступен.");
|
|
||||||
}
|
|
||||||
}).finally(() => {
|
|
||||||
if (!controller.signal.aborted) setTgsAnchorLoading(false);
|
|
||||||
});
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [review.linkedRouteReviewResultId, selectedTgsCase?.sourceSequence, showTgs]);
|
|
||||||
|
|
||||||
const packedTgsCells = useMemo<LaboratoryMetricPackedCellEvidence | undefined>(() => {
|
|
||||||
if (!tgsAnchor || !tgsWithinEvidenceWindow) return undefined;
|
|
||||||
const currentBody = spatialEvidence.frame?.bodyFrame;
|
|
||||||
const anchorBody = tgsReferenceEvidence.frame?.bodyFrame;
|
|
||||||
if (!currentBody || !anchorBody) return undefined;
|
|
||||||
return canonicalRecordedLabPackedTgsCells(tgsAnchor.costmap, anchorBody, currentBody);
|
|
||||||
}, [
|
|
||||||
spatialEvidence.frame?.bodyFrame,
|
|
||||||
tgsAnchor,
|
|
||||||
tgsReferenceEvidence.frame?.bodyFrame,
|
|
||||||
tgsWithinEvidenceWindow,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const semanticOverlay = showCameraSemantic ? {
|
|
||||||
src: vegetationFullRouteMaskUrl(resultId, semanticLayer, sequenceIndex),
|
|
||||||
prefetchSrcs,
|
|
||||||
classes: semantic.classes,
|
|
||||||
palette: semantic.palette,
|
|
||||||
opacity: 0.46,
|
|
||||||
ariaLabel: `${layer.name} semantic prediction frame ${sequence}`,
|
|
||||||
} : undefined;
|
|
||||||
|
|
||||||
const mediaContent = (
|
|
||||||
<div className="m4-replay-threat-visual__media-layer" data-media={mediaMode ?? "none"}>
|
|
||||||
{videoSource ? (
|
|
||||||
<RecordedEvidenceVideoScene
|
|
||||||
source={videoSource}
|
|
||||||
playback={playbackController.playback}
|
|
||||||
imageWidth={review.width}
|
|
||||||
imageHeight={review.height}
|
|
||||||
boxes={[]}
|
|
||||||
semanticOverlay={semanticOverlay}
|
|
||||||
ariaLabel={`RAVNOVES004TREE recorded frame ${sequence}`}
|
|
||||||
interactive={false}
|
|
||||||
segmentSequence={sequence}
|
|
||||||
segmentCount={review.frameCount}
|
|
||||||
onPlaybackChange={playbackController.synchronize}
|
|
||||||
playbackAuthority="host"
|
|
||||||
playbackTransport="epoch-stream"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="l3-visual-audit__state" role={videoError ? "alert" : "status"}>
|
|
||||||
{videoError ?? "Открываем автономный RAVNOVES004TREE source…"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const spatialContent = spatialMode ? (
|
|
||||||
<>
|
|
||||||
{spatialEvidence.frame ? (
|
|
||||||
<LaboratoryMetricEvidenceScene
|
|
||||||
ref={metricSceneRef}
|
|
||||||
pointCloudBodyXyzM={showSourcePoints
|
|
||||||
? spatialEvidence.frame.sourcePointsBodyXyzM
|
|
||||||
: []}
|
|
||||||
localSurfaceBodyXyzM={showLocalSlam
|
|
||||||
? spatialEvidence.frame.localSlamBodyXyzM
|
|
||||||
: []}
|
|
||||||
obstacles={[]}
|
|
||||||
rig={{
|
|
||||||
lengthM: 1,
|
|
||||||
widthM: 0.8,
|
|
||||||
nominalSensorHeightM: spatialEvidence.frame.sensorHeight.meters,
|
|
||||||
}}
|
|
||||||
corridor={{ forwardLengthM: 12, rearMarginM: 1, halfWidthM: 0.4 }}
|
|
||||||
occupiedVoxelSizeM={tgsAnchor?.costmap.cellSizeM ?? 0.45}
|
|
||||||
mode={spatialMode}
|
|
||||||
label="RAV004 canonical source points, local SLAM and TGS costmap"
|
|
||||||
showCurrentIncrement={showSourcePoints}
|
|
||||||
showLocalSurface={showLocalSlam}
|
|
||||||
showRollingMap={showTgs}
|
|
||||||
showLowStep={false}
|
|
||||||
classifiedPackedCells={packedTgsCells}
|
|
||||||
classifiedCellSizeM={tgsAnchor?.costmap.cellSizeM}
|
|
||||||
showClassifiedCells={showTgs && Boolean(packedTgsCells)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="l3-visual-audit__state" role={spatialEvidence.error ? "alert" : "status"}>
|
|
||||||
{spatialEvidence.loading ? <span className="busy-indicator" aria-hidden="true" /> : null}
|
|
||||||
<span>{spatialEvidence.error ?? "Открываем source points и Local SLAM из sealed RRD…"}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showTgs && selectedTgsCase ? (
|
|
||||||
<div className="m4-replay-threat-visual__pane-status" role="status">
|
|
||||||
{tgsAnchorError ?? linkedReviewError ?? (tgsAnchorLoading
|
|
||||||
? `Открываем sealed TGS anchor ${selectedTgsCase.sourceSequence}; source/SLAM и общий clock продолжаются.`
|
|
||||||
: tgsWithinEvidenceWindow
|
|
||||||
? `TGS anchor ${selectedTgsCase.sourceSequence} из 10; source/SLAM и общий clock продолжаются.`
|
|
||||||
: `TGS anchor ${selectedTgsCase.sourceSequence} старше доказанного окна 1 с; слой скрыт, playback продолжается.`)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
const mediaLayerControls = (
|
|
||||||
<div
|
|
||||||
className="m4-replay-threat-visual__pane-layer-controls"
|
|
||||||
role="group"
|
|
||||||
aria-label="Слои камеры и видео"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="compact"
|
|
||||||
shape="pill"
|
|
||||||
variant={showCameraSemantic ? "primary" : "secondary"}
|
|
||||||
aria-pressed={showCameraSemantic}
|
|
||||||
onClick={() => setShowCameraSemantic((visible) => !visible)}
|
|
||||||
>
|
|
||||||
SEMANTICS
|
|
||||||
</Button>
|
|
||||||
<SegmentedControl
|
|
||||||
value={semanticLayer}
|
|
||||||
items={[...FULL_ROUTE_SEMANTIC_MODES]}
|
|
||||||
label="Источник семантики"
|
|
||||||
onChange={(value) => {
|
|
||||||
setSemanticLayer(value);
|
|
||||||
setShowCameraSemantic(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const spatialLayerControls = (
|
|
||||||
<div
|
|
||||||
className="m4-replay-threat-visual__pane-layer-controls"
|
|
||||||
role="group"
|
|
||||||
aria-label="Слои 3D и плана"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="compact"
|
|
||||||
shape="pill"
|
|
||||||
variant={showSourcePoints ? "primary" : "secondary"}
|
|
||||||
aria-pressed={showSourcePoints}
|
|
||||||
onClick={() => setShowSourcePoints((visible) => !visible)}
|
|
||||||
>
|
|
||||||
SOURCE POINTS
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="compact"
|
|
||||||
shape="pill"
|
|
||||||
variant={showLocalSlam ? "primary" : "secondary"}
|
|
||||||
aria-pressed={showLocalSlam}
|
|
||||||
onClick={() => setShowLocalSlam((visible) => !visible)}
|
|
||||||
>
|
|
||||||
LOCAL SLAM
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="compact"
|
|
||||||
shape="pill"
|
|
||||||
variant={showTgs ? "primary" : "secondary"}
|
|
||||||
aria-pressed={showTgs}
|
|
||||||
disabled={!linkedReview}
|
|
||||||
title={linkedReviewError ?? "10 sealed causal TGS anchors; continuous TGS отсутствует"}
|
|
||||||
onClick={() => setShowTgs((visible) => !visible)}
|
|
||||||
>
|
|
||||||
TGS COSTMAP
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="compact"
|
|
||||||
shape="pill"
|
|
||||||
variant={showCameraSemantic ? "primary" : "secondary"}
|
|
||||||
aria-pressed={showCameraSemantic}
|
|
||||||
title="Recorded semantic layer; camera-aligned prediction, без выдуманной 3D-проекции"
|
|
||||||
onClick={() => setShowCameraSemantic((visible) => !visible)}
|
|
||||||
>
|
|
||||||
SEMANTICS
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetSpatialView = (
|
|
||||||
<IconButton
|
|
||||||
label="Сбросить ракурс"
|
|
||||||
onClick={() => metricSceneRef.current?.resetView()}
|
|
||||||
>
|
|
||||||
<Icon name="refresh" size={16} />
|
|
||||||
</IconButton>
|
|
||||||
);
|
|
||||||
|
|
||||||
const overlayPanePercent = splitView && splitOrientation === "vertical"
|
|
||||||
? splitPrimarySize
|
|
||||||
: 100;
|
|
||||||
const overlay = (
|
|
||||||
<div
|
|
||||||
className="l3-visual-audit__overlay m4-replay-threat-visual__overlay"
|
|
||||||
style={{
|
|
||||||
"--m4-replay-threat-overlay-pane-width": `${overlayPanePercent}%`,
|
|
||||||
} as CSSProperties}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span>RAVNOVES004TREE · recorded realtime</span>
|
|
||||||
<strong>frame {sequence}/{review.frameCount}</strong>
|
|
||||||
<small>
|
|
||||||
+{(playbackController.playback.currentSeconds - review.timelineStartSeconds).toFixed(3)} с
|
|
||||||
· {playbackController.playback.playing ? "воспроизведение" : "пауза / seek"}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Spatial evidence</span>
|
|
||||||
<strong>{showTgs && selectedTgsCase && tgsWithinEvidenceWindow
|
|
||||||
? `TGS anchor ${selectedTgsCase.sourceSequence} · ${selectedTgsCase.tgs.occupiedCells} occupied`
|
|
||||||
: "source RRD · points + bounded Local SLAM"}</strong>
|
|
||||||
<small>{showTgs
|
|
||||||
? "TGS visible only inside sealed 1 s evidence window · playback retained"
|
|
||||||
: "5 s bounded Local SLAM · ground-rebased recorded source"}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const transport = (
|
|
||||||
<ObservationTimeline
|
|
||||||
className="m4-replay-threat-visual__timeline"
|
|
||||||
active
|
|
||||||
sourceCount={4}
|
|
||||||
mode="recorded"
|
|
||||||
seekable
|
|
||||||
synchronization="host-arrival-best-effort"
|
|
||||||
rangeNs={{
|
|
||||||
min: Math.round(review.timelineStartSeconds * 1_000_000_000),
|
|
||||||
max: Math.round(review.timelineEndSeconds * 1_000_000_000),
|
|
||||||
}}
|
|
||||||
currentNs={Math.round(playbackController.playback.currentSeconds * 1_000_000_000)}
|
|
||||||
playing={playbackController.playback.playing}
|
|
||||||
playbackRate={playbackController.playback.rate ?? 1}
|
|
||||||
onSeek={(timeNs) => playbackController.seek(timeNs / 1_000_000_000)}
|
|
||||||
onPlayingChange={playbackController.setPlaying}
|
|
||||||
onPlaybackRateChange={playbackController.setRate}
|
|
||||||
showJumpToEnd={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CanonicalRecordedLabReplay
|
<M4ReplayThreatVisual
|
||||||
label="RAVNOVES004TREE full recorded review"
|
resultId={resultId}
|
||||||
mediaMode={mediaMode ?? "none"}
|
timelineEndpointRoot={VEGETATION_TIMELINE_ENDPOINT}
|
||||||
mediaModes={FULL_ROUTE_MEDIA_MODES}
|
semanticLayers={semanticLayers}
|
||||||
spatialMode={spatialMode ?? "none"}
|
initialSemanticLayerId="vegetation"
|
||||||
spatialModes={FULL_ROUTE_SPATIAL_MODES}
|
initialSpatialMode="3d"
|
||||||
expanded={expanded}
|
classifiedSpatialLayer={sealedSpatialGap}
|
||||||
splitPrimarySize={splitPrimarySize}
|
evidenceLabel="RAVNOVES004TREE"
|
||||||
splitOrientation={splitOrientation}
|
playbackTransport="segmented"
|
||||||
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео"}
|
recoverTimestampStalls
|
||||||
spatialAriaLabel={spatialMode === "3d" ? "Трёхмерная сцена" : "Вид сверху"}
|
showReferenceMediaLayers
|
||||||
mediaLayerControls={mediaLayerControls}
|
showSpatialOverlaySummary
|
||||||
spatialLayerControls={spatialLayerControls}
|
|
||||||
spatialLeadingControl={resetSpatialView}
|
|
||||||
mediaMultiLayer
|
|
||||||
mediaContent={mediaContent}
|
|
||||||
spatialContent={spatialContent}
|
|
||||||
emptyMessage="Выберите VIDEO/CAMERA или 3D/PLAN. Общий таймлайн останется на месте."
|
|
||||||
overlay={overlay}
|
|
||||||
transport={transport}
|
|
||||||
trailingActions={!splitView && spatialMode ? resetSpatialView : null}
|
|
||||||
onMediaModeChange={handleMediaModeChange}
|
|
||||||
onSpatialModeChange={handleSpatialModeChange}
|
|
||||||
onExpandedChange={setExpanded}
|
|
||||||
onSplitPrimarySizeChange={setSplitPrimarySize}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -575,32 +101,32 @@ function FullRouteReviewResult({
|
|||||||
summary={(
|
summary={(
|
||||||
<LaboratorySummary
|
<LaboratorySummary
|
||||||
title="LAB V1 · RAVNOVES004TREE · полный маршрут"
|
title="LAB V1 · RAVNOVES004TREE · полный маршрут"
|
||||||
description="Общий recorded-LAB шаблон воспроизводит запись с травой и оврагами: RIGHT camera, исходное облако, ограниченный Local SLAM, два независимых semantic-слоя и десять реально просчитанных TGS-якорей."
|
description="Принятый recorded-LAB инструмент воспроизводит RAV004 без отдельного viewer: одна media-clock timeline, RIGHT camera, source points, bounded Local SLAM и переключаемые EoMT/DDRNet."
|
||||||
status="FULL RECORDED REVIEW · truth отсутствует · commands OFF"
|
status="FULL RECORDED REVIEW · truth отсутствует · commands OFF"
|
||||||
statusTone="warning"
|
statusTone="warning"
|
||||||
facts={[
|
facts={[
|
||||||
{ label: "Источник", value: `${review.sourceId} · ${review.frameCount}/${review.frameCount} frames` },
|
{ label: "Источник", value: `${review.sourceId} · ${review.frameCount}/${review.frameCount} camera frames` },
|
||||||
{ label: "3D", value: "1437 source point chunks · 2825 SLAM poses · recorded RRD" },
|
{ label: "3D", value: "1444 source cloud increments · gravity-stable RFU → body" },
|
||||||
{ label: "Город", value: `${review.city.name} · ${decimal(review.city.inferenceFps, 2)} fps` },
|
{ label: "Город", value: `${review.city.name} · ${decimal(review.city.inferenceFps, 2)} fps` },
|
||||||
{ label: "Природа", value: `${review.vegetation.name} · ${decimal(review.vegetation.inferenceFps, 2)} fps` },
|
{ label: "Природа", value: `${review.vegetation.name} · ${decimal(review.vegetation.inferenceFps, 2)} fps` },
|
||||||
{ label: "TGS", value: "10 sealed causal anchors · continuous costmap отсутствует" },
|
{ label: "TGS", value: "10 review anchors существуют · full-route artifact отсутствует" },
|
||||||
{ label: "Authority", value: `${rigLabel} · VISUAL REVIEW ONLY · commands OFF` },
|
{ label: "Authority", value: `${rigLabel} · VISUAL REVIEW ONLY · commands OFF` },
|
||||||
]}
|
]}
|
||||||
brief={{
|
brief={{
|
||||||
question: "Что реально видно на полном RAV004-прогоне с высокой травой, оврагами и переходом к городу?",
|
question: "Что реально видно на полном RAV004-прогоне с высокой травой, оврагами и переходом к городу?",
|
||||||
approach: "Одна recorded timeline открывается общим LAB viewer. Camera и RRD синхронизированы; EoMT/DDRNet переключаются на камере, source points и 5-секундный Local SLAM — в 3D, TGS — только в доказанном окне десяти запечатанных якорей.",
|
approach: "RAV004 поставляет только data/provider configuration в тот же M4 recorded viewer. Видеодекодер владеет clock; новые source increments проецируются в gravity-stable forward/left/up frame, Local SLAM ограничен пятью секундами.",
|
||||||
principalResult: "RAV004 больше не подменяется RAV00: доступна полная исходная запись и её реальные пространственные слои.",
|
principalResult: "RAV004 больше не имеет отдельной логики окон, таймера, seek, cache или 3D controls. Модели и подписи меняются конфигурацией, архитектура переключения остаётся общей.",
|
||||||
limitation: "Ручной truth, continuous TGS и point-aligned 3D semantics отсутствуют. Один повреждённый H.264-пакет на позиции 6092 заменён предыдущим декодированным кадром и отражён в proof.",
|
limitation: "Full-route TGS, независимый person/vehicle detector, ручной truth и point-aligned 3D semantics пока не запечатаны. Semantic-derived рамки диагностические и не являются STOP-authority.",
|
||||||
}}
|
}}
|
||||||
method={{
|
method={{
|
||||||
completeness: "complete",
|
completeness: "complete",
|
||||||
executionClass: "ai-inference",
|
executionClass: "ai-inference",
|
||||||
pipelineId: "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1",
|
pipelineId: "canonical-recorded-lab-rav004tree/v3",
|
||||||
components: [
|
components: [
|
||||||
{ kind: "algorithm", name: "Recorded source points + bounded Local SLAM", version: "source-paced-ground-v2", role: "spatial source evidence", identitySha256: null },
|
{ kind: "algorithm", name: "Canonical recorded replay", version: "media-clock / one viewer", role: "shared camera + spatial transport", identitySha256: null },
|
||||||
|
{ kind: "algorithm", name: "Recorded source points + bounded Local SLAM", version: "source-paced-ground-v3", role: "gravity-stable spatial evidence", identitySha256: null },
|
||||||
{ kind: "model", name: review.city.name, version: "sealed Worker 006 run", role: "urban semantic review", identitySha256: null },
|
{ kind: "model", name: review.city.name, version: "sealed Worker 006 run", role: "urban semantic review", identitySha256: null },
|
||||||
{ kind: "model", name: review.vegetation.name, version: "GOOSE DDRNet-39", role: "vegetation semantic review", identitySha256: null },
|
{ kind: "model", name: review.vegetation.name, version: "GOOSE DDRNet-39", role: "vegetation semantic review", identitySha256: null },
|
||||||
{ kind: "algorithm", name: "Causal TGS", version: "10 linked route anchors", role: "bounded geometric evidence", identitySha256: null },
|
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -617,19 +143,19 @@ function FullRouteReviewResult({
|
|||||||
)}
|
)}
|
||||||
result={(
|
result={(
|
||||||
<LaboratoryResultSummary
|
<LaboratoryResultSummary
|
||||||
title="Полный RAV004 visual review восстановлен; управление не авторизовано"
|
title="RAV004 переведён на общий replay-каркас; safety evidence ещё не полно"
|
||||||
status="Recorded evidence ready · navigation/actuation OFF"
|
status="Recorded evidence · navigation/actuation OFF"
|
||||||
statusTone="warning"
|
statusTone="warning"
|
||||||
metrics={[
|
metrics={[
|
||||||
{ label: "Route masks", value: "6830/6830 × 2", hint: "sealed local archives · Worker не требуется" },
|
{ label: "Camera timeline", value: "6830 frames · ≈9.51 Hz", hint: "media clock owns video, overlays and spatial" },
|
||||||
{ label: "EoMT throughput", value: `${decimal(review.city.inferenceFps, 2)} fps`, hint: "изолированный полный прогон" },
|
{ label: "Source geometry", value: "1444 increments · ≈2 Hz", hint: "last proven spatial frame is held between source arrivals" },
|
||||||
{ label: "DDRNet throughput", value: `${decimal(review.vegetation.inferenceFps, 2)} fps`, hint: "изолированный полный прогон" },
|
{ label: "EoMT throughput", value: `${decimal(review.city.inferenceFps, 2)} fps`, hint: "изолированный full pass; не realtime stack" },
|
||||||
{ label: "Spatial evidence", value: "RRD + 10 TGS anchors", hint: "continuous TGS и 3D semantics отсутствуют" },
|
{ label: "DDRNet throughput", value: `${decimal(review.vegetation.inferenceFps, 2)} fps`, hint: "изолированный full pass; temporal stability не принята" },
|
||||||
]}
|
]}
|
||||||
conclusion={{
|
conclusion={{
|
||||||
proved: "Полный RAV004 открывается в каноническом recorded viewer с camera, source points, bounded Local SLAM, EoMT, DDRNet и связанными TGS-якорями.",
|
proved: "Camera, seek, spatial layers and semantic switching use one accepted reusable viewer and one media clock; RFU source geometry no longer inherits LiDAR roll/pitch.",
|
||||||
notProved: "Не доказаны truth accuracy, временная стабильность DDRNet, continuous negative-obstacle detection и безопасное управление ровером.",
|
notProved: "Не доказаны continuous TGS, независимый detector/STOP, truth accuracy, temporal stability DDRNet и ≥10 FPS совместного live stack.",
|
||||||
decision: "Использовать как visual audit. Следующий gate — truth-набор овраг/трава/дерево/яма и motion-aware temporal evaluation при целевых ≥10 FPS; navigation/actuation оставить OFF.",
|
decision: "Продолжать как visual audit. До запечатанного full-route TGS и detector/load gate navigation/actuation остаются OFF.",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -754,27 +280,9 @@ export function VegetationShadowResultView({
|
|||||||
executionClass: "ai-inference",
|
executionClass: "ai-inference",
|
||||||
pipelineId: "ravnoves-eomt-ddrnet-yolox-causal-tgs-recorded-review/v1",
|
pipelineId: "ravnoves-eomt-ddrnet-yolox-causal-tgs-recorded-review/v1",
|
||||||
components: [
|
components: [
|
||||||
{
|
{ kind: "model", name: "EoMT Cityscapes semantic", version: "sealed E47 archive", role: "urban semantic review", identitySha256: null },
|
||||||
kind: "model",
|
{ kind: "model", name: selected.loadedModelName, version: selected.candidate, role: "vegetation material candidate", identitySha256: selected.checkpointSha256 },
|
||||||
name: "EoMT Cityscapes semantic",
|
{ kind: "algorithm", name: "Frozen YOLOX + causal TGS", version: "linked M4/M4.9 archives", role: "independent object and geometry veto", identitySha256: null },
|
||||||
version: "sealed E47 archive",
|
|
||||||
role: "urban semantic review",
|
|
||||||
identitySha256: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "model",
|
|
||||||
name: selected.loadedModelName,
|
|
||||||
version: selected.candidate,
|
|
||||||
role: "vegetation material candidate",
|
|
||||||
identitySha256: selected.checkpointSha256,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "algorithm",
|
|
||||||
name: "Frozen YOLOX + causal TGS",
|
|
||||||
version: "linked M4/M4.9 archives",
|
|
||||||
role: "independent object and geometry veto",
|
|
||||||
identitySha256: null,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -795,26 +303,10 @@ export function VegetationShadowResultView({
|
|||||||
status="Semantics advisory · YOLOX/TGS veto cannot be cleared"
|
status="Semantics advisory · YOLOX/TGS veto cannot be cleared"
|
||||||
statusTone="warning"
|
statusTone="warning"
|
||||||
metrics={[
|
metrics={[
|
||||||
{
|
{ label: "Route masks", value: `${route.frameCount}/${route.frameCount}`, hint: "sealed local playback · Worker для открытия не нужен" },
|
||||||
label: "Route masks",
|
{ label: "Semantic sources", value: "2 independent layers", hint: "EoMT CITY / DDRNet VEGETATION · display switches, evidence does not fuse" },
|
||||||
value: `${route.frameCount}/${route.frameCount}`,
|
{ label: "Vegetation worker p95", value: `${decimal(selected.shadowLatencyP95Ms, 2)} ms`, hint: "изолированный DDRNet inference; не совместный realtime stack" },
|
||||||
hint: "sealed local playback · Worker для открытия не нужен",
|
{ label: "Vegetation peak VRAM", value: `${decimal(selected.peakReservedVramBytes / 1024 ** 3, 2)} GiB`, hint: "DDRNet candidate на Worker 006" },
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Semantic sources",
|
|
||||||
value: "2 independent layers",
|
|
||||||
hint: "EoMT CITY / DDRNet VEGETATION · display switches, evidence does not fuse",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Vegetation worker p95",
|
|
||||||
value: `${decimal(selected.shadowLatencyP95Ms, 2)} ms`,
|
|
||||||
hint: "изолированный DDRNet inference; не совместный realtime stack",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Vegetation peak VRAM",
|
|
||||||
value: `${decimal(selected.peakReservedVramBytes / 1024 ** 3, 2)} GiB`,
|
|
||||||
hint: "DDRNet candidate на Worker 006",
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
conclusion={{
|
conclusion={{
|
||||||
proved: "На одной recorded timeline доступны городской EoMT, природный DDRNet, YOLOX detections и causal TGS; LAB автономна от Worker.",
|
proved: "На одной recorded timeline доступны городской EoMT, природный DDRNet, YOLOX detections и causal TGS; LAB автономна от Worker.",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
|
|
||||||
const REQUESTED_CHUNK_FRAMES = 24;
|
const REQUESTED_CHUNK_FRAMES = 24;
|
||||||
const RETAINED_CHUNK_COUNT = 4;
|
const RETAINED_CHUNK_COUNT = 4;
|
||||||
|
const RETAINED_CHUNKS_BEHIND = 1;
|
||||||
const PREFETCH_CHUNKS_AHEAD = 1;
|
const PREFETCH_CHUNKS_AHEAD = 1;
|
||||||
const RETAINED_CAMERA_POINT_OVERLAYS = 12;
|
const RETAINED_CAMERA_POINT_OVERLAYS = 12;
|
||||||
|
|
||||||
@@ -31,10 +32,17 @@ export function m4ThreatChunkWindowStarts(
|
|||||||
frameCount: number,
|
frameCount: number,
|
||||||
): readonly number[] {
|
): readonly number[] {
|
||||||
if (chunkSize < 1 || frameCount < 1) return [];
|
if (chunkSize < 1 || frameCount < 1) return [];
|
||||||
return Array.from(
|
return [
|
||||||
{ length: PREFETCH_CHUNKS_AHEAD + 1 },
|
activeChunkStart,
|
||||||
(_, index) => activeChunkStart + index * chunkSize,
|
...Array.from(
|
||||||
).filter((start) => start >= 0 && start < frameCount);
|
{ length: RETAINED_CHUNKS_BEHIND },
|
||||||
|
(_, index) => activeChunkStart - (index + 1) * chunkSize,
|
||||||
|
),
|
||||||
|
...Array.from(
|
||||||
|
{ length: PREFETCH_CHUNKS_AHEAD },
|
||||||
|
(_, index) => activeChunkStart + (index + 1) * chunkSize,
|
||||||
|
),
|
||||||
|
].filter((start) => start >= 0 && start < frameCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cancelM4ThreatChunkRequestsOutsideWindow<T extends { abort(): void }>(
|
export function cancelM4ThreatChunkRequestsOutsideWindow<T extends { abort(): void }>(
|
||||||
|
|||||||
@@ -769,7 +769,7 @@ test("M4.6 local SLAM surface reprojects registered increments into the active b
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("M4.6 spatial buffering keeps the active and one future chunk", () => {
|
test("M4.6 spatial buffering keeps the active and one future chunk", () => {
|
||||||
assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 72]);
|
assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 24, 72]);
|
||||||
assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24]);
|
assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -786,8 +786,8 @@ test("M4.6 spatial buffering drops stale in-flight windows across rapid jumps",
|
|||||||
if (!inFlight.has(start)) inFlight.set(start, controller(start));
|
if (!inFlight.has(start)) inFlight.set(start, controller(start));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert.deepEqual([...inFlight.keys()], [4488]);
|
assert.deepEqual([...inFlight.keys()], [4488, 4464]);
|
||||||
assert.deepEqual(aborted, [0, 24, 1488, 1512]);
|
assert.deepEqual(aborted, [0, 24, 1488, 1464, 1512]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("recorded evidence clock advances by selected rate and stops at the sealed end", () => {
|
test("recorded evidence clock advances by selected rate and stops at the sealed end", () => {
|
||||||
@@ -863,7 +863,7 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
|||||||
assert.match(canonical, /m4-replay-threat-visual__deck/);
|
assert.match(canonical, /m4-replay-threat-visual__deck/);
|
||||||
assert.match(visual, /lastFrameRef/);
|
assert.match(visual, /lastFrameRef/);
|
||||||
assert.match(visual, /lastSpatialFrameRef/);
|
assert.match(visual, /lastSpatialFrameRef/);
|
||||||
assert.match(visual, /const spatialFrame = frame\?\.spatialAvailable/);
|
assert.match(visual, /const spatialFrame = currentSpatialFrame/);
|
||||||
assert.match(visual, /<ObservationTimeline/);
|
assert.match(visual, /<ObservationTimeline/);
|
||||||
assert.match(visual, /useM4ThreatTimelineFrame/);
|
assert.match(visual, /useM4ThreatTimelineFrame/);
|
||||||
assert.match(visual, /resolveObservationSessionReplay\(timeline\.recordedSourceSessionId/);
|
assert.match(visual, /resolveObservationSessionReplay\(timeline\.recordedSourceSessionId/);
|
||||||
@@ -888,7 +888,7 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
|||||||
assert.match(canonical, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/);
|
assert.match(canonical, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/);
|
||||||
assert.match(canonical, /secondaryMode=\{\{/);
|
assert.match(canonical, /secondaryMode=\{\{/);
|
||||||
assert.match(visual, /playback=\{playbackController\.playback\}/);
|
assert.match(visual, /playback=\{playbackController\.playback\}/);
|
||||||
assert.match(visual, /clock: "animation"/);
|
assert.match(visual, /clock: "external"/);
|
||||||
assert.match(visual, /useCanonicalRecordedLabReplayState/);
|
assert.match(visual, /useCanonicalRecordedLabReplayState/);
|
||||||
assert.match(canonical, /current === next \? null : next/);
|
assert.match(canonical, /current === next \? null : next/);
|
||||||
assert.match(visual, /timelineFrame\.activeSequence \+ 1/);
|
assert.match(visual, /timelineFrame\.activeSequence \+ 1/);
|
||||||
@@ -898,8 +898,9 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
|||||||
await readFile(new URL("../src/components/laboratory/useRecordedEvidencePlayback.ts", import.meta.url), "utf8"),
|
await readFile(new URL("../src/components/laboratory/useRecordedEvidencePlayback.ts", import.meta.url), "utf8"),
|
||||||
/if \(clock === "animation"\) return;/,
|
/if \(clock === "animation"\) return;/,
|
||||||
);
|
);
|
||||||
assert.match(visual, /playbackAuthority="host"/);
|
assert.match(visual, /playbackAuthority="media"/);
|
||||||
assert.match(visual, /playbackTransport="epoch-stream"/);
|
assert.match(visual, /playbackTransport = "epoch-stream"/);
|
||||||
|
assert.match(visual, /playbackTransport=\{playbackTransport\}/);
|
||||||
assert.match(
|
assert.match(
|
||||||
await readFile(new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url), "utf8"),
|
await readFile(new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url), "utf8"),
|
||||||
/if \(playbackAuthority === "host"\) return;/,
|
/if \(playbackAuthority === "host"\) return;/,
|
||||||
@@ -927,6 +928,7 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
|||||||
assert.match(visualCss, /laboratory-metric-evidence-scene__legend/);
|
assert.match(visualCss, /laboratory-metric-evidence-scene__legend/);
|
||||||
assert.match(visualCss, /bottom: auto/);
|
assert.match(visualCss, /bottom: auto/);
|
||||||
assert.match(videoScene, /<RecordedFmp4Player/);
|
assert.match(videoScene, /<RecordedFmp4Player/);
|
||||||
|
assert.match(videoScene, /!playback\.playing && Math\.abs\(next\.currentSeconds - playback\.currentSeconds\) > 0\.35/);
|
||||||
assert.match(imageScene, /<RecordedEvidenceBoxOverlay/);
|
assert.match(imageScene, /<RecordedEvidenceBoxOverlay/);
|
||||||
assert.match(imageScene, /<RecordedEvidencePointCloudOverlay/);
|
assert.match(imageScene, /<RecordedEvidencePointCloudOverlay/);
|
||||||
assert.match(videoScene, /<RecordedEvidencePointCloudOverlay/);
|
assert.match(videoScene, /<RecordedEvidencePointCloudOverlay/);
|
||||||
@@ -935,13 +937,14 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
|||||||
assert.match(metricScene, /OrbitControls/);
|
assert.match(metricScene, /OrbitControls/);
|
||||||
assert.match(visual, /LOCAL SLAM/);
|
assert.match(visual, /LOCAL SLAM/);
|
||||||
assert.match(visual, /showLocalSurface/);
|
assert.match(visual, /showLocalSurface/);
|
||||||
|
assert.match(visual, /const latestAvailableSpatialFrame = \[\.\.\.timelineFrame\.availableFrames\][\s\S]*candidate\.spatialAvailable[\s\S]*candidate\.sequence <= timelineFrame\.activeSequence/);
|
||||||
assert.match(
|
assert.match(
|
||||||
visual,
|
visual,
|
||||||
/pointCloudBodyXyzM=\{displayedClassifiedSpatialFrame && replaceClassifiedPointCloud[\s\S]*\? classifiedPointsBody[\s\S]*: classifiedContextSpatialFrame\?\.pointCloudBodyXyzM \?\? \[\]\}/,
|
/pointCloudBodyXyzM=\{displayedClassifiedSpatialFrame && replaceClassifiedPointCloud[\s\S]*\? classifiedPointsBody[\s\S]*: classifiedContextSpatialFrame\?\.pointCloudBodyXyzM[\s\S]*\?\? activeSpatialFrame\?\.pointCloudBodyXyzM[\s\S]*\?\? \[\]\}/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
visual,
|
visual,
|
||||||
/const classifiedSpatialFrame = classifiedSpatialLayer\?\.frame\?\.sourceSequence === timelineFrame\.activeSequence[\s\S]*lastClassifiedSpatialFrameRef[\s\S]*const displayedClassifiedSpatialFrame = classifiedSpatialFrame\s*&&\s*classifiedSpatialFrame\.sampleAvailable !== false/,
|
/const classifiedSpatialFrame = hasClassifiedSpatialOutput[\s\S]*classifiedSpatialLayer\?\.frame\?\.sourceSequence === timelineFrame\.activeSequence[\s\S]*lastClassifiedSpatialFrameRef[\s\S]*const displayedClassifiedSpatialFrame = classifiedSpatialFrame\s*&&\s*classifiedSpatialFrame\.sampleAvailable !== false/,
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(visual, /classifiedSpatialFrame\?\.sampleAvailable !== false/);
|
assert.doesNotMatch(visual, /classifiedSpatialFrame\?\.sampleAvailable !== false/);
|
||||||
assert.match(visual, /timelineFrame\.availableFrames\.find/);
|
assert.match(visual, /timelineFrame\.availableFrames\.find/);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ let recordedMediaDecodeStartSequence;
|
|||||||
let recordedMediaSegmentAppendOrder;
|
let recordedMediaSegmentAppendOrder;
|
||||||
let recordedMediaSegmentSequenceAtTime;
|
let recordedMediaSegmentSequenceAtTime;
|
||||||
let recordedMediaCanRollTarget;
|
let recordedMediaCanRollTarget;
|
||||||
|
let recordedMediaTimestampStallRecoveryTarget;
|
||||||
let nextRecordedMediaRandomAccessSequence;
|
let nextRecordedMediaRandomAccessSequence;
|
||||||
let recordedMediaRecoveryTargetSequence;
|
let recordedMediaRecoveryTargetSequence;
|
||||||
let selectRecordedMediaPreparationEpoch;
|
let selectRecordedMediaPreparationEpoch;
|
||||||
@@ -32,6 +33,7 @@ before(async () => {
|
|||||||
recordedMediaSegmentAppendOrder,
|
recordedMediaSegmentAppendOrder,
|
||||||
recordedMediaSegmentSequenceAtTime,
|
recordedMediaSegmentSequenceAtTime,
|
||||||
recordedMediaCanRollTarget,
|
recordedMediaCanRollTarget,
|
||||||
|
recordedMediaTimestampStallRecoveryTarget,
|
||||||
nextRecordedMediaRandomAccessSequence,
|
nextRecordedMediaRandomAccessSequence,
|
||||||
recordedMediaRecoveryTargetSequence,
|
recordedMediaRecoveryTargetSequence,
|
||||||
selectRecordedMediaPreparationEpoch,
|
selectRecordedMediaPreparationEpoch,
|
||||||
@@ -270,6 +272,12 @@ test("recorded player preserves forward rolling playback but seeks backward clip
|
|||||||
assert.equal(recordedMediaCanRollTarget(20, 21, true, false), false);
|
assert.equal(recordedMediaCanRollTarget(20, 21, true, false), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("recorded player skips only a proven buffered corrupt timestamp interval", () => {
|
||||||
|
assert.equal(recordedMediaTimestampStallRecoveryTarget(11.422, [[0, 16.287]]), 11.602);
|
||||||
|
assert.equal(recordedMediaTimestampStallRecoveryTarget(16.25, [[0, 16.287]]), null);
|
||||||
|
assert.equal(recordedMediaTimestampStallRecoveryTarget(20, [[0, 16.287]]), null);
|
||||||
|
});
|
||||||
|
|
||||||
test("loading and error overlays fully conceal recorded camera pixels", async () => {
|
test("loading and error overlays fully conceal recorded camera pixels", async () => {
|
||||||
const css = await readFile(
|
const css = await readFile(
|
||||||
new URL("../src/styles/observation.css", import.meta.url),
|
new URL("../src/styles/observation.css", import.meta.url),
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ test("canonical recorded LAB spatial frame keeps source, SLAM and body identity
|
|||||||
fetcher: async (url) => {
|
fetcher: async (url) => {
|
||||||
requestedUrl = String(url);
|
requestedUrl = String(url);
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
schema_version: "missioncore.canonical-recorded-lab-spatial-frame/v2",
|
schema_version: "missioncore.canonical-recorded-lab-spatial-frame/v3",
|
||||||
target_time_ns: 82_770_000_000,
|
target_time_ns: 82_770_000_000,
|
||||||
source_time_ns: 82_769_535_708,
|
source_time_ns: 82_769_535_708,
|
||||||
pose_time_ns: 82_769_535_708,
|
pose_time_ns: 82_769_535_708,
|
||||||
@@ -415,13 +415,13 @@ test("canonical recorded LAB spatial frame keeps source, SLAM and body identity
|
|||||||
coordinate_frame: "body-ground",
|
coordinate_frame: "body-ground",
|
||||||
sensor_height: {
|
sensor_height: {
|
||||||
meters: 0.32,
|
meters: 0.32,
|
||||||
source: "initial-source-cloud-lower-quantile-median",
|
source: "local-source-cloud-ground-quantile-median",
|
||||||
sample_count: 20,
|
sample_count: 20,
|
||||||
mad_m: 0.03,
|
mad_m: 0.03,
|
||||||
authority: "visual-derived",
|
authority: "visual-derived",
|
||||||
},
|
},
|
||||||
spatial_profile: {
|
spatial_profile: {
|
||||||
profile_id: "source-paced-ground-v2",
|
profile_id: "source-paced-ground-v3",
|
||||||
local_slam_history_seconds: 5,
|
local_slam_history_seconds: 5,
|
||||||
local_slam_radius_m: 30,
|
local_slam_radius_m: 30,
|
||||||
local_slam_voxel_size_m: 0.12,
|
local_slam_voxel_size_m: 0.12,
|
||||||
@@ -443,7 +443,7 @@ test("canonical recorded LAB spatial frame keeps source, SLAM and body identity
|
|||||||
});
|
});
|
||||||
assert.equal(
|
assert.equal(
|
||||||
requestedUrl,
|
requestedUrl,
|
||||||
`/api/v1/observation-sessions/session-004/canonical-lab/spatial-frame?generation=${generation}&time_ns=82770000000&profile=source-paced-ground-v2`,
|
`/api/v1/observation-sessions/session-004/canonical-lab/spatial-frame?generation=${generation}&time_ns=82770000000&profile=source-paced-ground-v3`,
|
||||||
);
|
);
|
||||||
assert.equal(frame.sourcePointCount, 2);
|
assert.equal(frame.sourcePointCount, 2);
|
||||||
assert.equal(frame.localSlamBodyXyzM.length, 2);
|
assert.equal(frame.localSlamBodyXyzM.length, 2);
|
||||||
@@ -473,32 +473,23 @@ test("vegetation realtime LAB and archival benchmark use separate admitted instr
|
|||||||
assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/);
|
assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/);
|
||||||
assert.match(resultSource, /M49TgsFullShadowEvidence/);
|
assert.match(resultSource, /M49TgsFullShadowEvidence/);
|
||||||
assert.match(resultSource, /semanticOverride/);
|
assert.match(resultSource, /semanticOverride/);
|
||||||
assert.match(resultSource, /EoMT CITY \/ DDRNet VEGETATION/);
|
|
||||||
assert.match(m49Source, /spatialSemantic=\{spatialSemantic\}/);
|
assert.match(m49Source, /spatialSemantic=\{spatialSemantic\}/);
|
||||||
assert.match(m49Source, /controlLabel: "SEMANTICS"/);
|
assert.match(m49Source, /controlLabel: "SEMANTICS"/);
|
||||||
assert.equal(resultSource.match(/<LaboratoryEvidence\b/g)?.length, 2);
|
assert.equal(resultSource.match(/<LaboratoryEvidence\b/g)?.length, 2);
|
||||||
assert.doesNotMatch(resultSource, /RAVNOVES004TREE mixed route review/);
|
assert.doesNotMatch(resultSource, /RAVNOVES004TREE mixed route review/);
|
||||||
assert.match(resultSource, /RAVNOVES004TREE full recorded review/);
|
assert.match(resultSource, /CANONICAL RECORDED LAB · RAVNOVES004TREE/);
|
||||||
assert.match(resultSource, /CanonicalRecordedLabReplay/);
|
assert.match(resultSource, /<M4ReplayThreatVisual/);
|
||||||
assert.match(resultSource, /RecordedEvidenceVideoScene/);
|
assert.match(resultSource, /timelineEndpointRoot=\{VEGETATION_TIMELINE_ENDPOINT\}/);
|
||||||
assert.match(resultSource, /LaboratoryMetricEvidenceScene/);
|
assert.match(resultSource, /playbackTransport="segmented"/);
|
||||||
|
assert.match(resultSource, /recoverTimestampStalls/);
|
||||||
assert.doesNotMatch(resultSource, /RerunViewport/);
|
assert.doesNotMatch(resultSource, /RerunViewport/);
|
||||||
assert.match(resultSource, /useCanonicalRecordedLabSpatialFrame/);
|
|
||||||
assert.doesNotMatch(resultSource, /cacheRef|pumpRef|desiredRef/);
|
assert.doesNotMatch(resultSource, /cacheRef|pumpRef|desiredRef/);
|
||||||
assert.match(resultSource, /useCanonicalRecordedLabReplayState/);
|
|
||||||
assert.match(resultSource, /playbackTransport="epoch-stream"/);
|
|
||||||
assert.match(resultSource, /causalTgsCase/);
|
|
||||||
assert.match(resultSource, /TGS visible only inside sealed 1 s evidence window/);
|
|
||||||
assert.match(resultSource, /canonicalRecordedLabPackedTgsCells/);
|
|
||||||
assert.doesNotMatch(resultSource, /LaboratoryRecordedClipPlayer|M48EvidenceModeRail/);
|
assert.doesNotMatch(resultSource, /LaboratoryRecordedClipPlayer|M48EvidenceModeRail/);
|
||||||
assert.doesNotMatch(resultSource, /assets\.tgs|<img/);
|
assert.doesNotMatch(resultSource, /assets\.tgs|<img/);
|
||||||
assert.match(resultSource, /SOURCE POINTS/);
|
assert.match(resultSource, /SOURCE POINTS/);
|
||||||
assert.match(resultSource, /LOCAL SLAM/);
|
|
||||||
assert.match(resultSource, /TGS COSTMAP/);
|
assert.match(resultSource, /TGS COSTMAP/);
|
||||||
assert.match(resultSource, /onClick=\{\(\) => setShowTgs\(\(visible\) => !visible\)\}/);
|
assert.match(resultSource, /point-aligned 3D semantics пока не запечатаны/);
|
||||||
assert.match(resultSource, /showClassifiedCells=\{showTgs && Boolean\(packedTgsCells\)\}/);
|
assert.match(resultSource, /cellLayerAvailable: false/);
|
||||||
assert.doesNotMatch(resultSource, /setShowTgs\(false\)/);
|
|
||||||
assert.doesNotMatch(resultSource, /setPlaying\(false\);[\s\S]{0,160}setShowTgs/);
|
|
||||||
assert.match(canonicalSource, /primary=\{mediaPane\}/);
|
assert.match(canonicalSource, /primary=\{mediaPane\}/);
|
||||||
assert.match(canonicalSource, /secondary=\{spatialPane/);
|
assert.match(canonicalSource, /secondary=\{spatialPane/);
|
||||||
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
|
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
@@ -0,0 +1,167 @@
|
|||||||
|
# RAVNOVES004TREE canonical LAB replay audit
|
||||||
|
|
||||||
|
Date: 2026-08-30
|
||||||
|
|
||||||
|
Scope: Mission Core recorded LAB replay, RAVNOVES004TREE, OPS perception state
|
||||||
|
|
||||||
|
Excluded: Gaussian/simulation workers and their artifacts
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
RAVNOVES004TREE no longer owns a custom LAB viewer. It supplies recording and
|
||||||
|
model configuration to the same `M4ReplayThreatVisual` and
|
||||||
|
`CanonicalRecordedLabReplay` implementation used by the accepted recorded LAB.
|
||||||
|
No new window or status type was added. The stable interaction contract remains:
|
||||||
|
|
||||||
|
- media: `SEMANTICS`, model selector, `VIDEO` / `CAMERA`;
|
||||||
|
- spatial: `SOURCE POINTS`, `LOCAL SLAM`, `TGS COSTMAP`, `SEMANTICS`, `3D` / `PLAN`;
|
||||||
|
- one timeline, one resizable split and one media-owned playback clock.
|
||||||
|
|
||||||
|
Models, result IDs, endpoints, labels and replay transport are configuration.
|
||||||
|
Window structure, switching, seek, buffering and spatial scene code are shared.
|
||||||
|
|
||||||
|
## Why the previous LAB failed
|
||||||
|
|
||||||
|
### Video and spatial state had different clocks
|
||||||
|
|
||||||
|
The removed RAV004 viewer advanced an animation/host clock even when the browser
|
||||||
|
decoder stopped. The point cloud therefore continued while the camera frame and
|
||||||
|
timeline could remain frozen. The shared viewer now uses the decoded media time
|
||||||
|
as the external clock, and image masks/boxes are rendered only when their time is
|
||||||
|
within 250 ms of the actually presented video time.
|
||||||
|
|
||||||
|
The RAV004 MP4 itself is not clean. An independent `ffmpeg` decode around the
|
||||||
|
reproducible stop at 11.422 s reported non-monotonic DTS values and corrupt H.264
|
||||||
|
macroblocks. The RAV004 profile therefore uses the shared segmented MSE transport
|
||||||
|
and an opt-in timestamp recovery rule. Recovery is allowed only when all of these
|
||||||
|
conditions are true:
|
||||||
|
|
||||||
|
- playback is requested and the media element is not paused, ended or seeking;
|
||||||
|
- decoded media time has not advanced by 20 ms for at least 1.25 s;
|
||||||
|
- the browser reports decoded media buffered ahead of the frozen timestamp.
|
||||||
|
|
||||||
|
Only then is the broken timestamp interval skipped by 180 ms. The media clock
|
||||||
|
immediately remains authoritative; the host does not free-run. A stale callback
|
||||||
|
from the old MSE window is also prevented from undoing an operator seek.
|
||||||
|
|
||||||
|
### LiDAR orientation inherited the wrong axes
|
||||||
|
|
||||||
|
The RRD declares `/world` as RFU (`Right`, `Forward`, `Up`) and logs
|
||||||
|
`/world/points` in map space. The earlier adapter treated raw LiDAR quaternion
|
||||||
|
columns as rover forward/left/up and inherited sensor roll/pitch. That is why the
|
||||||
|
grid, rover and facade could visibly disagree.
|
||||||
|
|
||||||
|
The v3 adapter now uses:
|
||||||
|
|
||||||
|
- map `+Z` as gravity/up;
|
||||||
|
- the smoothed pose-trajectory tangent projected onto the ground as forward;
|
||||||
|
- `left = up × forward`;
|
||||||
|
- projected sensor `+Y` only as a fallback when the tangent is unavailable.
|
||||||
|
|
||||||
|
This is a deterministic coordinate contract, not a visual angle correction.
|
||||||
|
|
||||||
|
### Sensor height was treated as a constant
|
||||||
|
|
||||||
|
RAV004 does not have a stable 0.4 m mounting height throughout the recording.
|
||||||
|
The adapter now estimates the local ground plane from a causal one-second
|
||||||
|
near-field point window and uses the sealed session estimate only as fallback.
|
||||||
|
Observed local heights include approximately 0.17 m, 1.24 m, 1.05 m and 0.22 m
|
||||||
|
at different route positions; a single hand-entered value is therefore invalid.
|
||||||
|
|
||||||
|
### Sparse LiDAR frames were held incorrectly
|
||||||
|
|
||||||
|
Camera is approximately 9.51 Hz while source points arrive at approximately
|
||||||
|
2 Hz. A camera frame without a new LiDAR increment used to retain whichever
|
||||||
|
spatial frame happened to finish loading last; under fast playback this could be
|
||||||
|
dozens of seconds old. The buffer now loads the active chunk first, the preceding
|
||||||
|
chunk second and the next chunk as prefetch. The scene selects the latest proven
|
||||||
|
source increment whose sequence is not later than the active camera frame.
|
||||||
|
|
||||||
|
At the final UI check, camera frame 189 causally held spatial frame 184. Before
|
||||||
|
the fix the same point could hold frame 16.
|
||||||
|
|
||||||
|
## Capability ledger
|
||||||
|
|
||||||
|
| Layer | RAV004 full route | UI behavior | Authority |
|
||||||
|
|---|---:|---|---|
|
||||||
|
| Recorded RIGHT camera | 6830/6830 | `VIDEO` / `CAMERA`, segmented playback | recorded evidence |
|
||||||
|
| DDRNet semantic mask | 6830/6830 | selectable, opaque enough for review | diagnostic prediction |
|
||||||
|
| EoMT semantic mask | 6830/6830 | selectable | diagnostic prediction |
|
||||||
|
| Diagnostic object boxes | derived from connected EoMT mask components | media-time gated | not an independent detector |
|
||||||
|
| Source points | 1444 increments | `SOURCE POINTS` | recorded geometry |
|
||||||
|
| Bounded Local SLAM | causal 5 s / 27k-point limit | `LOCAL SLAM` | visual-derived |
|
||||||
|
| Full-route TGS | **absent** | canonical `TGS COSTMAP` control is visible but disabled | unavailable, fail closed |
|
||||||
|
| Point-aligned 3D semantics | **absent** | canonical `SEMANTICS` control is visible but disabled | unavailable |
|
||||||
|
| Independent person/vehicle detector | **absent** | no STOP claim | unavailable |
|
||||||
|
|
||||||
|
Ten old TGS review anchors exist, but they are not a continuous route artifact.
|
||||||
|
They are not repeated or held as if they were full TGS. The accepted RAVNOVES00
|
||||||
|
full-TGS result is also not reused because it has a different source identity and
|
||||||
|
4489-frame timeline.
|
||||||
|
|
||||||
|
## Performance evidence
|
||||||
|
|
||||||
|
Measured on the canonical local service and current immutable artifacts:
|
||||||
|
|
||||||
|
- replay launch POST: 3.55 s on first opening;
|
||||||
|
- timeline metadata: 0.02 s warm;
|
||||||
|
- active spatial chunk, eight camera frames: 35.17 s first process-local RRD
|
||||||
|
index build, 0.67 s warm, approximately 3.81 MB;
|
||||||
|
- UI replay: passed the previously deterministic 11.422 s decoder stop, then
|
||||||
|
continued to 59 s with media and timeline advancing together;
|
||||||
|
- operator reset seek: 16.4 s to 0 s, one mounted media worker, successful;
|
||||||
|
- browser console after the acceptance run: no warnings or errors.
|
||||||
|
|
||||||
|
The first RRD index is still process-local rather than a persistent disk cache.
|
||||||
|
That is an explicit remaining performance gap; warm playback is the admitted
|
||||||
|
profile, cold restart latency is not yet accepted.
|
||||||
|
|
||||||
|
## Nature perception: current OPS stopping point
|
||||||
|
|
||||||
|
OPS card `MISSIONCOR-65` defines the intended independent layers as EoMT,
|
||||||
|
DDRNet, frozen YOLOX and TGS. The current immutable RAV004 artifact proves full
|
||||||
|
EoMT and DDRNet inference only. It does not prove full TGS, negative-obstacle
|
||||||
|
handling, an independent person/vehicle STOP layer or combined real-time load.
|
||||||
|
|
||||||
|
Isolated full-route measurements:
|
||||||
|
|
||||||
|
- DDRNet-39: p95 27.44 ms, 52.67 inference FPS, validation mean IoU 29.715%,
|
||||||
|
vegetation mean IoU 0.3701;
|
||||||
|
- EoMT: p95 361.62 ms, approximately 3.01 inference FPS;
|
||||||
|
- prior accepted RAVNOVES00 TGS: p95 1.694 ms CPU-only, but this is algorithm
|
||||||
|
performance on another source, not RAV004 proof.
|
||||||
|
|
||||||
|
The DDRNet isolated throughput is sufficient for a 10 FPS budget. DDRNet is not
|
||||||
|
accepted for driving policy because temporal stability and nature quality are
|
||||||
|
not sufficient: the OPS temporal sample recorded adjacent-frame IoU near 0.195
|
||||||
|
for high grass and 0.400 for woody vegetation. EoMT does not meet 10 FPS in its
|
||||||
|
current form. The next evidentiary milestone is therefore not another UI model
|
||||||
|
toggle; it is synchronized truth for grass/tree/ditch/drop-off, full TGS and
|
||||||
|
negative-obstacle evidence, frozen independent detector output and a combined
|
||||||
|
load test at at least 10 FPS.
|
||||||
|
|
||||||
|
Worker 006 was audited read-only. Triton and the Gaussian containers were left
|
||||||
|
untouched. The separate Mission Core perception worker is currently in a restart
|
||||||
|
loop (404 during model inference startup); this audit did not stop, recreate or
|
||||||
|
deploy it.
|
||||||
|
|
||||||
|
## Acceptance performed
|
||||||
|
|
||||||
|
- 44 focused backend tests passed;
|
||||||
|
- 37 frontend replay, buffering and LAB contract tests passed;
|
||||||
|
- TypeScript project typecheck passed;
|
||||||
|
- production Vite build passed (only existing large-chunk warnings);
|
||||||
|
- `git diff --check` passed;
|
||||||
|
- live browser run verified the shared controls, disabled unsealed TGS/3D
|
||||||
|
semantics, continuous media recovery, causal spatial hold and clean console.
|
||||||
|
|
||||||
|
Visual QA: `docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_QA.jpg`.
|
||||||
|
|
||||||
|
## External coordinate and media references
|
||||||
|
|
||||||
|
- Rerun ViewCoordinates: <https://rerun.io/docs/reference/types/datatypes/view_coordinates>
|
||||||
|
- Rerun transform relation: <https://rerun.io/docs/reference/types/components/transform_relation>
|
||||||
|
- Rerun transforms: <https://rerun.io/docs/concepts/logging-and-ingestion/transforms>
|
||||||
|
- Rerun Transform3D: <https://rerun.io/docs/reference/types/archetypes/transform3d>
|
||||||
|
- WHATWG media element model: <https://html.spec.whatwg.org/multipage/media.html>
|
||||||
|
- W3C Media Source Extensions: <https://www.w3.org/TR/media-source-2/>
|
||||||
@@ -5,7 +5,7 @@ transport. This adapter reads the immutable recording once, indexes the
|
|||||||
recorded source cloud and sensor pose, estimates the session sensor height from
|
recorded source cloud and sensor pose, estimates the session sensor height from
|
||||||
the initial stationary cloud, and returns both the current increment and a
|
the initial stationary cloud, and returns both the current increment and a
|
||||||
bounded accumulated local-SLAM cloud in a ground-rebased body frame. Camera,
|
bounded accumulated local-SLAM cloud in a ground-rebased body frame. Camera,
|
||||||
spatial layers and the common timeline can therefore be driven by one host
|
spatial layers and the common timeline can therefore be driven by one media
|
||||||
clock without a per-LAB coordinate adapter.
|
clock without a per-LAB coordinate adapter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ from typing import Any, Final
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import rerun_bindings as rr_bindings
|
import rerun_bindings as rr_bindings
|
||||||
|
|
||||||
CANONICAL_LAB_SPATIAL_PROFILE: Final = "source-paced-ground-v2"
|
CANONICAL_LAB_SPATIAL_PROFILE: Final = "source-paced-ground-v3"
|
||||||
_POINT_ENTITY: Final = "/world/points"
|
_POINT_ENTITY: Final = "/world/points"
|
||||||
_POSE_ENTITY: Final = "/world/sensor_pose"
|
_POSE_ENTITY: Final = "/world/sensor_pose"
|
||||||
_TRAJECTORY_ENTITY: Final = "/world/trajectory"
|
_TRAJECTORY_ENTITY: Final = "/world/trajectory"
|
||||||
@@ -35,11 +35,15 @@ _HEIGHT_CALIBRATION_MAX_FRAMES: Final = 120
|
|||||||
_HEIGHT_NEAR_MIN_RADIUS_M: Final = 1.0
|
_HEIGHT_NEAR_MIN_RADIUS_M: Final = 1.0
|
||||||
_HEIGHT_NEAR_MAX_RADIUS_M: Final = 6.0
|
_HEIGHT_NEAR_MAX_RADIUS_M: Final = 6.0
|
||||||
_HEIGHT_LOWER_QUANTILE: Final = 0.025
|
_HEIGHT_LOWER_QUANTILE: Final = 0.025
|
||||||
|
_LOCAL_HEIGHT_QUANTILE: Final = 0.10
|
||||||
|
_LOCAL_HEIGHT_HALF_WINDOW_SECONDS: Final = 1.0
|
||||||
_LOCAL_SLAM_HISTORY_SECONDS: Final = 5.0
|
_LOCAL_SLAM_HISTORY_SECONDS: Final = 5.0
|
||||||
_LOCAL_SLAM_RADIUS_M: Final = 30.0
|
_LOCAL_SLAM_RADIUS_M: Final = 30.0
|
||||||
_LOCAL_SLAM_VERTICAL_LIMIT_M: Final = 6.0
|
_LOCAL_SLAM_VERTICAL_LIMIT_M: Final = 6.0
|
||||||
_LOCAL_SLAM_VOXEL_SIZE_M: Final = 0.12
|
_LOCAL_SLAM_VOXEL_SIZE_M: Final = 0.12
|
||||||
_LOCAL_SLAM_POINT_LIMIT: Final = 27_000
|
_LOCAL_SLAM_POINT_LIMIT: Final = 27_000
|
||||||
|
_FORWARD_HALF_WINDOW_SECONDS: Final = 1.0
|
||||||
|
_FORWARD_MINIMUM_DISPLACEMENT_M: Final = 0.15
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -233,6 +237,67 @@ def _map_points_to_body(
|
|||||||
return body.astype(np.float32)
|
return body.astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def _gravity_stable_basis_map_from_body(
|
||||||
|
poses: _TimedPoses,
|
||||||
|
target_time_ns: int,
|
||||||
|
) -> tuple[np.ndarray, str]:
|
||||||
|
"""Return a right-handed forward/left/up base frame in the RFU map.
|
||||||
|
|
||||||
|
Rerun declares this recording map as RFU, while the metric LAB scene
|
||||||
|
consumes points as forward/left/up. The LiDAR quaternion columns are sensor
|
||||||
|
right/forward/up and also contain rover or handheld roll/pitch, so they are
|
||||||
|
not a body basis. Route displacement owns yaw when available; the sensor's
|
||||||
|
local +Y (Rerun Forward) projected onto map gravity is the stationary
|
||||||
|
fallback. Map +Z always owns up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
center = _latest_index(poses.times_ns, target_time_ns)
|
||||||
|
half_window_ns = round(_FORWARD_HALF_WINDOW_SECONDS * 1_000_000_000)
|
||||||
|
first = _latest_index(poses.times_ns, max(0, target_time_ns - half_window_ns))
|
||||||
|
last = min(
|
||||||
|
len(poses.times_ns) - 1,
|
||||||
|
max(0, bisect_right(poses.times_ns, target_time_ns + half_window_ns) - 1),
|
||||||
|
)
|
||||||
|
route = poses.translations[last] - poses.translations[first]
|
||||||
|
route_xy = np.asarray([route[0], route[1], 0.0], dtype=np.float64)
|
||||||
|
route_norm = float(np.linalg.norm(route_xy))
|
||||||
|
|
||||||
|
sensor_rotation = _rotation_map_from_body(poses.quaternions_xyzw[center])
|
||||||
|
sensor_forward = np.asarray(
|
||||||
|
[sensor_rotation[0, 1], sensor_rotation[1, 1], 0.0],
|
||||||
|
dtype=np.float64,
|
||||||
|
)
|
||||||
|
sensor_forward_norm = float(np.linalg.norm(sensor_forward))
|
||||||
|
if sensor_forward_norm <= 1e-9:
|
||||||
|
raise ValueError("Recorded LAB sensor forward axis is invalid")
|
||||||
|
sensor_forward /= sensor_forward_norm
|
||||||
|
|
||||||
|
if route_norm >= _FORWARD_MINIMUM_DISPLACEMENT_M:
|
||||||
|
forward = route_xy / route_norm
|
||||||
|
if float(np.dot(forward, sensor_forward)) < 0.0:
|
||||||
|
forward = -forward
|
||||||
|
forward_source = "smoothed-pose-trajectory-tangent"
|
||||||
|
else:
|
||||||
|
forward = sensor_forward
|
||||||
|
forward_source = "rerun-rfu-sensor-forward-fallback"
|
||||||
|
|
||||||
|
up = np.asarray([0.0, 0.0, 1.0], dtype=np.float64)
|
||||||
|
left = np.cross(up, forward)
|
||||||
|
left_norm = float(np.linalg.norm(left))
|
||||||
|
if left_norm <= 1e-9:
|
||||||
|
raise ValueError("Recorded LAB body left axis is invalid")
|
||||||
|
left /= left_norm
|
||||||
|
forward = np.cross(left, up)
|
||||||
|
forward /= float(np.linalg.norm(forward))
|
||||||
|
basis = np.column_stack((forward, left, up))
|
||||||
|
if (
|
||||||
|
not np.allclose(basis.T @ basis, np.eye(3), atol=1e-7)
|
||||||
|
or np.linalg.det(basis) < 0.999999
|
||||||
|
):
|
||||||
|
raise ValueError("Recorded LAB gravity-stable body basis is invalid")
|
||||||
|
return basis, forward_source
|
||||||
|
|
||||||
|
|
||||||
def _estimate_sensor_height(points: _TimedPoints, poses: _TimedPoses) -> tuple[float, int, float]:
|
def _estimate_sensor_height(points: _TimedPoints, poses: _TimedPoses) -> tuple[float, int, float]:
|
||||||
"""Estimate one session mount height from the initial qualified cloud.
|
"""Estimate one session mount height from the initial qualified cloud.
|
||||||
|
|
||||||
@@ -253,17 +318,13 @@ def _estimate_sensor_height(points: _TimedPoints, poses: _TimedPoses) -> tuple[f
|
|||||||
estimates: list[float] = []
|
estimates: list[float] = []
|
||||||
for point_index in candidates:
|
for point_index in candidates:
|
||||||
pose_index = _latest_index(poses.times_ns, points.times_ns[point_index])
|
pose_index = _latest_index(poses.times_ns, points.times_ns[point_index])
|
||||||
body = _map_points_to_body(
|
delta = points.values[point_index].astype(np.float64) - poses.translations[pose_index]
|
||||||
points.values[point_index],
|
radius = np.linalg.norm(delta[:, :2], axis=1)
|
||||||
poses.translations[pose_index],
|
eligible = delta[
|
||||||
poses.quaternions_xyzw[pose_index],
|
|
||||||
)
|
|
||||||
radius = np.linalg.norm(body[:, :2], axis=1)
|
|
||||||
eligible = body[
|
|
||||||
(radius >= _HEIGHT_NEAR_MIN_RADIUS_M)
|
(radius >= _HEIGHT_NEAR_MIN_RADIUS_M)
|
||||||
& (radius <= _HEIGHT_NEAR_MAX_RADIUS_M)
|
& (radius <= _HEIGHT_NEAR_MAX_RADIUS_M)
|
||||||
& (body[:, 2] >= -2.0)
|
& (delta[:, 2] >= -2.0)
|
||||||
& (body[:, 2] <= 0.5)
|
& (delta[:, 2] <= 0.5)
|
||||||
]
|
]
|
||||||
if eligible.shape[0] < 100:
|
if eligible.shape[0] < 100:
|
||||||
continue
|
continue
|
||||||
@@ -278,12 +339,55 @@ def _estimate_sensor_height(points: _TimedPoints, poses: _TimedPoses) -> tuple[f
|
|||||||
return height, len(estimates), mad
|
return height, len(estimates), mad
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_local_sensor_height(
|
||||||
|
points: _TimedPoints,
|
||||||
|
poses: _TimedPoses,
|
||||||
|
target_time_ns: int,
|
||||||
|
fallback_height_m: float,
|
||||||
|
) -> tuple[float, int, float, str]:
|
||||||
|
"""Estimate the current gravity-axis height without a fixed camera mount.
|
||||||
|
|
||||||
|
RAVNOVES004TREE changes sensor height during the route. A session-wide
|
||||||
|
constant therefore moves the scene vertically whenever the operator raises
|
||||||
|
or lowers K1. Use a short source-time window and a conservative near-field
|
||||||
|
ground quantile; fall back to the sealed session calibration only when the
|
||||||
|
current cloud has insufficient support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
half_window_ns = round(_LOCAL_HEIGHT_HALF_WINDOW_SECONDS * 1_000_000_000)
|
||||||
|
first = bisect_right(points.times_ns, max(0, target_time_ns - half_window_ns) - 1)
|
||||||
|
last = bisect_right(points.times_ns, target_time_ns + half_window_ns)
|
||||||
|
estimates: list[float] = []
|
||||||
|
for point_index in range(first, last):
|
||||||
|
pose_index = _latest_index(poses.times_ns, points.times_ns[point_index])
|
||||||
|
delta = points.values[point_index].astype(np.float64) - poses.translations[pose_index]
|
||||||
|
radius = np.linalg.norm(delta[:, :2], axis=1)
|
||||||
|
eligible = delta[
|
||||||
|
(radius >= _HEIGHT_NEAR_MIN_RADIUS_M)
|
||||||
|
& (radius <= _HEIGHT_NEAR_MAX_RADIUS_M)
|
||||||
|
& (delta[:, 2] >= -2.5)
|
||||||
|
& (delta[:, 2] <= 0.5)
|
||||||
|
]
|
||||||
|
if eligible.shape[0] < 100:
|
||||||
|
continue
|
||||||
|
estimate = -float(np.quantile(eligible[:, 2], _LOCAL_HEIGHT_QUANTILE))
|
||||||
|
if 0.03 <= estimate <= 2.5:
|
||||||
|
estimates.append(estimate)
|
||||||
|
if not estimates:
|
||||||
|
return fallback_height_m, 0, 0.0, "session-source-cloud-fallback"
|
||||||
|
values = np.asarray(estimates, dtype=np.float64)
|
||||||
|
height = float(np.median(values))
|
||||||
|
mad = float(np.median(np.abs(values - height)))
|
||||||
|
return height, len(estimates), mad, "local-source-cloud-ground-quantile-median"
|
||||||
|
|
||||||
|
|
||||||
def _ground_origin_map(
|
def _ground_origin_map(
|
||||||
sensor_origin_map: np.ndarray,
|
sensor_origin_map: np.ndarray,
|
||||||
basis_map_from_body: np.ndarray,
|
|
||||||
sensor_height_m: float,
|
sensor_height_m: float,
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
return sensor_origin_map - basis_map_from_body[:, 2] * sensor_height_m
|
# The calibrated height belongs to the map gravity axis. Sensor roll/pitch
|
||||||
|
# must never tilt the ground origin or the accumulated world cloud.
|
||||||
|
return sensor_origin_map - np.asarray([0.0, 0.0, sensor_height_m])
|
||||||
|
|
||||||
|
|
||||||
def _map_points_to_ground_body(
|
def _map_points_to_ground_body(
|
||||||
@@ -329,32 +433,29 @@ def _bounded_local_slam(
|
|||||||
return np.ascontiguousarray(local, dtype=np.float32), len(selected), source_count
|
return np.ascontiguousarray(local, dtype=np.float32), len(selected), source_count
|
||||||
|
|
||||||
|
|
||||||
def canonical_lab_spatial_frame(
|
def _canonical_lab_spatial_frame_from_index(
|
||||||
recording_path: Path,
|
index: _CanonicalSpatialIndex,
|
||||||
generation_sha256: str,
|
|
||||||
target_time_ns: int,
|
target_time_ns: int,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return the current source cloud and bounded Local SLAM on one host time."""
|
|
||||||
|
|
||||||
if target_time_ns < 0:
|
|
||||||
raise ValueError("Recorded LAB target time is invalid")
|
|
||||||
stat = recording_path.stat()
|
|
||||||
index = _load_index(
|
|
||||||
str(recording_path),
|
|
||||||
stat.st_size,
|
|
||||||
stat.st_mtime_ns,
|
|
||||||
generation_sha256,
|
|
||||||
)
|
|
||||||
point_index = _latest_index(index.points.times_ns, target_time_ns)
|
point_index = _latest_index(index.points.times_ns, target_time_ns)
|
||||||
pose_index = _latest_index(index.poses.times_ns, index.points.times_ns[point_index])
|
pose_index = _latest_index(index.poses.times_ns, index.points.times_ns[point_index])
|
||||||
trajectory_index = _latest_index(index.trajectories.times_ns, target_time_ns)
|
trajectory_index = _latest_index(index.trajectories.times_ns, target_time_ns)
|
||||||
translation = index.poses.translations[pose_index]
|
translation = index.poses.translations[pose_index]
|
||||||
quaternion = index.poses.quaternions_xyzw[pose_index]
|
sensor_height_m, sensor_height_sample_count, sensor_height_mad_m, height_source = (
|
||||||
basis_map_from_body = _rotation_map_from_body(quaternion)
|
_estimate_local_sensor_height(
|
||||||
|
index.points,
|
||||||
|
index.poses,
|
||||||
|
index.points.times_ns[point_index],
|
||||||
|
index.sensor_height_m,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
basis_map_from_body, forward_source = _gravity_stable_basis_map_from_body(
|
||||||
|
index.poses,
|
||||||
|
index.points.times_ns[point_index],
|
||||||
|
)
|
||||||
ground_origin = _ground_origin_map(
|
ground_origin = _ground_origin_map(
|
||||||
translation,
|
translation,
|
||||||
basis_map_from_body,
|
sensor_height_m,
|
||||||
index.sensor_height_m,
|
|
||||||
)
|
)
|
||||||
points_body = _map_points_to_ground_body(
|
points_body = _map_points_to_ground_body(
|
||||||
index.points.values[point_index],
|
index.points.values[point_index],
|
||||||
@@ -368,17 +469,18 @@ def canonical_lab_spatial_frame(
|
|||||||
basis_map_from_body,
|
basis_map_from_body,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v2",
|
"schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v3",
|
||||||
"target_time_ns": target_time_ns,
|
"target_time_ns": target_time_ns,
|
||||||
"source_time_ns": index.points.times_ns[point_index],
|
"source_time_ns": index.points.times_ns[point_index],
|
||||||
"pose_time_ns": index.poses.times_ns[pose_index],
|
"pose_time_ns": index.poses.times_ns[pose_index],
|
||||||
"trajectory_time_ns": index.trajectories.times_ns[trajectory_index],
|
"trajectory_time_ns": index.trajectories.times_ns[trajectory_index],
|
||||||
"coordinate_frame": "body-ground",
|
"coordinate_frame": "body-ground",
|
||||||
"sensor_height": {
|
"sensor_height": {
|
||||||
"meters": index.sensor_height_m,
|
"meters": sensor_height_m,
|
||||||
"source": "initial-source-cloud-lower-quantile-median",
|
"source": height_source,
|
||||||
"sample_count": index.sensor_height_sample_count,
|
"sample_count": sensor_height_sample_count,
|
||||||
"mad_m": index.sensor_height_mad_m,
|
"mad_m": sensor_height_mad_m,
|
||||||
|
"session_fallback_meters": index.sensor_height_m,
|
||||||
"authority": "visual-derived",
|
"authority": "visual-derived",
|
||||||
},
|
},
|
||||||
"spatial_profile": {
|
"spatial_profile": {
|
||||||
@@ -392,6 +494,8 @@ def canonical_lab_spatial_frame(
|
|||||||
"origin_map_xyz_m": ground_origin.tolist(),
|
"origin_map_xyz_m": ground_origin.tolist(),
|
||||||
"sensor_origin_map_xyz_m": translation.tolist(),
|
"sensor_origin_map_xyz_m": translation.tolist(),
|
||||||
"basis_map_from_body": basis_map_from_body.tolist(),
|
"basis_map_from_body": basis_map_from_body.tolist(),
|
||||||
|
"up_source": "rerun-rfu-map-gravity-axis",
|
||||||
|
"forward_source": forward_source,
|
||||||
},
|
},
|
||||||
"source_point_count": int(points_body.shape[0]),
|
"source_point_count": int(points_body.shape[0]),
|
||||||
"source_points_body_xyz_m": points_body.tolist(),
|
"source_points_body_xyz_m": points_body.tolist(),
|
||||||
@@ -400,3 +504,70 @@ def canonical_lab_spatial_frame(
|
|||||||
"local_slam_point_count": int(local_slam.shape[0]),
|
"local_slam_point_count": int(local_slam.shape[0]),
|
||||||
"local_slam_body_xyz_m": local_slam.tolist(),
|
"local_slam_body_xyz_m": local_slam.tolist(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_lab_spatial_frame(
|
||||||
|
recording_path: Path,
|
||||||
|
generation_sha256: str,
|
||||||
|
target_time_ns: int,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Return the current source cloud and bounded Local SLAM on one media time."""
|
||||||
|
|
||||||
|
if target_time_ns < 0:
|
||||||
|
raise ValueError("Recorded LAB target time is invalid")
|
||||||
|
stat = recording_path.stat()
|
||||||
|
index = _load_index(
|
||||||
|
str(recording_path),
|
||||||
|
stat.st_size,
|
||||||
|
stat.st_mtime_ns,
|
||||||
|
generation_sha256,
|
||||||
|
)
|
||||||
|
return _canonical_lab_spatial_frame_from_index(index, target_time_ns)
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_lab_spatial_timeline_samples(
|
||||||
|
recording_path: Path,
|
||||||
|
generation_sha256: str,
|
||||||
|
frame_times_ns: tuple[int, ...],
|
||||||
|
start_sequence: int,
|
||||||
|
frame_count: int,
|
||||||
|
) -> tuple[dict[str, object] | None, ...]:
|
||||||
|
"""Project only new source increments onto a denser camera timeline.
|
||||||
|
|
||||||
|
Camera is roughly 10 Hz in RAVNOVES004TREE while the sealed source cloud is
|
||||||
|
roughly 2 Hz. Returning the same JSON point array for every camera frame
|
||||||
|
multiplies transfer and parse cost and makes the viewer chase itself. A row
|
||||||
|
is populated only when its nearest causal source increment changes; the
|
||||||
|
canonical viewer retains that spatial frame until the next increment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if (
|
||||||
|
start_sequence < 0
|
||||||
|
or frame_count < 1
|
||||||
|
or start_sequence >= len(frame_times_ns)
|
||||||
|
or any(current <= previous for previous, current in zip(frame_times_ns, frame_times_ns[1:]))
|
||||||
|
):
|
||||||
|
raise ValueError("Recorded LAB timeline sample request is invalid")
|
||||||
|
stat = recording_path.stat()
|
||||||
|
index = _load_index(
|
||||||
|
str(recording_path),
|
||||||
|
stat.st_size,
|
||||||
|
stat.st_mtime_ns,
|
||||||
|
generation_sha256,
|
||||||
|
)
|
||||||
|
stop = min(len(frame_times_ns), start_sequence + frame_count)
|
||||||
|
samples: list[dict[str, object] | None] = []
|
||||||
|
for sequence in range(start_sequence, stop):
|
||||||
|
target_time_ns = frame_times_ns[sequence]
|
||||||
|
point_index = _latest_index(index.points.times_ns, target_time_ns)
|
||||||
|
previous_point_index = (
|
||||||
|
-1
|
||||||
|
if sequence == 0
|
||||||
|
else _latest_index(index.points.times_ns, frame_times_ns[sequence - 1])
|
||||||
|
)
|
||||||
|
samples.append(
|
||||||
|
_canonical_lab_spatial_frame_from_index(index, target_time_ns)
|
||||||
|
if point_index != previous_point_index
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return tuple(samples)
|
||||||
|
|||||||
@@ -360,6 +360,15 @@ def _m48_recorded_camera_playback_source(
|
|||||||
return session_recorded_camera_frame_service.playback_source(session_id)
|
return session_recorded_camera_frame_service.playback_source(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_lab_recording_source(session_id: str) -> tuple[Path, str] | None:
|
||||||
|
"""Resolve one already-published immutable RRD without starting new work."""
|
||||||
|
|
||||||
|
snapshot = session_recording_preparation_manager.status(session_id)
|
||||||
|
if snapshot is None or snapshot.state != "ready" or snapshot.recording is None:
|
||||||
|
return None
|
||||||
|
return snapshot.recording.path, snapshot.recording.sha256
|
||||||
|
|
||||||
|
|
||||||
def refresh_observation_catalog() -> tuple[str, ...]:
|
def refresh_observation_catalog() -> tuple[str, ...]:
|
||||||
"""Discover completed or recoverable local evidence without copying payloads."""
|
"""Discover completed or recoverable local evidence without copying payloads."""
|
||||||
|
|
||||||
@@ -1032,6 +1041,12 @@ app.include_router(
|
|||||||
/ "lab-v1-vegetation"
|
/ "lab-v1-vegetation"
|
||||||
/ "results"
|
/ "results"
|
||||||
),
|
),
|
||||||
|
canonical_recording_provider=_canonical_lab_recording_source,
|
||||||
|
camera_frame_provider=(
|
||||||
|
session_recorded_camera_frame_service.extract
|
||||||
|
if session_recorded_camera_frame_service is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
app.include_router(
|
app.include_router(
|
||||||
|
|||||||
@@ -835,11 +835,11 @@ def build_session_router(
|
|||||||
session_id: str,
|
session_id: str,
|
||||||
generation: Annotated[str, Query(min_length=64, max_length=64)],
|
generation: Annotated[str, Query(min_length=64, max_length=64)],
|
||||||
time_ns: Annotated[int, Query(ge=0, le=MAX_SAFE_INTEGER)],
|
time_ns: Annotated[int, Query(ge=0, le=MAX_SAFE_INTEGER)],
|
||||||
profile: Literal["source-paced-ground-v2"],
|
profile: Literal["source-paced-ground-v3"],
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""Serve one body-frame sample for the canonical recorded-LAB clock.
|
"""Serve one body-frame sample for the canonical recorded-LAB clock.
|
||||||
|
|
||||||
The camera timeline owns playback. Spatial evidence is sampled from
|
The camera media clock owns playback. Spatial evidence is sampled from
|
||||||
the same immutable recording instead of starting a second Rerun clock.
|
the same immutable recording instead of starting a second Rerun clock.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import copy
|
import copy
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
|
import statistics
|
||||||
import zipfile
|
import zipfile
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
@@ -14,6 +17,7 @@ from typing import Any, Final
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from k1link.laboratory.evidence_registry import LaboratoryEvidenceDefinition
|
from k1link.laboratory.evidence_registry import LaboratoryEvidenceDefinition
|
||||||
from k1link.laboratory.evidence_report import (
|
from k1link.laboratory.evidence_report import (
|
||||||
@@ -21,9 +25,14 @@ from k1link.laboratory.evidence_report import (
|
|||||||
verify_laboratory_evidence_result,
|
verify_laboratory_evidence_result,
|
||||||
)
|
)
|
||||||
from k1link.laboratory.vegetation_shadow_lab import LAB_SCHEMA
|
from k1link.laboratory.vegetation_shadow_lab import LAB_SCHEMA
|
||||||
|
from k1link.sessions import RecordedCameraFrame, SessionIntegrityError
|
||||||
|
from k1link.sessions.canonical_lab_spatial import canonical_lab_spatial_timeline_samples
|
||||||
|
|
||||||
RootProvider = Callable[[], Path | None]
|
RootProvider = Callable[[], Path | None]
|
||||||
|
CanonicalRecordingProvider = Callable[[str], tuple[Path, str] | None]
|
||||||
|
CameraFrameProvider = Callable[[str, int], RecordedCameraFrame]
|
||||||
_MAX_DOCUMENT_BYTES: Final = 1024 * 1024
|
_MAX_DOCUMENT_BYTES: Final = 1024 * 1024
|
||||||
|
_CANONICAL_ROUTE_CHUNK_FRAMES: Final = 8
|
||||||
_DEFINITION: Final = LaboratoryEvidenceDefinition(
|
_DEFINITION: Final = LaboratoryEvidenceDefinition(
|
||||||
work_id="lab-v1-vegetation-shadow",
|
work_id="lab-v1-vegetation-shadow",
|
||||||
runtime_relative_root=PurePosixPath("lab-v1-vegetation/results"),
|
runtime_relative_root=PurePosixPath("lab-v1-vegetation/results"),
|
||||||
@@ -41,12 +50,17 @@ _BENCHMARK_DEFINITION: Final = LaboratoryEvidenceDefinition(
|
|||||||
|
|
||||||
|
|
||||||
def build_vegetation_shadow_lab_router(
|
def build_vegetation_shadow_lab_router(
|
||||||
*, root_provider: RootProvider = lambda: None,
|
*,
|
||||||
|
root_provider: RootProvider = lambda: None,
|
||||||
|
canonical_recording_provider: CanonicalRecordingProvider | None = None,
|
||||||
|
camera_frame_provider: CameraFrameProvider | None = None,
|
||||||
) -> APIRouter:
|
) -> APIRouter:
|
||||||
return _build_vegetation_lab_router(
|
return _build_vegetation_lab_router(
|
||||||
prefix="/api/v1/laboratory/vegetation-shadow",
|
prefix="/api/v1/laboratory/vegetation-shadow",
|
||||||
definition=_DEFINITION,
|
definition=_DEFINITION,
|
||||||
root_provider=root_provider,
|
root_provider=root_provider,
|
||||||
|
canonical_recording_provider=canonical_recording_provider,
|
||||||
|
camera_frame_provider=camera_frame_provider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -65,6 +79,8 @@ def _build_vegetation_lab_router(
|
|||||||
prefix: str,
|
prefix: str,
|
||||||
definition: LaboratoryEvidenceDefinition,
|
definition: LaboratoryEvidenceDefinition,
|
||||||
root_provider: RootProvider,
|
root_provider: RootProvider,
|
||||||
|
canonical_recording_provider: CanonicalRecordingProvider | None = None,
|
||||||
|
camera_frame_provider: CameraFrameProvider | None = None,
|
||||||
) -> APIRouter:
|
) -> APIRouter:
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix=prefix,
|
prefix=prefix,
|
||||||
@@ -263,6 +279,143 @@ def _build_vegetation_lab_router(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.get("/{result_id}/timeline")
|
||||||
|
def get_canonical_route_timeline(result_id: str) -> dict[str, object]:
|
||||||
|
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||||
|
manifest = _read_verified(candidate, definition)
|
||||||
|
route, frame_times_ns = _full_route_context(candidate, manifest)
|
||||||
|
intervals = [
|
||||||
|
(current - previous) / 1_000_000_000
|
||||||
|
for previous, current in zip(frame_times_ns, frame_times_ns[1:])
|
||||||
|
]
|
||||||
|
nominal_interval = statistics.median(intervals)
|
||||||
|
if not math.isfinite(nominal_interval) or nominal_interval <= 0:
|
||||||
|
raise HTTPException(status_code=503, detail="Full-route timeline cadence is invalid")
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.recorded-spatial-evidence-timeline/v1",
|
||||||
|
"result_id": result_id,
|
||||||
|
"recorded_source": {
|
||||||
|
"session_id": route["session_id"],
|
||||||
|
"source_id": route["source_id"],
|
||||||
|
"representation_id": "registered-map-increment-v1",
|
||||||
|
"synchronization": "host-arrival-best-effort",
|
||||||
|
},
|
||||||
|
"frame_count": len(frame_times_ns),
|
||||||
|
"frame_times_ns": list(frame_times_ns),
|
||||||
|
"timeline_start_seconds": frame_times_ns[0] / 1_000_000_000,
|
||||||
|
"timeline_end_seconds": frame_times_ns[-1] / 1_000_000_000,
|
||||||
|
"nominal_frame_interval_seconds": nominal_interval,
|
||||||
|
"nominal_rate_hz": 1.0 / nominal_interval,
|
||||||
|
"max_chunk_frames": _CANONICAL_ROUTE_CHUNK_FRAMES,
|
||||||
|
"point_sample_limit": 100_000,
|
||||||
|
"maximum_source_points_per_frame": 100_000,
|
||||||
|
"point_delivery": "exact-current-increment",
|
||||||
|
"world_state_frame_count": len(frame_times_ns),
|
||||||
|
"superseded_frame_count": 0,
|
||||||
|
"local_surface_visualization": {
|
||||||
|
"derivation": "bounded-registered-increment-accumulation",
|
||||||
|
"window_seconds": 5.0,
|
||||||
|
"voxel_size_m": 0.12,
|
||||||
|
"radius_m": 30.0,
|
||||||
|
"point_limit": 27_000,
|
||||||
|
"authority": "visual-derived",
|
||||||
|
},
|
||||||
|
"image_width": route["width"],
|
||||||
|
"image_height": route["height"],
|
||||||
|
"rig": {"length_m": 1.0, "width_m": 0.8, "nominal_sensor_height_m": 0.4},
|
||||||
|
"corridor": {
|
||||||
|
"forward_length_m": 8.0,
|
||||||
|
"rear_margin_m": 0.5,
|
||||||
|
"occupied_voxel_size_m": 0.45,
|
||||||
|
"half_width_m": 0.6,
|
||||||
|
"prediction_horizon_seconds": 8.0,
|
||||||
|
},
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": "replay-simulated",
|
||||||
|
"access": "read-only-bounded-recorded-replay",
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/{result_id}/timeline/chunk")
|
||||||
|
def get_canonical_route_timeline_chunk(
|
||||||
|
result_id: str,
|
||||||
|
start: int = 0,
|
||||||
|
count: int = _CANONICAL_ROUTE_CHUNK_FRAMES,
|
||||||
|
include_points: bool = True,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if start < 0 or not 1 <= count <= _CANONICAL_ROUTE_CHUNK_FRAMES:
|
||||||
|
raise HTTPException(status_code=422, detail="Full-route timeline chunk is invalid")
|
||||||
|
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||||
|
manifest = _read_verified(candidate, definition)
|
||||||
|
route, frame_times_ns = _full_route_context(candidate, manifest)
|
||||||
|
if start >= len(frame_times_ns):
|
||||||
|
raise HTTPException(status_code=404, detail="Full-route timeline chunk not found")
|
||||||
|
if canonical_recording_provider is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Canonical spatial recording is unavailable")
|
||||||
|
recording = canonical_recording_provider(str(route["session_id"]))
|
||||||
|
if recording is None:
|
||||||
|
raise HTTPException(status_code=409, detail="Canonical spatial recording is not ready")
|
||||||
|
recording_path, generation_sha256 = recording
|
||||||
|
try:
|
||||||
|
samples = canonical_lab_spatial_timeline_samples(
|
||||||
|
recording_path,
|
||||||
|
generation_sha256,
|
||||||
|
frame_times_ns,
|
||||||
|
start,
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
raise HTTPException(status_code=503, detail="Canonical spatial chunk failed") from None
|
||||||
|
stop = start + len(samples)
|
||||||
|
frames = [
|
||||||
|
_canonical_timeline_frame(
|
||||||
|
result_id=result_id,
|
||||||
|
endpoint_prefix=prefix,
|
||||||
|
candidate=candidate,
|
||||||
|
route=route,
|
||||||
|
sequence=sequence,
|
||||||
|
source_time_ns=frame_times_ns[sequence],
|
||||||
|
spatial=sample,
|
||||||
|
include_points=include_points,
|
||||||
|
)
|
||||||
|
for sequence, sample in zip(range(start, stop), samples, strict=True)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.recorded-spatial-evidence-chunk/v1",
|
||||||
|
"result_id": result_id,
|
||||||
|
"start_sequence": start,
|
||||||
|
"frame_count": len(frames),
|
||||||
|
"next_sequence": stop if stop < len(frame_times_ns) else None,
|
||||||
|
"frames": frames,
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": "replay-simulated",
|
||||||
|
"access": "read-only-bounded-recorded-replay",
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/{result_id}/timeline/frames/{sequence}/camera")
|
||||||
|
def get_canonical_route_camera(result_id: str, sequence: int) -> Response:
|
||||||
|
if camera_frame_provider is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Recorded camera decoder is unavailable")
|
||||||
|
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||||
|
manifest = _read_verified(candidate, definition)
|
||||||
|
route, frame_times_ns = _full_route_context(candidate, manifest)
|
||||||
|
if not 0 <= sequence < len(frame_times_ns):
|
||||||
|
raise HTTPException(status_code=404, detail="Full-route camera frame not found")
|
||||||
|
try:
|
||||||
|
camera = camera_frame_provider(str(route["session_id"]), sequence)
|
||||||
|
except (OSError, SessionIntegrityError, ValueError):
|
||||||
|
raise HTTPException(status_code=503, detail="Full-route camera frame unavailable") from None
|
||||||
|
if camera.width != route["width"] or camera.height != route["height"]:
|
||||||
|
raise HTTPException(status_code=503, detail="Full-route camera dimensions changed")
|
||||||
|
return Response(
|
||||||
|
content=camera.payload,
|
||||||
|
media_type=camera.media_type,
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable",
|
||||||
|
"ETag": f'"{camera.sha256}"',
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/{result_id}/route-tgs-anchor/{source_sequence}")
|
@router.get("/{result_id}/route-tgs-anchor/{source_sequence}")
|
||||||
def get_route_tgs_anchor(result_id: str, source_sequence: int) -> JSONResponse:
|
def get_route_tgs_anchor(result_id: str, source_sequence: int) -> JSONResponse:
|
||||||
candidate = _resolve_candidate(root_provider, definition, result_id)
|
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||||
@@ -314,6 +467,242 @@ def _build_vegetation_lab_router(
|
|||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _full_route_context(
|
||||||
|
candidate: Path,
|
||||||
|
manifest: dict[str, Any],
|
||||||
|
) -> tuple[dict[str, Any], tuple[int, ...]]:
|
||||||
|
route = manifest.get("route_full_review")
|
||||||
|
timeline = route.get("timeline") if isinstance(route, dict) else None
|
||||||
|
relative_text = timeline.get("path") if isinstance(timeline, dict) else None
|
||||||
|
if (
|
||||||
|
not isinstance(route, dict)
|
||||||
|
or route.get("source_id") != "RAVNOVES004TREE"
|
||||||
|
or route.get("session_id") != "20260828T130511Z_viewer_live"
|
||||||
|
or route.get("frame_count") != 6830
|
||||||
|
or route.get("width") != 800
|
||||||
|
or route.get("height") != 600
|
||||||
|
or not isinstance(relative_text, str)
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=404, detail="Full-route canonical timeline not found")
|
||||||
|
path = candidate.joinpath(*PurePosixPath(relative_text).parts)
|
||||||
|
try:
|
||||||
|
payload = path.read_bytes()
|
||||||
|
if (
|
||||||
|
len(payload) != timeline.get("byte_length")
|
||||||
|
or hashlib.sha256(payload).hexdigest() != timeline.get("sha256")
|
||||||
|
):
|
||||||
|
raise ValueError("timeline digest changed")
|
||||||
|
values = np.frombuffer(payload, dtype="<u8")
|
||||||
|
frame_times_ns = tuple(int(value) for value in values)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
raise HTTPException(status_code=503, detail="Full-route timeline verification failed") from None
|
||||||
|
if (
|
||||||
|
len(frame_times_ns) != route["frame_count"]
|
||||||
|
or any(current <= previous for previous, current in zip(frame_times_ns, frame_times_ns[1:]))
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=503, detail="Full-route timeline order changed")
|
||||||
|
return route, frame_times_ns
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_timeline_frame(
|
||||||
|
*,
|
||||||
|
result_id: str,
|
||||||
|
endpoint_prefix: str,
|
||||||
|
candidate: Path,
|
||||||
|
route: dict[str, Any],
|
||||||
|
sequence: int,
|
||||||
|
source_time_ns: int,
|
||||||
|
spatial: dict[str, object] | None,
|
||||||
|
include_points: bool,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
points = [] if spatial is None or not include_points else spatial["source_points_body_xyz_m"]
|
||||||
|
point_count = 0 if spatial is None else int(spatial["source_point_count"])
|
||||||
|
body_frame = None if spatial is None else spatial["body_frame"]
|
||||||
|
local_slam = [] if spatial is None else spatial["local_slam_body_xyz_m"]
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.recorded-spatial-evidence-frame/v1",
|
||||||
|
"sequence": sequence,
|
||||||
|
"frame_id": f"frame-{sequence:06d}",
|
||||||
|
"source_time_ns": source_time_ns,
|
||||||
|
"session_seconds": source_time_ns / 1_000_000_000,
|
||||||
|
"source_available": spatial is not None,
|
||||||
|
"spatial_available": spatial is not None,
|
||||||
|
"world_state_available": True,
|
||||||
|
"terminal_outcome": "delivered",
|
||||||
|
"body_frame": body_frame,
|
||||||
|
"point_cloud_body_xyz_m": points,
|
||||||
|
"point_cloud_source_count": point_count,
|
||||||
|
"point_cloud_sample_count": point_count if not include_points else len(points),
|
||||||
|
"point_cloud_layer": "current-increment",
|
||||||
|
"local_slam_body_xyz_m": local_slam,
|
||||||
|
"local_slam_source_frame_count": 0
|
||||||
|
if spatial is None else spatial["local_slam_source_frame_count"],
|
||||||
|
"local_slam_source_point_count": 0
|
||||||
|
if spatial is None else spatial["local_slam_source_point_count"],
|
||||||
|
"rolling_map_component_count": 0,
|
||||||
|
"metric_obstacles": [],
|
||||||
|
"camera_proposals": _semantic_component_proposals(candidate, route, sequence),
|
||||||
|
"decision_counts": {"threat": 0, "not-threat": 0, "unknown": 0},
|
||||||
|
"camera_url": (
|
||||||
|
f"{endpoint_prefix}/{result_id}/timeline/frames/{sequence}/camera"
|
||||||
|
),
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": "replay-simulated",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _semantic_component_proposals(
|
||||||
|
candidate: Path,
|
||||||
|
route: dict[str, Any],
|
||||||
|
sequence: int,
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
layers = route.get("layers")
|
||||||
|
city = layers.get("city") if isinstance(layers, dict) else None
|
||||||
|
archive = city.get("mask_archive") if isinstance(city, dict) else None
|
||||||
|
relative = archive.get("path") if isinstance(archive, dict) else None
|
||||||
|
if not isinstance(relative, str):
|
||||||
|
return []
|
||||||
|
archive_path = candidate.joinpath(*PurePosixPath(relative).parts)
|
||||||
|
try:
|
||||||
|
stat = archive_path.stat()
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
dict(proposal)
|
||||||
|
for proposal in _semantic_component_proposals_cached(
|
||||||
|
str(archive_path),
|
||||||
|
stat.st_size,
|
||||||
|
stat.st_mtime_ns,
|
||||||
|
sequence,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=256)
|
||||||
|
def _semantic_component_proposals_cached(
|
||||||
|
archive_path_text: str,
|
||||||
|
archive_size: int,
|
||||||
|
archive_mtime_ns: int,
|
||||||
|
sequence: int,
|
||||||
|
) -> tuple[dict[str, object], ...]:
|
||||||
|
del archive_size, archive_mtime_ns
|
||||||
|
archive_path = Path(archive_path_text)
|
||||||
|
member = f"masks/frame-{sequence + 1:06d}.png"
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(archive_path) as frozen:
|
||||||
|
payload = frozen.read(member)
|
||||||
|
with Image.open(io.BytesIO(payload)) as image:
|
||||||
|
mask = np.asarray(image.convert("L"), dtype=np.uint8)
|
||||||
|
except (KeyError, OSError, ValueError, zipfile.BadZipFile):
|
||||||
|
return ()
|
||||||
|
labels = {
|
||||||
|
1: "semantic person",
|
||||||
|
2: "semantic bicycle",
|
||||||
|
3: "semantic motorcycle",
|
||||||
|
4: "semantic car",
|
||||||
|
5: "semantic heavy vehicle",
|
||||||
|
13: "semantic static obstacle",
|
||||||
|
14: "semantic animal",
|
||||||
|
}
|
||||||
|
proposals: list[dict[str, object]] = []
|
||||||
|
for class_id, label in labels.items():
|
||||||
|
minimum_pixels = 80 if class_id == 13 else 24
|
||||||
|
for component_index, (left, top, right, bottom, pixel_count) in enumerate(
|
||||||
|
_mask_component_boxes(mask, class_id, minimum_pixels=minimum_pixels)[:12]
|
||||||
|
):
|
||||||
|
proposals.append({
|
||||||
|
"proposal_id": f"semantic-{class_id}-{sequence}-{component_index}",
|
||||||
|
"bbox_xyxy": [left, top, right, bottom],
|
||||||
|
"objectness": round(min(0.99, 0.5 + pixel_count / 20_000), 4),
|
||||||
|
"semantic_hint": label,
|
||||||
|
"occupied_support": False,
|
||||||
|
"range_m": None,
|
||||||
|
"threat_decision": None,
|
||||||
|
"threat_reason_codes": ["semantic-mask-derived-not-fail-safe-detector"],
|
||||||
|
})
|
||||||
|
proposals.sort(
|
||||||
|
key=lambda proposal: (
|
||||||
|
-float(proposal["objectness"]),
|
||||||
|
str(proposal["proposal_id"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(proposals[:32])
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_component_boxes(
|
||||||
|
mask: np.ndarray,
|
||||||
|
class_id: int,
|
||||||
|
*,
|
||||||
|
minimum_pixels: int,
|
||||||
|
) -> list[tuple[int, int, int, int, int]]:
|
||||||
|
"""Return 8-connected run-length components without an OpenCV dependency."""
|
||||||
|
|
||||||
|
if mask.ndim != 2 or minimum_pixels < 1:
|
||||||
|
return []
|
||||||
|
parents: list[int] = []
|
||||||
|
runs: list[tuple[int, int, int, int]] = []
|
||||||
|
|
||||||
|
def root(index: int) -> int:
|
||||||
|
while parents[index] != index:
|
||||||
|
parents[index] = parents[parents[index]]
|
||||||
|
index = parents[index]
|
||||||
|
return index
|
||||||
|
|
||||||
|
def union(left: int, right: int) -> None:
|
||||||
|
left_root = root(left)
|
||||||
|
right_root = root(right)
|
||||||
|
if left_root != right_root:
|
||||||
|
parents[right_root] = left_root
|
||||||
|
|
||||||
|
previous: list[int] = []
|
||||||
|
for row_index, row in enumerate(mask):
|
||||||
|
matches = np.flatnonzero(row == class_id)
|
||||||
|
if matches.size == 0:
|
||||||
|
previous = []
|
||||||
|
continue
|
||||||
|
split_at = np.flatnonzero(np.diff(matches) > 1) + 1
|
||||||
|
groups = np.split(matches, split_at)
|
||||||
|
current: list[int] = []
|
||||||
|
previous_cursor = 0
|
||||||
|
for group in groups:
|
||||||
|
start = int(group[0])
|
||||||
|
stop = int(group[-1]) + 1
|
||||||
|
run_index = len(runs)
|
||||||
|
runs.append((row_index, start, stop, stop - start))
|
||||||
|
parents.append(run_index)
|
||||||
|
current.append(run_index)
|
||||||
|
while (
|
||||||
|
previous_cursor < len(previous)
|
||||||
|
and runs[previous[previous_cursor]][2] < start
|
||||||
|
):
|
||||||
|
previous_cursor += 1
|
||||||
|
candidate_cursor = previous_cursor
|
||||||
|
while candidate_cursor < len(previous):
|
||||||
|
previous_index = previous[candidate_cursor]
|
||||||
|
_, previous_start, previous_stop, _ = runs[previous_index]
|
||||||
|
if previous_start > stop:
|
||||||
|
break
|
||||||
|
union(run_index, previous_index)
|
||||||
|
candidate_cursor += 1
|
||||||
|
previous = current
|
||||||
|
|
||||||
|
components: dict[int, list[int]] = {}
|
||||||
|
for run_index, (row, start, stop, count) in enumerate(runs):
|
||||||
|
component = components.setdefault(root(run_index), [start, row, stop, row + 1, 0])
|
||||||
|
component[0] = min(component[0], start)
|
||||||
|
component[1] = min(component[1], row)
|
||||||
|
component[2] = max(component[2], stop)
|
||||||
|
component[3] = max(component[3], row + 1)
|
||||||
|
component[4] += count
|
||||||
|
result = [
|
||||||
|
(left, top, right, bottom, count)
|
||||||
|
for left, top, right, bottom, count in components.values()
|
||||||
|
if count >= minimum_pixels and right - left >= 2 and bottom - top >= 3
|
||||||
|
]
|
||||||
|
result.sort(key=lambda box: (-box[4], box[1], box[0]))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _route_tgs_anchor_payload(path: Path, source_sequence: int) -> dict[str, object]:
|
def _route_tgs_anchor_payload(path: Path, source_sequence: int) -> dict[str, object]:
|
||||||
before = path.stat()
|
before = path.stat()
|
||||||
with np.load(path, allow_pickle=False) as archive:
|
with np.load(path, allow_pickle=False) as archive:
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from k1link.sessions.canonical_lab_spatial import (
|
|||||||
_TimedPoses,
|
_TimedPoses,
|
||||||
_bounded_local_slam,
|
_bounded_local_slam,
|
||||||
_estimate_sensor_height,
|
_estimate_sensor_height,
|
||||||
|
_estimate_local_sensor_height,
|
||||||
|
_gravity_stable_basis_map_from_body,
|
||||||
_ground_origin_map,
|
_ground_origin_map,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ def test_local_slam_accumulates_source_increments_in_ground_body_frame() -> None
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
basis = np.eye(3)
|
basis = np.eye(3)
|
||||||
ground_origin = _ground_origin_map(np.asarray([0.0, 0.0, 0.0]), basis, 0.32)
|
ground_origin = _ground_origin_map(np.asarray([0.0, 0.0, 0.0]), 0.32)
|
||||||
|
|
||||||
local, frame_count, source_count = _bounded_local_slam(
|
local, frame_count, source_count = _bounded_local_slam(
|
||||||
points,
|
points,
|
||||||
@@ -69,3 +71,59 @@ def test_local_slam_accumulates_source_increments_in_ground_body_frame() -> None
|
|||||||
assert frame_count == 3
|
assert frame_count == 3
|
||||||
assert source_count == 3
|
assert source_count == 3
|
||||||
assert local[:, 2].tolist() == pytest.approx([0.0, 0.0, 0.0], abs=1e-6)
|
assert local[:, 2].tolist() == pytest.approx([0.0, 0.0, 0.0], abs=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gravity_stable_body_frame_converts_rfu_to_forward_left_up() -> None:
|
||||||
|
times = (0, 1_000_000_000, 2_000_000_000)
|
||||||
|
poses = _TimedPoses(
|
||||||
|
times_ns=times,
|
||||||
|
translations=(
|
||||||
|
np.asarray([0.0, 0.0, 0.4]),
|
||||||
|
np.asarray([0.0, 1.0, 0.5]),
|
||||||
|
np.asarray([0.0, 2.0, 0.3]),
|
||||||
|
),
|
||||||
|
quaternions_xyzw=tuple(
|
||||||
|
np.asarray([0.25, 0.0, 0.0, np.sqrt(1.0 - 0.25**2)]) for _ in times
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
basis, source = _gravity_stable_basis_map_from_body(poses, 1_000_000_000)
|
||||||
|
|
||||||
|
assert source == "smoothed-pose-trajectory-tangent"
|
||||||
|
assert basis[:, 0].tolist() == pytest.approx([0.0, 1.0, 0.0], abs=1e-7)
|
||||||
|
assert basis[:, 1].tolist() == pytest.approx([-1.0, 0.0, 0.0], abs=1e-7)
|
||||||
|
assert basis[:, 2].tolist() == pytest.approx([0.0, 0.0, 1.0], abs=1e-7)
|
||||||
|
assert np.linalg.det(basis) == pytest.approx(1.0, abs=1e-7)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ground_origin_is_projected_only_along_map_gravity() -> None:
|
||||||
|
origin = _ground_origin_map(np.asarray([4.0, -2.0, 1.25]), 0.32)
|
||||||
|
assert origin.tolist() == pytest.approx([4.0, -2.0, 0.93], abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensor_height_tracks_current_source_window_instead_of_fixed_mount() -> None:
|
||||||
|
times = tuple(index * 500_000_000 for index in range(8))
|
||||||
|
points = _TimedPoints(
|
||||||
|
times_ns=times,
|
||||||
|
values=tuple(
|
||||||
|
_calibration_cloud(0.18 if index < 4 else 1.05, index)
|
||||||
|
for index in range(8)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
poses = _TimedPoses(
|
||||||
|
times_ns=times,
|
||||||
|
translations=tuple(np.zeros(3) for _ in times),
|
||||||
|
quaternions_xyzw=tuple(np.asarray([0.0, 0.0, 0.0, 1.0]) for _ in times),
|
||||||
|
)
|
||||||
|
|
||||||
|
low, low_samples, _, low_source = _estimate_local_sensor_height(
|
||||||
|
points, poses, 500_000_000, 0.5,
|
||||||
|
)
|
||||||
|
high, high_samples, _, high_source = _estimate_local_sensor_height(
|
||||||
|
points, poses, 3_000_000_000, 0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert low == pytest.approx(0.18, abs=0.03)
|
||||||
|
assert high == pytest.approx(1.05, abs=0.03)
|
||||||
|
assert low_samples >= 3 and high_samples >= 3
|
||||||
|
assert low_source == high_source == "local-source-cloud-ground-quantile-median"
|
||||||
|
|||||||
@@ -489,7 +489,7 @@ def test_canonical_lab_spatial_frame_uses_ready_immutable_recording(
|
|||||||
assert resolved is not None and resolved.recording is not None
|
assert resolved is not None and resolved.recording is not None
|
||||||
generation = hashlib.sha256(payload).hexdigest()
|
generation = hashlib.sha256(payload).hexdigest()
|
||||||
expected = {
|
expected = {
|
||||||
"schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v2",
|
"schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v3",
|
||||||
"target_time_ns": 500_000_000,
|
"target_time_ns": 500_000_000,
|
||||||
"source_time_ns": 499_000_000,
|
"source_time_ns": 499_000_000,
|
||||||
"pose_time_ns": 499_000_000,
|
"pose_time_ns": 499_000_000,
|
||||||
@@ -497,13 +497,13 @@ def test_canonical_lab_spatial_frame_uses_ready_immutable_recording(
|
|||||||
"coordinate_frame": "body-ground",
|
"coordinate_frame": "body-ground",
|
||||||
"sensor_height": {
|
"sensor_height": {
|
||||||
"meters": 0.32,
|
"meters": 0.32,
|
||||||
"source": "initial-source-cloud-lower-quantile-median",
|
"source": "local-source-cloud-ground-quantile-median",
|
||||||
"sample_count": 20,
|
"sample_count": 20,
|
||||||
"mad_m": 0.03,
|
"mad_m": 0.03,
|
||||||
"authority": "visual-derived",
|
"authority": "visual-derived",
|
||||||
},
|
},
|
||||||
"spatial_profile": {
|
"spatial_profile": {
|
||||||
"profile_id": "source-paced-ground-v2",
|
"profile_id": "source-paced-ground-v3",
|
||||||
"local_slam_history_seconds": 5.0,
|
"local_slam_history_seconds": 5.0,
|
||||||
"local_slam_radius_m": 30.0,
|
"local_slam_radius_m": 30.0,
|
||||||
"local_slam_voxel_size_m": 0.12,
|
"local_slam_voxel_size_m": 0.12,
|
||||||
@@ -544,11 +544,11 @@ def test_canonical_lab_spatial_frame_uses_ready_immutable_recording(
|
|||||||
session_id=session.name,
|
session_id=session.name,
|
||||||
generation=generation,
|
generation=generation,
|
||||||
time_ns=500_000_000,
|
time_ns=500_000_000,
|
||||||
profile="source-paced-ground-v2",
|
profile="source-paced-ground-v3",
|
||||||
))
|
))
|
||||||
assert json.loads(response.body) == expected
|
assert json.loads(response.body) == expected
|
||||||
assert response.headers["etag"] == (
|
assert response.headers["etag"] == (
|
||||||
f'"{generation}:source-paced-ground-v2:499000000"'
|
f'"{generation}:source-paced-ground-v3:499000000"'
|
||||||
)
|
)
|
||||||
assert response.headers["cache-control"].endswith("immutable")
|
assert response.headers["cache-control"].endswith("immutable")
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from k1link.laboratory.evidence_report import verify_laboratory_evidence_result
|
|||||||
from k1link.laboratory.vegetation_policy_review import seal_vegetation_policy_review
|
from k1link.laboratory.vegetation_policy_review import seal_vegetation_policy_review
|
||||||
from k1link.laboratory.vegetation_shadow_lab import seal_vegetation_shadow_lab
|
from k1link.laboratory.vegetation_shadow_lab import seal_vegetation_shadow_lab
|
||||||
from k1link.web.vegetation_shadow_lab_api import (
|
from k1link.web.vegetation_shadow_lab_api import (
|
||||||
|
_mask_component_boxes,
|
||||||
_route_tgs_anchor_payload,
|
_route_tgs_anchor_payload,
|
||||||
build_vegetation_shadow_lab_router,
|
build_vegetation_shadow_lab_router,
|
||||||
)
|
)
|
||||||
@@ -29,6 +30,18 @@ from k1link.web.vegetation_shadow_lab_api import (
|
|||||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_semantic_component_boxes_keep_distinct_objects_separate() -> None:
|
||||||
|
mask = np.zeros((20, 30), dtype=np.uint8)
|
||||||
|
mask[2:10, 3:8] = 4
|
||||||
|
mask[4:12, 18:24] = 4
|
||||||
|
mask[15:17, 3:5] = 4
|
||||||
|
|
||||||
|
assert _mask_component_boxes(mask, 4, minimum_pixels=20) == [
|
||||||
|
(18, 4, 24, 12, 48),
|
||||||
|
(3, 2, 8, 10, 40),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_route_tgs_anchor_payload_preserves_metric_evidence(tmp_path: Path) -> None:
|
def test_route_tgs_anchor_payload_preserves_metric_evidence(tmp_path: Path) -> None:
|
||||||
path = tmp_path / "tgs-evidence.npz"
|
path = tmp_path / "tgs-evidence.npz"
|
||||||
point_counts = np.arange(1, 11, dtype=np.int64)
|
point_counts = np.arange(1, 11, dtype=np.int64)
|
||||||
|
|||||||
Reference in New Issue
Block a user