Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions examples/camera_viz/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ dependencies = [
# Vendor-SDK deps are optional. ``./camera_viz.sh setup`` installs all
# extras by default; pass ``--no-<extra>`` to skip.
[project.optional-dependencies]
v4l2 = ["opencv-python>=4.5"]
# OpenCV 5.0 wheels on aarch64 omit V4L2; keep camera installs on 4.x.
v4l2 = ["opencv-python>=4.5,<5"]
# type: video (file replay) decodes via OpenCV's FFmpeg backend — same
# wheel as v4l2, listed separately so --no-v4l2 setups can still ask for it.
video = ["opencv-python>=4.5"]
video = ["opencv-python>=4.5,<5"]
oakd = ["depthai>=3.0"]
# pyzed isn't on PyPI — the ZED SDK's bundled ``get_python_api.py``
# produces a Python-version-specific wheel. ``camera_viz.sh setup --with-zed``
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Check OpenCV's V4L2 support without opening a camera or initializing CUDA."""

import sys
from types import ModuleType


def load_v4l2() -> ModuleType:
try:
import cv2
except ImportError as exc:
raise RuntimeError(
"V4L2 capture requires opencv-python. Run camera_viz.sh setup "
'or install "opencv-python>=4.5,<5" in the camera environment.'
) from exc

if not cv2.videoio_registry.hasBackend(cv2.CAP_V4L2):
raise RuntimeError(
f"OpenCV {cv2.__version__} has no available V4L2 backend. "
"Run camera_viz.sh setup to install the supported OpenCV wheel, "
"or use a Linux OpenCV build with WITH_V4L=ON."
)
return cv2


def main() -> int:
try:
cv2 = load_v4l2()
except Exception as exc:
print(f"V4L2 check failed: {exc}", file=sys.stderr)
return 1
print(f"OpenCV {cv2.__version__}: V4L2 backend available")
return 0


if __name__ == "__main__":
sys.exit(main())
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import numpy as np

from ._helpers import PolledSource
from ._v4l2_backend import load_v4l2


class V4l2Source(PolledSource):
Expand All @@ -39,6 +40,8 @@ def __init__(
fps: float = 30.0,
fourcc: Optional[str] = None,
) -> None:
# A missing backend cannot recover in the camera reconnect loop.
self._cv2 = load_v4l2()
super().__init__(name=name, width=width, height=height, staging_channels=3)
self._device = device
self._fps = float(fps)
Expand All @@ -51,20 +54,9 @@ def __init__(
# keeps the alpha channel untouched.
self._gpu_bgr = self._cp.empty((height, width, 3), dtype=self._cp.uint8)

self._cv2 = None # imported lazily in _open_device
self._cap = None

def _open_device(self) -> bool:
if self._cv2 is None:
try:
import cv2
except ImportError as e:
raise RuntimeError(
"V4l2Source requires opencv-python. Install via "
"`uv pip install opencv-python`."
) from e
self._cv2 = cv2

cv2 = self._cv2
cap = cv2.VideoCapture(self._device, cv2.CAP_V4L2)
if not cap.isOpened():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def __init__(
except ImportError as e:
raise RuntimeError(
"VideoFileSource requires opencv-python. Install via "
"`uv pip install opencv-python`."
'`uv pip install "opencv-python>=4.5,<5"`.'
) from e
self._cv2 = cv2
self._path = Path(path)
Expand Down
8 changes: 7 additions & 1 deletion examples/camera_viz/scripts/_install_deps.sh
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ fi
# pillow renders the XR controls HUD (isaaccapture_examples/camera_viz/controls/hud.py).
PKGS=("pyyaml>=6.0" "$target_cupy" "numpy>=1.23" "scipy>=1.15" "pillow>=10.0")
[[ "$MODE" == full ]] && PKGS=("$ISAACCAPTURE_PKG" "${PKGS[@]}")
$WITH_V4L2 && PKGS+=("opencv-python>=4.5")
$WITH_V4L2 && PKGS+=("opencv-python>=4.5,<5")
$WITH_OAKD && PKGS+=("depthai>=3.0")
$WITH_RTP && PKGS+=("pybind11>=2.11" "PyGObject>=3.42,<3.52")

Expand Down Expand Up @@ -622,6 +622,12 @@ if $WITH_RTP; then
fi
fi

if $WITH_V4L2; then
step "verifying OpenCV V4L2 backend"
"$PY" "$CAMERA_VIZ_DIR/python/isaaccapture_examples/camera_viz/sources/_v4l2_backend.py" \
|| die "V4L2 capture is unavailable — see the diagnostics above"
fi

# Native Argus camera source. Jetson-only and opt-in; failures are non-fatal
# because some Jetson images may still lack matching libargus/CUDA headers.
ARGUS_DIR="$CAMERA_VIZ_DIR/python/isaaccapture_examples/camera_viz/argus"
Expand Down
2 changes: 1 addition & 1 deletion tests/python/examples/camera_viz/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ dev = [
"numpy",
"pyyaml>=6.0",
# Encodes + decodes the synthetic clips in test_video_file_source.py.
"opencv-python>=4.5",
"opencv-python>=4.5,<5",
# placements/_math.py (pulled in by test_controls.py) uses Rotation.
"scipy>=1.15",
# hud.py draws the controls panel with PIL.
Expand Down
164 changes: 164 additions & 0 deletions tests/python/examples/camera_viz/test_v4l2_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""V4L2 backend failures must surface before capture starts, without a GPU."""

import os
import shlex
import subprocess
import sys
from types import SimpleNamespace
from unittest.mock import Mock

import numpy as np
import pytest

from isaaccapture_examples.camera_viz.pipeline import SourceSpec
from isaaccapture_examples.camera_viz.sources._helpers import PolledSource
from isaaccapture_examples.camera_viz.sources.v4l2 import V4l2Source
from repo_paths import repo_root


@pytest.fixture
def opencv(monkeypatch):
cv2 = SimpleNamespace(
__version__="5.0.0",
CAP_V4L2=200,
CAP_PROP_FOURCC=6,
CAP_PROP_FRAME_WIDTH=3,
CAP_PROP_FRAME_HEIGHT=4,
CAP_PROP_FPS=5,
CAP_PROP_BUFFERSIZE=38,
videoio_registry=SimpleNamespace(hasBackend=Mock(return_value=True)),
VideoCapture=Mock(),
VideoWriter_fourcc=Mock(return_value=1196444237),
)
monkeypatch.setitem(sys.modules, "cv2", cv2)
allocations = []

def init(source, name, width, height, **kwargs):
allocations.append(name)
source._cp = np
source._spec = SourceSpec(name, width, height, "rgba8")

monkeypatch.setattr(PolledSource, "__init__", init)
return cv2, allocations


def test_missing_backend_fails_before_gpu_allocation(opencv):
cv2, allocations = opencv
cv2.videoio_registry.hasBackend.return_value = False
with pytest.raises(RuntimeError, match=r"OpenCV 5\.0\.0.*V4L2") as error:
V4l2Source("usb")
assert "camera_viz.sh setup" in str(error.value)
assert not allocations
cv2.VideoCapture.assert_not_called()


def test_missing_opencv_fails_before_gpu_allocation(opencv, monkeypatch):
_, allocations = opencv
monkeypatch.setitem(sys.modules, "cv2", None)
with pytest.raises(RuntimeError, match="opencv-python"):
V4l2Source("usb")
assert not allocations


def test_available_backend_opens_and_configures_camera(opencv):
cv2, _ = opencv
cap = cv2.VideoCapture.return_value
cap.isOpened.return_value = True
cap.get.side_effect = [640, 480]
source = V4l2Source("usb", "/dev/video6", 640, 480, 30, "MJPG")
assert source._open_device()
cv2.videoio_registry.hasBackend.assert_called_once_with(cv2.CAP_V4L2)
cv2.VideoCapture.assert_called_once_with("/dev/video6", cv2.CAP_V4L2)
assert [call.args for call in cap.set.call_args_list] == [
(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc.return_value),
(cv2.CAP_PROP_FRAME_WIDTH, 640),
(cv2.CAP_PROP_FRAME_HEIGHT, 480),
(cv2.CAP_PROP_FPS, 30.0),
(cv2.CAP_PROP_BUFFERSIZE, 1),
]
source._close_device()
cap.release.assert_called_once()


def test_unplugged_camera_can_reconnect(opencv):
cv2, _ = opencv
cap = cv2.VideoCapture.return_value
cap.isOpened.side_effect = [False, True]
cap.get.side_effect = [640, 480]
source = V4l2Source("usb", width=640, height=480)
assert source._open_device() is False
cap.release.assert_called_once()
assert source._open_device() is True
source._close_device()


@pytest.mark.skipif(sys.platform == "win32", reason="setup uses a POSIX shell")
@pytest.mark.parametrize(
"enabled, available, exit_code",
[(True, True, 0), (True, False, 1), (False, False, 0)],
)
def test_setup_checks_backend_when_enabled(tmp_path, enabled, available, exit_code):
camera_dir = repo_root() / "examples" / "camera_viz"
toolkit = tmp_path / "cuda-12.6"
toolkit.mkdir()
venv = tmp_path / "venv"
bin_dir = venv / "bin"
bin_dir.mkdir(parents=True)
wheel = tmp_path / "isaaccapture.whl"
wheel.touch()
modules = tmp_path / "modules"
modules.mkdir()
(modules / "cv2.py").write_text(
"from types import SimpleNamespace\n"
"__version__ = 'test-build'\nCAP_V4L2 = 200\n"
f"videoio_registry = SimpleNamespace(hasBackend=lambda _: {available!r})\n"
)
# Stub installation and GPU probes, but execute the real backend check.
python = bin_dir / "python"
python.write_text(
'#!/bin/sh\ncase "$1" in\n'
f'*/_v4l2_backend.py) exec {shlex.quote(sys.executable)} "$@" ;;\n'
"esac\nexit 0\n"
)
python.chmod(0o755)
uv = bin_dir / "uv"
uv.write_text(
'#!/bin/sh\nif [ "$1" = pip ] && [ "$2" = show ]; then exit 1; fi\nexit 0\n'
)
uv.chmod(0o755)
args = [
"bash",
str(camera_dir / "scripts" / "_install_deps.sh"),
"--venv",
str(venv),
"--wheel",
str(wheel),
"--no-oakd",
]
if not enabled:
args.append("--no-v4l2")
result = subprocess.run(
args,
cwd=tmp_path,
env={
**os.environ,
"PATH": str(bin_dir) + os.pathsep + os.environ["PATH"],
"CUDA_PATH": str(toolkit),
"PYTHONPATH": str(modules),
},
check=False, # A missing backend must make setup exit unsuccessfully.
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == exit_code, result.stdout + result.stderr
assert ("setup complete" in result.stdout) == (exit_code == 0)
if enabled:
assert "opencv-python>=4.5,<5" in result.stdout
assert ("V4L2 backend available" in result.stdout) == available
if not available:
assert "no available V4L2 backend" in result.stderr
else:
assert "verifying OpenCV V4L2 backend" not in result.stdout
Loading