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