Files
gps-denied-onboard/src/gps_denied_onboard/frame_source/video_file.py
T
Oleksandr Bezdieniezhnykh 823c0f1b2e [AZ-398] Replay: FrameSource + Clock Protocols + Clock injection
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>
2026-05-12 05:10:01 +03:00

200 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""``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"]