feat(perception): add frozen yolox provider

This commit is contained in:
DCCONSTRUCTIONS
2026-08-05 14:09:51 +03:00
parent 029b486c67
commit a680debfaf
10 changed files with 965 additions and 11 deletions
@@ -26,6 +26,11 @@
"module": "k1link.compute.pipeline_telemetry",
"role": "stage, queue and terminal-outcome telemetry",
"admission": "reuse"
},
{
"module": "k1link.compute.yolox_object_detector",
"role": "frozen source-neutral YOLOX preprocess, Triton transport and postprocess",
"admission": "adapt-behind-provider"
}
],
"historical_wrappers": [
@@ -2,7 +2,7 @@
Date: 2026-08-05
Status: in progress; M4.0M4.2 implemented
Status: in progress; M4.0M4.2 implemented, M4.3 runtime gate open
Audit base: `1b3e0b3` on `feat/simulation-polygon-s1`
@@ -679,6 +679,36 @@ geometry/temporal/degradation/telemetry tests and the complete Python suite
`src/k1link/perception`. The canonical GUI remained on `127.0.0.1:8000`; no
duplicate development server or Worker 006 mutation was introduced.
### 2026-08-05 — M4.3 provider increment, runtime gate remains open
The product detector seam is implemented without importing the immutable E46J
wrapper:
- `k1link.compute.yolox_object_detector` contains only the frozen raw-KB4
valid-FOV fill, top-left bilinear letterbox, Triton V2 binary HTTP transport,
standard YOLOX decode, fixed score/class-wise NMS and FOV/box validation;
- `k1link.perception.detector.FrozenYoloxDetectorProvider` turns exactly one
decoded image into class-agnostic `ObjectProposal2D` rows; the COCO label is
retained only as optional `semantic_hint`, and no tracklet is published;
- model, config, valid-FOV, score, NMS, target classes and raster identities are
frozen against in-place tuning;
- zero-proposal, pathological/non-finite and provider-failure frames are counted
explicitly; graph stage telemetry remains the common latency path;
- the recorded source now has a separate sequential PyAV decode seam, so image
decoding remains source work and no detector logic enters the adapter;
- all 4,489 accepted immutable E46J frame documents and 15,499 detections map to
the new proposal contract with exact frame accounting.
This increment does **not** claim a new 4,489-frame Triton execution. The existing
E46J 47.840 FPS result remains the baseline evidence. A fresh provider execution
requires a digest-bound shadow package; ad-hoc executable staging on Worker 006
is prohibited by the deployment canon. Therefore M4.3 runtime/capacity exit and
its final checker remain open, and M4.4 does not start yet.
Validation at this increment: 76 focused-and-related tests and the complete
Python suite (`1221 passed, 1 skipped`). Scoped Ruff and strict mypy pass for the
new compute primitive and complete `src/k1link/perception` package.
## Implementation order
The implementation sequence is intentionally strict:
+420
View File
@@ -0,0 +1,420 @@
"""Source-neutral frozen YOLOX preprocessing, inference transport and postprocessing."""
from __future__ import annotations
import hashlib
import http.client
import importlib
import json
import math
import urllib.parse
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Final, Protocol, cast
import numpy as np
from numpy.typing import NDArray
from PIL import Image
YOLOX_MODEL_ID: Final = "yolox_s"
YOLOX_MODEL_VERSION: Final = 1
YOLOX_MODEL_SHA256: Final = (
"c5c2d13e59ae883e6af3b45daea64af4833a4951c92d116ec270d9ddbe998063"
)
YOLOX_CONFIG_SHA256: Final = (
"5795c737a7935a655961b069e8404d336d891f9762fb6dffb93956a076479604"
)
YOLOX_VALID_FOV_SHA256: Final = (
"a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63"
)
COCO_CLASSES: Final = (
"person", "bicycle", "car", "motorcycle", "airplane", "bus", "train",
"truck", "boat", "traffic light", "fire hydrant", "stop sign",
"parking meter", "bench", "bird", "cat", "dog", "horse", "sheep",
"cow", "elephant", "bear", "zebra", "giraffe", "backpack", "umbrella",
"handbag", "tie", "suitcase", "frisbee", "skis", "snowboard",
"sports ball", "kite", "baseball bat", "baseball glove", "skateboard",
"surfboard", "tennis racket", "bottle", "wine glass", "cup", "fork",
"knife", "spoon", "bowl", "banana", "apple", "sandwich", "orange",
"broccoli", "carrot", "hot dog", "pizza", "donut", "cake", "chair",
"couch", "potted plant", "bed", "dining table", "toilet", "tv",
"laptop", "mouse", "remote", "keyboard", "cell phone", "microwave",
"oven", "toaster", "sink", "refrigerator", "book", "clock", "vase",
"scissors", "teddy bear", "hair drier", "toothbrush",
)
class YoloxDetectorError(RuntimeError):
"""The frozen detector profile, tensor or inference response is incompatible."""
class InferenceBackend(Protocol):
def infer(self, tensor: NDArray[np.float32]) -> NDArray[np.float32]: ...
class ImageResizer(Protocol):
def resize(self, image: NDArray[np.uint8], width: int, height: int) -> NDArray[np.uint8]: ...
@dataclass(frozen=True, slots=True)
class FrozenYoloxConfig:
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, ...] = (0, 1, 2, 3, 5, 7)
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, (0, 1, 2, 3, 5, 7), 64.0, 0.5, 0.5, True):
raise YoloxDetectorError("frozen YOLOX detector profile cannot be tuned in place")
FROZEN_YOLOX_CONFIG: Final = FrozenYoloxConfig()
@dataclass(frozen=True, slots=True)
class YoloxDetection:
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) or self.label != COCO_CLASSES[self.class_id]:
raise YoloxDetectorError("YOLOX semantic diagnostic is incompatible")
if not math.isfinite(self.score) or not 0.0 <= self.score <= 1.0:
raise YoloxDetectorError("YOLOX 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 YoloxDetectorError("YOLOX source bounding box is invalid")
if not 0.0 <= self.valid_fov_fraction <= 1.0:
raise YoloxDetectorError("YOLOX valid-FOV fraction is invalid")
@dataclass(frozen=True, slots=True)
class YoloxPostprocessResult:
detections: tuple[YoloxDetection, ...]
rejected: tuple[tuple[str, int], ...]
class OpenCvBilinearResizer:
"""Lazy exact E46J resize backend; importing this module does not require OpenCV."""
def resize(
self,
image: NDArray[np.uint8],
width: int,
height: int,
) -> NDArray[np.uint8]:
try:
cv2 = importlib.import_module("cv2")
except ModuleNotFoundError as exc:
raise YoloxDetectorError("OpenCV resize backend is unavailable") from exc
resized = cv2.resize(image, (width, height), interpolation=cv2.INTER_LINEAR)
return np.asarray(resized, dtype=np.uint8)
class TritonHttpInferenceBackend:
"""Persistent, single-stream Triton V2 binary HTTP inference transport."""
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.query or parsed.fragment:
raise YoloxDetectorError("Triton endpoint must be an explicit HTTP origin")
if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
raise YoloxDetectorError("Triton timeout must be positive")
self.path = f"{parsed.path.rstrip('/')}/v2/models/{YOLOX_MODEL_ID}/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]) -> NDArray[np.float32]:
contiguous = np.ascontiguousarray(tensor, dtype=np.float32)
if contiguous.shape != (1, 3, 640, 640) or not np.isfinite(contiguous).all():
raise YoloxDetectorError("Triton input tensor is incompatible")
binary = contiguous.tobytes()
header = {
"inputs": [{
"name": "images",
"shape": [1, 3, 640, 640],
"datatype": "FP32",
"parameters": {"binary_data_size": len(binary)},
}],
"outputs": [{"name": "output", "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 YoloxDetectorError(f"Triton 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"][0]
shape = tuple(int(value) for value in descriptor["shape"])
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise YoloxDetectorError("Triton output descriptor is invalid") from exc
if descriptor.get("name") != "output" or descriptor.get("datatype") != "FP32":
raise YoloxDetectorError("Triton output identity changed")
array = np.frombuffer(payload[header_length:], dtype="<f4")
if shape != (1, 8400, 85) or array.size != math.prod(shape):
raise YoloxDetectorError("Triton YOLOX output shape changed")
output = np.asarray(array.reshape(shape), dtype=np.float32)
if not np.isfinite(output).all():
raise YoloxDetectorError("Triton YOLOX output contains non-finite values")
return output
def load_valid_fov_mask(
path: Path,
*,
expected_sha256: str = YOLOX_VALID_FOV_SHA256,
) -> NDArray[np.bool_]:
resolved = path.resolve(strict=True)
if resolved.is_symlink() or _sha256(resolved) != expected_sha256:
raise YoloxDetectorError("valid-FOV mask identity changed")
mask = np.asarray(Image.open(resolved).convert("L")) > 0
if mask.shape != (600, 800) or not np.any(mask):
raise YoloxDetectorError("valid-FOV mask geometry changed")
return np.asarray(mask, dtype=np.bool_)
def preprocess_raw_kb4(
image_bgr: NDArray[np.uint8],
mask: NDArray[np.bool_],
*,
config: FrozenYoloxConfig = FROZEN_YOLOX_CONFIG,
resizer: ImageResizer | None = None,
) -> NDArray[np.float32]:
if image_bgr.shape != (config.source_height, config.source_width, 3):
raise YoloxDetectorError("raw KB4 image raster changed")
if image_bgr.dtype != np.uint8 or mask.shape != image_bgr.shape[:2] or mask.dtype != np.bool_:
raise YoloxDetectorError("raw KB4 image or valid-FOV mask type changed")
ratio = min(
config.input_height / config.source_height,
config.input_width / config.source_width,
)
resized_width = int(config.source_width * ratio)
resized_height = int(config.source_height * ratio)
masked = np.where(mask[..., None], image_bgr, config.fill_value).astype(np.uint8)
resized = (resizer or OpenCvBilinearResizer()).resize(masked, resized_width, resized_height)
if resized.shape != (resized_height, resized_width, 3):
raise YoloxDetectorError("resize backend returned an incompatible raster")
canvas = np.full(
(config.input_height, config.input_width, 3),
config.fill_value,
dtype=np.uint8,
)
canvas[:resized_height, :resized_width] = resized
return np.ascontiguousarray(canvas.transpose(2, 0, 1), dtype=np.float32)[None]
def postprocess_yolox(
output: NDArray[np.float32],
mask: NDArray[np.bool_],
*,
config: FrozenYoloxConfig = FROZEN_YOLOX_CONFIG,
) -> YoloxPostprocessResult:
if output.shape != (1, 8400, 85) or not np.isfinite(output).all():
raise YoloxDetectorError("YOLOX output tensor is incompatible")
if mask.shape != (config.source_height, config.source_width) or mask.dtype != np.bool_:
raise YoloxDetectorError("valid-FOV mask is incompatible")
prediction = _decode_yolox(output)[0]
boxes = prediction[:, :4]
boxes_xyxy = np.empty_like(boxes)
boxes_xyxy[:, 0] = boxes[:, 0] - boxes[:, 2] / 2.0
boxes_xyxy[:, 1] = boxes[:, 1] - boxes[:, 3] / 2.0
boxes_xyxy[:, 2] = boxes[:, 0] + boxes[:, 2] / 2.0
boxes_xyxy[:, 3] = boxes[:, 1] + boxes[:, 3] / 2.0
ratio = min(
config.input_height / config.source_height,
config.input_width / config.source_width,
)
boxes_xyxy /= ratio
class_scores = prediction[:, 4:5] * prediction[:, 5:]
class_ids = class_scores.argmax(axis=1)
scores = class_scores[np.arange(class_scores.shape[0]), class_ids]
candidate_mask = np.logical_and(
scores >= config.minimum_score,
np.isin(class_ids, config.target_class_ids),
)
candidate_boxes = boxes_xyxy[candidate_mask]
candidate_scores = scores[candidate_mask]
candidate_classes = class_ids[candidate_mask]
finite = np.logical_and(np.isfinite(candidate_boxes).all(axis=1), np.isfinite(candidate_scores))
rejected: Counter[str] = Counter()
nonfinite_count = int((~finite).sum())
if nonfinite_count:
rejected["nonfinite"] = nonfinite_count
candidate_boxes = candidate_boxes[finite]
candidate_scores = candidate_scores[finite]
candidate_classes = candidate_classes[finite]
integral = np.pad(mask.astype(np.int64), ((1, 0), (1, 0))).cumsum(0).cumsum(1)
result: list[YoloxDetection] = []
for class_id in config.target_class_ids:
indices = np.where(candidate_classes == class_id)[0]
if not indices.size:
continue
keep = _nms(candidate_boxes[indices], candidate_scores[indices], config.nms_iou_threshold)
for selected in indices[keep]:
box = candidate_boxes[selected].copy()
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(YoloxDetection(
class_id=int(class_id),
label=COCO_CLASSES[int(class_id)],
score=round(float(candidate_scores[selected]), 9),
bbox_xyxy=(
round(float(box[0]), 6),
round(float(box[1]), 6),
round(float(box[2]), 6),
round(float(box[3]), 6),
),
valid_fov_fraction=round(fraction, 6),
))
result.sort(key=lambda item: (-item.score, item.class_id))
return YoloxPostprocessResult(tuple(result), tuple(sorted(rejected.items())))
def _decode_yolox(output: NDArray[np.float32]) -> NDArray[np.float32]:
predictions = output.copy()
grids: list[NDArray[np.int64]] = []
strides: list[NDArray[np.int64]] = []
for stride in (8, 16, 32):
height = 640 // stride
width = 640 // stride
yv, xv = np.meshgrid(np.arange(height), np.arange(width), indexing="ij")
grids.append(np.stack((xv, yv), axis=2).reshape(1, -1, 2))
strides.append(np.full((1, height * width, 1), stride, dtype=np.int64))
grid = np.concatenate(grids, axis=1)
expanded_strides = np.concatenate(strides, axis=1)
predictions[..., :2] = (predictions[..., :2] + grid) * expanded_strides
with np.errstate(over="ignore", invalid="ignore"):
predictions[..., 2:4] = np.exp(predictions[..., 2:4]) * expanded_strides
return predictions
def _nms(boxes: NDArray[np.float32], scores: NDArray[np.float32], threshold: float) -> list[int]:
order = scores.argsort()[::-1]
keep: list[int] = []
while order.size:
index = int(order[0])
keep.append(index)
overlaps = _box_iou(boxes[index], boxes[order[1:]])
order = order[np.where(overlaps <= threshold)[0] + 1]
return keep
def _box_iou(one: NDArray[np.float32], many: NDArray[np.float32]) -> NDArray[np.float32]:
if many.size == 0:
return np.zeros((0,), dtype=np.float32)
top_left = np.maximum(one[:2], many[:, :2])
bottom_right = np.minimum(one[2:], many[:, 2:])
intersection = np.prod(np.maximum(0.0, bottom_right - top_left), axis=1)
one_area = max(0.0, float(one[2] - one[0])) * max(0.0, float(one[3] - one[1]))
many_area = np.maximum(0.0, many[:, 2] - many[:, 0]) * np.maximum(
0.0, many[:, 3] - many[:, 1]
)
union = one_area + many_area - intersection
return cast(
NDArray[np.float32],
np.divide(intersection, union, out=np.zeros_like(intersection), where=union > 0),
)
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
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
__all__ = [
"YOLOX_CONFIG_SHA256", "YOLOX_MODEL_ID", "YOLOX_MODEL_SHA256",
"YOLOX_MODEL_VERSION", "YOLOX_VALID_FOV_SHA256", "FROZEN_YOLOX_CONFIG",
"FrozenYoloxConfig",
"ImageResizer", "InferenceBackend", "OpenCvBilinearResizer",
"TritonHttpInferenceBackend", "YoloxDetection", "YoloxDetectorError",
"YoloxPostprocessResult", "load_valid_fov_mask", "postprocess_yolox",
"preprocess_raw_kb4",
]
+22
View File
@@ -49,6 +49,15 @@ from .contracts import (
TimestampBundle,
validate_exclusive_point_ownership,
)
from .detector import (
FROZEN_YOLOX_MODEL_ID,
FROZEN_YOLOX_PREPROCESS_ID,
FROZEN_YOLOX_PROVIDER_ID,
DetectorProviderError,
DetectorProviderSnapshot,
FrozenYoloxDetectorProvider,
proposals_from_detections,
)
from .graph import (
GRAPH_RESULT_SCHEMA,
REFERENCE_GRAPH_ID,
@@ -78,8 +87,11 @@ from .providers import (
ThreatProvider,
)
from .recorded_source import (
DecodedRecordedSource,
LiveSourceAdapter,
PyAvRecordedImageDecoder,
RecordedFrameReference,
RecordedImageDecoder,
RecordedRavnoves00Source,
RecordedSourceError,
ReplayPacing,
@@ -129,6 +141,13 @@ __all__ = [
"ThreatDecision",
"TimestampBundle",
"validate_exclusive_point_ownership",
"FROZEN_YOLOX_MODEL_ID",
"FROZEN_YOLOX_PREPROCESS_ID",
"FROZEN_YOLOX_PROVIDER_ID",
"DetectorProviderError",
"DetectorProviderSnapshot",
"FrozenYoloxDetectorProvider",
"proposals_from_detections",
"REFERENCE_GRAPH_CONFIG_SCHEMA",
"DetectorProvider",
"GeometryAssociationProvider",
@@ -154,7 +173,10 @@ __all__ = [
"TerminalOutcome",
"TerminalOutcomeType",
"LiveSourceAdapter",
"DecodedRecordedSource",
"PyAvRecordedImageDecoder",
"RecordedFrameReference",
"RecordedImageDecoder",
"RecordedRavnoves00Source",
"RecordedSourceError",
"ReplayPacing",
+5 -1
View File
@@ -21,6 +21,9 @@ BASELINE_SOURCE_ID: Final = "RAVNOVES00"
BASELINE_SESSION_ID: Final = "20260720T065719Z_viewer_live"
BASELINE_CAMERA_SOURCE_ID: Final = "sensor.camera.right"
BASELINE_RECORDED_JOB_ID: Final = "recorded-camera-602ac89026ed12978619801d"
BASELINE_CAMERA_STREAM_SHA256: Final = (
"cadd1696ff000904eb78633a0a8418104b8024f178b91f3421789021ccb160e8"
)
BASELINE_SOURCE_PACK_ID: Final = (
"e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b"
)
@@ -124,7 +127,8 @@ def load_m4_baseline(path: Path) -> BaselineProfile:
raise BaselineContractError("M4 source pack identity changed")
if source.get("source_pack_artifact_sha256") != BASELINE_SOURCE_PACK_SHA256:
raise BaselineContractError("M4 source pack artifact identity changed")
_digest(source.get("camera_stream_sha256"), "camera stream digest")
if source.get("camera_stream_sha256") != BASELINE_CAMERA_STREAM_SHA256:
raise BaselineContractError("M4 camera stream identity changed")
modalities = _string_array(source.get("modalities"), "source modalities")
if set(modalities) != {"image", "registered-point-increment", "pose"}:
raise BaselineContractError("baseline source must bind image, points and pose")
+156
View File
@@ -0,0 +1,156 @@
"""Frozen raw-KB4 YOLOX provider for class-agnostic object proposals."""
from __future__ import annotations
import time
from collections import Counter
from collections.abc import Callable
from dataclasses import dataclass
from threading import Lock
from typing import Final
import numpy as np
from numpy.typing import NDArray
from k1link.compute.yolox_object_detector import (
FROZEN_YOLOX_CONFIG,
YOLOX_MODEL_ID,
YOLOX_MODEL_VERSION,
FrozenYoloxConfig,
ImageResizer,
InferenceBackend,
YoloxDetection,
postprocess_yolox,
preprocess_raw_kb4,
)
from .contracts import BoundingRegion2D, ObjectProposal2D
from .providers import SourcePacket
FROZEN_YOLOX_PROVIDER_ID: Final = "triton-yolox-s-raw-kb4/v1"
FROZEN_YOLOX_MODEL_ID: Final = f"{YOLOX_MODEL_ID}:{YOLOX_MODEL_VERSION}"
FROZEN_YOLOX_PREPROCESS_ID: Final = "raw-kb4-valid-fov-letterbox/v1"
class DetectorProviderError(RuntimeError):
"""The detector input, frozen inference or proposal output is incompatible."""
@dataclass(frozen=True, slots=True)
class DetectorProviderSnapshot:
input_frames: int
completed_frames: int
failed_frames: int
zero_proposal_frames: int
proposal_count: int
rejected: tuple[tuple[str, int], ...]
core_duration_ns: int
class FrozenYoloxDetectorProvider:
"""One image payload produces one frozen inference request and proposal tuple."""
provider_id: str = FROZEN_YOLOX_PROVIDER_ID
def __init__(
self,
*,
mask: NDArray[np.bool_],
backend: InferenceBackend,
resizer: ImageResizer | None = None,
config: FrozenYoloxConfig = 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):
raise DetectorProviderError("frozen 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("detector 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(
image,
self.mask,
config=self.config,
resizer=self.resizer,
)
output = self.backend.infer(tensor)
postprocessed = postprocess_yolox(output, self.mask, config=self.config)
proposals = proposals_from_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_detections(
packet: SourcePacket,
detections: tuple[YoloxDetection, ...],
) -> 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=FROZEN_YOLOX_PROVIDER_ID,
model_id=FROZEN_YOLOX_MODEL_ID,
preprocess_id=FROZEN_YOLOX_PREPROCESS_ID,
semantic_hint=detection.label,
provider_tracklet=None,
)
for index, detection in enumerate(detections)
)
__all__ = [
"FROZEN_YOLOX_MODEL_ID",
"FROZEN_YOLOX_PREPROCESS_ID",
"FROZEN_YOLOX_PROVIDER_ID",
"DetectorProviderError",
"DetectorProviderSnapshot",
"FrozenYoloxDetectorProvider",
"proposals_from_detections",
]
+78 -2
View File
@@ -3,18 +3,21 @@
from __future__ import annotations
import hashlib
import importlib
import json
import time
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from dataclasses import dataclass, replace
from enum import StrEnum
from pathlib import Path
from threading import Event
from typing import Final, Protocol
from typing import Any, Final, Protocol, cast
import numpy as np
from numpy.typing import NDArray
from .baseline import (
BASELINE_CAMERA_STREAM_SHA256,
BASELINE_PROFILE_ID,
BASELINE_RECORDED_JOB_ID,
BASELINE_SESSION_ID,
@@ -75,6 +78,10 @@ class LiveSourceAdapter(Protocol):
def packets(self, stop_event: Event) -> Iterator[SourcePacket]: ...
class RecordedImageDecoder(Protocol):
def frames(self, stop_event: Event) -> Iterator[NDArray[np.uint8]]: ...
WaitFunction = Callable[[Event, float], bool]
@@ -165,6 +172,72 @@ class RecordedRavnoves00Source:
return False
class DecodedRecordedSource:
"""Attach decoded BGR frames without introducing detector logic into the source."""
provider_id: str = RECORDED_SOURCE_PROVIDER_ID
def __init__(
self,
*,
source: RecordedRavnoves00Source,
decoder: RecordedImageDecoder,
) -> None:
self.source = source
self.decoder = decoder
def packets(self, stop_event: Event) -> Iterator[SourcePacket]:
images = self.decoder.frames(stop_event)
for packet in self.source.packets(stop_event):
try:
image = next(images)
except StopIteration as exc:
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)
except StopIteration:
return
raise RecordedSourceError("decoded image stream exceeds source timeline")
class PyAvRecordedImageDecoder:
"""Sequential full-video decoder used by the Worker 006 recorded source adapter."""
def __init__(
self,
path: Path,
*,
expected_sha256: str = BASELINE_CAMERA_STREAM_SHA256,
) -> None:
self.path = path.resolve(strict=True)
if self.path.is_symlink() or _file_sha256(self.path) != expected_sha256:
raise RecordedSourceError("recorded camera video identity changed")
def frames(self, stop_event: Event) -> Iterator[NDArray[np.uint8]]:
try:
av: Any = importlib.import_module("av")
except ModuleNotFoundError as exc:
raise RecordedSourceError("PyAV decoder is unavailable") from exc
container: Any = av.open(str(self.path))
try:
streams = container.streams.video
if len(streams) != 1:
raise RecordedSourceError("recorded camera video stream count changed")
for decoded in container.decode(streams[0]):
if stop_event.is_set():
return
image = cast(NDArray[np.uint8], decoded.to_ndarray(format="bgr24"))
yield np.asarray(image, dtype=np.uint8)
finally:
container.close()
def _packet(
frame_index: int,
camera: dict[str, object],
@@ -316,8 +389,11 @@ def _event_wait(stop_event: Event, timeout_seconds: float) -> bool:
__all__ = [
"BASELINE_PROFILE_ID",
"LiveSourceAdapter",
"DecodedRecordedSource",
"PyAvRecordedImageDecoder",
"RECORDED_SOURCE_PROVIDER_ID",
"RecordedFrameReference",
"RecordedImageDecoder",
"RecordedRavnoves00Source",
"RecordedSourceError",
"ReplayPacing",
+10 -7
View File
@@ -84,16 +84,19 @@ def test_new_perception_boundary_has_no_experiment_specific_imports() -> None:
assert violations == {}
def test_reference_graph_imports_only_an_admitted_compute_primitive() -> None:
def test_product_perception_imports_only_admitted_compute_primitives() -> None:
inventory = validate_reuse_inventory(REUSE_PATH)
admitted = {
item["module"]
for item in inventory["reusable_primitives"]
if isinstance(item, dict) and isinstance(item.get("module"), str)
}
compute_imports = {
module
for module in _imports(PERCEPTION_ROOT / "graph.py")
if module.startswith("k1link.compute")
}
assert compute_imports <= admitted
violations: dict[str, set[str]] = {}
for path in PERCEPTION_ROOT.glob("*.py"):
compute_imports = {
module for module in _imports(path) if module.startswith("k1link.compute")
}
unadmitted = compute_imports - admitted
if unadmitted:
violations[path.name] = unadmitted
assert violations == {}
+24
View File
@@ -50,6 +50,7 @@ from k1link.perception.providers import (
SourcePacket,
)
from k1link.perception.recorded_source import (
DecodedRecordedSource,
RecordedRavnoves00Source,
RecordedSourceError,
ReplayPacing,
@@ -529,6 +530,29 @@ def test_recorded_source_rejects_timeline_mismatch(tmp_path: Path) -> None:
list(source.packets(Event()))
def test_decoded_recorded_source_attaches_images_without_detector_logic(tmp_path: Path) -> None:
camera_path, timeline_path = _write_recorded_fixture(tmp_path)
source = RecordedRavnoves00Source(
camera_index_path=camera_path,
source_pack_path=timeline_path,
expected_frame_count=2,
expected_source_pack_sha256=None,
)
class Decoder:
def frames(self, stop_event: Event) -> Iterator[np.ndarray]:
for value in (3, 7):
if stop_event.is_set():
return
yield np.full((600, 800, 3), value, dtype=np.uint8)
packets = list(DecodedRecordedSource(source=source, decoder=Decoder()).packets(Event()))
assert len(packets) == 2
assert isinstance(packets[0].image_payload, np.ndarray)
assert int(packets[0].image_payload[0, 0, 0]) == 3
assert int(packets[1].image_payload[0, 0, 0]) == 7
def test_camera_only_path_never_invents_metric_occupancy_or_free_space() -> None:
result = _graph(_Source((_packet(0, lidar=False),))).run()
delivery = result.deliveries[0]
+214
View File
@@ -0,0 +1,214 @@
from __future__ import annotations
import json
import math
from pathlib import Path
from threading import Event
import numpy as np
import pytest
from numpy.typing import NDArray
from k1link.compute.yolox_object_detector import (
FrozenYoloxConfig,
YoloxDetection,
YoloxDetectorError,
postprocess_yolox,
preprocess_raw_kb4,
)
from k1link.perception.contracts import (
ClockBasis,
ModalityOutcome,
ModalityStatus,
SourceEnvelope,
TimestampBundle,
)
from k1link.perception.detector import (
FROZEN_YOLOX_PROVIDER_ID,
DetectorProviderError,
FrozenYoloxDetectorProvider,
proposals_from_detections,
)
from k1link.perception.providers import SourcePacket
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
E46J_FRAMES = (
REPOSITORY_ROOT
/ ".runtime/compute-experiments/e46j/results"
/ "e46j-raw-fisheye-realtime-7119ce4344438eaa0e748db65aa044e9f9f4a0a226e5eea037a7180d0bc7ace7"
/ "frames.jsonl"
)
def _status() -> ModalityStatus:
return ModalityStatus(True, ModalityOutcome.AVAILABLE, "test-available")
def _packet(sequence: int, image: object) -> SourcePacket:
return SourcePacket(
envelope=SourceEnvelope(
source_id="RAVNOVES00",
session_id="20260720T065719Z_viewer_live",
frame_id=f"frame-{sequence:06d}",
sequence=sequence,
timestamps=TimestampBundle(
utc_ns=1_000 + sequence,
monotonic_ns=2_000 + sequence,
source_ns=3_000 + sequence,
clock_basis=ClockBasis.RECORDED_HOST,
),
source_age_ns=0,
binding_reason="test-recorded-source",
calibration_id="camera-1-kb4-test",
representation_id="registered-map-increment-v1",
image=_status(),
registered_point_increment=_status(),
pose=_status(),
),
image_payload=image,
registered_point_increment_payload=("points", sequence),
pose_payload=("pose", sequence),
)
class _Resizer:
def resize(
self,
image: NDArray[np.uint8],
width: int,
height: int,
) -> NDArray[np.uint8]:
assert image.shape == (600, 800, 3)
return np.zeros((height, width, 3), dtype=np.uint8)
class _Backend:
def __init__(self, output: NDArray[np.float32]) -> None:
self.output = output
self.calls = 0
def infer(self, tensor: NDArray[np.float32]) -> NDArray[np.float32]:
assert tensor.shape == (1, 3, 640, 640)
self.calls += 1
return self.output
def _one_person_output() -> NDArray[np.float32]:
output = np.zeros((1, 8400, 85), dtype=np.float32)
output[0, 0, :4] = [40.0, 30.0, math.log(10.0), math.log(10.0)]
output[0, 0, 4] = 0.9
output[0, 0, 5] = 0.9
return output
def test_frozen_preprocess_and_postprocess_match_the_e46j_contract() -> None:
image = np.full((600, 800, 3), 7, dtype=np.uint8)
mask = np.ones((600, 800), dtype=np.bool_)
tensor = preprocess_raw_kb4(image, mask, resizer=_Resizer())
result = postprocess_yolox(_one_person_output(), mask)
assert tensor.shape == (1, 3, 640, 640)
assert np.all(tensor[:, :, 480:, :] == 114)
assert result.rejected == ()
assert len(result.detections) == 1
assert result.detections[0].label == "person"
assert result.detections[0].score == pytest.approx(0.81)
assert result.detections[0].bbox_xyxy == pytest.approx((350.0, 250.0, 450.0, 350.0))
def test_provider_emits_class_optional_product_proposals_and_metrics() -> None:
backend = _Backend(_one_person_output())
provider = FrozenYoloxDetectorProvider(
mask=np.ones((600, 800), dtype=np.bool_),
backend=backend,
resizer=_Resizer(),
clock_ns=iter((10, 20)).__next__,
)
packet = _packet(7, np.zeros((600, 800, 3), dtype=np.uint8))
proposals = provider.detect(packet)
assert backend.calls == 1
assert len(proposals) == 1
assert proposals[0].proposal_id == "proposal-7-0"
assert proposals[0].provider_id == FROZEN_YOLOX_PROVIDER_ID
assert proposals[0].semantic_hint == "person"
assert proposals[0].provider_tracklet is None
assert provider.snapshot().proposal_count == 1
assert provider.snapshot().core_duration_ns == 10
def test_provider_accounts_zero_pathological_and_failed_frames() -> None:
mask = np.ones((600, 800), dtype=np.bool_)
zero = FrozenYoloxDetectorProvider(
mask=mask,
backend=_Backend(np.zeros((1, 8400, 85), dtype=np.float32)),
resizer=_Resizer(),
)
assert zero.detect(_packet(0, np.zeros((600, 800, 3), dtype=np.uint8))) == ()
assert zero.snapshot().zero_proposal_frames == 1
pathological_output = _one_person_output()
pathological_output[0, 0, 2:4] = 1000.0
pathological = FrozenYoloxDetectorProvider(
mask=mask,
backend=_Backend(pathological_output),
resizer=_Resizer(),
)
assert pathological.detect(_packet(1, np.zeros((600, 800, 3), dtype=np.uint8))) == ()
assert dict(pathological.snapshot().rejected)["nonfinite"] == 1
failed = FrozenYoloxDetectorProvider(
mask=mask,
backend=_Backend(_one_person_output()),
resizer=_Resizer(),
)
with pytest.raises(DetectorProviderError, match="decoded BGR"):
failed.detect(_packet(2, "opaque-reference"))
assert failed.snapshot().failed_frames == 1
def test_frozen_profile_rejects_in_place_threshold_tuning() -> None:
with pytest.raises(YoloxDetectorError, match="cannot be tuned"):
FrozenYoloxConfig(minimum_score=0.51)
def test_all_4489_accepted_e46j_frames_map_to_product_contract_without_class_routing() -> None:
assert E46J_FRAMES.is_file()
image = np.zeros((600, 800, 3), dtype=np.uint8)
frame_count = 0
proposal_count = 0
zero_frames = 0
for line in E46J_FRAMES.read_text("utf-8").splitlines():
row = json.loads(line)
detections = tuple(
YoloxDetection(
class_id=item["class_id"],
label=item["label"],
score=item["score"],
bbox_xyxy=tuple(item["bbox_xyxy"]),
valid_fov_fraction=item["valid_fov_fraction"],
)
for item in row["detections"]
)
proposals = proposals_from_detections(_packet(frame_count, image), detections)
assert all(proposal.source_id == "RAVNOVES00" for proposal in proposals)
assert all(proposal.provider_tracklet is None for proposal in proposals)
assert len({proposal.proposal_id for proposal in proposals}) == len(proposals)
proposal_count += len(proposals)
zero_frames += not proposals
frame_count += 1
assert frame_count == 4489
assert proposal_count == 15499
assert zero_frames == 181
def test_source_packet_requires_a_decoded_image_before_real_provider_execution() -> None:
provider = FrozenYoloxDetectorProvider(
mask=np.ones((600, 800), dtype=np.bool_),
backend=_Backend(_one_person_output()),
resizer=_Resizer(),
)
with pytest.raises(DetectorProviderError, match="decoded BGR"):
provider.detect(_packet(0, Event()))