feat(perception): evaluate fixed-class detector candidates

This commit is contained in:
DCCONSTRUCTIONS
2026-08-25 16:44:15 +03:00
parent 6276bbf324
commit 33cef2fdea
28 changed files with 4498 additions and 13 deletions
+154 -4
View File
@@ -1,4 +1,4 @@
"""Frozen raw-KB4 YOLOX provider for class-agnostic object proposals."""
"""Versioned fixed-class detector providers for raw-KB4 object proposals."""
from __future__ import annotations
@@ -14,10 +14,22 @@ from numpy.typing import NDArray
from .contracts import BoundingRegion2D, ObjectProposal2D
from .providers import SourcePacket
from .rf_detr_object_detector import (
RF_DETR_CONFIG,
RF_DETR_MODEL_ID,
RF_DETR_MODEL_VERSION,
RfDetrConfig,
RfDetrDetection,
RfDetrInferenceBackend,
postprocess_rf_detr,
preprocess_raw_kb4_rf_detr,
)
from .yolox_object_detector import (
ALL_COCO_YOLOX_CONFIG,
FROZEN_YOLOX_CONFIG,
YOLOX_MODEL_ID,
YOLOX_MODEL_VERSION,
AllCocoYoloxConfig,
FrozenYoloxConfig,
ImageResizer,
InferenceBackend,
@@ -27,8 +39,12 @@ from .yolox_object_detector import (
)
FROZEN_YOLOX_PROVIDER_ID: Final = "triton-yolox-s-raw-kb4/v1"
ALL_COCO_YOLOX_PROVIDER_ID: Final = "triton-yolox-s-raw-kb4-all-coco/v2"
FROZEN_YOLOX_MODEL_ID: Final = f"{YOLOX_MODEL_ID}:{YOLOX_MODEL_VERSION}"
FROZEN_YOLOX_PREPROCESS_ID: Final = "raw-kb4-valid-fov-letterbox/v1"
RF_DETR_SHADOW_PROVIDER_ID: Final = "triton-rf-detr-large-coco-risk-fp16-shadow/v0"
RF_DETR_SHADOW_MODEL_ID: Final = f"{RF_DETR_MODEL_ID}:{RF_DETR_MODEL_VERSION}"
RF_DETR_SHADOW_PREPROCESS_ID: Final = "raw-kb4-valid-fov-rgb-stretch-imagenet/v0"
class DetectorProviderError(RuntimeError):
@@ -57,7 +73,7 @@ class FrozenYoloxDetectorProvider:
mask: NDArray[np.bool_],
backend: InferenceBackend,
resizer: ImageResizer | None = None,
config: FrozenYoloxConfig = FROZEN_YOLOX_CONFIG,
config: FrozenYoloxConfig | AllCocoYoloxConfig = FROZEN_YOLOX_CONFIG,
clock_ns: Callable[[], int] = time.perf_counter_ns,
) -> None:
if mask.shape != (600, 800) or mask.dtype != np.bool_ or not np.any(mask):
@@ -95,7 +111,11 @@ class FrozenYoloxDetectorProvider:
)
output = self.backend.infer(tensor)
postprocessed = postprocess_yolox(output, self.mask, config=self.config)
proposals = proposals_from_detections(packet, postprocessed.detections)
proposals = proposals_from_detections(
packet,
postprocessed.detections,
provider_id=self.provider_id,
)
except Exception:
with self._lock:
self._failed_frames += 1
@@ -125,6 +145,8 @@ class FrozenYoloxDetectorProvider:
def proposals_from_detections(
packet: SourcePacket,
detections: tuple[YoloxDetection, ...],
*,
provider_id: str = FROZEN_YOLOX_PROVIDER_ID,
) -> tuple[ObjectProposal2D, ...]:
envelope = packet.envelope
return tuple(
@@ -134,7 +156,7 @@ def proposals_from_detections(
frame_id=envelope.frame_id,
region=BoundingRegion2D(*detection.bbox_xyxy),
objectness=detection.score,
provider_id=FROZEN_YOLOX_PROVIDER_ID,
provider_id=provider_id,
model_id=FROZEN_YOLOX_MODEL_ID,
preprocess_id=FROZEN_YOLOX_PREPROCESS_ID,
semantic_hint=detection.label,
@@ -144,12 +166,140 @@ def proposals_from_detections(
)
class AllCocoYoloxDetectorProvider(FrozenYoloxDetectorProvider):
"""Emit every qualified COCO class without adding another inference pass."""
provider_id: str = ALL_COCO_YOLOX_PROVIDER_ID
def __init__(
self,
*,
mask: NDArray[np.bool_],
backend: InferenceBackend,
resizer: ImageResizer | None = None,
config: AllCocoYoloxConfig = ALL_COCO_YOLOX_CONFIG,
clock_ns: Callable[[], int] = time.perf_counter_ns,
) -> None:
super().__init__(
mask=mask,
backend=backend,
resizer=resizer,
config=config,
clock_ns=clock_ns,
)
class RfDetrShadowDetectorProvider:
"""Emit behavior-relevant fixed classes from one RF-DETR inference pass."""
provider_id: str = RF_DETR_SHADOW_PROVIDER_ID
def __init__(
self,
*,
mask: NDArray[np.bool_],
backend: RfDetrInferenceBackend,
resizer: ImageResizer | None = None,
config: RfDetrConfig = RF_DETR_CONFIG,
clock_ns: Callable[[], int] = time.perf_counter_ns,
) -> None:
if mask.shape != (600, 800) or mask.dtype != np.bool_ or not np.any(mask):
raise DetectorProviderError("RF-DETR valid-FOV mask is incompatible")
self.mask = np.asarray(mask, dtype=np.bool_)
self.backend = backend
self.resizer = resizer
self.config = config
self._clock_ns = clock_ns
self._lock = Lock()
self._input_frames = 0
self._completed_frames = 0
self._failed_frames = 0
self._zero_proposal_frames = 0
self._proposal_count = 0
self._rejected: Counter[str] = Counter()
self._core_duration_ns = 0
def detect(self, packet: SourcePacket) -> tuple[ObjectProposal2D, ...]:
payload = packet.image_payload
with self._lock:
self._input_frames += 1
started_ns = int(self._clock_ns())
try:
if not isinstance(payload, np.ndarray):
raise DetectorProviderError("RF-DETR requires a decoded BGR image payload")
image = np.asarray(payload)
if image.dtype != np.uint8:
raise DetectorProviderError("decoded BGR image must be uint8")
tensor = preprocess_raw_kb4_rf_detr(
image,
self.mask,
config=self.config,
resizer=self.resizer,
)
output = self.backend.infer(tensor)
postprocessed = postprocess_rf_detr(output, self.mask, config=self.config)
proposals = proposals_from_rf_detr_detections(packet, postprocessed.detections)
except Exception:
with self._lock:
self._failed_frames += 1
self._core_duration_ns += max(0, int(self._clock_ns()) - started_ns)
raise
with self._lock:
self._completed_frames += 1
self._proposal_count += len(proposals)
self._zero_proposal_frames += not proposals
self._rejected.update(dict(postprocessed.rejected))
self._core_duration_ns += max(0, int(self._clock_ns()) - started_ns)
return proposals
def snapshot(self) -> DetectorProviderSnapshot:
with self._lock:
return DetectorProviderSnapshot(
input_frames=self._input_frames,
completed_frames=self._completed_frames,
failed_frames=self._failed_frames,
zero_proposal_frames=self._zero_proposal_frames,
proposal_count=self._proposal_count,
rejected=tuple(sorted(self._rejected.items())),
core_duration_ns=self._core_duration_ns,
)
def proposals_from_rf_detr_detections(
packet: SourcePacket,
detections: tuple[RfDetrDetection, ...],
) -> tuple[ObjectProposal2D, ...]:
envelope = packet.envelope
return tuple(
ObjectProposal2D(
proposal_id=f"proposal-{envelope.sequence}-{index}",
source_id=envelope.source_id,
frame_id=envelope.frame_id,
region=BoundingRegion2D(*detection.bbox_xyxy),
objectness=detection.score,
provider_id=RF_DETR_SHADOW_PROVIDER_ID,
model_id=RF_DETR_SHADOW_MODEL_ID,
preprocess_id=RF_DETR_SHADOW_PREPROCESS_ID,
semantic_hint=detection.label,
provider_tracklet=None,
)
for index, detection in enumerate(detections)
)
__all__ = [
"ALL_COCO_YOLOX_PROVIDER_ID",
"FROZEN_YOLOX_MODEL_ID",
"FROZEN_YOLOX_PREPROCESS_ID",
"FROZEN_YOLOX_PROVIDER_ID",
"RF_DETR_SHADOW_MODEL_ID",
"RF_DETR_SHADOW_PREPROCESS_ID",
"RF_DETR_SHADOW_PROVIDER_ID",
"DetectorProviderError",
"DetectorProviderSnapshot",
"AllCocoYoloxDetectorProvider",
"FrozenYoloxDetectorProvider",
"RfDetrShadowDetectorProvider",
"proposals_from_detections",
"proposals_from_rf_detr_detections",
]
@@ -0,0 +1,285 @@
"""Contracts for the bounded M48S fixed-class detector tournament."""
from __future__ import annotations
import hashlib
import json
from collections import Counter
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Final
WORKER_RUN_SCHEMA: Final = "missioncore.m48s-fixed-detector-candidate-worker/v0"
TOURNAMENT_SCHEMA: Final = "missioncore.m48s-fixed-detector-tournament/v0"
EXACT_FRAME_NAMES: Final = (
"frame-000121.png",
"frame-000131.png",
"frame-000253.png",
"frame-000275.png",
"frame-000443.png",
"frame-000463.png",
"frame-001094.png",
"frame-001228.png",
"frame-001454.png",
"frame-001856.png",
"frame-002386.png",
)
RISK_GROUPS: Final[Mapping[str, frozenset[str]]] = {
"person": frozenset({"person"}),
"animal": frozenset(
{"bird", "cat", "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra", "giraffe"}
),
"light-road-user": frozenset({"bicycle", "motorcycle", "skateboard"}),
"vehicle": frozenset({"car", "bus", "truck"}),
}
_RISK_LABELS: Final = frozenset().union(*RISK_GROUPS.values())
_FALSE_AUTHORITY: Final = {
"ground_truth": False,
"candidate_accepted": False,
"commands_enabled": False,
"actuation_allowed": False,
"navigation_or_safety_accepted": False,
}
class FixedClassTournamentError(ValueError):
"""Raised when tournament evidence violates its bounded contract."""
@dataclass(frozen=True)
class CandidateDetection:
"""One normalized COCO detection emitted by a candidate."""
class_id: int
label: str
score: float
bbox_xyxy: tuple[float, float, float, float]
valid_fov_fraction: float
@classmethod
def from_document(cls, document: object) -> CandidateDetection:
item = _mapping(document, "detection")
box = item.get("bbox_xyxy")
if not isinstance(box, list) or len(box) != 4:
raise FixedClassTournamentError("detection bbox_xyxy must contain four numbers")
values = (
_number(box[0], "bbox coordinate"),
_number(box[1], "bbox coordinate"),
_number(box[2], "bbox coordinate"),
_number(box[3], "bbox coordinate"),
)
x1, y1, x2, y2 = values
if x2 <= x1 or y2 <= y1:
raise FixedClassTournamentError("detection box must have positive area")
score = _number(item.get("score"), "detection score")
valid_fov_fraction = _number(
item.get("valid_fov_fraction"), "detection valid-FOV fraction"
)
if not 0.0 <= score <= 1.0:
raise FixedClassTournamentError("detection score must be in [0, 1]")
if not 0.0 <= valid_fov_fraction <= 1.0:
raise FixedClassTournamentError("valid-FOV fraction must be in [0, 1]")
return cls(
class_id=_integer(item.get("class_id"), "detection class id"),
label=_text(item.get("label"), "detection label"),
score=score,
bbox_xyxy=values,
valid_fov_fraction=valid_fov_fraction,
)
@property
def risk_group(self) -> str | None:
for group, labels in RISK_GROUPS.items():
if self.label in labels:
return group
return None
@dataclass(frozen=True)
class CandidateFrame:
"""One exact-frame candidate result."""
frame_name: str
source_sha256: str
detections: tuple[CandidateDetection, ...]
end_to_end_ms: float
@classmethod
def from_document(cls, document: object) -> CandidateFrame:
item = _mapping(document, "frame")
detections = item.get("detections")
if not isinstance(detections, list):
raise FixedClassTournamentError("frame detections must be a list")
return cls(
frame_name=_text(item.get("frame_name"), "frame name"),
source_sha256=_digest(item.get("source_sha256"), "source digest"),
detections=tuple(CandidateDetection.from_document(value) for value in detections),
end_to_end_ms=_nonnegative_number(
_mapping(item.get("timing_ms"), "frame timing").get("end_to_end"),
"frame end-to-end timing",
),
)
@dataclass(frozen=True)
class CandidateWorkerRun:
"""Validated raw Worker result for one candidate."""
profile_id: str
provider_id: str
upstream_revision: str
checkpoint_sha256: str
frames: tuple[CandidateFrame, ...]
metrics: Mapping[str, object]
authority: Mapping[str, bool]
@classmethod
def from_document(cls, document: object) -> CandidateWorkerRun:
root = _mapping(document, "worker result")
if root.get("schema_version") != WORKER_RUN_SCHEMA:
raise FixedClassTournamentError("unexpected candidate Worker schema")
frames_raw = root.get("frames")
if not isinstance(frames_raw, list):
raise FixedClassTournamentError("worker result frames must be a list")
frames = tuple(CandidateFrame.from_document(value) for value in frames_raw)
if tuple(sorted(frame.frame_name for frame in frames)) != EXACT_FRAME_NAMES:
raise FixedClassTournamentError("worker result does not contain the exact M48S slice")
if len({frame.frame_name for frame in frames}) != len(EXACT_FRAME_NAMES):
raise FixedClassTournamentError("worker result contains duplicate frames")
execution = _mapping(root.get("execution"), "worker execution")
if execution.get("inference_passes_per_evidence_frame") != 1:
raise FixedClassTournamentError(
"candidate must use one inference pass per evidence frame"
)
authority = _boolean_mapping(root.get("authority"), "worker authority")
if authority != _FALSE_AUTHORITY:
raise FixedClassTournamentError("candidate Worker result must retain false authority")
completed = root.get("completed")
if completed is not True:
raise FixedClassTournamentError("candidate Worker result is incomplete")
return cls(
profile_id=_text(root.get("profile_id"), "profile id"),
provider_id=_text(root.get("provider_id"), "provider id"),
upstream_revision=_text(root.get("upstream_revision"), "upstream revision"),
checkpoint_sha256=_digest(root.get("checkpoint_sha256"), "checkpoint digest"),
frames=frames,
metrics=_mapping(root.get("metrics"), "worker metrics"),
authority=authority,
)
@classmethod
def from_path(cls, path: Path) -> CandidateWorkerRun:
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise FixedClassTournamentError(
f"cannot read candidate Worker result: {path}"
) from error
return cls.from_document(document)
def quality_summary(self, *, threshold: float) -> dict[str, object]:
if not 0.0 <= threshold <= 1.0:
raise FixedClassTournamentError("quality threshold must be in [0, 1]")
selected = tuple(
detection
for frame in self.frames
for detection in frame.detections
if detection.score >= threshold
)
class_counts = Counter(item.label for item in selected)
risk_counts = Counter(item.risk_group for item in selected if item.risk_group is not None)
dog_frame = next(frame for frame in self.frames if frame.frame_name == "frame-000253.png")
dog_detections = tuple(
detection
for detection in dog_frame.detections
if detection.label == "dog" and detection.score >= threshold
)
return {
"threshold": threshold,
"detection_count": len(selected),
"class_counts": dict(sorted(class_counts.items())),
"risk_group_counts": dict(sorted(risk_counts.items())),
"risk_detection_count": sum(1 for item in selected if item.label in _RISK_LABELS),
"frame_000253_dog_detected": bool(dog_detections),
"frame_000253_dog_max_score": (
round(max(item.score for item in dog_detections), 6) if dog_detections else None
),
}
def canonical_json(value: object) -> bytes:
"""Return deterministic JSON bytes for immutable evidence identities."""
return json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
).encode("utf-8")
def sha256_path(path: Path) -> str:
"""Hash a file without loading it into memory."""
digest = hashlib.sha256()
try:
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
except OSError as error:
raise FixedClassTournamentError(f"cannot hash evidence file: {path}") from error
return digest.hexdigest()
def false_authority() -> dict[str, bool]:
"""Return a fresh false-authority document."""
return dict(_FALSE_AUTHORITY)
def _mapping(value: object, name: str) -> Mapping[str, Any]:
if not isinstance(value, dict):
raise FixedClassTournamentError(f"{name} must be an object")
return value
def _boolean_mapping(value: object, name: str) -> Mapping[str, bool]:
mapping = _mapping(value, name)
if set(mapping) != set(_FALSE_AUTHORITY) or not all(
isinstance(item, bool) for item in mapping.values()
):
raise FixedClassTournamentError(f"{name} must contain the exact boolean authority fields")
return mapping
def _text(value: object, name: str) -> str:
if not isinstance(value, str) or not value.strip():
raise FixedClassTournamentError(f"{name} must be non-empty text")
return value
def _digest(value: object, name: str) -> str:
text = _text(value, name)
if len(text) != 64 or any(character not in "0123456789abcdef" for character in text):
raise FixedClassTournamentError(f"{name} must be a lowercase SHA-256 digest")
return text
def _integer(value: object, name: str) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise FixedClassTournamentError(f"{name} must be an integer")
return value
def _number(value: object, name: str) -> float:
if isinstance(value, bool) or not isinstance(value, int | float):
raise FixedClassTournamentError(f"{name} must be numeric")
return float(value)
def _nonnegative_number(value: object, name: str) -> float:
result = _number(value, name)
if result < 0.0:
raise FixedClassTournamentError(f"{name} must be non-negative")
return result
+12 -6
View File
@@ -188,20 +188,26 @@ class DecodedRecordedSource:
def packets(self, stop_event: Event) -> Iterator[SourcePacket]:
images = self.decoder.frames(stop_event)
try:
image: NDArray[np.uint8] | None = next(images)
except StopIteration as exc:
if stop_event.is_set():
return
raise RecordedSourceError("decoded image stream is empty") from exc
for packet in self.source.packets(stop_event):
try:
image = next(images)
except StopIteration as exc:
if image is None:
raise RecordedSourceError(
"decoded image stream ended before source timeline"
) from exc
)
if image.shape != (600, 800, 3) or image.dtype != np.uint8:
raise RecordedSourceError("decoded image raster is incompatible")
yield replace(packet, image_payload=image)
if not stop_event.is_set():
try:
next(images)
image = next(images)
except StopIteration:
image = None
if not stop_event.is_set():
if image is None:
return
raise RecordedSourceError("decoded image stream exceeds source timeline")
@@ -0,0 +1,401 @@
"""Pinned RF-DETR-L TensorRT shadow detector for behavior-relevant COCO classes."""
from __future__ import annotations
import http.client
import json
import math
import urllib.parse
from collections import Counter
from dataclasses import dataclass
from typing import Final, Protocol, cast
import numpy as np
from numpy.typing import NDArray
from .yolox_object_detector import COCO_CLASSES, ImageResizer, OpenCvBilinearResizer
RF_DETR_MODEL_ID: Final = "rf_detr_large"
RF_DETR_MODEL_VERSION: Final = 1
RF_DETR_CHECKPOINT_SHA256: Final = (
"0f4e20e19a99c0f8a62b5685f57f6c8b5c371c59081feda6752a0561a79ccf38"
)
RF_DETR_ONNX_SHA256: Final = (
"9c1948e56bbb6ff03349012b8bb334cacaf8ae480f22caa0704ee70de9a72300"
)
RF_DETR_FP16_ONNX_SHA256: Final = (
"9015fcc1317f268ce866bed6b5a33132c24963e1502b02f145fa184e11de5ecb"
)
RF_DETR_ENGINE_SHA256: Final = (
"986399ce706b7380472cf5e473232249fed6e628971d8007f6609e83128d46b8"
)
COCO_SPARSE_IDS: Final = (
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 14, 15, 16, 17, 18, 19, 20, 21,
22, 23, 24, 25, 27, 28, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42,
43, 44, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60,
61, 62, 63, 64, 65, 67, 70, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81,
82, 84, 85, 86, 87, 88, 89, 90,
)
COCO_SPARSE_TO_CONTIGUOUS: Final = {
sparse_id: contiguous_id for contiguous_id, sparse_id in enumerate(COCO_SPARSE_IDS)
}
RISK_CLASS_IDS: Final = (
0, # person
1, # bicycle
2, # car
3, # motorcycle
5, # bus
7, # truck
14, 15, 16, 17, 18, 19, 20, 21, 22, 23, # animals
36, # skateboard / light road user proxy
)
_MEANS: Final = np.asarray((0.485, 0.456, 0.406), dtype=np.float32)
_STDS: Final = np.asarray((0.229, 0.224, 0.225), dtype=np.float32)
class RfDetrDetectorError(RuntimeError):
"""The RF-DETR profile, tensor or inference response is incompatible."""
@dataclass(frozen=True, slots=True)
class RfDetrConfig:
source_width: int = 800
source_height: int = 600
input_width: int = 704
input_height: int = 704
fill_value: int = 114
minimum_score: float = 0.25
target_class_ids: tuple[int, ...] = RISK_CLASS_IDS
maximum_detections: int = 300
minimum_box_area_pixels: float = 64.0
maximum_box_area_fraction: float = 0.5
minimum_valid_fov_fraction: float = 0.5
require_center_inside_valid_fov: bool = True
def __post_init__(self) -> None:
if (
self.source_width,
self.source_height,
self.input_width,
self.input_height,
self.fill_value,
self.minimum_score,
self.target_class_ids,
self.maximum_detections,
self.minimum_box_area_pixels,
self.maximum_box_area_fraction,
self.minimum_valid_fov_fraction,
self.require_center_inside_valid_fov,
) != (
800,
600,
704,
704,
114,
0.25,
RISK_CLASS_IDS,
300,
64.0,
0.5,
0.5,
True,
):
raise RfDetrDetectorError("RF-DETR shadow profile cannot be tuned in place")
RF_DETR_CONFIG: Final = RfDetrConfig()
@dataclass(frozen=True, slots=True)
class RfDetrRawOutput:
boxes: NDArray[np.float16]
logits: NDArray[np.float16]
class RfDetrInferenceBackend(Protocol):
def infer(self, tensor: NDArray[np.float32]) -> RfDetrRawOutput: ...
@dataclass(frozen=True, slots=True)
class RfDetrDetection:
class_id: int
label: str
score: float
bbox_xyxy: tuple[float, float, float, float]
valid_fov_fraction: float
def __post_init__(self) -> None:
if not 0 <= self.class_id < len(COCO_CLASSES):
raise RfDetrDetectorError("RF-DETR class id is invalid")
if self.label != COCO_CLASSES[self.class_id]:
raise RfDetrDetectorError("RF-DETR class label is invalid")
if not math.isfinite(self.score) or not 0.0 <= self.score <= 1.0:
raise RfDetrDetectorError("RF-DETR score is invalid")
x1, y1, x2, y2 = self.bbox_xyxy
if not all(math.isfinite(value) for value in self.bbox_xyxy) or not (
0.0 <= x1 < x2 <= 800.0 and 0.0 <= y1 < y2 <= 600.0
):
raise RfDetrDetectorError("RF-DETR source bounding box is invalid")
if not 0.0 <= self.valid_fov_fraction <= 1.0:
raise RfDetrDetectorError("RF-DETR valid-FOV fraction is invalid")
@dataclass(frozen=True, slots=True)
class RfDetrPostprocessResult:
detections: tuple[RfDetrDetection, ...]
rejected: tuple[tuple[str, int], ...]
class TritonRfDetrHttpInferenceBackend:
"""Persistent Triton V2 HTTP transport for the strongly typed FP16 engine."""
def __init__(self, endpoint: str, *, timeout_seconds: float = 60.0) -> None:
parsed = urllib.parse.urlsplit(endpoint)
if (
parsed.scheme != "http"
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
raise RfDetrDetectorError("Triton endpoint must be an explicit HTTP origin")
if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
raise RfDetrDetectorError("Triton timeout must be positive")
self.path = (
f"{parsed.path.rstrip('/')}/v2/models/{RF_DETR_MODEL_ID}"
f"/versions/{RF_DETR_MODEL_VERSION}/infer"
)
self.connection = http.client.HTTPConnection(
parsed.hostname,
parsed.port or 80,
timeout=timeout_seconds,
)
def close(self) -> None:
self.connection.close()
def infer(self, tensor: NDArray[np.float32]) -> RfDetrRawOutput:
contiguous = np.ascontiguousarray(tensor, dtype=np.float32)
if contiguous.shape != (1, 3, 704, 704) or not np.isfinite(contiguous).all():
raise RfDetrDetectorError("Triton RF-DETR input tensor is incompatible")
binary = contiguous.tobytes()
header = {
"inputs": [
{
"name": "input",
"shape": [1, 3, 704, 704],
"datatype": "FP32",
"parameters": {"binary_data_size": len(binary)},
}
],
"outputs": [
{"name": "dets", "parameters": {"binary_data": True}},
{"name": "labels", "parameters": {"binary_data": True}},
],
}
encoded = json.dumps(header, sort_keys=True, separators=(",", ":")).encode()
self.connection.request(
"POST",
self.path,
body=encoded + binary,
headers={
"Content-Type": "application/octet-stream",
"Inference-Header-Content-Length": str(len(encoded)),
},
)
response = self.connection.getresponse()
payload = response.read()
if response.status != 200:
raise RfDetrDetectorError(
f"Triton RF-DETR inference failed with HTTP {response.status}"
)
header_value = response.getheader("Inference-Header-Content-Length")
try:
header_length = int(header_value or "")
descriptor = json.loads(payload[:header_length])
outputs = descriptor["outputs"]
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise RfDetrDetectorError("Triton RF-DETR output descriptor is invalid") from exc
if not isinstance(outputs, list) or len(outputs) != 2:
raise RfDetrDetectorError("Triton RF-DETR output count changed")
offset = header_length
arrays: dict[str, NDArray[np.float16]] = {}
for output, expected_name, expected_shape in zip(
outputs,
("dets", "labels"),
((1, 300, 4), (1, 300, 91)),
strict=True,
):
try:
name = output["name"]
datatype = output["datatype"]
shape = tuple(int(value) for value in output["shape"])
byte_length = int(output["parameters"]["binary_data_size"])
except (KeyError, TypeError, ValueError) as exc:
raise RfDetrDetectorError(
"Triton RF-DETR output descriptor is incomplete"
) from exc
expected_bytes = math.prod(expected_shape) * np.dtype("<f2").itemsize
if (
name != expected_name
or datatype != "FP16"
or shape != expected_shape
or byte_length != expected_bytes
or offset + byte_length > len(payload)
):
raise RfDetrDetectorError("Triton RF-DETR output identity changed")
array = np.frombuffer(payload[offset : offset + byte_length], dtype="<f2")
arrays[name] = np.asarray(array.reshape(shape), dtype=np.float16)
offset += byte_length
if offset != len(payload):
raise RfDetrDetectorError("Triton RF-DETR output byte length changed")
return RfDetrRawOutput(boxes=arrays["dets"], logits=arrays["labels"])
def preprocess_raw_kb4_rf_detr(
image_bgr: NDArray[np.uint8],
mask: NDArray[np.bool_],
*,
config: RfDetrConfig = RF_DETR_CONFIG,
resizer: ImageResizer | None = None,
) -> NDArray[np.float32]:
if image_bgr.shape != (config.source_height, config.source_width, 3):
raise RfDetrDetectorError("raw KB4 image raster changed")
if image_bgr.dtype != np.uint8 or mask.shape != image_bgr.shape[:2] or mask.dtype != np.bool_:
raise RfDetrDetectorError("raw KB4 image or valid-FOV mask type changed")
masked_bgr = np.where(mask[..., None], image_bgr, config.fill_value).astype(np.uint8)
rgb = np.ascontiguousarray(masked_bgr[:, :, ::-1])
resized = (resizer or OpenCvBilinearResizer()).resize(
rgb,
config.input_width,
config.input_height,
)
if resized.shape != (config.input_height, config.input_width, 3):
raise RfDetrDetectorError("resize backend returned an incompatible raster")
normalized = resized.astype(np.float32) / 255.0
normalized = (normalized - _MEANS) / _STDS
return np.ascontiguousarray(normalized.transpose(2, 0, 1), dtype=np.float32)[None]
def postprocess_rf_detr(
output: RfDetrRawOutput,
mask: NDArray[np.bool_],
*,
config: RfDetrConfig = RF_DETR_CONFIG,
) -> RfDetrPostprocessResult:
if output.boxes.shape != (1, 300, 4) or output.logits.shape != (1, 300, 91):
raise RfDetrDetectorError("RF-DETR output tensor shapes are incompatible")
if output.boxes.dtype != np.float16 or output.logits.dtype != np.float16:
raise RfDetrDetectorError("RF-DETR output tensor types are incompatible")
if not np.isfinite(output.boxes).all() or not np.isfinite(output.logits).all():
raise RfDetrDetectorError("RF-DETR output contains non-finite values")
if mask.shape != (config.source_height, config.source_width) or mask.dtype != np.bool_:
raise RfDetrDetectorError("valid-FOV mask is incompatible")
logits = output.logits[0].astype(np.float32)
probabilities = 1.0 / (1.0 + np.exp(-np.clip(logits, -80.0, 80.0)))
flattened = probabilities.reshape(-1)
topk = np.argsort(-flattened, kind="stable")[: config.maximum_detections]
integral = np.pad(mask.astype(np.int64), ((1, 0), (1, 0))).cumsum(0).cumsum(1)
rejected: Counter[str] = Counter()
result: list[RfDetrDetection] = []
for flat_index in topk:
score = float(flattened[flat_index])
if score <= config.minimum_score:
continue
query_index = int(flat_index // output.logits.shape[2])
sparse_class_id = int(flat_index % output.logits.shape[2])
class_id = COCO_SPARSE_TO_CONTIGUOUS.get(sparse_class_id)
if class_id is None:
rejected["unmapped-class-slot"] += 1
continue
if class_id not in config.target_class_ids:
rejected["non-risk-class"] += 1
continue
center_x, center_y, box_width, box_height = (
float(value) for value in output.boxes[0, query_index].astype(np.float32)
)
box = np.asarray(
(
(center_x - box_width / 2.0) * config.source_width,
(center_y - box_height / 2.0) * config.source_height,
(center_x + box_width / 2.0) * config.source_width,
(center_y + box_height / 2.0) * config.source_height,
),
dtype=np.float32,
)
box[[0, 2]] = np.clip(box[[0, 2]], 0, config.source_width)
box[[1, 3]] = np.clip(box[[1, 3]], 0, config.source_height)
fraction, center_inside, area = _valid_fraction(box, integral)
if area < config.minimum_box_area_pixels:
rejected["small-box"] += 1
continue
if area / (config.source_width * config.source_height) > config.maximum_box_area_fraction:
rejected["large-box"] += 1
continue
if fraction < config.minimum_valid_fov_fraction:
rejected["outside-valid-fov"] += 1
continue
if config.require_center_inside_valid_fov and not center_inside:
rejected["center-outside-valid-fov"] += 1
continue
result.append(
RfDetrDetection(
class_id=class_id,
label=COCO_CLASSES[class_id],
score=round(score, 9),
bbox_xyxy=cast(
tuple[float, float, float, float],
tuple(round(float(value), 6) for value in box),
),
valid_fov_fraction=round(fraction, 6),
)
)
result.sort(key=lambda item: (-item.score, item.class_id))
return RfDetrPostprocessResult(tuple(result), tuple(sorted(rejected.items())))
def _valid_fraction(
box: NDArray[np.float32], integral: NDArray[np.int64]
) -> tuple[float, bool, float]:
height = integral.shape[0] - 1
width = integral.shape[1] - 1
x1 = int(np.clip(math.floor(float(box[0])), 0, width))
y1 = int(np.clip(math.floor(float(box[1])), 0, height))
x2 = int(np.clip(math.ceil(float(box[2])), 0, width))
y2 = int(np.clip(math.ceil(float(box[3])), 0, height))
area = float(max(0, x2 - x1) * max(0, y2 - y1))
if area <= 0:
return 0.0, False, 0.0
inside = integral[y2, x2] - integral[y1, x2] - integral[y2, x1] + integral[y1, x1]
center_x = int(np.clip(round((float(box[0]) + float(box[2])) / 2.0), 0, width - 1))
center_y = int(np.clip(round((float(box[1]) + float(box[3])) / 2.0), 0, height - 1))
center_inside = bool(
integral[center_y + 1, center_x + 1]
- integral[center_y, center_x + 1]
- integral[center_y + 1, center_x]
+ integral[center_y, center_x]
)
return float(inside) / area, center_inside, area
__all__ = [
"COCO_SPARSE_IDS",
"RF_DETR_CHECKPOINT_SHA256",
"RF_DETR_CONFIG",
"RF_DETR_ENGINE_SHA256",
"RF_DETR_FP16_ONNX_SHA256",
"RF_DETR_MODEL_ID",
"RF_DETR_MODEL_VERSION",
"RF_DETR_ONNX_SHA256",
"RISK_CLASS_IDS",
"RfDetrConfig",
"RfDetrDetection",
"RfDetrDetectorError",
"RfDetrInferenceBackend",
"RfDetrPostprocessResult",
"RfDetrRawOutput",
"TritonRfDetrHttpInferenceBackend",
"postprocess_rf_detr",
"preprocess_raw_kb4_rf_detr",
]
+57 -3
View File
@@ -43,6 +43,7 @@ COCO_CLASSES: Final = (
"oven", "toaster", "sink", "refrigerator", "book", "clock", "vase",
"scissors", "teddy bear", "hair drier", "toothbrush",
)
ALL_COCO_CLASS_IDS: Final = tuple(range(len(COCO_CLASSES)))
class YoloxDetectorError(RuntimeError):
@@ -93,6 +94,58 @@ class FrozenYoloxConfig:
FROZEN_YOLOX_CONFIG: Final = FrozenYoloxConfig()
@dataclass(frozen=True, slots=True)
class AllCocoYoloxConfig:
"""Versioned all-COCO shadow profile using the exact frozen YOLOX tensor."""
source_width: int = 800
source_height: int = 600
input_width: int = 640
input_height: int = 640
fill_value: int = 114
minimum_score: float = 0.5
nms_iou_threshold: float = 0.45
target_class_ids: tuple[int, ...] = ALL_COCO_CLASS_IDS
minimum_box_area_pixels: float = 64.0
maximum_box_area_fraction: float = 0.5
minimum_valid_fov_fraction: float = 0.5
require_center_inside_valid_fov: bool = True
def __post_init__(self) -> None:
if (
self.source_width,
self.source_height,
self.input_width,
self.input_height,
self.fill_value,
self.minimum_score,
self.nms_iou_threshold,
self.target_class_ids,
self.minimum_box_area_pixels,
self.maximum_box_area_fraction,
self.minimum_valid_fov_fraction,
self.require_center_inside_valid_fov,
) != (
800,
600,
640,
640,
114,
0.5,
0.45,
ALL_COCO_CLASS_IDS,
64.0,
0.5,
0.5,
True,
):
raise YoloxDetectorError("all-COCO YOLOX profile cannot be tuned in place")
ALL_COCO_YOLOX_CONFIG: Final = AllCocoYoloxConfig()
type YoloxPostprocessConfig = FrozenYoloxConfig | AllCocoYoloxConfig
@dataclass(frozen=True, slots=True)
class YoloxDetection:
class_id: int
@@ -231,7 +284,7 @@ def preprocess_raw_kb4(
image_bgr: NDArray[np.uint8],
mask: NDArray[np.bool_],
*,
config: FrozenYoloxConfig = FROZEN_YOLOX_CONFIG,
config: YoloxPostprocessConfig = FROZEN_YOLOX_CONFIG,
resizer: ImageResizer | None = None,
) -> NDArray[np.float32]:
if image_bgr.shape != (config.source_height, config.source_width, 3):
@@ -261,7 +314,7 @@ def postprocess_yolox(
output: NDArray[np.float32],
mask: NDArray[np.bool_],
*,
config: FrozenYoloxConfig = FROZEN_YOLOX_CONFIG,
config: YoloxPostprocessConfig = FROZEN_YOLOX_CONFIG,
) -> YoloxPostprocessResult:
if output.shape != (1, 8400, 85) or not np.isfinite(output).all():
raise YoloxDetectorError("YOLOX output tensor is incompatible")
@@ -420,9 +473,10 @@ def _sha256(path: Path) -> str:
__all__ = [
"ALL_COCO_CLASS_IDS", "ALL_COCO_YOLOX_CONFIG", "COCO_CLASSES",
"YOLOX_CONFIG_SHA256", "YOLOX_MODEL_ID", "YOLOX_MODEL_SHA256",
"YOLOX_MODEL_VERSION", "YOLOX_VALID_FOV_SHA256", "FROZEN_YOLOX_CONFIG",
"FrozenYoloxConfig",
"AllCocoYoloxConfig", "FrozenYoloxConfig", "YoloxPostprocessConfig",
"ImageResizer", "InferenceBackend", "OpenCvBilinearResizer",
"TritonHttpInferenceBackend", "YoloxDetection", "YoloxDetectorError",
"YoloxPostprocessResult", "load_valid_fov_mask", "postprocess_yolox",