feat(perception): add diagnostic semantic SLAM replay
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schema_version": "missioncore.laboratory-evidence-definition/v1",
|
||||
"work_id": "e47-semantic-slam-shadow",
|
||||
"evidence": {
|
||||
"runtime_relative_root": "e47/semantic-slam-results",
|
||||
"result_id_prefix": "e47-semantic-slam",
|
||||
"document_name": "manifest.json",
|
||||
"schema_version": "missioncore.e47-semantic-slam-result/v1"
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,25 @@
|
||||
"run": "missioncore.laboratory-run/v1",
|
||||
"evidence": "missioncore.e46j-raw-fisheye-realtime-result/v1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"work_id": "e47-semantic-slam-shadow",
|
||||
"lifecycle": "experimental",
|
||||
"isolation": "bounded-adapter",
|
||||
"adapter_id": "experimental.e47-semantic-slam-shadow/v1",
|
||||
"input_roles": [
|
||||
"repository_root",
|
||||
"semantic_result_root",
|
||||
"threat_result_root",
|
||||
"geometry_result_root"
|
||||
],
|
||||
"contracts": {
|
||||
"source": "missioncore.e47-semantic-slam-source-set/v1",
|
||||
"provider": "missioncore.semantic-slam-diagnostic-provider/v1",
|
||||
"graph": "missioncore.e47-semantic-slam-shadow-graph/v1",
|
||||
"run": "missioncore.laboratory-run/v1",
|
||||
"evidence": "missioncore.e47-semantic-slam-result/v1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"legacy_work_ids": [
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"schema_version": "missioncore.e47-semantic-slam-profile/v1",
|
||||
"profile_id": "ravnoves00-eomt-kb4-slam-shadow/v1",
|
||||
"source": {
|
||||
"source_id": "RAVNOVES00",
|
||||
"session_id": "20260720T065719Z_viewer_live",
|
||||
"frame_count": 4489,
|
||||
"image_width": 800,
|
||||
"image_height": 600,
|
||||
"source_pack_id": "e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b",
|
||||
"source_pack_sha256": "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944",
|
||||
"calibration_content_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"
|
||||
},
|
||||
"semantic_provider": {
|
||||
"provider_id": "eomt-cityscapes-semantic-control/v1",
|
||||
"model_id": "tue-mps/cityscapes_semantic_eomt_large_1024",
|
||||
"model_revision": "8d6b6d1a3f7b50d441afd7d247c2ed10db186e8f",
|
||||
"model_weights_sha256": "c265da9a74f58f5c3f4826d23ca4ca78beac0b106cca5842beca61580de5b782",
|
||||
"preprocess_id": "raw-kb4-valid-fov-semantic/v1",
|
||||
"mask_metadata_schema_version": "missioncore.panoptic-frame/v1",
|
||||
"mask_payload": {
|
||||
"media_type": "image/png",
|
||||
"encoding": "uint8-class-id",
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"sequence_binding": "sequence-0-to-frame-000001"
|
||||
},
|
||||
"role": "fixed-control-not-selected-production-provider"
|
||||
},
|
||||
"taxonomy": [
|
||||
{"class_id": 0, "label": "outside_valid_fov", "disposition": "ambiguous", "color_rgb": [0, 0, 0]},
|
||||
{"class_id": 1, "label": "person", "disposition": "labeled", "color_rgb": [220, 20, 60]},
|
||||
{"class_id": 2, "label": "bicycle", "disposition": "labeled", "color_rgb": [119, 11, 32]},
|
||||
{"class_id": 3, "label": "motorcycle", "disposition": "labeled", "color_rgb": [0, 0, 230]},
|
||||
{"class_id": 4, "label": "car", "disposition": "labeled", "color_rgb": [0, 0, 142]},
|
||||
{"class_id": 5, "label": "heavy_vehicle", "disposition": "labeled", "color_rgb": [0, 0, 70]},
|
||||
{"class_id": 6, "label": "building_structure", "disposition": "labeled", "color_rgb": [70, 70, 70]},
|
||||
{"class_id": 7, "label": "paved_road", "disposition": "labeled", "color_rgb": [128, 64, 128]},
|
||||
{"class_id": 8, "label": "sidewalk_curb", "disposition": "labeled", "color_rgb": [244, 35, 232]},
|
||||
{"class_id": 9, "label": "ground_dirt", "disposition": "labeled", "color_rgb": [81, 0, 81]},
|
||||
{"class_id": 10, "label": "grass_low_vegetation", "disposition": "labeled", "color_rgb": [152, 251, 152]},
|
||||
{"class_id": 11, "label": "tree_woody_vegetation", "disposition": "labeled", "color_rgb": [107, 142, 35]},
|
||||
{"class_id": 12, "label": "sky", "disposition": "labeled", "color_rgb": [70, 130, 180]},
|
||||
{"class_id": 13, "label": "static_obstacle", "disposition": "labeled", "color_rgb": [220, 220, 0]},
|
||||
{"class_id": 14, "label": "animal", "disposition": "labeled", "color_rgb": [255, 127, 80]},
|
||||
{"class_id": 15, "label": "other_background", "disposition": "labeled", "color_rgb": [153, 153, 153]}
|
||||
],
|
||||
"fusion": {
|
||||
"projection": "factory-kb4-current-increment/v1",
|
||||
"point_index_space": "frame-local-source-point-id/v1",
|
||||
"observation_aggregation": "dominant-labeled-majority-diagnostic/v1",
|
||||
"unprojected_status": "unprojected",
|
||||
"semantic_absence_means_free": false,
|
||||
"semantic_can_create_obstacle": false,
|
||||
"semantic_can_change_identity": false,
|
||||
"semantic_can_change_metric_geometry": false,
|
||||
"semantic_can_change_occupancy": false,
|
||||
"semantic_can_change_motion": false,
|
||||
"semantic_can_change_threat": false
|
||||
},
|
||||
"temporal_binding": {
|
||||
"semantic_to_camera": "exact-sequence-and-session-time",
|
||||
"camera_to_lidar": "accepted-e6-nearest-host-arrival-best-effort",
|
||||
"clock_basis": "recorded-host-monotonic-arrival",
|
||||
"maximum_lidar_camera_delta_ms": 100.0,
|
||||
"maximum_pose_point_delta_ms": 100.0,
|
||||
"physical_synchronization_proven": false
|
||||
},
|
||||
"acceptance": {
|
||||
"full_frame_accounting_required": true,
|
||||
"point_accounting_required": true,
|
||||
"observation_binding_required": true,
|
||||
"exact_mask_archive_required": true,
|
||||
"independent_semantic_truth_required_for_provider_promotion": true
|
||||
},
|
||||
"authority": {
|
||||
"ground_truth": false,
|
||||
"physical_live": false,
|
||||
"commands_enabled": false,
|
||||
"actuation_allowed": false,
|
||||
"navigation_or_safety_accepted": false,
|
||||
"semantic_authority": "diagnostic-only"
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ WHEEL_NAME = "nodedc_mission_core-0.1.0-py3-none-any.whl"
|
||||
RUNNER_NAME = RUNNER.name
|
||||
PATCH_ID = re.compile(r"^[A-Za-z0-9._-]{1,96}$")
|
||||
EXPECTED_BASELINE_SHA256 = "ea10359339e6cce31b5780a2710299771cab7cc0c1c2a2b56a1621f786b31fa8"
|
||||
EXPECTED_WHEEL_SHA256 = "2fc53bf3c2cd82a33e62b158a455d813bca64b844707e758792b4bac263b2543"
|
||||
EXPECTED_WHEEL_SHA256 = "c396a202d5cddc2d22dcc3e8b936519205399d20b0f060c763c02e51ba16c62a"
|
||||
PAYLOAD_FILES = (
|
||||
RUNNER_NAME,
|
||||
WHEEL_NAME,
|
||||
|
||||
@@ -315,6 +315,7 @@ def canonical_laboratory_adapters() -> dict[str, LaboratoryAdapter]:
|
||||
"canonical.e33-worker-shadow/v1": _run_e33,
|
||||
"canonical.e35-degradation-recovery/v1": _run_e35,
|
||||
"canonical.e46j-raw-fisheye-realtime/v1": _run_e46j,
|
||||
"experimental.e47-semantic-slam-shadow/v1": _run_e47,
|
||||
}
|
||||
|
||||
|
||||
@@ -375,6 +376,22 @@ def _run_e46j(request: LaboratoryRunRequest) -> LaboratoryAdapterResult:
|
||||
)
|
||||
|
||||
|
||||
def _run_e47(request: LaboratoryRunRequest) -> LaboratoryAdapterResult:
|
||||
from k1link.perception.semantic_slam_replay import build_semantic_slam_replay
|
||||
|
||||
result = build_semantic_slam_replay(
|
||||
repository_root=request.inputs["repository_root"],
|
||||
semantic_result_root=request.inputs["semantic_result_root"],
|
||||
threat_result_root=request.inputs["threat_result_root"],
|
||||
geometry_result_root=request.inputs["geometry_result_root"],
|
||||
output_root=request.output_root,
|
||||
)
|
||||
return LaboratoryAdapterResult(
|
||||
result_root=result.result_root,
|
||||
result_id=result.result_id,
|
||||
)
|
||||
|
||||
|
||||
def _validate_request(
|
||||
request: LaboratoryRunRequest,
|
||||
definition: LaboratoryExecutionDefinition,
|
||||
|
||||
@@ -83,6 +83,22 @@ class GeometryFrame:
|
||||
return int(self.points_map.shape[0])
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordedFrameTemporalBinding:
|
||||
"""Digest-bound recorded timing evidence for one camera-indexed increment.
|
||||
|
||||
The shared session time binds the camera ordinal to the E10 pack entry. The
|
||||
LiDAR and pose deltas retain their admitted E6 meaning: nearest host-arrival
|
||||
best effort, not hardware synchronization.
|
||||
"""
|
||||
|
||||
frame_index: int
|
||||
source_time_ns: int
|
||||
source_available: bool
|
||||
lidar_camera_delta_ms: float | None
|
||||
pose_point_delta_ms: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReplayBodyFrameInputs:
|
||||
"""Verified inputs required to derive one replay-only virtual body frame."""
|
||||
@@ -222,13 +238,29 @@ class RecordedGeometryStore:
|
||||
or pose_reference.frame_index != envelope.sequence
|
||||
):
|
||||
raise GeometryProviderError("packet geometry references are not source-bound")
|
||||
frame_index = envelope.sequence
|
||||
frame = self.frame_for_index(envelope.sequence)
|
||||
if frame is None:
|
||||
raise GeometryProviderError("packet claims unavailable source geometry as current")
|
||||
return frame
|
||||
|
||||
def frame_for_index(self, frame_index: int) -> GeometryFrame | None:
|
||||
"""Expose one verified source increment with its pose and KB4 calibration.
|
||||
|
||||
This read-only seam is intentionally narrower than the source archive. It
|
||||
exists for deterministic replay diagnostics which must project the exact
|
||||
frame-local point index space without manufacturing a ``SourcePacket``.
|
||||
An unavailable recorded increment remains ``None``; surface validity is
|
||||
retained on the returned frame rather than silently filtering its points.
|
||||
"""
|
||||
|
||||
if not isinstance(frame_index, int) or isinstance(frame_index, bool):
|
||||
raise GeometryProviderError("replay geometry frame index is invalid")
|
||||
if not 0 <= frame_index < self.profile.frame_count:
|
||||
raise GeometryProviderError("packet geometry frame index is outside the profile")
|
||||
raise GeometryProviderError("replay geometry frame is outside the profile")
|
||||
if int(self._source["frame_indices"][frame_index]) != frame_index:
|
||||
raise GeometryProviderError("source pack frame sequence changed")
|
||||
if not bool(self._source["sample_available"][frame_index]):
|
||||
raise GeometryProviderError("packet claims unavailable source geometry as current")
|
||||
return None
|
||||
offsets = self._source["cloud_offsets"]
|
||||
start, end = int(offsets[frame_index]), int(offsets[frame_index + 1])
|
||||
return GeometryFrame(
|
||||
@@ -247,6 +279,42 @@ class RecordedGeometryStore:
|
||||
surface_valid=bool(self._surface["frame_valid"][frame_index]),
|
||||
)
|
||||
|
||||
def temporal_binding_for_index(self, frame_index: int) -> RecordedFrameTemporalBinding:
|
||||
"""Return the sealed ordinal/session binding and admitted best-effort deltas."""
|
||||
|
||||
if not isinstance(frame_index, int) or isinstance(frame_index, bool):
|
||||
raise GeometryProviderError("replay temporal frame index is invalid")
|
||||
if not 0 <= frame_index < self.profile.frame_count:
|
||||
raise GeometryProviderError("replay temporal frame is outside the profile")
|
||||
if (
|
||||
int(self._source["frame_indices"][frame_index]) != frame_index
|
||||
or int(self._source["source_frame_indices"][frame_index]) != frame_index
|
||||
):
|
||||
raise GeometryProviderError("source pack temporal sequence changed")
|
||||
session_seconds = float(self._source["session_seconds"][frame_index])
|
||||
if not math.isfinite(session_seconds) or session_seconds < 0.0:
|
||||
raise GeometryProviderError("source pack session time is invalid")
|
||||
source_available = bool(self._source["sample_available"][frame_index])
|
||||
lidar_delta = float(self._source["lidar_camera_delta_ms"][frame_index])
|
||||
pose_delta = float(self._source["pose_point_delta_ms"][frame_index])
|
||||
if source_available:
|
||||
if not math.isfinite(lidar_delta) or not math.isfinite(pose_delta):
|
||||
raise GeometryProviderError("available source temporal deltas are invalid")
|
||||
lidar_value: float | None = lidar_delta
|
||||
pose_value: float | None = pose_delta
|
||||
else:
|
||||
if not math.isnan(lidar_delta) or not math.isnan(pose_delta):
|
||||
raise GeometryProviderError("unavailable source carries temporal deltas")
|
||||
lidar_value = None
|
||||
pose_value = None
|
||||
return RecordedFrameTemporalBinding(
|
||||
frame_index=frame_index,
|
||||
source_time_ns=round(session_seconds * 1_000_000_000),
|
||||
source_available=source_available,
|
||||
lidar_camera_delta_ms=lidar_value,
|
||||
pose_point_delta_ms=pose_value,
|
||||
)
|
||||
|
||||
def current_points(self, packet: SourcePacket) -> FloatArray | None:
|
||||
"""Expose the verified frame-local point index space to temporal occupancy."""
|
||||
|
||||
@@ -393,6 +461,8 @@ class RecordedGeometryStore:
|
||||
def _validate(self) -> None:
|
||||
source_required = {
|
||||
"frame_indices",
|
||||
"source_frame_indices",
|
||||
"session_seconds",
|
||||
"sample_available",
|
||||
"cloud_offsets",
|
||||
"cloud_points_map",
|
||||
@@ -401,6 +471,8 @@ class RecordedGeometryStore:
|
||||
"intrinsic_fx_fy_cx_cy",
|
||||
"distortion_kb4",
|
||||
"t_camera_from_lidar",
|
||||
"lidar_camera_delta_ms",
|
||||
"pose_point_delta_ms",
|
||||
}
|
||||
surface_required = {"frame_valid", "point_class"}
|
||||
if not source_required.issubset(self._source):
|
||||
@@ -411,6 +483,8 @@ class RecordedGeometryStore:
|
||||
points = self.profile.point_count
|
||||
shapes = {
|
||||
"frame_indices": (frames,),
|
||||
"source_frame_indices": (frames,),
|
||||
"session_seconds": (frames,),
|
||||
"sample_available": (frames,),
|
||||
"cloud_offsets": (frames + 1,),
|
||||
"cloud_points_map": (points, 3),
|
||||
@@ -419,6 +493,8 @@ class RecordedGeometryStore:
|
||||
"intrinsic_fx_fy_cx_cy": (4,),
|
||||
"distortion_kb4": (4,),
|
||||
"t_camera_from_lidar": (4, 4),
|
||||
"lidar_camera_delta_ms": (frames,),
|
||||
"pose_point_delta_ms": (frames,),
|
||||
}
|
||||
if any(self._source[name].shape != shape for name, shape in shapes.items()):
|
||||
raise GeometryProviderError("source pack array shapes changed")
|
||||
@@ -428,6 +504,26 @@ class RecordedGeometryStore:
|
||||
raise GeometryProviderError("local surface point shape changed")
|
||||
if int(self._source["cloud_offsets"][-1]) != points:
|
||||
raise GeometryProviderError("source point offsets do not close")
|
||||
expected_indices = np.arange(frames, dtype=np.int64)
|
||||
session_seconds = np.asarray(self._source["session_seconds"], dtype=np.float64)
|
||||
if (
|
||||
not np.array_equal(self._source["frame_indices"], expected_indices)
|
||||
or not np.array_equal(self._source["source_frame_indices"], expected_indices)
|
||||
or not np.isfinite(session_seconds).all()
|
||||
or np.any(session_seconds < 0.0)
|
||||
or np.any(np.diff(session_seconds) <= 0.0)
|
||||
):
|
||||
raise GeometryProviderError("source temporal index changed")
|
||||
available = np.asarray(self._source["sample_available"], dtype=np.bool_)
|
||||
lidar_deltas = np.asarray(self._source["lidar_camera_delta_ms"], dtype=np.float64)
|
||||
pose_deltas = np.asarray(self._source["pose_point_delta_ms"], dtype=np.float64)
|
||||
if (
|
||||
not np.isfinite(lidar_deltas[available]).all()
|
||||
or not np.isfinite(pose_deltas[available]).all()
|
||||
or not np.isnan(lidar_deltas[~available]).all()
|
||||
or not np.isnan(pose_deltas[~available]).all()
|
||||
):
|
||||
raise GeometryProviderError("source temporal delta availability changed")
|
||||
if (
|
||||
int(np.count_nonzero(self._source["sample_available"]))
|
||||
!= self.profile.valid_frame_count
|
||||
@@ -1030,6 +1126,7 @@ __all__ = [
|
||||
"GeometryProviderError",
|
||||
"GeometryProviderSnapshot",
|
||||
"Ravnoves00GeometryAssociationProvider",
|
||||
"RecordedFrameTemporalBinding",
|
||||
"RecordedGeometryStore",
|
||||
"load_geometry_profile",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
"""Model-neutral semantic diagnostics for admitted geometry observations.
|
||||
|
||||
The seam projects a provider-owned ``uint8`` semantic mask onto the existing
|
||||
frame-local point index space and aggregates those labels for already-created
|
||||
``ObstacleObservation`` values. It deliberately returns separate diagnostic
|
||||
evidence: semantic output cannot create or replace obstacle identity, metric
|
||||
occupancy, motion, threat, or safety authority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum, StrEnum
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from .contracts import ObstacleObservation
|
||||
from .geometry_math import ProjectedPointCloud
|
||||
|
||||
Int16Array = npt.NDArray[np.int16]
|
||||
UInt8Array = npt.NDArray[np.uint8]
|
||||
|
||||
NO_SEMANTIC_CLASS_ID = -1
|
||||
_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$")
|
||||
|
||||
|
||||
class SemanticFusionError(ValueError):
|
||||
"""Semantic input or its geometry binding violates the diagnostic contract."""
|
||||
|
||||
|
||||
class SemanticClassDisposition(StrEnum):
|
||||
"""Whether one provider class is usable as a label or explicitly uncertain."""
|
||||
|
||||
LABELED = "labeled"
|
||||
AMBIGUOUS = "ambiguous"
|
||||
|
||||
|
||||
class SemanticEvidenceStatus(IntEnum):
|
||||
"""Compact source-point and observation semantic state."""
|
||||
|
||||
ABSENT = 0
|
||||
UNPROJECTED = 1
|
||||
AMBIGUOUS = 2
|
||||
LABELED = 3
|
||||
|
||||
|
||||
class SemanticEvidenceAuthority(StrEnum):
|
||||
"""Semantic output is never promoted into planner or safety authority."""
|
||||
|
||||
DIAGNOSTIC_ONLY = "diagnostic-only"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticClassDefinition:
|
||||
"""Provider-neutral meaning assigned to one raw ``uint8`` mask value."""
|
||||
|
||||
class_id: int
|
||||
label: str
|
||||
disposition: SemanticClassDisposition = SemanticClassDisposition.LABELED
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
not isinstance(self.class_id, int)
|
||||
or isinstance(self.class_id, bool)
|
||||
or not 0 <= self.class_id <= 255
|
||||
):
|
||||
raise SemanticFusionError("semantic class id must fit uint8")
|
||||
_label(self.label, "semantic class label")
|
||||
if not isinstance(self.disposition, SemanticClassDisposition):
|
||||
raise SemanticFusionError("semantic class disposition is invalid")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticMask:
|
||||
"""One source-bound hard semantic mask plus its complete class vocabulary."""
|
||||
|
||||
source_id: str
|
||||
frame_id: str
|
||||
provider_id: str
|
||||
model_id: str
|
||||
preprocess_id: str
|
||||
labels: UInt8Array
|
||||
classes: tuple[SemanticClassDefinition, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for value, label in (
|
||||
(self.source_id, "semantic source id"),
|
||||
(self.frame_id, "semantic frame id"),
|
||||
(self.provider_id, "semantic provider id"),
|
||||
(self.model_id, "semantic model id"),
|
||||
(self.preprocess_id, "semantic preprocess id"),
|
||||
):
|
||||
_identifier(value, label)
|
||||
if not isinstance(self.labels, np.ndarray):
|
||||
raise SemanticFusionError("semantic mask must be a numpy array")
|
||||
if self.labels.dtype != np.uint8 or self.labels.ndim != 2:
|
||||
raise SemanticFusionError("semantic mask must have uint8 HxW shape")
|
||||
if self.labels.shape[0] < 1 or self.labels.shape[1] < 1:
|
||||
raise SemanticFusionError("semantic mask dimensions must be positive")
|
||||
if not isinstance(self.classes, tuple) or not self.classes:
|
||||
raise SemanticFusionError("semantic class vocabulary must be a nonempty tuple")
|
||||
if any(not isinstance(item, SemanticClassDefinition) for item in self.classes):
|
||||
raise SemanticFusionError("semantic class vocabulary is invalid")
|
||||
class_ids = tuple(item.class_id for item in self.classes)
|
||||
if len(set(class_ids)) != len(class_ids):
|
||||
raise SemanticFusionError("semantic class ids must be unique")
|
||||
undeclared = set(int(value) for value in np.unique(self.labels)) - set(class_ids)
|
||||
if undeclared:
|
||||
raise SemanticFusionError("semantic mask contains undeclared class ids")
|
||||
frozen = np.array(self.labels, dtype=np.uint8, order="C", copy=True)
|
||||
frozen.setflags(write=False)
|
||||
object.__setattr__(self, "labels", frozen)
|
||||
|
||||
@property
|
||||
def height(self) -> int:
|
||||
return int(self.labels.shape[0])
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
return int(self.labels.shape[1])
|
||||
|
||||
def class_definition(self, class_id: int) -> SemanticClassDefinition:
|
||||
for definition in self.classes:
|
||||
if definition.class_id == class_id:
|
||||
return definition
|
||||
raise SemanticFusionError("semantic class id is not declared")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PointSemanticLabels:
|
||||
"""Semantic labels aligned to the complete source-point index space.
|
||||
|
||||
``class_ids`` uses ``-1`` only when the corresponding status is ``ABSENT``
|
||||
or ``UNPROJECTED``. Callers must never interpret that sentinel as a model
|
||||
class. Ambiguous provider classes retain their raw class id for review.
|
||||
"""
|
||||
|
||||
class_ids: Int16Array
|
||||
status_codes: UInt8Array
|
||||
classes: tuple[SemanticClassDefinition, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.class_ids, np.ndarray) or self.class_ids.dtype != np.int16:
|
||||
raise SemanticFusionError("point semantic class ids must be int16")
|
||||
if self.class_ids.ndim != 1:
|
||||
raise SemanticFusionError("point semantic class ids must be one-dimensional")
|
||||
if not isinstance(self.status_codes, np.ndarray) or self.status_codes.dtype != np.uint8:
|
||||
raise SemanticFusionError("point semantic status codes must be uint8")
|
||||
if self.status_codes.shape != self.class_ids.shape:
|
||||
raise SemanticFusionError("point semantic arrays must have equal shape")
|
||||
if not isinstance(self.classes, tuple) or any(
|
||||
not isinstance(item, SemanticClassDefinition) for item in self.classes
|
||||
):
|
||||
raise SemanticFusionError("point semantic vocabulary is invalid")
|
||||
if len({item.class_id for item in self.classes}) != len(self.classes):
|
||||
raise SemanticFusionError("point semantic class ids must be unique")
|
||||
valid_statuses = {int(status) for status in SemanticEvidenceStatus}
|
||||
if set(int(value) for value in np.unique(self.status_codes)) - valid_statuses:
|
||||
raise SemanticFusionError("point semantic status code is invalid")
|
||||
unavailable = np.isin(
|
||||
self.status_codes,
|
||||
(SemanticEvidenceStatus.ABSENT, SemanticEvidenceStatus.UNPROJECTED),
|
||||
)
|
||||
if np.any(self.class_ids[unavailable] != NO_SEMANTIC_CLASS_ID):
|
||||
raise SemanticFusionError("unavailable point semantics cannot carry a class id")
|
||||
available = ~unavailable
|
||||
if np.any((self.class_ids[available] < 0) | (self.class_ids[available] > 255)):
|
||||
raise SemanticFusionError("available point semantic class id is invalid")
|
||||
definitions = {item.class_id: item for item in self.classes}
|
||||
for class_id, status_code in zip(
|
||||
self.class_ids[available].tolist(),
|
||||
self.status_codes[available].tolist(),
|
||||
strict=True,
|
||||
):
|
||||
definition = definitions.get(int(class_id))
|
||||
if definition is None:
|
||||
raise SemanticFusionError("point semantic class id is not declared")
|
||||
expected = (
|
||||
SemanticEvidenceStatus.AMBIGUOUS
|
||||
if definition.disposition is SemanticClassDisposition.AMBIGUOUS
|
||||
else SemanticEvidenceStatus.LABELED
|
||||
)
|
||||
if int(status_code) != int(expected):
|
||||
raise SemanticFusionError("point semantic status disagrees with its class")
|
||||
class_ids = np.array(self.class_ids, dtype=np.int16, order="C", copy=True)
|
||||
status_codes = np.array(self.status_codes, dtype=np.uint8, order="C", copy=True)
|
||||
class_ids.setflags(write=False)
|
||||
status_codes.setflags(write=False)
|
||||
object.__setattr__(self, "class_ids", class_ids)
|
||||
object.__setattr__(self, "status_codes", status_codes)
|
||||
|
||||
@property
|
||||
def source_point_count(self) -> int:
|
||||
return int(self.class_ids.size)
|
||||
|
||||
def status_for(self, source_point_id: int) -> SemanticEvidenceStatus:
|
||||
_point_id(source_point_id, self.source_point_count)
|
||||
return SemanticEvidenceStatus(int(self.status_codes[source_point_id]))
|
||||
|
||||
def class_id_for(self, source_point_id: int) -> int | None:
|
||||
_point_id(source_point_id, self.source_point_count)
|
||||
value = int(self.class_ids[source_point_id])
|
||||
return None if value == NO_SEMANTIC_CLASS_ID else value
|
||||
|
||||
def label_for(self, source_point_id: int) -> str | None:
|
||||
class_id = self.class_id_for(source_point_id)
|
||||
if class_id is None:
|
||||
return None
|
||||
for definition in self.classes:
|
||||
if definition.class_id == class_id:
|
||||
return definition.label
|
||||
raise AssertionError("validated point class disappeared from its vocabulary")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticClassEvidence:
|
||||
"""Point support for one semantic class inside an existing observation."""
|
||||
|
||||
class_id: int
|
||||
label: str
|
||||
disposition: SemanticClassDisposition
|
||||
point_count: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
not isinstance(self.class_id, int)
|
||||
or isinstance(self.class_id, bool)
|
||||
or not 0 <= self.class_id <= 255
|
||||
):
|
||||
raise SemanticFusionError("semantic evidence class id must fit uint8")
|
||||
_label(self.label, "semantic evidence label")
|
||||
if not isinstance(self.disposition, SemanticClassDisposition):
|
||||
raise SemanticFusionError("semantic evidence disposition is invalid")
|
||||
if (
|
||||
not isinstance(self.point_count, int)
|
||||
or isinstance(self.point_count, bool)
|
||||
or self.point_count < 1
|
||||
):
|
||||
raise SemanticFusionError("semantic evidence point count must be positive")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ObservationSemanticEvidence:
|
||||
"""Aggregated, non-authoritative semantics for one immutable observation."""
|
||||
|
||||
observation_id: str
|
||||
occupancy_key: str
|
||||
status: SemanticEvidenceStatus
|
||||
source_point_count: int
|
||||
labeled_point_count: int
|
||||
ambiguous_point_count: int
|
||||
unprojected_point_count: int
|
||||
absent_point_count: int
|
||||
class_evidence: tuple[SemanticClassEvidence, ...]
|
||||
dominant_class_id: int | None
|
||||
dominant_label: str | None
|
||||
dominant_fraction_of_labeled: float | None
|
||||
reason_code: str
|
||||
authority: SemanticEvidenceAuthority = SemanticEvidenceAuthority.DIAGNOSTIC_ONLY
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_identifier(self.observation_id, "semantic observation id")
|
||||
_identifier(self.occupancy_key, "semantic occupancy binding")
|
||||
_identifier(self.reason_code, "semantic evidence reason")
|
||||
if not isinstance(self.status, SemanticEvidenceStatus):
|
||||
raise SemanticFusionError("observation semantic status is invalid")
|
||||
if self.authority is not SemanticEvidenceAuthority.DIAGNOSTIC_ONLY:
|
||||
raise SemanticFusionError("semantic evidence cannot acquire product authority")
|
||||
counts = (
|
||||
self.source_point_count,
|
||||
self.labeled_point_count,
|
||||
self.ambiguous_point_count,
|
||||
self.unprojected_point_count,
|
||||
self.absent_point_count,
|
||||
)
|
||||
if any(
|
||||
not isinstance(value, int) or isinstance(value, bool) or value < 0
|
||||
for value in counts
|
||||
):
|
||||
raise SemanticFusionError("semantic evidence counts must be nonnegative integers")
|
||||
if sum(counts[1:]) != self.source_point_count:
|
||||
raise SemanticFusionError("semantic evidence accounting is incomplete")
|
||||
if not isinstance(self.class_evidence, tuple) or any(
|
||||
not isinstance(item, SemanticClassEvidence) for item in self.class_evidence
|
||||
):
|
||||
raise SemanticFusionError("semantic class evidence is invalid")
|
||||
if sum(item.point_count for item in self.class_evidence) != (
|
||||
self.labeled_point_count + self.ambiguous_point_count
|
||||
):
|
||||
raise SemanticFusionError("semantic class evidence accounting is incomplete")
|
||||
if len({item.class_id for item in self.class_evidence}) != len(self.class_evidence):
|
||||
raise SemanticFusionError("semantic class evidence ids must be unique")
|
||||
if self.status is SemanticEvidenceStatus.ABSENT:
|
||||
if self.absent_point_count != self.source_point_count:
|
||||
raise SemanticFusionError("absent semantic evidence accounting is invalid")
|
||||
elif self.status is SemanticEvidenceStatus.UNPROJECTED:
|
||||
if self.source_point_count and self.unprojected_point_count != self.source_point_count:
|
||||
raise SemanticFusionError("unprojected semantic evidence accounting is invalid")
|
||||
elif self.status is SemanticEvidenceStatus.AMBIGUOUS:
|
||||
if not self.labeled_point_count and not self.ambiguous_point_count:
|
||||
raise SemanticFusionError("ambiguous semantic evidence needs projected labels")
|
||||
elif not self.labeled_point_count:
|
||||
raise SemanticFusionError("labeled semantic evidence needs labeled points")
|
||||
dominant_values = (
|
||||
self.dominant_class_id,
|
||||
self.dominant_label,
|
||||
self.dominant_fraction_of_labeled,
|
||||
)
|
||||
if self.status is SemanticEvidenceStatus.LABELED:
|
||||
if any(value is None for value in dominant_values):
|
||||
raise SemanticFusionError("labeled semantic evidence needs a dominant class")
|
||||
if (
|
||||
not isinstance(self.dominant_fraction_of_labeled, float)
|
||||
or not math.isfinite(self.dominant_fraction_of_labeled)
|
||||
or not 0.5 < self.dominant_fraction_of_labeled <= 1.0
|
||||
):
|
||||
raise SemanticFusionError("dominant semantic fraction must be a majority")
|
||||
elif any(value is not None for value in dominant_values):
|
||||
raise SemanticFusionError("non-labeled semantic evidence cannot claim a dominant class")
|
||||
|
||||
@property
|
||||
def semantic_coverage_fraction(self) -> float:
|
||||
if not self.source_point_count:
|
||||
return 0.0
|
||||
return (self.labeled_point_count + self.ambiguous_point_count) / self.source_point_count
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticFusionResult:
|
||||
"""One frame's detached semantic diagnostics."""
|
||||
|
||||
mask_available: bool
|
||||
point_labels: PointSemanticLabels
|
||||
observation_evidence: tuple[ObservationSemanticEvidence, ...]
|
||||
authority: SemanticEvidenceAuthority = SemanticEvidenceAuthority.DIAGNOSTIC_ONLY
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.mask_available, bool):
|
||||
raise SemanticFusionError("semantic mask availability must be boolean")
|
||||
if not isinstance(self.point_labels, PointSemanticLabels):
|
||||
raise SemanticFusionError("semantic point labels are invalid")
|
||||
if not isinstance(self.observation_evidence, tuple) or any(
|
||||
not isinstance(item, ObservationSemanticEvidence)
|
||||
for item in self.observation_evidence
|
||||
):
|
||||
raise SemanticFusionError("observation semantic evidence is invalid")
|
||||
if len({item.observation_id for item in self.observation_evidence}) != len(
|
||||
self.observation_evidence
|
||||
):
|
||||
raise SemanticFusionError("observation semantic evidence ids must be unique")
|
||||
if self.authority is not SemanticEvidenceAuthority.DIAGNOSTIC_ONLY:
|
||||
raise SemanticFusionError("semantic fusion cannot acquire product authority")
|
||||
if self.mask_available is not bool(self.point_labels.classes):
|
||||
raise SemanticFusionError("semantic mask availability and vocabulary disagree")
|
||||
|
||||
|
||||
def fuse_semantic_diagnostics(
|
||||
*,
|
||||
semantic_mask: SemanticMask | None,
|
||||
projected: ProjectedPointCloud,
|
||||
observations: tuple[ObstacleObservation, ...],
|
||||
) -> SemanticFusionResult:
|
||||
"""Attach mask diagnostics to points and observations without mutating authority."""
|
||||
|
||||
if semantic_mask is not None and not isinstance(semantic_mask, SemanticMask):
|
||||
raise SemanticFusionError("semantic mask contract is invalid")
|
||||
if not isinstance(observations, tuple) or any(
|
||||
not isinstance(item, ObstacleObservation) for item in observations
|
||||
):
|
||||
raise SemanticFusionError("geometry observations must be a tuple")
|
||||
_validate_projection(projected)
|
||||
_validate_observation_bindings(
|
||||
observations,
|
||||
semantic_mask=semantic_mask,
|
||||
source_point_count=projected.source_point_count,
|
||||
)
|
||||
point_labels = _project_point_labels(semantic_mask, projected)
|
||||
evidence = tuple(
|
||||
_aggregate_observation(observation, point_labels) for observation in observations
|
||||
)
|
||||
return SemanticFusionResult(
|
||||
mask_available=semantic_mask is not None,
|
||||
point_labels=point_labels,
|
||||
observation_evidence=evidence,
|
||||
)
|
||||
|
||||
|
||||
def _project_point_labels(
|
||||
semantic_mask: SemanticMask | None,
|
||||
projected: ProjectedPointCloud,
|
||||
) -> PointSemanticLabels:
|
||||
point_count = projected.source_point_count
|
||||
class_ids = np.full(point_count, NO_SEMANTIC_CLASS_ID, dtype=np.int16)
|
||||
if semantic_mask is None:
|
||||
return PointSemanticLabels(
|
||||
class_ids=class_ids,
|
||||
status_codes=np.full(
|
||||
point_count,
|
||||
int(SemanticEvidenceStatus.ABSENT),
|
||||
dtype=np.uint8,
|
||||
),
|
||||
classes=(),
|
||||
)
|
||||
|
||||
statuses = np.full(
|
||||
point_count,
|
||||
int(SemanticEvidenceStatus.UNPROJECTED),
|
||||
dtype=np.uint8,
|
||||
)
|
||||
if projected.projected_point_count:
|
||||
pixel_indices = np.floor(projected.pixels_xy).astype(np.int64)
|
||||
inside = (
|
||||
(pixel_indices[:, 0] >= 0)
|
||||
& (pixel_indices[:, 0] < semantic_mask.width)
|
||||
& (pixel_indices[:, 1] >= 0)
|
||||
& (pixel_indices[:, 1] < semantic_mask.height)
|
||||
)
|
||||
rows = np.flatnonzero(inside)
|
||||
if rows.size:
|
||||
source_ids = projected.source_indices[rows]
|
||||
pixels = pixel_indices[rows]
|
||||
raw_classes = semantic_mask.labels[pixels[:, 1], pixels[:, 0]]
|
||||
class_ids[source_ids] = raw_classes.astype(np.int16, copy=False)
|
||||
ambiguous_ids = np.asarray(
|
||||
[
|
||||
item.class_id
|
||||
for item in semantic_mask.classes
|
||||
if item.disposition is SemanticClassDisposition.AMBIGUOUS
|
||||
],
|
||||
dtype=np.uint8,
|
||||
)
|
||||
ambiguous = np.isin(raw_classes, ambiguous_ids)
|
||||
statuses[source_ids] = np.where(
|
||||
ambiguous,
|
||||
int(SemanticEvidenceStatus.AMBIGUOUS),
|
||||
int(SemanticEvidenceStatus.LABELED),
|
||||
).astype(np.uint8, copy=False)
|
||||
return PointSemanticLabels(
|
||||
class_ids=class_ids,
|
||||
status_codes=statuses,
|
||||
classes=semantic_mask.classes,
|
||||
)
|
||||
|
||||
|
||||
def _aggregate_observation(
|
||||
observation: ObstacleObservation,
|
||||
point_labels: PointSemanticLabels,
|
||||
) -> ObservationSemanticEvidence:
|
||||
point_ids = np.asarray(observation.source_point_ids, dtype=np.int64)
|
||||
statuses = point_labels.status_codes[point_ids]
|
||||
class_ids = point_labels.class_ids[point_ids]
|
||||
counts = Counter(int(value) for value in statuses.tolist())
|
||||
labeled_count = counts[int(SemanticEvidenceStatus.LABELED)]
|
||||
ambiguous_count = counts[int(SemanticEvidenceStatus.AMBIGUOUS)]
|
||||
unprojected_count = counts[int(SemanticEvidenceStatus.UNPROJECTED)]
|
||||
absent_count = counts[int(SemanticEvidenceStatus.ABSENT)]
|
||||
definitions = {item.class_id: item for item in point_labels.classes}
|
||||
semantic_class_ids = class_ids[
|
||||
np.isin(
|
||||
statuses,
|
||||
(SemanticEvidenceStatus.LABELED, SemanticEvidenceStatus.AMBIGUOUS),
|
||||
)
|
||||
]
|
||||
class_counts = Counter(int(value) for value in semantic_class_ids.tolist())
|
||||
class_evidence = tuple(
|
||||
SemanticClassEvidence(
|
||||
class_id=class_id,
|
||||
label=definitions[class_id].label,
|
||||
disposition=definitions[class_id].disposition,
|
||||
point_count=point_count,
|
||||
)
|
||||
for class_id, point_count in sorted(class_counts.items())
|
||||
)
|
||||
|
||||
status: SemanticEvidenceStatus
|
||||
dominant_class_id: int | None = None
|
||||
dominant_label: str | None = None
|
||||
dominant_fraction: float | None = None
|
||||
if not point_labels.classes:
|
||||
status = SemanticEvidenceStatus.ABSENT
|
||||
reason_code = "semantic-mask-unavailable"
|
||||
elif not observation.source_point_ids or unprojected_count == len(
|
||||
observation.source_point_ids
|
||||
):
|
||||
status = SemanticEvidenceStatus.UNPROJECTED
|
||||
reason_code = (
|
||||
"observation-has-no-source-points"
|
||||
if not observation.source_point_ids
|
||||
else "observation-points-unprojected"
|
||||
)
|
||||
elif not labeled_count:
|
||||
status = SemanticEvidenceStatus.AMBIGUOUS
|
||||
reason_code = "semantic-classes-ambiguous"
|
||||
else:
|
||||
labeled_ids = class_ids[statuses == int(SemanticEvidenceStatus.LABELED)]
|
||||
labeled_counts = Counter(int(value) for value in labeled_ids.tolist())
|
||||
maximum = max(labeled_counts.values())
|
||||
candidates = [
|
||||
class_id for class_id, count in labeled_counts.items() if count == maximum
|
||||
]
|
||||
semantic_point_count = labeled_count + ambiguous_count
|
||||
if len(candidates) != 1 or maximum * 2 <= semantic_point_count:
|
||||
status = SemanticEvidenceStatus.AMBIGUOUS
|
||||
reason_code = "semantic-label-majority-ambiguous"
|
||||
else:
|
||||
status = SemanticEvidenceStatus.LABELED
|
||||
dominant_class_id = candidates[0]
|
||||
dominant_label = definitions[dominant_class_id].label
|
||||
dominant_fraction = float(maximum / labeled_count)
|
||||
reason_code = "semantic-label-majority"
|
||||
return ObservationSemanticEvidence(
|
||||
observation_id=observation.observation_id,
|
||||
occupancy_key=observation.occupancy_key,
|
||||
status=status,
|
||||
source_point_count=len(observation.source_point_ids),
|
||||
labeled_point_count=labeled_count,
|
||||
ambiguous_point_count=ambiguous_count,
|
||||
unprojected_point_count=unprojected_count,
|
||||
absent_point_count=absent_count,
|
||||
class_evidence=class_evidence,
|
||||
dominant_class_id=dominant_class_id,
|
||||
dominant_label=dominant_label,
|
||||
dominant_fraction_of_labeled=dominant_fraction,
|
||||
reason_code=reason_code,
|
||||
)
|
||||
|
||||
|
||||
def _validate_projection(projected: ProjectedPointCloud) -> None:
|
||||
if not isinstance(projected, ProjectedPointCloud):
|
||||
raise SemanticFusionError("projected point cloud contract is invalid")
|
||||
if (
|
||||
not isinstance(projected.pixels_xy, np.ndarray)
|
||||
or projected.pixels_xy.dtype != np.float64
|
||||
or projected.pixels_xy.ndim != 2
|
||||
or projected.pixels_xy.shape[1:] != (2,)
|
||||
):
|
||||
raise SemanticFusionError("projected pixels must have float64 Nx2 shape")
|
||||
count = projected.projected_point_count
|
||||
if (
|
||||
not isinstance(projected.depths_m, np.ndarray)
|
||||
or projected.depths_m.dtype != np.float64
|
||||
or projected.depths_m.shape != (count,)
|
||||
):
|
||||
raise SemanticFusionError("projected depths must have float64 N shape")
|
||||
if (
|
||||
not isinstance(projected.source_indices, np.ndarray)
|
||||
or projected.source_indices.dtype != np.int64
|
||||
or projected.source_indices.shape != (count,)
|
||||
):
|
||||
raise SemanticFusionError("projected source indices must have int64 N shape")
|
||||
for value, label in (
|
||||
(projected.source_point_count, "source point count"),
|
||||
(projected.camera_front_point_count, "camera-front point count"),
|
||||
):
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise SemanticFusionError(f"{label} must be a nonnegative integer")
|
||||
if not count <= projected.camera_front_point_count <= projected.source_point_count:
|
||||
raise SemanticFusionError("projected point accounting is invalid")
|
||||
if (
|
||||
not np.isfinite(projected.pixels_xy).all()
|
||||
or not np.isfinite(projected.depths_m).all()
|
||||
or np.any(projected.depths_m <= 0.0)
|
||||
):
|
||||
raise SemanticFusionError("projected point values must be finite and in front")
|
||||
if np.any(projected.source_indices < 0) or np.any(
|
||||
projected.source_indices >= projected.source_point_count
|
||||
):
|
||||
raise SemanticFusionError("projected source point id is outside the source frame")
|
||||
if np.unique(projected.source_indices).size != count:
|
||||
raise SemanticFusionError("projected source point ids must be unique")
|
||||
|
||||
|
||||
def _validate_observation_bindings(
|
||||
observations: tuple[ObstacleObservation, ...],
|
||||
*,
|
||||
semantic_mask: SemanticMask | None,
|
||||
source_point_count: int,
|
||||
) -> None:
|
||||
observation_ids: set[str] = set()
|
||||
point_owners: dict[int, str] = {}
|
||||
source_frames = {(item.source_id, item.frame_id) for item in observations}
|
||||
if len(source_frames) > 1:
|
||||
raise SemanticFusionError("geometry observations escaped their source frame")
|
||||
for observation in observations:
|
||||
if observation.observation_id in observation_ids:
|
||||
raise SemanticFusionError("geometry observation ids must be unique")
|
||||
observation_ids.add(observation.observation_id)
|
||||
if semantic_mask is not None and (
|
||||
observation.source_id != semantic_mask.source_id
|
||||
or observation.frame_id != semantic_mask.frame_id
|
||||
):
|
||||
raise SemanticFusionError("semantic mask escaped its observation source frame")
|
||||
for point_id in observation.source_point_ids:
|
||||
if point_id >= source_point_count:
|
||||
raise SemanticFusionError("observation source point id is outside the source frame")
|
||||
previous = point_owners.setdefault(point_id, observation.observation_id)
|
||||
if previous != observation.observation_id:
|
||||
raise SemanticFusionError("source point has duplicate observation ownership")
|
||||
|
||||
|
||||
def _identifier(value: str, label: str) -> None:
|
||||
if not isinstance(value, str) or _IDENTIFIER.fullmatch(value) is None:
|
||||
raise SemanticFusionError(f"{label} is invalid")
|
||||
|
||||
|
||||
def _label(value: str, label: str) -> None:
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or not value
|
||||
or value != value.strip()
|
||||
or len(value) > 120
|
||||
or any(ord(character) < 32 for character in value)
|
||||
):
|
||||
raise SemanticFusionError(f"{label} is invalid")
|
||||
|
||||
|
||||
def _point_id(value: int, source_point_count: int) -> None:
|
||||
if (
|
||||
not isinstance(value, int)
|
||||
or isinstance(value, bool)
|
||||
or not 0 <= value < source_point_count
|
||||
):
|
||||
raise SemanticFusionError("source point id is outside the point-label frame")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"NO_SEMANTIC_CLASS_ID",
|
||||
"ObservationSemanticEvidence",
|
||||
"PointSemanticLabels",
|
||||
"SemanticClassDefinition",
|
||||
"SemanticClassDisposition",
|
||||
"SemanticClassEvidence",
|
||||
"SemanticEvidenceAuthority",
|
||||
"SemanticEvidenceStatus",
|
||||
"SemanticFusionError",
|
||||
"SemanticFusionResult",
|
||||
"SemanticMask",
|
||||
"fuse_semantic_diagnostics",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -74,6 +74,7 @@ from k1link.web.e46i_grounding_dino_full_replay_api import (
|
||||
from k1link.web.e46j_raw_fisheye_realtime_api import (
|
||||
build_e46j_raw_fisheye_realtime_router,
|
||||
)
|
||||
from k1link.web.e47_semantic_slam_api import build_e47_semantic_slam_router
|
||||
from k1link.web.environment_api import build_environment_router
|
||||
from k1link.web.l3_pointpillars_visual_api import (
|
||||
build_l3_pointpillars_visual_router,
|
||||
@@ -777,6 +778,17 @@ app.include_router(
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_e47_semantic_slam_router(
|
||||
root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e47"
|
||||
/ "semantic-slam-results"
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_e46e_ready_stack_router(
|
||||
root_provider=lambda: (
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
"""Read-only LAB projection of the immutable E47 semantic/SLAM shadow result."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
|
||||
from k1link.perception.semantic_fusion import SemanticEvidenceStatus
|
||||
from k1link.perception.semantic_slam_replay import (
|
||||
PUBLICATION_STATUS,
|
||||
SEMANTIC_SLAM_MANIFEST_NAME,
|
||||
SEMANTIC_SLAM_MASKS_NAME,
|
||||
SEMANTIC_SLAM_OBSERVATIONS_NAME,
|
||||
SEMANTIC_SLAM_POINTS_NAME,
|
||||
SEMANTIC_SLAM_REPORT_NAME,
|
||||
SEMANTIC_SLAM_RESULT_PREFIX,
|
||||
SEMANTIC_SLAM_TAXONOMY_NAME,
|
||||
SEMANTIC_SLAM_TAXONOMY_SCHEMA,
|
||||
SemanticSlamReplayError,
|
||||
SemanticSlamReplayResult,
|
||||
read_semantic_slam_replay_result,
|
||||
)
|
||||
|
||||
E47_SEMANTIC_SLAM_CATALOG_SCHEMA: Final = "missioncore.e47-semantic-slam-catalog/v1"
|
||||
E47_SEMANTIC_SLAM_VIEW_SCHEMA: Final = "missioncore.e47-semantic-slam-view/v1"
|
||||
E47_SEMANTIC_SLAM_CHUNK_SCHEMA: Final = "missioncore.e47-semantic-slam-chunk/v1"
|
||||
E47_SEMANTIC_SLAM_FRAME_SCHEMA: Final = "missioncore.e47-semantic-slam-frame/v1"
|
||||
E47_SEMANTIC_SLAM_VIEW_STATUS: Final = "diagnostic-semantic-slam-shadow"
|
||||
E47_SEMANTIC_SLAM_MAX_CHUNK_FRAMES: Final = 24
|
||||
|
||||
_RESULT_ID = re.compile(rf"^{SEMANTIC_SLAM_RESULT_PREFIX}[a-f0-9]{{64}}$")
|
||||
_EXPECTED_ARTIFACTS: Final = (
|
||||
SEMANTIC_SLAM_MANIFEST_NAME,
|
||||
SEMANTIC_SLAM_REPORT_NAME,
|
||||
SEMANTIC_SLAM_POINTS_NAME,
|
||||
SEMANTIC_SLAM_OBSERVATIONS_NAME,
|
||||
SEMANTIC_SLAM_MASKS_NAME,
|
||||
SEMANTIC_SLAM_TAXONOMY_NAME,
|
||||
)
|
||||
_MAX_TAXONOMY_BYTES: Final = 1024 * 1024
|
||||
_MAX_MASK_BYTES: Final = 16 * 1024 * 1024
|
||||
|
||||
RootProvider = Callable[[], Path | None]
|
||||
Int64Array = npt.NDArray[np.int64]
|
||||
Int32Array = npt.NDArray[np.int32]
|
||||
UInt8Array = npt.NDArray[np.uint8]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SemanticPointLedger:
|
||||
frame_offsets: Int64Array
|
||||
point_labels: UInt8Array
|
||||
point_status_codes: UInt8Array
|
||||
frame_source_point_counts: Int32Array
|
||||
frame_labeled_point_counts: Int32Array
|
||||
frame_ambiguous_point_counts: Int32Array
|
||||
frame_unprojected_point_counts: Int32Array
|
||||
frame_absent_point_counts: Int32Array
|
||||
|
||||
@property
|
||||
def frame_count(self) -> int:
|
||||
return int(self.frame_offsets.size - 1)
|
||||
|
||||
|
||||
def build_e47_semantic_slam_router(
|
||||
*,
|
||||
root_provider: RootProvider = lambda: None,
|
||||
) -> APIRouter:
|
||||
"""Expose immutable E47 evidence without granting it safety authority."""
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/laboratory/e47-semantic-slam",
|
||||
tags=["laboratory"],
|
||||
)
|
||||
|
||||
def result(result_id: str) -> SemanticSlamReplayResult:
|
||||
if _RESULT_ID.fullmatch(result_id) is None:
|
||||
raise HTTPException(status_code=404, detail="E47 result не найден")
|
||||
root = _configured_root(root_provider)
|
||||
if root is None:
|
||||
raise HTTPException(status_code=404, detail="E47 result не найден")
|
||||
candidate = root / result_id
|
||||
if candidate.is_symlink():
|
||||
raise HTTPException(status_code=404, detail="E47 result не найден")
|
||||
try:
|
||||
path = candidate.resolve(strict=True)
|
||||
except OSError:
|
||||
raise HTTPException(status_code=404, detail="E47 result не найден") from None
|
||||
if path.parent != root or not path.is_dir():
|
||||
raise HTTPException(status_code=404, detail="E47 result не найден")
|
||||
try:
|
||||
return _read_semantic_result_cached(str(path), _result_signature(path))
|
||||
except (SemanticSlamReplayError, OSError, ValueError, zipfile.BadZipFile):
|
||||
raise HTTPException(status_code=404, detail="E47 result не найден") from None
|
||||
|
||||
def point_ledger(result_id: str) -> tuple[SemanticSlamReplayResult, _SemanticPointLedger]:
|
||||
frozen = result(result_id)
|
||||
try:
|
||||
signature = _result_signature(frozen.result_root)
|
||||
return frozen, _read_point_ledger_cached(str(frozen.result_root), signature)
|
||||
except (SemanticSlamReplayError, OSError, ValueError, zipfile.BadZipFile):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="E47 semantic timeline не прошёл проверку",
|
||||
) from None
|
||||
|
||||
@router.get("/results")
|
||||
def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]:
|
||||
candidates = _candidates(root_provider)
|
||||
items: list[dict[str, object]] = []
|
||||
invalid_total = 0
|
||||
for candidate in candidates:
|
||||
if len(items) >= limit:
|
||||
break
|
||||
try:
|
||||
items.append(_project_result(result(candidate.name)))
|
||||
except (HTTPException, OSError, ValueError, json.JSONDecodeError):
|
||||
invalid_total += 1
|
||||
return {
|
||||
"schema_version": E47_SEMANTIC_SLAM_CATALOG_SCHEMA,
|
||||
"configured": _configured_root(root_provider) is not None,
|
||||
"items": items,
|
||||
"candidate_total": len(candidates),
|
||||
"invalid_total": invalid_total,
|
||||
"access": "read-only-diagnostic-shadow",
|
||||
}
|
||||
|
||||
@router.get("/results/{result_id}/timeline/chunk")
|
||||
def get_timeline_chunk(
|
||||
result_id: str,
|
||||
start: int = Query(default=0, ge=0),
|
||||
count: int = Query(
|
||||
default=12,
|
||||
ge=1,
|
||||
le=E47_SEMANTIC_SLAM_MAX_CHUNK_FRAMES,
|
||||
),
|
||||
) -> dict[str, object]:
|
||||
frozen, ledger = point_ledger(result_id)
|
||||
if (
|
||||
not isinstance(start, int)
|
||||
or isinstance(start, bool)
|
||||
or not isinstance(count, int)
|
||||
or isinstance(count, bool)
|
||||
or start < 0
|
||||
or not 1 <= count <= E47_SEMANTIC_SLAM_MAX_CHUNK_FRAMES
|
||||
):
|
||||
raise HTTPException(status_code=422, detail="Некорректный E47 timeline chunk")
|
||||
if start >= ledger.frame_count:
|
||||
raise HTTPException(status_code=404, detail="E47 timeline chunk не найден")
|
||||
stop = min(start + count, ledger.frame_count)
|
||||
try:
|
||||
frames = [_project_frame(ledger, sequence) for sequence in range(start, stop)]
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="E47 semantic timeline не прошёл проверку",
|
||||
) from None
|
||||
return {
|
||||
"schema_version": E47_SEMANTIC_SLAM_CHUNK_SCHEMA,
|
||||
"result_id": frozen.result_id,
|
||||
"start_sequence": start,
|
||||
"frame_count": len(frames),
|
||||
"next_sequence": stop if stop < ledger.frame_count else None,
|
||||
"frames": frames,
|
||||
"access": "read-only-diagnostic-shadow",
|
||||
}
|
||||
|
||||
@router.get("/results/{result_id}/masks/{sequence}")
|
||||
def get_mask(result_id: str, sequence: int) -> Response:
|
||||
frozen = result(result_id)
|
||||
frame_total = _frame_total(frozen)
|
||||
if (
|
||||
not isinstance(sequence, int)
|
||||
or isinstance(sequence, bool)
|
||||
or not 0 <= sequence < frame_total
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="E47 semantic mask не найдена")
|
||||
try:
|
||||
signature = _result_signature(frozen.result_root)
|
||||
frozen = _read_semantic_result_cached(str(frozen.result_root), signature)
|
||||
payload = _read_mask(frozen, sequence)
|
||||
if _result_signature(frozen.result_root) != signature:
|
||||
raise ValueError("E47 result changed during mask read")
|
||||
except (
|
||||
SemanticSlamReplayError,
|
||||
OSError,
|
||||
KeyError,
|
||||
ValueError,
|
||||
RuntimeError,
|
||||
zipfile.BadZipFile,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="E47 semantic mask не прошла проверку",
|
||||
) from None
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
return Response(
|
||||
content=payload,
|
||||
media_type="image/png",
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"ETag": f'"{digest}"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _read_semantic_result_cached(
|
||||
root_value: str,
|
||||
signature: tuple[int, ...],
|
||||
) -> SemanticSlamReplayResult:
|
||||
del signature
|
||||
return read_semantic_slam_replay_result(Path(root_value))
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _read_point_ledger_cached(
|
||||
root_value: str,
|
||||
signature: tuple[int, ...],
|
||||
) -> _SemanticPointLedger:
|
||||
frozen = _read_semantic_result_cached(root_value, signature)
|
||||
path = frozen.result_root / SEMANTIC_SLAM_POINTS_NAME
|
||||
required = {
|
||||
"frame_offsets",
|
||||
"point_labels",
|
||||
"point_status_codes",
|
||||
"frame_source_point_counts",
|
||||
"frame_labeled_point_counts",
|
||||
"frame_ambiguous_point_counts",
|
||||
"frame_unprojected_point_counts",
|
||||
"frame_absent_point_counts",
|
||||
}
|
||||
with np.load(path, allow_pickle=False) as archive:
|
||||
if not required.issubset(archive.files):
|
||||
raise ValueError("E47 semantic point arrays are incomplete")
|
||||
ledger = _SemanticPointLedger(
|
||||
frame_offsets=_frozen_int64(archive["frame_offsets"]),
|
||||
point_labels=_frozen_uint8(archive["point_labels"]),
|
||||
point_status_codes=_frozen_uint8(archive["point_status_codes"]),
|
||||
frame_source_point_counts=_frozen_int32(archive["frame_source_point_counts"]),
|
||||
frame_labeled_point_counts=_frozen_int32(archive["frame_labeled_point_counts"]),
|
||||
frame_ambiguous_point_counts=_frozen_int32(archive["frame_ambiguous_point_counts"]),
|
||||
frame_unprojected_point_counts=_frozen_int32(archive["frame_unprojected_point_counts"]),
|
||||
frame_absent_point_counts=_frozen_int32(archive["frame_absent_point_counts"]),
|
||||
)
|
||||
_validate_point_ledger(ledger, _frame_total(frozen))
|
||||
_validate_class_status_bindings(ledger, _read_taxonomy(frozen))
|
||||
return ledger
|
||||
|
||||
|
||||
def _project_result(result: SemanticSlamReplayResult) -> dict[str, object]:
|
||||
if result.status != PUBLICATION_STATUS:
|
||||
raise ValueError("E47 publication status changed")
|
||||
identity = _object(result.manifest.get("identity"), "E47 identity")
|
||||
provider = _object(identity.get("semantic_provider"), "E47 provider")
|
||||
temporal_binding = _object(
|
||||
identity.get("temporal_binding"),
|
||||
"E47 temporal binding",
|
||||
)
|
||||
authority = _object(identity.get("authority"), "E47 authority")
|
||||
if (
|
||||
authority.get("ground_truth") is not False
|
||||
or authority.get("semantic_authority") != "diagnostic-only"
|
||||
or authority.get("navigation_or_safety_accepted") is not False
|
||||
or authority.get("actuation_allowed") is not False
|
||||
):
|
||||
raise ValueError("E47 authority changed")
|
||||
taxonomy = _read_taxonomy(result)
|
||||
return {
|
||||
"schema_version": E47_SEMANTIC_SLAM_VIEW_SCHEMA,
|
||||
"result_id": result.result_id,
|
||||
"created_at_utc": result.manifest["created_at_utc"],
|
||||
"status": E47_SEMANTIC_SLAM_VIEW_STATUS,
|
||||
"profile_id": identity["profile_id"],
|
||||
"base_m4_result_id": identity["base_m4_result_id"],
|
||||
"semantic_result_id": identity["semantic_result_id"],
|
||||
"geometry_result_id": identity["geometry_result_id"],
|
||||
"source_pack_id": identity["source_pack_id"],
|
||||
"calibration_content_sha256": identity["calibration_content_sha256"],
|
||||
"provider": {
|
||||
"provider_id": provider["provider_id"],
|
||||
"model_id": provider["model_id"],
|
||||
"model_revision": provider["model_revision"],
|
||||
"model_weights_sha256": provider["model_weights_sha256"],
|
||||
"preprocess_id": provider["preprocess_id"],
|
||||
},
|
||||
"temporal_binding": copy.deepcopy(temporal_binding),
|
||||
"taxonomy": taxonomy,
|
||||
"metrics": copy.deepcopy(result.metrics),
|
||||
"acceptance": {
|
||||
"artifact_contract_passed": True,
|
||||
"frame_accounting_passed": True,
|
||||
"point_accounting_passed": True,
|
||||
"observation_binding_passed": True,
|
||||
"temporal_binding_passed": True,
|
||||
"independent_semantic_truth_passed": False,
|
||||
"provider_promoted": False,
|
||||
},
|
||||
"limitations": copy.deepcopy(result.report["limitations"]),
|
||||
"ground_truth": False,
|
||||
"semantic_authority": "diagnostic-only",
|
||||
"navigation_or_safety_accepted": False,
|
||||
"actuation_allowed": False,
|
||||
"access": "read-only-diagnostic-shadow",
|
||||
}
|
||||
|
||||
|
||||
def _project_frame(ledger: _SemanticPointLedger, sequence: int) -> dict[str, object]:
|
||||
offset = int(ledger.frame_offsets[sequence])
|
||||
stop = int(ledger.frame_offsets[sequence + 1])
|
||||
labels = ledger.point_labels[offset:stop].astype(np.int16)
|
||||
statuses = ledger.point_status_codes[offset:stop]
|
||||
unavailable = np.isin(
|
||||
statuses,
|
||||
(
|
||||
int(SemanticEvidenceStatus.ABSENT),
|
||||
int(SemanticEvidenceStatus.UNPROJECTED),
|
||||
),
|
||||
)
|
||||
labels[unavailable] = -1
|
||||
counts = {
|
||||
"labeled": int(ledger.frame_labeled_point_counts[sequence]),
|
||||
"ambiguous": int(ledger.frame_ambiguous_point_counts[sequence]),
|
||||
"unprojected": int(ledger.frame_unprojected_point_counts[sequence]),
|
||||
"absent": int(ledger.frame_absent_point_counts[sequence]),
|
||||
}
|
||||
actual_counts = {
|
||||
"labeled": int(np.count_nonzero(statuses == int(SemanticEvidenceStatus.LABELED))),
|
||||
"ambiguous": int(np.count_nonzero(statuses == int(SemanticEvidenceStatus.AMBIGUOUS))),
|
||||
"unprojected": int(np.count_nonzero(statuses == int(SemanticEvidenceStatus.UNPROJECTED))),
|
||||
"absent": int(np.count_nonzero(statuses == int(SemanticEvidenceStatus.ABSENT))),
|
||||
}
|
||||
if counts != actual_counts or sum(counts.values()) != stop - offset:
|
||||
raise ValueError("E47 frame point accounting changed")
|
||||
return {
|
||||
"schema_version": E47_SEMANTIC_SLAM_FRAME_SCHEMA,
|
||||
"sequence": sequence,
|
||||
"source_point_count": int(ledger.frame_source_point_counts[sequence]),
|
||||
"class_ids": labels.tolist(),
|
||||
"status_codes": statuses.tolist(),
|
||||
"counts": counts,
|
||||
}
|
||||
|
||||
|
||||
def _read_taxonomy(result: SemanticSlamReplayResult) -> list[dict[str, object]]:
|
||||
path = result.result_root / SEMANTIC_SLAM_TAXONOMY_NAME
|
||||
if not path.is_file() or path.is_symlink() or path.stat().st_size > _MAX_TAXONOMY_BYTES:
|
||||
raise ValueError("E47 taxonomy is invalid")
|
||||
payload = path.read_bytes()
|
||||
identity = _object(result.manifest.get("identity"), "E47 identity")
|
||||
if hashlib.sha256(payload).hexdigest() != identity.get("taxonomy_sha256"):
|
||||
raise ValueError("E47 taxonomy identity changed")
|
||||
document = json.loads(payload)
|
||||
if not isinstance(document, dict) or set(document) != {"schema_version", "classes"}:
|
||||
raise ValueError("E47 taxonomy contract changed")
|
||||
if document.get("schema_version") != SEMANTIC_SLAM_TAXONOMY_SCHEMA:
|
||||
raise ValueError("E47 taxonomy schema changed")
|
||||
classes = document.get("classes")
|
||||
if not isinstance(classes, list) or not classes:
|
||||
raise ValueError("E47 taxonomy classes are invalid")
|
||||
for item in classes:
|
||||
if not isinstance(item, dict) or set(item) != {
|
||||
"class_id",
|
||||
"label",
|
||||
"disposition",
|
||||
"color_rgb",
|
||||
}:
|
||||
raise ValueError("E47 taxonomy class changed")
|
||||
return copy.deepcopy(classes)
|
||||
|
||||
|
||||
def _read_mask(result: SemanticSlamReplayResult, sequence: int) -> bytes:
|
||||
archive_path = result.result_root / SEMANTIC_SLAM_MASKS_NAME
|
||||
if not archive_path.is_file() or archive_path.is_symlink():
|
||||
raise ValueError("E47 semantic mask archive is invalid")
|
||||
member_name = f"semantic-masks/frame-{sequence + 1:06d}.png"
|
||||
with zipfile.ZipFile(archive_path, mode="r") as archive:
|
||||
info = archive.getinfo(member_name)
|
||||
if info.is_dir() or not 0 < info.file_size <= _MAX_MASK_BYTES:
|
||||
raise ValueError("E47 semantic mask member is invalid")
|
||||
payload = archive.read(info)
|
||||
if len(payload) != info.file_size or not payload.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
raise ValueError("E47 semantic mask payload is invalid")
|
||||
return payload
|
||||
|
||||
|
||||
def _validate_point_ledger(ledger: _SemanticPointLedger, frame_total: int) -> None:
|
||||
arrays = (
|
||||
ledger.frame_source_point_counts,
|
||||
ledger.frame_labeled_point_counts,
|
||||
ledger.frame_ambiguous_point_counts,
|
||||
ledger.frame_unprojected_point_counts,
|
||||
ledger.frame_absent_point_counts,
|
||||
)
|
||||
if (
|
||||
ledger.frame_offsets.ndim != 1
|
||||
or ledger.frame_offsets.shape != (frame_total + 1,)
|
||||
or int(ledger.frame_offsets[0]) != 0
|
||||
or np.any(np.diff(ledger.frame_offsets) < 0)
|
||||
or ledger.point_labels.ndim != 1
|
||||
or ledger.point_status_codes.shape != ledger.point_labels.shape
|
||||
or int(ledger.frame_offsets[-1]) != ledger.point_labels.size
|
||||
or any(value.ndim != 1 or value.shape != (frame_total,) for value in arrays)
|
||||
or np.any(np.asarray(arrays) < 0)
|
||||
or not np.array_equal(
|
||||
np.diff(ledger.frame_offsets),
|
||||
ledger.frame_source_point_counts,
|
||||
)
|
||||
):
|
||||
raise ValueError("E47 semantic point ledger changed")
|
||||
valid_statuses = {int(status) for status in SemanticEvidenceStatus}
|
||||
if set(int(value) for value in np.unique(ledger.point_status_codes)) - valid_statuses:
|
||||
raise ValueError("E47 semantic status changed")
|
||||
expected_total = (
|
||||
ledger.frame_labeled_point_counts
|
||||
+ ledger.frame_ambiguous_point_counts
|
||||
+ ledger.frame_unprojected_point_counts
|
||||
+ ledger.frame_absent_point_counts
|
||||
)
|
||||
if not np.array_equal(expected_total, ledger.frame_source_point_counts):
|
||||
raise ValueError("E47 semantic frame accounting changed")
|
||||
|
||||
|
||||
def _validate_class_status_bindings(
|
||||
ledger: _SemanticPointLedger,
|
||||
taxonomy: list[dict[str, object]],
|
||||
) -> None:
|
||||
dispositions: dict[int, str] = {}
|
||||
for item in taxonomy:
|
||||
class_id = item.get("class_id")
|
||||
disposition = item.get("disposition")
|
||||
if (
|
||||
not isinstance(class_id, int)
|
||||
or isinstance(class_id, bool)
|
||||
or not 0 <= class_id <= 255
|
||||
or disposition not in {"labeled", "ambiguous"}
|
||||
or class_id in dispositions
|
||||
):
|
||||
raise ValueError("E47 semantic taxonomy binding changed")
|
||||
dispositions[class_id] = str(disposition)
|
||||
unavailable = np.isin(
|
||||
ledger.point_status_codes,
|
||||
(
|
||||
int(SemanticEvidenceStatus.ABSENT),
|
||||
int(SemanticEvidenceStatus.UNPROJECTED),
|
||||
),
|
||||
)
|
||||
if np.any(ledger.point_labels[unavailable] != 0):
|
||||
raise ValueError("E47 unavailable semantic point carried a class")
|
||||
for status, disposition in (
|
||||
(SemanticEvidenceStatus.AMBIGUOUS, "ambiguous"),
|
||||
(SemanticEvidenceStatus.LABELED, "labeled"),
|
||||
):
|
||||
class_ids = np.unique(ledger.point_labels[ledger.point_status_codes == int(status)])
|
||||
if any(dispositions.get(int(class_id)) != disposition for class_id in class_ids):
|
||||
raise ValueError("E47 semantic point status disagrees with taxonomy")
|
||||
|
||||
|
||||
def _frame_total(result: SemanticSlamReplayResult) -> int:
|
||||
frames = _object(result.metrics.get("frames"), "E47 frame metrics")
|
||||
value = frames.get("total")
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
||||
raise ValueError("E47 frame count changed")
|
||||
return value
|
||||
|
||||
|
||||
def _frozen_int64(value: npt.ArrayLike) -> Int64Array:
|
||||
array = np.array(value, dtype=np.int64, order="C", copy=True)
|
||||
array.setflags(write=False)
|
||||
return array
|
||||
|
||||
|
||||
def _frozen_int32(value: npt.ArrayLike) -> Int32Array:
|
||||
array = np.array(value, dtype=np.int32, order="C", copy=True)
|
||||
array.setflags(write=False)
|
||||
return array
|
||||
|
||||
|
||||
def _frozen_uint8(value: npt.ArrayLike) -> UInt8Array:
|
||||
array = np.array(value, dtype=np.uint8, order="C", copy=True)
|
||||
array.setflags(write=False)
|
||||
return array
|
||||
|
||||
|
||||
def _configured_root(provider: RootProvider) -> Path | None:
|
||||
value = provider()
|
||||
if value is None:
|
||||
return None
|
||||
candidate = value.expanduser().absolute()
|
||||
if candidate.is_symlink():
|
||||
return None
|
||||
try:
|
||||
root = candidate.resolve(strict=True)
|
||||
except OSError:
|
||||
return None
|
||||
return root if root.is_dir() else None
|
||||
|
||||
|
||||
def _result_signature(root: Path) -> tuple[int, ...]:
|
||||
signature: list[int] = []
|
||||
for name in _EXPECTED_ARTIFACTS:
|
||||
path = root / name
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise ValueError("E47 result artifact is invalid")
|
||||
stat = path.stat()
|
||||
signature.extend((stat.st_ino, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns))
|
||||
return tuple(signature)
|
||||
|
||||
|
||||
def _candidates(provider: RootProvider) -> list[Path]:
|
||||
root = _configured_root(provider)
|
||||
if root is None:
|
||||
return []
|
||||
try:
|
||||
return sorted(
|
||||
(
|
||||
item
|
||||
for item in root.iterdir()
|
||||
if item.is_dir() and not item.is_symlink() and _RESULT_ID.fullmatch(item.name)
|
||||
),
|
||||
key=lambda item: item.stat().st_mtime_ns,
|
||||
reverse=True,
|
||||
)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, object]:
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{label} is invalid")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"E47_SEMANTIC_SLAM_CATALOG_SCHEMA",
|
||||
"E47_SEMANTIC_SLAM_CHUNK_SCHEMA",
|
||||
"E47_SEMANTIC_SLAM_FRAME_SCHEMA",
|
||||
"E47_SEMANTIC_SLAM_MAX_CHUNK_FRAMES",
|
||||
"E47_SEMANTIC_SLAM_VIEW_SCHEMA",
|
||||
"build_e47_semantic_slam_router",
|
||||
]
|
||||
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from k1link.perception.semantic_slam_replay import (
|
||||
PUBLICATION_STATUS,
|
||||
SemanticSlamReplayResult,
|
||||
)
|
||||
from k1link.web import e47_semantic_slam_api as api
|
||||
|
||||
RESULT_ID = f"e47-semantic-slam-{'a' * 64}"
|
||||
M4_RESULT_ID = f"m4-threat-replay-{'b' * 64}"
|
||||
PNG_0 = b"\x89PNG\r\n\x1a\nsealed-mask-zero"
|
||||
PNG_1 = b"\x89PNG\r\n\x1a\nsealed-mask-one"
|
||||
|
||||
|
||||
def _endpoint(path: str, root: Path):
|
||||
router = api.build_e47_semantic_slam_router(root_provider=lambda: root)
|
||||
return next(
|
||||
route.endpoint
|
||||
for route in router.routes
|
||||
if isinstance(route, APIRoute) and route.path == path
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_api_caches() -> None:
|
||||
api._read_semantic_result_cached.cache_clear()
|
||||
api._read_point_ledger_cached.cache_clear()
|
||||
yield
|
||||
api._read_semantic_result_cached.cache_clear()
|
||||
api._read_point_ledger_cached.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def publication(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]:
|
||||
root = tmp_path / "semantic-slam-results"
|
||||
result_root = root / RESULT_ID
|
||||
result_root.mkdir(parents=True)
|
||||
|
||||
taxonomy = {
|
||||
"schema_version": "missioncore.e47-semantic-taxonomy/v1",
|
||||
"classes": [
|
||||
{
|
||||
"class_id": 0,
|
||||
"label": "ambiguous",
|
||||
"disposition": "ambiguous",
|
||||
"color_rgb": [0, 0, 0],
|
||||
},
|
||||
{
|
||||
"class_id": 1,
|
||||
"label": "road",
|
||||
"disposition": "labeled",
|
||||
"color_rgb": [128, 64, 128],
|
||||
},
|
||||
],
|
||||
}
|
||||
taxonomy_payload = json.dumps(taxonomy, separators=(",", ":")).encode()
|
||||
(result_root / "taxonomy.json").write_bytes(taxonomy_payload)
|
||||
np.savez(
|
||||
result_root / "semantic-points.npz",
|
||||
frame_offsets=np.asarray([0, 4, 7], dtype=np.int64),
|
||||
point_labels=np.asarray([1, 0, 0, 0, 0, 1, 1], dtype=np.uint8),
|
||||
point_status_codes=np.asarray([3, 2, 1, 0, 2, 3, 3], dtype=np.uint8),
|
||||
frame_source_point_counts=np.asarray([4, 3], dtype=np.int32),
|
||||
frame_labeled_point_counts=np.asarray([1, 2], dtype=np.int32),
|
||||
frame_ambiguous_point_counts=np.asarray([1, 1], dtype=np.int32),
|
||||
frame_unprojected_point_counts=np.asarray([1, 0], dtype=np.int32),
|
||||
frame_absent_point_counts=np.asarray([1, 0], dtype=np.int32),
|
||||
)
|
||||
with zipfile.ZipFile(result_root / "semantic-masks.zip", mode="w") as archive:
|
||||
archive.writestr("semantic-masks/frame-000001.png", PNG_0)
|
||||
archive.writestr("semantic-masks/frame-000002.png", PNG_1)
|
||||
for name, payload in (
|
||||
("manifest.json", b"fixture-manifest"),
|
||||
("report.json", b"fixture-report"),
|
||||
("semantic-observations.jsonl", b"fixture-observations\n"),
|
||||
):
|
||||
(result_root / name).write_bytes(payload)
|
||||
|
||||
metrics = {
|
||||
"frames": {"total": 2, "mask_available": 2, "source_available": 2},
|
||||
"points": {
|
||||
"total": 7,
|
||||
"projected": 5,
|
||||
"labeled": 3,
|
||||
"ambiguous": 2,
|
||||
"unprojected": 1,
|
||||
"absent": 1,
|
||||
},
|
||||
"observations": {
|
||||
"total": 3,
|
||||
"labeled": 1,
|
||||
"ambiguous": 1,
|
||||
"unprojected": 1,
|
||||
"absent": 0,
|
||||
},
|
||||
"runtime": {"elapsed_ms": 10.0, "frames_per_second": 200.0},
|
||||
}
|
||||
identity = {
|
||||
"profile_id": "ravnoves00-eomt-kb4-slam-shadow/v1",
|
||||
"base_m4_result_id": M4_RESULT_ID,
|
||||
"semantic_result_id": f"result-{'c' * 64}",
|
||||
"geometry_result_id": f"m4-geometry-replay-{'d' * 64}",
|
||||
"source_pack_id": "ravnoves00-source-pack/v1",
|
||||
"calibration_content_sha256": "e" * 64,
|
||||
"taxonomy_sha256": hashlib.sha256(taxonomy_payload).hexdigest(),
|
||||
"semantic_provider": {
|
||||
"provider_id": "eomt-cityscapes-semantic-control/v1",
|
||||
"model_id": "tue-mps/eomt",
|
||||
"model_revision": "f" * 40,
|
||||
"model_weights_sha256": "1" * 64,
|
||||
"preprocess_id": "raw-kb4-valid-fov-semantic/v1",
|
||||
"role": "fixed-control-not-selected-production-provider",
|
||||
},
|
||||
"temporal_binding": {
|
||||
"semantic_to_camera": "exact-sequence-and-session-time",
|
||||
"camera_to_lidar": "accepted-e6-nearest-host-arrival-best-effort",
|
||||
"clock_basis": "recorded-host-monotonic-arrival",
|
||||
"maximum_lidar_camera_delta_ms": 100.0,
|
||||
"maximum_pose_point_delta_ms": 100.0,
|
||||
"physical_synchronization_proven": False,
|
||||
},
|
||||
"authority": {
|
||||
"ground_truth": False,
|
||||
"semantic_authority": "diagnostic-only",
|
||||
"navigation_or_safety_accepted": False,
|
||||
"actuation_allowed": False,
|
||||
},
|
||||
}
|
||||
frozen = SemanticSlamReplayResult(
|
||||
result_id=RESULT_ID,
|
||||
result_root=result_root,
|
||||
status=PUBLICATION_STATUS,
|
||||
metrics=metrics,
|
||||
report={
|
||||
"status": PUBLICATION_STATUS,
|
||||
"metrics": metrics,
|
||||
"limitations": ["No independent semantic truth."],
|
||||
},
|
||||
manifest={
|
||||
"result_id": RESULT_ID,
|
||||
"created_at_utc": "2026-08-06T06:30:00.000Z",
|
||||
"identity": identity,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(api, "_read_semantic_result_cached", lambda *_: frozen)
|
||||
return root, result_root
|
||||
|
||||
|
||||
def test_catalog_projects_exact_diagnostic_only_view(
|
||||
publication: tuple[Path, Path],
|
||||
) -> None:
|
||||
root, _ = publication
|
||||
list_results = _endpoint("/api/v1/laboratory/e47-semantic-slam/results", root)
|
||||
|
||||
catalog = list_results(limit=1)
|
||||
|
||||
assert catalog["schema_version"] == "missioncore.e47-semantic-slam-catalog/v1"
|
||||
assert catalog["candidate_total"] == 1
|
||||
assert catalog["invalid_total"] == 0
|
||||
item = catalog["items"][0]
|
||||
assert item["schema_version"] == "missioncore.e47-semantic-slam-view/v1"
|
||||
assert item["result_id"] == RESULT_ID
|
||||
assert item["status"] == "diagnostic-semantic-slam-shadow"
|
||||
assert item["base_m4_result_id"] == M4_RESULT_ID
|
||||
assert item["provider"]["provider_id"] == "eomt-cityscapes-semantic-control/v1"
|
||||
assert item["temporal_binding"]["semantic_to_camera"] == (
|
||||
"exact-sequence-and-session-time"
|
||||
)
|
||||
assert item["temporal_binding"]["physical_synchronization_proven"] is False
|
||||
assert [entry["label"] for entry in item["taxonomy"]] == ["ambiguous", "road"]
|
||||
assert item["acceptance"] == {
|
||||
"artifact_contract_passed": True,
|
||||
"frame_accounting_passed": True,
|
||||
"point_accounting_passed": True,
|
||||
"observation_binding_passed": True,
|
||||
"temporal_binding_passed": True,
|
||||
"independent_semantic_truth_passed": False,
|
||||
"provider_promoted": False,
|
||||
}
|
||||
assert item["semantic_authority"] == "diagnostic-only"
|
||||
assert item["navigation_or_safety_accepted"] is False
|
||||
assert item["actuation_allowed"] is False
|
||||
|
||||
|
||||
def test_timeline_chunk_preserves_point_index_space_and_unavailable_sentinel(
|
||||
publication: tuple[Path, Path],
|
||||
) -> None:
|
||||
root, _ = publication
|
||||
get_chunk = _endpoint(
|
||||
"/api/v1/laboratory/e47-semantic-slam/results/{result_id}/timeline/chunk",
|
||||
root,
|
||||
)
|
||||
|
||||
chunk = get_chunk(RESULT_ID, start=0, count=2)
|
||||
|
||||
assert chunk["schema_version"] == "missioncore.e47-semantic-slam-chunk/v1"
|
||||
assert chunk["frame_count"] == 2
|
||||
assert chunk["next_sequence"] is None
|
||||
first = chunk["frames"][0]
|
||||
assert first == {
|
||||
"schema_version": "missioncore.e47-semantic-slam-frame/v1",
|
||||
"sequence": 0,
|
||||
"source_point_count": 4,
|
||||
"class_ids": [1, 0, -1, -1],
|
||||
"status_codes": [3, 2, 1, 0],
|
||||
"counts": {"labeled": 1, "ambiguous": 1, "unprojected": 1, "absent": 1},
|
||||
}
|
||||
assert chunk["frames"][1]["class_ids"] == [0, 1, 1]
|
||||
|
||||
|
||||
def test_timeline_chunk_rejects_out_of_range_and_oversized_requests(
|
||||
publication: tuple[Path, Path],
|
||||
) -> None:
|
||||
root, _ = publication
|
||||
get_chunk = _endpoint(
|
||||
"/api/v1/laboratory/e47-semantic-slam/results/{result_id}/timeline/chunk",
|
||||
root,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as out_of_range:
|
||||
get_chunk(RESULT_ID, start=2, count=1)
|
||||
assert out_of_range.value.status_code == 404
|
||||
with pytest.raises(HTTPException) as oversized:
|
||||
get_chunk(RESULT_ID, start=0, count=25)
|
||||
assert oversized.value.status_code == 422
|
||||
|
||||
|
||||
def test_mask_endpoint_streams_exact_png_with_immutable_identity(
|
||||
publication: tuple[Path, Path],
|
||||
) -> None:
|
||||
root, _ = publication
|
||||
get_mask = _endpoint(
|
||||
"/api/v1/laboratory/e47-semantic-slam/results/{result_id}/masks/{sequence}",
|
||||
root,
|
||||
)
|
||||
|
||||
response = get_mask(RESULT_ID, 1)
|
||||
|
||||
assert response.body == PNG_1
|
||||
assert response.media_type == "image/png"
|
||||
assert response.headers["cache-control"] == "private, max-age=31536000, immutable"
|
||||
assert response.headers["etag"] == f'"{hashlib.sha256(PNG_1).hexdigest()}"'
|
||||
assert response.headers["x-content-type-options"] == "nosniff"
|
||||
|
||||
|
||||
def test_result_resolution_rejects_invalid_id_and_symlink(tmp_path: Path) -> None:
|
||||
root = tmp_path / "results"
|
||||
root.mkdir()
|
||||
outside = tmp_path / RESULT_ID
|
||||
outside.mkdir()
|
||||
(root / RESULT_ID).symlink_to(outside, target_is_directory=True)
|
||||
get_chunk = _endpoint(
|
||||
"/api/v1/laboratory/e47-semantic-slam/results/{result_id}/timeline/chunk",
|
||||
root,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as invalid:
|
||||
get_chunk("../escape", start=0, count=1)
|
||||
assert invalid.value.status_code == 404
|
||||
with pytest.raises(HTTPException) as linked:
|
||||
get_chunk(RESULT_ID, start=0, count=1)
|
||||
assert linked.value.status_code == 404
|
||||
@@ -127,10 +127,11 @@ def test_product_registry_declares_every_advanced_evidence_source() -> None:
|
||||
repository_root / "config" / "laboratories"
|
||||
)
|
||||
|
||||
assert len(registry.definitions) == 32
|
||||
assert len(registry.definitions) == 33
|
||||
assert {item.work_id for item in registry.definitions} >= {
|
||||
"e31-source-binding",
|
||||
"e46j-raw-fisheye-realtime",
|
||||
"e47-semantic-slam-shadow",
|
||||
"l3-pointpillars-visual-audit",
|
||||
"l31-pointpillars-ravnoves",
|
||||
"l32-pointpillars-camera-review",
|
||||
|
||||
@@ -94,8 +94,16 @@ def test_repository_registry_classifies_every_evidence_definition() -> None:
|
||||
"e33-worker-shadow",
|
||||
"e35-degradation-recovery",
|
||||
"e46j-raw-fisheye-realtime",
|
||||
"e47-semantic-slam-shadow",
|
||||
}
|
||||
assert all(row.lifecycle == "canonical" for row in execution.definitions)
|
||||
by_work_id = {row.work_id: row for row in execution.definitions}
|
||||
assert by_work_id["e47-semantic-slam-shadow"].lifecycle == "experimental"
|
||||
assert by_work_id["e47-semantic-slam-shadow"].isolation == "bounded-adapter"
|
||||
assert all(
|
||||
row.lifecycle == "canonical"
|
||||
for row in execution.definitions
|
||||
if row.work_id != "e47-semantic-slam-shadow"
|
||||
)
|
||||
assert len(execution.definitions) + len(execution.legacy_work_ids) == len(
|
||||
evidence.definitions
|
||||
)
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from k1link.perception.contracts import (
|
||||
EvidenceBasis,
|
||||
EvidenceCurrentness,
|
||||
MetricGeometry,
|
||||
ObstacleObservation,
|
||||
)
|
||||
from k1link.perception.geometry_math import ProjectedPointCloud
|
||||
from k1link.perception.semantic_fusion import (
|
||||
NO_SEMANTIC_CLASS_ID,
|
||||
SemanticClassDefinition,
|
||||
SemanticClassDisposition,
|
||||
SemanticEvidenceAuthority,
|
||||
SemanticEvidenceStatus,
|
||||
SemanticFusionError,
|
||||
SemanticMask,
|
||||
fuse_semantic_diagnostics,
|
||||
)
|
||||
|
||||
|
||||
def _classes() -> tuple[SemanticClassDefinition, ...]:
|
||||
return (
|
||||
SemanticClassDefinition(1, "road"),
|
||||
SemanticClassDefinition(2, "car"),
|
||||
SemanticClassDefinition(
|
||||
255,
|
||||
"void / uncertain",
|
||||
SemanticClassDisposition.AMBIGUOUS,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _mask(*, source_id: str = "RAVNOVES00", frame_id: str = "frame-000014") -> SemanticMask:
|
||||
return SemanticMask(
|
||||
source_id=source_id,
|
||||
frame_id=frame_id,
|
||||
provider_id="semantic-provider/v1",
|
||||
model_id="semantic-model/v1",
|
||||
preprocess_id="raw-kb4-semantic/v1",
|
||||
labels=np.asarray(
|
||||
[
|
||||
[1, 2, 255, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
],
|
||||
dtype=np.uint8,
|
||||
),
|
||||
classes=_classes(),
|
||||
)
|
||||
|
||||
|
||||
def _projection() -> ProjectedPointCloud:
|
||||
return ProjectedPointCloud(
|
||||
pixels_xy=np.asarray(
|
||||
[
|
||||
[0.1, 0.1],
|
||||
[1.2, 0.2],
|
||||
[1.8, 0.8],
|
||||
[2.1, 0.2],
|
||||
[9.0, 9.0],
|
||||
],
|
||||
dtype=np.float64,
|
||||
),
|
||||
depths_m=np.asarray([2.0, 2.1, 2.2, 2.3, 2.4], dtype=np.float64),
|
||||
source_indices=np.asarray([0, 1, 2, 3, 4], dtype=np.int64),
|
||||
source_point_count=5,
|
||||
camera_front_point_count=5,
|
||||
)
|
||||
|
||||
|
||||
def _geometry_observation(
|
||||
*point_ids: int,
|
||||
observation_id: str = "geometry-observation-1",
|
||||
) -> ObstacleObservation:
|
||||
return ObstacleObservation(
|
||||
observation_id=observation_id,
|
||||
occupancy_key=f"occupancy-{observation_id}",
|
||||
source_id="RAVNOVES00",
|
||||
frame_id="frame-000014",
|
||||
evidence_time_ns=14_000_000_000,
|
||||
basis=EvidenceBasis.LIDAR,
|
||||
currentness=EvidenceCurrentness.CURRENT,
|
||||
occupied_support=True,
|
||||
source_point_ids=point_ids,
|
||||
metric_geometry=MetricGeometry(
|
||||
coordinate_frame="map",
|
||||
centroid_xyz_m=(2.0, 0.0, 0.5),
|
||||
range_m=2.0,
|
||||
covariance_diagonal_m2=(0.1, 0.1, 0.1),
|
||||
),
|
||||
proposal_ids=(),
|
||||
semantic_hint=None,
|
||||
reason_codes=("qualified-lidar-points",),
|
||||
)
|
||||
|
||||
|
||||
def _camera_only_observation() -> ObstacleObservation:
|
||||
return ObstacleObservation(
|
||||
observation_id="camera-observation-1",
|
||||
occupancy_key="occupancy-camera-observation-1",
|
||||
source_id="RAVNOVES00",
|
||||
frame_id="frame-000014",
|
||||
evidence_time_ns=14_000_000_000,
|
||||
basis=EvidenceBasis.CAMERA,
|
||||
currentness=EvidenceCurrentness.CURRENT,
|
||||
occupied_support=False,
|
||||
source_point_ids=(),
|
||||
metric_geometry=None,
|
||||
proposal_ids=("proposal-1",),
|
||||
semantic_hint=None,
|
||||
reason_codes=("camera-only",),
|
||||
)
|
||||
|
||||
|
||||
def test_semantic_mask_is_strict_source_bound_uint8_and_immutable() -> None:
|
||||
labels = np.asarray([[1, 2]], dtype=np.uint8)
|
||||
semantic = SemanticMask(
|
||||
source_id="RAVNOVES00",
|
||||
frame_id="frame-000014",
|
||||
provider_id="semantic-provider/v1",
|
||||
model_id="semantic-model/v1",
|
||||
preprocess_id="raw-kb4-semantic/v1",
|
||||
labels=labels,
|
||||
classes=_classes(),
|
||||
)
|
||||
labels[0, 0] = 2
|
||||
assert semantic.labels.tolist() == [[1, 2]]
|
||||
assert semantic.labels.flags.writeable is False
|
||||
with pytest.raises(ValueError):
|
||||
semantic.labels[0, 0] = 2
|
||||
|
||||
with pytest.raises(SemanticFusionError, match="uint8 HxW"):
|
||||
replace(semantic, labels=np.asarray([[1, 2]], dtype=np.int64))
|
||||
with pytest.raises(SemanticFusionError, match="undeclared"):
|
||||
replace(semantic, labels=np.asarray([[1, 7]], dtype=np.uint8))
|
||||
with pytest.raises(SemanticFusionError, match="unique"):
|
||||
replace(
|
||||
semantic,
|
||||
classes=(SemanticClassDefinition(1, "road"), SemanticClassDefinition(1, "other")),
|
||||
)
|
||||
|
||||
|
||||
def test_mask_projection_keeps_absence_ambiguity_and_unprojected_separate() -> None:
|
||||
result = fuse_semantic_diagnostics(
|
||||
semantic_mask=_mask(),
|
||||
projected=_projection(),
|
||||
observations=(),
|
||||
)
|
||||
labels = result.point_labels
|
||||
assert [labels.status_for(index) for index in range(5)] == [
|
||||
SemanticEvidenceStatus.LABELED,
|
||||
SemanticEvidenceStatus.LABELED,
|
||||
SemanticEvidenceStatus.LABELED,
|
||||
SemanticEvidenceStatus.AMBIGUOUS,
|
||||
SemanticEvidenceStatus.UNPROJECTED,
|
||||
]
|
||||
assert [labels.class_id_for(index) for index in range(5)] == [1, 2, 2, 255, None]
|
||||
assert [labels.label_for(index) for index in range(5)] == [
|
||||
"road",
|
||||
"car",
|
||||
"car",
|
||||
"void / uncertain",
|
||||
None,
|
||||
]
|
||||
assert labels.class_ids.tolist() == [1, 2, 2, 255, NO_SEMANTIC_CLASS_ID]
|
||||
assert labels.class_ids.flags.writeable is False
|
||||
assert labels.status_codes.flags.writeable is False
|
||||
assert result.authority is SemanticEvidenceAuthority.DIAGNOSTIC_ONLY
|
||||
|
||||
|
||||
def test_observation_aggregation_is_detached_from_geometry_and_safety_authority() -> None:
|
||||
observation = _geometry_observation(0, 1, 2, 4)
|
||||
before = observation.to_dict()
|
||||
result = fuse_semantic_diagnostics(
|
||||
semantic_mask=_mask(),
|
||||
projected=_projection(),
|
||||
observations=(observation,),
|
||||
)
|
||||
evidence = result.observation_evidence[0]
|
||||
assert observation.to_dict() == before
|
||||
assert evidence.observation_id == observation.observation_id
|
||||
assert evidence.occupancy_key == observation.occupancy_identity
|
||||
assert evidence.status is SemanticEvidenceStatus.LABELED
|
||||
assert evidence.dominant_class_id == 2
|
||||
assert evidence.dominant_label == "car"
|
||||
assert evidence.dominant_fraction_of_labeled == pytest.approx(2 / 3)
|
||||
assert evidence.labeled_point_count == 3
|
||||
assert evidence.unprojected_point_count == 1
|
||||
assert evidence.semantic_coverage_fraction == pytest.approx(0.75)
|
||||
assert evidence.authority is SemanticEvidenceAuthority.DIAGNOSTIC_ONLY
|
||||
assert not hasattr(evidence, "occupied_support")
|
||||
assert not hasattr(evidence, "motion")
|
||||
assert not hasattr(evidence, "threat")
|
||||
assert not hasattr(evidence, "actuation_allowed")
|
||||
|
||||
|
||||
def test_tied_or_provider_ambiguous_labels_remain_ambiguous() -> None:
|
||||
tied = _geometry_observation(0, 1, observation_id="geometry-tied")
|
||||
provider_ambiguous = _geometry_observation(3, observation_id="geometry-void")
|
||||
result = fuse_semantic_diagnostics(
|
||||
semantic_mask=_mask(),
|
||||
projected=_projection(),
|
||||
observations=(tied, provider_ambiguous),
|
||||
)
|
||||
tie_evidence, void_evidence = result.observation_evidence
|
||||
assert tie_evidence.status is SemanticEvidenceStatus.AMBIGUOUS
|
||||
assert tie_evidence.reason_code == "semantic-label-majority-ambiguous"
|
||||
assert tie_evidence.dominant_class_id is None
|
||||
assert {item.label: item.point_count for item in tie_evidence.class_evidence} == {
|
||||
"road": 1,
|
||||
"car": 1,
|
||||
}
|
||||
assert void_evidence.status is SemanticEvidenceStatus.AMBIGUOUS
|
||||
assert void_evidence.reason_code == "semantic-classes-ambiguous"
|
||||
assert void_evidence.ambiguous_point_count == 1
|
||||
assert void_evidence.class_evidence[0].disposition is SemanticClassDisposition.AMBIGUOUS
|
||||
|
||||
mixed = fuse_semantic_diagnostics(
|
||||
semantic_mask=_mask(),
|
||||
projected=_projection(),
|
||||
observations=(_geometry_observation(1, 3, observation_id="geometry-mixed"),),
|
||||
).observation_evidence[0]
|
||||
assert mixed.status is SemanticEvidenceStatus.AMBIGUOUS
|
||||
assert mixed.reason_code == "semantic-label-majority-ambiguous"
|
||||
assert mixed.dominant_class_id is None
|
||||
|
||||
|
||||
def test_missing_mask_and_pointless_geometry_have_distinct_outcomes() -> None:
|
||||
observation = _geometry_observation(0, 1)
|
||||
absent = fuse_semantic_diagnostics(
|
||||
semantic_mask=None,
|
||||
projected=_projection(),
|
||||
observations=(observation,),
|
||||
)
|
||||
assert absent.mask_available is False
|
||||
assert [absent.point_labels.status_for(index) for index in range(5)] == [
|
||||
SemanticEvidenceStatus.ABSENT
|
||||
] * 5
|
||||
assert absent.observation_evidence[0].status is SemanticEvidenceStatus.ABSENT
|
||||
assert absent.observation_evidence[0].absent_point_count == 2
|
||||
|
||||
unprojected = fuse_semantic_diagnostics(
|
||||
semantic_mask=_mask(),
|
||||
projected=_projection(),
|
||||
observations=(_camera_only_observation(),),
|
||||
)
|
||||
evidence = unprojected.observation_evidence[0]
|
||||
assert evidence.status is SemanticEvidenceStatus.UNPROJECTED
|
||||
assert evidence.reason_code == "observation-has-no-source-points"
|
||||
assert evidence.source_point_count == 0
|
||||
|
||||
|
||||
def test_fusion_rejects_frame_escape_invalid_point_ids_and_duplicate_ownership() -> None:
|
||||
observation = _geometry_observation(0)
|
||||
with pytest.raises(SemanticFusionError, match="source frame"):
|
||||
fuse_semantic_diagnostics(
|
||||
semantic_mask=_mask(frame_id="frame-000015"),
|
||||
projected=_projection(),
|
||||
observations=(observation,),
|
||||
)
|
||||
with pytest.raises(SemanticFusionError, match="outside the source frame"):
|
||||
fuse_semantic_diagnostics(
|
||||
semantic_mask=_mask(),
|
||||
projected=_projection(),
|
||||
observations=(_geometry_observation(5),),
|
||||
)
|
||||
with pytest.raises(SemanticFusionError, match="duplicate observation ownership"):
|
||||
fuse_semantic_diagnostics(
|
||||
semantic_mask=_mask(),
|
||||
projected=_projection(),
|
||||
observations=(
|
||||
observation,
|
||||
_geometry_observation(0, observation_id="geometry-observation-2"),
|
||||
),
|
||||
)
|
||||
with pytest.raises(SemanticFusionError, match="escaped their source frame"):
|
||||
fuse_semantic_diagnostics(
|
||||
semantic_mask=None,
|
||||
projected=_projection(),
|
||||
observations=(
|
||||
observation,
|
||||
replace(
|
||||
_geometry_observation(1, observation_id="geometry-observation-2"),
|
||||
frame_id="frame-000015",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_projection_validator_rejects_malformed_existing_contract_values() -> None:
|
||||
malformed = replace(
|
||||
_projection(),
|
||||
source_indices=np.asarray([0, 1, 2, 3, 5], dtype=np.int64),
|
||||
)
|
||||
with pytest.raises(SemanticFusionError, match="outside the source frame"):
|
||||
fuse_semantic_diagnostics(
|
||||
semantic_mask=_mask(),
|
||||
projected=malformed,
|
||||
observations=(),
|
||||
)
|
||||
@@ -0,0 +1,506 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import tarfile
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
import k1link.perception.semantic_slam_replay as replay
|
||||
from k1link.perception.contracts import (
|
||||
EvidenceBasis,
|
||||
EvidenceCurrentness,
|
||||
MetricGeometry,
|
||||
ObstacleObservation,
|
||||
)
|
||||
from k1link.perception.geometry import GeometryFrame, RecordedFrameTemporalBinding
|
||||
from k1link.perception.geometry_math import Kb4ProjectionProfile
|
||||
from k1link.perception.geometry_replay import GeometryReplayResult
|
||||
from k1link.perception.semantic_fusion import (
|
||||
SemanticClassDisposition,
|
||||
SemanticEvidenceStatus,
|
||||
)
|
||||
from k1link.perception.threat_replay import ThreatReplayResult
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _write_jsonl(path: Path, rows: list[dict[str, object]]) -> None:
|
||||
path.write_bytes(b"".join(_canonical_json(row) + b"\n" for row in rows))
|
||||
|
||||
|
||||
def _png(labels: np.ndarray) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
Image.fromarray(labels, mode="L").save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Store:
|
||||
frame: GeometryFrame
|
||||
lidar_delta_ms: float = 4.0
|
||||
pose_delta_ms: float = 2.0
|
||||
|
||||
def frame_for_index(self, frame_index: int) -> GeometryFrame | None:
|
||||
return self.frame if frame_index == 0 else None
|
||||
|
||||
def temporal_binding_for_index(self, frame_index: int) -> RecordedFrameTemporalBinding:
|
||||
return RecordedFrameTemporalBinding(
|
||||
frame_index=frame_index,
|
||||
source_time_ns=(frame_index + 1) * 1_000_000_000,
|
||||
source_available=frame_index == 0,
|
||||
lidar_camera_delta_ms=self.lidar_delta_ms if frame_index == 0 else None,
|
||||
pose_point_delta_ms=self.pose_delta_ms if frame_index == 0 else None,
|
||||
)
|
||||
|
||||
|
||||
def _observation() -> ObstacleObservation:
|
||||
return ObstacleObservation(
|
||||
observation_id="frame-000000:obstacle-0",
|
||||
occupancy_key="frame-000000:obstacle-0",
|
||||
source_id="RAVNOVES00",
|
||||
frame_id="frame-000000",
|
||||
evidence_time_ns=1,
|
||||
basis=EvidenceBasis.FUSED,
|
||||
currentness=EvidenceCurrentness.CURRENT,
|
||||
occupied_support=True,
|
||||
source_point_ids=(0, 1),
|
||||
metric_geometry=MetricGeometry(
|
||||
coordinate_frame="map",
|
||||
centroid_xyz_m=(0.0, 0.0, 1.0),
|
||||
range_m=1.0,
|
||||
covariance_diagonal_m2=(0.0, 0.0, 0.0),
|
||||
),
|
||||
proposal_ids=("proposal-0",),
|
||||
semantic_hint="car",
|
||||
reason_codes=("current-test-support",),
|
||||
)
|
||||
|
||||
|
||||
def _fixture(tmp_path: Path) -> replay._AdmittedInputs:
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
profile_path = source / "profile.json"
|
||||
profile_path.write_text('{"fixture":true}\n', encoding="utf-8")
|
||||
authority = {
|
||||
"ground_truth": False,
|
||||
"physical_live": False,
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"semantic_authority": "diagnostic-only",
|
||||
}
|
||||
fusion = {
|
||||
"projection": "factory-kb4-current-increment/v1",
|
||||
"point_index_space": "frame-local-source-point-id/v1",
|
||||
"observation_aggregation": "dominant-labeled-majority-diagnostic/v1",
|
||||
"unprojected_status": "unprojected",
|
||||
"semantic_absence_means_free": False,
|
||||
"semantic_can_create_obstacle": False,
|
||||
"semantic_can_change_identity": False,
|
||||
"semantic_can_change_metric_geometry": False,
|
||||
"semantic_can_change_occupancy": False,
|
||||
"semantic_can_change_motion": False,
|
||||
"semantic_can_change_threat": False,
|
||||
}
|
||||
profile = replay._SemanticSlamProfile(
|
||||
path=profile_path,
|
||||
sha256=_sha256(profile_path),
|
||||
profile_id="fixture-semantic-slam/v1",
|
||||
source_id="RAVNOVES00",
|
||||
session_id="fixture-session",
|
||||
frame_count=2,
|
||||
image_width=4,
|
||||
image_height=4,
|
||||
source_pack_id="fixture-source-pack",
|
||||
source_pack_sha256="1" * 64,
|
||||
calibration_sha256="2" * 64,
|
||||
provider_id="fixture-semantic-provider/v1",
|
||||
model_id="fixture-model",
|
||||
model_revision="fixture-revision",
|
||||
model_weights_sha256="3" * 64,
|
||||
preprocess_id="fixture-preprocess/v1",
|
||||
mask_metadata_schema_version="missioncore.panoptic-frame/v1",
|
||||
mask_payload={
|
||||
"media_type": "image/png",
|
||||
"encoding": "uint8-class-id",
|
||||
"width": 4,
|
||||
"height": 4,
|
||||
"sequence_binding": "sequence-0-to-frame-000001",
|
||||
},
|
||||
provider_role="fixed-control-not-selected-production-provider",
|
||||
classes=(
|
||||
replay._TaxonomyClass(
|
||||
class_id=0,
|
||||
label="outside_valid_fov",
|
||||
disposition=SemanticClassDisposition.AMBIGUOUS,
|
||||
color_rgb=(0, 0, 0),
|
||||
),
|
||||
replay._TaxonomyClass(
|
||||
class_id=4,
|
||||
label="car",
|
||||
disposition=SemanticClassDisposition.LABELED,
|
||||
color_rgb=(0, 0, 142),
|
||||
),
|
||||
),
|
||||
fusion=fusion,
|
||||
temporal_binding={
|
||||
"semantic_to_camera": "exact-sequence-and-session-time",
|
||||
"camera_to_lidar": "accepted-e6-nearest-host-arrival-best-effort",
|
||||
"clock_basis": "recorded-host-monotonic-arrival",
|
||||
"maximum_lidar_camera_delta_ms": 100.0,
|
||||
"maximum_pose_point_delta_ms": 100.0,
|
||||
"physical_synchronization_proven": False,
|
||||
},
|
||||
acceptance={
|
||||
"full_frame_accounting_required": True,
|
||||
"point_accounting_required": True,
|
||||
"observation_binding_required": True,
|
||||
"exact_mask_archive_required": True,
|
||||
"independent_semantic_truth_required_for_provider_promotion": True,
|
||||
},
|
||||
authority=authority,
|
||||
)
|
||||
|
||||
semantic_root = source / "semantic"
|
||||
semantic_root.mkdir()
|
||||
result_json = semantic_root / "result.json"
|
||||
result_json.write_text('{"sealed":"fixture"}\n', encoding="utf-8")
|
||||
masks = []
|
||||
first = np.zeros((4, 4), dtype=np.uint8)
|
||||
first[2, 2] = 4
|
||||
masks.append(_png(first))
|
||||
masks.append(_png(np.zeros((4, 4), dtype=np.uint8)))
|
||||
mask_archive = semantic_root / "masks.tar.gz"
|
||||
with tarfile.open(mask_archive, mode="w:gz") as archive:
|
||||
directory = tarfile.TarInfo("semantic-masks")
|
||||
directory.type = tarfile.DIRTYPE
|
||||
archive.addfile(directory)
|
||||
for sequence, payload in enumerate(masks, start=1):
|
||||
member = tarfile.TarInfo(f"semantic-masks/frame-{sequence:06d}.png")
|
||||
member.size = len(payload)
|
||||
archive.addfile(member, io.BytesIO(payload))
|
||||
semantic_frames = semantic_root / "frames.jsonl"
|
||||
_write_jsonl(
|
||||
semantic_frames,
|
||||
[
|
||||
{
|
||||
"schema_version": "missioncore.panoptic-frame/v1",
|
||||
"frame_index": 0,
|
||||
"sequence": 1,
|
||||
"session_seconds": 1.0,
|
||||
"instances": [],
|
||||
"semantic_classes": [
|
||||
{
|
||||
"id": 4,
|
||||
"label": "car",
|
||||
"pixels": 1,
|
||||
"fraction_of_valid_fov": 0.0625,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"schema_version": "missioncore.panoptic-frame/v1",
|
||||
"frame_index": 1,
|
||||
"sequence": 2,
|
||||
"session_seconds": 2.0,
|
||||
"instances": [],
|
||||
"semantic_classes": [],
|
||||
},
|
||||
],
|
||||
)
|
||||
semantic = replay._SemanticUpstream(
|
||||
result_id="result-" + "4" * 64,
|
||||
result_root=semantic_root,
|
||||
result_manifest_sha256=_sha256(result_json),
|
||||
frames_path=semantic_frames,
|
||||
frames_sha256=_sha256(semantic_frames),
|
||||
masks_path=mask_archive,
|
||||
masks_sha256=_sha256(mask_archive),
|
||||
created_at_utc="2026-08-06T00:00:00.000Z",
|
||||
job_id="fixture-job",
|
||||
input_sha256="5" * 64,
|
||||
source_id="sensor.camera.right",
|
||||
session_id="fixture-session",
|
||||
calibration_sha256="2" * 64,
|
||||
configuration_profile_sha256="6" * 64,
|
||||
model_id="fixture-model",
|
||||
model_revision="fixture-revision",
|
||||
model_weights_sha256="3" * 64,
|
||||
)
|
||||
|
||||
observation = _observation()
|
||||
geometry_root = source / "geometry"
|
||||
geometry_root.mkdir()
|
||||
geometry_frames = geometry_root / "frames.jsonl"
|
||||
_write_jsonl(
|
||||
geometry_frames,
|
||||
[
|
||||
{
|
||||
"schema_version": "missioncore.perception-geometry-replay-frame/v1",
|
||||
"sequence": 0,
|
||||
"frame_id": "frame-000000",
|
||||
"source_available": True,
|
||||
"observations": [observation.to_dict()],
|
||||
},
|
||||
{
|
||||
"schema_version": "missioncore.perception-geometry-replay-frame/v1",
|
||||
"sequence": 1,
|
||||
"frame_id": "frame-000001",
|
||||
"source_available": False,
|
||||
"observations": [],
|
||||
},
|
||||
],
|
||||
)
|
||||
geometry_sha256 = _sha256(geometry_frames)
|
||||
geometry = GeometryReplayResult(
|
||||
result_id="m4-geometry-replay-" + "7" * 64,
|
||||
result_root=geometry_root,
|
||||
accepted=True,
|
||||
metrics={"frames": {"total": 2}},
|
||||
report={},
|
||||
manifest={"identity": {"frames_sha256": geometry_sha256}},
|
||||
)
|
||||
|
||||
threat_root = source / "threat"
|
||||
threat_root.mkdir()
|
||||
threat_frames = threat_root / "frames.jsonl"
|
||||
_write_jsonl(threat_frames, [{"decision": "unchanged"}])
|
||||
threat_sha256 = _sha256(threat_frames)
|
||||
threat = ThreatReplayResult(
|
||||
result_id="m4-threat-replay-" + "8" * 64,
|
||||
result_root=threat_root,
|
||||
accepted=True,
|
||||
metrics={"frames": {"total": 2}},
|
||||
report={},
|
||||
manifest={"identity": {"frames_sha256": threat_sha256}},
|
||||
)
|
||||
|
||||
transform = np.eye(4, dtype=np.float64)
|
||||
frame = GeometryFrame(
|
||||
frame_index=0,
|
||||
points_map=np.asarray(((0.0, 0.0, 1.0), (100.0, 0.0, 1.0)), dtype=np.float64),
|
||||
point_class=np.zeros(2, dtype=np.uint8),
|
||||
sensor_position_map=np.zeros(3, dtype=np.float64),
|
||||
sensor_orientation_xyzw=np.asarray((0.0, 0.0, 0.0, 1.0), dtype=np.float64),
|
||||
projection=Kb4ProjectionProfile(
|
||||
width=4,
|
||||
height=4,
|
||||
intrinsic_fx_fy_cx_cy=(2.0, 2.0, 2.0, 2.0),
|
||||
distortion_kb4=(0.0, 0.0, 0.0, 0.0),
|
||||
t_camera_from_lidar=transform,
|
||||
),
|
||||
surface_valid=True,
|
||||
)
|
||||
return replay._AdmittedInputs(
|
||||
profile=profile,
|
||||
semantic=semantic,
|
||||
geometry=geometry,
|
||||
threat=threat,
|
||||
store=_Store(frame), # type: ignore[arg-type]
|
||||
geometry_frames_path=geometry_frames,
|
||||
geometry_frames_sha256=geometry_sha256,
|
||||
threat_frames_path=threat_frames,
|
||||
threat_frames_sha256=threat_sha256,
|
||||
)
|
||||
|
||||
|
||||
def _build(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> tuple[replay.SemanticSlamReplayResult, replay._AdmittedInputs]:
|
||||
admitted = _fixture(tmp_path)
|
||||
monkeypatch.setattr(replay, "_admit_inputs", lambda **_kwargs: admitted)
|
||||
result = replay.build_semantic_slam_replay(
|
||||
repository_root=tmp_path,
|
||||
semantic_result_root=tmp_path,
|
||||
threat_result_root=tmp_path,
|
||||
geometry_result_root=tmp_path,
|
||||
output_root=tmp_path / "output",
|
||||
)
|
||||
return result, admitted
|
||||
|
||||
|
||||
def test_builder_is_idempotent_and_preserves_geometry_and_threat_ledgers(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
admitted = _fixture(tmp_path)
|
||||
geometry_before = admitted.geometry_frames_path.read_bytes()
|
||||
threat_before = admitted.threat_frames_path.read_bytes()
|
||||
monkeypatch.setattr(replay, "_admit_inputs", lambda **_kwargs: admitted)
|
||||
arguments = {
|
||||
"repository_root": tmp_path,
|
||||
"semantic_result_root": tmp_path,
|
||||
"threat_result_root": tmp_path,
|
||||
"geometry_result_root": tmp_path,
|
||||
"output_root": tmp_path / "output",
|
||||
}
|
||||
first = replay.build_semantic_slam_replay(**arguments)
|
||||
manifest_before = (first.result_root / replay.SEMANTIC_SLAM_MANIFEST_NAME).read_bytes()
|
||||
second = replay.build_semantic_slam_replay(**arguments)
|
||||
|
||||
assert second.result_id == first.result_id
|
||||
assert (second.result_root / replay.SEMANTIC_SLAM_MANIFEST_NAME).read_bytes() == manifest_before
|
||||
assert admitted.geometry_frames_path.read_bytes() == geometry_before
|
||||
assert admitted.threat_frames_path.read_bytes() == threat_before
|
||||
identity = first.manifest["identity"]
|
||||
assert identity["geometry_frames_sha256"] == hashlib.sha256(geometry_before).hexdigest()
|
||||
assert identity["base_m4_frames_sha256"] == hashlib.sha256(threat_before).hexdigest()
|
||||
|
||||
|
||||
def test_unprojected_source_point_is_uint8_zero_with_explicit_status(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
result, _ = _build(tmp_path, monkeypatch)
|
||||
with np.load(
|
||||
result.result_root / replay.SEMANTIC_SLAM_POINTS_NAME,
|
||||
allow_pickle=False,
|
||||
) as archive:
|
||||
assert archive["point_labels"].dtype == np.uint8
|
||||
assert archive["point_labels"].tolist() == [4, 0]
|
||||
assert archive["point_status_codes"].tolist() == [
|
||||
int(SemanticEvidenceStatus.LABELED),
|
||||
int(SemanticEvidenceStatus.UNPROJECTED),
|
||||
]
|
||||
assert archive["point_projected"].tolist() == [1, 0]
|
||||
|
||||
rows = [
|
||||
json.loads(line)
|
||||
for line in (result.result_root / replay.SEMANTIC_SLAM_OBSERVATIONS_NAME)
|
||||
.read_text("utf-8")
|
||||
.splitlines()
|
||||
]
|
||||
assert rows[0]["observations"][0]["observation_id"] == _observation().observation_id
|
||||
assert rows[0]["observations"][0]["status"] == "labeled"
|
||||
assert rows[0]["observations"][0]["unprojected_point_count"] == 1
|
||||
assert "threat" not in rows[0]["observations"][0]
|
||||
assert rows[0]["source_time_ns"] == 1_000_000_000
|
||||
assert rows[0]["temporal_binding"] == {
|
||||
"semantic_to_camera": "exact-sequence-and-session-time",
|
||||
"camera_to_lidar": "accepted-e6-nearest-host-arrival-best-effort",
|
||||
"lidar_camera_delta_ms": 4.0,
|
||||
"pose_point_delta_ms": 2.0,
|
||||
"physical_synchronization_proven": False,
|
||||
}
|
||||
assert rows[1]["temporal_binding"]["lidar_camera_delta_ms"] is None
|
||||
|
||||
|
||||
def test_reader_rejects_tampered_point_artifact(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
result, _ = _build(tmp_path, monkeypatch)
|
||||
points = result.result_root / replay.SEMANTIC_SLAM_POINTS_NAME
|
||||
points.write_bytes(points.read_bytes() + b"tamper")
|
||||
|
||||
with pytest.raises(replay.SemanticSlamReplayError, match="digest changed"):
|
||||
replay.read_semantic_slam_replay_result(result.result_root)
|
||||
|
||||
|
||||
def test_builder_rejects_semantic_and_source_pack_session_time_mismatch(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
admitted = _fixture(tmp_path)
|
||||
rows = [
|
||||
json.loads(line) for line in admitted.semantic.frames_path.read_text("utf-8").splitlines()
|
||||
]
|
||||
rows[1]["session_seconds"] = 2.001
|
||||
_write_jsonl(admitted.semantic.frames_path, rows)
|
||||
semantic = replace(
|
||||
admitted.semantic,
|
||||
frames_sha256=_sha256(admitted.semantic.frames_path),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
replay,
|
||||
"_admit_inputs",
|
||||
lambda **_kwargs: replace(admitted, semantic=semantic),
|
||||
)
|
||||
|
||||
with pytest.raises(replay.SemanticSlamReplayError, match="session time disagree"):
|
||||
replay.build_semantic_slam_replay(
|
||||
repository_root=tmp_path,
|
||||
semantic_result_root=tmp_path,
|
||||
threat_result_root=tmp_path,
|
||||
geometry_result_root=tmp_path,
|
||||
output_root=tmp_path / "output",
|
||||
)
|
||||
|
||||
|
||||
def test_builder_rejects_best_effort_delta_outside_admitted_bound(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
admitted = _fixture(tmp_path)
|
||||
frame = admitted.store.frame_for_index(0)
|
||||
assert frame is not None
|
||||
monkeypatch.setattr(
|
||||
replay,
|
||||
"_admit_inputs",
|
||||
lambda **_kwargs: replace(
|
||||
admitted,
|
||||
store=_Store(frame, lidar_delta_ms=100.001), # type: ignore[arg-type]
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(replay.SemanticSlamReplayError, match="delta exceeds"):
|
||||
replay.build_semantic_slam_replay(
|
||||
repository_root=tmp_path,
|
||||
semantic_result_root=tmp_path,
|
||||
threat_result_root=tmp_path,
|
||||
geometry_result_root=tmp_path,
|
||||
output_root=tmp_path / "output",
|
||||
)
|
||||
|
||||
|
||||
def test_reader_rejects_leaf_result_symlink(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
result, _ = _build(tmp_path, monkeypatch)
|
||||
alias_parent = tmp_path / "alias"
|
||||
alias_parent.mkdir()
|
||||
alias = alias_parent / result.result_id
|
||||
alias.symlink_to(result.result_root, target_is_directory=True)
|
||||
|
||||
with pytest.raises(replay.SemanticSlamReplayError, match="result root is invalid"):
|
||||
replay.read_semantic_slam_replay_result(alias)
|
||||
|
||||
|
||||
def test_builder_rejects_existing_destination_symlink(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
result, admitted = _build(tmp_path, monkeypatch)
|
||||
monkeypatch.setattr(replay, "_admit_inputs", lambda **_kwargs: admitted)
|
||||
second_output = tmp_path / "second-output"
|
||||
second_output.mkdir()
|
||||
(second_output / result.result_id).symlink_to(result.result_root, target_is_directory=True)
|
||||
|
||||
with pytest.raises(replay.SemanticSlamReplayError, match="destination cannot be a symlink"):
|
||||
replay.build_semantic_slam_replay(
|
||||
repository_root=tmp_path,
|
||||
semantic_result_root=tmp_path,
|
||||
threat_result_root=tmp_path,
|
||||
geometry_result_root=tmp_path,
|
||||
output_root=second_output,
|
||||
)
|
||||
Reference in New Issue
Block a user