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
GUI/main_GUI.py
ADDED
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import tkinter as tk
|
|
3
|
+
from tkinter import ttk
|
|
4
|
+
from tkinter import messagebox
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import cv2
|
|
8
|
+
from PIL import Image, ImageTk
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _create_and_setup_dialog(parent, title, width, height):
|
|
13
|
+
"""
|
|
14
|
+
==========================================
|
|
15
|
+
Create and setup a dialog window with proper centering and transient relationship.
|
|
16
|
+
Returns the configured dialog window.
|
|
17
|
+
==========================================
|
|
18
|
+
"""
|
|
19
|
+
if parent is not None:
|
|
20
|
+
dialog = tk.Toplevel(parent)
|
|
21
|
+
else:
|
|
22
|
+
dialog = tk.Tk()
|
|
23
|
+
|
|
24
|
+
dialog.title(title)
|
|
25
|
+
# Compute center coordinates here (inlined from previous helper).
|
|
26
|
+
try:
|
|
27
|
+
if parent is not None:
|
|
28
|
+
# Ensure parent is fully mapped and realized before querying geometry
|
|
29
|
+
parent.deiconify()
|
|
30
|
+
parent.update()
|
|
31
|
+
parent.update_idletasks()
|
|
32
|
+
px = parent.winfo_rootx()
|
|
33
|
+
py = parent.winfo_rooty()
|
|
34
|
+
pw = parent.winfo_width()
|
|
35
|
+
ph = parent.winfo_height()
|
|
36
|
+
# If parent reports tiny size, fall back to screen center
|
|
37
|
+
if pw <= 1 or ph <= 1:
|
|
38
|
+
sw = dialog.winfo_screenwidth()
|
|
39
|
+
sh = dialog.winfo_screenheight()
|
|
40
|
+
x = (sw - width) // 2
|
|
41
|
+
y = (sh - height) // 2
|
|
42
|
+
else:
|
|
43
|
+
x = px + (pw - width) // 2
|
|
44
|
+
y = py + (ph - height) // 2
|
|
45
|
+
else:
|
|
46
|
+
sw = dialog.winfo_screenwidth()
|
|
47
|
+
sh = dialog.winfo_screenheight()
|
|
48
|
+
x = (sw - width) // 2
|
|
49
|
+
y = (sh - height) // 2
|
|
50
|
+
except Exception:
|
|
51
|
+
x, y = 100, 100
|
|
52
|
+
|
|
53
|
+
dialog.geometry(f"{width}x{height}+{x}+{y}")
|
|
54
|
+
|
|
55
|
+
# REMOVE TITLEBAR (no X, no frame, no native drag)
|
|
56
|
+
dialog.overrideredirect(True)
|
|
57
|
+
|
|
58
|
+
# Apply transient AFTER geometry is set (important for proper positioning)
|
|
59
|
+
if parent is not None:
|
|
60
|
+
dialog.transient(parent)
|
|
61
|
+
|
|
62
|
+
return dialog
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _make_dialog_modal_and_wait(dialog, parent):
|
|
66
|
+
"""
|
|
67
|
+
==========================================
|
|
68
|
+
Make a dialog modal and wait for user interaction.
|
|
69
|
+
Handles both parent-relative and standalone dialogs.
|
|
70
|
+
==========================================
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
dialog.lift()
|
|
74
|
+
dialog.attributes("-topmost", True)
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
if parent is not None:
|
|
79
|
+
dialog.grab_set_global()
|
|
80
|
+
dialog.focus_set()
|
|
81
|
+
parent.wait_window(dialog)
|
|
82
|
+
else:
|
|
83
|
+
dialog.mainloop()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def select_camera_gui(UVC_devices, parent=None):
|
|
87
|
+
"""
|
|
88
|
+
==========================================
|
|
89
|
+
Opens a small modal dialog (Toplevel) with a dropdown list of camera names.
|
|
90
|
+
If `parent` is provided (a Tk or Toplevel), the dialog will be centered
|
|
91
|
+
over it and be modal/always-on-top so it doesn't appear behind the main GUI.
|
|
92
|
+
|
|
93
|
+
Returns (device_path, camera_format, request_rgb24) where camera_format is a CameraFormat NamedTuple.
|
|
94
|
+
==========================================
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
# We now track both the camera and the specific format chosen
|
|
98
|
+
selected_data = {
|
|
99
|
+
"camera": None,
|
|
100
|
+
"format": None,
|
|
101
|
+
"device_path": None,
|
|
102
|
+
"camera_format": None,
|
|
103
|
+
"request_rgb24": False,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
def on_select():
|
|
107
|
+
"""
|
|
108
|
+
==========================================
|
|
109
|
+
Store current combobox selections and close the dialog.
|
|
110
|
+
==========================================
|
|
111
|
+
"""
|
|
112
|
+
camera_idx = combo_UVC_name.current()
|
|
113
|
+
format_idx = combo_UVC_format.current()
|
|
114
|
+
|
|
115
|
+
if camera_idx != -1 and format_idx != -1:
|
|
116
|
+
selected_camera = UVC_devices[camera_idx]
|
|
117
|
+
selected_data["device_path"] = selected_camera.path
|
|
118
|
+
selected_format = selected_camera.formats[format_idx]
|
|
119
|
+
selected_data["camera_format"] = selected_format
|
|
120
|
+
selected_data["request_rgb24"] = bool(request_rgb24_var.get())
|
|
121
|
+
|
|
122
|
+
dialog.destroy()
|
|
123
|
+
|
|
124
|
+
def on_cancel_exit():
|
|
125
|
+
"""
|
|
126
|
+
==========================================
|
|
127
|
+
Close parent/dialog and terminate application flow.
|
|
128
|
+
==========================================
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
if parent is not None:
|
|
132
|
+
parent.destroy()
|
|
133
|
+
finally:
|
|
134
|
+
dialog.destroy()
|
|
135
|
+
sys.exit(0)
|
|
136
|
+
|
|
137
|
+
def update_formats(event):
|
|
138
|
+
"""
|
|
139
|
+
==========================================
|
|
140
|
+
Refresh format combobox when a different camera is selected.
|
|
141
|
+
==========================================
|
|
142
|
+
"""
|
|
143
|
+
selected_idx = combo_UVC_name.current()
|
|
144
|
+
if selected_idx != -1:
|
|
145
|
+
# New dot-notation access for CameraDeviceInfo
|
|
146
|
+
selected_camera_data = UVC_devices[selected_idx]
|
|
147
|
+
raw_formats = selected_camera_data.formats or []
|
|
148
|
+
|
|
149
|
+
# New dot-notation access for CameraFormat
|
|
150
|
+
display_formats = [
|
|
151
|
+
f"{f.width} x {f.height} @ {f.fps:.2f} FPS ({f.pixel_format})"
|
|
152
|
+
for f in raw_formats
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
combo_UVC_format['values'] = display_formats
|
|
156
|
+
|
|
157
|
+
if display_formats:
|
|
158
|
+
combo_UVC_format.current(0)
|
|
159
|
+
else:
|
|
160
|
+
combo_UVC_format.set("No formats available")
|
|
161
|
+
|
|
162
|
+
# The height is increased slightly (180) to accommodate the second box comfortably
|
|
163
|
+
dialog = _create_and_setup_dialog(parent, "Select Camera", 400, 180)
|
|
164
|
+
|
|
165
|
+
label = ttk.Label(dialog, text="Choose a camera:")
|
|
166
|
+
label.pack(pady=(10, 0))
|
|
167
|
+
|
|
168
|
+
# New dot-notation: device.name
|
|
169
|
+
names_only = [device.name for device in UVC_devices]
|
|
170
|
+
combo_UVC_name = ttk.Combobox(dialog, values=names_only, state="readonly", width=45)
|
|
171
|
+
combo_UVC_name.pack(pady=5)
|
|
172
|
+
|
|
173
|
+
label_fmt = ttk.Label(dialog, text="Choose a resolution/format:")
|
|
174
|
+
label_fmt.pack()
|
|
175
|
+
|
|
176
|
+
combo_UVC_format = ttk.Combobox(dialog, state="readonly", width=45)
|
|
177
|
+
combo_UVC_format.pack(pady=5)
|
|
178
|
+
|
|
179
|
+
request_rgb24_var = tk.BooleanVar(value=False)
|
|
180
|
+
request_rgb24_checkbox = ttk.Checkbutton(
|
|
181
|
+
dialog,
|
|
182
|
+
text="Request RGB24",
|
|
183
|
+
variable=request_rgb24_var
|
|
184
|
+
)
|
|
185
|
+
request_rgb24_checkbox.pack(pady=(0, 6))
|
|
186
|
+
|
|
187
|
+
combo_UVC_name.bind("<<ComboboxSelected>>", update_formats)
|
|
188
|
+
|
|
189
|
+
if UVC_devices:
|
|
190
|
+
combo_UVC_name.current(0)
|
|
191
|
+
update_formats(None)
|
|
192
|
+
|
|
193
|
+
btn_frame = tk.Frame(dialog)
|
|
194
|
+
btn_frame.pack(pady=10)
|
|
195
|
+
|
|
196
|
+
button = ttk.Button(btn_frame, text="Select", command=on_select)
|
|
197
|
+
button.pack(side="left", padx=8)
|
|
198
|
+
|
|
199
|
+
exit_btn = ttk.Button(btn_frame, text="Cancel and Exit", command=on_cancel_exit)
|
|
200
|
+
exit_btn.pack(side="left", padx=8)
|
|
201
|
+
|
|
202
|
+
_make_dialog_modal_and_wait(dialog, parent)
|
|
203
|
+
|
|
204
|
+
return selected_data["device_path"], selected_data["camera_format"], selected_data["request_rgb24"]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def show_no_camera_dialog(parent=None):
|
|
208
|
+
"""
|
|
209
|
+
==========================================
|
|
210
|
+
Show a modal retry/cancel dialog informing the user no cameras were found.
|
|
211
|
+
Returns True if the user chose Retry, False if Cancel.
|
|
212
|
+
==========================================
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
result = {"retry": False}
|
|
216
|
+
|
|
217
|
+
def on_retry():
|
|
218
|
+
"""
|
|
219
|
+
==========================================
|
|
220
|
+
Mark retry choice and close dialog.
|
|
221
|
+
==========================================
|
|
222
|
+
"""
|
|
223
|
+
result["retry"] = True
|
|
224
|
+
dialog.destroy()
|
|
225
|
+
|
|
226
|
+
def on_cancel():
|
|
227
|
+
"""
|
|
228
|
+
==========================================
|
|
229
|
+
Mark cancel choice and close dialog.
|
|
230
|
+
==========================================
|
|
231
|
+
"""
|
|
232
|
+
result["retry"] = False
|
|
233
|
+
dialog.destroy()
|
|
234
|
+
|
|
235
|
+
dialog = _create_and_setup_dialog(parent, "No Camera Found", 360, 120)
|
|
236
|
+
|
|
237
|
+
label = ttk.Label(dialog, text="No cameras detected. Please plug in a camera and Retry, or Cancel to exit.", wraplength=320, justify="center")
|
|
238
|
+
label.pack(pady=(20, 10), padx=10)
|
|
239
|
+
|
|
240
|
+
btn_frame = tk.Frame(dialog)
|
|
241
|
+
btn_frame.pack(pady=8)
|
|
242
|
+
|
|
243
|
+
retry_btn = ttk.Button(btn_frame, text="Retry", command=on_retry)
|
|
244
|
+
retry_btn.pack(side="left", padx=8)
|
|
245
|
+
|
|
246
|
+
cancel_btn = ttk.Button(btn_frame, text="Cancel", command=on_cancel)
|
|
247
|
+
cancel_btn.pack(side="left", padx=8)
|
|
248
|
+
|
|
249
|
+
_make_dialog_modal_and_wait(dialog, parent)
|
|
250
|
+
|
|
251
|
+
return result["retry"]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class MainGUI:
|
|
255
|
+
def __init__(self):
|
|
256
|
+
"""
|
|
257
|
+
==========================================
|
|
258
|
+
Initialize the main window, layout, and control widgets.
|
|
259
|
+
==========================================
|
|
260
|
+
"""
|
|
261
|
+
self.root = tk.Tk()
|
|
262
|
+
self.root.title("Camera Control Panel")
|
|
263
|
+
self.root.geometry("1000x700")
|
|
264
|
+
self.camera = None
|
|
265
|
+
self.device_path = None
|
|
266
|
+
|
|
267
|
+
# layout frames
|
|
268
|
+
self.video_frame = tk.Frame(self.root)
|
|
269
|
+
self.video_frame.pack(side="left", fill="both", expand=True)
|
|
270
|
+
|
|
271
|
+
self.controls_frame = tk.Frame(self.root)
|
|
272
|
+
self.controls_frame.pack(side="right", fill="y")
|
|
273
|
+
|
|
274
|
+
self.controls_canvas = tk.Canvas(self.controls_frame, highlightthickness=0)
|
|
275
|
+
self.controls_scrollbar = ttk.Scrollbar(self.controls_frame, orient="vertical", command=self.controls_canvas.yview)
|
|
276
|
+
self.controls_canvas.configure(yscrollcommand=self.controls_scrollbar.set)
|
|
277
|
+
|
|
278
|
+
self.controls_scrollbar.pack(side="right", fill="y")
|
|
279
|
+
self.controls_canvas.pack(side="left", fill="both", expand=True)
|
|
280
|
+
|
|
281
|
+
self.controls_content_frame = tk.Frame(self.controls_canvas)
|
|
282
|
+
self.controls_canvas_window = self.controls_canvas.create_window((0, 0), window=self.controls_content_frame, anchor="nw")
|
|
283
|
+
|
|
284
|
+
self.controls_content_frame.bind(
|
|
285
|
+
"<Configure>",
|
|
286
|
+
lambda event: self.controls_canvas.configure(scrollregion=self.controls_canvas.bbox("all"))
|
|
287
|
+
)
|
|
288
|
+
self.controls_canvas.bind(
|
|
289
|
+
"<Configure>",
|
|
290
|
+
lambda event: self.controls_canvas.itemconfigure(self.controls_canvas_window, width=event.width)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# A LabelFrame is the Tkinter equivalent of a .NET GroupBox
|
|
294
|
+
self.settings_group = ttk.LabelFrame(self.controls_content_frame, text="Camera Controls")
|
|
295
|
+
self.settings_group.pack(padx=10, pady=10, fill="x", anchor="n")
|
|
296
|
+
|
|
297
|
+
self.current_format_var = tk.StringVar(value="Current format: N/A")
|
|
298
|
+
self.current_format_label = tk.Label(
|
|
299
|
+
self.settings_group,
|
|
300
|
+
textvariable=self.current_format_var,
|
|
301
|
+
wraplength=260,
|
|
302
|
+
justify="left",
|
|
303
|
+
fg="black"
|
|
304
|
+
)
|
|
305
|
+
self.current_format_label.pack(padx=10, pady=(10, 4), fill="x")
|
|
306
|
+
|
|
307
|
+
self.current_fps_var = tk.StringVar(value="FPS Ingest: -- FPS Render: -- FPS (.NET): --")
|
|
308
|
+
self.current_fps_label = tk.Label(
|
|
309
|
+
self.settings_group,
|
|
310
|
+
textvariable=self.current_fps_var,
|
|
311
|
+
justify="left",
|
|
312
|
+
fg="black"
|
|
313
|
+
)
|
|
314
|
+
self.current_fps_label.pack(padx=10, pady=(0, 6), fill="x")
|
|
315
|
+
|
|
316
|
+
self._ingest_fps_window_start = time.perf_counter()
|
|
317
|
+
self._ingest_frame_count = 0
|
|
318
|
+
self._ingest_fps_value = None
|
|
319
|
+
self._render_fps_window_start = time.perf_counter()
|
|
320
|
+
self._render_frame_count = 0
|
|
321
|
+
self._render_fps_value = None
|
|
322
|
+
self._dotnet_fps_value = None
|
|
323
|
+
self._last_ingest_time = None
|
|
324
|
+
self._last_render_time_for_fps = None
|
|
325
|
+
self._fps_has_measurement = False
|
|
326
|
+
self._fps_stale_timeout_sec = 1.5
|
|
327
|
+
self.root.after(500, self._refresh_fps_display_state)
|
|
328
|
+
|
|
329
|
+
self.format_button = ttk.Button(
|
|
330
|
+
self.settings_group,
|
|
331
|
+
text="Camera Format Options",
|
|
332
|
+
command=self.show_camera_format_options,
|
|
333
|
+
state="disabled"
|
|
334
|
+
)
|
|
335
|
+
self.format_button.pack(padx=10, pady=(4, 10), fill="x")
|
|
336
|
+
|
|
337
|
+
self.reset_settings_button = ttk.Button(
|
|
338
|
+
self.settings_group,
|
|
339
|
+
text="Reset Settings",
|
|
340
|
+
command=self.show_reset_settings_options,
|
|
341
|
+
state="disabled"
|
|
342
|
+
)
|
|
343
|
+
self.reset_settings_button.pack(padx=10, pady=(0, 10), fill="x")
|
|
344
|
+
|
|
345
|
+
self.auto_mode_title = ttk.Label(self.settings_group, text="Auto/Manual Controls")
|
|
346
|
+
self.auto_mode_title.pack(padx=10, pady=(0, 4), anchor="w")
|
|
347
|
+
|
|
348
|
+
self.auto_mode_controls_frame = tk.Frame(self.settings_group)
|
|
349
|
+
self.auto_mode_controls_frame.pack(padx=10, pady=(0, 10), fill="x")
|
|
350
|
+
|
|
351
|
+
self.auto_mode_vars = {}
|
|
352
|
+
self.auto_mode_checkbuttons = {}
|
|
353
|
+
|
|
354
|
+
self.property_value_controls_frame = tk.Frame(self.settings_group)
|
|
355
|
+
self.property_value_controls_frame.pack(padx=10, pady=(0, 10), fill="x")
|
|
356
|
+
self.property_slider_vars = {}
|
|
357
|
+
self.property_sliders = {}
|
|
358
|
+
self.property_value_labels = {}
|
|
359
|
+
self._updating_property_sliders = set()
|
|
360
|
+
|
|
361
|
+
# You can now add widgets to self.settings_group instead of self.controls_frame
|
|
362
|
+
|
|
363
|
+
# placeholder for video canvas
|
|
364
|
+
self.canvas = tk.Canvas(self.video_frame, width=640, height=480, bg="black")
|
|
365
|
+
self.canvas.pack(fill="both", expand=True)
|
|
366
|
+
self.canvas.bind("<Configure>", self._on_video_canvas_configure)
|
|
367
|
+
|
|
368
|
+
# To prevent garbage collection of the image
|
|
369
|
+
self.current_image = None
|
|
370
|
+
self.canvas_image_id = None
|
|
371
|
+
|
|
372
|
+
# Frame rendering backpressure:
|
|
373
|
+
# keep only the latest frame and ensure max one pending Tk render callback.
|
|
374
|
+
self._latest_bgr_frame = None
|
|
375
|
+
self._render_pending = False
|
|
376
|
+
self._target_ui_fps = 30.0
|
|
377
|
+
self._last_render_time = 0.0
|
|
378
|
+
self._last_canvas_resize_time = 0.0
|
|
379
|
+
self._resize_settle_seconds = 0.30
|
|
380
|
+
self._preview_cap_enabled = True
|
|
381
|
+
self._preview_max_width = 1280
|
|
382
|
+
self._preview_max_height = 720
|
|
383
|
+
|
|
384
|
+
def bind_camera(self, camera, device_path):
|
|
385
|
+
"""
|
|
386
|
+
==========================================
|
|
387
|
+
Bind active camera instance and device path to GUI controls.
|
|
388
|
+
==========================================
|
|
389
|
+
"""
|
|
390
|
+
self.camera = camera
|
|
391
|
+
self.device_path = device_path
|
|
392
|
+
self.format_button.configure(state="normal")
|
|
393
|
+
self.reset_settings_button.configure(state="normal")
|
|
394
|
+
self._ingest_fps_value = None
|
|
395
|
+
self._render_fps_value = None
|
|
396
|
+
self._dotnet_fps_value = None
|
|
397
|
+
self._update_fps_label()
|
|
398
|
+
self._ingest_fps_window_start = time.perf_counter()
|
|
399
|
+
self._ingest_frame_count = 0
|
|
400
|
+
self._render_fps_window_start = time.perf_counter()
|
|
401
|
+
self._render_frame_count = 0
|
|
402
|
+
self._last_ingest_time = None
|
|
403
|
+
self._last_render_time_for_fps = None
|
|
404
|
+
self._fps_has_measurement = False
|
|
405
|
+
self._refresh_current_format_label()
|
|
406
|
+
self._refresh_auto_mode_controls()
|
|
407
|
+
self._refresh_property_value_controls()
|
|
408
|
+
|
|
409
|
+
def _update_fps_label(self):
|
|
410
|
+
"""
|
|
411
|
+
=====================
|
|
412
|
+
Refresh combined FPS label for Python-side and .NET-side values.
|
|
413
|
+
=====================
|
|
414
|
+
"""
|
|
415
|
+
ingest_text = "--" if self._ingest_fps_value is None else f"{float(self._ingest_fps_value):.1f}"
|
|
416
|
+
render_text = "--" if self._render_fps_value is None else f"{float(self._render_fps_value):.1f}"
|
|
417
|
+
dotnet_text = "--" if self._dotnet_fps_value is None else f"{float(self._dotnet_fps_value):.1f}"
|
|
418
|
+
self.current_fps_var.set(
|
|
419
|
+
f"FPS Ingest (Py): {ingest_text} FPS Render (Py): {render_text} FPS (.NET): {dotnet_text}"
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
@staticmethod
|
|
423
|
+
def _show_reset_failure_message(action_title, success_count, total_count):
|
|
424
|
+
"""
|
|
425
|
+
==========================================
|
|
426
|
+
Show one aggregated failure message for a reset action.
|
|
427
|
+
==========================================
|
|
428
|
+
"""
|
|
429
|
+
failed_count = max(0, int(total_count) - int(success_count))
|
|
430
|
+
|
|
431
|
+
if int(total_count) <= 0:
|
|
432
|
+
message = f"{action_title} failed: no supported properties were available to reset."
|
|
433
|
+
elif int(success_count) <= 0:
|
|
434
|
+
message = f"{action_title} failed: 0/{int(total_count)} properties were reset."
|
|
435
|
+
else:
|
|
436
|
+
message = (
|
|
437
|
+
f"{action_title} partially failed: "
|
|
438
|
+
f"{int(success_count)}/{int(total_count)} succeeded, {failed_count} failed."
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
messagebox.showerror("Reset Failed", message)
|
|
442
|
+
|
|
443
|
+
def show_reset_settings_options(self):
|
|
444
|
+
"""
|
|
445
|
+
==========================================
|
|
446
|
+
Show reset actions dialog for properties and property flags.
|
|
447
|
+
==========================================
|
|
448
|
+
"""
|
|
449
|
+
if self.camera is None:
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
dialog = _create_and_setup_dialog(self.root, "Reset Settings", 400, 180)
|
|
453
|
+
|
|
454
|
+
title_label = ttk.Label(dialog, text="Choose reset action", justify="center")
|
|
455
|
+
title_label.pack(pady=(12, 8), padx=12)
|
|
456
|
+
|
|
457
|
+
btn_frame = tk.Frame(dialog)
|
|
458
|
+
btn_frame.pack(padx=12, pady=(4, 12), fill="x")
|
|
459
|
+
|
|
460
|
+
reset_properties_btn = ttk.Button(btn_frame, text="Reset Properties")
|
|
461
|
+
reset_properties_btn.pack(fill="x", pady=(0, 8))
|
|
462
|
+
|
|
463
|
+
reset_flags_btn = ttk.Button(btn_frame, text="Reset Property Flags")
|
|
464
|
+
reset_flags_btn.pack(fill="x")
|
|
465
|
+
|
|
466
|
+
def on_reset_properties():
|
|
467
|
+
all_success, reset_count, total_supported = self.camera.reset_all_properties_to_default_values()
|
|
468
|
+
|
|
469
|
+
if all_success:
|
|
470
|
+
reset_properties_btn.configure(text="Reset Properties - Success", state="disabled")
|
|
471
|
+
self.current_format_label.configure(fg="green")
|
|
472
|
+
self._refresh_property_value_controls()
|
|
473
|
+
return
|
|
474
|
+
|
|
475
|
+
self.current_format_label.configure(fg="red")
|
|
476
|
+
self._refresh_property_value_controls()
|
|
477
|
+
self._show_reset_failure_message("Reset Properties", reset_count, total_supported)
|
|
478
|
+
|
|
479
|
+
def on_reset_flags():
|
|
480
|
+
all_success, updated_count, total_auto_supported = self.camera.reset_all_property_flags()
|
|
481
|
+
|
|
482
|
+
if all_success:
|
|
483
|
+
reset_flags_btn.configure(text="Reset Property Flags - Success", state="disabled")
|
|
484
|
+
self.current_format_label.configure(fg="green")
|
|
485
|
+
self._refresh_auto_mode_controls()
|
|
486
|
+
self._refresh_property_value_controls()
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
self.current_format_label.configure(fg="red")
|
|
490
|
+
self._refresh_auto_mode_controls()
|
|
491
|
+
self._refresh_property_value_controls()
|
|
492
|
+
self._show_reset_failure_message("Reset Property Flags", updated_count, total_auto_supported)
|
|
493
|
+
|
|
494
|
+
reset_properties_btn.configure(command=on_reset_properties)
|
|
495
|
+
reset_flags_btn.configure(command=on_reset_flags)
|
|
496
|
+
|
|
497
|
+
close_btn = ttk.Button(dialog, text="Close", command=dialog.destroy)
|
|
498
|
+
close_btn.pack(pady=(0, 12))
|
|
499
|
+
|
|
500
|
+
_make_dialog_modal_and_wait(dialog, self.root)
|
|
501
|
+
|
|
502
|
+
def _clear_auto_mode_controls(self):
|
|
503
|
+
"""
|
|
504
|
+
==========================================
|
|
505
|
+
Remove existing auto/manual control checkboxes from the UI.
|
|
506
|
+
==========================================
|
|
507
|
+
"""
|
|
508
|
+
for widget in self.auto_mode_controls_frame.winfo_children():
|
|
509
|
+
widget.destroy()
|
|
510
|
+
self.auto_mode_vars = {}
|
|
511
|
+
self.auto_mode_checkbuttons = {}
|
|
512
|
+
|
|
513
|
+
def _on_auto_mode_toggle(self, property_name, var):
|
|
514
|
+
"""
|
|
515
|
+
==========================================
|
|
516
|
+
Handle user toggling of one auto/manual property checkbox.
|
|
517
|
+
==========================================
|
|
518
|
+
"""
|
|
519
|
+
if self.camera is None:
|
|
520
|
+
return
|
|
521
|
+
|
|
522
|
+
requested_auto_on = bool(var.get())
|
|
523
|
+
success, is_auto_enabled = self.camera.set_property_auto_mode(property_name, requested_auto_on)
|
|
524
|
+
var.set(bool(is_auto_enabled))
|
|
525
|
+
|
|
526
|
+
if success:
|
|
527
|
+
self.current_format_label.configure(fg="green")
|
|
528
|
+
else:
|
|
529
|
+
self.current_format_label.configure(fg="red")
|
|
530
|
+
|
|
531
|
+
self._refresh_property_value_controls()
|
|
532
|
+
|
|
533
|
+
def _refresh_property_value_controls(self):
|
|
534
|
+
"""
|
|
535
|
+
==========================================
|
|
536
|
+
Rebuild property sliders from the camera property ranges.
|
|
537
|
+
==========================================
|
|
538
|
+
"""
|
|
539
|
+
for widget in self.property_value_controls_frame.winfo_children():
|
|
540
|
+
widget.destroy()
|
|
541
|
+
|
|
542
|
+
self.property_slider_vars = {}
|
|
543
|
+
self.property_sliders = {}
|
|
544
|
+
self.property_value_labels = {}
|
|
545
|
+
self._updating_property_sliders = set()
|
|
546
|
+
|
|
547
|
+
section_title = ttk.Label(self.property_value_controls_frame, text="Property Controls")
|
|
548
|
+
section_title.pack(anchor="w")
|
|
549
|
+
|
|
550
|
+
if self.camera is None:
|
|
551
|
+
unavailable_label = ttk.Label(self.property_value_controls_frame, text="Property controls not available")
|
|
552
|
+
unavailable_label.pack(anchor="w", pady=(2, 0))
|
|
553
|
+
return
|
|
554
|
+
|
|
555
|
+
ranges = self.camera.property_ranges or {}
|
|
556
|
+
supported_properties = [
|
|
557
|
+
(name, camera_range)
|
|
558
|
+
for name, camera_range in ranges.items()
|
|
559
|
+
if camera_range.property_supported
|
|
560
|
+
]
|
|
561
|
+
|
|
562
|
+
if not supported_properties:
|
|
563
|
+
unavailable_label = ttk.Label(self.property_value_controls_frame, text="Property controls not available")
|
|
564
|
+
unavailable_label.pack(anchor="w", pady=(2, 0))
|
|
565
|
+
return
|
|
566
|
+
|
|
567
|
+
for property_name, property_range in sorted(supported_properties, key=lambda x: str(x[0]).lower()):
|
|
568
|
+
label = ttk.Label(self.property_value_controls_frame, text=str(property_name))
|
|
569
|
+
label.pack(anchor="w", pady=(6, 0))
|
|
570
|
+
|
|
571
|
+
min_value = float(property_range.min)
|
|
572
|
+
max_value = float(property_range.max)
|
|
573
|
+
step_value = float(property_range.step) if float(property_range.step) > 0 else 1.0
|
|
574
|
+
current_value = float(property_range.current)
|
|
575
|
+
|
|
576
|
+
slider_var = tk.DoubleVar(value=current_value)
|
|
577
|
+
slider = tk.Scale(
|
|
578
|
+
self.property_value_controls_frame,
|
|
579
|
+
from_=min_value,
|
|
580
|
+
to=max_value,
|
|
581
|
+
orient="horizontal",
|
|
582
|
+
showvalue=False,
|
|
583
|
+
resolution=step_value,
|
|
584
|
+
variable=slider_var,
|
|
585
|
+
command=lambda raw_value, n=property_name: self._on_property_slider_change(n, raw_value)
|
|
586
|
+
)
|
|
587
|
+
slider.pack(fill="x", pady=(2, 0))
|
|
588
|
+
|
|
589
|
+
min_max_label = ttk.Label(
|
|
590
|
+
self.property_value_controls_frame,
|
|
591
|
+
text=f"Min: {int(min_value)} Max: {int(max_value)} Value: {int(current_value)}"
|
|
592
|
+
)
|
|
593
|
+
min_max_label.pack(anchor="w", pady=(2, 0))
|
|
594
|
+
|
|
595
|
+
slider_state = "disabled" if (property_range.auto_supported and bool(property_range.is_auto)) else "normal"
|
|
596
|
+
slider.configure(state=slider_state)
|
|
597
|
+
|
|
598
|
+
property_key = str(property_name)
|
|
599
|
+
self.property_slider_vars[property_key] = slider_var
|
|
600
|
+
self.property_sliders[property_key] = slider
|
|
601
|
+
self.property_value_labels[property_key] = min_max_label
|
|
602
|
+
|
|
603
|
+
def _on_property_slider_change(self, property_name, raw_value):
|
|
604
|
+
"""
|
|
605
|
+
==========================================
|
|
606
|
+
Handle property slider movement and apply snapped value based on API step.
|
|
607
|
+
==========================================
|
|
608
|
+
"""
|
|
609
|
+
if self.camera is None:
|
|
610
|
+
return
|
|
611
|
+
|
|
612
|
+
property_key = str(property_name)
|
|
613
|
+
if property_key in self._updating_property_sliders:
|
|
614
|
+
return
|
|
615
|
+
|
|
616
|
+
ranges = self.camera.property_ranges or {}
|
|
617
|
+
selected_range = None
|
|
618
|
+
for name, camera_range in ranges.items():
|
|
619
|
+
if str(name).lower() == str(property_name).lower():
|
|
620
|
+
selected_range = camera_range
|
|
621
|
+
break
|
|
622
|
+
|
|
623
|
+
if selected_range is None:
|
|
624
|
+
return
|
|
625
|
+
|
|
626
|
+
min_value = float(selected_range.min)
|
|
627
|
+
max_value = float(selected_range.max)
|
|
628
|
+
step_value = float(selected_range.step) if float(selected_range.step) > 0 else 1.0
|
|
629
|
+
raw_numeric = float(raw_value)
|
|
630
|
+
|
|
631
|
+
snapped_value = min_value + round((raw_numeric - min_value) / step_value) * step_value
|
|
632
|
+
snapped_value = max(min_value, min(max_value, snapped_value))
|
|
633
|
+
target_value = int(round(snapped_value))
|
|
634
|
+
|
|
635
|
+
success, actual_value = self.camera.set_property_value(str(property_name), target_value)
|
|
636
|
+
|
|
637
|
+
slider_var = self.property_slider_vars.get(property_key)
|
|
638
|
+
if slider_var is not None:
|
|
639
|
+
self._updating_property_sliders.add(property_key)
|
|
640
|
+
slider_var.set(actual_value)
|
|
641
|
+
self._updating_property_sliders.discard(property_key)
|
|
642
|
+
|
|
643
|
+
value_label = self.property_value_labels.get(property_key)
|
|
644
|
+
if value_label is not None:
|
|
645
|
+
value_label.configure(
|
|
646
|
+
text=f"Min: {int(min_value)} Max: {int(max_value)} Value: {int(actual_value)}"
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
if success:
|
|
650
|
+
self.current_format_label.configure(fg="green")
|
|
651
|
+
else:
|
|
652
|
+
self.current_format_label.configure(fg="red")
|
|
653
|
+
|
|
654
|
+
def _refresh_auto_mode_controls(self):
|
|
655
|
+
"""
|
|
656
|
+
==========================================
|
|
657
|
+
Rebuild auto/manual controls from the camera property cache.
|
|
658
|
+
==========================================
|
|
659
|
+
"""
|
|
660
|
+
self._clear_auto_mode_controls()
|
|
661
|
+
|
|
662
|
+
if self.camera is None:
|
|
663
|
+
return
|
|
664
|
+
|
|
665
|
+
ranges = self.camera.property_ranges or {}
|
|
666
|
+
supported_auto_properties = [
|
|
667
|
+
(name, camera_range)
|
|
668
|
+
for name, camera_range in ranges.items()
|
|
669
|
+
if camera_range.property_supported and camera_range.auto_supported
|
|
670
|
+
]
|
|
671
|
+
|
|
672
|
+
if not supported_auto_properties:
|
|
673
|
+
no_controls_label = ttk.Label(self.auto_mode_controls_frame, text="No auto/manual controls available")
|
|
674
|
+
no_controls_label.pack(anchor="w")
|
|
675
|
+
return
|
|
676
|
+
|
|
677
|
+
for property_name, camera_range in sorted(supported_auto_properties, key=lambda x: x[0].lower()):
|
|
678
|
+
var = tk.BooleanVar(value=bool(camera_range.is_auto))
|
|
679
|
+
checkbox = ttk.Checkbutton(
|
|
680
|
+
self.auto_mode_controls_frame,
|
|
681
|
+
text=f"{property_name} Auto",
|
|
682
|
+
variable=var,
|
|
683
|
+
command=lambda n=property_name, v=var: self._on_auto_mode_toggle(n, v)
|
|
684
|
+
)
|
|
685
|
+
checkbox.pack(anchor="w", pady=1)
|
|
686
|
+
self.auto_mode_vars[property_name] = var
|
|
687
|
+
self.auto_mode_checkbuttons[property_name] = checkbox
|
|
688
|
+
|
|
689
|
+
@staticmethod
|
|
690
|
+
def _format_to_display_text(camera_format):
|
|
691
|
+
"""
|
|
692
|
+
==========================================
|
|
693
|
+
Format camera mode details into a user-friendly label string.
|
|
694
|
+
==========================================
|
|
695
|
+
"""
|
|
696
|
+
return f"{camera_format.width} x {camera_format.height} @ {camera_format.fps:.2f} FPS ({camera_format.pixel_format})"
|
|
697
|
+
|
|
698
|
+
def _refresh_current_format_label(self, format_change_succeeded=None):
|
|
699
|
+
"""
|
|
700
|
+
==========================================
|
|
701
|
+
Refresh current format text and status color.
|
|
702
|
+
==========================================
|
|
703
|
+
"""
|
|
704
|
+
if self.camera is None or self.camera.current_format is None:
|
|
705
|
+
self.current_format_var.set("Current format: N/A")
|
|
706
|
+
self.current_format_label.configure(fg="black")
|
|
707
|
+
return
|
|
708
|
+
|
|
709
|
+
current_text = self._format_to_display_text(self.camera.current_format)
|
|
710
|
+
self.current_format_var.set(f"Current format: {current_text}")
|
|
711
|
+
if format_change_succeeded is True:
|
|
712
|
+
self.current_format_label.configure(fg="green")
|
|
713
|
+
elif format_change_succeeded is False:
|
|
714
|
+
self.current_format_label.configure(fg="red")
|
|
715
|
+
else:
|
|
716
|
+
self.current_format_label.configure(fg="black")
|
|
717
|
+
|
|
718
|
+
def show_camera_format_options(self):
|
|
719
|
+
"""
|
|
720
|
+
==========================================
|
|
721
|
+
Show available formats and allow switching to a different one.
|
|
722
|
+
==========================================
|
|
723
|
+
"""
|
|
724
|
+
if self.camera is None or self.device_path is None:
|
|
725
|
+
return
|
|
726
|
+
|
|
727
|
+
available_formats = []
|
|
728
|
+
try:
|
|
729
|
+
available_formats = self.camera.get_camera_formats(self.device_path) or []
|
|
730
|
+
except Exception:
|
|
731
|
+
available_formats = []
|
|
732
|
+
|
|
733
|
+
if not available_formats:
|
|
734
|
+
available_formats = self.camera.available_formats or []
|
|
735
|
+
else:
|
|
736
|
+
self.camera.available_formats = available_formats
|
|
737
|
+
|
|
738
|
+
if not available_formats:
|
|
739
|
+
return
|
|
740
|
+
|
|
741
|
+
current_format = self.camera.current_format
|
|
742
|
+
|
|
743
|
+
dialog = _create_and_setup_dialog(self.root, "Camera Format Options", 400, 160)
|
|
744
|
+
|
|
745
|
+
current_text = "Current format: "
|
|
746
|
+
if current_format is not None:
|
|
747
|
+
current_text += self._format_to_display_text(current_format)
|
|
748
|
+
else:
|
|
749
|
+
current_text += "Unknown"
|
|
750
|
+
|
|
751
|
+
current_label = ttk.Label(dialog, text=current_text, wraplength=300, justify="center")
|
|
752
|
+
current_label.pack(pady=(12, 8), padx=12)
|
|
753
|
+
|
|
754
|
+
display_formats = [self._format_to_display_text(fmt) for fmt in available_formats]
|
|
755
|
+
combo_formats = ttk.Combobox(dialog, values=display_formats, state="readonly", width=65)
|
|
756
|
+
combo_formats.pack(pady=6, padx=12)
|
|
757
|
+
|
|
758
|
+
current_rgb24_request = bool(getattr(self.camera, "_request_rgb24_conversion", False))
|
|
759
|
+
request_rgb24_var = tk.BooleanVar(value=current_rgb24_request)
|
|
760
|
+
request_rgb24_checkbox = ttk.Checkbutton(
|
|
761
|
+
dialog,
|
|
762
|
+
text="Request RGB24",
|
|
763
|
+
variable=request_rgb24_var
|
|
764
|
+
)
|
|
765
|
+
request_rgb24_checkbox.pack(pady=(0, 6), padx=12)
|
|
766
|
+
|
|
767
|
+
default_index = 0
|
|
768
|
+
if current_format is not None:
|
|
769
|
+
for idx, fmt in enumerate(available_formats):
|
|
770
|
+
if fmt == current_format:
|
|
771
|
+
default_index = idx
|
|
772
|
+
break
|
|
773
|
+
combo_formats.current(default_index)
|
|
774
|
+
|
|
775
|
+
def on_apply():
|
|
776
|
+
"""
|
|
777
|
+
=====================
|
|
778
|
+
Apply the selected format and refresh dependent controls.
|
|
779
|
+
=====================
|
|
780
|
+
"""
|
|
781
|
+
selected_idx = combo_formats.current()
|
|
782
|
+
if selected_idx == -1:
|
|
783
|
+
dialog.destroy()
|
|
784
|
+
return
|
|
785
|
+
|
|
786
|
+
selected_format = available_formats[selected_idx]
|
|
787
|
+
target_format = selected_format
|
|
788
|
+
request_rgb24 = bool(request_rgb24_var.get())
|
|
789
|
+
dialog.destroy()
|
|
790
|
+
|
|
791
|
+
self.current_format_label.configure(fg="black")
|
|
792
|
+
|
|
793
|
+
def apply_format_in_background():
|
|
794
|
+
format_changed = False
|
|
795
|
+
try:
|
|
796
|
+
format_changed = bool(
|
|
797
|
+
self.camera.set_format(
|
|
798
|
+
target_format,
|
|
799
|
+
request_rgb24_conversion=request_rgb24
|
|
800
|
+
)
|
|
801
|
+
)
|
|
802
|
+
except Exception:
|
|
803
|
+
format_changed = False
|
|
804
|
+
|
|
805
|
+
def update_after_apply():
|
|
806
|
+
self._refresh_current_format_label(format_changed)
|
|
807
|
+
self._refresh_auto_mode_controls()
|
|
808
|
+
self._refresh_property_value_controls()
|
|
809
|
+
|
|
810
|
+
self.root.after(0, update_after_apply)
|
|
811
|
+
|
|
812
|
+
threading.Thread(target=apply_format_in_background, daemon=True).start()
|
|
813
|
+
|
|
814
|
+
def on_close():
|
|
815
|
+
"""
|
|
816
|
+
=====================
|
|
817
|
+
Close the format options dialog without changes.
|
|
818
|
+
=====================
|
|
819
|
+
"""
|
|
820
|
+
dialog.destroy()
|
|
821
|
+
|
|
822
|
+
btn_frame = tk.Frame(dialog)
|
|
823
|
+
btn_frame.pack(pady=10)
|
|
824
|
+
|
|
825
|
+
apply_btn = ttk.Button(btn_frame, text="Apply", command=on_apply)
|
|
826
|
+
apply_btn.pack(side="left", padx=8)
|
|
827
|
+
|
|
828
|
+
close_btn = ttk.Button(btn_frame, text="Close", command=on_close)
|
|
829
|
+
close_btn.pack(side="left", padx=8)
|
|
830
|
+
|
|
831
|
+
_make_dialog_modal_and_wait(dialog, self.root)
|
|
832
|
+
|
|
833
|
+
def run(self):
|
|
834
|
+
"""
|
|
835
|
+
==========================================
|
|
836
|
+
Start the Tkinter main event loop.
|
|
837
|
+
==========================================
|
|
838
|
+
"""
|
|
839
|
+
self.root.mainloop()
|
|
840
|
+
|
|
841
|
+
def update_video_frame(self, success, frame):
|
|
842
|
+
"""
|
|
843
|
+
=====================
|
|
844
|
+
Callback to be passed to the Camera class.
|
|
845
|
+
Handles thread-safety by scheduling the UI update on the main loop.
|
|
846
|
+
=====================
|
|
847
|
+
"""
|
|
848
|
+
# print(f"GUI received frame: success={success}, shape={frame.shape if frame is not None else 'None'}")
|
|
849
|
+
|
|
850
|
+
if not success or frame is None:
|
|
851
|
+
return
|
|
852
|
+
|
|
853
|
+
self._ingest_frame_count += 1
|
|
854
|
+
now = time.perf_counter()
|
|
855
|
+
self._last_ingest_time = now
|
|
856
|
+
ingest_elapsed = now - self._ingest_fps_window_start
|
|
857
|
+
if ingest_elapsed >= 0.5:
|
|
858
|
+
self._ingest_fps_value = self._ingest_frame_count / ingest_elapsed
|
|
859
|
+
self._ingest_fps_window_start = now
|
|
860
|
+
self._ingest_frame_count = 0
|
|
861
|
+
self._fps_has_measurement = True
|
|
862
|
+
|
|
863
|
+
# Keep only the newest frame in BGR. Conversion happens only for frames that are rendered.
|
|
864
|
+
self._latest_bgr_frame = frame
|
|
865
|
+
if not self._render_pending:
|
|
866
|
+
self._render_pending = True
|
|
867
|
+
self.root.after(0, self._drain_latest_frame)
|
|
868
|
+
|
|
869
|
+
def _on_video_canvas_configure(self, event):
|
|
870
|
+
"""
|
|
871
|
+
=====================
|
|
872
|
+
Track canvas resize activity so render path can use cheaper scaling while resizing.
|
|
873
|
+
=====================
|
|
874
|
+
"""
|
|
875
|
+
self._last_canvas_resize_time = time.perf_counter()
|
|
876
|
+
|
|
877
|
+
def _drain_latest_frame(self):
|
|
878
|
+
"""
|
|
879
|
+
=====================
|
|
880
|
+
Render at most one frame callback at a time and coalesce burst updates.
|
|
881
|
+
=====================
|
|
882
|
+
"""
|
|
883
|
+
now = time.perf_counter()
|
|
884
|
+
min_render_interval = 1.0 / self._target_ui_fps if self._target_ui_fps > 0 else 0.0
|
|
885
|
+
elapsed_since_last_render = now - self._last_render_time
|
|
886
|
+
if elapsed_since_last_render < min_render_interval:
|
|
887
|
+
remaining_ms = max(1, int((min_render_interval - elapsed_since_last_render) * 1000))
|
|
888
|
+
self.root.after(remaining_ms, self._drain_latest_frame)
|
|
889
|
+
return
|
|
890
|
+
|
|
891
|
+
frame_to_render = self._latest_bgr_frame
|
|
892
|
+
if frame_to_render is None:
|
|
893
|
+
self._render_pending = False
|
|
894
|
+
return
|
|
895
|
+
|
|
896
|
+
self._latest_bgr_frame = None
|
|
897
|
+
self._update_canvas_safe(frame_to_render)
|
|
898
|
+
self._last_render_time = time.perf_counter()
|
|
899
|
+
|
|
900
|
+
if self._latest_bgr_frame is not None:
|
|
901
|
+
self.root.after(0, self._drain_latest_frame)
|
|
902
|
+
else:
|
|
903
|
+
self._render_pending = False
|
|
904
|
+
|
|
905
|
+
def _update_canvas_safe(self, bgr_frame):
|
|
906
|
+
"""
|
|
907
|
+
=====================
|
|
908
|
+
Actual UI update running on the main thread.
|
|
909
|
+
=====================
|
|
910
|
+
"""
|
|
911
|
+
# Get canvas dimensions
|
|
912
|
+
canvas_width = self.canvas.winfo_width()
|
|
913
|
+
canvas_height = self.canvas.winfo_height()
|
|
914
|
+
|
|
915
|
+
# Use configured size if canvas not yet rendered
|
|
916
|
+
if canvas_width <= 1:
|
|
917
|
+
canvas_width = 640
|
|
918
|
+
if canvas_height <= 1:
|
|
919
|
+
canvas_height = 480
|
|
920
|
+
|
|
921
|
+
target_width = canvas_width
|
|
922
|
+
target_height = canvas_height
|
|
923
|
+
if self._preview_cap_enabled:
|
|
924
|
+
target_width = min(target_width, int(self._preview_max_width))
|
|
925
|
+
target_height = min(target_height, int(self._preview_max_height))
|
|
926
|
+
|
|
927
|
+
# Convert to RGB only for frames that are actually rendered.
|
|
928
|
+
rgb_frame = cv2.cvtColor(bgr_frame, cv2.COLOR_BGR2RGB)
|
|
929
|
+
pil_image = Image.fromarray(rgb_frame)
|
|
930
|
+
|
|
931
|
+
# Downscale-only fit while maintaining aspect ratio.
|
|
932
|
+
# If target area is larger than source, keep source size (no upscaling).
|
|
933
|
+
img_width, img_height = pil_image.size
|
|
934
|
+
width_scale = float(target_width) / float(img_width)
|
|
935
|
+
height_scale = float(target_height) / float(img_height)
|
|
936
|
+
downscale_factor = min(width_scale, height_scale, 1.0)
|
|
937
|
+
new_width = max(1, int(img_width * downscale_factor))
|
|
938
|
+
new_height = max(1, int(img_height * downscale_factor))
|
|
939
|
+
|
|
940
|
+
if new_width != img_width or new_height != img_height:
|
|
941
|
+
now = time.perf_counter()
|
|
942
|
+
is_resizing = (now - self._last_canvas_resize_time) < self._resize_settle_seconds
|
|
943
|
+
resample_filter = Image.NEAREST if is_resizing else Image.BILINEAR
|
|
944
|
+
pil_image = pil_image.resize((new_width, new_height), resample_filter)
|
|
945
|
+
imgtk = ImageTk.PhotoImage(image=pil_image)
|
|
946
|
+
|
|
947
|
+
# Keep a reference to prevent GC
|
|
948
|
+
self.current_image = imgtk
|
|
949
|
+
|
|
950
|
+
# Center the image on canvas
|
|
951
|
+
x_offset = (canvas_width - new_width) // 2
|
|
952
|
+
y_offset = (canvas_height - new_height) // 2
|
|
953
|
+
if self.canvas_image_id is None:
|
|
954
|
+
self.canvas_image_id = self.canvas.create_image(x_offset, y_offset, anchor="nw", image=imgtk)
|
|
955
|
+
else:
|
|
956
|
+
self.canvas.coords(self.canvas_image_id, x_offset, y_offset)
|
|
957
|
+
self.canvas.itemconfig(self.canvas_image_id, image=imgtk)
|
|
958
|
+
|
|
959
|
+
self._render_frame_count += 1
|
|
960
|
+
now = time.perf_counter()
|
|
961
|
+
self._last_render_time_for_fps = now
|
|
962
|
+
render_elapsed = now - self._render_fps_window_start
|
|
963
|
+
if render_elapsed >= 0.5:
|
|
964
|
+
self._render_fps_value = float(self._render_frame_count / render_elapsed)
|
|
965
|
+
self._update_fps_label()
|
|
966
|
+
self._fps_has_measurement = True
|
|
967
|
+
self._render_fps_window_start = now
|
|
968
|
+
self._render_frame_count = 0
|
|
969
|
+
|
|
970
|
+
def _refresh_fps_display_state(self):
|
|
971
|
+
"""
|
|
972
|
+
=====================
|
|
973
|
+
Reset FPS label when stream is stale and keep periodic watchdog running.
|
|
974
|
+
=====================
|
|
975
|
+
"""
|
|
976
|
+
now = time.perf_counter()
|
|
977
|
+
latest_activity_time = None
|
|
978
|
+
if self._last_ingest_time is not None:
|
|
979
|
+
latest_activity_time = self._last_ingest_time
|
|
980
|
+
if self._last_render_time_for_fps is not None:
|
|
981
|
+
if latest_activity_time is None or self._last_render_time_for_fps > latest_activity_time:
|
|
982
|
+
latest_activity_time = self._last_render_time_for_fps
|
|
983
|
+
|
|
984
|
+
if latest_activity_time is not None:
|
|
985
|
+
if (now - latest_activity_time) >= self._fps_stale_timeout_sec and self._fps_has_measurement:
|
|
986
|
+
self._ingest_fps_value = None
|
|
987
|
+
self._render_fps_value = None
|
|
988
|
+
self._fps_has_measurement = False
|
|
989
|
+
self._ingest_frame_count = 0
|
|
990
|
+
self._render_frame_count = 0
|
|
991
|
+
self._ingest_fps_window_start = now
|
|
992
|
+
self._render_fps_window_start = now
|
|
993
|
+
|
|
994
|
+
if self.camera is not None:
|
|
995
|
+
try:
|
|
996
|
+
dotnet_fps = float(self.camera.get_current_fps())
|
|
997
|
+
self._dotnet_fps_value = dotnet_fps if dotnet_fps > 0 else None
|
|
998
|
+
except Exception:
|
|
999
|
+
self._dotnet_fps_value = None
|
|
1000
|
+
else:
|
|
1001
|
+
self._dotnet_fps_value = None
|
|
1002
|
+
|
|
1003
|
+
self._update_fps_label()
|
|
1004
|
+
|
|
1005
|
+
self.root.after(500, self._refresh_fps_display_state)
|
|
1006
|
+
|