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,1305 @@
1
+ import os
2
+ import sys
3
+ import clr
4
+ import ctypes
5
+ import numpy as np
6
+ import threading
7
+ import time
8
+ from types import SimpleNamespace
9
+ from enum import Enum, IntEnum
10
+ from typing import Optional, Callable, Tuple
11
+
12
+ try:
13
+ import cv2
14
+ except Exception:
15
+ cv2 = None
16
+
17
+
18
+ class DebugTier(IntEnum):
19
+ VERBOSE = 1
20
+ ERROR = 2
21
+
22
+
23
+ class CaptureMode(str, Enum):
24
+ NONE = "none"
25
+ EVENT_DRIVEN = "event_driven"
26
+
27
+ class CameraDeviceBridge:
28
+ """
29
+ Python wrapper for the .NET CameraDevice class.
30
+ Provides access to DirectShow camera streaming functionality.
31
+ """
32
+
33
+ # Debug tiers:
34
+ # 1 = verbose/everything
35
+ # 2 = errors only
36
+ DEBUG_TIER_VERBOSE = DebugTier.VERBOSE
37
+ DEBUG_TIER_ERROR = DebugTier.ERROR
38
+ DebugTier = DebugTier
39
+ CaptureMode = CaptureMode
40
+
41
+ def __init__(
42
+ self,
43
+ device_path: str,
44
+ camera_format: Optional['CameraFormat'] = None,
45
+ debug_logging: bool = False,
46
+ request_rgb24_conversion: bool = False
47
+ ):
48
+ """
49
+ ==========================================
50
+ Initialize the camera device bridge.
51
+
52
+ Args:
53
+ device_path: The device path from CameraDeviceInfo
54
+ camera_format: Optional CameraFormat NamedTuple with width, height, fps, pixel_format
55
+ debug_logging: Enable verbose bridge debug logs
56
+ request_rgb24_conversion: Request RGB24 conversion in .NET SampleGrabber path
57
+ ==========================================
58
+ """
59
+ self._device = None # Instance of the .NET CameraDevice
60
+ self._camera_device_class = None # Reference to the .NET CameraDevice type/class
61
+ self._dotnet_log_file_path = None # Optional .NET-side log file destination
62
+ self._dotnet_log_limits = None # Optional cached .NET log retention/size settings
63
+ self._frame_callback = None # User callback invoked when a frame is processed
64
+ self._format = camera_format # Selected CameraFormat (width, height, fps, pixel format)
65
+ self._pixel_format_name = ""
66
+ self._bytes_per_pixel = 0
67
+ self._frame_width = int(getattr(camera_format, "width", 0) or 0)
68
+ self._frame_height = int(getattr(camera_format, "height", 0) or 0)
69
+ self._yuy2_buffer_size = 0
70
+ self._raw_buffer_size = 0
71
+ self._shape_yuy2 = None
72
+ self._shape_gray = None
73
+ self._shape_rgb = None
74
+ self._shape_raw = None
75
+ self._request_rgb24_conversion = bool(request_rgb24_conversion)
76
+
77
+ # Boolean flags per debug tier index.
78
+ # Index 0 is unused for readability, tier IDs start at 1.
79
+ self.debug_tiers_enabled = [False, False, False]
80
+
81
+ # Backward-compatible setup:
82
+ # - False/0: no debug tiers
83
+ # - True: verbose + errors
84
+ # - int tier: enable only that tier
85
+ # - list/tuple of bools: explicit per-tier control (copied into internal array)
86
+ if isinstance(debug_logging, (list, tuple)):
87
+ for i in range(1, min(len(self.debug_tiers_enabled), len(debug_logging))):
88
+ self.debug_tiers_enabled[i] = bool(debug_logging[i])
89
+ elif isinstance(debug_logging, int) and debug_logging in (self.DEBUG_TIER_VERBOSE, self.DEBUG_TIER_ERROR):
90
+ self.debug_tiers_enabled[debug_logging] = True
91
+ elif bool(debug_logging):
92
+ self.debug_tiers_enabled[self.DEBUG_TIER_VERBOSE] = True
93
+ self.debug_tiers_enabled[self.DEBUG_TIER_ERROR] = True
94
+
95
+ self._current_frame = None
96
+ self._frame_state_lock = threading.Lock()
97
+ self._event_subscription = None # Keep reference to event handler
98
+ self._capture_mode = self.CaptureMode.NONE # Current frame acquisition mode
99
+ self._warned_unsupported_pixel_format = False
100
+ self._warned_mjpg_decoder_unavailable = False
101
+ self._refresh_cached_format_metadata()
102
+ self._initialize_bridge(device_path, camera_format)
103
+
104
+ def _refresh_cached_format_metadata(self):
105
+ """
106
+ ==========================================
107
+ Cache normalized pixel-format metadata used in frame hot paths.
108
+ ==========================================
109
+ """
110
+ if self._format is None:
111
+ self._pixel_format_name = ""
112
+ self._bytes_per_pixel = 0
113
+ self._frame_width = 0
114
+ self._frame_height = 0
115
+ self._yuy2_buffer_size = 0
116
+ self._raw_buffer_size = 0
117
+ self._shape_yuy2 = None
118
+ self._shape_gray = None
119
+ self._shape_rgb = None
120
+ self._shape_raw = None
121
+ return
122
+
123
+ raw = str(getattr(self._format, "pixel_format", "") or "")
124
+ self._pixel_format_name = raw.strip().upper()
125
+
126
+ # .NET RGB24 conversion typically lands in BI_RGB byte order (B, G, R) in memory.
127
+ # Treat converted frames as BGR24 so the GUI path (which expects BGR input) stays correct.
128
+ if bool(self._request_rgb24_conversion):
129
+ self._pixel_format_name = "BGR24"
130
+ self._frame_width = int(getattr(self._format, "width", 0) or 0)
131
+ self._frame_height = int(getattr(self._format, "height", 0) or 0)
132
+
133
+ if self._pixel_format_name in ("RGB24", "BGR24"):
134
+ self._bytes_per_pixel = 3
135
+ elif self._pixel_format_name in ("RGB32", "BGR32", "ARGB32", "XRGB32"):
136
+ self._bytes_per_pixel = 4
137
+ elif self._pixel_format_name in ("GRAY8", "Y8"):
138
+ self._bytes_per_pixel = 1
139
+ else:
140
+ self._bytes_per_pixel = 0
141
+
142
+ self._yuy2_buffer_size = int(self._frame_width) * int(self._frame_height) * 2
143
+ self._raw_buffer_size = int(self._frame_width) * int(self._frame_height) * int(self._bytes_per_pixel)
144
+ self._shape_yuy2 = (self._frame_height, self._frame_width, 2)
145
+ self._shape_gray = (self._frame_height, self._frame_width, 1)
146
+ self._shape_rgb = (self._frame_height, self._frame_width, 3)
147
+ self._shape_raw = (self._frame_height, self._frame_width, self._bytes_per_pixel)
148
+
149
+ def _normalized_pixel_format_name(self) -> str:
150
+ """
151
+ ==========================================
152
+ Return selected pixel format as an uppercase, trimmed string.
153
+ ==========================================
154
+ """
155
+ return self._pixel_format_name
156
+
157
+ def _bytes_per_pixel_for_current_format(self) -> int:
158
+ """
159
+ ==========================================
160
+ Map current uncompressed pixel format to bytes-per-pixel.
161
+ ==========================================
162
+ """
163
+ return int(self._bytes_per_pixel)
164
+
165
+ def _build_dotnet_log_settings_from_tiers(self):
166
+ """
167
+ ==========================================
168
+ Build .NET CameraDevice log-level settings from Python debug tiers.
169
+
170
+ Returns:
171
+ list[tuple[str, bool]]: [(log_type_name, enabled), ...]
172
+ ==========================================
173
+ """
174
+ verbose_enabled = bool(self.debug_tiers_enabled[self.DEBUG_TIER_VERBOSE])
175
+ error_enabled = bool(self.debug_tiers_enabled[self.DEBUG_TIER_ERROR])
176
+
177
+ return [
178
+ ("Error", bool(error_enabled or verbose_enabled)),
179
+ ("Warning", bool(verbose_enabled)),
180
+ ("Info", bool(verbose_enabled)),
181
+ ("Debug", bool(verbose_enabled)),
182
+ ]
183
+
184
+ def _to_dotnet_log_settings_array(self, log_levels):
185
+ """
186
+ ==========================================
187
+ Convert Python log-level settings to .NET ValueTuple[] for SetLogLevels.
188
+
189
+ Args:
190
+ log_levels: list[tuple[str, bool]] where names are Error/Warning/Info/Debug
191
+
192
+ Returns:
193
+ System.Array[ValueTuple[CameraDevice.LogType, bool]]
194
+ ==========================================
195
+ """
196
+ if self._camera_device_class is None:
197
+ raise RuntimeError("CameraDevice .NET type is not available")
198
+
199
+ system_module = __import__("System")
200
+ log_type_enum = self._camera_device_class.LogType
201
+ tuple_type = system_module.ValueTuple[log_type_enum, system_module.Boolean]
202
+
203
+ dotnet_items = []
204
+ for log_type_name, enabled in log_levels:
205
+ if not hasattr(log_type_enum, str(log_type_name)):
206
+ continue
207
+ enum_value = getattr(log_type_enum, str(log_type_name))
208
+ dotnet_items.append(tuple_type(enum_value, bool(enabled)))
209
+
210
+ return system_module.Array[tuple_type](dotnet_items)
211
+
212
+ def _apply_dotnet_logging_configuration(self):
213
+ """
214
+ ==========================================
215
+ Apply bridge logging configuration to the .NET CameraDevice instance.
216
+ ==========================================
217
+ """
218
+ if self._device is None:
219
+ return
220
+
221
+ try:
222
+ tier_settings = self._build_dotnet_log_settings_from_tiers()
223
+ self.set_dotnet_log_levels(tier_settings)
224
+ except Exception as e:
225
+ self.debug_print(f"Failed to apply .NET log levels: {e}", self.DEBUG_TIER_ERROR)
226
+
227
+ if self._dotnet_log_file_path is not None:
228
+ try:
229
+ self.set_dotnet_log_file_location(self._dotnet_log_file_path)
230
+ except Exception as e:
231
+ self.debug_print(f"Failed to apply .NET log file path: {e}", self.DEBUG_TIER_ERROR)
232
+
233
+ if self._dotnet_log_limits is not None:
234
+ try:
235
+ self.set_dotnet_log_limits(**self._dotnet_log_limits)
236
+ except Exception as e:
237
+ self.debug_print(f"Failed to apply .NET log limits: {e}", self.DEBUG_TIER_ERROR)
238
+
239
+ def set_dotnet_log_levels(self, log_levels):
240
+ """
241
+ ==========================================
242
+ Set .NET CameraDevice log levels.
243
+
244
+ Args:
245
+ log_levels: list[tuple[str, bool]] where names are Error/Warning/Info/Debug
246
+
247
+ Returns:
248
+ bool: True if applied, False otherwise.
249
+ ==========================================
250
+ """
251
+ allowed_log_level_names = {"Error", "Warning", "Info", "Debug"}
252
+ normalized_levels = []
253
+
254
+ if log_levels is not None:
255
+ try:
256
+ for item in log_levels:
257
+ if not isinstance(item, (tuple, list)) or len(item) != 2:
258
+ self.debug_print(
259
+ f"Skipping invalid log-level entry: {item}",
260
+ self.DEBUG_TIER_ERROR
261
+ )
262
+ continue
263
+
264
+ log_type_name, enabled = item
265
+
266
+ if isinstance(log_type_name, Enum):
267
+ parsed_name = str(log_type_name.value)
268
+ elif hasattr(log_type_name, "ToString"):
269
+ parsed_name = str(log_type_name.ToString())
270
+ else:
271
+ parsed_name = str(log_type_name)
272
+
273
+ if parsed_name not in allowed_log_level_names:
274
+ self.debug_print(
275
+ f"Skipping unknown log-level name: {parsed_name}",
276
+ self.DEBUG_TIER_ERROR
277
+ )
278
+ continue
279
+
280
+ normalized_levels.append((parsed_name, bool(enabled)))
281
+ except Exception as e:
282
+ self.debug_print(f"Invalid log-levels payload: {e}", self.DEBUG_TIER_ERROR)
283
+
284
+ if len(normalized_levels) == 0:
285
+ normalized_levels = self._build_dotnet_log_settings_from_tiers()
286
+
287
+ if self._device is None:
288
+ return False
289
+
290
+ try:
291
+ settings_array = self._to_dotnet_log_settings_array(normalized_levels)
292
+ self._device.SetLogLevels(settings_array)
293
+ return True
294
+ except Exception as e:
295
+ self.debug_print(f"Failed to set .NET log levels: {e}", self.DEBUG_TIER_ERROR)
296
+ return False
297
+
298
+ def get_dotnet_log_levels(self):
299
+ """
300
+ ==========================================
301
+ Get .NET CameraDevice log levels.
302
+
303
+ Returns:
304
+ dict[str, bool]: {"Error": bool, "Warning": bool, "Info": bool, "Debug": bool}
305
+ ==========================================
306
+ """
307
+ if self._device is None:
308
+ return {}
309
+
310
+ try:
311
+ raw_levels = self._device.GetLogLevels()
312
+ parsed = {}
313
+ if raw_levels is None:
314
+ return parsed
315
+
316
+ for item in raw_levels:
317
+ try:
318
+ parsed[str(item.Item1)] = bool(item.Item2)
319
+ except Exception:
320
+ continue
321
+
322
+ return parsed
323
+ except Exception as e:
324
+ self.debug_print(f"Failed to get .NET log levels: {e}", self.DEBUG_TIER_ERROR)
325
+ return {}
326
+
327
+ def set_dotnet_log_limits(
328
+ self,
329
+ max_log_size_bytes=None,
330
+ max_log_age_milliseconds=None,
331
+ target_log_age_milliseconds=None,
332
+ limit_log_size=None,
333
+ limit_log_time=None
334
+ ):
335
+ """
336
+ ==========================================
337
+ Configure .NET CameraDevice log limits.
338
+
339
+ Returns:
340
+ tuple or None: Result from .NET SetLogLimits
341
+ ==========================================
342
+ """
343
+ self._dotnet_log_limits = {
344
+ "max_log_size_bytes": max_log_size_bytes,
345
+ "max_log_age_milliseconds": max_log_age_milliseconds,
346
+ "target_log_age_milliseconds": target_log_age_milliseconds,
347
+ "limit_log_size": limit_log_size,
348
+ "limit_log_time": limit_log_time,
349
+ }
350
+
351
+ if self._device is None:
352
+ return None
353
+
354
+ try:
355
+ return self._device.SetLogLimits(
356
+ max_log_size_bytes,
357
+ max_log_age_milliseconds,
358
+ target_log_age_milliseconds,
359
+ limit_log_size,
360
+ limit_log_time,
361
+ )
362
+ except Exception as e:
363
+ self.debug_print(f"Failed to set .NET log limits: {e}", self.DEBUG_TIER_ERROR)
364
+ return None
365
+
366
+ def get_dotnet_log_limits(self):
367
+ """
368
+ ==========================================
369
+ Get current .NET CameraDevice log limits.
370
+
371
+ Returns:
372
+ dict: Parsed limit settings.
373
+ ==========================================
374
+ """
375
+ if self._device is None:
376
+ return {}
377
+
378
+ try:
379
+ raw_limits = self._device.GetLogLimits()
380
+ if raw_limits is None:
381
+ return {}
382
+
383
+ return {
384
+ "max_log_size_bytes": int(raw_limits.Item1),
385
+ "max_log_age_milliseconds": int(raw_limits.Item2),
386
+ "target_log_age_milliseconds": int(raw_limits.Item3),
387
+ "limit_log_size": bool(raw_limits.Item4),
388
+ "limit_log_time": bool(raw_limits.Item5),
389
+ }
390
+ except Exception as e:
391
+ self.debug_print(f"Failed to get .NET log limits: {e}", self.DEBUG_TIER_ERROR)
392
+ return {}
393
+
394
+ def set_dotnet_log_file_location(self, log_file_path: str):
395
+ """
396
+ ==========================================
397
+ Set .NET CameraDevice log file location.
398
+
399
+ Returns:
400
+ bool: True if accepted by .NET, False otherwise.
401
+ ==========================================
402
+ """
403
+ self._dotnet_log_file_path = log_file_path
404
+
405
+ if self._device is None:
406
+ return False
407
+
408
+ try:
409
+ return bool(self._device.SetLogFileLocation(str(log_file_path)))
410
+ except Exception as e:
411
+ self.debug_print(f"Failed to set .NET log file location: {e}", self.DEBUG_TIER_ERROR)
412
+ return False
413
+
414
+ def clean_dotnet_log(self):
415
+ """
416
+ ==========================================
417
+ Trigger .NET CameraDevice log cleanup immediately.
418
+
419
+ Returns:
420
+ bool: True if call succeeded, False otherwise.
421
+ ==========================================
422
+ """
423
+ if self._device is None:
424
+ return False
425
+
426
+ try:
427
+ self._device.CleanLog()
428
+ return True
429
+ except Exception as e:
430
+ self.debug_print(f"Failed to clean .NET log: {e}", self.DEBUG_TIER_ERROR)
431
+ return False
432
+
433
+ def configure_bridge_logging(self, debug_tiers_enabled=None, log_file_path=None, log_limits=None):
434
+ """
435
+ ==========================================
436
+ Configure bridge and .NET logging in one call.
437
+
438
+ Args:
439
+ debug_tiers_enabled: Optional list/tuple of bools by tier index.
440
+ log_file_path: Optional .NET log file path.
441
+ log_limits: Optional dict for SetLogLimits args.
442
+
443
+ Returns:
444
+ bool: True if configuration accepted.
445
+ ==========================================
446
+ """
447
+ if debug_tiers_enabled is not None:
448
+ for i in range(1, min(len(self.debug_tiers_enabled), len(debug_tiers_enabled))):
449
+ self.debug_tiers_enabled[i] = bool(debug_tiers_enabled[i])
450
+
451
+ if log_file_path is not None:
452
+ self._dotnet_log_file_path = str(log_file_path)
453
+
454
+ if isinstance(log_limits, dict):
455
+ self._dotnet_log_limits = {
456
+ "max_log_size_bytes": log_limits.get("max_log_size_bytes"),
457
+ "max_log_age_milliseconds": log_limits.get("max_log_age_milliseconds"),
458
+ "target_log_age_milliseconds": log_limits.get("target_log_age_milliseconds"),
459
+ "limit_log_size": log_limits.get("limit_log_size"),
460
+ "limit_log_time": log_limits.get("limit_log_time"),
461
+ }
462
+
463
+ self._apply_dotnet_logging_configuration()
464
+ return True
465
+
466
+ def debug_print(self, text: str, tier: int):
467
+ """
468
+ ==========================================
469
+ Print debug text only if the requested tier is enabled.
470
+
471
+ Args:
472
+ text (str): Message text to print.
473
+ tier (int): Debug tier ID.
474
+ ==========================================
475
+ """
476
+ if 0 <= int(tier) < len(self.debug_tiers_enabled) and self.debug_tiers_enabled[int(tier)]:
477
+ print(text)
478
+
479
+ def _initialize_bridge(self, device_path: str, camera_format: Optional['CameraFormat']):
480
+ """
481
+ ==========================================
482
+ Loads the DLLs and initializes the CameraDevice.
483
+ ==========================================
484
+ """
485
+ current_dir = os.path.dirname(os.path.abspath(__file__))
486
+ project_root = os.path.dirname(current_dir)
487
+ dotnet_runtime_dir = os.path.join(project_root, "runtime", "dotnet")
488
+
489
+ # Prefer runtime/dotnet layout, but keep camera/ fallback for compatibility.
490
+ candidate_paths = [dotnet_runtime_dir, current_dir]
491
+ for candidate in candidate_paths:
492
+ if os.path.isdir(candidate) and candidate not in sys.path:
493
+ sys.path.append(candidate)
494
+
495
+ try:
496
+ # Add references to .NET DLLs
497
+ clr.AddReference("DirectShowLib")
498
+ clr.AddReference("DirectShowLibWrapper")
499
+
500
+ # Import the CameraDevice class and CameraFormat struct
501
+ from DirectShowLibWrapper import CameraDevice, CameraFormat as DotNetCameraFormat
502
+ self._camera_device_class = CameraDevice
503
+
504
+ # Create the device with or without format specification
505
+ if camera_format:
506
+ # Create a .NET CameraFormat struct
507
+ dotnet_format = DotNetCameraFormat()
508
+ dotnet_format.Width = camera_format.width
509
+ dotnet_format.Height = camera_format.height
510
+ dotnet_format.FrameRate = camera_format.fps
511
+ dotnet_format.PixelFormat = camera_format.pixel_format
512
+
513
+ # Create device with format
514
+ self._device = CameraDevice(device_path, dotnet_format, bool(self._request_rgb24_conversion))
515
+
516
+ try:
517
+ self._event_subscription = self._on_frame_ready_event
518
+ self._device.FrameReady += self._event_subscription
519
+ self._debug("FrameReady event subscribed successfully")
520
+ except Exception as e:
521
+ self._debug(f"Event subscription failed. Frame callbacks unavailable: {e}")
522
+ self._event_subscription = None
523
+ else:
524
+ # Create device with default format
525
+ self._device = CameraDevice(device_path)
526
+ self._debug("Warning: Device created without dimensions. Frame reading not supported.")
527
+
528
+ self._apply_dotnet_logging_configuration()
529
+
530
+ except Exception as e:
531
+ self.debug_print(f"CRITICAL: Failed to initialize CameraDevice: {e}", self.DEBUG_TIER_ERROR)
532
+ self._device = None
533
+
534
+ def start(self):
535
+ """
536
+ ==========================================
537
+ Start the camera streaming.
538
+ ==========================================
539
+ """
540
+ if not self._device:
541
+ raise RuntimeError("Camera device not initialized")
542
+
543
+ try:
544
+ # Check available methods on the .NET device for debugging
545
+ self._debug(f"[DeviceBridge] .NET device type: {type(self._device)}")
546
+ if self.debug_tiers_enabled[int(self.DEBUG_TIER_VERBOSE)]:
547
+ available_methods = [m for m in dir(self._device) if not m.startswith('_')]
548
+ self._debug(f"[DeviceBridge] Available methods: {available_methods}")
549
+
550
+ self._device.Start()
551
+ self._debug(f"[DeviceBridge] .NET Start() completed")
552
+
553
+ # Wait briefly for graph readiness but do not always block full 100 ms.
554
+ self._wait_for_frame_pointer_ready(max_wait_seconds=0.10)
555
+
556
+ # Align decode metadata with negotiated output format/dimensions.
557
+ self._sync_cached_format_from_device()
558
+
559
+ if self._event_subscription is not None:
560
+ self._capture_mode = self.CaptureMode.EVENT_DRIVEN
561
+ self._debug("[DeviceBridge] Event-driven mode active")
562
+ else:
563
+ self._capture_mode = self.CaptureMode.NONE
564
+ self._debug("[DeviceBridge] No FrameReady subscription. Frame callbacks unavailable.")
565
+
566
+ except Exception as e:
567
+ self.debug_print(f"Failed to start camera: {e}", self.DEBUG_TIER_ERROR)
568
+ import traceback
569
+ self.debug_print(traceback.format_exc(), self.DEBUG_TIER_ERROR)
570
+ raise RuntimeError(f"Failed to start camera: {e}")
571
+
572
+ def _wait_for_frame_pointer_ready(self, max_wait_seconds: float = 0.10):
573
+ """
574
+ ==========================================
575
+ Poll frame pointer for a short time and return when buffer becomes available.
576
+ ==========================================
577
+ """
578
+ deadline = time.perf_counter() + max(0.0, float(max_wait_seconds))
579
+ while time.perf_counter() < deadline:
580
+ if self.get_frame_pointer() != 0:
581
+ return
582
+ time.sleep(0.005)
583
+
584
+ def _sync_cached_format_from_device(self):
585
+ """
586
+ ==========================================
587
+ Refresh cached decode metadata from the actual negotiated device format.
588
+ ==========================================
589
+ """
590
+ actual_format = self.get_actual_camera_format()
591
+ if actual_format is None:
592
+ return
593
+
594
+ width, height, fps, pixel_format = actual_format
595
+ self._format = SimpleNamespace(
596
+ width=int(width),
597
+ height=int(height),
598
+ fps=float(fps),
599
+ pixel_format=str(pixel_format),
600
+ )
601
+ self._refresh_cached_format_metadata()
602
+
603
+ def stop(self):
604
+ """
605
+ ==========================================
606
+ Stop the camera streaming.
607
+ ==========================================
608
+ """
609
+ self._capture_mode = self.CaptureMode.NONE
610
+
611
+ # Unsubscribe from event first
612
+ if self._event_subscription and self._device:
613
+ try:
614
+ self._device.FrameReady -= self._event_subscription
615
+ self._debug("FrameReady event unsubscribed")
616
+ except Exception as e:
617
+ self._debug(f"Error unsubscribing from event: {e}")
618
+
619
+ # Then stop the device
620
+ if self._device:
621
+ try:
622
+ self._device.Stop()
623
+ except Exception as e:
624
+ self.debug_print(f"Error stopping camera: {e}", self.DEBUG_TIER_ERROR)
625
+
626
+ def get_frame_pointer(self) -> int:
627
+ """
628
+ ==========================================
629
+ Get the pointer to the current frame buffer.
630
+
631
+ Returns:
632
+ int: Memory address of the frame buffer (IntPtr as Python int)
633
+ ==========================================
634
+ """
635
+ if not self._device:
636
+ return 0
637
+
638
+ try:
639
+ # GetFramePointer returns System.IntPtr.
640
+ # pythonnet does not always support int(IntPtr), so convert explicitly.
641
+ ptr = self._device.GetFramePointer()
642
+
643
+ if ptr is None:
644
+ return 0
645
+
646
+ if hasattr(ptr, "ToInt64"):
647
+ return int(ptr.ToInt64())
648
+
649
+ if hasattr(ptr, "ToInt32"):
650
+ return int(ptr.ToInt32())
651
+
652
+ # Fallback for runtimes where direct int conversion works
653
+ return int(ptr)
654
+ except Exception as e:
655
+ self.debug_print(f"Error getting frame pointer: {e}", self.DEBUG_TIER_ERROR)
656
+ return 0
657
+
658
+ def get_current_fps(self) -> float:
659
+ """
660
+ ==========================================
661
+ Get the current FPS reported by the .NET camera device.
662
+
663
+ Returns:
664
+ float: Current measured FPS or 0.0 on failure.
665
+ ==========================================
666
+ """
667
+ if not self._device:
668
+ return 0.0
669
+
670
+ try:
671
+ return float(self._device.GetCurrentFps())
672
+ except Exception as e:
673
+ self.debug_print(f"Error getting current FPS: {e}", self.DEBUG_TIER_ERROR)
674
+ return 0.0
675
+
676
+ def get_actual_camera_format(self):
677
+ """
678
+ ==========================================
679
+ Get the actual negotiated camera format from the .NET camera device.
680
+
681
+ Returns:
682
+ tuple | None: (width, height, fps, pixel_format) or None on failure.
683
+ ==========================================
684
+ """
685
+ if not self._device:
686
+ return None
687
+
688
+ if not hasattr(self._device, "GetActualCameraFormat"):
689
+ return None
690
+
691
+ try:
692
+ actual = self._device.GetActualCameraFormat()
693
+ if actual is None:
694
+ return None
695
+
696
+ width = int(getattr(actual, "Width"))
697
+ height = int(getattr(actual, "Height"))
698
+ fps = float(getattr(actual, "FrameRate"))
699
+ pixel_format = str(getattr(actual, "PixelFormat"))
700
+ return (width, height, fps, pixel_format)
701
+ except Exception as e:
702
+ self.debug_print(f"Error getting actual camera format: {e}", self.DEBUG_TIER_ERROR)
703
+ return None
704
+
705
+ def set_property_auto_mode(self, property_name: str, auto_on: bool) -> Tuple[bool, bool]:
706
+ """
707
+ ==========================================
708
+ Toggle auto/manual mode for a camera property.
709
+
710
+ Args:
711
+ property_name: Camera property name (e.g. Exposure, Brightness, Focus).
712
+ auto_on: True for Auto mode, False for Manual mode.
713
+
714
+ Returns:
715
+ Tuple[bool, bool]: (success, is_auto_enabled)
716
+ ==========================================
717
+ """
718
+ if not self._device:
719
+ return False, False
720
+
721
+ try:
722
+ result = self._device.SetPropertyAutoMode(property_name, bool(auto_on))
723
+
724
+ # pythonnet often returns (returnValue, outParam) as tuple/list.
725
+ if isinstance(result, (tuple, list)) and len(result) >= 2:
726
+ return bool(result[0]), bool(result[1])
727
+
728
+ # Fallback if runtime returns only bool return value.
729
+ success = bool(result)
730
+ if success:
731
+ read_success, is_auto_enabled = self.get_property_auto_mode(property_name)
732
+ if read_success:
733
+ return True, bool(is_auto_enabled)
734
+
735
+ return success, bool(auto_on)
736
+ except Exception as e:
737
+ self.debug_print(f"Error setting auto mode for '{property_name}': {e}", self.DEBUG_TIER_ERROR)
738
+ return False, False
739
+
740
+ def get_property_auto_mode(self, property_name: str) -> Tuple[bool, bool]:
741
+ """
742
+ ==========================================
743
+ Get the current auto/manual mode for a camera property.
744
+
745
+ Args:
746
+ property_name: Camera property name (e.g. Exposure, Brightness, Focus).
747
+
748
+ Returns:
749
+ Tuple[bool, bool]: (success, is_auto_enabled)
750
+ ==========================================
751
+ """
752
+ if not self._device:
753
+ return False, False
754
+
755
+ try:
756
+ result = self._device.GetPropertyAutoMode(property_name)
757
+
758
+ # pythonnet often returns (returnValue, outParam) as tuple/list.
759
+ if isinstance(result, (tuple, list)) and len(result) >= 2:
760
+ return bool(result[0]), bool(result[1])
761
+
762
+ # If only bool is returned, we don't have out value reliably.
763
+ return bool(result), False
764
+ except Exception as e:
765
+ self.debug_print(f"Error getting auto mode for '{property_name}': {e}", self.DEBUG_TIER_ERROR)
766
+ return False, False
767
+
768
+ def set_property_value(self, property_name: str, value: int) -> Tuple[bool, int]:
769
+ """
770
+ ==========================================
771
+ Set a numeric value for a camera property.
772
+
773
+ Args:
774
+ property_name: Camera property name (e.g. Exposure, Brightness, Focus).
775
+ value: Target numeric value.
776
+
777
+ Returns:
778
+ Tuple[bool, int]: (success, actual_value_applied)
779
+ ==========================================
780
+ """
781
+ if not self._device:
782
+ return False, int(value)
783
+
784
+ try:
785
+ result = self._device.SetPropertyValue(property_name, int(value))
786
+
787
+ # pythonnet often returns (returnValue, outParam) as tuple/list.
788
+ if isinstance(result, (tuple, list)) and len(result) >= 2:
789
+ return bool(result[0]), int(result[1])
790
+
791
+ # Fallback if runtime returns only bool return value.
792
+ return bool(result), int(value)
793
+ except Exception as e:
794
+ self.debug_print(f"Error setting value for '{property_name}': {e}", self.DEBUG_TIER_ERROR)
795
+ return False, int(value)
796
+
797
+ def set_property_values(self, properties: list[Tuple[str, int]]) -> Tuple[bool, list[Tuple[str, bool, int]]]:
798
+ """
799
+ ==========================================
800
+ Set multiple numeric camera properties and return per-property results.
801
+
802
+ Returns:
803
+ Tuple[bool, list[Tuple[str, bool, int]]]:
804
+ (all_success, [(property_name, success, actual_value), ...])
805
+ ==========================================
806
+ """
807
+ if not self._device or not properties:
808
+ return False, []
809
+
810
+ try:
811
+ system_module = __import__("System")
812
+ tuple_type = system_module.ValueTuple[system_module.String, system_module.Int32]
813
+ values_array = system_module.Array[tuple_type]([
814
+ tuple_type(str(property_name), int(value))
815
+ for property_name, value in properties
816
+ ])
817
+
818
+ result = self._device.SetPropertyValues(values_array)
819
+
820
+ if isinstance(result, (tuple, list)) and len(result) >= 2:
821
+ out_results = result[1]
822
+ parsed_by_name: dict[str, list[Tuple[str, bool, int]]] = {}
823
+ if out_results is not None:
824
+ for item in out_results:
825
+ try:
826
+ parsed_name = str(item.Item1)
827
+ parsed_tuple = (parsed_name, bool(item.Item2), int(item.Item3))
828
+ parsed_key = parsed_name.lower()
829
+ parsed_by_name.setdefault(parsed_key, []).append(parsed_tuple)
830
+ except Exception as parse_error:
831
+ self._debug(f"[SetPropertyValues] Failed to parse one batch result item: {parse_error}")
832
+
833
+ normalized_results = []
834
+ for requested_name, requested_value in properties:
835
+ request_key = str(requested_name).lower()
836
+ parsed_list = parsed_by_name.get(request_key)
837
+ if parsed_list:
838
+ normalized_results.append(parsed_list.pop(0))
839
+ else:
840
+ self._debug(
841
+ f"[SetPropertyValues] Missing/invalid result for '{requested_name}'. "
842
+ "Marking as failed in normalized output."
843
+ )
844
+ normalized_results.append((str(requested_name), False, int(requested_value)))
845
+
846
+ all_success = bool(result[0]) and all(
847
+ bool(success) for _name, success, _value in normalized_results
848
+ ) and len(normalized_results) > 0
849
+
850
+ return all_success, normalized_results
851
+
852
+ # Fallback if runtime returns only bool return value.
853
+ all_success = bool(result)
854
+ fallback_results = [(str(name), all_success, int(value)) for name, value in properties]
855
+ return all_success, fallback_results
856
+ except Exception as e:
857
+ self._debug(f"[SetPropertyValues] Batch call failed, falling back to per-property set: {e}")
858
+ fallback_results = []
859
+ for property_name, value in properties:
860
+ success, actual_value = self.set_property_value(property_name, value)
861
+ fallback_results.append((str(property_name), bool(success), int(actual_value)))
862
+ all_success = all(bool(success) for _name, success, _value in fallback_results) and len(fallback_results) > 0
863
+ return all_success, fallback_results
864
+
865
+ def get_property_values(self, property_names: list[str]) -> Tuple[bool, list[Tuple[str, bool, int]]]:
866
+ """
867
+ ==========================================
868
+ Get multiple numeric camera property values and return per-property results.
869
+
870
+ Args:
871
+ property_names: list of camera property names.
872
+
873
+ Returns:
874
+ Tuple[bool, list[Tuple[str, bool, int]]]:
875
+ (all_success, [(property_name, success, current_value), ...])
876
+ ==========================================
877
+ """
878
+ if not self._device or not property_names:
879
+ return False, []
880
+
881
+ if not hasattr(self._device, "GetPropertyValues"):
882
+ return False, []
883
+
884
+ try:
885
+ system_module = __import__("System")
886
+ names_array = system_module.Array[system_module.String]([str(name) for name in property_names])
887
+ result = self._device.GetPropertyValues(names_array)
888
+
889
+ parsed_by_name: dict[str, list[Tuple[str, bool, int]]] = {}
890
+ all_success = False
891
+
892
+ if isinstance(result, (tuple, list)) and len(result) >= 2:
893
+ all_success = bool(result[0])
894
+ out_values = result[1]
895
+
896
+ if out_values is not None:
897
+ for item in out_values:
898
+ try:
899
+ parsed_name = str(item.Item1)
900
+ parsed_tuple = (parsed_name, bool(item.Item2), int(item.Item3))
901
+ parsed_key = parsed_name.lower()
902
+ parsed_by_name.setdefault(parsed_key, []).append(parsed_tuple)
903
+ except Exception as parse_error:
904
+ self._debug(f"[GetPropertyValues] Failed to parse one batch result item: {parse_error}")
905
+
906
+ normalized_results = []
907
+ for requested_name in property_names:
908
+ request_key = str(requested_name).lower()
909
+ parsed_list = parsed_by_name.get(request_key)
910
+ if parsed_list:
911
+ normalized_results.append(parsed_list.pop(0))
912
+ else:
913
+ normalized_results.append((str(requested_name), False, 0))
914
+
915
+ if len(normalized_results) > 0:
916
+ all_success = bool(all_success) and all(bool(success) for _name, success, _value in normalized_results)
917
+
918
+ return all_success, normalized_results
919
+ except Exception as e:
920
+ self.debug_print(f"Error getting property values: {e}", self.DEBUG_TIER_ERROR)
921
+ return False, []
922
+
923
+ def reset_all_properties_to_default_values(self) -> Tuple[bool, list[Tuple[str, bool, int]]]:
924
+ """
925
+ ==========================================
926
+ Reset all supported capabilities to numeric defaults and return per-property results.
927
+
928
+ Returns:
929
+ Tuple[bool, list[Tuple[str, bool, int]]]:
930
+ (all_success, [(property_name, success, actual_value), ...])
931
+ ==========================================
932
+ """
933
+ if not self._device:
934
+ return False, []
935
+
936
+ try:
937
+ capabilities = self._device.GetCachedControlCapabilities()
938
+ except Exception as e:
939
+ self.debug_print(f"Error getting cached capabilities: {e}", self.DEBUG_TIER_ERROR)
940
+ return False, []
941
+
942
+ if capabilities is None:
943
+ return False, []
944
+
945
+ seen_property_names = set()
946
+ default_properties = []
947
+
948
+ for capability in capabilities:
949
+ try:
950
+ if not bool(capability.PropertySupported):
951
+ continue
952
+
953
+ property_name = str(capability.PropertyName)
954
+ if not property_name:
955
+ continue
956
+
957
+ property_key = property_name.lower()
958
+ if property_key in seen_property_names:
959
+ continue
960
+ seen_property_names.add(property_key)
961
+
962
+ default_value = int(capability.Default)
963
+ default_properties.append((property_name, default_value))
964
+ except Exception as e:
965
+ self._debug(f"[ResetDefaults] Error while processing capability: {e}")
966
+
967
+ if not default_properties:
968
+ return False, []
969
+
970
+ return self.set_property_values(default_properties)
971
+
972
+ def reset_all_property_flags(self) -> Tuple[bool, list[Tuple[str, bool, bool]]]:
973
+ """
974
+ ==========================================
975
+ Reset all auto/manual property flags to Auto mode and return per-property results.
976
+
977
+ Returns:
978
+ Tuple[bool, list[Tuple[str, bool, bool]]]:
979
+ (all_success, [(property_name, success, is_auto_enabled), ...])
980
+ ==========================================
981
+ """
982
+ if not self._device:
983
+ return False, []
984
+
985
+ try:
986
+ capabilities = self._device.GetCachedControlCapabilities()
987
+ except Exception as e:
988
+ self.debug_print(f"Error getting cached capabilities: {e}", self.DEBUG_TIER_ERROR)
989
+ return False, []
990
+
991
+ if capabilities is None:
992
+ return False, []
993
+
994
+ seen_property_names = set()
995
+ flag_results = []
996
+
997
+ for capability in capabilities:
998
+ try:
999
+ if not bool(capability.PropertySupported):
1000
+ continue
1001
+ if not bool(capability.AutoSupported):
1002
+ continue
1003
+
1004
+ property_name = str(capability.PropertyName)
1005
+ if not property_name:
1006
+ continue
1007
+
1008
+ property_key = property_name.lower()
1009
+ if property_key in seen_property_names:
1010
+ continue
1011
+ seen_property_names.add(property_key)
1012
+
1013
+ success, is_auto_enabled = self.set_property_auto_mode(property_name, True)
1014
+ flag_results.append((property_name, bool(success), bool(is_auto_enabled)))
1015
+ if not success:
1016
+ self._debug(f"[ResetFlags] Failed to set '{property_name}' to Auto")
1017
+ except Exception as e:
1018
+ self._debug(f"[ResetFlags] Error while processing capability: {e}")
1019
+
1020
+ all_success = all(bool(success) for _name, success, _is_auto in flag_results) and len(flag_results) > 0
1021
+ return all_success, flag_results
1022
+
1023
+ def _on_frame_ready_event(self, frame_count, buffer_len=None):
1024
+ """
1025
+ ==========================================
1026
+ Event handler fired by DirectShow when FrameReady event occurs.
1027
+
1028
+ Args:
1029
+ frame_count: Current frame count from the .NET event
1030
+ buffer_len: Optional frame buffer length from the .NET event
1031
+ ==========================================
1032
+ """
1033
+ if self._capture_mode != self.CaptureMode.EVENT_DRIVEN:
1034
+ return
1035
+
1036
+ try:
1037
+ if self.debug_tiers_enabled[int(self.DEBUG_TIER_VERBOSE)]:
1038
+ event_time = time.strftime("%Y-%m-%d %H:%M:%S") + f".{int((time.time() % 1) * 1000):03d}"
1039
+ if buffer_len is None:
1040
+ self.debug_print(f"[{event_time}] [Event] Frame {frame_count} arrived", self.DEBUG_TIER_VERBOSE)
1041
+ else:
1042
+ self.debug_print(
1043
+ f"[{event_time}] [Event] Frame {frame_count} arrived (buffer_len={int(buffer_len)})",
1044
+ self.DEBUG_TIER_VERBOSE
1045
+ )
1046
+ self._process_frame(frame_count, buffer_len)
1047
+ except Exception as e:
1048
+ self.debug_print(f"Error in frame ready event handler: {e}", self.DEBUG_TIER_ERROR)
1049
+
1050
+ def _process_frame(self, frame_count, buffer_len=None):
1051
+ """
1052
+ ==========================================
1053
+ Process a new frame by reading it from memory.
1054
+
1055
+ Args:
1056
+ frame_count: The current frame count
1057
+ buffer_len: Optional frame buffer size from event callback
1058
+ ==========================================
1059
+ """
1060
+ try:
1061
+ pixel_format = self._pixel_format_name
1062
+
1063
+ # MJPG path: decode compressed JPEG payload using the exact buffer length
1064
+ # reported by the frame callback.
1065
+ if pixel_format in ("MJPG", "MJPEG"):
1066
+ if cv2 is None:
1067
+ if not self._warned_mjpg_decoder_unavailable:
1068
+ self.debug_print(
1069
+ "MJPG decode requires OpenCV (cv2), but cv2 is not available. Frame skipped.",
1070
+ self.DEBUG_TIER_ERROR
1071
+ )
1072
+ self._warned_mjpg_decoder_unavailable = True
1073
+ return
1074
+
1075
+ if buffer_len is None or int(buffer_len) <= 0:
1076
+ self.debug_print(
1077
+ f"MJPG frame #{frame_count} missing valid buffer_len; frame skipped.",
1078
+ self.DEBUG_TIER_ERROR
1079
+ )
1080
+ return
1081
+
1082
+ ptr = self.get_frame_pointer()
1083
+ self._debug(f"[Process] MJPG frame #{frame_count}, pointer: 0x{ptr:X}, buffer_len={int(buffer_len)}")
1084
+ if ptr == 0:
1085
+ self._debug("[Process] ERROR: Null pointer!")
1086
+ return
1087
+
1088
+ encoded_length = int(buffer_len)
1089
+ encoded_buffer = (ctypes.c_ubyte * encoded_length).from_address(ptr)
1090
+ encoded_array = np.ctypeslib.as_array(encoded_buffer)
1091
+ decoded_frame = cv2.imdecode(encoded_array, cv2.IMREAD_COLOR)
1092
+
1093
+ if decoded_frame is None:
1094
+ self.debug_print(
1095
+ f"Failed to decode MJPG frame #{frame_count} (buffer_len={encoded_length}).",
1096
+ self.DEBUG_TIER_ERROR
1097
+ )
1098
+ return
1099
+
1100
+ stable_frame = decoded_frame.copy()
1101
+ with self._frame_state_lock:
1102
+ self._current_frame = stable_frame
1103
+ callback = self._frame_callback
1104
+
1105
+ if callback:
1106
+ callback(frame_count, stable_frame)
1107
+ return
1108
+
1109
+ # YUY2/YUYV path: decode packed YUV422 to BGR.
1110
+ if pixel_format in ("YUY2", "YUYV", "YUNV"):
1111
+ if cv2 is None:
1112
+ self.debug_print(
1113
+ "YUY2 decode requires OpenCV (cv2), but cv2 is not available. Frame skipped.",
1114
+ self.DEBUG_TIER_ERROR
1115
+ )
1116
+ return
1117
+
1118
+ ptr = self.get_frame_pointer()
1119
+ self._debug(f"[Process] {pixel_format} frame #{frame_count}, pointer: 0x{ptr:X}")
1120
+ if ptr == 0:
1121
+ self._debug("[Process] ERROR: Null pointer!")
1122
+ return
1123
+
1124
+ expected_size = int(self._yuy2_buffer_size)
1125
+ source_size = expected_size
1126
+ if buffer_len is not None:
1127
+ source_size = min(int(buffer_len), expected_size)
1128
+
1129
+ if source_size < expected_size:
1130
+ self.debug_print(
1131
+ f"{pixel_format} frame #{frame_count} buffer too small "
1132
+ f"({source_size} < {expected_size}); frame skipped.",
1133
+ self.DEBUG_TIER_ERROR
1134
+ )
1135
+ return
1136
+
1137
+ raw_buffer = (ctypes.c_ubyte * expected_size).from_address(ptr)
1138
+ yuy2_array = np.frombuffer(raw_buffer, dtype=np.uint8)
1139
+
1140
+ try:
1141
+ yuy2_array = yuy2_array.reshape(self._shape_yuy2)
1142
+ bgr_frame = cv2.cvtColor(yuy2_array, cv2.COLOR_YUV2BGR_YUY2)
1143
+ except Exception as e:
1144
+ self.debug_print(
1145
+ f"Failed to decode {pixel_format} frame #{frame_count}: {e}",
1146
+ self.DEBUG_TIER_ERROR
1147
+ )
1148
+ return
1149
+
1150
+ # YUY2 decode path is already top-down on this device; keep orientation as-is.
1151
+ stable_frame = bgr_frame.copy()
1152
+ with self._frame_state_lock:
1153
+ self._current_frame = stable_frame
1154
+ callback = self._frame_callback
1155
+
1156
+ if callback:
1157
+ callback(frame_count, stable_frame)
1158
+ return
1159
+
1160
+ bytes_per_pixel = self._bytes_per_pixel
1161
+ if bytes_per_pixel == 0:
1162
+ if not self._warned_unsupported_pixel_format:
1163
+ pixel_format = pixel_format or "UNKNOWN"
1164
+ self.debug_print(
1165
+ "Unsupported pixel format for raw-pointer frame decode: "
1166
+ f"'{pixel_format}'. Expected uncompressed RGB/BGR/GRAY format. "
1167
+ "Frame skipped to avoid unsafe memory access.",
1168
+ self.DEBUG_TIER_ERROR
1169
+ )
1170
+ self._warned_unsupported_pixel_format = True
1171
+ return
1172
+
1173
+ # Get the pointer to the frame buffer
1174
+ ptr = self.get_frame_pointer()
1175
+ self._debug(f"[Process] Frame #{frame_count}, pointer: 0x{ptr:X}")
1176
+ if ptr == 0:
1177
+ self._debug(f"[Process] ERROR: Null pointer!")
1178
+ return
1179
+
1180
+ # Calculate expected buffer size for current uncompressed pixel format.
1181
+ buffer_size = int(self._raw_buffer_size)
1182
+
1183
+ if buffer_len is not None and int(buffer_len) < buffer_size:
1184
+ self.debug_print(
1185
+ f"{pixel_format} frame #{frame_count} buffer too small "
1186
+ f"({int(buffer_len)} < {buffer_size}); frame skipped.",
1187
+ self.DEBUG_TIER_ERROR
1188
+ )
1189
+ return
1190
+
1191
+ # Read raw bytes from memory using ctypes
1192
+ frame_buffer = (ctypes.c_ubyte * buffer_size).from_address(ptr)
1193
+
1194
+ # Convert to numpy array and reshape
1195
+ frame_array = np.frombuffer(frame_buffer, dtype=np.uint8)
1196
+ if bytes_per_pixel == 1:
1197
+ frame_array = frame_array.reshape(self._shape_gray)
1198
+ frame_array = np.repeat(frame_array, 3, axis=2)
1199
+ elif bytes_per_pixel == 3:
1200
+ frame_array = frame_array.reshape(self._shape_rgb)
1201
+ else:
1202
+ frame_array = frame_array.reshape(self._shape_raw)
1203
+ frame_array = frame_array[:, :, :3]
1204
+
1205
+ # DirectShow typically gives bottom-up images, flip vertically.
1206
+ # We copy after flip because frame_array points to unmanaged camera memory
1207
+ # that can be overwritten by the next frame. Keeping a view here risks
1208
+ # tearing/corrupted reads in callbacks or get_latest_frame().
1209
+ # Tradeoff: extra memory bandwidth per frame for data safety/stability.
1210
+ stable_frame = np.flipud(frame_array).copy()
1211
+ with self._frame_state_lock:
1212
+ self._current_frame = stable_frame
1213
+ callback = self._frame_callback
1214
+ self._debug(f"[Process] Frame ready: {self._current_frame.shape}, dtype={self._current_frame.dtype}")
1215
+
1216
+ # Call user callback if set
1217
+ if callback:
1218
+ self._debug(f"[Process] Calling callback...")
1219
+ callback(frame_count, stable_frame)
1220
+ else:
1221
+ self._debug(f"[Process] WARNING: No callback registered!")
1222
+
1223
+ except Exception as e:
1224
+ self.debug_print(f"Error processing frame: {e}", self.DEBUG_TIER_ERROR)
1225
+
1226
+ def get_latest_frame(self):
1227
+ """
1228
+ ==========================================
1229
+ Get the most recent frame captured by the camera.
1230
+
1231
+ Returns:
1232
+ numpy.ndarray: The latest frame as a numpy array (height, width, 3) or None
1233
+ ==========================================
1234
+ """
1235
+ with self._frame_state_lock:
1236
+ return self._current_frame
1237
+
1238
+ def set_frame_callback(self, callback: Callable[[int, np.ndarray], None]):
1239
+ """
1240
+ ==========================================
1241
+ Set a callback to be called when a new frame is ready.
1242
+
1243
+ Args:
1244
+ callback: Function to call with (frame_count: int, frame: np.ndarray)
1245
+ ==========================================
1246
+ """
1247
+ with self._frame_state_lock:
1248
+ self._frame_callback = callback
1249
+ self._debug(f"[DeviceBridge] Frame callback registered: {callback.__name__ if hasattr(callback, '__name__') else 'anonymous'}")
1250
+
1251
+ def dispose(self):
1252
+ """
1253
+ ==========================================
1254
+ Dispose of the camera device and release resources.
1255
+ ==========================================
1256
+ """
1257
+ if self._device:
1258
+ try:
1259
+ self.stop()
1260
+ except Exception as e:
1261
+ self._debug(f"Error stopping camera during dispose: {e}")
1262
+
1263
+ try:
1264
+ self._device.Dispose()
1265
+ except Exception as e:
1266
+ self.debug_print(f"Error disposing camera device: {e}", self.DEBUG_TIER_ERROR)
1267
+ finally:
1268
+ self._device = None
1269
+ self._event_subscription = None
1270
+ self._capture_mode = self.CaptureMode.NONE
1271
+ with self._frame_state_lock:
1272
+ self._current_frame = None
1273
+ self._frame_callback = None
1274
+
1275
+ def __del__(self):
1276
+ """
1277
+ ==========================================
1278
+ Destructor to ensure cleanup.
1279
+ ==========================================
1280
+ """
1281
+ self.dispose()
1282
+
1283
+ def __enter__(self):
1284
+ """
1285
+ ==========================================
1286
+ Context manager entry.
1287
+ ==========================================
1288
+ """
1289
+ return self
1290
+
1291
+ def __exit__(self, exc_type, exc_val, exc_tb):
1292
+ """
1293
+ ==========================================
1294
+ Context manager exit.
1295
+ ==========================================
1296
+ """
1297
+ self.dispose()
1298
+
1299
+ def _debug(self, message: str):
1300
+ """
1301
+ ==========================================
1302
+ Print debug messages only when debug logging is enabled.
1303
+ =====================
1304
+ """
1305
+ self.debug_print(message, self.DEBUG_TIER_VERBOSE)