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
37import argparse
8+ import json
49import os
10+ import platform
11+ import subprocess
512import time
613
714import cv2
815import numpy as np
916import pyarrow as pa
1017from 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
14138FLIP = os .getenv ("FLIP" , "" )
15139
16140
17141def 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