feat(perception): define semantic object understanding
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from k1link.perception.contracts import (
|
||||
EvidenceBasis,
|
||||
EvidenceCurrentness,
|
||||
MetricGeometry,
|
||||
ObstacleObservation,
|
||||
)
|
||||
from k1link.perception.geometry import GeometryFrame
|
||||
from k1link.perception.geometry_math import Kb4ProjectionProfile
|
||||
from k1link.perception.geometry_semantic_roi import (
|
||||
build_geometry_semantic_rois,
|
||||
materialize_geometry_semantic_crop,
|
||||
select_geometry_roi_detections,
|
||||
)
|
||||
from k1link.perception.object_understanding import load_object_semantic_vocabulary
|
||||
from k1link.perception.open_vocabulary_semantics import (
|
||||
OpenVocabularyDetection,
|
||||
fuse_open_vocabulary_detections,
|
||||
load_open_vocabulary_semantic_profile,
|
||||
)
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
PROFILE_PATH = REPOSITORY_ROOT / "config/perception/open-vocabulary-semantic-shadow-v0.json"
|
||||
VOCABULARY_PATH = REPOSITORY_ROOT / "config/perception/object-semantic-vocabulary-v0.json"
|
||||
|
||||
|
||||
def _point_for_pixel(u: float, v: float, *, z: float = 5.0) -> tuple[float, float, float]:
|
||||
theta_x = (u - 400.0) / 100.0
|
||||
theta_y = (v - 300.0) / 100.0
|
||||
ray = np.asarray((np.tan(theta_x), np.tan(theta_y), 1.0), dtype=np.float64)
|
||||
return tuple(float(item) for item in ray * z) # type: ignore[return-value]
|
||||
|
||||
|
||||
def _frame() -> GeometryFrame:
|
||||
points = np.asarray(
|
||||
(
|
||||
_point_for_pixel(350.0, 250.0),
|
||||
_point_for_pixel(450.0, 250.0),
|
||||
_point_for_pixel(350.0, 350.0),
|
||||
_point_for_pixel(450.0, 350.0),
|
||||
_point_for_pixel(600.0, 300.0),
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
return GeometryFrame(
|
||||
frame_index=121,
|
||||
points_map=points,
|
||||
point_class=np.full(points.shape[0], 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=800,
|
||||
height=600,
|
||||
intrinsic_fx_fy_cx_cy=(100.0, 100.0, 400.0, 300.0),
|
||||
distortion_kb4=(0.0, 0.0, 0.0, 0.0),
|
||||
t_camera_from_lidar=np.eye(4, dtype=np.float64),
|
||||
),
|
||||
surface_valid=True,
|
||||
)
|
||||
|
||||
|
||||
def _observation(ordinal: int, source_point_ids: tuple[int, ...]) -> ObstacleObservation:
|
||||
return ObstacleObservation(
|
||||
observation_id=f"frame-000121:observation-{ordinal}",
|
||||
occupancy_key=f"frame-000121:occupancy-{ordinal}",
|
||||
source_id="RAVNOVES00",
|
||||
frame_id="frame-000121",
|
||||
evidence_time_ns=121,
|
||||
basis=EvidenceBasis.LIDAR,
|
||||
currentness=EvidenceCurrentness.CURRENT,
|
||||
occupied_support=True,
|
||||
source_point_ids=source_point_ids,
|
||||
metric_geometry=MetricGeometry(
|
||||
coordinate_frame="map",
|
||||
centroid_xyz_m=(1.0, 2.0, 0.5),
|
||||
range_m=2.2,
|
||||
covariance_diagonal_m2=(0.1, 0.1, 0.1),
|
||||
),
|
||||
proposal_ids=(),
|
||||
semantic_hint=None,
|
||||
reason_codes=("test-geometry-only",),
|
||||
)
|
||||
|
||||
|
||||
def test_geometry_points_own_crop_and_sparse_observation_stays_unprojected(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
result = build_geometry_semantic_rois(
|
||||
frame=_frame(),
|
||||
observations=(_observation(0, (0, 1, 2, 3)), _observation(1, (4,))),
|
||||
)
|
||||
|
||||
assert len(result.rois) == 1
|
||||
assert result.not_projected_observations[0].observation_id.endswith("observation-1")
|
||||
roi = result.rois[0]
|
||||
assert roi.observation.source_point_ids == (0, 1, 2, 3)
|
||||
assert roi.core_region.x_min < 400.0 < roi.core_region.x_max
|
||||
assert roi.core_region.y_min < 300.0 < roi.core_region.y_max
|
||||
assert roi.crop_region.x_min <= roi.core_region.x_min
|
||||
assert roi.crop_region.y_max >= roi.core_region.y_max
|
||||
|
||||
source = tmp_path / "frame-000121.png"
|
||||
Image.new("RGB", (800, 600), (114, 114, 114)).save(source)
|
||||
destination = tmp_path / roi.crop_name
|
||||
materialize_geometry_semantic_crop(
|
||||
image_path=source,
|
||||
roi=roi,
|
||||
destination=destination,
|
||||
)
|
||||
with Image.open(destination) as crop:
|
||||
assert crop.size == (
|
||||
int(roi.crop_region.x_max - roi.crop_region.x_min),
|
||||
int(roi.crop_region.y_max - roi.crop_region.y_min),
|
||||
)
|
||||
|
||||
|
||||
def test_roi_selection_returns_only_cluster_covering_geometry_core() -> None:
|
||||
profile = load_open_vocabulary_semantic_profile(PROFILE_PATH)
|
||||
vocabulary = load_object_semantic_vocabulary(VOCABULARY_PATH)
|
||||
roi = build_geometry_semantic_rois(
|
||||
frame=_frame(),
|
||||
observations=(_observation(0, (0, 1, 2, 3)),),
|
||||
).rois[0]
|
||||
fusion = fuse_open_vocabulary_detections(
|
||||
(
|
||||
OpenVocabularyDetection(
|
||||
detection_id="inside",
|
||||
source_id="RAVNOVES00",
|
||||
frame_id="frame-000121",
|
||||
prompt_set_id="urban-static/v0",
|
||||
raw_label="trash bin",
|
||||
confidence=0.8,
|
||||
region=roi.core_region,
|
||||
),
|
||||
OpenVocabularyDetection(
|
||||
detection_id="outside",
|
||||
source_id="RAVNOVES00",
|
||||
frame_id="frame-000121",
|
||||
prompt_set_id="urban-static/v0",
|
||||
raw_label="traffic cone",
|
||||
confidence=0.9,
|
||||
region=type(roi.core_region)(10.0, 10.0, 50.0, 50.0),
|
||||
),
|
||||
),
|
||||
profile=profile,
|
||||
vocabulary=vocabulary,
|
||||
valid_fov_mask=np.ones((600, 800), dtype=np.bool_),
|
||||
)
|
||||
|
||||
selected = select_geometry_roi_detections(roi, fusion)
|
||||
|
||||
assert tuple(item.detection_id for item in selected) == ("inside",)
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from k1link.perception.geometry_semantic_shadow_replay import (
|
||||
read_geometry_semantic_shadow_replay,
|
||||
)
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
RESULT_ROOT = (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime/compute-experiments/m48s-semantic-shadow/geometry-first-results"
|
||||
/ (
|
||||
"m48s-geometry-semantic-shadow-"
|
||||
"1a6c5d04fb3f7aef4385ee28fc67d3e69f4528a77e8e1194b1283530ef73c7c2"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_geometry_first_semantic_shadow_is_complete_but_not_accepted() -> None:
|
||||
result = read_geometry_semantic_shadow_replay(RESULT_ROOT)
|
||||
|
||||
assert result.completed is True
|
||||
assert result.accepted is False
|
||||
assert result.metrics["geometry"] == {
|
||||
"not_projected_observation_count": 15,
|
||||
"observation_count": 73,
|
||||
"roi_count": 58,
|
||||
}
|
||||
semantics = result.metrics["semantics"]
|
||||
assert isinstance(semantics, dict)
|
||||
assert semantics["resolution_counts"] == {
|
||||
"ambiguous": 6,
|
||||
"selected": 11,
|
||||
"unresolved": 56,
|
||||
}
|
||||
assert semantics["selected_class_counts"] == {
|
||||
"human.adult": 1,
|
||||
"static.bollard": 1,
|
||||
"static.concrete-hemisphere": 1,
|
||||
"vehicle.car": 8,
|
||||
}
|
||||
assert result.report["decision"] == {
|
||||
"geometry_first_binding_completed": True,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"reason_codes": [
|
||||
"two-reviewer-independent-truth-unavailable",
|
||||
"open-vocabulary-geometry-roi-remains-experimental-shadow",
|
||||
"unknown-and-unresolved-objects-remain-route-around-obstacles",
|
||||
],
|
||||
"semantic_quality_accepted": False,
|
||||
}
|
||||
assert result.report["authority"]["commands_enabled"] is False # type: ignore[index]
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
RESULT_ID = (
|
||||
"m48s-mask-grounding-dino-shadow-"
|
||||
"b0a37f265223b4138754f76d5d7b8d17e7c4f5395481990f169fccc1645baba8"
|
||||
)
|
||||
RESULT_ROOT = (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime/compute-experiments/m48s-semantic-shadow/mask-grounding-dino-results"
|
||||
/ RESULT_ID
|
||||
)
|
||||
|
||||
|
||||
def test_mask_grounding_dino_shadow_is_complete_but_semantically_rejected() -> None:
|
||||
manifest = json.loads((RESULT_ROOT / "manifest.json").read_text("utf-8"))
|
||||
report = json.loads((RESULT_ROOT / "report.json").read_text("utf-8"))
|
||||
|
||||
assert manifest["result_id"] == RESULT_ID
|
||||
assert manifest["completed"] is True
|
||||
assert manifest["accepted"] is False
|
||||
assert manifest["metrics"]["frames"] == {"completed": 11, "requested": 11}
|
||||
assert manifest["metrics"]["geometry_observation_count"] == 73
|
||||
assert manifest["metrics"]["raw_detection_count"] == 25
|
||||
assert manifest["metrics"]["mask_instance_count"] == 7
|
||||
assert manifest["metrics"]["geometry_binding_resolution_counts"] == {
|
||||
"ambiguous": 2,
|
||||
"selected": 4,
|
||||
"unresolved": 1,
|
||||
}
|
||||
assert manifest["metrics"]["combined_resolution_counts"] == {
|
||||
"ambiguous": 6,
|
||||
"unresolved": 1,
|
||||
}
|
||||
assert manifest["metrics"]["single_class_agent_probe"] == {
|
||||
"detection_counts": {"adult": 0, "child": 0, "dog": 0},
|
||||
"frame_indices": [253, 1228],
|
||||
"passed": False,
|
||||
}
|
||||
assert report["decision"]["semantic_quality_accepted"] is False
|
||||
assert report["decision"]["agent_semantics_accepted"] is False
|
||||
assert report["authority"]["commands_enabled"] is False
|
||||
assert report["authority"]["navigation_or_safety_accepted"] is False
|
||||
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from k1link.perception.contracts import (
|
||||
EvidenceBasis,
|
||||
EvidenceCurrentness,
|
||||
MetricGeometry,
|
||||
ObstacleObservation,
|
||||
)
|
||||
from k1link.perception.geometry_math import ProjectedPointCloud
|
||||
from k1link.perception.mask_grounding_semantics import (
|
||||
MASK_GROUNDING_EVIDENCE_SCHEMA,
|
||||
MaskBindingResolution,
|
||||
MaskGroundingDetection,
|
||||
MaskLabelResolution,
|
||||
bind_mask_instances_to_geometry,
|
||||
cluster_mask_instances,
|
||||
load_mask_grounding_evidence,
|
||||
resolve_mask_instance_label,
|
||||
)
|
||||
|
||||
|
||||
def _observation(ordinal: int, source_ids: tuple[int, ...]) -> ObstacleObservation:
|
||||
return ObstacleObservation(
|
||||
observation_id=f"frame-000253:geometry:{ordinal}",
|
||||
occupancy_key=f"frame-000253:occupancy:{ordinal}",
|
||||
source_id="RAVNOVES00",
|
||||
frame_id="frame-000253",
|
||||
evidence_time_ns=253,
|
||||
basis=EvidenceBasis.LIDAR,
|
||||
currentness=EvidenceCurrentness.CURRENT,
|
||||
occupied_support=True,
|
||||
source_point_ids=source_ids,
|
||||
metric_geometry=MetricGeometry(
|
||||
coordinate_frame="map",
|
||||
centroid_xyz_m=(2.0 + ordinal, 0.0, 0.5),
|
||||
range_m=2.0 + ordinal,
|
||||
covariance_diagonal_m2=(0.1, 0.1, 0.1),
|
||||
),
|
||||
proposal_ids=(),
|
||||
semantic_hint=None,
|
||||
reason_codes=("test-geometry-only",),
|
||||
)
|
||||
|
||||
|
||||
def _projected() -> ProjectedPointCloud:
|
||||
return ProjectedPointCloud(
|
||||
pixels_xy=np.asarray(
|
||||
(
|
||||
(10.0, 10.0),
|
||||
(11.0, 10.0),
|
||||
(10.0, 11.0),
|
||||
(11.0, 11.0),
|
||||
(30.0, 30.0),
|
||||
(31.0, 30.0),
|
||||
(30.0, 31.0),
|
||||
(31.0, 31.0),
|
||||
),
|
||||
dtype=np.float64,
|
||||
),
|
||||
depths_m=np.ones(8, dtype=np.float64),
|
||||
source_indices=np.arange(8, dtype=np.int64),
|
||||
source_point_count=8,
|
||||
camera_front_point_count=8,
|
||||
)
|
||||
|
||||
|
||||
def _detection(
|
||||
detection_id: str,
|
||||
class_name: str,
|
||||
confidence: float,
|
||||
mask: np.ndarray,
|
||||
) -> MaskGroundingDetection:
|
||||
packed = mask.astype(np.uint8)
|
||||
return MaskGroundingDetection(
|
||||
detection_id=detection_id,
|
||||
prompt_set_id="urban-static/v0",
|
||||
class_id=0,
|
||||
class_name=class_name,
|
||||
confidence=confidence,
|
||||
box_xyxy=(1.0, 1.0, 40.0, 40.0),
|
||||
mask_sha256=hashlib.sha256(packed.tobytes()).hexdigest(),
|
||||
mask=mask,
|
||||
)
|
||||
|
||||
|
||||
def test_empty_early_v0_ledger_is_read_without_inventing_boxes(tmp_path: Path) -> None:
|
||||
path = tmp_path / "frame-000253.npz"
|
||||
np.savez_compressed(
|
||||
path,
|
||||
schema_version=np.asarray(MASK_GROUNDING_EVIDENCE_SCHEMA),
|
||||
source_file_sha256=np.asarray("a" * 64),
|
||||
source_pixel_sha256=np.asarray("b" * 64),
|
||||
class_ids=np.empty(0, dtype=np.int16),
|
||||
class_names=np.empty(0, dtype="U128"),
|
||||
scores=np.empty(0, dtype=np.float32),
|
||||
boxes_xyxy=np.empty(0, dtype=np.float32),
|
||||
masks=np.empty((0, 600, 800), dtype=np.uint8),
|
||||
)
|
||||
|
||||
evidence = load_mask_grounding_evidence(path, prompt_set_id="urban-agents/v0")
|
||||
|
||||
assert evidence.detections == ()
|
||||
assert evidence.source_file_sha256 == "a" * 64
|
||||
|
||||
|
||||
def test_same_mask_becomes_one_instance_and_conflicting_name_stays_ambiguous() -> None:
|
||||
mask = np.zeros((600, 800), dtype=np.bool_)
|
||||
mask[5:20, 5:20] = True
|
||||
instances = cluster_mask_instances(
|
||||
(
|
||||
_detection("trash", "trash bin", 0.43, mask),
|
||||
_detection("cart", "shopping cart", 0.41, mask),
|
||||
)
|
||||
)
|
||||
|
||||
bindings = bind_mask_instances_to_geometry(
|
||||
instances,
|
||||
observations=(_observation(0, (0, 1, 2, 3)), _observation(1, (4, 5, 6, 7))),
|
||||
projected=_projected(),
|
||||
)
|
||||
label = resolve_mask_instance_label(instances[0])
|
||||
|
||||
assert len(instances) == 1
|
||||
assert bindings[0].resolution is MaskBindingResolution.SELECTED
|
||||
assert bindings[0].selected_observation_id == "frame-000253:geometry:0"
|
||||
assert label.resolution is MaskLabelResolution.AMBIGUOUS
|
||||
assert label.selected_label is None
|
||||
|
||||
|
||||
def test_mask_covering_two_obstacles_does_not_claim_either() -> None:
|
||||
mask = np.zeros((600, 800), dtype=np.bool_)
|
||||
mask[5:40, 5:40] = True
|
||||
instance = cluster_mask_instances((_detection("wide", "trash bin", 0.7, mask),))
|
||||
|
||||
binding = bind_mask_instances_to_geometry(
|
||||
instance,
|
||||
observations=(_observation(0, (0, 1, 2, 3)), _observation(1, (4, 5, 6, 7))),
|
||||
projected=_projected(),
|
||||
)[0]
|
||||
|
||||
assert binding.resolution is MaskBindingResolution.AMBIGUOUS
|
||||
assert binding.selected_observation_id is None
|
||||
assert binding.reason_code == "mask-covers-multiple-geometry-observations"
|
||||
|
||||
|
||||
def test_two_distinct_masks_cannot_claim_one_geometry_observation() -> None:
|
||||
narrow = np.zeros((600, 800), dtype=np.bool_)
|
||||
narrow[9:13, 9:13] = True
|
||||
wide = np.zeros((600, 800), dtype=np.bool_)
|
||||
wide[1:25, 1:25] = True
|
||||
instances = cluster_mask_instances(
|
||||
(
|
||||
_detection("narrow", "bollard", 0.8, narrow),
|
||||
_detection("wide", "post", 0.8, wide),
|
||||
)
|
||||
)
|
||||
|
||||
bindings = bind_mask_instances_to_geometry(
|
||||
instances,
|
||||
observations=(_observation(0, (0, 1, 2, 3)),),
|
||||
projected=_projected(),
|
||||
)
|
||||
|
||||
assert len(instances) == 2
|
||||
assert all(item.resolution is MaskBindingResolution.AMBIGUOUS for item in bindings)
|
||||
assert all(item.selected_observation_id is None for item in bindings)
|
||||
assert all(
|
||||
item.reason_code == "observation-claimed-by-multiple-mask-instances"
|
||||
for item in bindings
|
||||
)
|
||||
@@ -0,0 +1,317 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.perception.contracts import (
|
||||
EvidenceBasis,
|
||||
EvidenceCurrentness,
|
||||
MetricGeometry,
|
||||
MotionState,
|
||||
ObstacleObservation,
|
||||
)
|
||||
from k1link.perception.object_understanding import (
|
||||
AdvisoryResponse,
|
||||
AdvisoryRiskAssessment,
|
||||
AgencyState,
|
||||
EvidenceKind,
|
||||
EvidenceProvenance,
|
||||
ObjectStateEstimate,
|
||||
ObjectUnderstanding,
|
||||
ObjectUnderstandingError,
|
||||
RiskBasis,
|
||||
RiskLevel,
|
||||
SemanticDecision,
|
||||
SemanticHypothesis,
|
||||
SemanticResolution,
|
||||
StateBasis,
|
||||
load_object_semantic_vocabulary,
|
||||
validate_object_understanding,
|
||||
)
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
VOCABULARY_PATH = REPOSITORY_ROOT / "config/perception/object-semantic-vocabulary-v0.json"
|
||||
|
||||
|
||||
def _observation(*, semantic_hint: str | None = None) -> ObstacleObservation:
|
||||
return ObstacleObservation(
|
||||
observation_id="observation-vehicle-1",
|
||||
occupancy_key="occupied-component-17",
|
||||
source_id="RAVNOVES00",
|
||||
frame_id="frame-000253",
|
||||
evidence_time_ns=35_421_857_292,
|
||||
basis=EvidenceBasis.FUSED,
|
||||
currentness=EvidenceCurrentness.CURRENT,
|
||||
occupied_support=True,
|
||||
source_point_ids=(4, 7, 9),
|
||||
metric_geometry=MetricGeometry(
|
||||
coordinate_frame="map",
|
||||
centroid_xyz_m=(4.0, 1.0, 0.5),
|
||||
range_m=4.15,
|
||||
covariance_diagonal_m2=(0.04, 0.04, 0.09),
|
||||
),
|
||||
proposal_ids=("proposal-vehicle-1",),
|
||||
semantic_hint=semantic_hint,
|
||||
reason_codes=("current-qualified-points",),
|
||||
)
|
||||
|
||||
|
||||
def _detector_evidence(*, frame_id: str = "frame-000253") -> EvidenceProvenance:
|
||||
return EvidenceProvenance(
|
||||
evidence_id="evidence-grounding-dino-1",
|
||||
kind=EvidenceKind.DETECTOR,
|
||||
source_id="RAVNOVES00",
|
||||
frame_id=frame_id,
|
||||
provider_id="grounding-dino-open-vocabulary/v1",
|
||||
model_id="grounding-dino-tensorrt",
|
||||
model_revision="worker-006-probe-20260825",
|
||||
preprocess_id="kb4-rectified-rgb/v1",
|
||||
prompt_set_id="urban-risk-groups/v0",
|
||||
)
|
||||
|
||||
|
||||
def _temporal_evidence() -> EvidenceProvenance:
|
||||
return EvidenceProvenance(
|
||||
evidence_id="evidence-temporal-1",
|
||||
kind=EvidenceKind.TEMPORAL,
|
||||
source_id="RAVNOVES00",
|
||||
frame_id="frame-000253",
|
||||
provider_id="temporal-occupied/v1",
|
||||
model_id=None,
|
||||
model_revision=None,
|
||||
preprocess_id=None,
|
||||
)
|
||||
|
||||
|
||||
def _car_understanding() -> ObjectUnderstanding:
|
||||
detector = _detector_evidence()
|
||||
temporal = _temporal_evidence()
|
||||
return ObjectUnderstanding(
|
||||
understanding_id="understanding-vehicle-1",
|
||||
vocabulary_id="missioncore.urban-object-semantics/v0",
|
||||
generated_monotonic_ns=1_020_000,
|
||||
observation=_observation(semantic_hint="car"),
|
||||
hypotheses=(
|
||||
SemanticHypothesis(
|
||||
rank=1,
|
||||
class_id="vehicle.car",
|
||||
raw_label="car",
|
||||
confidence=0.79,
|
||||
evidence_ids=(detector.evidence_id,),
|
||||
),
|
||||
SemanticHypothesis(
|
||||
rank=2,
|
||||
class_id="vehicle.heavy",
|
||||
raw_label="heavy vehicle",
|
||||
confidence=0.12,
|
||||
evidence_ids=(detector.evidence_id,),
|
||||
),
|
||||
),
|
||||
semantic=SemanticDecision(
|
||||
resolution=SemanticResolution.SELECTED,
|
||||
selected_class_id="vehicle.car",
|
||||
selected_confidence=0.79,
|
||||
reason_codes=("top-hypothesis-qualified",),
|
||||
),
|
||||
state=ObjectStateEstimate(
|
||||
motion=MotionState.STATIONARY,
|
||||
motion_confidence=0.85,
|
||||
agency=AgencyState.SELF_PROPELLED,
|
||||
agency_basis=StateBasis.CLASS_PRIOR,
|
||||
evidence_ids=(detector.evidence_id, temporal.evidence_id),
|
||||
reason_codes=("stationary-observed-vehicle-prior-retained",),
|
||||
),
|
||||
risk=AdvisoryRiskAssessment(
|
||||
policy_id="urban-object-risk/v0",
|
||||
level=RiskLevel.ELEVATED,
|
||||
confidence=0.71,
|
||||
basis=RiskBasis.FUSED,
|
||||
responses=(
|
||||
AdvisoryResponse.MONITOR,
|
||||
AdvisoryResponse.REDUCE_SPEED,
|
||||
AdvisoryResponse.ROUTE_AROUND,
|
||||
),
|
||||
evidence_ids=(detector.evidence_id, temporal.evidence_id),
|
||||
reason_codes=("stationary-vehicle-may-start-moving",),
|
||||
),
|
||||
provenance=(detector, temporal),
|
||||
)
|
||||
|
||||
|
||||
def _unknown_understanding() -> ObjectUnderstanding:
|
||||
return ObjectUnderstanding(
|
||||
understanding_id="understanding-unknown-1",
|
||||
vocabulary_id="missioncore.urban-object-semantics/v0",
|
||||
generated_monotonic_ns=1_020_000,
|
||||
observation=_observation(),
|
||||
hypotheses=(),
|
||||
semantic=SemanticDecision(
|
||||
resolution=SemanticResolution.UNRESOLVED,
|
||||
selected_class_id=None,
|
||||
selected_confidence=None,
|
||||
reason_codes=("semantic-evidence-unavailable",),
|
||||
),
|
||||
state=ObjectStateEstimate(
|
||||
motion=MotionState.UNKNOWN,
|
||||
motion_confidence=0.0,
|
||||
agency=AgencyState.UNKNOWN,
|
||||
agency_basis=StateBasis.UNKNOWN,
|
||||
evidence_ids=(),
|
||||
reason_codes=("state-evidence-unavailable",),
|
||||
),
|
||||
risk=AdvisoryRiskAssessment(
|
||||
policy_id="urban-object-risk/v0",
|
||||
level=RiskLevel.UNKNOWN,
|
||||
confidence=0.0,
|
||||
basis=RiskBasis.UNKNOWN,
|
||||
responses=(AdvisoryResponse.ROUTE_AROUND,),
|
||||
evidence_ids=(),
|
||||
reason_codes=("unknown-object-remains-occupied",),
|
||||
),
|
||||
provenance=(),
|
||||
)
|
||||
|
||||
|
||||
def test_object_understanding_round_trip_is_strict_and_keeps_v1_geometry() -> None:
|
||||
value = _car_understanding()
|
||||
observation_before = value.observation.to_dict()
|
||||
document = json.loads(json.dumps(value.to_dict()))
|
||||
|
||||
restored = ObjectUnderstanding.from_dict(document)
|
||||
|
||||
assert restored == value
|
||||
assert restored.observation.to_dict() == observation_before
|
||||
assert restored.occupancy_identity == "occupied-component-17"
|
||||
assert restored.observation.metric_geometry is not None
|
||||
assert restored.observation.metric_geometry.range_m == pytest.approx(4.15)
|
||||
document["unexpected"] = True
|
||||
with pytest.raises(ObjectUnderstandingError, match="fields are incompatible"):
|
||||
ObjectUnderstanding.from_dict(document)
|
||||
|
||||
|
||||
def test_stationary_vehicle_keeps_separate_self_propelled_prior_and_advisory_risk() -> None:
|
||||
value = _car_understanding()
|
||||
|
||||
assert value.state.motion is MotionState.STATIONARY
|
||||
assert value.state.agency is AgencyState.SELF_PROPELLED
|
||||
assert value.state.agency_basis is StateBasis.CLASS_PRIOR
|
||||
assert value.risk.level is RiskLevel.ELEVATED
|
||||
assert AdvisoryResponse.REDUCE_SPEED in value.risk.responses
|
||||
assert value.authority.commands_enabled is False
|
||||
assert value.authority.actuation_allowed is False
|
||||
assert value.authority.navigation_or_safety_accepted is False
|
||||
|
||||
|
||||
def test_unknown_semantics_never_erase_metric_occupancy() -> None:
|
||||
value = _unknown_understanding()
|
||||
|
||||
assert value.semantic.resolution is SemanticResolution.UNRESOLVED
|
||||
assert value.hypotheses == ()
|
||||
assert value.observation.occupied_support is True
|
||||
assert value.observation.source_point_ids == (4, 7, 9)
|
||||
assert value.occupancy_identity == value.observation.occupancy_identity
|
||||
assert value.risk.responses == (AdvisoryResponse.ROUTE_AROUND,)
|
||||
|
||||
|
||||
def test_ranked_hypotheses_and_selected_class_must_agree() -> None:
|
||||
value = _car_understanding()
|
||||
with pytest.raises(ObjectUnderstandingError, match="ordered by confidence"):
|
||||
replace(
|
||||
value,
|
||||
hypotheses=(
|
||||
replace(value.hypotheses[0], confidence=0.10),
|
||||
replace(value.hypotheses[1], confidence=0.90),
|
||||
),
|
||||
)
|
||||
with pytest.raises(ObjectUnderstandingError, match="match one ranked hypothesis"):
|
||||
replace(
|
||||
value,
|
||||
semantic=replace(
|
||||
value.semantic,
|
||||
selected_class_id="animal.dog",
|
||||
selected_confidence=0.79,
|
||||
),
|
||||
)
|
||||
with pytest.raises(ObjectUnderstandingError, match="two hypotheses"):
|
||||
replace(
|
||||
value,
|
||||
hypotheses=value.hypotheses[:1],
|
||||
semantic=SemanticDecision(
|
||||
resolution=SemanticResolution.CONFLICT,
|
||||
selected_class_id=None,
|
||||
selected_confidence=None,
|
||||
reason_codes=("provider-disagreement",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_evidence_cannot_escape_geometry_frame_or_be_fabricated() -> None:
|
||||
value = _car_understanding()
|
||||
with pytest.raises(ObjectUnderstandingError, match="escaped"):
|
||||
replace(value, provenance=(_detector_evidence(frame_id="frame-000254"),))
|
||||
with pytest.raises(ObjectUnderstandingError, match="unknown evidence"):
|
||||
replace(
|
||||
value,
|
||||
hypotheses=(
|
||||
replace(value.hypotheses[0], evidence_ids=("missing-evidence",)),
|
||||
value.hypotheses[1],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_executable_vocabulary_resolves_current_urban_labels_and_hierarchy() -> None:
|
||||
vocabulary = load_object_semantic_vocabulary(VOCABULARY_PATH)
|
||||
|
||||
assert vocabulary.vocabulary_id == "missioncore.urban-object-semantics/v0"
|
||||
assert vocabulary.resolve_label("trash bin") == "static.trash-bin"
|
||||
assert vocabulary.resolve_label("Dog") == "animal.dog"
|
||||
assert vocabulary.resolve_label("sidewalk-curb") == "terrain.curb"
|
||||
assert {
|
||||
label: vocabulary.resolve_label(label)
|
||||
for label in ("car", "person", "bicycle", "road sign")
|
||||
} == {
|
||||
"car": "vehicle.car",
|
||||
"person": "human.unknown",
|
||||
"bicycle": "vehicle.bicycle",
|
||||
"road sign": "static.road-sign",
|
||||
}
|
||||
assert vocabulary.resolve_label("unseen alien object") is None
|
||||
assert vocabulary.ancestors("human.child") == (
|
||||
"human.unknown",
|
||||
"object.unknown",
|
||||
)
|
||||
validate_object_understanding(_car_understanding(), vocabulary)
|
||||
|
||||
|
||||
def test_vocabulary_validation_rejects_undeclared_class_and_authority_change(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
vocabulary = load_object_semantic_vocabulary(VOCABULARY_PATH)
|
||||
value = _car_understanding()
|
||||
with pytest.raises(ObjectUnderstandingError, match="undeclared classes"):
|
||||
validate_object_understanding(
|
||||
replace(
|
||||
value,
|
||||
hypotheses=(
|
||||
replace(value.hypotheses[0], class_id="vehicle.hovercraft"),
|
||||
value.hypotheses[1],
|
||||
),
|
||||
semantic=replace(
|
||||
value.semantic,
|
||||
selected_class_id="vehicle.hovercraft",
|
||||
),
|
||||
),
|
||||
vocabulary,
|
||||
)
|
||||
|
||||
document = json.loads(VOCABULARY_PATH.read_text("utf-8"))
|
||||
incompatible = copy.deepcopy(document)
|
||||
incompatible["policies"]["planner_command_authority"] = True
|
||||
path = tmp_path / "vocabulary.json"
|
||||
path.write_text(json.dumps(incompatible), "utf-8")
|
||||
with pytest.raises(ObjectUnderstandingError, match="authority policy"):
|
||||
load_object_semantic_vocabulary(path)
|
||||
@@ -0,0 +1,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from k1link.perception.contracts import (
|
||||
BoundingRegion2D,
|
||||
EvidenceBasis,
|
||||
EvidenceCurrentness,
|
||||
MetricGeometry,
|
||||
ObstacleObservation,
|
||||
)
|
||||
from k1link.perception.object_understanding import (
|
||||
AdvisoryResponse,
|
||||
AgencyState,
|
||||
SemanticResolution,
|
||||
StateBasis,
|
||||
load_object_semantic_vocabulary,
|
||||
)
|
||||
from k1link.perception.open_vocabulary_semantics import (
|
||||
OpenVocabularyDetection,
|
||||
OpenVocabularySemanticError,
|
||||
bind_object_understandings,
|
||||
fuse_open_vocabulary_detections,
|
||||
load_open_vocabulary_semantic_profile,
|
||||
parse_tao_grounding_dino_labels,
|
||||
)
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
PROFILE_PATH = REPOSITORY_ROOT / "config/perception/open-vocabulary-semantic-shadow-v0.json"
|
||||
VOCABULARY_PATH = REPOSITORY_ROOT / "config/perception/object-semantic-vocabulary-v0.json"
|
||||
VALID_FOV_MASK = np.ones((600, 800), dtype=np.bool_)
|
||||
|
||||
|
||||
def _detection(
|
||||
detection_id: str,
|
||||
raw_label: str,
|
||||
confidence: float,
|
||||
region: tuple[float, float, float, float],
|
||||
*,
|
||||
prompt_set_id: str = "urban-static/v0",
|
||||
) -> OpenVocabularyDetection:
|
||||
return OpenVocabularyDetection(
|
||||
detection_id=detection_id,
|
||||
source_id="RAVNOVES00",
|
||||
frame_id="frame-000121",
|
||||
prompt_set_id=prompt_set_id,
|
||||
raw_label=raw_label,
|
||||
confidence=confidence,
|
||||
region=BoundingRegion2D(*region),
|
||||
)
|
||||
|
||||
|
||||
def _observation(
|
||||
proposal_id: str | None,
|
||||
*,
|
||||
ordinal: int,
|
||||
) -> ObstacleObservation:
|
||||
return ObstacleObservation(
|
||||
observation_id=f"frame-000121:observation-{ordinal}",
|
||||
occupancy_key=f"frame-000121:occupancy-{ordinal}",
|
||||
source_id="RAVNOVES00",
|
||||
frame_id="frame-000121",
|
||||
evidence_time_ns=121,
|
||||
basis=EvidenceBasis.FUSED if proposal_id else EvidenceBasis.LIDAR,
|
||||
currentness=EvidenceCurrentness.CURRENT,
|
||||
occupied_support=True,
|
||||
source_point_ids=(ordinal,),
|
||||
metric_geometry=MetricGeometry(
|
||||
coordinate_frame="map",
|
||||
centroid_xyz_m=(1.0, 2.0, 0.5),
|
||||
range_m=2.2,
|
||||
covariance_diagonal_m2=(0.1, 0.1, 0.1),
|
||||
),
|
||||
proposal_ids=(proposal_id,) if proposal_id else (),
|
||||
semantic_hint="static.trash-bin" if proposal_id else None,
|
||||
reason_codes=("test-current-occupied-support",),
|
||||
)
|
||||
|
||||
|
||||
def test_profile_is_raw_kb4_and_preserves_false_authority() -> None:
|
||||
profile = load_open_vocabulary_semantic_profile(PROFILE_PATH)
|
||||
|
||||
assert profile.coordinate_space == "raw-kb4"
|
||||
assert (profile.width, profile.height) == (800, 600)
|
||||
assert profile.provider_id == "nvidia-tao-grounding-dino-trt/v1"
|
||||
assert profile.model_sha256 == (
|
||||
"6895acdc6b588e923f753e37b3bd18869e064256e5ecc1b2b9853e8c51125f94"
|
||||
)
|
||||
assert tuple(item.prompt_set_id for item in profile.prompt_groups) == (
|
||||
"urban-static/v0",
|
||||
"urban-agents/v0",
|
||||
"urban-vehicles/v0",
|
||||
)
|
||||
|
||||
|
||||
def test_tao_parser_keeps_source_coordinates_and_rejects_rectified_boxes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
profile = load_open_vocabulary_semantic_profile(PROFILE_PATH)
|
||||
labels = tmp_path / "frame-000121.txt"
|
||||
labels.write_text(
|
||||
"trash bin 0.00 0 0.00 336.0 123.0 359.0 160.0 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.826\n",
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
parsed = parse_tao_grounding_dino_labels(
|
||||
labels,
|
||||
source_id="RAVNOVES00",
|
||||
frame_id="frame-000121",
|
||||
prompt_set_id="urban-static/v0",
|
||||
profile=profile,
|
||||
)
|
||||
|
||||
assert len(parsed) == 1
|
||||
assert parsed[0].raw_label == "trash bin"
|
||||
assert parsed[0].region.as_tuple() == (336.0, 123.0, 359.0, 160.0)
|
||||
labels.write_text(
|
||||
"trash bin 0.00 0 0.00 336.0 123.0 900.0 160.0 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.826\n",
|
||||
"utf-8",
|
||||
)
|
||||
with pytest.raises(OpenVocabularySemanticError, match="raw image coordinate space"):
|
||||
parse_tao_grounding_dino_labels(
|
||||
labels,
|
||||
source_id="RAVNOVES00",
|
||||
frame_id="frame-000121",
|
||||
prompt_set_id="urban-static/v0",
|
||||
profile=profile,
|
||||
)
|
||||
|
||||
|
||||
def test_tao_parser_translates_geometry_crop_back_to_raw_coordinates(tmp_path: Path) -> None:
|
||||
profile = load_open_vocabulary_semantic_profile(PROFILE_PATH)
|
||||
labels = tmp_path / "frame-000121-geometry-roi-000.txt"
|
||||
labels.write_text(
|
||||
"dog 0.00 0 0.00 10.0 20.0 50.0 70.0 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.714\n",
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
parsed = parse_tao_grounding_dino_labels(
|
||||
labels,
|
||||
source_id="RAVNOVES00",
|
||||
frame_id="frame-000121",
|
||||
prompt_set_id="urban-agents/v0",
|
||||
profile=profile,
|
||||
image_width=96,
|
||||
image_height=96,
|
||||
offset_x=200.0,
|
||||
offset_y=150.0,
|
||||
detection_scope_id="frame-000121:geometry-roi-000",
|
||||
)
|
||||
|
||||
assert parsed[0].detection_id.startswith("frame-000121:geometry-roi-000")
|
||||
assert parsed[0].region.as_tuple() == (210.0, 170.0, 250.0, 220.0)
|
||||
|
||||
|
||||
def test_prompt_collisions_fuse_before_geometry_and_background_box_is_removed() -> None:
|
||||
profile = load_open_vocabulary_semantic_profile(PROFILE_PATH)
|
||||
vocabulary = load_object_semantic_vocabulary(VOCABULARY_PATH)
|
||||
detections = (
|
||||
_detection("trash", "trash bin", 0.82, (300.0, 100.0, 360.0, 200.0)),
|
||||
_detection("cone", "traffic cone", 0.35, (301.0, 101.0, 361.0, 201.0)),
|
||||
_detection(
|
||||
"dog",
|
||||
"dog",
|
||||
0.71,
|
||||
(100.0, 250.0, 180.0, 340.0),
|
||||
prompt_set_id="urban-agents/v0",
|
||||
),
|
||||
_detection(
|
||||
"background",
|
||||
"concrete hemisphere",
|
||||
0.66,
|
||||
(0.0, 100.0, 800.0, 600.0),
|
||||
),
|
||||
)
|
||||
|
||||
result = fuse_open_vocabulary_detections(
|
||||
detections,
|
||||
profile=profile,
|
||||
vocabulary=vocabulary,
|
||||
valid_fov_mask=VALID_FOV_MASK,
|
||||
)
|
||||
|
||||
assert result.input_detection_count == 4
|
||||
assert result.invalid_area_count == 1
|
||||
assert len(result.bindings) == 2
|
||||
assert sum(len(item.detections) for item in result.bindings) == 3
|
||||
trash = next(
|
||||
item for item in result.bindings if item.proposal.semantic_hint == "static.trash-bin"
|
||||
)
|
||||
assert len(trash.detections) == 2
|
||||
assert trash.proposal.objectness == pytest.approx(0.82)
|
||||
|
||||
|
||||
def test_binding_keeps_ranked_semantics_separate_from_state_risk_and_occupancy() -> None:
|
||||
profile = load_open_vocabulary_semantic_profile(PROFILE_PATH)
|
||||
vocabulary = load_object_semantic_vocabulary(VOCABULARY_PATH)
|
||||
fusion = fuse_open_vocabulary_detections(
|
||||
(
|
||||
_detection("trash", "trash bin", 0.82, (300.0, 100.0, 360.0, 200.0)),
|
||||
_detection("cone", "traffic cone", 0.35, (301.0, 101.0, 361.0, 201.0)),
|
||||
),
|
||||
profile=profile,
|
||||
vocabulary=vocabulary,
|
||||
valid_fov_mask=VALID_FOV_MASK,
|
||||
)
|
||||
proposal = fusion.proposals[0]
|
||||
observations = (
|
||||
_observation(proposal.proposal_id, ordinal=1),
|
||||
_observation(None, ordinal=2),
|
||||
)
|
||||
|
||||
understandings = bind_object_understandings(
|
||||
observations,
|
||||
bindings=fusion.bindings,
|
||||
profile=profile,
|
||||
vocabulary=vocabulary,
|
||||
generated_monotonic_ns=121,
|
||||
)
|
||||
|
||||
semantic = understandings[0]
|
||||
assert tuple(item.class_id for item in semantic.hypotheses) == (
|
||||
"static.trash-bin",
|
||||
"static.traffic-cone",
|
||||
)
|
||||
assert semantic.semantic.resolution is SemanticResolution.SELECTED
|
||||
assert semantic.semantic.selected_class_id == "static.trash-bin"
|
||||
assert semantic.state.agency is AgencyState.INERT
|
||||
assert semantic.state.agency_basis is StateBasis.CLASS_PRIOR
|
||||
assert semantic.state.motion.value == "unknown"
|
||||
assert semantic.risk.level.value == "unknown"
|
||||
assert semantic.risk.responses == (AdvisoryResponse.ROUTE_AROUND,)
|
||||
assert semantic.authority.navigation_or_safety_accepted is False
|
||||
assert semantic.observation.source_point_ids == (1,)
|
||||
geometry_only = understandings[1]
|
||||
assert geometry_only.semantic.resolution is SemanticResolution.UNRESOLVED
|
||||
assert geometry_only.hypotheses == ()
|
||||
assert geometry_only.observation.source_point_ids == (2,)
|
||||
|
||||
|
||||
def test_close_semantic_scores_remain_ambiguous() -> None:
|
||||
profile = load_open_vocabulary_semantic_profile(PROFILE_PATH)
|
||||
vocabulary = load_object_semantic_vocabulary(VOCABULARY_PATH)
|
||||
fusion = fuse_open_vocabulary_detections(
|
||||
(
|
||||
_detection(
|
||||
"adult",
|
||||
"adult person",
|
||||
0.64,
|
||||
(100.0, 50.0, 200.0, 350.0),
|
||||
prompt_set_id="urban-agents/v0",
|
||||
),
|
||||
_detection(
|
||||
"child",
|
||||
"child",
|
||||
0.59,
|
||||
(101.0, 51.0, 201.0, 351.0),
|
||||
prompt_set_id="urban-agents/v0",
|
||||
),
|
||||
),
|
||||
profile=profile,
|
||||
vocabulary=vocabulary,
|
||||
valid_fov_mask=VALID_FOV_MASK,
|
||||
)
|
||||
proposal = fusion.proposals[0]
|
||||
|
||||
result = bind_object_understandings(
|
||||
(_observation(proposal.proposal_id, ordinal=1),),
|
||||
bindings=fusion.bindings,
|
||||
profile=profile,
|
||||
vocabulary=vocabulary,
|
||||
generated_monotonic_ns=121,
|
||||
)[0]
|
||||
|
||||
assert result.semantic.resolution is SemanticResolution.AMBIGUOUS
|
||||
assert result.semantic.selected_class_id is None
|
||||
assert result.state.agency is AgencyState.UNKNOWN
|
||||
assert result.state.agency_basis is StateBasis.UNKNOWN
|
||||
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.laboratory.semantic_object_quality import (
|
||||
SemanticObjectQualityError,
|
||||
SemanticTruthLabel,
|
||||
load_semantic_object_quality_profile,
|
||||
score_semantic_object_quality,
|
||||
)
|
||||
from k1link.perception.contracts import (
|
||||
EvidenceBasis,
|
||||
EvidenceCurrentness,
|
||||
MetricGeometry,
|
||||
MotionState,
|
||||
ObstacleObservation,
|
||||
)
|
||||
from k1link.perception.object_understanding import (
|
||||
AdvisoryResponse,
|
||||
AdvisoryRiskAssessment,
|
||||
AgencyState,
|
||||
EvidenceKind,
|
||||
EvidenceProvenance,
|
||||
ObjectStateEstimate,
|
||||
ObjectUnderstanding,
|
||||
RiskBasis,
|
||||
RiskLevel,
|
||||
SemanticDecision,
|
||||
SemanticHypothesis,
|
||||
SemanticResolution,
|
||||
StateBasis,
|
||||
load_object_semantic_vocabulary,
|
||||
)
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
VOCABULARY_PATH = REPOSITORY_ROOT / "config/perception/object-semantic-vocabulary-v0.json"
|
||||
PROFILE_PATH = REPOSITORY_ROOT / "config/perception/m48s-semantic-object-quality-v0.json"
|
||||
|
||||
|
||||
def _observation(index: int) -> ObstacleObservation:
|
||||
return ObstacleObservation(
|
||||
observation_id=f"observation-{index}",
|
||||
occupancy_key=f"occupied-{index}",
|
||||
source_id="RAVNOVES00",
|
||||
frame_id=f"frame-{index:06d}",
|
||||
evidence_time_ns=index * 1_000_000,
|
||||
basis=EvidenceBasis.FUSED,
|
||||
currentness=EvidenceCurrentness.CURRENT,
|
||||
occupied_support=True,
|
||||
source_point_ids=(index,),
|
||||
metric_geometry=MetricGeometry(
|
||||
coordinate_frame="map",
|
||||
centroid_xyz_m=(float(index), 0.0, 0.5),
|
||||
range_m=float(index + 1),
|
||||
covariance_diagonal_m2=(0.04, 0.04, 0.09),
|
||||
),
|
||||
proposal_ids=(f"proposal-{index}",),
|
||||
semantic_hint=None,
|
||||
reason_codes=("current-qualified-points",),
|
||||
)
|
||||
|
||||
|
||||
def _prediction(
|
||||
index: int,
|
||||
*,
|
||||
hypotheses: tuple[tuple[str, str, float], ...],
|
||||
selected_class_id: str | None,
|
||||
) -> ObjectUnderstanding:
|
||||
observation = _observation(index)
|
||||
evidence = EvidenceProvenance(
|
||||
evidence_id=f"semantic-evidence-{index}",
|
||||
kind=EvidenceKind.DETECTOR,
|
||||
source_id=observation.source_id,
|
||||
frame_id=observation.frame_id,
|
||||
provider_id="semantic-candidate/v0",
|
||||
model_id="semantic-model",
|
||||
model_revision="candidate-1",
|
||||
preprocess_id="rgb/v1",
|
||||
prompt_set_id="urban-risk-groups/v0",
|
||||
)
|
||||
ranked = tuple(
|
||||
SemanticHypothesis(
|
||||
rank=rank,
|
||||
class_id=class_id,
|
||||
raw_label=raw_label,
|
||||
confidence=confidence,
|
||||
evidence_ids=(evidence.evidence_id,),
|
||||
)
|
||||
for rank, (class_id, raw_label, confidence) in enumerate(hypotheses, start=1)
|
||||
)
|
||||
selected = next(
|
||||
(item for item in ranked if item.class_id == selected_class_id),
|
||||
None,
|
||||
)
|
||||
return ObjectUnderstanding(
|
||||
understanding_id=f"understanding-{index}",
|
||||
vocabulary_id="missioncore.urban-object-semantics/v0",
|
||||
generated_monotonic_ns=index * 1_000_000 + 1,
|
||||
observation=observation,
|
||||
hypotheses=ranked,
|
||||
semantic=SemanticDecision(
|
||||
resolution=(
|
||||
SemanticResolution.SELECTED
|
||||
if selected is not None
|
||||
else SemanticResolution.UNRESOLVED
|
||||
),
|
||||
selected_class_id=None if selected is None else selected.class_id,
|
||||
selected_confidence=None if selected is None else selected.confidence,
|
||||
reason_codes=(
|
||||
"top-hypothesis-qualified"
|
||||
if selected is not None
|
||||
else "semantic-evidence-insufficient",
|
||||
),
|
||||
),
|
||||
state=ObjectStateEstimate(
|
||||
motion=MotionState.UNKNOWN,
|
||||
motion_confidence=0.0,
|
||||
agency=AgencyState.UNKNOWN,
|
||||
agency_basis=StateBasis.UNKNOWN,
|
||||
evidence_ids=(),
|
||||
reason_codes=("motion-evidence-unavailable",),
|
||||
),
|
||||
risk=AdvisoryRiskAssessment(
|
||||
policy_id="urban-object-risk/v0",
|
||||
level=RiskLevel.UNKNOWN,
|
||||
confidence=0.0,
|
||||
basis=RiskBasis.UNKNOWN,
|
||||
responses=(AdvisoryResponse.ROUTE_AROUND,),
|
||||
evidence_ids=(),
|
||||
reason_codes=("semantic-quality-does-not-score-risk",),
|
||||
),
|
||||
provenance=(evidence,),
|
||||
)
|
||||
|
||||
|
||||
def _truth(index: int, class_id: str) -> SemanticTruthLabel:
|
||||
return SemanticTruthLabel(
|
||||
label_id=f"semantic-label-{index}",
|
||||
observation_id=f"observation-{index}",
|
||||
source_id="RAVNOVES00",
|
||||
frame_id=f"frame-{index:06d}",
|
||||
class_id=class_id,
|
||||
reviewer_count=2,
|
||||
adjudicated=True,
|
||||
)
|
||||
|
||||
|
||||
def test_semantic_quality_scores_exact_group_topk_and_unresolved_separately() -> None:
|
||||
vocabulary = load_object_semantic_vocabulary(VOCABULARY_PATH)
|
||||
profile = load_semantic_object_quality_profile(PROFILE_PATH)
|
||||
predictions = (
|
||||
_prediction(
|
||||
1,
|
||||
hypotheses=(
|
||||
("human.unknown", "person", 0.70),
|
||||
("human.child", "child", 0.65),
|
||||
),
|
||||
selected_class_id="human.unknown",
|
||||
),
|
||||
_prediction(
|
||||
2,
|
||||
hypotheses=(("animal.dog", "dog", 0.80),),
|
||||
selected_class_id="animal.dog",
|
||||
),
|
||||
_prediction(3, hypotheses=(), selected_class_id=None),
|
||||
)
|
||||
truth = (
|
||||
_truth(1, "human.child"),
|
||||
_truth(2, "animal.dog"),
|
||||
_truth(3, "vehicle.car"),
|
||||
)
|
||||
|
||||
result = score_semantic_object_quality(
|
||||
predictions=predictions,
|
||||
truth=truth,
|
||||
vocabulary=vocabulary,
|
||||
profile=profile,
|
||||
)
|
||||
|
||||
metrics = result.report["metrics"]
|
||||
assert isinstance(metrics, dict)
|
||||
assert metrics["prediction_coverage"] == pytest.approx(1.0)
|
||||
assert metrics["exact_top1_accuracy"] == pytest.approx(1 / 3)
|
||||
assert metrics["coarse_group_accuracy"] == pytest.approx(2 / 3)
|
||||
assert metrics["exact_top_k_recall"] == pytest.approx(2 / 3)
|
||||
assert metrics["unresolved_fraction"] == pytest.approx(1 / 3)
|
||||
assert result.report["candidate_semantic_gate_passed"] is False
|
||||
assert result.cases[0].exact_top1_correct is False
|
||||
assert result.cases[0].coarse_group_correct is True
|
||||
assert result.cases[0].exact_top_k_hit is True
|
||||
|
||||
|
||||
def test_semantic_quality_reports_missing_projection_without_calling_it_detection() -> None:
|
||||
vocabulary = load_object_semantic_vocabulary(VOCABULARY_PATH)
|
||||
profile = load_semantic_object_quality_profile(PROFILE_PATH)
|
||||
result = score_semantic_object_quality(
|
||||
predictions=(
|
||||
_prediction(
|
||||
1,
|
||||
hypotheses=(("human.child", "child", 0.90),),
|
||||
selected_class_id="human.child",
|
||||
),
|
||||
),
|
||||
truth=(_truth(1, "human.child"), _truth(2, "animal.dog")),
|
||||
vocabulary=vocabulary,
|
||||
profile=profile,
|
||||
)
|
||||
|
||||
metrics = result.report["metrics"]
|
||||
assert isinstance(metrics, dict)
|
||||
assert metrics["prediction_coverage"] == pytest.approx(0.5)
|
||||
assert result.cases[1].resolution == "unavailable"
|
||||
scope = result.report["scope"]
|
||||
assert isinstance(scope, dict)
|
||||
assert scope["object_presence_scored"] is False
|
||||
|
||||
|
||||
def test_semantic_truth_is_strict_and_requires_independent_adjudication() -> None:
|
||||
label = _truth(1, "human.child")
|
||||
document = json.loads(json.dumps(label.to_dict()))
|
||||
assert SemanticTruthLabel.from_dict(document) == label
|
||||
document["category"] = "child"
|
||||
with pytest.raises(SemanticObjectQualityError, match="fields are incompatible"):
|
||||
SemanticTruthLabel.from_dict(document)
|
||||
with pytest.raises(SemanticObjectQualityError, match="two independent reviewers"):
|
||||
SemanticTruthLabel(
|
||||
label_id="semantic-label-1",
|
||||
observation_id="observation-1",
|
||||
source_id="RAVNOVES00",
|
||||
frame_id="frame-000001",
|
||||
class_id="human.child",
|
||||
reviewer_count=1,
|
||||
adjudicated=True,
|
||||
)
|
||||
with pytest.raises(SemanticObjectQualityError, match="must be adjudicated"):
|
||||
SemanticTruthLabel(
|
||||
label_id="semantic-label-1",
|
||||
observation_id="observation-1",
|
||||
source_id="RAVNOVES00",
|
||||
frame_id="frame-000001",
|
||||
class_id="human.child",
|
||||
reviewer_count=2,
|
||||
adjudicated=False,
|
||||
)
|
||||
|
||||
|
||||
def test_semantic_quality_profile_stays_separate_from_presence_and_risk(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
profile = load_semantic_object_quality_profile(PROFILE_PATH)
|
||||
assert profile.profile_id == "m48s-urban-semantic-object-quality/v0"
|
||||
assert profile.vocabulary_id == "missioncore.urban-object-semantics/v0"
|
||||
assert profile.top_k == 5
|
||||
|
||||
document = json.loads(PROFILE_PATH.read_text("utf-8"))
|
||||
document["scope"]["object_presence_scored"] = True
|
||||
changed = tmp_path / "m48s-profile-changed.json"
|
||||
changed.write_text(json.dumps(document), "utf-8")
|
||||
with pytest.raises(SemanticObjectQualityError, match="scope changed"):
|
||||
load_semantic_object_quality_profile(changed)
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from k1link.perception.semantic_shadow_replay import read_semantic_shadow_replay
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
RESULT_ROOT = (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime/compute-experiments/m48s-semantic-shadow/results"
|
||||
/ "m48s-semantic-shadow-a237a8860bd4655b4fae981ba23a5e4d86a560ed243ee7efdf867d87f5071739"
|
||||
)
|
||||
|
||||
|
||||
def test_bounded_raw_kb4_semantic_shadow_is_complete_but_not_accepted() -> None:
|
||||
result = read_semantic_shadow_replay(RESULT_ROOT)
|
||||
|
||||
assert result.completed is True
|
||||
assert result.accepted is False
|
||||
assert result.metrics["frames"] == {
|
||||
"completed": 11,
|
||||
"requested": 11,
|
||||
"source_available": 11,
|
||||
}
|
||||
assert result.metrics["detections"] == {
|
||||
"below_confidence": 0,
|
||||
"fused_proposals": 164,
|
||||
"invalid_area": 16,
|
||||
"outside_valid_fov": 0,
|
||||
"raw": 310,
|
||||
"retained": 294,
|
||||
}
|
||||
semantics = result.metrics["semantics"]
|
||||
assert isinstance(semantics, dict)
|
||||
assert semantics["resolution_counts"] == {
|
||||
"ambiguous": 4,
|
||||
"selected": 40,
|
||||
"unresolved": 165,
|
||||
}
|
||||
assert result.report["decision"] == {
|
||||
"navigation_or_safety_accepted": False,
|
||||
"next_gate": "M48S human-reviewed semantic object quality slice",
|
||||
"raw_kb4_coordinate_binding_completed": True,
|
||||
"risk_policy_accepted": False,
|
||||
"semantic_quality_accepted": False,
|
||||
}
|
||||
assert result.report["authority"]["commands_enabled"] is False
|
||||
Reference in New Issue
Block a user