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/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
+