mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 19:51:13 +00:00
823c0f1b2e
Ship the two Layer-1 cross-cutting Protocols replay mode needs to leave production C1-C5 components mode-agnostic (Invariant 1) and replay- deterministic (Invariant 2). Live + replay binaries see the same interfaces; only the strategy differs. * Clock Protocol (monotonic_ns / time_ns / sleep_until_ns) + WallClock (live + REALTIME replay) + TlogDerivedClock (ASAP replay; advance-on-call; non-monotonic source → ClockOrderingError). * FrameSource Protocol (next_frame -> NavCameraFrame | None / close) + LiveCameraFrameSource (cv2.VideoCapture device index) + VideoFileFrameSource (cv2.VideoCapture file). * Build-flag gating: BUILD_VIDEO_FILE_FRAME_SOURCE, BUILD_LIVE_CAMERA_FRAME_SOURCE (constructor-time check; Tier-0 OFF refuses construction with FrameSourceConfigError). * Composition-root factories: build_clock + build_frame_source. * Injected Clock across every component that previously called time.monotonic_ns() / time.sleep() directly: c5_state (estimator, ESKF, fallback watcher, source-label SM, isam2 handle), c8_fc_adapter (inbound MAVLink + MSP2, AP outbound, iNav outbound, QGC GCS), c13_fdr writer, c12_operator_tooling httpx flights client. All constructors default to WallClock() so existing call sites keep live-binary behaviour without a wiring change. * AC-4 CI guard (tests/_meta/test_no_direct_time_in_components.py) AST-scans components/**/*.py for direct time.monotonic_ns / time.time_ns / time.sleep references and fails loudly with file:line. * Conformance + factory tests: tests/unit/clock + tests/unit/frame_source. * Test fixture updates: FallbackWatcher / SourceLabelStateMachine clock_ns is now required (removed time.monotonic_ns default); test_az388 patches estimator._clock instead of a module-level time; test_az393 ardupilot adapter uses a _FixedClock test double. Excluded per the task spec: TlogReplayFcAdapter (AZ-399), ReplaySink (AZ-400), compose_replay (AZ-401), CLI (AZ-402), Docker/CI (AZ-403), E2E fixture (AZ-404), IMU auto-sync (AZ-405). Co-authored-by: Cursor <cursoragent@cursor.com>
200 lines
7.1 KiB
Python
200 lines
7.1 KiB
Python
"""``VideoFileFrameSource`` — replay-only file decoder (AZ-398).
|
||
|
||
Streams an MP4 / MKV / AVI file frame-by-frame via OpenCV's
|
||
:class:`cv2.VideoCapture`. Each emitted :class:`NavCameraFrame`
|
||
carries:
|
||
|
||
- ``frame_id`` — a strictly-increasing counter starting at 0.
|
||
- ``timestamp`` — UTC wall-clock at decode time (from the injected
|
||
:class:`Clock`); the file's own pts is NOT used for this field
|
||
because replay deterministically remaps it.
|
||
- ``image`` — the decoded BGR ``numpy.ndarray`` (OpenCV native order).
|
||
- ``metadata["monotonic_ns"]`` — the injected :class:`Clock`'s
|
||
``monotonic_ns()`` at decode time. This is the value AC-2 asserts
|
||
non-decreasing.
|
||
- ``metadata["source_pts_ns"]`` — the file's per-frame PTS in ns (the
|
||
``CAP_PROP_POS_MSEC`` reading × 1e6) for downstream determinism.
|
||
|
||
Gated by ``BUILD_VIDEO_FILE_FRAME_SOURCE`` (Invariant 9). The check is
|
||
performed at constructor entry — a Tier-0 build that imports this
|
||
module by accident still raises ``FrameSourceConfigError`` cleanly
|
||
without attempting an OpenCV import.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
from gps_denied_onboard.frame_source.errors import (
|
||
FrameSourceConfigError,
|
||
FrameSourceError,
|
||
)
|
||
|
||
if TYPE_CHECKING:
|
||
from gps_denied_onboard._types.nav import NavCameraFrame
|
||
from gps_denied_onboard.clock import Clock
|
||
|
||
|
||
_BUILD_FLAG = "BUILD_VIDEO_FILE_FRAME_SOURCE"
|
||
|
||
_logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _build_flag_on() -> bool:
|
||
"""``ON`` / ``1`` / ``true`` / ``yes`` (case-insensitive) → ``True``."""
|
||
raw = os.environ.get(_BUILD_FLAG, "")
|
||
return raw.strip().lower() in {"on", "1", "true", "yes"}
|
||
|
||
|
||
class VideoFileFrameSource:
|
||
"""Replay :class:`FrameSource` strategy backed by ``cv2.VideoCapture``.
|
||
|
||
Stream-decodes a video file; per-frame decode is amortised by
|
||
OpenCV's internal buffer. The strategy preserves the file's frame
|
||
order — there is no seek, no random-access path; this keeps
|
||
replay deterministic (Invariant 10).
|
||
|
||
Constructor parameters:
|
||
|
||
- ``path`` — filesystem path to an MP4/MKV/AVI (existence checked
|
||
at construction).
|
||
- ``camera_calibration_id`` — string identifier propagated into
|
||
every emitted :class:`NavCameraFrame` so downstream consumers
|
||
(C1, C3, C4) load the correct intrinsics for the recording.
|
||
- ``clock`` — injected :class:`Clock`; the strategy reads
|
||
``clock.monotonic_ns()`` per emitted frame for the
|
||
``metadata["monotonic_ns"]`` ordering field.
|
||
"""
|
||
|
||
__slots__ = (
|
||
"_path",
|
||
"_camera_calibration_id",
|
||
"_clock",
|
||
"_capture",
|
||
"_frame_counter",
|
||
"_last_monotonic_ns",
|
||
"_closed",
|
||
"_eos_returned",
|
||
)
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
path: Path | str,
|
||
camera_calibration_id: str,
|
||
clock: "Clock",
|
||
) -> None:
|
||
if not _build_flag_on():
|
||
raise FrameSourceConfigError(
|
||
f"{_BUILD_FLAG} is OFF in this binary; "
|
||
"VideoFileFrameSource is unavailable. Rebuild with the "
|
||
"flag set to ON in the replay binary's Dockerfile."
|
||
)
|
||
resolved = Path(path)
|
||
if not resolved.is_file():
|
||
raise FrameSourceConfigError(
|
||
f"VideoFileFrameSource: path {resolved!s} does not exist "
|
||
"or is not a regular file."
|
||
)
|
||
try:
|
||
import cv2 as _cv2
|
||
except ImportError as exc:
|
||
raise FrameSourceConfigError(
|
||
"VideoFileFrameSource requires opencv-python; not "
|
||
"importable in this binary."
|
||
) from exc
|
||
capture = _cv2.VideoCapture(str(resolved))
|
||
if not capture.isOpened():
|
||
capture.release()
|
||
raise FrameSourceConfigError(
|
||
f"VideoFileFrameSource: cv2.VideoCapture could not open "
|
||
f"{resolved!s} (unsupported codec or corrupt header)."
|
||
)
|
||
self._path = resolved
|
||
self._camera_calibration_id = camera_calibration_id
|
||
self._clock = clock
|
||
self._capture = capture
|
||
self._frame_counter = 0
|
||
self._last_monotonic_ns = -1
|
||
self._closed = False
|
||
self._eos_returned = False
|
||
|
||
def next_frame(self) -> "NavCameraFrame | None":
|
||
from gps_denied_onboard._types.nav import NavCameraFrame
|
||
|
||
if self._closed or self._eos_returned:
|
||
return None
|
||
try:
|
||
import cv2 as _cv2
|
||
except ImportError as exc: # pragma: no cover — established at __init__.
|
||
raise FrameSourceError(
|
||
"VideoFileFrameSource: opencv-python disappeared between "
|
||
"construction and next_frame()"
|
||
) from exc
|
||
ok, image = self._capture.read()
|
||
if not ok:
|
||
self._eos_returned = True
|
||
return None
|
||
if image is None:
|
||
# OpenCV's read() returning ok=True with image=None signals a
|
||
# decoder-internal failure for the current frame; treat as a
|
||
# transient error per Invariant 4 rather than silently
|
||
# advancing.
|
||
raise FrameSourceError(
|
||
f"VideoFileFrameSource: video decode failed at frame "
|
||
f"{self._frame_counter} (cv2.VideoCapture.read returned "
|
||
"ok=True with image=None)"
|
||
)
|
||
monotonic_ns = self._clock.monotonic_ns()
|
||
if monotonic_ns < self._last_monotonic_ns:
|
||
raise FrameSourceError(
|
||
f"VideoFileFrameSource: clock went backwards at frame "
|
||
f"{self._frame_counter}: {monotonic_ns} ns followed "
|
||
f"{self._last_monotonic_ns} ns (Invariant 3)"
|
||
)
|
||
pos_msec = float(self._capture.get(_cv2.CAP_PROP_POS_MSEC))
|
||
source_pts_ns = int(pos_msec * 1_000_000)
|
||
timestamp = datetime.fromtimestamp(
|
||
self._clock.time_ns() / 1e9, tz=timezone.utc
|
||
)
|
||
metadata: dict[str, Any] = {
|
||
"monotonic_ns": monotonic_ns,
|
||
"source_pts_ns": source_pts_ns,
|
||
"source": "video_file",
|
||
}
|
||
frame = NavCameraFrame(
|
||
frame_id=self._frame_counter,
|
||
timestamp=timestamp,
|
||
image=image,
|
||
camera_calibration_id=self._camera_calibration_id,
|
||
metadata=metadata,
|
||
)
|
||
self._frame_counter += 1
|
||
self._last_monotonic_ns = monotonic_ns
|
||
return frame
|
||
|
||
def close(self) -> None:
|
||
if self._closed:
|
||
_logger.debug(
|
||
"VideoFileFrameSource(%s) close called twice; no-op",
|
||
self._path,
|
||
)
|
||
return
|
||
self._closed = True
|
||
try:
|
||
self._capture.release()
|
||
except Exception: # pragma: no cover — defensive.
|
||
# cv2.VideoCapture.release should never raise; if it does on
|
||
# an exotic backend, we still want to flag the source as
|
||
# closed so a second close() stays a no-op.
|
||
_logger.exception(
|
||
"VideoFileFrameSource(%s) cv2.release() raised", self._path
|
||
)
|
||
|
||
|
||
__all__ = ["VideoFileFrameSource"]
|