@@ -101,7 +101,12 @@ class OpenCVCamera(Camera):
101101 ```
102102 """
103103
104- def __init__ (self , config : OpenCVCameraConfig ):
104+ def __init__ (
105+ self ,
106+ config : OpenCVCameraConfig ,
107+ source_camera : "OpenCVCamera | None" = None ,
108+ source_key : str | None = None ,
109+ ):
105110 """
106111 Initializes the OpenCVCamera instance.
107112
@@ -112,6 +117,10 @@ def __init__(self, config: OpenCVCameraConfig):
112117
113118 self .config = config
114119 self .index_or_path = config .index_or_path
120+ self .source_camera = source_camera
121+ self .source_key = source_key
122+ self .copy = config .copy
123+ self ._copy_connected = False
115124
116125 self .fps = config .fps
117126 self .color_mode = config .color_mode
@@ -133,12 +142,40 @@ def __init__(self, config: OpenCVCameraConfig):
133142 if self .rotation in [cv2 .ROTATE_90_CLOCKWISE , cv2 .ROTATE_90_COUNTERCLOCKWISE ]:
134143 self .capture_width , self .capture_height = self .height , self .width
135144
145+ def _validate_copy_configuration (self ) -> None :
146+ if self .source_camera is None :
147+ raise ValueError (f"{ self } copy source is not configured." )
148+
149+ config_mismatches = []
150+ if self .width != self .source_camera .width :
151+ config_mismatches .append (f"width={ self .width } != { self .source_camera .width } " )
152+ if self .height != self .source_camera .height :
153+ config_mismatches .append (f"height={ self .height } != { self .source_camera .height } " )
154+ if self .color_mode != self .source_camera .color_mode :
155+ config_mismatches .append (f"color_mode={ self .color_mode } != { self .source_camera .color_mode } " )
156+ if self .rotation != self .source_camera .rotation :
157+ config_mismatches .append (f"rotation={ self .rotation } != { self .source_camera .rotation } " )
158+
159+ if config_mismatches :
160+ raise ValueError (
161+ f"{ self } copy configuration must match source camera { self .source_key } : "
162+ + ", " .join (config_mismatches )
163+ )
164+
136165 def __str__ (self ) -> str :
166+ if self .is_copy_camera and self .source_key is not None :
167+ return f"{ self .__class__ .__name__ } (copy:{ self .source_key } )"
137168 return f"{ self .__class__ .__name__ } ({ self .index_or_path } )"
138169
170+ @property
171+ def is_copy_camera (self ) -> bool :
172+ return self .source_camera is not None
173+
139174 @property
140175 def is_connected (self ) -> bool :
141176 """Checks if the camera is currently connected and opened."""
177+ if self .is_copy_camera :
178+ return self ._copy_connected
142179 return isinstance (self .videocapture , cv2 .VideoCapture ) and self .videocapture .isOpened ()
143180
144181 def connect (self , warmup : bool = True ) -> None :
@@ -156,6 +193,14 @@ def connect(self, warmup: bool = True) -> None:
156193 if self .is_connected :
157194 raise DeviceAlreadyConnectedError (f"{ self } is already connected." )
158195
196+ if self .is_copy_camera :
197+ self ._validate_copy_configuration ()
198+ if self .source_camera is None or not self .source_camera .is_connected :
199+ raise ConnectionError (f"{ self } requires source camera { self .source_key } to be connected first." )
200+ self ._copy_connected = True
201+ logger .info (f"{ self } connected." )
202+ return
203+
159204 # Use 1 thread for OpenCV operations to avoid potential conflicts or
160205 # blocking in multi-threaded applications, especially during data collection.
161206 cv2 .setNumThreads (1 )
@@ -362,6 +407,9 @@ def read(self, color_mode: ColorMode | None = None) -> NDArray[Any]:
362407 received frame dimensions don't match expectations before rotation.
363408 ValueError: If an invalid `color_mode` is requested.
364409 """
410+ if self .is_copy_camera :
411+ return self ._read_from_source (timeout_ms = max (self .warmup_s * 1000 , 200 ), color_mode = color_mode )
412+
365413 if not self .is_connected :
366414 raise DeviceNotConnectedError (f"{ self } is not connected." )
367415
@@ -382,6 +430,19 @@ def read(self, color_mode: ColorMode | None = None) -> NDArray[Any]:
382430
383431 return processed_frame
384432
433+ def _read_from_source (self , timeout_ms : float , color_mode : ColorMode | None = None ) -> NDArray [Any ]:
434+ if not self .is_connected or self .source_camera is None or not self .source_camera .is_connected :
435+ raise DeviceNotConnectedError (f"{ self } is not connected." )
436+
437+ if color_mode is not None and color_mode != self .color_mode :
438+ raise ValueError (
439+ f"{ self } cannot override color_mode while copying from { self .source_key } . "
440+ "Match the copy camera config to the source camera config instead."
441+ )
442+
443+ frame = self .source_camera .async_read (timeout_ms = timeout_ms )
444+ return frame .copy ()
445+
385446 def _postprocess_image (self , image : NDArray [Any ], color_mode : ColorMode | None = None ) -> NDArray [Any ]:
386447 """
387448 Applies color conversion, dimension validation, and rotation to a raw frame.
@@ -496,6 +557,9 @@ def async_read(self, timeout_ms: float = 200) -> NDArray[Any]:
496557 TimeoutError: If no frame becomes available within the specified timeout.
497558 RuntimeError: If an unexpected error occurs.
498559 """
560+ if self .is_copy_camera :
561+ return self ._read_from_source (timeout_ms = timeout_ms )
562+
499563 if not self .is_connected :
500564 raise DeviceNotConnectedError (f"{ self } is not connected." )
501565
@@ -528,6 +592,13 @@ def disconnect(self) -> None:
528592 Raises:
529593 DeviceNotConnectedError: If the camera is already disconnected.
530594 """
595+ if self .is_copy_camera :
596+ if not self ._copy_connected :
597+ raise DeviceNotConnectedError (f"{ self } not connected." )
598+ self ._copy_connected = False
599+ logger .info (f"{ self } disconnected." )
600+ return
601+
531602 if not self .is_connected and self .thread is None :
532603 raise DeviceNotConnectedError (f"{ self } not connected." )
533604
0 commit comments