Skip to content

Commit f066219

Browse files
authored
Merge pull request #25 from dora-rs/feat/opencv-camera-id
Add camera-id option for stable camera selection
2 parents 7ef9a6a + 78a6a21 commit f066219

File tree

1 file changed

+199
-11
lines changed
  • node-hub/opencv-video-capture/opencv_video_capture

1 file changed

+199
-11
lines changed

node-hub/opencv-video-capture/opencv_video_capture/main.py

Lines changed: 199 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,149 @@
1-
"""TODO: Add docstring."""
1+
"""OpenCV Video Capture Node for Dora.
2+
3+
This module provides a video capture node that can access cameras by index
4+
or unique identifier for stable camera selection across platforms.
5+
"""
26

37
import argparse
8+
import json
49
import os
10+
import platform
11+
import subprocess
512
import time
613

714
import cv2
815
import numpy as np
916
import pyarrow as pa
1017
from dora import Node
1118

12-
RUNNER_CI = True if os.getenv("CI") == "true" else False
19+
20+
def get_macos_cameras() -> list[dict]:
21+
"""Get camera info from macOS system_profiler.
22+
23+
Returns:
24+
List of dicts with 'name', 'model_id', and 'unique_id' keys
25+
26+
"""
27+
cameras = []
28+
if platform.system() != "Darwin":
29+
return cameras
30+
31+
try:
32+
result = subprocess.run(
33+
["system_profiler", "SPCameraDataType"],
34+
capture_output=True,
35+
text=True,
36+
)
37+
current_camera = {}
38+
for line in result.stdout.split("\n"):
39+
line = line.strip()
40+
# Camera names appear as lines ending with ":" at low indent
41+
if line.endswith(":") and not line.startswith(
42+
("Camera", "Model ID", "Unique ID")
43+
):
44+
if current_camera:
45+
cameras.append(current_camera)
46+
current_camera = {"name": line[:-1]}
47+
elif line.startswith("Model ID:"):
48+
current_camera["model_id"] = line.split(":", 1)[1].strip()
49+
elif line.startswith("Unique ID:"):
50+
current_camera["unique_id"] = line.split(":", 1)[1].strip()
51+
if current_camera:
52+
cameras.append(current_camera)
53+
except Exception:
54+
pass
55+
56+
return cameras
57+
58+
59+
def get_windows_cameras() -> list[dict]:
60+
"""Get camera info from Windows using PowerShell.
61+
62+
Returns:
63+
List of dicts with 'name' and 'device_id' keys
64+
65+
"""
66+
cameras = []
67+
if platform.system() != "Windows":
68+
return cameras
69+
70+
try:
71+
# Query video capture devices via PowerShell
72+
ps_command = """
73+
Get-PnpDevice -Class Camera -Status OK | Select-Object FriendlyName, InstanceId | ConvertTo-Json
74+
"""
75+
result = subprocess.run(
76+
["powershell", "-Command", ps_command],
77+
capture_output=True,
78+
text=True,
79+
)
80+
if result.returncode == 0 and result.stdout.strip():
81+
data = json.loads(result.stdout)
82+
# Handle single device (dict) or multiple devices (list)
83+
if isinstance(data, dict):
84+
data = [data]
85+
for item in data:
86+
cameras.append( # noqa: PERF401
87+
{
88+
"name": item.get("FriendlyName", "Unknown"),
89+
"device_id": item.get("InstanceId", ""),
90+
}
91+
)
92+
except Exception:
93+
pass
94+
95+
return cameras
96+
97+
98+
def find_camera_by_id(unique_id: str) -> int | None:
99+
"""Find camera index by unique ID.
100+
101+
Args:
102+
unique_id: The unique ID of the camera:
103+
- macOS: from 'system_profiler SPCameraDataType'
104+
- Linux: from /dev/v4l/by-id/
105+
- Windows: from 'Get-PnpDevice -Class Camera' (InstanceId)
106+
107+
Returns:
108+
Camera index if found, None otherwise
109+
110+
"""
111+
if platform.system() == "Darwin":
112+
cameras = get_macos_cameras()
113+
for idx, cam in enumerate(cameras):
114+
if cam.get("unique_id", "").lower() == unique_id.lower():
115+
return idx
116+
117+
elif platform.system() == "Linux":
118+
# On Linux, the unique_id can be the full path or part of the by-id name
119+
by_id_path = "/dev/v4l/by-id/"
120+
if os.path.exists(by_id_path):
121+
for entry in sorted(os.listdir(by_id_path)):
122+
if unique_id.lower() in entry.lower():
123+
real_path = os.path.realpath(os.path.join(by_id_path, entry))
124+
if "video" in real_path:
125+
return int(real_path.replace("/dev/video", ""))
126+
127+
elif platform.system() == "Windows":
128+
cameras = get_windows_cameras()
129+
for idx, cam in enumerate(cameras):
130+
if unique_id.lower() in cam.get("device_id", "").lower():
131+
return idx
132+
133+
return None
134+
135+
136+
RUNNER_CI = os.getenv("CI") == "true"
13137

14138
FLIP = os.getenv("FLIP", "")
15139

16140

17141
def main():
18-
# Handle dynamic nodes, ask for the name of the node in the dataflow, and the same values as the ENV variables.
19-
"""TODO: Add docstring."""
142+
"""Handle video capture from cameras with stable camera selection.
143+
144+
Supports camera selection by index or unique identifier across platforms
145+
(macOS, Linux, Windows). Processes video frames and sends them via Dora.
146+
"""
20147
parser = argparse.ArgumentParser(
21148
description="OpenCV Video Capture: This node is used to capture video from a camera.",
22149
)
@@ -35,6 +162,16 @@ def main():
35162
help="The path of the device to capture (e.g. /dev/video1, or an index like 0, 1...",
36163
default=0,
37164
)
165+
parser.add_argument(
166+
"--camera-id",
167+
type=str,
168+
required=False,
169+
help=(
170+
"Unique camera ID. macOS: 'system_profiler SPCameraDataType', "
171+
"Linux: /dev/v4l/by-id/, Windows: 'Get-PnpDevice -Class Camera'."
172+
),
173+
default=None,
174+
)
38175
parser.add_argument(
39176
"--image-width",
40177
type=int,
@@ -59,14 +196,61 @@ def main():
59196

60197
args = parser.parse_args()
61198

62-
video_capture_path = os.getenv("CAPTURE_PATH", args.path)
63-
encoding = os.getenv("ENCODING", "bgr8")
199+
# Check for camera ID first (most reliable), then path/index
200+
camera_id = os.getenv("CAMERA_ID", args.camera_id)
64201

65-
if isinstance(video_capture_path, str) and video_capture_path.isnumeric():
66-
video_capture_path = int(video_capture_path)
202+
if camera_id:
203+
video_capture_path = find_camera_by_id(camera_id)
204+
if video_capture_path is None:
205+
if platform.system() == "Darwin":
206+
hint = (
207+
"Run 'system_profiler SPCameraDataType' to list available "
208+
"cameras."
209+
)
210+
elif platform.system() == "Windows":
211+
hint = (
212+
"Run 'Get-PnpDevice -Class Camera' in PowerShell to list "
213+
"available cameras."
214+
)
215+
else:
216+
hint = "Check /dev/v4l/by-id/ for available camera IDs."
217+
raise RuntimeError(
218+
f"Could not find camera with ID '{camera_id}'. {hint}"
219+
)
220+
else:
221+
video_capture_path = os.getenv("CAPTURE_PATH", args.path)
222+
if isinstance(video_capture_path, str) and video_capture_path.isnumeric():
223+
video_capture_path = int(video_capture_path)
224+
225+
encoding = os.getenv("ENCODING", "bgr8")
67226

68227
video_capture = cv2.VideoCapture(video_capture_path)
69228

229+
# Print camera info for debugging
230+
if video_capture.isOpened():
231+
if platform.system() == "Darwin":
232+
cameras = get_macos_cameras()
233+
elif platform.system() == "Windows":
234+
cameras = get_windows_cameras()
235+
else:
236+
cameras = []
237+
238+
if isinstance(video_capture_path, int) and video_capture_path < len(
239+
cameras
240+
):
241+
cam_info = cameras[video_capture_path]
242+
print(
243+
f"Opened camera at index {video_capture_path}: "
244+
f"{cam_info.get('name', 'Unknown')}"
245+
)
246+
# Print the appropriate ID field per platform
247+
cam_id = cam_info.get("unique_id") or cam_info.get(
248+
"device_id", "N/A"
249+
)
250+
print(f" Unique ID: {cam_id}")
251+
else:
252+
print(f"Opened camera at index {video_capture_path}")
253+
70254
image_width = os.getenv("IMAGE_WIDTH", args.image_width)
71255

72256
if image_width is not None:
@@ -106,12 +290,15 @@ def main():
106290
if not ret:
107291
if not RUNNER_CI:
108292
raise RuntimeError(
109-
f"Error: cannot read frame from camera at path {video_capture_path}. For resiliency you can use: restart_policy: on-failure in the node definition.",
293+
f"Error: cannot read frame from camera at path "
294+
f"{video_capture_path}. For resiliency you can use: "
295+
f"restart_policy: on-failure in the node definition."
110296
)
111297
frame = np.zeros((480, 640, 3), dtype=np.uint8)
112298
cv2.putText(
113299
frame,
114-
f"Error: no frame for camera at path {video_capture_path}.",
300+
f"Error: no frame for camera at path "
301+
f"{video_capture_path}.",
115302
(30, 30),
116303
cv2.FONT_HERSHEY_SIMPLEX,
117304
0.50,
@@ -132,7 +319,8 @@ def main():
132319
image_width is not None
133320
and image_height is not None
134321
and (
135-
frame.shape[1] != image_width or frame.shape[0] != image_height
322+
frame.shape[1] != image_width
323+
or frame.shape[0] != image_height
136324
)
137325
):
138326
frame = cv2.resize(frame, (image_width, image_height))

0 commit comments

Comments
 (0)