python-camera-manager-directshow 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1004 @@
1
+ from .camera_device_bridge import CameraDeviceBridge
2
+ from typing import NamedTuple, List, Optional, Dict, Sequence, Union
3
+ import threading
4
+ from enum import Enum
5
+
6
+ DebugLoggingConfig = Union[bool, int, Sequence[bool]]
7
+
8
+
9
+ class DotNetLogType(str, Enum):
10
+ Error = "Error"
11
+ Warning = "Warning"
12
+ Info = "Info"
13
+ Debug = "Debug"
14
+
15
+ class CameraFormat(NamedTuple):
16
+ width: int
17
+ height: int
18
+ fps: float
19
+ pixel_format: str
20
+
21
+ class CameraRange(NamedTuple):
22
+ min: float
23
+ max: float
24
+ step: float
25
+ default: float
26
+ current: float
27
+ property_supported: bool
28
+ is_auto: bool
29
+ auto_supported: bool
30
+
31
+ class CameraDeviceInfo(NamedTuple):
32
+ name: str
33
+ path: str
34
+ formats: Optional[List[CameraFormat]]
35
+ ranges: Optional[Dict[str, CameraRange]]
36
+
37
+
38
+ class Camera:
39
+ """
40
+ Encapsulates camera operations including opening, capturing frames,
41
+ and adjusting camera properties (exposure, contrast, brightness, etc.).
42
+ """
43
+
44
+ # The camera bridge is shared across all instances of Camera, and is initialized only once.
45
+ # This ensures that the .NET DLL is loaded only once, improving performance and avoiding redundant loads.
46
+ # The camera bridge provides access to the .NET functions for camera discovery and property retrieval.
47
+ _camera_bridge = None
48
+
49
+ # Debug tiers:
50
+ # 1 = verbose/everything
51
+ # 2 = errors only
52
+ DEBUG_TIER_VERBOSE = 1
53
+ DEBUG_TIER_ERROR = 2
54
+ DotNetLogType = DotNetLogType
55
+
56
+ def __init__(self, debug_logging: DebugLoggingConfig = False):
57
+ """
58
+ ==========================================
59
+ Initialize camera object (not yet connected).
60
+ ==========================================
61
+ """
62
+ self.device_bridge = None
63
+ self.is_open = False
64
+ self.frame_callback = None
65
+ self.property_ranges = {}
66
+ self._property_key_index = {}
67
+ self.available_formats = []
68
+ self._ranges_cache = {}
69
+ self._formats_cache = {}
70
+ self._property_cache_lock = threading.Lock()
71
+ self.device_path = None
72
+ self.current_format = None
73
+ self._request_rgb24_conversion = False
74
+ self._dotnet_log_file_path = None
75
+ self._dotnet_log_limits = None
76
+ self._dotnet_log_levels = None
77
+
78
+ # Boolean flags per debug tier index.
79
+ # Index 0 is unused for readability, tier IDs start at 1.
80
+ self.debug_tiers_enabled = [False, False, False]
81
+
82
+ # Backward-compatible setup:
83
+ # - False/0: no debug tiers
84
+ # - True: verbose + errors
85
+ # - int tier: enable only that tier
86
+ # - list/tuple of bools: explicit per-tier control (copied into internal array)
87
+ if isinstance(debug_logging, (list, tuple)):
88
+ for i in range(1, min(len(self.debug_tiers_enabled), len(debug_logging))):
89
+ self.debug_tiers_enabled[i] = bool(debug_logging[i])
90
+ elif isinstance(debug_logging, int) and debug_logging in (self.DEBUG_TIER_VERBOSE, self.DEBUG_TIER_ERROR):
91
+ self.debug_tiers_enabled[debug_logging] = True
92
+ elif bool(debug_logging):
93
+ self.debug_tiers_enabled[self.DEBUG_TIER_VERBOSE] = True
94
+ self.debug_tiers_enabled[self.DEBUG_TIER_ERROR] = True
95
+
96
+ self._dotnet_log_levels = self._build_dotnet_log_settings_from_tiers()
97
+
98
+ # Initialize the camera bridge ONLY once if it's not already there
99
+ Camera._ensure_bridge()
100
+
101
+ def debug_print(self, text: str, tier: int):
102
+ """
103
+ ==========================================
104
+ Print debug text only if the requested tier is enabled.
105
+
106
+ Args:
107
+ text (str): Message text to print.
108
+ tier (int): Debug tier ID.
109
+ ==========================================
110
+ """
111
+ if 0 <= int(tier) < len(self.debug_tiers_enabled) and self.debug_tiers_enabled[int(tier)]:
112
+ print(text)
113
+
114
+ def _build_dotnet_log_settings_from_tiers(self):
115
+ """
116
+ ==========================================
117
+ Build .NET CameraDevice log-level settings from Python debug tiers.
118
+
119
+ Returns:
120
+ list[tuple[str, bool]]: [(log_type_name, enabled), ...]
121
+ ==========================================
122
+ """
123
+ verbose_enabled = bool(self.debug_tiers_enabled[self.DEBUG_TIER_VERBOSE])
124
+ error_enabled = bool(self.debug_tiers_enabled[self.DEBUG_TIER_ERROR])
125
+
126
+ return [
127
+ ("Error", bool(error_enabled or verbose_enabled)),
128
+ ("Warning", bool(verbose_enabled)),
129
+ ("Info", bool(verbose_enabled)),
130
+ ("Debug", bool(verbose_enabled)),
131
+ ]
132
+
133
+ def _apply_dotnet_logging_configuration(self):
134
+ """
135
+ ==========================================
136
+ Apply cached logging configuration to the active CameraDeviceBridge.
137
+ ==========================================
138
+ """
139
+ if self.device_bridge is None:
140
+ return
141
+
142
+ effective_log_levels = self._dotnet_log_levels
143
+ if effective_log_levels is None:
144
+ effective_log_levels = self._build_dotnet_log_settings_from_tiers()
145
+
146
+ try:
147
+ self.device_bridge.set_dotnet_log_levels(effective_log_levels)
148
+ except Exception as e:
149
+ self.debug_print(f"Failed to apply .NET log levels: {e}", self.DEBUG_TIER_ERROR)
150
+
151
+ if self._dotnet_log_file_path is not None:
152
+ try:
153
+ self.device_bridge.set_dotnet_log_file_location(self._dotnet_log_file_path)
154
+ except Exception as e:
155
+ self.debug_print(f"Failed to apply .NET log file path: {e}", self.DEBUG_TIER_ERROR)
156
+
157
+ if self._dotnet_log_limits is not None:
158
+ try:
159
+ self.device_bridge.set_dotnet_log_limits(**self._dotnet_log_limits)
160
+ except Exception as e:
161
+ self.debug_print(f"Failed to apply .NET log limits: {e}", self.DEBUG_TIER_ERROR)
162
+
163
+ def set_dotnet_log_levels(self, log_levels):
164
+ """
165
+ ==========================================
166
+ Set .NET CameraDevice log levels.
167
+
168
+ Args:
169
+ log_levels: list[tuple[str, bool]] where names are Error/Warning/Info/Debug
170
+
171
+ Returns:
172
+ bool: True if accepted/applied, False otherwise.
173
+ ==========================================
174
+ """
175
+ allowed_log_level_names = {"Error", "Warning", "Info", "Debug"}
176
+ normalized_levels = []
177
+
178
+ if log_levels is not None:
179
+ try:
180
+ for item in log_levels:
181
+ if not isinstance(item, (tuple, list)) or len(item) != 2:
182
+ self.debug_print(
183
+ f"Skipping invalid log-level entry: {item}",
184
+ self.DEBUG_TIER_ERROR
185
+ )
186
+ continue
187
+
188
+ log_type_name, enabled = item
189
+
190
+ if isinstance(log_type_name, Enum):
191
+ parsed_name = str(log_type_name.value)
192
+ elif hasattr(log_type_name, "ToString"):
193
+ parsed_name = str(log_type_name.ToString())
194
+ else:
195
+ parsed_name = str(log_type_name)
196
+
197
+ if parsed_name not in allowed_log_level_names:
198
+ self.debug_print(
199
+ f"Skipping unknown log-level name: {parsed_name}",
200
+ self.DEBUG_TIER_ERROR
201
+ )
202
+ continue
203
+
204
+ normalized_levels.append((parsed_name, bool(enabled)))
205
+ except Exception as e:
206
+ self.debug_print(f"Invalid log-levels payload: {e}", self.DEBUG_TIER_ERROR)
207
+
208
+ if len(normalized_levels) == 0:
209
+ normalized_levels = self._build_dotnet_log_settings_from_tiers()
210
+
211
+ self._dotnet_log_levels = normalized_levels
212
+
213
+ if self.device_bridge is None:
214
+ return True
215
+
216
+ try:
217
+ return bool(self.device_bridge.set_dotnet_log_levels(self._dotnet_log_levels))
218
+ except Exception as e:
219
+ self.debug_print(f"Failed to set .NET log levels: {e}", self.DEBUG_TIER_ERROR)
220
+ return False
221
+
222
+ def get_dotnet_log_levels(self):
223
+ """
224
+ ==========================================
225
+ Get .NET CameraDevice log levels.
226
+
227
+ Returns:
228
+ dict[str, bool]: {"Error": bool, "Warning": bool, "Info": bool, "Debug": bool}
229
+ ==========================================
230
+ """
231
+ if self.device_bridge is not None:
232
+ try:
233
+ return self.device_bridge.get_dotnet_log_levels()
234
+ except Exception as e:
235
+ self.debug_print(f"Failed to get .NET log levels: {e}", self.DEBUG_TIER_ERROR)
236
+
237
+ levels = self._dotnet_log_levels or self._build_dotnet_log_settings_from_tiers()
238
+ return {str(name): bool(enabled) for name, enabled in levels}
239
+
240
+ def set_dotnet_log_limits(
241
+ self,
242
+ max_log_size_bytes=None,
243
+ max_log_age_milliseconds=None,
244
+ target_log_age_milliseconds=None,
245
+ limit_log_size=None,
246
+ limit_log_time=None
247
+ ):
248
+ """
249
+ ==========================================
250
+ Configure .NET CameraDevice log limits.
251
+
252
+ Returns:
253
+ tuple or None: Result from .NET SetLogLimits when bridge is open.
254
+ ==========================================
255
+ """
256
+ self._dotnet_log_limits = {
257
+ "max_log_size_bytes": max_log_size_bytes,
258
+ "max_log_age_milliseconds": max_log_age_milliseconds,
259
+ "target_log_age_milliseconds": target_log_age_milliseconds,
260
+ "limit_log_size": limit_log_size,
261
+ "limit_log_time": limit_log_time,
262
+ }
263
+
264
+ if self.device_bridge is None:
265
+ return None
266
+
267
+ try:
268
+ return self.device_bridge.set_dotnet_log_limits(
269
+ max_log_size_bytes=max_log_size_bytes,
270
+ max_log_age_milliseconds=max_log_age_milliseconds,
271
+ target_log_age_milliseconds=target_log_age_milliseconds,
272
+ limit_log_size=limit_log_size,
273
+ limit_log_time=limit_log_time,
274
+ )
275
+ except Exception as e:
276
+ self.debug_print(f"Failed to set .NET log limits: {e}", self.DEBUG_TIER_ERROR)
277
+ return None
278
+
279
+ def get_dotnet_log_limits(self):
280
+ """
281
+ ==========================================
282
+ Get current .NET CameraDevice log limits.
283
+
284
+ Returns:
285
+ dict: Parsed limit settings.
286
+ ==========================================
287
+ """
288
+ if self.device_bridge is not None:
289
+ try:
290
+ return self.device_bridge.get_dotnet_log_limits()
291
+ except Exception as e:
292
+ self.debug_print(f"Failed to get .NET log limits: {e}", self.DEBUG_TIER_ERROR)
293
+
294
+ return dict(self._dotnet_log_limits) if isinstance(self._dotnet_log_limits, dict) else {}
295
+
296
+ def set_dotnet_log_file_location(self, log_file_path: str):
297
+ """
298
+ ==========================================
299
+ Set .NET CameraDevice log file location.
300
+
301
+ Returns:
302
+ bool: True if accepted/applied, False otherwise.
303
+ ==========================================
304
+ """
305
+ self._dotnet_log_file_path = str(log_file_path)
306
+
307
+ if self.device_bridge is None:
308
+ return True
309
+
310
+ try:
311
+ return bool(self.device_bridge.set_dotnet_log_file_location(self._dotnet_log_file_path))
312
+ except Exception as e:
313
+ self.debug_print(f"Failed to set .NET log file location: {e}", self.DEBUG_TIER_ERROR)
314
+ return False
315
+
316
+ def clean_dotnet_log(self):
317
+ """
318
+ ==========================================
319
+ Trigger .NET CameraDevice log cleanup immediately.
320
+
321
+ Returns:
322
+ bool: True if call succeeded, False otherwise.
323
+ ==========================================
324
+ """
325
+ if self.device_bridge is None:
326
+ return False
327
+
328
+ try:
329
+ return bool(self.device_bridge.clean_dotnet_log())
330
+ except Exception as e:
331
+ self.debug_print(f"Failed to clean .NET log: {e}", self.DEBUG_TIER_ERROR)
332
+ return False
333
+
334
+ def configure_bridge_logging(self, debug_tiers_enabled=None, log_file_path=None, log_limits=None):
335
+ """
336
+ ==========================================
337
+ Configure camera and .NET bridge logging in one call.
338
+
339
+ Args:
340
+ debug_tiers_enabled: Optional list/tuple of bools by tier index.
341
+ log_file_path: Optional .NET log file path.
342
+ log_limits: Optional dict for SetLogLimits args.
343
+
344
+ Returns:
345
+ bool: True if configuration accepted.
346
+ ==========================================
347
+ """
348
+ if debug_tiers_enabled is not None:
349
+ for i in range(1, min(len(self.debug_tiers_enabled), len(debug_tiers_enabled))):
350
+ self.debug_tiers_enabled[i] = bool(debug_tiers_enabled[i])
351
+ self._dotnet_log_levels = self._build_dotnet_log_settings_from_tiers()
352
+
353
+ # Keep Python-side bridge debug behavior in sync while open.
354
+ if self.device_bridge is not None:
355
+ try:
356
+ self.device_bridge.debug_tiers_enabled = self.debug_tiers_enabled.copy()
357
+ except Exception as e:
358
+ self.debug_print(f"Failed to sync debug tiers to bridge: {e}", self.DEBUG_TIER_ERROR)
359
+
360
+ if log_file_path is not None:
361
+ self._dotnet_log_file_path = str(log_file_path)
362
+
363
+ if isinstance(log_limits, dict):
364
+ self._dotnet_log_limits = {
365
+ "max_log_size_bytes": log_limits.get("max_log_size_bytes"),
366
+ "max_log_age_milliseconds": log_limits.get("max_log_age_milliseconds"),
367
+ "target_log_age_milliseconds": log_limits.get("target_log_age_milliseconds"),
368
+ "limit_log_size": log_limits.get("limit_log_size"),
369
+ "limit_log_time": log_limits.get("limit_log_time"),
370
+ }
371
+
372
+ self._apply_dotnet_logging_configuration()
373
+ return True
374
+
375
+ # !!! ============ {WRAPPER METHODS FOR CAMERA INSPECTOR - START} ============ !!!
376
+ # Why wrap the camera inspector methods? This allows us the centralize camera handling
377
+ # into a single class, now we can use one class from the main, or from the GUI, and
378
+ # it will handle both the .NET bridge calls and the OpenCV capture.
379
+ # This will make the code cleaner.
380
+ @staticmethod
381
+ def get_camera_ranges(device_path):
382
+ """
383
+ ==========================================
384
+ Provides the user with the ranges for the various camera parameters,
385
+ such as exposure, contrast, brightness, etc.
386
+ For example some cameras may have exposure between 0 to 255 with a step of 1,
387
+ while others may have a different range and step.
388
+
389
+ Returns a dict mapping property names to CameraRange NamedTuples.
390
+ ==========================================
391
+ """
392
+ Camera._ensure_bridge()
393
+ raw_ranges = Camera._camera_bridge.get_camera_ranges(device_path) or []
394
+
395
+ # Consistent with get_connected_cameras
396
+ return {
397
+ str(raw_range.PropertyName): CameraRange(
398
+ min=float(raw_range.Min),
399
+ max=float(raw_range.Max),
400
+ step=float(raw_range.Step),
401
+ default=float(raw_range.Default),
402
+ current=float(raw_range.CurrentValue),
403
+ property_supported=bool(raw_range.PropertySupported),
404
+ is_auto=bool(raw_range.IsAuto),
405
+ auto_supported=bool(raw_range.AutoSupported)
406
+ ) for raw_range in raw_ranges
407
+ }
408
+
409
+ @staticmethod
410
+ def get_camera_formats(device_path):
411
+ """
412
+ ==========================================
413
+ Different cameras support different stream formats
414
+ (combinations of resolution, frame rate, and pixel format).
415
+ Get the list of unique camera formats for a specific device.
416
+ ==========================================
417
+ """
418
+ Camera._ensure_bridge()
419
+ raw_formats = Camera._camera_bridge.get_camera_formats(device_path) or []
420
+
421
+ python_formats = []
422
+ seen_signatures = set()
423
+
424
+ for raw_f in raw_formats:
425
+ # Clean up and standardize the data
426
+ width = int(raw_f.Width)
427
+ height = int(raw_f.Height)
428
+ raw_fps = float(raw_f.FrameRate)
429
+ pixel_format = str(raw_f.PixelFormat)
430
+
431
+ # "Snap" to common integers if they are extremely close (Epsilon check)
432
+ # This turns 30.00003000003 into 30.0, but keeps 29.97 as 29.97
433
+ if abs(raw_fps - round(raw_fps)) < 0.001:
434
+ fps = float(round(raw_fps))
435
+ else:
436
+ fps = round(raw_fps, 2)
437
+
438
+ # Create a unique 'signature' for this format
439
+ # If a format has the same W, H, FPS, and PixelFormat, it's a duplicate
440
+ signature = (width, height, fps, pixel_format)
441
+
442
+ if signature not in seen_signatures:
443
+ format = CameraFormat(
444
+ width=width,
445
+ height=height,
446
+ fps=fps,
447
+ pixel_format=pixel_format
448
+ )
449
+ python_formats.append(format)
450
+ seen_signatures.add(signature)
451
+
452
+ # Sort by Resolution (Width * Height) descending, then by FPS
453
+ # This ensures the 'best' formats are at the top of your dropdown
454
+ python_formats.sort(key=lambda x: (x.width * x.height, x.fps), reverse=True)
455
+
456
+ return python_formats
457
+
458
+ @staticmethod
459
+ def _ensure_bridge():
460
+ """
461
+ ==========================================
462
+ Private helper to ensure the bridge exists.
463
+ ==========================================
464
+ """
465
+ if Camera._camera_bridge is None:
466
+ # We import here to avoid circular imports if necessary
467
+ from .camera_inspector_bridge import CameraInspectorBridge
468
+ Camera._camera_bridge = CameraInspectorBridge()
469
+
470
+ @staticmethod
471
+ def get_connected_cameras(get_formats=False, get_ranges=False):
472
+ Camera._ensure_bridge()
473
+ uvc_devices_raw = Camera._camera_bridge.get_connected_cameras() or []
474
+ uvc_devices_python_style = []
475
+
476
+ for device in uvc_devices_raw:
477
+ device_path = str(device.DevicePath)
478
+
479
+ # 1. Fetch the data using our existing methods
480
+ formats = Camera.get_camera_formats(device_path) if get_formats else None
481
+ ranges = Camera.get_camera_ranges(device_path) if get_ranges else None
482
+
483
+ # 2. Wrap it in a NamedTuple instead of a dictionary
484
+ # This is where the magic happens
485
+ cam_info = CameraDeviceInfo(
486
+ name=str(device.Name),
487
+ path=device_path,
488
+ formats=formats,
489
+ ranges=ranges
490
+ )
491
+
492
+ uvc_devices_python_style.append(cam_info)
493
+
494
+ return uvc_devices_python_style
495
+ # !!! ============ {WRAPPER METHODS FOR CAMERA INSPECTOR - END} ============ !!!
496
+
497
+ def open(self, device_path: str, camera_format: CameraFormat, request_rgb24_conversion: bool = False):
498
+ """
499
+ ==========================================
500
+ Opens a camera using DirectShow.
501
+
502
+ Args:
503
+ device_path (str): The device path from CameraDeviceInfo
504
+ camera_format (CameraFormat): The desired format (width, height, fps, pixel_format)
505
+ request_rgb24_conversion (bool): Request .NET SampleGrabber RGB24 conversion.
506
+
507
+ Returns:
508
+ bool: True if successful, False otherwise.
509
+ ==========================================
510
+ """
511
+ try:
512
+ # Cache device capabilities on first open, then reuse.
513
+ with self._property_cache_lock:
514
+ if device_path not in self._ranges_cache:
515
+ self._ranges_cache[device_path] = Camera.get_camera_ranges(device_path)
516
+ if device_path not in self._formats_cache:
517
+ self._formats_cache[device_path] = Camera.get_camera_formats(device_path)
518
+
519
+ # Keep current device capabilities easily accessible.
520
+ with self._property_cache_lock:
521
+ self.property_ranges = self._ranges_cache.get(device_path, {})
522
+ self.available_formats = self._formats_cache.get(device_path, [])
523
+ self._rebuild_property_key_index()
524
+
525
+ self._request_rgb24_conversion = bool(request_rgb24_conversion)
526
+
527
+ # Create the DirectShow camera device bridge
528
+ self.device_bridge = CameraDeviceBridge(
529
+ device_path,
530
+ camera_format,
531
+ debug_logging=self.debug_tiers_enabled.copy(),
532
+ request_rgb24_conversion=self._request_rgb24_conversion
533
+ )
534
+
535
+ # Apply any cached .NET logging settings now that the bridge exists.
536
+ self._apply_dotnet_logging_configuration()
537
+
538
+ # Set up frame callback if user has registered one
539
+ if self.frame_callback:
540
+ self.device_bridge.set_frame_callback(self._on_frame_ready)
541
+
542
+ # Start the camera
543
+ self.device_bridge.start()
544
+
545
+ self.is_open = True
546
+ self.device_path = device_path
547
+
548
+ negotiated_format = None
549
+ try:
550
+ negotiated_format = self.device_bridge.get_actual_camera_format()
551
+ except Exception as e:
552
+ self.debug_print(f"Failed to query actual camera format: {e}", self.DEBUG_TIER_ERROR)
553
+
554
+ if negotiated_format is not None:
555
+ width, height, fps, pixel_format = negotiated_format
556
+ self.current_format = CameraFormat(
557
+ width=int(width),
558
+ height=int(height),
559
+ fps=float(fps),
560
+ pixel_format=str(pixel_format)
561
+ )
562
+ self.debug_print(
563
+ "Negotiated camera format: "
564
+ f"{self.current_format.width}x{self.current_format.height} @ "
565
+ f"{self.current_format.fps:.2f} FPS ({self.current_format.pixel_format})",
566
+ self.DEBUG_TIER_VERBOSE
567
+ )
568
+ else:
569
+ self.current_format = camera_format
570
+
571
+ try:
572
+ property_names = [
573
+ str(property_name)
574
+ for property_name, property_range in self.property_ranges.items()
575
+ if bool(property_range.property_supported)
576
+ ]
577
+
578
+ if len(property_names) > 0:
579
+ _all_success, property_results = self.device_bridge.get_property_values(property_names)
580
+ for property_name, success, actual_value in property_results:
581
+ if bool(success):
582
+ self._update_cached_property_value(property_name, int(actual_value))
583
+
584
+ self.debug_print(
585
+ f"Read back {sum(1 for _n, s, _v in property_results if bool(s))}/{len(property_results)} "
586
+ "property values after open.",
587
+ self.DEBUG_TIER_VERBOSE
588
+ )
589
+ except Exception as e:
590
+ self.debug_print(f"Failed to query property values after open: {e}", self.DEBUG_TIER_ERROR)
591
+ return True
592
+ except Exception as e:
593
+ self.debug_print(f"Failed to open camera: {e}", self.DEBUG_TIER_ERROR)
594
+
595
+ # Defensive cleanup if bridge creation/start partially succeeded.
596
+ if self.device_bridge is not None:
597
+ try:
598
+ self.device_bridge.stop()
599
+ except Exception:
600
+ pass
601
+ try:
602
+ self.device_bridge.dispose()
603
+ except Exception as dispose_error:
604
+ self.debug_print(f"Failed to dispose bridge after open failure: {dispose_error}", self.DEBUG_TIER_ERROR)
605
+ finally:
606
+ self.device_bridge = None
607
+
608
+ self.is_open = False
609
+ with self._property_cache_lock:
610
+ self.property_ranges = {}
611
+ self._property_key_index = {}
612
+ self.available_formats = []
613
+ self.device_path = None
614
+ self.current_format = None
615
+ return False
616
+
617
+ def close(self):
618
+ """
619
+ ==========================================
620
+ Closes the camera connection.
621
+ ==========================================
622
+ """
623
+ if self.device_bridge is not None:
624
+ try:
625
+ self.device_bridge.stop()
626
+ self.device_bridge.dispose()
627
+ except Exception as e:
628
+ self.debug_print(f"Error closing camera: {e}", self.DEBUG_TIER_ERROR)
629
+
630
+ self.is_open = False
631
+ self.device_bridge = None
632
+ with self._property_cache_lock:
633
+ self.property_ranges = {}
634
+ self._property_key_index = {}
635
+ self.available_formats = []
636
+ self.device_path = None
637
+ self.current_format = None
638
+
639
+ def set_format(self, camera_format: CameraFormat, request_rgb24_conversion: Optional[bool] = None):
640
+ """
641
+ ==========================================
642
+ Changes the camera stream format by reopening the current device.
643
+ It is important to point out that DirectShow does not support dynamic format changes on the fly,
644
+ so we have to close and reopen the camera with the new format.
645
+ I could have implemented similar method inside of the .net wrapper,
646
+ but it would literally just call the same open and close methods, just one layer closer to
647
+ DirectShow, so I decided to keep it here in the main camera class.
648
+
649
+ Args:
650
+ camera_format (CameraFormat): Target format to apply.
651
+ request_rgb24_conversion (Optional[bool]): Override RGB24 conversion request for reopen.
652
+
653
+ Returns:
654
+ bool: True if format change succeeded, False otherwise.
655
+ ==========================================
656
+ """
657
+ if camera_format is None:
658
+ return False
659
+
660
+ current_rgb24_request = bool(self._request_rgb24_conversion)
661
+ target_rgb24_request = (
662
+ current_rgb24_request
663
+ if request_rgb24_conversion is None
664
+ else bool(request_rgb24_conversion)
665
+ )
666
+ format_unchanged = (self.current_format == camera_format)
667
+ rgb24_unchanged = (target_rgb24_request == current_rgb24_request)
668
+
669
+ # Nothing to change in either camera format or RGB24 conversion behavior.
670
+ if format_unchanged and rgb24_unchanged:
671
+ return True
672
+
673
+ if self.device_path is None:
674
+ # No active/known camera target yet; cannot apply format.
675
+ return False
676
+
677
+ # If camera is currently closed, just remember the desired format.
678
+ if not self.is_open:
679
+ self.current_format = camera_format
680
+ self._request_rgb24_conversion = target_rgb24_request
681
+ return True
682
+
683
+ # Reopen the same device with the new format.
684
+ current_device_path = self.device_path
685
+ previous_format = self.current_format
686
+ previous_rgb24_request = bool(self._request_rgb24_conversion)
687
+ self.close()
688
+ format_changed = self.open(
689
+ current_device_path,
690
+ camera_format,
691
+ request_rgb24_conversion=target_rgb24_request
692
+ )
693
+ if format_changed:
694
+ return True
695
+
696
+ if previous_format is not None:
697
+ rollback_success = self.open(
698
+ current_device_path,
699
+ previous_format,
700
+ request_rgb24_conversion=previous_rgb24_request
701
+ )
702
+ if not rollback_success:
703
+ self.debug_print(
704
+ "Failed to change format and failed to restore previous format.",
705
+ self.DEBUG_TIER_ERROR
706
+ )
707
+
708
+ return False
709
+
710
+ def get_frame(self):
711
+ """
712
+ ==========================================
713
+ Gets the latest frame from the camera.
714
+
715
+ Returns:
716
+ tuple: (success: bool, frame: np.ndarray or None)
717
+ ==========================================
718
+ """
719
+ if not self.is_open or self.device_bridge is None:
720
+ return False, None
721
+
722
+ frame = self.device_bridge.get_latest_frame()
723
+ return frame is not None, frame
724
+
725
+ def get_current_fps(self):
726
+ """
727
+ ==========================================
728
+ Get FPS reported by the active .NET camera device.
729
+
730
+ Returns:
731
+ float: Current FPS measurement or 0.0 if unavailable.
732
+ ==========================================
733
+ """
734
+ if not self.is_open or self.device_bridge is None:
735
+ return 0.0
736
+
737
+ return float(self.device_bridge.get_current_fps())
738
+
739
+ def _on_frame_ready(self, frame_count, frame):
740
+ """
741
+ ==========================================
742
+ Internal callback when a new frame is ready from DirectShow.
743
+ Pass frame directly without conversion (DirectShow outputs BGR already).
744
+ ==========================================
745
+ """
746
+ self.debug_print(f"[Camera] _on_frame_ready called, frame_count={frame_count}", self.DEBUG_TIER_VERBOSE)
747
+ if frame is None:
748
+ self.debug_print("[Camera] ERROR: Frame is None!", self.DEBUG_TIER_ERROR)
749
+ return
750
+
751
+ # DirectShow outputs BGR directly - no conversion needed
752
+ self.debug_print("[Camera] Passing frame to GUI callback...", self.DEBUG_TIER_VERBOSE)
753
+
754
+ # Call user callback with frame as-is
755
+ if self.frame_callback is not None:
756
+ self.frame_callback(True, frame)
757
+ self.debug_print("[Camera] GUI callback completed", self.DEBUG_TIER_VERBOSE)
758
+
759
+ def set_property_auto_mode(self, property_name: str, auto_on: bool):
760
+ """
761
+ ==========================================
762
+ Toggle auto/manual mode for a specific camera property.
763
+
764
+ Args:
765
+ property_name (str): Property name (e.g. Exposure, Brightness, Focus).
766
+ auto_on (bool): True to enable auto mode, False for manual mode.
767
+
768
+ Returns:
769
+ tuple: (success: bool, is_auto_enabled: bool)
770
+ ==========================================
771
+ """
772
+ if not self.is_open or self.device_bridge is None:
773
+ return False, False
774
+
775
+ success, is_auto_enabled = self.device_bridge.set_property_auto_mode(property_name, auto_on)
776
+
777
+ if success:
778
+ self._update_cached_property_auto_mode(property_name, bool(is_auto_enabled))
779
+
780
+ return success, is_auto_enabled
781
+
782
+ def set_property_value(self, property_name: str, value: int):
783
+ """
784
+ ==========================================
785
+ Set a numeric property value on the active camera.
786
+
787
+ Args:
788
+ property_name (str): Property name (e.g. Exposure, Brightness, Focus).
789
+ value (int): Desired value.
790
+
791
+ Returns:
792
+ tuple: (success: bool, actual_value: int)
793
+ ==========================================
794
+ """
795
+ if not self.is_open or self.device_bridge is None:
796
+ return False, int(value)
797
+
798
+ success, actual_value = self.device_bridge.set_property_value(property_name, int(value))
799
+
800
+ if success:
801
+ self._update_cached_property_value(property_name, actual_value)
802
+
803
+ return success, int(actual_value)
804
+
805
+ def set_property_values(self, properties: List[tuple]):
806
+ """
807
+ ==========================================
808
+ Set multiple numeric property values in one batch operation.
809
+
810
+ Args:
811
+ properties (list): List of (property_name: str, value: int)
812
+
813
+ Returns:
814
+ tuple: (all_success: bool, results: list[(property_name, success, actual_value)])
815
+ ==========================================
816
+ """
817
+ if not self.is_open or self.device_bridge is None:
818
+ return False, []
819
+
820
+ all_success, results = self.device_bridge.set_property_values(properties)
821
+
822
+ for property_name, success, actual_value in results:
823
+ if not bool(success):
824
+ continue
825
+ self._update_cached_property_value(property_name, actual_value)
826
+
827
+ return all_success, results
828
+
829
+ def reset_all_properties_to_default_values(self):
830
+ """
831
+ ==========================================
832
+ Reset all supported camera property values to numeric defaults.
833
+
834
+ Note:
835
+ This does not change auto/manual flags.
836
+
837
+ Returns:
838
+ tuple: (all_success: bool, reset_count: int, total_supported_properties: int)
839
+ ==========================================
840
+ """
841
+ if not self.is_open or self.device_bridge is None:
842
+ return False, 0, 0
843
+
844
+ all_success, results = self.device_bridge.reset_all_properties_to_default_values()
845
+
846
+ reset_count = 0
847
+ for property_name, success, actual_value in results:
848
+ if not bool(success):
849
+ continue
850
+ reset_count += 1
851
+ self._update_cached_property_value(property_name, actual_value)
852
+
853
+ total_supported = len(results)
854
+
855
+ return all_success, int(reset_count), int(total_supported)
856
+
857
+ def reset_all_property_flags(self):
858
+ """
859
+ ==========================================
860
+ Reset all auto/manual property flags to Auto mode.
861
+
862
+ Returns:
863
+ tuple: (all_success: bool, updated_count: int, total_auto_supported_properties: int)
864
+ ==========================================
865
+ """
866
+ if not self.is_open or self.device_bridge is None:
867
+ return False, 0, 0
868
+
869
+ all_success, results = self.device_bridge.reset_all_property_flags()
870
+
871
+ updated_count = 0
872
+ for property_name, success, is_auto_enabled in results:
873
+ if not bool(success):
874
+ continue
875
+ updated_count += 1
876
+ self._update_cached_property_auto_mode(property_name, bool(is_auto_enabled))
877
+
878
+ total_auto_supported = len(results)
879
+
880
+ return all_success, int(updated_count), int(total_auto_supported)
881
+
882
+ def _update_cached_property_value(self, property_name: str, actual_value: int):
883
+ """
884
+ ==========================================
885
+ Update one property's cached numeric value.
886
+ ==========================================
887
+ """
888
+ with self._property_cache_lock:
889
+ existing_name = self._get_cached_property_key(property_name)
890
+ if existing_name is None:
891
+ return
892
+
893
+ existing_range = self.property_ranges.get(existing_name)
894
+ if existing_range is None:
895
+ return
896
+
897
+ updated_range = CameraRange(
898
+ min=existing_range.min,
899
+ max=existing_range.max,
900
+ step=existing_range.step,
901
+ default=existing_range.default,
902
+ current=float(actual_value),
903
+ property_supported=existing_range.property_supported,
904
+ is_auto=existing_range.is_auto,
905
+ auto_supported=existing_range.auto_supported
906
+ )
907
+ self.property_ranges[existing_name] = updated_range
908
+
909
+ if self.device_path in self._ranges_cache:
910
+ cached_ranges = self._ranges_cache[self.device_path]
911
+ if existing_name in cached_ranges:
912
+ cached_ranges[existing_name] = updated_range
913
+
914
+ def _update_cached_property_auto_mode(self, property_name: str, is_auto_enabled: bool):
915
+ """
916
+ ==========================================
917
+ Update one property's cached auto/manual mode.
918
+ ==========================================
919
+ """
920
+ with self._property_cache_lock:
921
+ existing_name = self._get_cached_property_key(property_name)
922
+ if existing_name is None:
923
+ return
924
+
925
+ existing_range = self.property_ranges.get(existing_name)
926
+ if existing_range is None:
927
+ return
928
+
929
+ updated_range = CameraRange(
930
+ min=existing_range.min,
931
+ max=existing_range.max,
932
+ step=existing_range.step,
933
+ default=existing_range.default,
934
+ current=existing_range.current,
935
+ property_supported=existing_range.property_supported,
936
+ is_auto=bool(is_auto_enabled),
937
+ auto_supported=existing_range.auto_supported
938
+ )
939
+ self.property_ranges[existing_name] = updated_range
940
+
941
+ if self.device_path in self._ranges_cache:
942
+ cached_ranges = self._ranges_cache[self.device_path]
943
+ if existing_name in cached_ranges:
944
+ cached_ranges[existing_name] = updated_range
945
+
946
+ def _refresh_property_ranges_cache(self):
947
+ """
948
+ ==========================================
949
+ Refresh property ranges from camera and update local caches.
950
+ ==========================================
951
+ """
952
+ if not self.device_path:
953
+ return
954
+
955
+ refreshed_ranges = Camera.get_camera_ranges(self.device_path)
956
+ with self._property_cache_lock:
957
+ self.property_ranges = refreshed_ranges
958
+ if self.device_path in self._ranges_cache:
959
+ self._ranges_cache[self.device_path] = refreshed_ranges
960
+ self._rebuild_property_key_index()
961
+
962
+ def _rebuild_property_key_index(self):
963
+ """
964
+ ==========================================
965
+ Rebuild lowercase property-name lookup index for O(1) cache updates.
966
+ ==========================================
967
+ """
968
+ self._property_key_index = {
969
+ str(existing_name).lower(): existing_name
970
+ for existing_name in self.property_ranges.keys()
971
+ }
972
+
973
+ def _get_cached_property_key(self, property_name: str):
974
+ """
975
+ ==========================================
976
+ Resolve user-provided property name to canonical cache key.
977
+ ==========================================
978
+ """
979
+ requested_key = str(property_name).lower()
980
+ existing_name = self._property_key_index.get(requested_key)
981
+ if existing_name is not None:
982
+ return existing_name
983
+
984
+ # Defensive fallback in case index is stale.
985
+ self._rebuild_property_key_index()
986
+ return self._property_key_index.get(requested_key)
987
+
988
+ def set_frame_callback(self, callback):
989
+ """
990
+ =====================
991
+ Register a callback function to receive frames.
992
+
993
+ Args:
994
+ callback (callable): Function that accepts (success: bool, frame: np.ndarray)
995
+
996
+ Note: The callback is called automatically when frames arrive from DirectShow.
997
+ =====================
998
+ """
999
+ self.frame_callback = callback
1000
+
1001
+ # If camera is already open, register callback with device bridge
1002
+ if self.is_open and self.device_bridge:
1003
+ self.device_bridge.set_frame_callback(self._on_frame_ready)
1004
+