feat(perception): add frozen yolox provider
This commit is contained in:
@@ -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 == {}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()))
|
||||
Reference in New Issue
Block a user