mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 14:41:15 +00:00
[AZ-594] Implement core-three harness stubs (fdr_reader, frame_source_replay, imu_replay)
Replaces the NotImplementedError stubs AZ-406 reserved on three runner-
side helpers; these were stranded from any tracker ticket since
AZ-407/408 never came back to fill them. Concrete bodies:
* fdr_reader.iter_records: JSONL parser + wire-envelope validator;
recursive *.jsonl walk; projects {schema_version, ts, producer_id,
kind, payload} to runner-side FdrRecord with record_type/monotonic_ms
renames; yields oldest-first.
* frame_source_replay.replay_video: OpenCV VideoCapture decode + JPEG
re-encode; auto-detects file vs directory; injectable sleep_fn for
unit-test pacing.
* imu_replay.ImuReplayer.replay: csv.DictReader parse; degrees->radians
attitude conversion; tolerates scientific notation; same sleep_fn
injection pattern.
Adds 34 unit tests (14 + 10 + 10). Full e2e unit suite: 558 passed (+31).
Existing scenario _harness_helpers_implemented probes still return False
because they also depend on sitl_observer / fc_proxy_runtime stubs that
remain pending; scenario probe cleanup is out of AZ-594 scope.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,25 +6,30 @@ Two replay modes:
|
||||
SUT polls.
|
||||
2. Video replay (FT-P-02, FT-P-04, FT-N-01..04, NFT-PERF-*) — decode an
|
||||
MP4 with OpenCV and emit frames at the encoded FPS (or a user-supplied
|
||||
rate for fast-forward).
|
||||
rate for fast-forward). ``replay_video`` also accepts a directory of
|
||||
extracted frames (`AD000001.jpg`-style) so the AZ-408 injectors that
|
||||
emit frame directories rather than MP4s can use the same surface.
|
||||
|
||||
The actual frame-source path inside the SUT container is configured via the
|
||||
``ONBOARD_FRAME_SOURCE_PATH`` environment variable on the SUT — the runner
|
||||
writes to a shared tmpfs volume mounted at the same path inside both
|
||||
containers.
|
||||
|
||||
This file currently provides the public surface used by per-scenario tests;
|
||||
concrete implementations land alongside their consuming test tasks
|
||||
(AZ-407 onward). The intent is that `FrameSourceReplayer` is a stable API
|
||||
the test specs can rely on while the underlying replay strategy is filled
|
||||
in incrementally.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
from typing import Callable, Protocol
|
||||
|
||||
import cv2
|
||||
|
||||
# Image extensions handled by ``replay_image_directory`` / ``replay_video``
|
||||
# when given a directory rather than a file. Sorted-by-name ordering implies
|
||||
# zero-padded filenames (``AD000001.jpg``) which the AZ-407 / AZ-408
|
||||
# fixture builders already produce.
|
||||
_IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".bmp")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -43,35 +48,109 @@ class FrameSink(Protocol):
|
||||
|
||||
|
||||
class FrameSourceReplayer:
|
||||
"""Public surface for replaying frames into the SUT's frame-source path.
|
||||
"""Public surface for replaying frames into the SUT's frame-source path."""
|
||||
|
||||
AZ-407 (Static fixture builders) supplies the concrete still-image replay
|
||||
implementation; AZ-408 (Runtime synthetic-injection) supplies the video
|
||||
+ injector variants. AZ-406 only commits to the contract.
|
||||
"""
|
||||
|
||||
def __init__(self, sink: FrameSink, cadence: ReplayCadence | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
sink: FrameSink,
|
||||
cadence: ReplayCadence | None = None,
|
||||
*,
|
||||
sleep_fn: Callable[[float], None] = time.sleep,
|
||||
) -> None:
|
||||
self._sink = sink
|
||||
self._cadence = cadence or ReplayCadence()
|
||||
# Injected so unit tests can drop wall-clock pacing entirely.
|
||||
self._sleep = sleep_fn
|
||||
|
||||
def replay_image_directory(self, directory: Path) -> int:
|
||||
"""Replay every image in ``directory`` (sorted by name). Returns count emitted.
|
||||
"""Replay every image in ``directory`` (sorted by name) at the configured
|
||||
cadence. Returns the number of frames emitted to the sink.
|
||||
|
||||
Raises NotImplementedError until AZ-407 lands. Tests that need this
|
||||
path should mark themselves @pytest.mark.skip(reason="awaiting AZ-407")
|
||||
until then; AC-1 (smoke) does not depend on this surface.
|
||||
Raises ``FileNotFoundError`` if ``directory`` does not exist or is not
|
||||
a directory. The sink is invoked with raw JPEG-encoded bytes and a
|
||||
monotonic-ms timestamp that starts at 0 and advances by the period
|
||||
derived from ``self._cadence.fps``.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"FrameSourceReplayer.replay_image_directory is owned by AZ-407 — "
|
||||
"AZ-406 supplies only the public surface."
|
||||
if not directory.exists() or not directory.is_dir():
|
||||
raise FileNotFoundError(
|
||||
f"frame directory not found: {directory}"
|
||||
)
|
||||
files = sorted(
|
||||
p for p in directory.iterdir()
|
||||
if p.is_file() and p.suffix.lower() in _IMAGE_EXTENSIONS
|
||||
)
|
||||
return self._emit_files(files)
|
||||
|
||||
def replay_video(self, video_path: Path) -> int:
|
||||
"""Replay an MP4 / .h264 file frame-by-frame. Returns count emitted.
|
||||
"""Replay frames from ``video_path``. Returns count emitted.
|
||||
|
||||
Raises NotImplementedError until AZ-408 lands.
|
||||
Auto-detects:
|
||||
* a regular file (``.mp4`` / ``.avi`` / ``.h264``) — decoded with
|
||||
OpenCV ``VideoCapture`` and re-encoded as JPEG before emission.
|
||||
* a directory — delegates to ``replay_image_directory`` so the
|
||||
AZ-408 injectors that emit frame directories can use this entry
|
||||
point without the caller knowing the difference.
|
||||
|
||||
Raises ``FileNotFoundError`` on a missing path.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"FrameSourceReplayer.replay_video is owned by AZ-408 — "
|
||||
"AZ-406 supplies only the public surface."
|
||||
)
|
||||
if not video_path.exists():
|
||||
raise FileNotFoundError(f"video path not found: {video_path}")
|
||||
if video_path.is_dir():
|
||||
return self.replay_image_directory(video_path)
|
||||
return self._decode_and_emit_video(video_path)
|
||||
|
||||
def _decode_and_emit_video(self, video_path: Path) -> int:
|
||||
cap = cv2.VideoCapture(str(video_path))
|
||||
if not cap.isOpened():
|
||||
raise ValueError(f"OpenCV failed to open video: {video_path}")
|
||||
try:
|
||||
encoded_fps = cap.get(cv2.CAP_PROP_FPS) or 0.0
|
||||
fps = encoded_fps if (self._cadence.realtime and encoded_fps > 0) else self._cadence.fps
|
||||
period_ms = self._period_ms(fps)
|
||||
emitted = 0
|
||||
t_ms = 0
|
||||
while True:
|
||||
ok, frame = cap.read()
|
||||
if not ok:
|
||||
break
|
||||
success, encoded = cv2.imencode(".jpg", frame)
|
||||
if not success:
|
||||
raise ValueError(
|
||||
f"OpenCV failed to JPEG-encode frame {emitted} of {video_path}"
|
||||
)
|
||||
self._sink.write_frame(encoded.tobytes(), t_ms)
|
||||
emitted += 1
|
||||
t_ms += period_ms
|
||||
if self._cadence.realtime and period_ms > 0:
|
||||
self._sleep(period_ms / 1000.0)
|
||||
return emitted
|
||||
finally:
|
||||
cap.release()
|
||||
|
||||
def _emit_files(self, files: list[Path]) -> int:
|
||||
period_ms = self._period_ms(self._cadence.fps)
|
||||
emitted = 0
|
||||
t_ms = 0
|
||||
for path in files:
|
||||
jpeg_bytes = path.read_bytes()
|
||||
if path.suffix.lower() != ".jpg" and path.suffix.lower() != ".jpeg":
|
||||
# Re-encode non-JPEG sources so the sink always gets JPEG bytes.
|
||||
img = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
|
||||
if img is None:
|
||||
raise ValueError(f"OpenCV failed to read image: {path}")
|
||||
success, encoded = cv2.imencode(".jpg", img)
|
||||
if not success:
|
||||
raise ValueError(f"OpenCV failed to JPEG-encode image: {path}")
|
||||
jpeg_bytes = encoded.tobytes()
|
||||
self._sink.write_frame(jpeg_bytes, t_ms)
|
||||
emitted += 1
|
||||
t_ms += period_ms
|
||||
if self._cadence.realtime and period_ms > 0:
|
||||
self._sleep(period_ms / 1000.0)
|
||||
return emitted
|
||||
|
||||
@staticmethod
|
||||
def _period_ms(fps: float) -> int:
|
||||
if fps <= 0:
|
||||
return 0
|
||||
return int(round(1000.0 / fps))
|
||||
|
||||
Reference in New Issue
Block a user