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.
- GUI/__init__.py +3 -0
- GUI/main_GUI.py +1006 -0
- app/__init__.py +0 -0
- app/main.py +106 -0
- camera/__init__.py +5 -0
- camera/camera_device_bridge.py +1305 -0
- camera/camera_inspector_bridge.py +46 -0
- camera/camera_manager.py +1004 -0
- python_camera_manager_directshow-0.1.0.dist-info/METADATA +239 -0
- python_camera_manager_directshow-0.1.0.dist-info/RECORD +18 -0
- python_camera_manager_directshow-0.1.0.dist-info/WHEEL +5 -0
- python_camera_manager_directshow-0.1.0.dist-info/entry_points.txt +2 -0
- python_camera_manager_directshow-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_camera_manager_directshow-0.1.0.dist-info/top_level.txt +4 -0
- runtime/__init__.py +0 -0
- runtime/dotnet/DirectShowLib.dll +0 -0
- runtime/dotnet/DirectShowLibWrapper.dll +0 -0
- runtime/dotnet/__init__.py +0 -0
|
@@ -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)
|