feat(perception): define semantic object understanding
This commit is contained in:
@@ -0,0 +1,454 @@
|
||||
"""Separate semantic-class evaluation over geometry-bound object projections.
|
||||
|
||||
This companion contour does not mutate the class-free M4.8 truth or score
|
||||
object presence. It evaluates canonical semantic resolution only after a
|
||||
separate two-reviewer, adjudicated semantic label set exists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from k1link.perception.object_understanding import (
|
||||
ObjectSemanticVocabulary,
|
||||
ObjectUnderstanding,
|
||||
SemanticResolution,
|
||||
validate_object_understanding,
|
||||
)
|
||||
|
||||
SEMANTIC_TRUTH_LABEL_SCHEMA: Final = "missioncore.semantic-object-truth-label/v0"
|
||||
SEMANTIC_QUALITY_PROFILE_SCHEMA: Final = "missioncore.semantic-object-quality-profile/v0"
|
||||
SEMANTIC_QUALITY_REPORT_SCHEMA: Final = "missioncore.semantic-object-quality-report/v0"
|
||||
|
||||
_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$")
|
||||
_FALSE_AUTHORITY: Final = {
|
||||
"physical_live": False,
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
|
||||
|
||||
class SemanticObjectQualityError(ValueError):
|
||||
"""A semantic truth, profile or evaluation input is incompatible."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticTruthLabel:
|
||||
"""One independently reviewed canonical class bound to existing geometry."""
|
||||
|
||||
label_id: str
|
||||
observation_id: str
|
||||
source_id: str
|
||||
frame_id: str
|
||||
class_id: str
|
||||
reviewer_count: int
|
||||
adjudicated: bool
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for value, label in (
|
||||
(self.label_id, "semantic truth label id"),
|
||||
(self.observation_id, "semantic truth observation id"),
|
||||
(self.source_id, "semantic truth source id"),
|
||||
(self.frame_id, "semantic truth frame id"),
|
||||
(self.class_id, "semantic truth class id"),
|
||||
):
|
||||
_identifier(value, label)
|
||||
if (
|
||||
not isinstance(self.reviewer_count, int)
|
||||
or isinstance(self.reviewer_count, bool)
|
||||
or self.reviewer_count < 2
|
||||
):
|
||||
raise SemanticObjectQualityError("semantic truth requires two independent reviewers")
|
||||
if self.adjudicated is not True:
|
||||
raise SemanticObjectQualityError("semantic truth must be adjudicated")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": SEMANTIC_TRUTH_LABEL_SCHEMA,
|
||||
"label_id": self.label_id,
|
||||
"observation_id": self.observation_id,
|
||||
"source_id": self.source_id,
|
||||
"frame_id": self.frame_id,
|
||||
"class_id": self.class_id,
|
||||
"reviewer_count": self.reviewer_count,
|
||||
"adjudicated": self.adjudicated,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> SemanticTruthLabel:
|
||||
document = _object(value, "semantic truth label")
|
||||
_exact_keys(
|
||||
document,
|
||||
{
|
||||
"schema_version",
|
||||
"label_id",
|
||||
"observation_id",
|
||||
"source_id",
|
||||
"frame_id",
|
||||
"class_id",
|
||||
"reviewer_count",
|
||||
"adjudicated",
|
||||
},
|
||||
"semantic truth label",
|
||||
)
|
||||
if document.get("schema_version") != SEMANTIC_TRUTH_LABEL_SCHEMA:
|
||||
raise SemanticObjectQualityError("semantic truth label schema is incompatible")
|
||||
return cls(
|
||||
label_id=_string(document, "label_id"),
|
||||
observation_id=_string(document, "observation_id"),
|
||||
source_id=_string(document, "source_id"),
|
||||
frame_id=_string(document, "frame_id"),
|
||||
class_id=_string(document, "class_id"),
|
||||
reviewer_count=_integer(document, "reviewer_count"),
|
||||
adjudicated=_boolean(document, "adjudicated"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticObjectQualityProfile:
|
||||
"""Bounded evaluation policy for a frozen semantic provider candidate."""
|
||||
|
||||
profile_id: str
|
||||
vocabulary_id: str
|
||||
top_k: int
|
||||
minimum_prediction_coverage: float
|
||||
minimum_exact_top1_accuracy: float
|
||||
minimum_coarse_group_accuracy: float
|
||||
minimum_exact_top_k_recall: float
|
||||
maximum_unresolved_fraction: float
|
||||
maximum_conflict_fraction: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_identifier(self.profile_id, "semantic quality profile id")
|
||||
_identifier(self.vocabulary_id, "semantic quality vocabulary id")
|
||||
if (
|
||||
not isinstance(self.top_k, int)
|
||||
or isinstance(self.top_k, bool)
|
||||
or not 1 <= self.top_k <= 5
|
||||
):
|
||||
raise SemanticObjectQualityError("semantic quality top-k is invalid")
|
||||
for value, label in (
|
||||
(self.minimum_prediction_coverage, "minimum prediction coverage"),
|
||||
(self.minimum_exact_top1_accuracy, "minimum exact top-1 accuracy"),
|
||||
(self.minimum_coarse_group_accuracy, "minimum coarse group accuracy"),
|
||||
(self.minimum_exact_top_k_recall, "minimum exact top-k recall"),
|
||||
(self.maximum_unresolved_fraction, "maximum unresolved fraction"),
|
||||
(self.maximum_conflict_fraction, "maximum conflict fraction"),
|
||||
):
|
||||
_fraction(value, label)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticQualityCase:
|
||||
"""One deterministic truth/prediction comparison row."""
|
||||
|
||||
label_id: str
|
||||
observation_id: str
|
||||
truth_class_id: str
|
||||
resolution: str
|
||||
selected_class_id: str | None
|
||||
exact_top1_correct: bool
|
||||
coarse_group_correct: bool
|
||||
exact_top_k_hit: bool
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"label_id": self.label_id,
|
||||
"observation_id": self.observation_id,
|
||||
"truth_class_id": self.truth_class_id,
|
||||
"resolution": self.resolution,
|
||||
"selected_class_id": self.selected_class_id,
|
||||
"exact_top1_correct": self.exact_top1_correct,
|
||||
"coarse_group_correct": self.coarse_group_correct,
|
||||
"exact_top_k_hit": self.exact_top_k_hit,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticObjectQualityResult:
|
||||
"""In-memory deterministic semantic report and comparison ledger."""
|
||||
|
||||
report: dict[str, object]
|
||||
cases: tuple[SemanticQualityCase, ...]
|
||||
|
||||
|
||||
def load_semantic_object_quality_profile(path: Path) -> SemanticObjectQualityProfile:
|
||||
"""Read a strict semantic evaluation profile."""
|
||||
|
||||
try:
|
||||
document = json.loads(path.expanduser().resolve(strict=True).read_text("utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise SemanticObjectQualityError("semantic quality profile cannot be read") from exc
|
||||
root = _object(document, "semantic quality profile")
|
||||
_exact_keys(
|
||||
root,
|
||||
{
|
||||
"schema_version",
|
||||
"profile_id",
|
||||
"vocabulary_id",
|
||||
"top_k",
|
||||
"thresholds",
|
||||
"scope",
|
||||
"authority",
|
||||
},
|
||||
"semantic quality profile",
|
||||
)
|
||||
if root.get("schema_version") != SEMANTIC_QUALITY_PROFILE_SCHEMA:
|
||||
raise SemanticObjectQualityError("semantic quality profile schema is incompatible")
|
||||
scope = _object(root.get("scope"), "semantic quality scope")
|
||||
_exact_keys(
|
||||
scope,
|
||||
{
|
||||
"object_presence_scored",
|
||||
"semantic_class_scored",
|
||||
"risk_policy_scored",
|
||||
"requires_separate_adjudicated_semantic_truth",
|
||||
},
|
||||
"semantic quality scope",
|
||||
)
|
||||
if scope != {
|
||||
"object_presence_scored": False,
|
||||
"semantic_class_scored": True,
|
||||
"risk_policy_scored": False,
|
||||
"requires_separate_adjudicated_semantic_truth": True,
|
||||
}:
|
||||
raise SemanticObjectQualityError("semantic quality scope changed")
|
||||
if _object(root.get("authority"), "semantic quality authority") != _FALSE_AUTHORITY:
|
||||
raise SemanticObjectQualityError("semantic quality authority changed")
|
||||
thresholds = _object(root.get("thresholds"), "semantic quality thresholds")
|
||||
_exact_keys(
|
||||
thresholds,
|
||||
{
|
||||
"minimum_prediction_coverage",
|
||||
"minimum_exact_top1_accuracy",
|
||||
"minimum_coarse_group_accuracy",
|
||||
"minimum_exact_top_k_recall",
|
||||
"maximum_unresolved_fraction",
|
||||
"maximum_conflict_fraction",
|
||||
},
|
||||
"semantic quality thresholds",
|
||||
)
|
||||
return SemanticObjectQualityProfile(
|
||||
profile_id=_string(root, "profile_id"),
|
||||
vocabulary_id=_string(root, "vocabulary_id"),
|
||||
top_k=_integer(root, "top_k"),
|
||||
minimum_prediction_coverage=_number(thresholds, "minimum_prediction_coverage"),
|
||||
minimum_exact_top1_accuracy=_number(thresholds, "minimum_exact_top1_accuracy"),
|
||||
minimum_coarse_group_accuracy=_number(thresholds, "minimum_coarse_group_accuracy"),
|
||||
minimum_exact_top_k_recall=_number(thresholds, "minimum_exact_top_k_recall"),
|
||||
maximum_unresolved_fraction=_number(thresholds, "maximum_unresolved_fraction"),
|
||||
maximum_conflict_fraction=_number(thresholds, "maximum_conflict_fraction"),
|
||||
)
|
||||
|
||||
|
||||
def score_semantic_object_quality(
|
||||
*,
|
||||
predictions: tuple[ObjectUnderstanding, ...],
|
||||
truth: tuple[SemanticTruthLabel, ...],
|
||||
vocabulary: ObjectSemanticVocabulary,
|
||||
profile: SemanticObjectQualityProfile,
|
||||
) -> SemanticObjectQualityResult:
|
||||
"""Score semantics on geometry-bound truth without scoring object presence."""
|
||||
|
||||
if not truth or any(not isinstance(item, SemanticTruthLabel) for item in truth):
|
||||
raise SemanticObjectQualityError("semantic truth set is invalid")
|
||||
if any(not isinstance(item, ObjectUnderstanding) for item in predictions):
|
||||
raise SemanticObjectQualityError("semantic prediction set is invalid")
|
||||
if profile.vocabulary_id != vocabulary.vocabulary_id:
|
||||
raise SemanticObjectQualityError("semantic quality vocabulary changed")
|
||||
truth_ids = tuple(item.observation_id for item in truth)
|
||||
if len(set(truth_ids)) != len(truth_ids):
|
||||
raise SemanticObjectQualityError("semantic truth observations must be unique")
|
||||
prediction_ids = tuple(item.observation.observation_id for item in predictions)
|
||||
if len(set(prediction_ids)) != len(prediction_ids):
|
||||
raise SemanticObjectQualityError("semantic prediction observations must be unique")
|
||||
declared_classes = {item.class_id for item in vocabulary.classes}
|
||||
if any(item.class_id not in declared_classes for item in truth):
|
||||
raise SemanticObjectQualityError("semantic truth uses an undeclared class")
|
||||
for candidate in predictions:
|
||||
validate_object_understanding(candidate, vocabulary)
|
||||
|
||||
by_observation = {item.observation.observation_id: item for item in predictions}
|
||||
cases: list[SemanticQualityCase] = []
|
||||
predicted_count = 0
|
||||
selected_count = 0
|
||||
conflict_count = 0
|
||||
for label in truth:
|
||||
prediction = by_observation.get(label.observation_id)
|
||||
if prediction is None:
|
||||
cases.append(
|
||||
SemanticQualityCase(
|
||||
label_id=label.label_id,
|
||||
observation_id=label.observation_id,
|
||||
truth_class_id=label.class_id,
|
||||
resolution="unavailable",
|
||||
selected_class_id=None,
|
||||
exact_top1_correct=False,
|
||||
coarse_group_correct=False,
|
||||
exact_top_k_hit=False,
|
||||
)
|
||||
)
|
||||
continue
|
||||
predicted_count += 1
|
||||
observation = prediction.observation
|
||||
if observation.source_id != label.source_id or observation.frame_id != label.frame_id:
|
||||
raise SemanticObjectQualityError("semantic prediction escaped its truth source frame")
|
||||
selected = prediction.semantic.selected_class_id
|
||||
if prediction.semantic.resolution is SemanticResolution.SELECTED:
|
||||
selected_count += 1
|
||||
if prediction.semantic.resolution is SemanticResolution.CONFLICT:
|
||||
conflict_count += 1
|
||||
exact = selected == label.class_id
|
||||
coarse = selected is not None and _coarse_group(selected, vocabulary) == _coarse_group(
|
||||
label.class_id, vocabulary
|
||||
)
|
||||
top_k_ids = {item.class_id for item in prediction.hypotheses[: profile.top_k]}
|
||||
cases.append(
|
||||
SemanticQualityCase(
|
||||
label_id=label.label_id,
|
||||
observation_id=label.observation_id,
|
||||
truth_class_id=label.class_id,
|
||||
resolution=prediction.semantic.resolution.value,
|
||||
selected_class_id=selected,
|
||||
exact_top1_correct=exact,
|
||||
coarse_group_correct=coarse,
|
||||
exact_top_k_hit=label.class_id in top_k_ids,
|
||||
)
|
||||
)
|
||||
|
||||
truth_count = len(truth)
|
||||
exact_accuracy = sum(item.exact_top1_correct for item in cases) / truth_count
|
||||
coarse_accuracy = sum(item.coarse_group_correct for item in cases) / truth_count
|
||||
top_k_recall = sum(item.exact_top_k_hit for item in cases) / truth_count
|
||||
prediction_coverage = predicted_count / truth_count
|
||||
unresolved_fraction = (truth_count - selected_count) / truth_count
|
||||
conflict_fraction = conflict_count / truth_count
|
||||
gates = {
|
||||
"prediction_coverage": (prediction_coverage >= profile.minimum_prediction_coverage),
|
||||
"exact_top1_accuracy": (exact_accuracy >= profile.minimum_exact_top1_accuracy),
|
||||
"coarse_group_accuracy": (coarse_accuracy >= profile.minimum_coarse_group_accuracy),
|
||||
"exact_top_k_recall": (top_k_recall >= profile.minimum_exact_top_k_recall),
|
||||
"unresolved_fraction": (unresolved_fraction <= profile.maximum_unresolved_fraction),
|
||||
"conflict_fraction": (conflict_fraction <= profile.maximum_conflict_fraction),
|
||||
}
|
||||
report: dict[str, object] = {
|
||||
"schema_version": SEMANTIC_QUALITY_REPORT_SCHEMA,
|
||||
"profile_id": profile.profile_id,
|
||||
"vocabulary_id": vocabulary.vocabulary_id,
|
||||
"scope": {
|
||||
"object_presence_scored": False,
|
||||
"semantic_class_scored": True,
|
||||
"risk_policy_scored": False,
|
||||
},
|
||||
"metrics": {
|
||||
"truth_count": truth_count,
|
||||
"prediction_count": predicted_count,
|
||||
"selected_count": selected_count,
|
||||
"conflict_count": conflict_count,
|
||||
"prediction_coverage": prediction_coverage,
|
||||
"exact_top1_accuracy": exact_accuracy,
|
||||
"coarse_group_accuracy": coarse_accuracy,
|
||||
"exact_top_k_recall": top_k_recall,
|
||||
"unresolved_fraction": unresolved_fraction,
|
||||
"conflict_fraction": conflict_fraction,
|
||||
},
|
||||
"gates": gates,
|
||||
"candidate_semantic_gate_passed": all(gates.values()),
|
||||
"limitations": [
|
||||
"Object presence and geometry quality remain owned by the class-free contour.",
|
||||
"Semantic qualification does not qualify risk policy or physical motion.",
|
||||
"No navigation, safety, command or actuation authority is granted.",
|
||||
],
|
||||
"authority": dict(_FALSE_AUTHORITY),
|
||||
}
|
||||
return SemanticObjectQualityResult(report=report, cases=tuple(cases))
|
||||
|
||||
|
||||
def _coarse_group(
|
||||
class_id: str,
|
||||
vocabulary: ObjectSemanticVocabulary,
|
||||
) -> str:
|
||||
ancestors = vocabulary.ancestors(class_id)
|
||||
if not ancestors:
|
||||
return class_id
|
||||
non_root = tuple(item for item in ancestors if item != "object.unknown")
|
||||
return non_root[-1] if non_root else class_id
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, object]:
|
||||
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
||||
raise SemanticObjectQualityError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _exact_keys(document: dict[str, object], expected: set[str], label: str) -> None:
|
||||
if set(document) != expected:
|
||||
raise SemanticObjectQualityError(f"{label} fields are incompatible")
|
||||
|
||||
|
||||
def _string(document: dict[str, object], key: str) -> str:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise SemanticObjectQualityError(f"{key} must be a nonempty string")
|
||||
return value
|
||||
|
||||
|
||||
def _integer(document: dict[str, object], key: str) -> int:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise SemanticObjectQualityError(f"{key} must be an integer")
|
||||
return value
|
||||
|
||||
|
||||
def _boolean(document: dict[str, object], key: str) -> bool:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, bool):
|
||||
raise SemanticObjectQualityError(f"{key} must be boolean")
|
||||
return value
|
||||
|
||||
|
||||
def _number(document: dict[str, object], key: str) -> float:
|
||||
value = document.get(key)
|
||||
if (
|
||||
not isinstance(value, (int, float))
|
||||
or isinstance(value, bool)
|
||||
or not math.isfinite(float(value))
|
||||
):
|
||||
raise SemanticObjectQualityError(f"{key} must be finite")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _fraction(value: object, label: str) -> float:
|
||||
if (
|
||||
not isinstance(value, (int, float))
|
||||
or isinstance(value, bool)
|
||||
or not math.isfinite(float(value))
|
||||
or not 0.0 <= float(value) <= 1.0
|
||||
):
|
||||
raise SemanticObjectQualityError(f"{label} must be within [0, 1]")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _identifier(value: str, label: str) -> None:
|
||||
if not isinstance(value, str) or _IDENTIFIER.fullmatch(value) is None:
|
||||
raise SemanticObjectQualityError(f"{label} is not a safe identifier")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SEMANTIC_QUALITY_PROFILE_SCHEMA",
|
||||
"SEMANTIC_QUALITY_REPORT_SCHEMA",
|
||||
"SEMANTIC_TRUTH_LABEL_SCHEMA",
|
||||
"SemanticObjectQualityError",
|
||||
"SemanticObjectQualityProfile",
|
||||
"SemanticObjectQualityResult",
|
||||
"SemanticQualityCase",
|
||||
"SemanticTruthLabel",
|
||||
"load_semantic_object_quality_profile",
|
||||
"score_semantic_object_quality",
|
||||
]
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Geometry-first raw-KB4 regions for semantic classification shadows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from .contracts import BoundingRegion2D, ObstacleObservation
|
||||
from .geometry import GeometryFrame
|
||||
from .geometry_math import project_map_points_kb4
|
||||
from .open_vocabulary_semantics import OpenVocabularyDetection, SemanticFusionResult
|
||||
|
||||
|
||||
class GeometrySemanticRoiError(ValueError):
|
||||
"""A geometry-owned semantic ROI or crop is incompatible."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GeometrySemanticRoiProfile:
|
||||
minimum_projected_points: int = 4
|
||||
minimum_crop_width: int = 96
|
||||
minimum_crop_height: int = 96
|
||||
padding_fraction: float = 0.25
|
||||
minimum_padding_pixels: int = 16
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
self.minimum_projected_points < 2
|
||||
or self.minimum_crop_width < 32
|
||||
or self.minimum_crop_height < 32
|
||||
or not math.isfinite(self.padding_fraction)
|
||||
or not 0.0 <= self.padding_fraction <= 1.0
|
||||
or self.minimum_padding_pixels < 0
|
||||
):
|
||||
raise GeometrySemanticRoiError("geometry semantic ROI profile is invalid")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GeometrySemanticRoi:
|
||||
roi_id: str
|
||||
frame_index: int
|
||||
observation: ObstacleObservation
|
||||
core_region: BoundingRegion2D
|
||||
crop_region: BoundingRegion2D
|
||||
projected_point_count: int
|
||||
crop_name: str
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"roi_id": self.roi_id,
|
||||
"frame_index": self.frame_index,
|
||||
"observation": self.observation.to_dict(),
|
||||
"core_region": self.core_region.to_dict(),
|
||||
"crop_region": self.crop_region.to_dict(),
|
||||
"projected_point_count": self.projected_point_count,
|
||||
"crop_name": self.crop_name,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GeometrySemanticRoiFrame:
|
||||
frame_index: int
|
||||
rois: tuple[GeometrySemanticRoi, ...]
|
||||
not_projected_observations: tuple[ObstacleObservation, ...]
|
||||
|
||||
|
||||
def build_geometry_semantic_rois(
|
||||
*,
|
||||
frame: GeometryFrame,
|
||||
observations: tuple[ObstacleObservation, ...],
|
||||
profile: GeometrySemanticRoiProfile | None = None,
|
||||
) -> GeometrySemanticRoiFrame:
|
||||
"""Project geometry-owned point identities into bounded semantic crops."""
|
||||
|
||||
selected_profile = profile or GeometrySemanticRoiProfile()
|
||||
if frame.frame_index < 0 or not frame.surface_valid:
|
||||
raise GeometrySemanticRoiError("geometry semantic ROI frame is unavailable")
|
||||
if any(
|
||||
item.frame_id != f"frame-{frame.frame_index:06d}"
|
||||
or item.metric_geometry is None
|
||||
or not item.occupied_support
|
||||
for item in observations
|
||||
):
|
||||
raise GeometrySemanticRoiError("geometry semantic ROI observations are incompatible")
|
||||
projected = project_map_points_kb4(
|
||||
frame.points_map,
|
||||
position_map_xyz=frame.sensor_position_map,
|
||||
orientation_map_from_lidar_xyzw=frame.sensor_orientation_xyzw,
|
||||
profile=frame.projection,
|
||||
)
|
||||
rois: list[GeometrySemanticRoi] = []
|
||||
missing: list[ObstacleObservation] = []
|
||||
for observation in observations:
|
||||
source_ids = np.asarray(observation.source_point_ids, dtype=np.int64)
|
||||
selected = np.isin(projected.source_indices, source_ids)
|
||||
pixels = projected.pixels_xy[selected]
|
||||
if pixels.shape[0] < selected_profile.minimum_projected_points:
|
||||
missing.append(observation)
|
||||
continue
|
||||
x_min, y_min = pixels.min(axis=0)
|
||||
x_max, y_max = pixels.max(axis=0)
|
||||
core = _nonempty_region(
|
||||
float(x_min),
|
||||
float(y_min),
|
||||
float(x_max),
|
||||
float(y_max),
|
||||
width=frame.projection.width,
|
||||
height=frame.projection.height,
|
||||
)
|
||||
crop = _crop_region(
|
||||
core,
|
||||
width=frame.projection.width,
|
||||
height=frame.projection.height,
|
||||
profile=selected_profile,
|
||||
)
|
||||
ordinal = len(rois)
|
||||
roi_id = f"{observation.frame_id}:geometry-roi-{ordinal:03d}"
|
||||
rois.append(
|
||||
GeometrySemanticRoi(
|
||||
roi_id=roi_id,
|
||||
frame_index=frame.frame_index,
|
||||
observation=observation,
|
||||
core_region=core,
|
||||
crop_region=crop,
|
||||
projected_point_count=int(pixels.shape[0]),
|
||||
crop_name=(
|
||||
f"frame-{frame.frame_index:06d}-geometry-roi-{ordinal:03d}.png"
|
||||
),
|
||||
)
|
||||
)
|
||||
return GeometrySemanticRoiFrame(
|
||||
frame_index=frame.frame_index,
|
||||
rois=tuple(rois),
|
||||
not_projected_observations=tuple(missing),
|
||||
)
|
||||
|
||||
|
||||
def materialize_geometry_semantic_crop(
|
||||
*,
|
||||
image_path: Path,
|
||||
roi: GeometrySemanticRoi,
|
||||
destination: Path,
|
||||
) -> None:
|
||||
"""Write one lossless crop while retaining raw-image coordinate lineage."""
|
||||
|
||||
try:
|
||||
with Image.open(image_path.resolve(strict=True)) as opened:
|
||||
if opened.size != (800, 600):
|
||||
raise GeometrySemanticRoiError("semantic crop source raster changed")
|
||||
image = opened.convert("RGB")
|
||||
crop = image.crop(_integer_box(roi.crop_region))
|
||||
except OSError as exc:
|
||||
raise GeometrySemanticRoiError("semantic crop source cannot be read") from exc
|
||||
target = destination.resolve()
|
||||
if target.exists() or target.name != roi.crop_name:
|
||||
raise GeometrySemanticRoiError("semantic crop destination is invalid")
|
||||
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
crop.save(target, format="PNG", optimize=False)
|
||||
|
||||
|
||||
def select_geometry_roi_detections(
|
||||
roi: GeometrySemanticRoi,
|
||||
fusion: SemanticFusionResult,
|
||||
) -> tuple[OpenVocabularyDetection, ...]:
|
||||
"""Select the semantic cluster covering the geometry-owned core, if any."""
|
||||
|
||||
center_x = (roi.core_region.x_min + roi.core_region.x_max) / 2.0
|
||||
center_y = (roi.core_region.y_min + roi.core_region.y_max) / 2.0
|
||||
candidates = []
|
||||
for binding in fusion.bindings:
|
||||
region = binding.proposal.region
|
||||
center_inside = (
|
||||
region.x_min <= center_x <= region.x_max
|
||||
and region.y_min <= center_y <= region.y_max
|
||||
)
|
||||
core_coverage = _intersection_area(region, roi.core_region) / _area(roi.core_region)
|
||||
if center_inside or core_coverage >= 0.25:
|
||||
candidates.append((binding.proposal.objectness, core_coverage, binding))
|
||||
if not candidates:
|
||||
return ()
|
||||
selected = max(candidates, key=lambda item: (item[0], item[1], item[2].proposal.proposal_id))
|
||||
return selected[2].detections
|
||||
|
||||
|
||||
def _nonempty_region(
|
||||
x_min: float,
|
||||
y_min: float,
|
||||
x_max: float,
|
||||
y_max: float,
|
||||
*,
|
||||
width: int,
|
||||
height: int,
|
||||
) -> BoundingRegion2D:
|
||||
if x_max - x_min < 1.0:
|
||||
center = (x_min + x_max) / 2.0
|
||||
x_min, x_max = center - 0.5, center + 0.5
|
||||
if y_max - y_min < 1.0:
|
||||
center = (y_min + y_max) / 2.0
|
||||
y_min, y_max = center - 0.5, center + 0.5
|
||||
x_min = max(0.0, min(x_min, width - 1.0))
|
||||
y_min = max(0.0, min(y_min, height - 1.0))
|
||||
x_max = min(float(width), max(x_max, x_min + 1.0))
|
||||
y_max = min(float(height), max(y_max, y_min + 1.0))
|
||||
return BoundingRegion2D(x_min, y_min, x_max, y_max)
|
||||
|
||||
|
||||
def _crop_region(
|
||||
core: BoundingRegion2D,
|
||||
*,
|
||||
width: int,
|
||||
height: int,
|
||||
profile: GeometrySemanticRoiProfile,
|
||||
) -> BoundingRegion2D:
|
||||
core_width = core.x_max - core.x_min
|
||||
core_height = core.y_max - core.y_min
|
||||
padding_x = max(profile.minimum_padding_pixels, core_width * profile.padding_fraction)
|
||||
padding_y = max(profile.minimum_padding_pixels, core_height * profile.padding_fraction)
|
||||
target_width = min(
|
||||
width,
|
||||
max(profile.minimum_crop_width, math.ceil(core_width + 2.0 * padding_x)),
|
||||
)
|
||||
target_height = min(
|
||||
height,
|
||||
max(profile.minimum_crop_height, math.ceil(core_height + 2.0 * padding_y)),
|
||||
)
|
||||
center_x = (core.x_min + core.x_max) / 2.0
|
||||
center_y = (core.y_min + core.y_max) / 2.0
|
||||
x_min = int(round(center_x - target_width / 2.0))
|
||||
y_min = int(round(center_y - target_height / 2.0))
|
||||
x_min = min(max(0, x_min), width - target_width)
|
||||
y_min = min(max(0, y_min), height - target_height)
|
||||
return BoundingRegion2D(
|
||||
float(x_min),
|
||||
float(y_min),
|
||||
float(x_min + target_width),
|
||||
float(y_min + target_height),
|
||||
)
|
||||
|
||||
|
||||
def _integer_box(region: BoundingRegion2D) -> tuple[int, int, int, int]:
|
||||
return (
|
||||
int(region.x_min),
|
||||
int(region.y_min),
|
||||
int(region.x_max),
|
||||
int(region.y_max),
|
||||
)
|
||||
|
||||
|
||||
def _area(region: BoundingRegion2D) -> float:
|
||||
return (region.x_max - region.x_min) * (region.y_max - region.y_min)
|
||||
|
||||
|
||||
def _intersection_area(left: BoundingRegion2D, right: BoundingRegion2D) -> float:
|
||||
return max(0.0, min(left.x_max, right.x_max) - max(left.x_min, right.x_min)) * max(
|
||||
0.0,
|
||||
min(left.y_max, right.y_max) - max(left.y_min, right.y_min),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GeometrySemanticRoi",
|
||||
"GeometrySemanticRoiError",
|
||||
"GeometrySemanticRoiFrame",
|
||||
"GeometrySemanticRoiProfile",
|
||||
"build_geometry_semantic_rois",
|
||||
"materialize_geometry_semantic_crop",
|
||||
"select_geometry_roi_detections",
|
||||
]
|
||||
@@ -0,0 +1,580 @@
|
||||
"""Immutable geometry-first M48S semantic shadow replay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import uuid
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from .detector_replay_result import (
|
||||
read_detector_replay_result,
|
||||
require_m4_detector_replay_acceptance,
|
||||
)
|
||||
from .geometry import Ravnoves00GeometryAssociationProvider, RecordedGeometryStore
|
||||
from .geometry_semantic_roi import (
|
||||
GeometrySemanticRoi,
|
||||
build_geometry_semantic_rois,
|
||||
select_geometry_roi_detections,
|
||||
)
|
||||
from .graph_validation import validate_observations
|
||||
from .object_understanding import (
|
||||
ObjectUnderstanding,
|
||||
SemanticResolution,
|
||||
load_object_semantic_vocabulary,
|
||||
)
|
||||
from .open_vocabulary_semantics import (
|
||||
OpenVocabularyDetection,
|
||||
fuse_open_vocabulary_detections,
|
||||
load_open_vocabulary_semantic_profile,
|
||||
parse_tao_grounding_dino_labels,
|
||||
understand_geometry_observation,
|
||||
)
|
||||
from .semantic_shadow_replay import semantic_replay_packet
|
||||
from .yolox_object_detector import load_valid_fov_mask
|
||||
|
||||
GEOMETRY_SEMANTIC_SHADOW_REPLAY_SCHEMA: Final = (
|
||||
"missioncore.m48s-geometry-semantic-shadow-replay/v0"
|
||||
)
|
||||
GEOMETRY_SEMANTIC_SHADOW_FRAME_SCHEMA: Final = (
|
||||
"missioncore.m48s-geometry-semantic-shadow-frame/v0"
|
||||
)
|
||||
GEOMETRY_SEMANTIC_ROI_PACKAGE_SCHEMA: Final = (
|
||||
"missioncore.m48s-geometry-semantic-roi-package/v0"
|
||||
)
|
||||
RESULT_PREFIX: Final = "m48s-geometry-semantic-shadow-"
|
||||
|
||||
|
||||
class GeometrySemanticShadowReplayError(RuntimeError):
|
||||
"""Geometry-first semantic evidence is incomplete or incompatible."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GeometrySemanticShadowReplayResult:
|
||||
result_id: str
|
||||
result_root: Path
|
||||
completed: bool
|
||||
accepted: bool
|
||||
metrics: dict[str, object]
|
||||
report: dict[str, object]
|
||||
manifest: dict[str, object]
|
||||
|
||||
|
||||
def build_geometry_semantic_shadow_replay(
|
||||
*,
|
||||
repository_root: Path,
|
||||
profile_path: Path,
|
||||
vocabulary_path: Path,
|
||||
detector_result_root: Path,
|
||||
roi_package_root: Path,
|
||||
valid_fov_mask_path: Path,
|
||||
worker_result_roots: Mapping[str, Path],
|
||||
frame_indices: tuple[int, ...],
|
||||
worker_execution: Mapping[str, object],
|
||||
output_root: Path,
|
||||
) -> GeometrySemanticShadowReplayResult:
|
||||
"""Name geometry-owned obstacles while leaving occupancy and authority untouched."""
|
||||
|
||||
repository = repository_root.resolve(strict=True)
|
||||
profile = load_open_vocabulary_semantic_profile(profile_path)
|
||||
vocabulary = load_object_semantic_vocabulary(vocabulary_path)
|
||||
if vocabulary.vocabulary_id != profile.vocabulary_id:
|
||||
raise GeometrySemanticShadowReplayError("semantic profile and vocabulary disagree")
|
||||
frames = _validate_frame_indices(frame_indices)
|
||||
roi_root = roi_package_root.resolve(strict=True)
|
||||
roi_manifest = _read_object(roi_root / "manifest.json", "ROI package manifest")
|
||||
roi_identity = _validate_roi_manifest(roi_manifest, frames=frames)
|
||||
crop_names = tuple(
|
||||
_string(_object(roi, "ROI"), "crop_name")
|
||||
for frame in _array(roi_identity, "frames")
|
||||
for roi in _array(_object(frame, "ROI frame"), "rois")
|
||||
)
|
||||
_validate_worker_results(worker_result_roots, profile=profile, crop_names=crop_names)
|
||||
valid_fov_mask = load_valid_fov_mask(
|
||||
valid_fov_mask_path,
|
||||
expected_sha256=profile.valid_fov_mask_sha256,
|
||||
)
|
||||
detector = read_detector_replay_result(detector_result_root)
|
||||
require_m4_detector_replay_acceptance(detector)
|
||||
detector_by_sequence = {item.sequence: item for item in detector.frames}
|
||||
if any(index not in detector_by_sequence for index in frames):
|
||||
raise GeometrySemanticShadowReplayError("semantic frame escaped detector timeline")
|
||||
store = RecordedGeometryStore.from_repository(repository)
|
||||
geometry = Ravnoves00GeometryAssociationProvider(store=store)
|
||||
|
||||
output = output_root.expanduser().absolute()
|
||||
output.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
staging = output / f".geometry-semantic-shadow.{uuid.uuid4().hex}.tmp"
|
||||
staging.mkdir(mode=0o700, exist_ok=False)
|
||||
totals: Counter[str] = Counter()
|
||||
resolution_counts: Counter[str] = Counter()
|
||||
class_counts: Counter[str] = Counter()
|
||||
try:
|
||||
frames_path = staging / "frames.jsonl"
|
||||
with frames_path.open("wb") as ledger:
|
||||
for frame_index in frames:
|
||||
detector_frame = detector_by_sequence[frame_index]
|
||||
if detector_frame.outcome != "completed":
|
||||
raise GeometrySemanticShadowReplayError("accepted detector frame failed")
|
||||
packet = semantic_replay_packet(detector_frame.envelope)
|
||||
observations = geometry.associate(packet, ())
|
||||
validate_observations(packet, (), observations)
|
||||
geometry_frame = store.frame(packet)
|
||||
if geometry_frame is None:
|
||||
raise GeometrySemanticShadowReplayError("selected geometry frame unavailable")
|
||||
roi_frame = build_geometry_semantic_rois(
|
||||
frame=geometry_frame,
|
||||
observations=observations,
|
||||
)
|
||||
stored_frame = _roi_manifest_frame(roi_identity, frame_index)
|
||||
if [item.to_dict() for item in roi_frame.rois] != [
|
||||
_roi_without_materialization(_object(item, "stored ROI"))
|
||||
for item in _array(stored_frame, "rois")
|
||||
]:
|
||||
raise GeometrySemanticShadowReplayError("stored ROI geometry changed")
|
||||
understandings_by_observation: dict[str, ObjectUnderstanding] = {}
|
||||
roi_documents: list[dict[str, object]] = []
|
||||
for roi in roi_frame.rois:
|
||||
detections = _roi_detections(
|
||||
roi,
|
||||
worker_result_roots=worker_result_roots,
|
||||
profile=profile,
|
||||
)
|
||||
fusion = fuse_open_vocabulary_detections(
|
||||
detections,
|
||||
profile=profile,
|
||||
vocabulary=vocabulary,
|
||||
valid_fov_mask=valid_fov_mask,
|
||||
)
|
||||
selected = select_geometry_roi_detections(roi, fusion)
|
||||
understanding = understand_geometry_observation(
|
||||
roi.observation,
|
||||
detections=selected,
|
||||
profile=profile,
|
||||
vocabulary=vocabulary,
|
||||
generated_monotonic_ns=detector_frame.envelope.timestamps.monotonic_ns,
|
||||
)
|
||||
understandings_by_observation[roi.observation.observation_id] = understanding
|
||||
totals["roi_count"] += 1
|
||||
totals["raw_detection_count"] += len(detections)
|
||||
totals["below_confidence_count"] += fusion.below_confidence_count
|
||||
totals["invalid_area_count"] += fusion.invalid_area_count
|
||||
totals["outside_valid_fov_count"] += fusion.outside_valid_fov_count
|
||||
totals["retained_detection_count"] += fusion.retained_detection_count
|
||||
totals["fused_proposal_count"] += len(fusion.proposals)
|
||||
totals["selected_cluster_detection_count"] += len(selected)
|
||||
totals["roi_with_selected_cluster_count"] += bool(selected)
|
||||
roi_documents.append(
|
||||
{
|
||||
"roi": roi.to_dict(),
|
||||
"detections": [_detection_document(item) for item in detections],
|
||||
"fused_proposals": [item.to_dict() for item in fusion.proposals],
|
||||
"selected_detection_ids": [item.detection_id for item in selected],
|
||||
"understanding": understanding.to_dict(),
|
||||
}
|
||||
)
|
||||
for observation in roi_frame.not_projected_observations:
|
||||
understandings_by_observation[observation.observation_id] = (
|
||||
understand_geometry_observation(
|
||||
observation,
|
||||
detections=(),
|
||||
profile=profile,
|
||||
vocabulary=vocabulary,
|
||||
generated_monotonic_ns=(
|
||||
detector_frame.envelope.timestamps.monotonic_ns
|
||||
),
|
||||
)
|
||||
)
|
||||
totals["not_projected_observation_count"] += 1
|
||||
ordered = tuple(
|
||||
understandings_by_observation[item.observation_id] for item in observations
|
||||
)
|
||||
if len(ordered) != len(observations):
|
||||
raise GeometrySemanticShadowReplayError("geometry obstacle accounting changed")
|
||||
for understanding in ordered:
|
||||
resolution_counts[understanding.semantic.resolution.value] += 1
|
||||
if (
|
||||
understanding.semantic.resolution is SemanticResolution.SELECTED
|
||||
and understanding.semantic.selected_class_id is not None
|
||||
):
|
||||
class_counts[understanding.semantic.selected_class_id] += 1
|
||||
totals["geometry_observation_count"] += len(observations)
|
||||
frame_document = {
|
||||
"schema_version": GEOMETRY_SEMANTIC_SHADOW_FRAME_SCHEMA,
|
||||
"frame_index": frame_index,
|
||||
"frame_id": detector_frame.envelope.frame_id,
|
||||
"geometry_observation_count": len(observations),
|
||||
"roi_count": len(roi_frame.rois),
|
||||
"not_projected_observation_count": len(
|
||||
roi_frame.not_projected_observations
|
||||
),
|
||||
"rois": roi_documents,
|
||||
"understandings": [item.to_dict() for item in ordered],
|
||||
"authority": _false_authority(),
|
||||
}
|
||||
ledger.write(_canonical_json(frame_document) + b"\n")
|
||||
metrics: dict[str, object] = {
|
||||
"frames": {"requested": len(frames), "completed": len(frames)},
|
||||
"geometry": {
|
||||
"observation_count": totals["geometry_observation_count"],
|
||||
"roi_count": totals["roi_count"],
|
||||
"not_projected_observation_count": totals[
|
||||
"not_projected_observation_count"
|
||||
],
|
||||
},
|
||||
"semantic_inference": {
|
||||
"raw_detection_count": totals["raw_detection_count"],
|
||||
"below_confidence_count": totals["below_confidence_count"],
|
||||
"invalid_area_count": totals["invalid_area_count"],
|
||||
"outside_valid_fov_count": totals["outside_valid_fov_count"],
|
||||
"retained_detection_count": totals["retained_detection_count"],
|
||||
"fused_proposal_count": totals["fused_proposal_count"],
|
||||
"roi_with_selected_cluster_count": totals[
|
||||
"roi_with_selected_cluster_count"
|
||||
],
|
||||
"selected_cluster_detection_count": totals[
|
||||
"selected_cluster_detection_count"
|
||||
],
|
||||
},
|
||||
"semantics": {
|
||||
"resolution_counts": dict(sorted(resolution_counts.items())),
|
||||
"selected_class_counts": dict(sorted(class_counts.items())),
|
||||
},
|
||||
"authority": _false_authority(),
|
||||
}
|
||||
completed = (
|
||||
totals["geometry_observation_count"]
|
||||
== totals["roi_count"] + totals["not_projected_observation_count"]
|
||||
and sum(resolution_counts.values()) == totals["geometry_observation_count"]
|
||||
)
|
||||
identity = {
|
||||
"schema_version": GEOMETRY_SEMANTIC_SHADOW_REPLAY_SCHEMA,
|
||||
"profile_id": profile.profile_id,
|
||||
"profile_sha256": _sha256(profile_path),
|
||||
"vocabulary_id": vocabulary.vocabulary_id,
|
||||
"vocabulary_sha256": _sha256(vocabulary_path),
|
||||
"detector_result_id": detector.result_id,
|
||||
"roi_package_id": _string(roi_manifest, "package_id"),
|
||||
"roi_package_sha256": _sha256(roi_root / "manifest.json"),
|
||||
"frame_indices": list(frames),
|
||||
"worker_execution": dict(worker_execution),
|
||||
"worker_artifacts": _worker_artifacts(worker_result_roots, profile, crop_names),
|
||||
"producer_sha256": {
|
||||
name: _sha256(repository / "src/k1link/perception" / name)
|
||||
for name in (
|
||||
"geometry_semantic_roi.py",
|
||||
"geometry_semantic_shadow_replay.py",
|
||||
"object_understanding.py",
|
||||
"open_vocabulary_semantics.py",
|
||||
)
|
||||
},
|
||||
"frames_sha256": _sha256(frames_path),
|
||||
"metrics": metrics,
|
||||
"completed": completed,
|
||||
"accepted": False,
|
||||
"authority": _false_authority(),
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
result_id = f"{RESULT_PREFIX}{identity_sha256}"
|
||||
report = {
|
||||
"schema_version": GEOMETRY_SEMANTIC_SHADOW_REPLAY_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"created_at_utc": datetime.now(UTC).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00", "Z"
|
||||
),
|
||||
"completed": completed,
|
||||
"accepted": False,
|
||||
"metrics": metrics,
|
||||
"decision": {
|
||||
"geometry_first_binding_completed": completed,
|
||||
"semantic_quality_accepted": False,
|
||||
"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",
|
||||
],
|
||||
},
|
||||
"authority": _false_authority(),
|
||||
}
|
||||
manifest: dict[str, object] = {
|
||||
"schema_version": GEOMETRY_SEMANTIC_SHADOW_REPLAY_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"identity_sha256": identity_sha256,
|
||||
"identity": identity,
|
||||
"artifacts": {
|
||||
"frames.jsonl": _sha256(frames_path),
|
||||
"report.json": hashlib.sha256(_canonical_json(report) + b"\n").hexdigest(),
|
||||
},
|
||||
}
|
||||
(staging / "report.json").write_bytes(_canonical_json(report) + b"\n")
|
||||
(staging / "manifest.json").write_bytes(_canonical_json(manifest) + b"\n")
|
||||
destination = output / result_id
|
||||
if destination.exists():
|
||||
shutil.rmtree(staging)
|
||||
else:
|
||||
staging.rename(destination)
|
||||
except Exception:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
return GeometrySemanticShadowReplayResult(
|
||||
result_id=result_id,
|
||||
result_root=destination,
|
||||
completed=completed,
|
||||
accepted=False,
|
||||
metrics=metrics,
|
||||
report=report,
|
||||
manifest=manifest,
|
||||
)
|
||||
|
||||
|
||||
def read_geometry_semantic_shadow_replay(
|
||||
result_root: Path,
|
||||
) -> GeometrySemanticShadowReplayResult:
|
||||
"""Read and revalidate one immutable geometry-first semantic result."""
|
||||
|
||||
root = result_root.resolve(strict=True)
|
||||
manifest = _read_object(root / "manifest.json", "geometry semantic manifest")
|
||||
report = _read_object(root / "report.json", "geometry semantic report")
|
||||
identity = _object(manifest.get("identity"), "geometry semantic identity")
|
||||
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
result_id = f"{RESULT_PREFIX}{identity_sha256}"
|
||||
artifacts = _object(manifest.get("artifacts"), "geometry semantic artifacts")
|
||||
if (
|
||||
manifest.get("schema_version") != GEOMETRY_SEMANTIC_SHADOW_REPLAY_SCHEMA
|
||||
or manifest.get("identity_sha256") != identity_sha256
|
||||
or manifest.get("result_id") != result_id
|
||||
or root.name != result_id
|
||||
or report.get("schema_version") != GEOMETRY_SEMANTIC_SHADOW_REPLAY_SCHEMA
|
||||
or report.get("result_id") != result_id
|
||||
or artifacts
|
||||
!= {
|
||||
"frames.jsonl": _sha256(root / "frames.jsonl"),
|
||||
"report.json": _sha256(root / "report.json"),
|
||||
}
|
||||
):
|
||||
raise GeometrySemanticShadowReplayError("geometry semantic result identity changed")
|
||||
metrics = _object(report.get("metrics"), "geometry semantic metrics")
|
||||
completed = report.get("completed")
|
||||
accepted = report.get("accepted")
|
||||
if not isinstance(completed, bool) or not isinstance(accepted, bool):
|
||||
raise GeometrySemanticShadowReplayError("geometry semantic result state changed")
|
||||
if identity.get("metrics") != metrics or identity.get("completed") != completed:
|
||||
raise GeometrySemanticShadowReplayError("geometry semantic result accounting changed")
|
||||
return GeometrySemanticShadowReplayResult(
|
||||
result_id=result_id,
|
||||
result_root=root,
|
||||
completed=completed,
|
||||
accepted=accepted,
|
||||
metrics=metrics,
|
||||
report=report,
|
||||
manifest=manifest,
|
||||
)
|
||||
|
||||
|
||||
def _roi_detections(
|
||||
roi: GeometrySemanticRoi,
|
||||
*,
|
||||
worker_result_roots: Mapping[str, Path],
|
||||
profile: object,
|
||||
) -> tuple[OpenVocabularyDetection, ...]:
|
||||
from .open_vocabulary_semantics import OpenVocabularySemanticProfile
|
||||
|
||||
if not isinstance(profile, OpenVocabularySemanticProfile):
|
||||
raise GeometrySemanticShadowReplayError("semantic profile is incompatible")
|
||||
width = int(roi.crop_region.x_max - roi.crop_region.x_min)
|
||||
height = int(roi.crop_region.y_max - roi.crop_region.y_min)
|
||||
detections: list[OpenVocabularyDetection] = []
|
||||
for group in profile.prompt_groups:
|
||||
labels = (
|
||||
worker_result_roots[group.prompt_set_id].resolve(strict=True)
|
||||
/ "labels"
|
||||
/ roi.crop_name.replace(".png", ".txt")
|
||||
)
|
||||
detections.extend(
|
||||
parse_tao_grounding_dino_labels(
|
||||
labels,
|
||||
source_id=profile.source_id,
|
||||
frame_id=roi.observation.frame_id,
|
||||
prompt_set_id=group.prompt_set_id,
|
||||
profile=profile,
|
||||
image_width=width,
|
||||
image_height=height,
|
||||
offset_x=roi.crop_region.x_min,
|
||||
offset_y=roi.crop_region.y_min,
|
||||
detection_scope_id=roi.roi_id,
|
||||
)
|
||||
)
|
||||
return tuple(detections)
|
||||
|
||||
|
||||
def _validate_worker_results(
|
||||
roots: Mapping[str, Path],
|
||||
*,
|
||||
profile: object,
|
||||
crop_names: tuple[str, ...],
|
||||
) -> None:
|
||||
from .open_vocabulary_semantics import OpenVocabularySemanticProfile
|
||||
|
||||
if not isinstance(profile, OpenVocabularySemanticProfile):
|
||||
raise GeometrySemanticShadowReplayError("semantic profile is incompatible")
|
||||
if set(roots) != {item.prompt_set_id for item in profile.prompt_groups}:
|
||||
raise GeometrySemanticShadowReplayError("Worker prompt roots changed")
|
||||
expected = {item.replace(".png", ".txt") for item in crop_names}
|
||||
for root in roots.values():
|
||||
resolved = root.resolve(strict=True)
|
||||
actual = {item.name for item in (resolved / "labels").glob("*.txt")}
|
||||
if actual != expected:
|
||||
raise GeometrySemanticShadowReplayError("Worker label coverage changed")
|
||||
status = (resolved / "status.json").read_text("utf-8")
|
||||
if '"status": "SUCCESS"' not in status:
|
||||
raise GeometrySemanticShadowReplayError("Worker semantic run did not succeed")
|
||||
|
||||
|
||||
def _worker_artifacts(
|
||||
roots: Mapping[str, Path],
|
||||
profile: object,
|
||||
crop_names: tuple[str, ...],
|
||||
) -> dict[str, object]:
|
||||
from .open_vocabulary_semantics import OpenVocabularySemanticProfile
|
||||
|
||||
if not isinstance(profile, OpenVocabularySemanticProfile):
|
||||
raise GeometrySemanticShadowReplayError("semantic profile is incompatible")
|
||||
artifacts: dict[str, object] = {}
|
||||
for group in profile.prompt_groups:
|
||||
root = roots[group.prompt_set_id].resolve(strict=True)
|
||||
labels = [
|
||||
{
|
||||
"name": crop_name.replace(".png", ".txt"),
|
||||
"sha256": _sha256(root / "labels" / crop_name.replace(".png", ".txt")),
|
||||
}
|
||||
for crop_name in crop_names
|
||||
]
|
||||
artifacts[group.prompt_set_id] = {
|
||||
"status_sha256": _sha256(root / "status.json"),
|
||||
"experiment_sha256": _sha256(root / "experiment.yaml"),
|
||||
"labels": labels,
|
||||
}
|
||||
return artifacts
|
||||
|
||||
|
||||
def _validate_roi_manifest(
|
||||
manifest: dict[str, object],
|
||||
*,
|
||||
frames: tuple[int, ...],
|
||||
) -> dict[str, object]:
|
||||
if manifest.get("schema_version") != GEOMETRY_SEMANTIC_ROI_PACKAGE_SCHEMA:
|
||||
raise GeometrySemanticShadowReplayError("ROI package schema changed")
|
||||
identity = _object(manifest.get("identity"), "ROI identity")
|
||||
if identity.get("frame_indices") != list(frames):
|
||||
raise GeometrySemanticShadowReplayError("ROI frame selection changed")
|
||||
if identity.get("roi_count") != 58 or identity.get("geometry_observation_count") != 73:
|
||||
raise GeometrySemanticShadowReplayError("bounded ROI package accounting changed")
|
||||
digest = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
if manifest.get("identity_sha256") != digest or manifest.get("package_id") != (
|
||||
f"m48s-geometry-semantic-rois-{digest}"
|
||||
):
|
||||
raise GeometrySemanticShadowReplayError("ROI package identity changed")
|
||||
return identity
|
||||
|
||||
|
||||
def _roi_manifest_frame(identity: dict[str, object], frame_index: int) -> dict[str, object]:
|
||||
matches = [
|
||||
_object(item, "ROI frame")
|
||||
for item in _array(identity, "frames")
|
||||
if _object(item, "ROI frame").get("frame_index") == frame_index
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise GeometrySemanticShadowReplayError("ROI frame manifest is ambiguous")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _roi_without_materialization(value: dict[str, object]) -> dict[str, object]:
|
||||
return {
|
||||
key: item
|
||||
for key, item in value.items()
|
||||
if key not in {"crop_sha256", "crop_width", "crop_height"}
|
||||
}
|
||||
|
||||
|
||||
def _validate_frame_indices(value: tuple[int, ...]) -> tuple[int, ...]:
|
||||
if not value or tuple(sorted(set(value))) != value or any(item < 0 for item in value):
|
||||
raise GeometrySemanticShadowReplayError("semantic frame selection is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _detection_document(value: OpenVocabularyDetection) -> dict[str, object]:
|
||||
return {
|
||||
"detection_id": value.detection_id,
|
||||
"source_id": value.source_id,
|
||||
"frame_id": value.frame_id,
|
||||
"prompt_set_id": value.prompt_set_id,
|
||||
"raw_label": value.raw_label,
|
||||
"confidence": value.confidence,
|
||||
"region": value.region.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
def _read_object(path: Path, label: str) -> dict[str, object]:
|
||||
try:
|
||||
value = json.loads(path.resolve(strict=True).read_text("utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise GeometrySemanticShadowReplayError(f"{label} cannot be read") from exc
|
||||
return _object(value, label)
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, object]:
|
||||
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
||||
raise GeometrySemanticShadowReplayError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _array(value: dict[str, object], key: str) -> list[object]:
|
||||
item = value.get(key)
|
||||
if not isinstance(item, list):
|
||||
raise GeometrySemanticShadowReplayError(f"{key} must be an array")
|
||||
return item
|
||||
|
||||
|
||||
def _string(value: dict[str, object], key: str) -> str:
|
||||
item = value.get(key)
|
||||
if not isinstance(item, str) or not item:
|
||||
raise GeometrySemanticShadowReplayError(f"{key} must be a nonempty string")
|
||||
return item
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.resolve(strict=True).open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _false_authority() -> dict[str, bool]:
|
||||
return {
|
||||
"ground_truth": False,
|
||||
"independent_truth": False,
|
||||
"candidate_accepted": False,
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GeometrySemanticShadowReplayError",
|
||||
"GeometrySemanticShadowReplayResult",
|
||||
"build_geometry_semantic_shadow_replay",
|
||||
"read_geometry_semantic_shadow_replay",
|
||||
]
|
||||
@@ -0,0 +1,436 @@
|
||||
"""Fail-closed Mask Grounding DINO evidence and geometry binding.
|
||||
|
||||
The mask model is allowed to suggest instance masks and class hypotheses. It
|
||||
cannot create occupied geometry, and contradictory captions for one pixel mask
|
||||
remain explicitly ambiguous.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from .contracts import ObstacleObservation
|
||||
from .geometry_math import ProjectedPointCloud
|
||||
|
||||
MASK_GROUNDING_EVIDENCE_SCHEMA: Final = (
|
||||
"missioncore.m48s-mask-grounding-dino-evidence/v0"
|
||||
)
|
||||
|
||||
BoolArray = npt.NDArray[np.bool_]
|
||||
|
||||
|
||||
class MaskGroundingSemanticError(ValueError):
|
||||
"""Mask semantic evidence is malformed or cannot be bound safely."""
|
||||
|
||||
|
||||
class MaskBindingResolution(StrEnum):
|
||||
SELECTED = "selected"
|
||||
AMBIGUOUS = "ambiguous"
|
||||
UNRESOLVED = "unresolved"
|
||||
|
||||
|
||||
class MaskLabelResolution(StrEnum):
|
||||
SELECTED = "selected"
|
||||
AMBIGUOUS = "ambiguous"
|
||||
UNRESOLVED = "unresolved"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MaskGroundingDetection:
|
||||
detection_id: str
|
||||
prompt_set_id: str
|
||||
class_id: int
|
||||
class_name: str
|
||||
confidence: float
|
||||
box_xyxy: tuple[float, float, float, float]
|
||||
mask_sha256: str
|
||||
mask: BoolArray
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
not self.detection_id
|
||||
or not self.prompt_set_id
|
||||
or not self.class_name
|
||||
or self.class_id < 0
|
||||
or not math.isfinite(self.confidence)
|
||||
or not 0.0 <= self.confidence <= 1.0
|
||||
or len(self.box_xyxy) != 4
|
||||
or not np.isfinite(self.box_xyxy).all()
|
||||
or self.box_xyxy[2] <= self.box_xyxy[0]
|
||||
or self.box_xyxy[3] <= self.box_xyxy[1]
|
||||
or self.mask.shape != (600, 800)
|
||||
or not self.mask.any()
|
||||
or len(self.mask_sha256) != 64
|
||||
):
|
||||
raise MaskGroundingSemanticError("mask grounding detection is invalid")
|
||||
frozen = np.asarray(self.mask, dtype=np.bool_).copy()
|
||||
frozen.setflags(write=False)
|
||||
object.__setattr__(self, "mask", frozen)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MaskGroundingEvidence:
|
||||
source_file_sha256: str
|
||||
source_pixel_sha256: str
|
||||
detections: tuple[MaskGroundingDetection, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MaskInstance:
|
||||
instance_id: str
|
||||
detections: tuple[MaskGroundingDetection, ...]
|
||||
|
||||
@property
|
||||
def ranked_labels(self) -> tuple[tuple[str, float], ...]:
|
||||
confidence_by_label: dict[str, float] = {}
|
||||
for item in self.detections:
|
||||
confidence_by_label[item.class_name] = max(
|
||||
item.confidence,
|
||||
confidence_by_label.get(item.class_name, 0.0),
|
||||
)
|
||||
return tuple(
|
||||
sorted(
|
||||
confidence_by_label.items(),
|
||||
key=lambda item: (-item[1], item[0]),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MaskObservationSupport:
|
||||
observation_id: str
|
||||
projected_point_count: int
|
||||
support_fraction: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MaskGeometryBinding:
|
||||
instance_id: str
|
||||
resolution: MaskBindingResolution
|
||||
selected_observation_id: str | None
|
||||
supports: tuple[MaskObservationSupport, ...]
|
||||
reason_code: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MaskLabelDecision:
|
||||
instance_id: str
|
||||
resolution: MaskLabelResolution
|
||||
selected_label: str | None
|
||||
ranked_labels: tuple[tuple[str, float], ...]
|
||||
reason_code: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MaskGeometryBindingProfile:
|
||||
minimum_mask_iou: float = 0.9
|
||||
minimum_projected_points: int = 4
|
||||
minimum_support_fraction: float = 0.5
|
||||
maximum_secondary_support_fraction: float = 0.25
|
||||
minimum_label_confidence: float = 0.3
|
||||
minimum_label_margin: float = 0.1
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
fractions = (
|
||||
self.minimum_mask_iou,
|
||||
self.minimum_support_fraction,
|
||||
self.maximum_secondary_support_fraction,
|
||||
self.minimum_label_confidence,
|
||||
self.minimum_label_margin,
|
||||
)
|
||||
if (
|
||||
self.minimum_projected_points < 1
|
||||
or not all(math.isfinite(item) and 0.0 <= item <= 1.0 for item in fractions)
|
||||
or self.maximum_secondary_support_fraction
|
||||
>= self.minimum_support_fraction
|
||||
):
|
||||
raise MaskGroundingSemanticError("mask geometry binding profile is invalid")
|
||||
|
||||
|
||||
def load_mask_grounding_evidence(
|
||||
path: Path,
|
||||
*,
|
||||
prompt_set_id: str,
|
||||
) -> MaskGroundingEvidence:
|
||||
"""Load one lossless Worker ledger without allowing object arrays."""
|
||||
|
||||
if not prompt_set_id:
|
||||
raise MaskGroundingSemanticError("mask prompt set id is required")
|
||||
try:
|
||||
source = path.resolve(strict=True)
|
||||
with np.load(source, allow_pickle=False) as archive:
|
||||
expected = {
|
||||
"schema_version",
|
||||
"source_file_sha256",
|
||||
"source_pixel_sha256",
|
||||
"class_ids",
|
||||
"class_names",
|
||||
"scores",
|
||||
"boxes_xyxy",
|
||||
"masks",
|
||||
}
|
||||
if set(archive.files) != expected:
|
||||
raise MaskGroundingSemanticError("mask evidence fields changed")
|
||||
schema = str(archive["schema_version"].item())
|
||||
source_file_sha256 = str(archive["source_file_sha256"].item())
|
||||
source_pixel_sha256 = str(archive["source_pixel_sha256"].item())
|
||||
class_ids = np.asarray(archive["class_ids"], dtype=np.int64)
|
||||
class_names = np.asarray(archive["class_names"])
|
||||
scores = np.asarray(archive["scores"], dtype=np.float64)
|
||||
boxes = np.asarray(archive["boxes_xyxy"], dtype=np.float64)
|
||||
masks = np.asarray(archive["masks"], dtype=np.uint8)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise MaskGroundingSemanticError("mask evidence cannot be read") from exc
|
||||
if schema != MASK_GROUNDING_EVIDENCE_SCHEMA:
|
||||
raise MaskGroundingSemanticError("mask evidence schema changed")
|
||||
count = int(class_ids.size)
|
||||
if count == 0 and boxes.shape == (0,):
|
||||
# Early v0 shadow ledgers encoded an empty list before the writer was
|
||||
# tightened to (0, 4). No values are inferred by this normalization.
|
||||
boxes = boxes.reshape((0, 4))
|
||||
if (
|
||||
len(source_file_sha256) != 64
|
||||
or len(source_pixel_sha256) != 64
|
||||
or class_ids.shape != (count,)
|
||||
or class_names.shape != (count,)
|
||||
or scores.shape != (count,)
|
||||
or boxes.shape != (count, 4)
|
||||
or masks.shape != (count, 600, 800)
|
||||
or np.any((masks != 0) & (masks != 1))
|
||||
):
|
||||
raise MaskGroundingSemanticError("mask evidence tensors are incompatible")
|
||||
detections = []
|
||||
for index in range(count):
|
||||
mask = masks[index].astype(np.bool_)
|
||||
mask_sha256 = hashlib.sha256(masks[index].tobytes()).hexdigest()
|
||||
detections.append(
|
||||
MaskGroundingDetection(
|
||||
detection_id=f"{path.stem}:{prompt_set_id}:{index:04d}",
|
||||
prompt_set_id=prompt_set_id,
|
||||
class_id=int(class_ids[index]),
|
||||
class_name=str(class_names[index]),
|
||||
confidence=float(scores[index]),
|
||||
box_xyxy=tuple(float(item) for item in boxes[index]), # type: ignore[arg-type]
|
||||
mask_sha256=mask_sha256,
|
||||
mask=mask,
|
||||
)
|
||||
)
|
||||
return MaskGroundingEvidence(
|
||||
source_file_sha256=source_file_sha256,
|
||||
source_pixel_sha256=source_pixel_sha256,
|
||||
detections=tuple(detections),
|
||||
)
|
||||
|
||||
|
||||
def cluster_mask_instances(
|
||||
detections: tuple[MaskGroundingDetection, ...],
|
||||
*,
|
||||
profile: MaskGeometryBindingProfile | None = None,
|
||||
) -> tuple[MaskInstance, ...]:
|
||||
"""Collapse caption duplicates into deterministic pixel-owned instances."""
|
||||
|
||||
selected_profile = profile or MaskGeometryBindingProfile()
|
||||
ordered = tuple(sorted(detections, key=lambda item: item.detection_id))
|
||||
parent = list(range(len(ordered)))
|
||||
|
||||
def find(index: int) -> int:
|
||||
while parent[index] != index:
|
||||
parent[index] = parent[parent[index]]
|
||||
index = parent[index]
|
||||
return index
|
||||
|
||||
def union(left: int, right: int) -> None:
|
||||
left_root, right_root = find(left), find(right)
|
||||
if left_root != right_root:
|
||||
parent[max(left_root, right_root)] = min(left_root, right_root)
|
||||
|
||||
for left in range(len(ordered)):
|
||||
for right in range(left):
|
||||
if _mask_iou(ordered[left].mask, ordered[right].mask) >= (
|
||||
selected_profile.minimum_mask_iou
|
||||
):
|
||||
union(left, right)
|
||||
grouped: dict[int, list[MaskGroundingDetection]] = defaultdict(list)
|
||||
for index, detection in enumerate(ordered):
|
||||
grouped[find(index)].append(detection)
|
||||
instances = []
|
||||
for ordinal, members in enumerate(grouped.values()):
|
||||
member_ids = "\n".join(item.detection_id for item in members).encode()
|
||||
digest = hashlib.sha256(member_ids).hexdigest()[:16]
|
||||
instances.append(
|
||||
MaskInstance(
|
||||
instance_id=f"mask-instance-{ordinal:03d}-{digest}",
|
||||
detections=tuple(members),
|
||||
)
|
||||
)
|
||||
return tuple(instances)
|
||||
|
||||
|
||||
def bind_mask_instances_to_geometry(
|
||||
instances: tuple[MaskInstance, ...],
|
||||
*,
|
||||
observations: tuple[ObstacleObservation, ...],
|
||||
projected: ProjectedPointCloud,
|
||||
profile: MaskGeometryBindingProfile | None = None,
|
||||
) -> tuple[MaskGeometryBinding, ...]:
|
||||
"""Bind masks only to exclusively supported occupied-geometry observations."""
|
||||
|
||||
selected_profile = profile or MaskGeometryBindingProfile()
|
||||
preliminary = tuple(
|
||||
_bind_instance(
|
||||
instance,
|
||||
observations=observations,
|
||||
projected=projected,
|
||||
profile=selected_profile,
|
||||
)
|
||||
for instance in instances
|
||||
)
|
||||
claims: dict[str, list[int]] = defaultdict(list)
|
||||
for index, binding in enumerate(preliminary):
|
||||
if binding.selected_observation_id is not None:
|
||||
claims[binding.selected_observation_id].append(index)
|
||||
collisions = {index for indices in claims.values() if len(indices) > 1 for index in indices}
|
||||
return tuple(
|
||||
MaskGeometryBinding(
|
||||
instance_id=item.instance_id,
|
||||
resolution=MaskBindingResolution.AMBIGUOUS,
|
||||
selected_observation_id=None,
|
||||
supports=item.supports,
|
||||
reason_code="observation-claimed-by-multiple-mask-instances",
|
||||
)
|
||||
if index in collisions
|
||||
else item
|
||||
for index, item in enumerate(preliminary)
|
||||
)
|
||||
|
||||
|
||||
def resolve_mask_instance_label(
|
||||
instance: MaskInstance,
|
||||
*,
|
||||
profile: MaskGeometryBindingProfile | None = None,
|
||||
) -> MaskLabelDecision:
|
||||
"""Resolve a name only when confidence and inter-label margin both pass."""
|
||||
|
||||
selected_profile = profile or MaskGeometryBindingProfile()
|
||||
ranked = instance.ranked_labels
|
||||
if not ranked or ranked[0][1] < selected_profile.minimum_label_confidence:
|
||||
return MaskLabelDecision(
|
||||
instance.instance_id,
|
||||
MaskLabelResolution.UNRESOLVED,
|
||||
None,
|
||||
ranked,
|
||||
"top-label-below-confidence",
|
||||
)
|
||||
runner_up = ranked[1][1] if len(ranked) > 1 else 0.0
|
||||
if ranked[0][1] - runner_up < selected_profile.minimum_label_margin:
|
||||
return MaskLabelDecision(
|
||||
instance.instance_id,
|
||||
MaskLabelResolution.AMBIGUOUS,
|
||||
None,
|
||||
ranked,
|
||||
"top-label-margin-insufficient",
|
||||
)
|
||||
return MaskLabelDecision(
|
||||
instance.instance_id,
|
||||
MaskLabelResolution.SELECTED,
|
||||
ranked[0][0],
|
||||
ranked,
|
||||
"confidence-and-margin-passed",
|
||||
)
|
||||
|
||||
|
||||
def _bind_instance(
|
||||
instance: MaskInstance,
|
||||
*,
|
||||
observations: tuple[ObstacleObservation, ...],
|
||||
projected: ProjectedPointCloud,
|
||||
profile: MaskGeometryBindingProfile,
|
||||
) -> MaskGeometryBinding:
|
||||
supports = []
|
||||
for observation in observations:
|
||||
selected = np.isin(
|
||||
projected.source_indices,
|
||||
np.asarray(observation.source_point_ids, dtype=np.int64),
|
||||
)
|
||||
pixels = projected.pixels_xy[selected]
|
||||
if pixels.shape[0] < profile.minimum_projected_points:
|
||||
continue
|
||||
integer_pixels = np.floor(pixels).astype(np.int64)
|
||||
x = integer_pixels[:, 0]
|
||||
y = integer_pixels[:, 1]
|
||||
support = max(
|
||||
float(detection.mask[y, x].mean()) for detection in instance.detections
|
||||
)
|
||||
supports.append(
|
||||
MaskObservationSupport(
|
||||
observation_id=observation.observation_id,
|
||||
projected_point_count=int(pixels.shape[0]),
|
||||
support_fraction=support,
|
||||
)
|
||||
)
|
||||
ordered = tuple(
|
||||
sorted(
|
||||
supports,
|
||||
key=lambda item: (-item.support_fraction, item.observation_id),
|
||||
)
|
||||
)
|
||||
if not ordered or ordered[0].support_fraction < profile.minimum_support_fraction:
|
||||
return MaskGeometryBinding(
|
||||
instance.instance_id,
|
||||
MaskBindingResolution.UNRESOLVED,
|
||||
None,
|
||||
ordered,
|
||||
"occupied-geometry-support-insufficient",
|
||||
)
|
||||
if (
|
||||
len(ordered) > 1
|
||||
and ordered[1].support_fraction > profile.maximum_secondary_support_fraction
|
||||
):
|
||||
return MaskGeometryBinding(
|
||||
instance.instance_id,
|
||||
MaskBindingResolution.AMBIGUOUS,
|
||||
None,
|
||||
ordered,
|
||||
"mask-covers-multiple-geometry-observations",
|
||||
)
|
||||
return MaskGeometryBinding(
|
||||
instance.instance_id,
|
||||
MaskBindingResolution.SELECTED,
|
||||
ordered[0].observation_id,
|
||||
ordered,
|
||||
"exclusive-occupied-geometry-support-passed",
|
||||
)
|
||||
|
||||
|
||||
def _mask_iou(left: BoolArray, right: BoolArray) -> float:
|
||||
intersection = int(np.logical_and(left, right).sum())
|
||||
union = int(np.logical_or(left, right).sum())
|
||||
return intersection / union if union else 0.0
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MASK_GROUNDING_EVIDENCE_SCHEMA",
|
||||
"MaskBindingResolution",
|
||||
"MaskGeometryBinding",
|
||||
"MaskGeometryBindingProfile",
|
||||
"MaskGroundingDetection",
|
||||
"MaskGroundingEvidence",
|
||||
"MaskGroundingSemanticError",
|
||||
"MaskInstance",
|
||||
"MaskLabelDecision",
|
||||
"MaskLabelResolution",
|
||||
"MaskObservationSupport",
|
||||
"bind_mask_instances_to_geometry",
|
||||
"cluster_mask_instances",
|
||||
"load_mask_grounding_evidence",
|
||||
"resolve_mask_instance_label",
|
||||
]
|
||||
@@ -0,0 +1,929 @@
|
||||
"""Versioned semantic, state and advisory-risk projection for an obstacle.
|
||||
|
||||
The projection composes an immutable :class:`ObstacleObservation` instead of
|
||||
changing the strict v1 geometry contract. Semantic identity, observed state,
|
||||
class priors and advisory risk remain separate claims with explicit evidence.
|
||||
None of them can create occupancy or acquire navigation, safety or actuation
|
||||
authority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from .contracts import FalseAuthority, MotionState, ObstacleObservation
|
||||
|
||||
OBJECT_UNDERSTANDING_SCHEMA: Final = "missioncore.object-understanding/v1"
|
||||
OBJECT_SEMANTIC_VOCABULARY_SCHEMA: Final = "missioncore.object-semantic-vocabulary/v0"
|
||||
MAX_SEMANTIC_HYPOTHESES: Final = 5
|
||||
|
||||
_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$")
|
||||
_NORMALIZED_LABEL = re.compile(r"^[a-z0-9][a-z0-9_]{0,79}$")
|
||||
|
||||
|
||||
class ObjectUnderstandingError(ValueError):
|
||||
"""An object-understanding document or vocabulary is incompatible."""
|
||||
|
||||
|
||||
class EvidenceKind(StrEnum):
|
||||
DETECTOR = "detector"
|
||||
SEMANTIC_MASK = "semantic-mask"
|
||||
HUMAN_REVIEW = "human-review"
|
||||
GEOMETRY = "geometry"
|
||||
TEMPORAL = "temporal"
|
||||
POLICY = "policy"
|
||||
|
||||
|
||||
class SemanticResolution(StrEnum):
|
||||
UNRESOLVED = "unresolved"
|
||||
SELECTED = "selected"
|
||||
AMBIGUOUS = "ambiguous"
|
||||
CONFLICT = "conflict"
|
||||
|
||||
|
||||
class AgencyState(StrEnum):
|
||||
UNKNOWN = "unknown"
|
||||
INERT = "inert"
|
||||
ANIMATE = "animate"
|
||||
SELF_PROPELLED = "self-propelled"
|
||||
|
||||
|
||||
class StateBasis(StrEnum):
|
||||
UNKNOWN = "unknown"
|
||||
OBSERVED = "observed"
|
||||
CLASS_PRIOR = "class-prior"
|
||||
FUSED = "fused"
|
||||
|
||||
|
||||
class RiskLevel(StrEnum):
|
||||
UNKNOWN = "unknown"
|
||||
LOW = "low"
|
||||
ELEVATED = "elevated"
|
||||
HIGH = "high"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
class RiskBasis(StrEnum):
|
||||
UNKNOWN = "unknown"
|
||||
SEMANTIC_PRIOR = "semantic-prior"
|
||||
OBSERVED_STATE = "observed-state"
|
||||
GEOMETRY = "geometry"
|
||||
FUSED = "fused"
|
||||
|
||||
|
||||
class AdvisoryResponse(StrEnum):
|
||||
MONITOR = "monitor"
|
||||
REDUCE_SPEED = "reduce-speed"
|
||||
YIELD = "yield"
|
||||
STOP = "stop"
|
||||
ROUTE_AROUND = "route-around"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EvidenceProvenance:
|
||||
"""One source-bound evidence item used by semantic, state or risk claims."""
|
||||
|
||||
evidence_id: str
|
||||
kind: EvidenceKind
|
||||
source_id: str
|
||||
frame_id: str
|
||||
provider_id: str
|
||||
model_id: str | None
|
||||
model_revision: str | None
|
||||
preprocess_id: str | None
|
||||
prompt_set_id: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.kind, EvidenceKind):
|
||||
raise ObjectUnderstandingError("evidence kind is invalid")
|
||||
for value, label in (
|
||||
(self.evidence_id, "evidence id"),
|
||||
(self.source_id, "evidence source id"),
|
||||
(self.frame_id, "evidence frame id"),
|
||||
(self.provider_id, "evidence provider id"),
|
||||
):
|
||||
_identifier(value, label)
|
||||
for optional_value, label in (
|
||||
(self.model_id, "evidence model id"),
|
||||
(self.model_revision, "evidence model revision"),
|
||||
(self.preprocess_id, "evidence preprocess id"),
|
||||
(self.prompt_set_id, "evidence prompt-set id"),
|
||||
):
|
||||
_optional_identifier(optional_value, label)
|
||||
if self.kind in {EvidenceKind.DETECTOR, EvidenceKind.SEMANTIC_MASK} and (
|
||||
self.model_id is None or self.model_revision is None or self.preprocess_id is None
|
||||
):
|
||||
raise ObjectUnderstandingError(
|
||||
"model evidence requires model, revision and preprocess identity"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"evidence_id": self.evidence_id,
|
||||
"kind": self.kind.value,
|
||||
"source_id": self.source_id,
|
||||
"frame_id": self.frame_id,
|
||||
"provider_id": self.provider_id,
|
||||
"model_id": self.model_id,
|
||||
"model_revision": self.model_revision,
|
||||
"preprocess_id": self.preprocess_id,
|
||||
"prompt_set_id": self.prompt_set_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> EvidenceProvenance:
|
||||
document = _object(value, "evidence provenance")
|
||||
_exact_keys(
|
||||
document,
|
||||
{
|
||||
"evidence_id",
|
||||
"kind",
|
||||
"source_id",
|
||||
"frame_id",
|
||||
"provider_id",
|
||||
"model_id",
|
||||
"model_revision",
|
||||
"preprocess_id",
|
||||
"prompt_set_id",
|
||||
},
|
||||
"evidence provenance",
|
||||
)
|
||||
return cls(
|
||||
evidence_id=_string(document, "evidence_id"),
|
||||
kind=_enum(EvidenceKind, document.get("kind"), "evidence kind"),
|
||||
source_id=_string(document, "source_id"),
|
||||
frame_id=_string(document, "frame_id"),
|
||||
provider_id=_string(document, "provider_id"),
|
||||
model_id=_optional_string(document.get("model_id"), "model id"),
|
||||
model_revision=_optional_string(document.get("model_revision"), "model revision"),
|
||||
preprocess_id=_optional_string(document.get("preprocess_id"), "preprocess id"),
|
||||
prompt_set_id=_optional_string(document.get("prompt_set_id"), "prompt-set id"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticHypothesis:
|
||||
"""One ranked canonical class hypothesis, never an occupancy identity."""
|
||||
|
||||
rank: int
|
||||
class_id: str
|
||||
raw_label: str
|
||||
confidence: float
|
||||
evidence_ids: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_positive_integer(self.rank, "semantic rank")
|
||||
_identifier(self.class_id, "semantic class id")
|
||||
_label(self.raw_label, "raw semantic label")
|
||||
_confidence(self.confidence, "semantic confidence")
|
||||
_unique_identifiers(self.evidence_ids, "semantic evidence ids")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"rank": self.rank,
|
||||
"class_id": self.class_id,
|
||||
"raw_label": self.raw_label,
|
||||
"confidence": self.confidence,
|
||||
"evidence_ids": list(self.evidence_ids),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> SemanticHypothesis:
|
||||
document = _object(value, "semantic hypothesis")
|
||||
_exact_keys(
|
||||
document,
|
||||
{"rank", "class_id", "raw_label", "confidence", "evidence_ids"},
|
||||
"semantic hypothesis",
|
||||
)
|
||||
return cls(
|
||||
rank=_integer(document, "rank"),
|
||||
class_id=_string(document, "class_id"),
|
||||
raw_label=_string(document, "raw_label"),
|
||||
confidence=_number(document, "confidence"),
|
||||
evidence_ids=_string_tuple(document.get("evidence_ids"), "evidence ids"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticDecision:
|
||||
"""Resolution over ranked hypotheses; ambiguity remains first-class."""
|
||||
|
||||
resolution: SemanticResolution
|
||||
selected_class_id: str | None
|
||||
selected_confidence: float | None
|
||||
reason_codes: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.resolution, SemanticResolution):
|
||||
raise ObjectUnderstandingError("semantic resolution is invalid")
|
||||
_optional_identifier(self.selected_class_id, "selected semantic class id")
|
||||
if self.selected_confidence is not None:
|
||||
_confidence(self.selected_confidence, "selected semantic confidence")
|
||||
_unique_identifiers(self.reason_codes, "semantic decision reasons")
|
||||
has_selection = self.selected_class_id is not None and self.selected_confidence is not None
|
||||
if self.resolution is SemanticResolution.SELECTED:
|
||||
if not has_selection:
|
||||
raise ObjectUnderstandingError("selected semantics require class and confidence")
|
||||
elif self.selected_class_id is not None or self.selected_confidence is not None:
|
||||
raise ObjectUnderstandingError("non-selected semantics cannot publish a selected class")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"resolution": self.resolution.value,
|
||||
"selected_class_id": self.selected_class_id,
|
||||
"selected_confidence": self.selected_confidence,
|
||||
"reason_codes": list(self.reason_codes),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> SemanticDecision:
|
||||
document = _object(value, "semantic decision")
|
||||
_exact_keys(
|
||||
document,
|
||||
{
|
||||
"resolution",
|
||||
"selected_class_id",
|
||||
"selected_confidence",
|
||||
"reason_codes",
|
||||
},
|
||||
"semantic decision",
|
||||
)
|
||||
return cls(
|
||||
resolution=_enum(
|
||||
SemanticResolution,
|
||||
document.get("resolution"),
|
||||
"semantic resolution",
|
||||
),
|
||||
selected_class_id=_optional_string(
|
||||
document.get("selected_class_id"), "selected class id"
|
||||
),
|
||||
selected_confidence=_optional_number(
|
||||
document.get("selected_confidence"), "selected confidence"
|
||||
),
|
||||
reason_codes=_string_tuple(document.get("reason_codes"), "reason codes"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ObjectStateEstimate:
|
||||
"""Observed motion and agency prior, with their bases kept explicit."""
|
||||
|
||||
motion: MotionState
|
||||
motion_confidence: float
|
||||
agency: AgencyState
|
||||
agency_basis: StateBasis
|
||||
evidence_ids: tuple[str, ...]
|
||||
reason_codes: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.motion, MotionState):
|
||||
raise ObjectUnderstandingError("motion state is invalid")
|
||||
if not isinstance(self.agency, AgencyState):
|
||||
raise ObjectUnderstandingError("agency state is invalid")
|
||||
if not isinstance(self.agency_basis, StateBasis):
|
||||
raise ObjectUnderstandingError("agency basis is invalid")
|
||||
_confidence(self.motion_confidence, "motion confidence")
|
||||
_unique_identifiers(self.evidence_ids, "state evidence ids", allow_empty=True)
|
||||
_unique_identifiers(self.reason_codes, "state reason codes")
|
||||
if self.agency is AgencyState.UNKNOWN:
|
||||
if self.agency_basis is not StateBasis.UNKNOWN:
|
||||
raise ObjectUnderstandingError("unknown agency must retain unknown evidence basis")
|
||||
elif self.agency_basis is StateBasis.UNKNOWN:
|
||||
raise ObjectUnderstandingError("agency claim requires an explicit basis")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"motion": self.motion.value,
|
||||
"motion_confidence": self.motion_confidence,
|
||||
"agency": self.agency.value,
|
||||
"agency_basis": self.agency_basis.value,
|
||||
"evidence_ids": list(self.evidence_ids),
|
||||
"reason_codes": list(self.reason_codes),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> ObjectStateEstimate:
|
||||
document = _object(value, "object state")
|
||||
_exact_keys(
|
||||
document,
|
||||
{
|
||||
"motion",
|
||||
"motion_confidence",
|
||||
"agency",
|
||||
"agency_basis",
|
||||
"evidence_ids",
|
||||
"reason_codes",
|
||||
},
|
||||
"object state",
|
||||
)
|
||||
return cls(
|
||||
motion=_enum(MotionState, document.get("motion"), "motion state"),
|
||||
motion_confidence=_number(document, "motion_confidence"),
|
||||
agency=_enum(AgencyState, document.get("agency"), "agency state"),
|
||||
agency_basis=_enum(StateBasis, document.get("agency_basis"), "agency basis"),
|
||||
evidence_ids=_string_tuple(document.get("evidence_ids"), "evidence ids"),
|
||||
reason_codes=_string_tuple(document.get("reason_codes"), "reason codes"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AdvisoryRiskAssessment:
|
||||
"""Evidence-qualified risk hint that is never a planner command."""
|
||||
|
||||
policy_id: str
|
||||
level: RiskLevel
|
||||
confidence: float
|
||||
basis: RiskBasis
|
||||
responses: tuple[AdvisoryResponse, ...]
|
||||
evidence_ids: tuple[str, ...]
|
||||
reason_codes: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.level, RiskLevel):
|
||||
raise ObjectUnderstandingError("risk level is invalid")
|
||||
if not isinstance(self.basis, RiskBasis):
|
||||
raise ObjectUnderstandingError("risk basis is invalid")
|
||||
_identifier(self.policy_id, "risk policy id")
|
||||
_confidence(self.confidence, "risk confidence")
|
||||
_unique_enum_values(self.responses, "advisory responses", allow_empty=True)
|
||||
_unique_identifiers(self.evidence_ids, "risk evidence ids", allow_empty=True)
|
||||
_unique_identifiers(self.reason_codes, "risk reason codes")
|
||||
if self.level is RiskLevel.UNKNOWN:
|
||||
if self.basis is not RiskBasis.UNKNOWN or self.confidence != 0.0:
|
||||
raise ObjectUnderstandingError(
|
||||
"unknown risk must retain unknown basis and zero confidence"
|
||||
)
|
||||
elif self.basis is RiskBasis.UNKNOWN:
|
||||
raise ObjectUnderstandingError("risk claim requires an explicit basis")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"policy_id": self.policy_id,
|
||||
"level": self.level.value,
|
||||
"confidence": self.confidence,
|
||||
"basis": self.basis.value,
|
||||
"responses": [item.value for item in self.responses],
|
||||
"evidence_ids": list(self.evidence_ids),
|
||||
"reason_codes": list(self.reason_codes),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> AdvisoryRiskAssessment:
|
||||
document = _object(value, "advisory risk")
|
||||
_exact_keys(
|
||||
document,
|
||||
{
|
||||
"policy_id",
|
||||
"level",
|
||||
"confidence",
|
||||
"basis",
|
||||
"responses",
|
||||
"evidence_ids",
|
||||
"reason_codes",
|
||||
},
|
||||
"advisory risk",
|
||||
)
|
||||
return cls(
|
||||
policy_id=_string(document, "policy_id"),
|
||||
level=_enum(RiskLevel, document.get("level"), "risk level"),
|
||||
confidence=_number(document, "confidence"),
|
||||
basis=_enum(RiskBasis, document.get("basis"), "risk basis"),
|
||||
responses=tuple(
|
||||
_enum(AdvisoryResponse, item, "advisory response")
|
||||
for item in _array(document, "responses")
|
||||
),
|
||||
evidence_ids=_string_tuple(document.get("evidence_ids"), "evidence ids"),
|
||||
reason_codes=_string_tuple(document.get("reason_codes"), "reason codes"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ObjectUnderstanding:
|
||||
"""Complete machine projection: geometry, semantics, state, risk and lineage."""
|
||||
|
||||
understanding_id: str
|
||||
vocabulary_id: str
|
||||
generated_monotonic_ns: int
|
||||
observation: ObstacleObservation
|
||||
hypotheses: tuple[SemanticHypothesis, ...]
|
||||
semantic: SemanticDecision
|
||||
state: ObjectStateEstimate
|
||||
risk: AdvisoryRiskAssessment
|
||||
provenance: tuple[EvidenceProvenance, ...]
|
||||
authority: FalseAuthority = FalseAuthority()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_identifier(self.understanding_id, "understanding id")
|
||||
_identifier(self.vocabulary_id, "semantic vocabulary id")
|
||||
_nonnegative_integer(self.generated_monotonic_ns, "generation time")
|
||||
if not isinstance(self.observation, ObstacleObservation):
|
||||
raise ObjectUnderstandingError("object geometry observation is invalid")
|
||||
if (
|
||||
not isinstance(self.hypotheses, tuple)
|
||||
or any(not isinstance(item, SemanticHypothesis) for item in self.hypotheses)
|
||||
or len(self.hypotheses) > MAX_SEMANTIC_HYPOTHESES
|
||||
):
|
||||
raise ObjectUnderstandingError("semantic hypothesis set is invalid")
|
||||
if tuple(item.rank for item in self.hypotheses) != tuple(
|
||||
range(1, len(self.hypotheses) + 1)
|
||||
):
|
||||
raise ObjectUnderstandingError("semantic hypotheses must have contiguous ranks")
|
||||
class_ids = tuple(item.class_id for item in self.hypotheses)
|
||||
if len(set(class_ids)) != len(class_ids):
|
||||
raise ObjectUnderstandingError("semantic hypothesis classes must be unique")
|
||||
if any(
|
||||
self.hypotheses[index].confidence < self.hypotheses[index + 1].confidence
|
||||
for index in range(len(self.hypotheses) - 1)
|
||||
):
|
||||
raise ObjectUnderstandingError("semantic hypotheses must be ordered by confidence")
|
||||
if not isinstance(self.semantic, SemanticDecision):
|
||||
raise ObjectUnderstandingError("semantic decision is invalid")
|
||||
if not isinstance(self.state, ObjectStateEstimate):
|
||||
raise ObjectUnderstandingError("object state is invalid")
|
||||
if not isinstance(self.risk, AdvisoryRiskAssessment):
|
||||
raise ObjectUnderstandingError("advisory risk is invalid")
|
||||
if not isinstance(self.authority, FalseAuthority):
|
||||
raise ObjectUnderstandingError("object understanding authority is invalid")
|
||||
if not isinstance(self.provenance, tuple) or any(
|
||||
not isinstance(item, EvidenceProvenance) for item in self.provenance
|
||||
):
|
||||
raise ObjectUnderstandingError("evidence provenance is invalid")
|
||||
evidence_ids = tuple(item.evidence_id for item in self.provenance)
|
||||
if len(set(evidence_ids)) != len(evidence_ids):
|
||||
raise ObjectUnderstandingError("evidence provenance ids must be unique")
|
||||
if any(
|
||||
item.source_id != self.observation.source_id
|
||||
or item.frame_id != self.observation.frame_id
|
||||
for item in self.provenance
|
||||
):
|
||||
raise ObjectUnderstandingError("object evidence escaped its geometry source frame")
|
||||
known_evidence = set(evidence_ids)
|
||||
claimed_evidence = (
|
||||
{evidence_id for item in self.hypotheses for evidence_id in item.evidence_ids}
|
||||
| set(self.state.evidence_ids)
|
||||
| set(self.risk.evidence_ids)
|
||||
)
|
||||
if claimed_evidence - known_evidence:
|
||||
raise ObjectUnderstandingError("object claim references unknown evidence")
|
||||
if (
|
||||
self.semantic.resolution
|
||||
in {
|
||||
SemanticResolution.AMBIGUOUS,
|
||||
SemanticResolution.CONFLICT,
|
||||
}
|
||||
and not self.hypotheses
|
||||
):
|
||||
raise ObjectUnderstandingError("ambiguous or conflicting semantics require hypotheses")
|
||||
if self.semantic.resolution is SemanticResolution.CONFLICT and len(self.hypotheses) < 2:
|
||||
raise ObjectUnderstandingError("semantic conflict requires two hypotheses")
|
||||
if self.semantic.resolution is SemanticResolution.SELECTED:
|
||||
selected = next(
|
||||
(
|
||||
item
|
||||
for item in self.hypotheses
|
||||
if item.class_id == self.semantic.selected_class_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if selected is None or selected.confidence != self.semantic.selected_confidence:
|
||||
raise ObjectUnderstandingError(
|
||||
"selected semantics must match one ranked hypothesis"
|
||||
)
|
||||
|
||||
@property
|
||||
def occupancy_identity(self) -> str:
|
||||
"""Semantic or risk changes never replace the geometry-owned identity."""
|
||||
|
||||
return self.observation.occupancy_identity
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": OBJECT_UNDERSTANDING_SCHEMA,
|
||||
"understanding_id": self.understanding_id,
|
||||
"vocabulary_id": self.vocabulary_id,
|
||||
"generated_monotonic_ns": self.generated_monotonic_ns,
|
||||
"observation": self.observation.to_dict(),
|
||||
"hypotheses": [item.to_dict() for item in self.hypotheses],
|
||||
"semantic": self.semantic.to_dict(),
|
||||
"state": self.state.to_dict(),
|
||||
"risk": self.risk.to_dict(),
|
||||
"provenance": [item.to_dict() for item in self.provenance],
|
||||
"authority": self.authority.to_dict(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> ObjectUnderstanding:
|
||||
document = _contract(
|
||||
value,
|
||||
OBJECT_UNDERSTANDING_SCHEMA,
|
||||
{
|
||||
"understanding_id",
|
||||
"vocabulary_id",
|
||||
"generated_monotonic_ns",
|
||||
"observation",
|
||||
"hypotheses",
|
||||
"semantic",
|
||||
"state",
|
||||
"risk",
|
||||
"provenance",
|
||||
"authority",
|
||||
},
|
||||
"object understanding",
|
||||
)
|
||||
return cls(
|
||||
understanding_id=_string(document, "understanding_id"),
|
||||
vocabulary_id=_string(document, "vocabulary_id"),
|
||||
generated_monotonic_ns=_integer(document, "generated_monotonic_ns"),
|
||||
observation=ObstacleObservation.from_dict(document.get("observation")),
|
||||
hypotheses=tuple(
|
||||
SemanticHypothesis.from_dict(item) for item in _array(document, "hypotheses")
|
||||
),
|
||||
semantic=SemanticDecision.from_dict(document.get("semantic")),
|
||||
state=ObjectStateEstimate.from_dict(document.get("state")),
|
||||
risk=AdvisoryRiskAssessment.from_dict(document.get("risk")),
|
||||
provenance=tuple(
|
||||
EvidenceProvenance.from_dict(item) for item in _array(document, "provenance")
|
||||
),
|
||||
authority=FalseAuthority.from_dict(document.get("authority")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CanonicalObjectClass:
|
||||
"""One class in the bounded experimental object vocabulary."""
|
||||
|
||||
class_id: str
|
||||
parent_id: str | None
|
||||
aliases: tuple[str, ...]
|
||||
agency_prior: AgencyState
|
||||
risk_traits: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.agency_prior, AgencyState):
|
||||
raise ObjectUnderstandingError("canonical agency prior is invalid")
|
||||
_identifier(self.class_id, "canonical class id")
|
||||
_optional_identifier(self.parent_id, "canonical parent id")
|
||||
if not self.aliases:
|
||||
raise ObjectUnderstandingError("canonical class aliases must be nonempty")
|
||||
normalized = tuple(normalize_raw_label(item) for item in self.aliases)
|
||||
if len(set(normalized)) != len(normalized):
|
||||
raise ObjectUnderstandingError("canonical class aliases must be unique")
|
||||
_unique_identifiers(self.risk_traits, "class risk traits", allow_empty=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ObjectSemanticVocabulary:
|
||||
"""Loaded executable vocabulary profile; not a runtime ontology service."""
|
||||
|
||||
vocabulary_id: str
|
||||
status: str
|
||||
scope: str
|
||||
classes: tuple[CanonicalObjectClass, ...]
|
||||
max_hypotheses: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_identifier(self.vocabulary_id, "vocabulary id")
|
||||
if self.status != "experimental":
|
||||
raise ObjectUnderstandingError("object vocabulary must remain experimental")
|
||||
_identifier(self.scope, "vocabulary scope")
|
||||
if not 1 <= self.max_hypotheses <= MAX_SEMANTIC_HYPOTHESES:
|
||||
raise ObjectUnderstandingError("vocabulary top-k bound is invalid")
|
||||
if not self.classes:
|
||||
raise ObjectUnderstandingError("object vocabulary must declare classes")
|
||||
by_id = {item.class_id: item for item in self.classes}
|
||||
if len(by_id) != len(self.classes):
|
||||
raise ObjectUnderstandingError("canonical class ids must be unique")
|
||||
for item in self.classes:
|
||||
if item.parent_id is not None and item.parent_id not in by_id:
|
||||
raise ObjectUnderstandingError("canonical class parent is undeclared")
|
||||
seen = {item.class_id}
|
||||
parent_id = item.parent_id
|
||||
while parent_id is not None:
|
||||
if parent_id in seen:
|
||||
raise ObjectUnderstandingError("canonical class hierarchy is cyclic")
|
||||
seen.add(parent_id)
|
||||
parent_id = by_id[parent_id].parent_id
|
||||
aliases = [normalize_raw_label(alias) for item in self.classes for alias in item.aliases]
|
||||
if len(set(aliases)) != len(aliases):
|
||||
raise ObjectUnderstandingError("canonical aliases must be globally unique")
|
||||
|
||||
def class_definition(self, class_id: str) -> CanonicalObjectClass:
|
||||
for item in self.classes:
|
||||
if item.class_id == class_id:
|
||||
return item
|
||||
raise ObjectUnderstandingError("canonical class is undeclared")
|
||||
|
||||
def resolve_label(self, raw_label: str) -> str | None:
|
||||
normalized = normalize_raw_label(raw_label)
|
||||
for item in self.classes:
|
||||
if normalized in {normalize_raw_label(alias) for alias in item.aliases}:
|
||||
return item.class_id
|
||||
return None
|
||||
|
||||
def ancestors(self, class_id: str) -> tuple[str, ...]:
|
||||
by_id = {item.class_id: item for item in self.classes}
|
||||
current = self.class_definition(class_id)
|
||||
result: list[str] = []
|
||||
while current.parent_id is not None:
|
||||
result.append(current.parent_id)
|
||||
current = by_id[current.parent_id]
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def load_object_semantic_vocabulary(path: Path) -> ObjectSemanticVocabulary:
|
||||
"""Load and fail-close an executable vocabulary profile."""
|
||||
|
||||
try:
|
||||
document = json.loads(path.expanduser().resolve(strict=True).read_text("utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ObjectUnderstandingError("object vocabulary cannot be read") from exc
|
||||
root = _object(document, "object semantic vocabulary")
|
||||
_exact_keys(
|
||||
root,
|
||||
{
|
||||
"schema_version",
|
||||
"vocabulary_id",
|
||||
"status",
|
||||
"scope",
|
||||
"classes",
|
||||
"policies",
|
||||
},
|
||||
"object semantic vocabulary",
|
||||
)
|
||||
if root.get("schema_version") != OBJECT_SEMANTIC_VOCABULARY_SCHEMA:
|
||||
raise ObjectUnderstandingError("object vocabulary schema is incompatible")
|
||||
policies = _object(root.get("policies"), "object vocabulary policies")
|
||||
_exact_keys(
|
||||
policies,
|
||||
{
|
||||
"occupancy_independent_of_semantics",
|
||||
"unknown_preserves_obstacle",
|
||||
"class_prior_is_not_observed_state",
|
||||
"risk_is_advisory_only",
|
||||
"planner_command_authority",
|
||||
"max_hypotheses",
|
||||
},
|
||||
"object vocabulary policies",
|
||||
)
|
||||
required_true = (
|
||||
"occupancy_independent_of_semantics",
|
||||
"unknown_preserves_obstacle",
|
||||
"class_prior_is_not_observed_state",
|
||||
"risk_is_advisory_only",
|
||||
)
|
||||
if (
|
||||
any(policies.get(key) is not True for key in required_true)
|
||||
or policies.get("planner_command_authority") is not False
|
||||
):
|
||||
raise ObjectUnderstandingError("object vocabulary authority policy changed")
|
||||
classes: list[CanonicalObjectClass] = []
|
||||
for raw in _array(root, "classes"):
|
||||
row = _object(raw, "canonical object class")
|
||||
_exact_keys(
|
||||
row,
|
||||
{"class_id", "parent_id", "aliases", "agency_prior", "risk_traits"},
|
||||
"canonical object class",
|
||||
)
|
||||
classes.append(
|
||||
CanonicalObjectClass(
|
||||
class_id=_string(row, "class_id"),
|
||||
parent_id=_optional_string(row.get("parent_id"), "parent id"),
|
||||
aliases=_string_tuple(row.get("aliases"), "aliases"),
|
||||
agency_prior=_enum(AgencyState, row.get("agency_prior"), "agency prior"),
|
||||
risk_traits=_string_tuple(row.get("risk_traits"), "risk traits"),
|
||||
)
|
||||
)
|
||||
return ObjectSemanticVocabulary(
|
||||
vocabulary_id=_string(root, "vocabulary_id"),
|
||||
status=_string(root, "status"),
|
||||
scope=_string(root, "scope"),
|
||||
classes=tuple(classes),
|
||||
max_hypotheses=_integer(policies, "max_hypotheses"),
|
||||
)
|
||||
|
||||
|
||||
def validate_object_understanding(
|
||||
value: ObjectUnderstanding,
|
||||
vocabulary: ObjectSemanticVocabulary,
|
||||
) -> None:
|
||||
"""Validate canonical class references without changing the document."""
|
||||
|
||||
if not isinstance(value, ObjectUnderstanding):
|
||||
raise ObjectUnderstandingError("object understanding is invalid")
|
||||
if value.vocabulary_id != vocabulary.vocabulary_id:
|
||||
raise ObjectUnderstandingError("object understanding vocabulary changed")
|
||||
declared = {item.class_id for item in vocabulary.classes}
|
||||
referenced = {item.class_id for item in value.hypotheses}
|
||||
if value.semantic.selected_class_id is not None:
|
||||
referenced.add(value.semantic.selected_class_id)
|
||||
if referenced - declared:
|
||||
raise ObjectUnderstandingError("object understanding uses undeclared classes")
|
||||
if len(value.hypotheses) > vocabulary.max_hypotheses:
|
||||
raise ObjectUnderstandingError("object understanding exceeds vocabulary top-k")
|
||||
|
||||
|
||||
def normalize_raw_label(value: str) -> str:
|
||||
"""Normalize a provider label only for alias lookup, never as class truth."""
|
||||
|
||||
_label(value, "raw semantic label")
|
||||
normalized = re.sub(r"[_\s-]+", "_", value.strip().lower())
|
||||
if _NORMALIZED_LABEL.fullmatch(normalized) is None:
|
||||
raise ObjectUnderstandingError("raw semantic label cannot be normalized")
|
||||
return normalized
|
||||
|
||||
|
||||
def _contract(
|
||||
value: object,
|
||||
schema: str,
|
||||
fields: set[str],
|
||||
label: str,
|
||||
) -> dict[str, object]:
|
||||
document = _object(value, label)
|
||||
_exact_keys(document, {"schema_version", *fields}, label)
|
||||
if document.get("schema_version") != schema:
|
||||
raise ObjectUnderstandingError(f"{label} schema is incompatible")
|
||||
return document
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, object]:
|
||||
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
||||
raise ObjectUnderstandingError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _exact_keys(document: dict[str, object], expected: set[str], label: str) -> None:
|
||||
if set(document) != expected:
|
||||
raise ObjectUnderstandingError(f"{label} fields are incompatible")
|
||||
|
||||
|
||||
def _array(document: dict[str, object], key: str) -> list[object]:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, list):
|
||||
raise ObjectUnderstandingError(f"{key} must be an array")
|
||||
return value
|
||||
|
||||
|
||||
def _string(document: dict[str, object], key: str) -> str:
|
||||
return _string_value(document.get(key), key)
|
||||
|
||||
|
||||
def _string_value(value: object, label: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ObjectUnderstandingError(f"{label} must be a nonempty string")
|
||||
return value
|
||||
|
||||
|
||||
def _optional_string(value: object, label: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return _string_value(value, label)
|
||||
|
||||
|
||||
def _integer(document: dict[str, object], key: str) -> int:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise ObjectUnderstandingError(f"{key} must be an integer")
|
||||
return value
|
||||
|
||||
|
||||
def _number(document: dict[str, object], key: str) -> float:
|
||||
value = document.get(key)
|
||||
if (
|
||||
not isinstance(value, (int, float))
|
||||
or isinstance(value, bool)
|
||||
or not math.isfinite(float(value))
|
||||
):
|
||||
raise ObjectUnderstandingError(f"{key} must be finite")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _optional_number(value: object, label: str) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if (
|
||||
not isinstance(value, (int, float))
|
||||
or isinstance(value, bool)
|
||||
or not math.isfinite(float(value))
|
||||
):
|
||||
raise ObjectUnderstandingError(f"{label} must be finite")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _identifier(value: str, label: str) -> None:
|
||||
if not isinstance(value, str) or _IDENTIFIER.fullmatch(value) is None:
|
||||
raise ObjectUnderstandingError(f"{label} is not a safe identifier")
|
||||
|
||||
|
||||
def _optional_identifier(value: str | None, label: str) -> None:
|
||||
if value is not None:
|
||||
_identifier(value, label)
|
||||
|
||||
|
||||
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 ObjectUnderstandingError(f"{label} is invalid")
|
||||
|
||||
|
||||
def _nonnegative_integer(value: object, label: str) -> int:
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise ObjectUnderstandingError(f"{label} must be a nonnegative integer")
|
||||
return value
|
||||
|
||||
|
||||
def _positive_integer(value: object, label: str) -> int:
|
||||
result = _nonnegative_integer(value, label)
|
||||
if result == 0:
|
||||
raise ObjectUnderstandingError(f"{label} must be positive")
|
||||
return result
|
||||
|
||||
|
||||
def _confidence(value: object, label: str) -> float:
|
||||
if (
|
||||
not isinstance(value, (int, float))
|
||||
or isinstance(value, bool)
|
||||
or not math.isfinite(float(value))
|
||||
or not 0.0 <= float(value) <= 1.0
|
||||
):
|
||||
raise ObjectUnderstandingError(f"{label} must be within [0, 1]")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _unique_identifiers(
|
||||
values: tuple[str, ...],
|
||||
label: str,
|
||||
*,
|
||||
allow_empty: bool = False,
|
||||
) -> None:
|
||||
if (not values and not allow_empty) or len(set(values)) != len(values):
|
||||
raise ObjectUnderstandingError(f"{label} must be unique")
|
||||
for value in values:
|
||||
_identifier(value, label)
|
||||
|
||||
|
||||
def _unique_enum_values(
|
||||
values: tuple[AdvisoryResponse, ...],
|
||||
label: str,
|
||||
*,
|
||||
allow_empty: bool,
|
||||
) -> None:
|
||||
if (not values and not allow_empty) or len(set(values)) != len(values):
|
||||
raise ObjectUnderstandingError(f"{label} must be unique")
|
||||
if any(not isinstance(value, AdvisoryResponse) for value in values):
|
||||
raise ObjectUnderstandingError(f"{label} are invalid")
|
||||
|
||||
|
||||
def _string_tuple(value: object, label: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, list):
|
||||
raise ObjectUnderstandingError(f"{label} must be an array")
|
||||
return tuple(_string_value(item, label) for item in value)
|
||||
|
||||
|
||||
def _enum[ENUM: StrEnum](
|
||||
enum_type: type[ENUM],
|
||||
value: object,
|
||||
label: str,
|
||||
) -> ENUM:
|
||||
if not isinstance(value, str):
|
||||
raise ObjectUnderstandingError(f"{label} must be a string")
|
||||
try:
|
||||
return enum_type(value)
|
||||
except ValueError as exc:
|
||||
raise ObjectUnderstandingError(f"{label} is incompatible") from exc
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_SEMANTIC_HYPOTHESES",
|
||||
"OBJECT_SEMANTIC_VOCABULARY_SCHEMA",
|
||||
"OBJECT_UNDERSTANDING_SCHEMA",
|
||||
"AdvisoryResponse",
|
||||
"AdvisoryRiskAssessment",
|
||||
"AgencyState",
|
||||
"CanonicalObjectClass",
|
||||
"EvidenceKind",
|
||||
"EvidenceProvenance",
|
||||
"ObjectSemanticVocabulary",
|
||||
"ObjectStateEstimate",
|
||||
"ObjectUnderstanding",
|
||||
"ObjectUnderstandingError",
|
||||
"RiskBasis",
|
||||
"RiskLevel",
|
||||
"SemanticDecision",
|
||||
"SemanticHypothesis",
|
||||
"SemanticResolution",
|
||||
"StateBasis",
|
||||
"load_object_semantic_vocabulary",
|
||||
"normalize_raw_label",
|
||||
"validate_object_understanding",
|
||||
]
|
||||
@@ -0,0 +1,784 @@
|
||||
"""Bounded open-vocabulary detections projected onto immutable obstacle geometry.
|
||||
|
||||
The adapter deliberately consumes detections in the raw KB4 image coordinate
|
||||
space used by the admitted geometry provider. It fuses prompt collisions into
|
||||
one spatial proposal, retains ranked canonical hypotheses and never turns a
|
||||
semantic label into occupancy, risk authority or a planner command.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from .contracts import BoundingRegion2D, MotionState, ObjectProposal2D, ObstacleObservation
|
||||
from .object_understanding import (
|
||||
AdvisoryResponse,
|
||||
AdvisoryRiskAssessment,
|
||||
AgencyState,
|
||||
EvidenceKind,
|
||||
EvidenceProvenance,
|
||||
ObjectSemanticVocabulary,
|
||||
ObjectStateEstimate,
|
||||
ObjectUnderstanding,
|
||||
RiskBasis,
|
||||
RiskLevel,
|
||||
SemanticDecision,
|
||||
SemanticHypothesis,
|
||||
SemanticResolution,
|
||||
StateBasis,
|
||||
normalize_raw_label,
|
||||
validate_object_understanding,
|
||||
)
|
||||
|
||||
OPEN_VOCABULARY_SEMANTIC_SHADOW_PROFILE_SCHEMA: Final = (
|
||||
"missioncore.open-vocabulary-semantic-shadow-profile/v0"
|
||||
)
|
||||
TAO_GROUNDING_DINO_TRAILING_FIELDS: Final = 15
|
||||
|
||||
|
||||
class OpenVocabularySemanticError(ValueError):
|
||||
"""An open-vocabulary profile, label ledger or binding is incompatible."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PromptGroup:
|
||||
prompt_set_id: str
|
||||
captions: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OpenVocabularySemanticProfile:
|
||||
profile_id: str
|
||||
session_id: str
|
||||
source_id: str
|
||||
camera_source_id: str
|
||||
coordinate_space: str
|
||||
width: int
|
||||
height: int
|
||||
valid_fov_result_id: str
|
||||
valid_fov_mask_sha256: str
|
||||
valid_fov_fill_value: int
|
||||
provider_id: str
|
||||
provider_name: str
|
||||
model_id: str
|
||||
model_revision: str
|
||||
model_sha256: str
|
||||
engine_sha256: str
|
||||
container_reference: str
|
||||
container_image_id: str
|
||||
preprocess_id: str
|
||||
engine_input_width: int
|
||||
engine_input_height: int
|
||||
minimum_input_confidence: float
|
||||
minimum_box_area_fraction: float
|
||||
maximum_box_area_fraction: float
|
||||
minimum_valid_fov_fraction: float
|
||||
require_center_inside_valid_fov: bool
|
||||
fusion_iou_threshold: float
|
||||
selected_minimum_confidence: float
|
||||
selected_minimum_margin: float
|
||||
max_hypotheses: int
|
||||
prompt_groups: tuple[PromptGroup, ...]
|
||||
vocabulary_id: str
|
||||
risk_policy_id: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.coordinate_space != "raw-kb4" or (self.width, self.height) != (800, 600):
|
||||
raise OpenVocabularySemanticError("semantic shadow must remain in raw KB4 800x600")
|
||||
if self.width < 1 or self.height < 1:
|
||||
raise OpenVocabularySemanticError("semantic shadow raster is invalid")
|
||||
if self.valid_fov_fill_value != 114 or len(self.valid_fov_mask_sha256) != 64:
|
||||
raise OpenVocabularySemanticError("semantic valid-FOV preprocessing changed")
|
||||
if (self.engine_input_width, self.engine_input_height) != (960, 544):
|
||||
raise OpenVocabularySemanticError("semantic engine input raster changed")
|
||||
for value, label in (
|
||||
(self.minimum_input_confidence, "minimum input confidence"),
|
||||
(self.minimum_box_area_fraction, "minimum box area fraction"),
|
||||
(self.maximum_box_area_fraction, "maximum box area fraction"),
|
||||
(self.minimum_valid_fov_fraction, "minimum valid-FOV fraction"),
|
||||
(self.fusion_iou_threshold, "fusion IoU threshold"),
|
||||
(self.selected_minimum_confidence, "selection confidence"),
|
||||
(self.selected_minimum_margin, "selection margin"),
|
||||
):
|
||||
if not math.isfinite(value) or not 0.0 <= value <= 1.0:
|
||||
raise OpenVocabularySemanticError(f"{label} must be within [0, 1]")
|
||||
if (
|
||||
self.minimum_box_area_fraction <= 0.0
|
||||
or self.maximum_box_area_fraction <= self.minimum_box_area_fraction
|
||||
or not 1 <= self.max_hypotheses <= 5
|
||||
or not self.prompt_groups
|
||||
or self.require_center_inside_valid_fov is not True
|
||||
):
|
||||
raise OpenVocabularySemanticError("semantic shadow postprocessing is invalid")
|
||||
prompt_ids = tuple(item.prompt_set_id for item in self.prompt_groups)
|
||||
if len(set(prompt_ids)) != len(prompt_ids):
|
||||
raise OpenVocabularySemanticError("semantic prompt-set ids are duplicated")
|
||||
captions = tuple(
|
||||
normalize_raw_label(caption)
|
||||
for group in self.prompt_groups
|
||||
for caption in group.captions
|
||||
)
|
||||
if len(set(captions)) != len(captions):
|
||||
raise OpenVocabularySemanticError("semantic captions are duplicated")
|
||||
|
||||
def prompt_group(self, prompt_set_id: str) -> PromptGroup:
|
||||
for item in self.prompt_groups:
|
||||
if item.prompt_set_id == prompt_set_id:
|
||||
return item
|
||||
raise OpenVocabularySemanticError("semantic prompt set is undeclared")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OpenVocabularyDetection:
|
||||
detection_id: str
|
||||
source_id: str
|
||||
frame_id: str
|
||||
prompt_set_id: str
|
||||
raw_label: str
|
||||
confidence: float
|
||||
region: BoundingRegion2D
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.detection_id or not self.source_id or not self.frame_id:
|
||||
raise OpenVocabularySemanticError("semantic detection identity is invalid")
|
||||
normalize_raw_label(self.raw_label)
|
||||
if not math.isfinite(self.confidence) or not 0.0 <= self.confidence <= 1.0:
|
||||
raise OpenVocabularySemanticError("semantic detection confidence is invalid")
|
||||
|
||||
@property
|
||||
def evidence_id(self) -> str:
|
||||
return f"evidence:{self.detection_id}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticProposalBinding:
|
||||
proposal: ObjectProposal2D
|
||||
detections: tuple[OpenVocabularyDetection, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticFusionResult:
|
||||
bindings: tuple[SemanticProposalBinding, ...]
|
||||
input_detection_count: int
|
||||
below_confidence_count: int
|
||||
invalid_area_count: int
|
||||
outside_valid_fov_count: int
|
||||
|
||||
@property
|
||||
def proposals(self) -> tuple[ObjectProposal2D, ...]:
|
||||
return tuple(item.proposal for item in self.bindings)
|
||||
|
||||
@property
|
||||
def retained_detection_count(self) -> int:
|
||||
return sum(len(item.detections) for item in self.bindings)
|
||||
|
||||
|
||||
def load_open_vocabulary_semantic_profile(path: Path) -> OpenVocabularySemanticProfile:
|
||||
"""Load the experimental shadow profile and fail closed on authority drift."""
|
||||
|
||||
try:
|
||||
root = json.loads(path.expanduser().resolve(strict=True).read_text("utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise OpenVocabularySemanticError("semantic shadow profile cannot be read") from exc
|
||||
if not isinstance(root, dict) or root.get("schema_version") != (
|
||||
OPEN_VOCABULARY_SEMANTIC_SHADOW_PROFILE_SCHEMA
|
||||
):
|
||||
raise OpenVocabularySemanticError("semantic shadow profile schema changed")
|
||||
expected_root = {
|
||||
"schema_version",
|
||||
"profile_id",
|
||||
"status",
|
||||
"source",
|
||||
"provider",
|
||||
"postprocessing",
|
||||
"prompt_groups",
|
||||
"semantic_vocabulary_id",
|
||||
"risk_policy_id",
|
||||
"authority",
|
||||
}
|
||||
if set(root) != expected_root or root.get("status") != "experimental-shadow":
|
||||
raise OpenVocabularySemanticError("semantic shadow profile fields changed")
|
||||
source = _object(root.get("source"), "semantic source")
|
||||
provider = _object(root.get("provider"), "semantic provider")
|
||||
postprocessing = _object(root.get("postprocessing"), "semantic postprocessing")
|
||||
authority = _object(root.get("authority"), "semantic authority")
|
||||
if authority != _false_authority():
|
||||
raise OpenVocabularySemanticError("semantic shadow acquired authority")
|
||||
raw_groups = root.get("prompt_groups")
|
||||
if not isinstance(raw_groups, list):
|
||||
raise OpenVocabularySemanticError("semantic prompt groups must be an array")
|
||||
groups: list[PromptGroup] = []
|
||||
for raw_group in raw_groups:
|
||||
group = _object(raw_group, "semantic prompt group")
|
||||
if set(group) != {"prompt_set_id", "captions"}:
|
||||
raise OpenVocabularySemanticError("semantic prompt group fields changed")
|
||||
raw_captions = group.get("captions")
|
||||
if (
|
||||
not isinstance(raw_captions, list)
|
||||
or not raw_captions
|
||||
or any(not isinstance(item, str) or not item for item in raw_captions)
|
||||
):
|
||||
raise OpenVocabularySemanticError("semantic prompt captions are invalid")
|
||||
groups.append(
|
||||
PromptGroup(
|
||||
prompt_set_id=_string(group, "prompt_set_id"),
|
||||
captions=tuple(raw_captions),
|
||||
)
|
||||
)
|
||||
return OpenVocabularySemanticProfile(
|
||||
profile_id=_string(root, "profile_id"),
|
||||
session_id=_string(source, "session_id"),
|
||||
source_id=_string(source, "source_id"),
|
||||
camera_source_id=_string(source, "camera_source_id"),
|
||||
coordinate_space=_string(source, "coordinate_space"),
|
||||
width=_integer(source, "width"),
|
||||
height=_integer(source, "height"),
|
||||
valid_fov_result_id=_string(source, "valid_fov_result_id"),
|
||||
valid_fov_mask_sha256=_string(source, "valid_fov_mask_sha256"),
|
||||
valid_fov_fill_value=_integer(source, "valid_fov_fill_value"),
|
||||
provider_id=_string(provider, "provider_id"),
|
||||
provider_name=_string(provider, "name"),
|
||||
model_id=_string(provider, "model_id"),
|
||||
model_revision=_string(provider, "model_revision"),
|
||||
model_sha256=_string(provider, "model_sha256"),
|
||||
engine_sha256=_string(provider, "engine_sha256"),
|
||||
container_reference=_string(provider, "container_reference"),
|
||||
container_image_id=_string(provider, "container_image_id"),
|
||||
preprocess_id=_string(provider, "preprocess_id"),
|
||||
engine_input_width=_integer(provider, "engine_input_width"),
|
||||
engine_input_height=_integer(provider, "engine_input_height"),
|
||||
minimum_input_confidence=_number(postprocessing, "minimum_input_confidence"),
|
||||
minimum_box_area_fraction=_number(postprocessing, "minimum_box_area_fraction"),
|
||||
maximum_box_area_fraction=_number(postprocessing, "maximum_box_area_fraction"),
|
||||
minimum_valid_fov_fraction=_number(postprocessing, "minimum_valid_fov_fraction"),
|
||||
require_center_inside_valid_fov=_boolean(postprocessing, "require_center_inside_valid_fov"),
|
||||
fusion_iou_threshold=_number(postprocessing, "fusion_iou_threshold"),
|
||||
selected_minimum_confidence=_number(postprocessing, "selected_minimum_confidence"),
|
||||
selected_minimum_margin=_number(postprocessing, "selected_minimum_margin"),
|
||||
max_hypotheses=_integer(postprocessing, "max_hypotheses"),
|
||||
prompt_groups=tuple(groups),
|
||||
vocabulary_id=_string(root, "semantic_vocabulary_id"),
|
||||
risk_policy_id=_string(root, "risk_policy_id"),
|
||||
)
|
||||
|
||||
|
||||
def parse_tao_grounding_dino_labels(
|
||||
path: Path,
|
||||
*,
|
||||
source_id: str,
|
||||
frame_id: str,
|
||||
prompt_set_id: str,
|
||||
profile: OpenVocabularySemanticProfile,
|
||||
image_width: int | None = None,
|
||||
image_height: int | None = None,
|
||||
offset_x: float = 0.0,
|
||||
offset_y: float = 0.0,
|
||||
detection_scope_id: str | None = None,
|
||||
) -> tuple[OpenVocabularyDetection, ...]:
|
||||
"""Parse one TAO label file while preserving original source-image boxes."""
|
||||
|
||||
group = profile.prompt_group(prompt_set_id)
|
||||
allowed = {normalize_raw_label(item) for item in group.captions}
|
||||
local_width = profile.width if image_width is None else image_width
|
||||
local_height = profile.height if image_height is None else image_height
|
||||
if (
|
||||
local_width < 1
|
||||
or local_height < 1
|
||||
or offset_x < 0.0
|
||||
or offset_y < 0.0
|
||||
or offset_x + local_width > profile.width
|
||||
or offset_y + local_height > profile.height
|
||||
):
|
||||
raise OpenVocabularySemanticError("semantic label image window is invalid")
|
||||
scope_id = frame_id if detection_scope_id is None else detection_scope_id
|
||||
if not scope_id:
|
||||
raise OpenVocabularySemanticError("semantic detection scope is invalid")
|
||||
resolved = path.resolve(strict=True)
|
||||
if resolved.is_symlink() or not resolved.is_file():
|
||||
raise OpenVocabularySemanticError("semantic label ledger must be a regular file")
|
||||
detections: list[OpenVocabularyDetection] = []
|
||||
for line_number, raw_line in enumerate(resolved.read_text("utf-8").splitlines(), start=1):
|
||||
if not raw_line.strip():
|
||||
continue
|
||||
parts = raw_line.split()
|
||||
if len(parts) <= TAO_GROUNDING_DINO_TRAILING_FIELDS:
|
||||
raise OpenVocabularySemanticError("TAO semantic label row is incomplete")
|
||||
label_parts = parts[:-TAO_GROUNDING_DINO_TRAILING_FIELDS]
|
||||
numeric_parts = parts[-TAO_GROUNDING_DINO_TRAILING_FIELDS:]
|
||||
raw_label = " ".join(label_parts)
|
||||
if normalize_raw_label(raw_label) not in allowed:
|
||||
raise OpenVocabularySemanticError("TAO semantic label escaped its prompt set")
|
||||
try:
|
||||
values = tuple(float(item) for item in numeric_parts)
|
||||
except ValueError as exc:
|
||||
raise OpenVocabularySemanticError("TAO semantic label row is not numeric") from exc
|
||||
if any(not math.isfinite(item) for item in values):
|
||||
raise OpenVocabularySemanticError("TAO semantic label row is not finite")
|
||||
x_min, y_min, x_max, y_max = values[3:7]
|
||||
if x_min < 0.0 or y_min < 0.0 or x_max > local_width or y_max > local_height:
|
||||
raise OpenVocabularySemanticError(
|
||||
"TAO semantic box is not in the declared raw image coordinate space"
|
||||
)
|
||||
detections.append(
|
||||
OpenVocabularyDetection(
|
||||
detection_id=(f"{scope_id}:{prompt_set_id}:label-{line_number:04d}"),
|
||||
source_id=source_id,
|
||||
frame_id=frame_id,
|
||||
prompt_set_id=prompt_set_id,
|
||||
raw_label=raw_label,
|
||||
confidence=values[-1],
|
||||
region=BoundingRegion2D(
|
||||
x_min + offset_x,
|
||||
y_min + offset_y,
|
||||
x_max + offset_x,
|
||||
y_max + offset_y,
|
||||
),
|
||||
)
|
||||
)
|
||||
return tuple(detections)
|
||||
|
||||
|
||||
def fuse_open_vocabulary_detections(
|
||||
detections: tuple[OpenVocabularyDetection, ...],
|
||||
*,
|
||||
profile: OpenVocabularySemanticProfile,
|
||||
vocabulary: ObjectSemanticVocabulary,
|
||||
valid_fov_mask: NDArray[np.bool_],
|
||||
) -> SemanticFusionResult:
|
||||
"""Fuse prompt collisions spatially before the geometry provider owns points."""
|
||||
|
||||
if vocabulary.vocabulary_id != profile.vocabulary_id:
|
||||
raise OpenVocabularySemanticError("semantic profile and vocabulary disagree")
|
||||
if profile.max_hypotheses > vocabulary.max_hypotheses:
|
||||
raise OpenVocabularySemanticError("semantic profile exceeds vocabulary top-k")
|
||||
if not detections:
|
||||
return SemanticFusionResult((), 0, 0, 0, 0)
|
||||
if valid_fov_mask.shape != (profile.height, profile.width) or valid_fov_mask.dtype != np.bool_:
|
||||
raise OpenVocabularySemanticError("semantic valid-FOV mask is incompatible")
|
||||
source_ids = {item.source_id for item in detections}
|
||||
frame_ids = {item.frame_id for item in detections}
|
||||
if source_ids != {profile.source_id} or len(frame_ids) != 1:
|
||||
raise OpenVocabularySemanticError("semantic detections escaped one source frame")
|
||||
below_confidence = 0
|
||||
invalid_area = 0
|
||||
outside_valid_fov = 0
|
||||
retained: list[OpenVocabularyDetection] = []
|
||||
raster_area = float(profile.width * profile.height)
|
||||
for detection in detections:
|
||||
profile.prompt_group(detection.prompt_set_id)
|
||||
if vocabulary.resolve_label(detection.raw_label) is None:
|
||||
raise OpenVocabularySemanticError("semantic label is not in the vocabulary")
|
||||
if detection.confidence < profile.minimum_input_confidence:
|
||||
below_confidence += 1
|
||||
continue
|
||||
area_fraction = _area(detection.region) / raster_area
|
||||
if not (
|
||||
profile.minimum_box_area_fraction <= area_fraction <= profile.maximum_box_area_fraction
|
||||
):
|
||||
invalid_area += 1
|
||||
continue
|
||||
valid_fraction, center_inside = _valid_fov_support(detection.region, valid_fov_mask)
|
||||
if valid_fraction < profile.minimum_valid_fov_fraction or (
|
||||
profile.require_center_inside_valid_fov and not center_inside
|
||||
):
|
||||
outside_valid_fov += 1
|
||||
continue
|
||||
retained.append(detection)
|
||||
retained.sort(key=_detection_priority)
|
||||
clusters: list[list[OpenVocabularyDetection]] = []
|
||||
for detection in retained:
|
||||
spatial_candidates = tuple(
|
||||
(index, max(_iou(detection.region, item.region) for item in cluster))
|
||||
for index, cluster in enumerate(clusters)
|
||||
)
|
||||
match = max(spatial_candidates, key=lambda item: (item[1], -item[0]), default=None)
|
||||
if match is not None and match[1] >= profile.fusion_iou_threshold:
|
||||
clusters[match[0]].append(detection)
|
||||
else:
|
||||
clusters.append([detection])
|
||||
frame_id = next(iter(frame_ids))
|
||||
bindings: list[SemanticProposalBinding] = []
|
||||
for ordinal, cluster in enumerate(clusters):
|
||||
ordered = tuple(sorted(cluster, key=_detection_priority))
|
||||
semantic_candidates = _canonical_candidates(ordered, vocabulary, profile.max_hypotheses)
|
||||
if not semantic_candidates:
|
||||
raise OpenVocabularySemanticError("semantic cluster lost all hypotheses")
|
||||
anchor = ordered[0]
|
||||
proposal_id = f"semantic-{frame_id}-{ordinal:04d}"
|
||||
proposal = ObjectProposal2D(
|
||||
proposal_id=proposal_id,
|
||||
source_id=anchor.source_id,
|
||||
frame_id=anchor.frame_id,
|
||||
region=anchor.region,
|
||||
objectness=anchor.confidence,
|
||||
provider_id=profile.provider_id,
|
||||
model_id=profile.model_id,
|
||||
preprocess_id=profile.preprocess_id,
|
||||
semantic_hint=semantic_candidates[0][0],
|
||||
)
|
||||
bindings.append(SemanticProposalBinding(proposal=proposal, detections=ordered))
|
||||
return SemanticFusionResult(
|
||||
bindings=tuple(bindings),
|
||||
input_detection_count=len(detections),
|
||||
below_confidence_count=below_confidence,
|
||||
invalid_area_count=invalid_area,
|
||||
outside_valid_fov_count=outside_valid_fov,
|
||||
)
|
||||
|
||||
|
||||
def bind_object_understandings(
|
||||
observations: tuple[ObstacleObservation, ...],
|
||||
*,
|
||||
bindings: tuple[SemanticProposalBinding, ...],
|
||||
profile: OpenVocabularySemanticProfile,
|
||||
vocabulary: ObjectSemanticVocabulary,
|
||||
generated_monotonic_ns: int,
|
||||
) -> tuple[ObjectUnderstanding, ...]:
|
||||
"""Compose semantic hypotheses around geometry-owned obstacle observations."""
|
||||
|
||||
by_proposal = {item.proposal.proposal_id: item for item in bindings}
|
||||
if len(by_proposal) != len(bindings):
|
||||
raise OpenVocabularySemanticError("semantic proposal bindings are duplicated")
|
||||
understandings: list[ObjectUnderstanding] = []
|
||||
seen_proposals: set[str] = set()
|
||||
for observation in observations:
|
||||
if not observation.proposal_ids:
|
||||
understanding = _unknown_understanding(
|
||||
observation,
|
||||
profile=profile,
|
||||
vocabulary=vocabulary,
|
||||
generated_monotonic_ns=generated_monotonic_ns,
|
||||
reason="geometry-only-semantic-evidence-unavailable",
|
||||
)
|
||||
else:
|
||||
if len(observation.proposal_ids) != 1:
|
||||
raise OpenVocabularySemanticError("semantic observation ownership is ambiguous")
|
||||
proposal_id = observation.proposal_ids[0]
|
||||
binding = by_proposal.get(proposal_id)
|
||||
if binding is None:
|
||||
raise OpenVocabularySemanticError(
|
||||
"geometry references an unknown semantic proposal"
|
||||
)
|
||||
seen_proposals.add(proposal_id)
|
||||
understanding = _semantic_understanding(
|
||||
observation,
|
||||
detections=binding.detections,
|
||||
profile=profile,
|
||||
vocabulary=vocabulary,
|
||||
generated_monotonic_ns=generated_monotonic_ns,
|
||||
)
|
||||
validate_object_understanding(understanding, vocabulary)
|
||||
understandings.append(understanding)
|
||||
if seen_proposals != set(by_proposal):
|
||||
raise OpenVocabularySemanticError("a semantic proposal has no geometry observation")
|
||||
return tuple(understandings)
|
||||
|
||||
|
||||
def understand_geometry_observation(
|
||||
observation: ObstacleObservation,
|
||||
*,
|
||||
detections: tuple[OpenVocabularyDetection, ...],
|
||||
profile: OpenVocabularySemanticProfile,
|
||||
vocabulary: ObjectSemanticVocabulary,
|
||||
generated_monotonic_ns: int,
|
||||
) -> ObjectUnderstanding:
|
||||
"""Attach ROI semantics directly to geometry without changing its identity."""
|
||||
|
||||
if not detections:
|
||||
result = _unknown_understanding(
|
||||
observation,
|
||||
profile=profile,
|
||||
vocabulary=vocabulary,
|
||||
generated_monotonic_ns=generated_monotonic_ns,
|
||||
reason="geometry-roi-semantic-evidence-unavailable",
|
||||
)
|
||||
else:
|
||||
result = _semantic_understanding(
|
||||
observation,
|
||||
detections=detections,
|
||||
profile=profile,
|
||||
vocabulary=vocabulary,
|
||||
generated_monotonic_ns=generated_monotonic_ns,
|
||||
)
|
||||
validate_object_understanding(result, vocabulary)
|
||||
return result
|
||||
|
||||
|
||||
def _semantic_understanding(
|
||||
observation: ObstacleObservation,
|
||||
*,
|
||||
detections: tuple[OpenVocabularyDetection, ...],
|
||||
profile: OpenVocabularySemanticProfile,
|
||||
vocabulary: ObjectSemanticVocabulary,
|
||||
generated_monotonic_ns: int,
|
||||
) -> ObjectUnderstanding:
|
||||
candidates = _canonical_candidates(
|
||||
detections,
|
||||
vocabulary,
|
||||
profile.max_hypotheses,
|
||||
)
|
||||
hypotheses: list[SemanticHypothesis] = []
|
||||
referenced: dict[str, OpenVocabularyDetection] = {}
|
||||
for rank, (class_id, raw_label, confidence, evidence) in enumerate(candidates, start=1):
|
||||
for detection in evidence:
|
||||
referenced[detection.evidence_id] = detection
|
||||
hypotheses.append(
|
||||
SemanticHypothesis(
|
||||
rank=rank,
|
||||
class_id=class_id,
|
||||
raw_label=raw_label,
|
||||
confidence=confidence,
|
||||
evidence_ids=tuple(item.evidence_id for item in evidence),
|
||||
)
|
||||
)
|
||||
top = hypotheses[0]
|
||||
runner_up = hypotheses[1] if len(hypotheses) > 1 else None
|
||||
if top.confidence < profile.selected_minimum_confidence:
|
||||
semantic = SemanticDecision(
|
||||
resolution=SemanticResolution.UNRESOLVED,
|
||||
selected_class_id=None,
|
||||
selected_confidence=None,
|
||||
reason_codes=("top-hypothesis-below-selection-threshold",),
|
||||
)
|
||||
elif (
|
||||
runner_up is not None
|
||||
and top.confidence - runner_up.confidence < profile.selected_minimum_margin
|
||||
):
|
||||
semantic = SemanticDecision(
|
||||
resolution=SemanticResolution.AMBIGUOUS,
|
||||
selected_class_id=None,
|
||||
selected_confidence=None,
|
||||
reason_codes=("top-hypothesis-margin-insufficient",),
|
||||
)
|
||||
else:
|
||||
semantic = SemanticDecision(
|
||||
resolution=SemanticResolution.SELECTED,
|
||||
selected_class_id=top.class_id,
|
||||
selected_confidence=top.confidence,
|
||||
reason_codes=("top-hypothesis-qualified",),
|
||||
)
|
||||
if semantic.resolution is SemanticResolution.SELECTED:
|
||||
definition = vocabulary.class_definition(top.class_id)
|
||||
agency = definition.agency_prior
|
||||
if agency is AgencyState.UNKNOWN:
|
||||
agency_basis = StateBasis.UNKNOWN
|
||||
state_evidence: tuple[str, ...] = ()
|
||||
state_reasons: tuple[str, ...] = (
|
||||
"observed-motion-unavailable",
|
||||
"selected-class-has-no-agency-prior",
|
||||
)
|
||||
else:
|
||||
agency_basis = StateBasis.CLASS_PRIOR
|
||||
state_evidence = top.evidence_ids
|
||||
state_reasons = (
|
||||
"observed-motion-unavailable",
|
||||
"agency-from-selected-class-prior",
|
||||
)
|
||||
else:
|
||||
agency = AgencyState.UNKNOWN
|
||||
agency_basis = StateBasis.UNKNOWN
|
||||
state_evidence = ()
|
||||
state_reasons = ("observed-motion-and-resolved-agency-unavailable",)
|
||||
provenance = tuple(
|
||||
EvidenceProvenance(
|
||||
evidence_id=detection.evidence_id,
|
||||
kind=EvidenceKind.DETECTOR,
|
||||
source_id=observation.source_id,
|
||||
frame_id=observation.frame_id,
|
||||
provider_id=profile.provider_id,
|
||||
model_id=profile.model_id,
|
||||
model_revision=profile.model_revision,
|
||||
preprocess_id=profile.preprocess_id,
|
||||
prompt_set_id=detection.prompt_set_id,
|
||||
)
|
||||
for detection in sorted(referenced.values(), key=lambda item: item.evidence_id)
|
||||
)
|
||||
return ObjectUnderstanding(
|
||||
understanding_id=f"{observation.observation_id}:understanding",
|
||||
vocabulary_id=vocabulary.vocabulary_id,
|
||||
generated_monotonic_ns=generated_monotonic_ns,
|
||||
observation=observation,
|
||||
hypotheses=tuple(hypotheses),
|
||||
semantic=semantic,
|
||||
state=ObjectStateEstimate(
|
||||
motion=MotionState.UNKNOWN,
|
||||
motion_confidence=0.0,
|
||||
agency=agency,
|
||||
agency_basis=agency_basis,
|
||||
evidence_ids=state_evidence,
|
||||
reason_codes=state_reasons,
|
||||
),
|
||||
risk=_unknown_risk(profile, "semantic-shadow-has-no-risk-authority"),
|
||||
provenance=provenance,
|
||||
)
|
||||
|
||||
|
||||
def _unknown_understanding(
|
||||
observation: ObstacleObservation,
|
||||
*,
|
||||
profile: OpenVocabularySemanticProfile,
|
||||
vocabulary: ObjectSemanticVocabulary,
|
||||
generated_monotonic_ns: int,
|
||||
reason: str,
|
||||
) -> ObjectUnderstanding:
|
||||
return ObjectUnderstanding(
|
||||
understanding_id=f"{observation.observation_id}:understanding",
|
||||
vocabulary_id=vocabulary.vocabulary_id,
|
||||
generated_monotonic_ns=generated_monotonic_ns,
|
||||
observation=observation,
|
||||
hypotheses=(),
|
||||
semantic=SemanticDecision(
|
||||
resolution=SemanticResolution.UNRESOLVED,
|
||||
selected_class_id=None,
|
||||
selected_confidence=None,
|
||||
reason_codes=(reason,),
|
||||
),
|
||||
state=ObjectStateEstimate(
|
||||
motion=MotionState.UNKNOWN,
|
||||
motion_confidence=0.0,
|
||||
agency=AgencyState.UNKNOWN,
|
||||
agency_basis=StateBasis.UNKNOWN,
|
||||
evidence_ids=(),
|
||||
reason_codes=("state-evidence-unavailable",),
|
||||
),
|
||||
risk=_unknown_risk(profile, "unknown-object-remains-route-around-obstacle"),
|
||||
provenance=(),
|
||||
)
|
||||
|
||||
|
||||
def _unknown_risk(
|
||||
profile: OpenVocabularySemanticProfile,
|
||||
reason: str,
|
||||
) -> AdvisoryRiskAssessment:
|
||||
return AdvisoryRiskAssessment(
|
||||
policy_id=profile.risk_policy_id,
|
||||
level=RiskLevel.UNKNOWN,
|
||||
confidence=0.0,
|
||||
basis=RiskBasis.UNKNOWN,
|
||||
responses=(AdvisoryResponse.ROUTE_AROUND,),
|
||||
evidence_ids=(),
|
||||
reason_codes=(reason,),
|
||||
)
|
||||
|
||||
|
||||
def _canonical_candidates(
|
||||
detections: tuple[OpenVocabularyDetection, ...],
|
||||
vocabulary: ObjectSemanticVocabulary,
|
||||
maximum: int,
|
||||
) -> tuple[tuple[str, str, float, tuple[OpenVocabularyDetection, ...]], ...]:
|
||||
by_class: dict[str, list[OpenVocabularyDetection]] = {}
|
||||
for detection in detections:
|
||||
class_id = vocabulary.resolve_label(detection.raw_label)
|
||||
if class_id is None:
|
||||
raise OpenVocabularySemanticError("semantic label is not in the vocabulary")
|
||||
by_class.setdefault(class_id, []).append(detection)
|
||||
candidates: list[tuple[str, str, float, tuple[OpenVocabularyDetection, ...]]] = []
|
||||
for class_id, evidence in by_class.items():
|
||||
ordered = tuple(sorted(evidence, key=_detection_priority))
|
||||
candidates.append((class_id, ordered[0].raw_label, ordered[0].confidence, ordered))
|
||||
candidates.sort(key=lambda item: (-item[2], item[0], normalize_raw_label(item[1])))
|
||||
return tuple(candidates[:maximum])
|
||||
|
||||
|
||||
def _detection_priority(item: OpenVocabularyDetection) -> tuple[float, float, str]:
|
||||
return (-item.confidence, _area(item.region), item.detection_id)
|
||||
|
||||
|
||||
def _area(region: BoundingRegion2D) -> float:
|
||||
return (region.x_max - region.x_min) * (region.y_max - region.y_min)
|
||||
|
||||
|
||||
def _iou(left: BoundingRegion2D, right: BoundingRegion2D) -> float:
|
||||
x_min = max(left.x_min, right.x_min)
|
||||
y_min = max(left.y_min, right.y_min)
|
||||
x_max = min(left.x_max, right.x_max)
|
||||
y_max = min(left.y_max, right.y_max)
|
||||
intersection = max(0.0, x_max - x_min) * max(0.0, y_max - y_min)
|
||||
union = _area(left) + _area(right) - intersection
|
||||
return 0.0 if union <= 0.0 else intersection / union
|
||||
|
||||
|
||||
def _valid_fov_support(
|
||||
region: BoundingRegion2D,
|
||||
mask: NDArray[np.bool_],
|
||||
) -> tuple[float, bool]:
|
||||
height, width = mask.shape
|
||||
x_min = int(np.clip(math.floor(region.x_min), 0, width))
|
||||
y_min = int(np.clip(math.floor(region.y_min), 0, height))
|
||||
x_max = int(np.clip(math.ceil(region.x_max), 0, width))
|
||||
y_max = int(np.clip(math.ceil(region.y_max), 0, height))
|
||||
area = max(0, x_max - x_min) * max(0, y_max - y_min)
|
||||
if area == 0:
|
||||
return 0.0, False
|
||||
valid_fraction = float(np.count_nonzero(mask[y_min:y_max, x_min:x_max])) / area
|
||||
center_x = int(np.clip(round((region.x_min + region.x_max) / 2.0), 0, width - 1))
|
||||
center_y = int(np.clip(round((region.y_min + region.y_max) / 2.0), 0, height - 1))
|
||||
return valid_fraction, bool(mask[center_y, center_x])
|
||||
|
||||
|
||||
def _false_authority() -> dict[str, bool]:
|
||||
return {
|
||||
"ground_truth": False,
|
||||
"independent_truth": False,
|
||||
"candidate_accepted": False,
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, object]:
|
||||
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
||||
raise OpenVocabularySemanticError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _string(document: dict[str, object], key: str) -> str:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise OpenVocabularySemanticError(f"{key} must be a nonempty string")
|
||||
return value
|
||||
|
||||
|
||||
def _integer(document: dict[str, object], key: str) -> int:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise OpenVocabularySemanticError(f"{key} must be an integer")
|
||||
return value
|
||||
|
||||
|
||||
def _number(document: dict[str, object], key: str) -> float:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||
raise OpenVocabularySemanticError(f"{key} must be numeric")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _boolean(document: dict[str, object], key: str) -> bool:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, bool):
|
||||
raise OpenVocabularySemanticError(f"{key} must be boolean")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OPEN_VOCABULARY_SEMANTIC_SHADOW_PROFILE_SCHEMA",
|
||||
"OpenVocabularyDetection",
|
||||
"OpenVocabularySemanticError",
|
||||
"OpenVocabularySemanticProfile",
|
||||
"PromptGroup",
|
||||
"SemanticFusionResult",
|
||||
"SemanticProposalBinding",
|
||||
"bind_object_understandings",
|
||||
"fuse_open_vocabulary_detections",
|
||||
"load_open_vocabulary_semantic_profile",
|
||||
"parse_tao_grounding_dino_labels",
|
||||
"understand_geometry_observation",
|
||||
]
|
||||
@@ -0,0 +1,768 @@
|
||||
"""Immutable bounded M48S replay for raw-KB4 semantic object understanding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import numpy as np
|
||||
import yaml
|
||||
from numpy.typing import NDArray
|
||||
from PIL import Image
|
||||
|
||||
from .baseline import BASELINE_RECORDED_JOB_ID
|
||||
from .contracts import EvidenceBasis, ObstacleObservation, SourceEnvelope
|
||||
from .detector_replay_result import (
|
||||
read_detector_replay_result,
|
||||
require_m4_detector_replay_acceptance,
|
||||
)
|
||||
from .geometry import (
|
||||
DEFAULT_GEOMETRY_PROFILE_PATH,
|
||||
Ravnoves00GeometryAssociationProvider,
|
||||
RecordedGeometryStore,
|
||||
load_geometry_profile,
|
||||
)
|
||||
from .graph_validation import validate_observations
|
||||
from .object_understanding import (
|
||||
ObjectUnderstanding,
|
||||
SemanticResolution,
|
||||
load_object_semantic_vocabulary,
|
||||
)
|
||||
from .open_vocabulary_semantics import (
|
||||
OpenVocabularyDetection,
|
||||
OpenVocabularySemanticError,
|
||||
OpenVocabularySemanticProfile,
|
||||
bind_object_understandings,
|
||||
fuse_open_vocabulary_detections,
|
||||
load_open_vocabulary_semantic_profile,
|
||||
parse_tao_grounding_dino_labels,
|
||||
)
|
||||
from .providers import SourcePacket
|
||||
from .recorded_source import RECORDED_SOURCE_PACK_ID, RecordedFrameReference
|
||||
from .yolox_object_detector import load_valid_fov_mask
|
||||
|
||||
SEMANTIC_SHADOW_REPLAY_SCHEMA: Final = "missioncore.m48s-semantic-shadow-replay/v0"
|
||||
SEMANTIC_SHADOW_REPORT_SCHEMA: Final = "missioncore.m48s-semantic-shadow-report/v0"
|
||||
SEMANTIC_WORKER_EXECUTION_SCHEMA: Final = "missioncore.m48s-semantic-worker-execution/v0"
|
||||
SEMANTIC_SHADOW_RESULT_PREFIX: Final = "m48s-semantic-shadow-"
|
||||
SEMANTIC_SHADOW_FRAMES_NAME: Final = "frames.jsonl"
|
||||
SEMANTIC_SHADOW_REPORT_NAME: Final = "report.json"
|
||||
SEMANTIC_SHADOW_MANIFEST_NAME: Final = "manifest.json"
|
||||
|
||||
|
||||
class SemanticShadowReplayError(RuntimeError):
|
||||
"""Bounded semantic shadow evidence is incomplete or internally inconsistent."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticShadowReplayResult:
|
||||
result_id: str
|
||||
result_root: Path
|
||||
completed: bool
|
||||
accepted: bool
|
||||
metrics: dict[str, object]
|
||||
report: dict[str, object]
|
||||
manifest: dict[str, object]
|
||||
|
||||
|
||||
def build_semantic_shadow_replay(
|
||||
*,
|
||||
repository_root: Path,
|
||||
profile_path: Path,
|
||||
vocabulary_path: Path,
|
||||
detector_result_root: Path,
|
||||
source_frames_root: Path,
|
||||
inference_frames_root: Path,
|
||||
valid_fov_mask_path: Path,
|
||||
worker_result_roots: Mapping[str, Path],
|
||||
worker_identity_path: Path,
|
||||
frame_indices: tuple[int, ...],
|
||||
output_root: Path,
|
||||
) -> SemanticShadowReplayResult:
|
||||
"""Bind exact Worker labels to admitted geometry for a bounded frame set."""
|
||||
|
||||
repository = repository_root.resolve(strict=True)
|
||||
profile = load_open_vocabulary_semantic_profile(profile_path)
|
||||
vocabulary = load_object_semantic_vocabulary(vocabulary_path)
|
||||
if profile.vocabulary_id != vocabulary.vocabulary_id:
|
||||
raise SemanticShadowReplayError("semantic profile and vocabulary disagree")
|
||||
frames = _frame_selection(frame_indices)
|
||||
worker_identity = _validate_worker_identity(
|
||||
worker_identity_path,
|
||||
profile=profile,
|
||||
frame_indices=frames,
|
||||
)
|
||||
worker_artifacts = _validate_worker_results(
|
||||
worker_result_roots,
|
||||
profile=profile,
|
||||
frame_indices=frames,
|
||||
)
|
||||
valid_fov_mask = load_valid_fov_mask(
|
||||
valid_fov_mask_path,
|
||||
expected_sha256=profile.valid_fov_mask_sha256,
|
||||
)
|
||||
source_artifacts = _validate_source_frames(
|
||||
source_frames_root,
|
||||
profile=profile,
|
||||
frame_indices=frames,
|
||||
)
|
||||
inference_artifacts = _validate_inference_frames(
|
||||
inference_frames_root,
|
||||
source_frames_root=source_frames_root,
|
||||
valid_fov_mask=valid_fov_mask,
|
||||
profile=profile,
|
||||
frame_indices=frames,
|
||||
)
|
||||
detector = read_detector_replay_result(detector_result_root)
|
||||
require_m4_detector_replay_acceptance(detector)
|
||||
detector_by_sequence = {item.sequence: item for item in detector.frames}
|
||||
if any(index not in detector_by_sequence for index in frames):
|
||||
raise SemanticShadowReplayError("semantic frame escaped the detector timeline")
|
||||
geometry_profile = load_geometry_profile(repository / DEFAULT_GEOMETRY_PROFILE_PATH)
|
||||
store = RecordedGeometryStore.from_repository(repository, profile=geometry_profile)
|
||||
geometry = Ravnoves00GeometryAssociationProvider(store=store)
|
||||
|
||||
output = output_root.expanduser().absolute()
|
||||
output.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
staging = output / f".semantic-shadow.{uuid.uuid4().hex}.tmp"
|
||||
staging.mkdir(mode=0o700, exist_ok=False)
|
||||
class_counts: Counter[str] = Counter()
|
||||
resolution_counts: Counter[str] = Counter()
|
||||
totals: Counter[str] = Counter()
|
||||
try:
|
||||
frames_path = staging / SEMANTIC_SHADOW_FRAMES_NAME
|
||||
with frames_path.open("wb") as ledger:
|
||||
for frame_index in frames:
|
||||
detector_frame = detector_by_sequence[frame_index]
|
||||
if detector_frame.outcome != "completed":
|
||||
raise SemanticShadowReplayError("accepted detector source frame failed")
|
||||
envelope = detector_frame.envelope
|
||||
detections = _frame_detections(
|
||||
worker_result_roots,
|
||||
frame_index=frame_index,
|
||||
envelope=envelope,
|
||||
profile=profile,
|
||||
)
|
||||
fusion = fuse_open_vocabulary_detections(
|
||||
detections,
|
||||
profile=profile,
|
||||
vocabulary=vocabulary,
|
||||
valid_fov_mask=valid_fov_mask,
|
||||
)
|
||||
packet = semantic_replay_packet(envelope)
|
||||
observations = geometry.associate(packet, fusion.proposals)
|
||||
validate_observations(packet, fusion.proposals, observations)
|
||||
understandings = bind_object_understandings(
|
||||
observations,
|
||||
bindings=fusion.bindings,
|
||||
profile=profile,
|
||||
vocabulary=vocabulary,
|
||||
generated_monotonic_ns=envelope.timestamps.monotonic_ns,
|
||||
)
|
||||
frame_metrics = _frame_metrics(
|
||||
detections=detections,
|
||||
fusion_input_count=fusion.input_detection_count,
|
||||
below_confidence_count=fusion.below_confidence_count,
|
||||
invalid_area_count=fusion.invalid_area_count,
|
||||
outside_valid_fov_count=fusion.outside_valid_fov_count,
|
||||
retained_detection_count=fusion.retained_detection_count,
|
||||
proposal_count=len(fusion.proposals),
|
||||
observations=observations,
|
||||
understandings=understandings,
|
||||
)
|
||||
for key, value in frame_metrics.items():
|
||||
totals[key] += value
|
||||
for understanding in understandings:
|
||||
resolution_counts[understanding.semantic.resolution.value] += 1
|
||||
if (
|
||||
understanding.semantic.resolution is SemanticResolution.SELECTED
|
||||
and understanding.semantic.selected_class_id is not None
|
||||
):
|
||||
class_counts[understanding.semantic.selected_class_id] += 1
|
||||
document = {
|
||||
"schema_version": "missioncore.m48s-semantic-shadow-frame/v0",
|
||||
"sequence": frame_index,
|
||||
"frame_id": envelope.frame_id,
|
||||
"source_envelope": envelope.to_dict(),
|
||||
"source_frame": source_artifacts[frame_index],
|
||||
"inference_frame": inference_artifacts[frame_index],
|
||||
"detections": [_detection_document(item) for item in detections],
|
||||
"proposals": [item.to_dict() for item in fusion.proposals],
|
||||
"understandings": [item.to_dict() for item in understandings],
|
||||
"metrics": frame_metrics,
|
||||
"authority": _false_authority(),
|
||||
}
|
||||
ledger.write(_canonical_json(document) + b"\n")
|
||||
metrics = {
|
||||
"frames": {
|
||||
"requested": len(frames),
|
||||
"completed": len(frames),
|
||||
"source_available": sum(
|
||||
detector_by_sequence[index].envelope.registered_point_increment.available
|
||||
for index in frames
|
||||
),
|
||||
},
|
||||
"detections": {
|
||||
"raw": totals["raw_detection_count"],
|
||||
"below_confidence": totals["below_confidence_count"],
|
||||
"invalid_area": totals["invalid_area_count"],
|
||||
"outside_valid_fov": totals["outside_valid_fov_count"],
|
||||
"retained": totals["retained_detection_count"],
|
||||
"fused_proposals": totals["proposal_count"],
|
||||
},
|
||||
"geometry": {
|
||||
"proposal_observations": totals["proposal_observation_count"],
|
||||
"ranged_proposal_observations": totals["ranged_proposal_observation_count"],
|
||||
"camera_only_proposal_observations": totals[
|
||||
"camera_only_proposal_observation_count"
|
||||
],
|
||||
"conflict_proposal_observations": totals["conflict_proposal_observation_count"],
|
||||
"geometry_only_observations": totals["geometry_only_observation_count"],
|
||||
},
|
||||
"semantics": {
|
||||
"resolution_counts": dict(sorted(resolution_counts.items())),
|
||||
"selected_class_counts": dict(sorted(class_counts.items())),
|
||||
},
|
||||
"authority": _false_authority(),
|
||||
}
|
||||
completed = (
|
||||
metrics["frames"]["completed"] == len(frames) # type: ignore[index]
|
||||
and totals["proposal_observation_count"] == totals["proposal_count"]
|
||||
and sum(resolution_counts.values())
|
||||
== totals["proposal_observation_count"] + totals["geometry_only_observation_count"]
|
||||
)
|
||||
frames_sha256 = _file_sha256(frames_path)
|
||||
detector_identity = _object(detector.manifest.get("identity"), "detector identity")
|
||||
identity = {
|
||||
"schema_version": SEMANTIC_SHADOW_REPLAY_SCHEMA,
|
||||
"profile_id": profile.profile_id,
|
||||
"profile_sha256": _file_sha256(profile_path),
|
||||
"vocabulary_id": vocabulary.vocabulary_id,
|
||||
"vocabulary_sha256": _file_sha256(vocabulary_path),
|
||||
"frame_indices": list(frames),
|
||||
"detector_result_id": detector.result_id,
|
||||
"detector_frames_sha256": detector_identity.get("frames_sha256"),
|
||||
"geometry_profile_id": geometry_profile.profile_id,
|
||||
"geometry_profile_sha256": geometry_profile.profile_sha256,
|
||||
"source_pack_id": geometry_profile.source_pack_id,
|
||||
"source_pack_sha256": geometry_profile.source_pack_sha256,
|
||||
"valid_fov": {
|
||||
"result_id": profile.valid_fov_result_id,
|
||||
"mask_sha256": profile.valid_fov_mask_sha256,
|
||||
"fill_value": profile.valid_fov_fill_value,
|
||||
},
|
||||
"source_frames": [source_artifacts[index] for index in frames],
|
||||
"inference_frames": [inference_artifacts[index] for index in frames],
|
||||
"worker_execution": worker_identity,
|
||||
"worker_execution_sha256": _file_sha256(worker_identity_path),
|
||||
"worker_artifacts": worker_artifacts,
|
||||
"producer_sha256": {
|
||||
name: _file_sha256(repository / "src/k1link/perception" / name)
|
||||
for name in (
|
||||
"object_understanding.py",
|
||||
"open_vocabulary_semantics.py",
|
||||
"semantic_shadow_replay.py",
|
||||
)
|
||||
},
|
||||
"frames_sha256": frames_sha256,
|
||||
"metrics": metrics,
|
||||
"completed": completed,
|
||||
"accepted": False,
|
||||
"authority": _false_authority(),
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
result_id = f"{SEMANTIC_SHADOW_RESULT_PREFIX}{identity_sha256}"
|
||||
created = datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
report = {
|
||||
"schema_version": SEMANTIC_SHADOW_REPORT_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"identity_sha256": identity_sha256,
|
||||
"created_at_utc": created,
|
||||
"status": (
|
||||
"completed-shadow-evidence-not-accepted"
|
||||
if completed
|
||||
else "rejected-incomplete-shadow"
|
||||
),
|
||||
"completed": completed,
|
||||
"accepted": False,
|
||||
"metrics": metrics,
|
||||
"decision": {
|
||||
"raw_kb4_coordinate_binding_completed": completed,
|
||||
"semantic_quality_accepted": False,
|
||||
"risk_policy_accepted": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"next_gate": "M48S human-reviewed semantic object quality slice",
|
||||
},
|
||||
"known_limits": [
|
||||
"bounded 11-frame diagnostic slice is not representative route truth",
|
||||
"Grounding DINO confidence is not calibrated class probability",
|
||||
"motion is intentionally unresolved without temporal evidence",
|
||||
"risk remains unknown and route-around advisory only",
|
||||
"TAO performed network metadata requests during tokenizer startup",
|
||||
],
|
||||
"authority": _false_authority(),
|
||||
}
|
||||
report_path = staging / SEMANTIC_SHADOW_REPORT_NAME
|
||||
_write_json(report_path, report)
|
||||
manifest = {
|
||||
"schema_version": SEMANTIC_SHADOW_REPLAY_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"identity_sha256": identity_sha256,
|
||||
"identity": identity,
|
||||
"created_at_utc": created,
|
||||
"completed": completed,
|
||||
"accepted": False,
|
||||
"artifacts": [
|
||||
_artifact(frames_path, "semantic-shadow-frames"),
|
||||
_artifact(report_path, "semantic-shadow-report"),
|
||||
],
|
||||
"authority": _false_authority(),
|
||||
}
|
||||
_write_json(staging / SEMANTIC_SHADOW_MANIFEST_NAME, manifest)
|
||||
destination = output / result_id
|
||||
if destination.exists():
|
||||
shutil.rmtree(staging)
|
||||
return read_semantic_shadow_replay(destination)
|
||||
os.replace(staging, destination)
|
||||
return read_semantic_shadow_replay(destination)
|
||||
except (OpenVocabularySemanticError, OSError, ValueError) as exc:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise SemanticShadowReplayError("semantic shadow replay failed closed") from exc
|
||||
except BaseException:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
def read_semantic_shadow_replay(root: Path) -> SemanticShadowReplayResult:
|
||||
"""Read and verify one immutable semantic shadow replay result."""
|
||||
|
||||
resolved = root.resolve(strict=True)
|
||||
if resolved.is_symlink() or not resolved.name.startswith(SEMANTIC_SHADOW_RESULT_PREFIX):
|
||||
raise SemanticShadowReplayError("semantic shadow result root is invalid")
|
||||
manifest = _read_json(resolved / SEMANTIC_SHADOW_MANIFEST_NAME)
|
||||
identity = _object(manifest.get("identity"), "semantic shadow identity")
|
||||
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
if (
|
||||
manifest.get("schema_version") != SEMANTIC_SHADOW_REPLAY_SCHEMA
|
||||
or manifest.get("result_id") != resolved.name
|
||||
or manifest.get("identity_sha256") != identity_sha256
|
||||
or resolved.name != f"{SEMANTIC_SHADOW_RESULT_PREFIX}{identity_sha256}"
|
||||
or manifest.get("accepted") is not False
|
||||
or manifest.get("authority") != _false_authority()
|
||||
):
|
||||
raise SemanticShadowReplayError("semantic shadow manifest changed")
|
||||
frames_path = resolved / SEMANTIC_SHADOW_FRAMES_NAME
|
||||
report_path = resolved / SEMANTIC_SHADOW_REPORT_NAME
|
||||
if _file_sha256(frames_path) != identity.get("frames_sha256") or not _artifact_matches(
|
||||
manifest.get("artifacts"), frames_path, report_path
|
||||
):
|
||||
raise SemanticShadowReplayError("semantic shadow artifacts changed")
|
||||
report = _read_json(report_path)
|
||||
if (
|
||||
report.get("schema_version") != SEMANTIC_SHADOW_REPORT_SCHEMA
|
||||
or report.get("result_id") != resolved.name
|
||||
or report.get("identity_sha256") != identity_sha256
|
||||
or report.get("metrics") != identity.get("metrics")
|
||||
or report.get("completed") != identity.get("completed")
|
||||
or report.get("accepted") is not False
|
||||
or report.get("authority") != _false_authority()
|
||||
):
|
||||
raise SemanticShadowReplayError("semantic shadow report changed")
|
||||
metrics = _object(identity.get("metrics"), "semantic shadow metrics")
|
||||
return SemanticShadowReplayResult(
|
||||
result_id=resolved.name,
|
||||
result_root=resolved,
|
||||
completed=identity.get("completed") is True,
|
||||
accepted=False,
|
||||
metrics=metrics,
|
||||
report=report,
|
||||
manifest=manifest,
|
||||
)
|
||||
|
||||
|
||||
def _frame_selection(value: tuple[int, ...]) -> tuple[int, ...]:
|
||||
if (
|
||||
not value
|
||||
or tuple(sorted(set(value))) != value
|
||||
or any(isinstance(item, bool) or item < 0 or item >= 4489 for item in value)
|
||||
):
|
||||
raise SemanticShadowReplayError("semantic shadow frame selection is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_worker_identity(
|
||||
path: Path,
|
||||
*,
|
||||
profile: OpenVocabularySemanticProfile,
|
||||
frame_indices: tuple[int, ...],
|
||||
) -> dict[str, object]:
|
||||
document = _read_json(path.resolve(strict=True))
|
||||
if (
|
||||
document.get("schema_version") != SEMANTIC_WORKER_EXECUTION_SCHEMA
|
||||
or document.get("model_sha256") != profile.model_sha256
|
||||
or document.get("engine_sha256") != profile.engine_sha256
|
||||
or document.get("container_reference") != profile.container_reference
|
||||
or document.get("container_image_id") != profile.container_image_id
|
||||
or document.get("coordinate_space") != "raw-kb4-800x600"
|
||||
or document.get("preprocess_id") != profile.preprocess_id
|
||||
or document.get("valid_fov_mask_sha256") != profile.valid_fov_mask_sha256
|
||||
or document.get("frame_indices") != list(frame_indices)
|
||||
or document.get("authority") != _false_authority()
|
||||
):
|
||||
raise SemanticShadowReplayError("semantic Worker execution identity changed")
|
||||
prompt_runs = _object(document.get("prompt_runs"), "semantic prompt runs")
|
||||
if set(prompt_runs) != {item.prompt_set_id for item in profile.prompt_groups} or any(
|
||||
_object(value, "semantic prompt run").get("status") != "SUCCESS"
|
||||
for value in prompt_runs.values()
|
||||
):
|
||||
raise SemanticShadowReplayError("semantic Worker prompt run failed")
|
||||
return document
|
||||
|
||||
|
||||
def _validate_worker_results(
|
||||
roots: Mapping[str, Path],
|
||||
*,
|
||||
profile: OpenVocabularySemanticProfile,
|
||||
frame_indices: tuple[int, ...],
|
||||
) -> list[dict[str, object]]:
|
||||
if set(roots) != {item.prompt_set_id for item in profile.prompt_groups}:
|
||||
raise SemanticShadowReplayError("semantic Worker result groups changed")
|
||||
expected_names = {f"frame-{index:06d}.txt" for index in frame_indices}
|
||||
result: list[dict[str, object]] = []
|
||||
for group in profile.prompt_groups:
|
||||
root = roots[group.prompt_set_id].resolve(strict=True)
|
||||
if root.is_symlink() or not root.is_dir():
|
||||
raise SemanticShadowReplayError("semantic Worker result root is invalid")
|
||||
status_path = root / "status.json"
|
||||
status_rows = _read_jsonl(status_path)
|
||||
if not status_rows or status_rows[-1].get("status") != "SUCCESS":
|
||||
raise SemanticShadowReplayError("semantic Worker result did not succeed")
|
||||
experiment_path = root / "experiment.yaml"
|
||||
try:
|
||||
experiment = yaml.safe_load(experiment_path.read_text("utf-8"))
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
raise SemanticShadowReplayError("semantic Worker experiment cannot be read") from exc
|
||||
experiment_root = _object(experiment, "semantic Worker experiment")
|
||||
dataset = _object(experiment_root.get("dataset"), "semantic Worker dataset")
|
||||
sources = _object(dataset.get("infer_data_sources"), "semantic Worker sources")
|
||||
inference = _object(experiment_root.get("inference"), "semantic Worker inference")
|
||||
if (
|
||||
sources.get("captions") != list(group.captions)
|
||||
or sources.get("image_dir") != ["/workspace/probe/input"]
|
||||
or inference.get("conf_threshold") != profile.minimum_input_confidence
|
||||
or inference.get("input_width") != profile.engine_input_width
|
||||
or inference.get("input_height") != profile.engine_input_height
|
||||
or not str(inference.get("trt_engine", "")).endswith(
|
||||
"/grounding_dino_swin_tiny_commercial_fp16.engine"
|
||||
)
|
||||
):
|
||||
raise SemanticShadowReplayError("semantic Worker experiment drifted")
|
||||
labels_root = root / "labels"
|
||||
label_paths = tuple(sorted(labels_root.glob("*.txt")))
|
||||
if {item.name for item in label_paths} != expected_names or any(
|
||||
item.is_symlink() or not item.is_file() for item in label_paths
|
||||
):
|
||||
raise SemanticShadowReplayError("semantic Worker label accounting changed")
|
||||
result.append(
|
||||
{
|
||||
"prompt_set_id": group.prompt_set_id,
|
||||
"experiment_sha256": _file_sha256(experiment_path),
|
||||
"status_sha256": _file_sha256(status_path),
|
||||
"labels": [
|
||||
{
|
||||
"name": item.name,
|
||||
"byte_length": item.stat().st_size,
|
||||
"sha256": _file_sha256(item),
|
||||
}
|
||||
for item in label_paths
|
||||
],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _validate_source_frames(
|
||||
root: Path,
|
||||
*,
|
||||
profile: OpenVocabularySemanticProfile,
|
||||
frame_indices: tuple[int, ...],
|
||||
) -> dict[int, dict[str, object]]:
|
||||
resolved = root.resolve(strict=True)
|
||||
expected_names = {f"frame-{index:06d}.jpg" for index in frame_indices}
|
||||
paths = tuple(sorted(resolved.glob("*.jpg")))
|
||||
if {item.name for item in paths} != expected_names:
|
||||
raise SemanticShadowReplayError("semantic raw frame accounting changed")
|
||||
result: dict[int, dict[str, object]] = {}
|
||||
for index in frame_indices:
|
||||
path = resolved / f"frame-{index:06d}.jpg"
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise SemanticShadowReplayError("semantic raw frame is invalid")
|
||||
try:
|
||||
with Image.open(path) as image:
|
||||
image.verify()
|
||||
with Image.open(path) as image:
|
||||
dimensions = image.size
|
||||
image_format = image.format
|
||||
except OSError as exc:
|
||||
raise SemanticShadowReplayError("semantic raw frame cannot be decoded") from exc
|
||||
if dimensions != (profile.width, profile.height) or image_format != "JPEG":
|
||||
raise SemanticShadowReplayError("semantic raw frame coordinate space changed")
|
||||
result[index] = {
|
||||
"source_frame_index": index,
|
||||
"camera_sequence": index + 1,
|
||||
"name": path.name,
|
||||
"width": profile.width,
|
||||
"height": profile.height,
|
||||
"byte_length": path.stat().st_size,
|
||||
"sha256": _file_sha256(path),
|
||||
"coordinate_space": profile.coordinate_space,
|
||||
"exact_source_frame": True,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _validate_inference_frames(
|
||||
root: Path,
|
||||
*,
|
||||
source_frames_root: Path,
|
||||
valid_fov_mask: NDArray[np.bool_],
|
||||
profile: OpenVocabularySemanticProfile,
|
||||
frame_indices: tuple[int, ...],
|
||||
) -> dict[int, dict[str, object]]:
|
||||
resolved = root.resolve(strict=True)
|
||||
source_root = source_frames_root.resolve(strict=True)
|
||||
expected_names = {f"frame-{index:06d}.png" for index in frame_indices}
|
||||
paths = tuple(sorted(resolved.glob("*.png")))
|
||||
if {item.name for item in paths} != expected_names:
|
||||
raise SemanticShadowReplayError("semantic inference frame accounting changed")
|
||||
result: dict[int, dict[str, object]] = {}
|
||||
for index in frame_indices:
|
||||
path = resolved / f"frame-{index:06d}.png"
|
||||
source_path = source_root / f"frame-{index:06d}.jpg"
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise SemanticShadowReplayError("semantic inference frame is invalid")
|
||||
try:
|
||||
with Image.open(path) as opened:
|
||||
image_format = opened.format
|
||||
inference = np.asarray(opened.convert("RGB"), dtype=np.uint8)
|
||||
with Image.open(source_path) as opened:
|
||||
source = np.asarray(opened.convert("RGB"), dtype=np.uint8)
|
||||
except OSError as exc:
|
||||
raise SemanticShadowReplayError("semantic inference frame cannot be decoded") from exc
|
||||
expected = np.where(
|
||||
valid_fov_mask[..., None],
|
||||
source,
|
||||
profile.valid_fov_fill_value,
|
||||
).astype(np.uint8)
|
||||
if (
|
||||
image_format != "PNG"
|
||||
or inference.shape != (profile.height, profile.width, 3)
|
||||
or not np.array_equal(inference, expected)
|
||||
):
|
||||
raise SemanticShadowReplayError(
|
||||
"semantic inference frame valid-FOV preprocessing changed"
|
||||
)
|
||||
result[index] = {
|
||||
"source_frame_index": index,
|
||||
"name": path.name,
|
||||
"width": profile.width,
|
||||
"height": profile.height,
|
||||
"byte_length": path.stat().st_size,
|
||||
"sha256": _file_sha256(path),
|
||||
"coordinate_space": profile.coordinate_space,
|
||||
"preprocess_id": profile.preprocess_id,
|
||||
"valid_fov_mask_sha256": profile.valid_fov_mask_sha256,
|
||||
"valid_fov_fill_value": profile.valid_fov_fill_value,
|
||||
"derived_from_source_sha256": _file_sha256(source_path),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _frame_detections(
|
||||
roots: Mapping[str, Path],
|
||||
*,
|
||||
frame_index: int,
|
||||
envelope: SourceEnvelope,
|
||||
profile: OpenVocabularySemanticProfile,
|
||||
) -> tuple[OpenVocabularyDetection, ...]:
|
||||
detections: list[OpenVocabularyDetection] = []
|
||||
for group in profile.prompt_groups:
|
||||
labels = (
|
||||
roots[group.prompt_set_id].resolve(strict=True)
|
||||
/ "labels"
|
||||
/ f"frame-{frame_index:06d}.txt"
|
||||
)
|
||||
detections.extend(
|
||||
parse_tao_grounding_dino_labels(
|
||||
labels,
|
||||
source_id=envelope.source_id,
|
||||
frame_id=envelope.frame_id,
|
||||
prompt_set_id=group.prompt_set_id,
|
||||
profile=profile,
|
||||
)
|
||||
)
|
||||
return tuple(detections)
|
||||
|
||||
|
||||
def semantic_replay_packet(envelope: SourceEnvelope) -> SourcePacket:
|
||||
"""Rebuild the exact digest-bound source packet used by semantic replays."""
|
||||
|
||||
image = RecordedFrameReference(BASELINE_RECORDED_JOB_ID, envelope.sequence)
|
||||
geometry = (
|
||||
RecordedFrameReference(RECORDED_SOURCE_PACK_ID, envelope.sequence)
|
||||
if envelope.registered_point_increment.available
|
||||
else None
|
||||
)
|
||||
return SourcePacket(
|
||||
envelope=envelope,
|
||||
image_payload=image,
|
||||
registered_point_increment_payload=geometry,
|
||||
pose_payload=geometry,
|
||||
)
|
||||
|
||||
|
||||
def _frame_metrics(
|
||||
*,
|
||||
detections: tuple[OpenVocabularyDetection, ...],
|
||||
fusion_input_count: int,
|
||||
below_confidence_count: int,
|
||||
invalid_area_count: int,
|
||||
outside_valid_fov_count: int,
|
||||
retained_detection_count: int,
|
||||
proposal_count: int,
|
||||
observations: tuple[ObstacleObservation, ...],
|
||||
understandings: tuple[ObjectUnderstanding, ...],
|
||||
) -> dict[str, int]:
|
||||
proposal_observations = tuple(item for item in observations if item.proposal_ids)
|
||||
geometry_only = tuple(item for item in observations if not item.proposal_ids)
|
||||
if len(detections) != fusion_input_count or len(understandings) != len(observations):
|
||||
raise SemanticShadowReplayError("semantic frame accounting changed")
|
||||
ranged = sum(item.metric_geometry is not None for item in proposal_observations)
|
||||
conflict = sum(item.basis is EvidenceBasis.CONFLICT for item in proposal_observations)
|
||||
return {
|
||||
"raw_detection_count": len(detections),
|
||||
"below_confidence_count": below_confidence_count,
|
||||
"invalid_area_count": invalid_area_count,
|
||||
"outside_valid_fov_count": outside_valid_fov_count,
|
||||
"retained_detection_count": retained_detection_count,
|
||||
"proposal_count": proposal_count,
|
||||
"proposal_observation_count": len(proposal_observations),
|
||||
"ranged_proposal_observation_count": ranged,
|
||||
"camera_only_proposal_observation_count": (len(proposal_observations) - ranged - conflict),
|
||||
"conflict_proposal_observation_count": conflict,
|
||||
"geometry_only_observation_count": len(geometry_only),
|
||||
}
|
||||
|
||||
|
||||
def _detection_document(value: OpenVocabularyDetection) -> dict[str, object]:
|
||||
return {
|
||||
"detection_id": value.detection_id,
|
||||
"source_id": value.source_id,
|
||||
"frame_id": value.frame_id,
|
||||
"prompt_set_id": value.prompt_set_id,
|
||||
"raw_label": value.raw_label,
|
||||
"confidence": value.confidence,
|
||||
"region": value.region.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
def _artifact(path: Path, role: str) -> dict[str, object]:
|
||||
return {
|
||||
"role": role,
|
||||
"path": path.name,
|
||||
"byte_length": path.stat().st_size,
|
||||
"sha256": _file_sha256(path),
|
||||
}
|
||||
|
||||
|
||||
def _artifact_matches(value: object, frames_path: Path, report_path: Path) -> bool:
|
||||
if not isinstance(value, list) or len(value) != 2:
|
||||
return False
|
||||
expected = {
|
||||
path.name: (path.stat().st_size, _file_sha256(path)) for path in (frames_path, report_path)
|
||||
}
|
||||
seen: set[str] = set()
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
return False
|
||||
path = item.get("path")
|
||||
if not isinstance(path, str) or path not in expected:
|
||||
return False
|
||||
seen.add(path)
|
||||
if (item.get("byte_length"), item.get("sha256")) != expected[path]:
|
||||
return False
|
||||
return seen == set(expected)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, object]:
|
||||
try:
|
||||
value = json.loads(path.read_text("utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise SemanticShadowReplayError(f"cannot read semantic JSON: {path.name}") from exc
|
||||
return _object(value, path.name)
|
||||
|
||||
|
||||
def _read_jsonl(path: Path) -> list[dict[str, object]]:
|
||||
try:
|
||||
rows = [json.loads(line) for line in path.read_text("utf-8").splitlines() if line]
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise SemanticShadowReplayError(f"cannot read semantic JSONL: {path.name}") from exc
|
||||
return [_object(item, path.name) for item in rows]
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.write_bytes(_canonical_json(value) + b"\n")
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.expanduser().resolve(strict=True).open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, object]:
|
||||
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
||||
raise SemanticShadowReplayError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _false_authority() -> dict[str, bool]:
|
||||
return {
|
||||
"ground_truth": False,
|
||||
"independent_truth": False,
|
||||
"candidate_accepted": False,
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SEMANTIC_SHADOW_REPLAY_SCHEMA",
|
||||
"SemanticShadowReplayError",
|
||||
"SemanticShadowReplayResult",
|
||||
"build_semantic_shadow_replay",
|
||||
"read_semantic_shadow_replay",
|
||||
"semantic_replay_packet",
|
||||
]
|
||||
Reference in New Issue
Block a user