feat: Add Camera Settings dialog for UVC controls and Qt parameters
- Implemented CameraSettingsDialog to manage UVC and Qt camera controls. - Integrated UVC parameter sliders and auto controls for brightness, contrast, saturation, hue, sharpness, gamma, white balance, backlight compensation, and exposure. - Added functionality to change white balance and exposure settings via Qt controls. - Updated MainWindow to open CameraSettingsDialog and manage UVC controller lifecycle. - Enhanced AppMenuBar to include a Camera Settings option. - Created tests for UVC controller abstraction layer and parameter info. - Documented camera specifications and supported features in new markdown files.
This commit is contained in:
310
app/ui/camera_settings_dialog.py
Normal file
310
app/ui/camera_settings_dialog.py
Normal file
@@ -0,0 +1,310 @@
|
||||
"""Camera Settings dialog — sliders for UVC controls + Qt WhiteBalance/Exposure."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtMultimedia import QCamera
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QScrollArea,
|
||||
QSlider,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from app.camera.uvc.base import UvcControllerBase, UvcParam
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Human-readable labels for each UVC parameter
|
||||
_PARAM_LABELS: dict[UvcParam, str] = {
|
||||
UvcParam.BRIGHTNESS: "Brightness",
|
||||
UvcParam.CONTRAST: "Contrast",
|
||||
UvcParam.SATURATION: "Saturation",
|
||||
UvcParam.HUE: "Hue",
|
||||
UvcParam.SHARPNESS: "Sharpness",
|
||||
UvcParam.GAMMA: "Gamma",
|
||||
UvcParam.WHITE_BALANCE: "White Balance (K)",
|
||||
UvcParam.BACKLIGHT_COMPENSATION: "Backlight Compensation",
|
||||
UvcParam.EXPOSURE: "Exposure Time",
|
||||
}
|
||||
|
||||
# Qt WhiteBalance modes shown in the combo
|
||||
_WB_MODES: list[tuple[str, QCamera.WhiteBalanceMode]] = [
|
||||
("Auto", QCamera.WhiteBalanceMode.WhiteBalanceAuto),
|
||||
("Sunlight", QCamera.WhiteBalanceMode.WhiteBalanceSunlight),
|
||||
("Cloudy", QCamera.WhiteBalanceMode.WhiteBalanceCloudy),
|
||||
("Shade", QCamera.WhiteBalanceMode.WhiteBalanceShade),
|
||||
("Tungsten", QCamera.WhiteBalanceMode.WhiteBalanceTungsten),
|
||||
("Fluorescent", QCamera.WhiteBalanceMode.WhiteBalanceFluorescent),
|
||||
("Flash", QCamera.WhiteBalanceMode.WhiteBalanceFlash),
|
||||
("Sunset", QCamera.WhiteBalanceMode.WhiteBalanceSunset),
|
||||
("Manual (K)", QCamera.WhiteBalanceMode.WhiteBalanceManual),
|
||||
]
|
||||
|
||||
_EXPOSURE_MODES: list[tuple[str, QCamera.ExposureMode]] = [
|
||||
("Auto", QCamera.ExposureMode.ExposureAuto),
|
||||
("Manual", QCamera.ExposureMode.ExposureManual),
|
||||
]
|
||||
|
||||
|
||||
class CameraSettingsDialog(QDialog):
|
||||
"""
|
||||
Modal dialog for camera image controls.
|
||||
|
||||
Sections:
|
||||
• Qt controls — WhiteBalance mode + colour temperature, Exposure mode + time
|
||||
• UVC controls — sliders for Brightness, Contrast, Saturation, Hue,
|
||||
Sharpness, Gamma, White Balance (manual K), Backlight,
|
||||
Exposure (if UVC controller is open)
|
||||
|
||||
Changes are applied live to the camera.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
camera: QCamera,
|
||||
uvc: UvcControllerBase,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Camera Settings")
|
||||
self.setMinimumWidth(440)
|
||||
|
||||
self._camera = camera
|
||||
self._uvc = uvc
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
outer.addWidget(scroll)
|
||||
|
||||
content = QWidget()
|
||||
scroll.setWidget(content)
|
||||
layout = QVBoxLayout(content)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
layout.addWidget(self._build_qt_group())
|
||||
|
||||
uvc_group = self._build_uvc_group()
|
||||
if uvc_group is not None:
|
||||
layout.addWidget(uvc_group)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||||
buttons.rejected.connect(self.accept)
|
||||
outer.addWidget(buttons)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Qt controls section
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_qt_group(self) -> QGroupBox:
|
||||
group = QGroupBox("Qt Camera Controls")
|
||||
form = QFormLayout(group)
|
||||
|
||||
# White Balance mode
|
||||
self._wb_combo = QComboBox()
|
||||
for label, mode in _WB_MODES:
|
||||
self._wb_combo.addItem(label, mode)
|
||||
|
||||
current_wb = self._camera.whiteBalanceMode()
|
||||
for i, (_, mode) in enumerate(_WB_MODES):
|
||||
if mode == current_wb:
|
||||
self._wb_combo.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
self._wb_combo.currentIndexChanged.connect(self._on_wb_mode_changed)
|
||||
form.addRow("White Balance:", self._wb_combo)
|
||||
|
||||
# Colour temperature (manual only)
|
||||
self._temp_label = QLabel("Colour Temp (K):")
|
||||
self._temp_spin = QSpinBox()
|
||||
self._temp_spin.setRange(2000, 10000)
|
||||
self._temp_spin.setSingleStep(100)
|
||||
current_temp = self._camera.colorTemperature()
|
||||
self._temp_spin.setValue(current_temp if current_temp >= 2000 else 5500)
|
||||
self._temp_spin.valueChanged.connect(self._on_color_temp_changed)
|
||||
form.addRow(self._temp_label, self._temp_spin)
|
||||
self._update_temp_visibility()
|
||||
|
||||
form.addRow(QLabel("")) # spacer
|
||||
|
||||
# Exposure mode
|
||||
self._exp_combo = QComboBox()
|
||||
for label, mode in _EXPOSURE_MODES:
|
||||
self._exp_combo.addItem(label, mode)
|
||||
|
||||
current_exp = self._camera.exposureMode()
|
||||
for i, (_, mode) in enumerate(_EXPOSURE_MODES):
|
||||
if mode == current_exp:
|
||||
self._exp_combo.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
self._exp_combo.currentIndexChanged.connect(self._on_exp_mode_changed)
|
||||
form.addRow("Exposure Mode:", self._exp_combo)
|
||||
|
||||
# Manual exposure time
|
||||
self._exp_label = QLabel("Exposure Time (s):")
|
||||
self._exp_spin = QDoubleSpinBox()
|
||||
exp_min = self._camera.minimumExposureTime()
|
||||
exp_max = self._camera.maximumExposureTime()
|
||||
self._exp_spin.setRange(
|
||||
exp_min if exp_min > 0 else 1e-6,
|
||||
exp_max if exp_max > 0 else 1.0,
|
||||
)
|
||||
self._exp_spin.setDecimals(6)
|
||||
self._exp_spin.setSingleStep(0.001)
|
||||
manual_t = self._camera.manualExposureTime()
|
||||
self._exp_spin.setValue(manual_t if manual_t > 0 else 0.033)
|
||||
self._exp_spin.valueChanged.connect(self._on_exp_time_changed)
|
||||
form.addRow(self._exp_label, self._exp_spin)
|
||||
self._update_exp_visibility()
|
||||
|
||||
return group
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# UVC controls section
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_uvc_group(self) -> QGroupBox | None:
|
||||
params = self._uvc.get_all_params()
|
||||
supported = [p for p in params if p.supported]
|
||||
|
||||
group = QGroupBox("UVC Camera Controls")
|
||||
form = QFormLayout(group)
|
||||
|
||||
if not supported:
|
||||
note = QLabel(
|
||||
"UVC controls not available.\n"
|
||||
"Install duvc-ctl (Windows) or pyuvc (macOS) to enable."
|
||||
)
|
||||
note.setEnabled(False)
|
||||
form.addRow(note)
|
||||
return group
|
||||
|
||||
self._uvc_sliders: dict[UvcParam, QSlider] = {}
|
||||
self._uvc_spins: dict[UvcParam, QSpinBox] = {}
|
||||
self._uvc_auto_boxes: dict[UvcParam, QCheckBox] = {}
|
||||
|
||||
for info in params:
|
||||
label = _PARAM_LABELS.get(info.param, info.param.name.title())
|
||||
|
||||
if not info.supported:
|
||||
lbl = QLabel("Not supported")
|
||||
lbl.setEnabled(False)
|
||||
form.addRow(f"{label}:", lbl)
|
||||
continue
|
||||
|
||||
row_widget = QWidget()
|
||||
row_layout = QHBoxLayout(row_widget)
|
||||
row_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
slider = QSlider(Qt.Orientation.Horizontal)
|
||||
slider.setRange(info.minimum, info.maximum)
|
||||
slider.setValue(info.current)
|
||||
slider.setSingleStep(max(1, info.step))
|
||||
|
||||
spin = QSpinBox()
|
||||
spin.setRange(info.minimum, info.maximum)
|
||||
spin.setValue(info.current)
|
||||
spin.setFixedWidth(75)
|
||||
|
||||
# Keep slider ↔ spin in sync
|
||||
slider.valueChanged.connect(spin.setValue)
|
||||
spin.valueChanged.connect(slider.setValue)
|
||||
slider.valueChanged.connect(
|
||||
lambda v, p=info.param: self._on_uvc_value(p, v)
|
||||
)
|
||||
|
||||
row_layout.addWidget(slider)
|
||||
row_layout.addWidget(spin)
|
||||
|
||||
if info.auto_supported:
|
||||
auto_box = QCheckBox("Auto")
|
||||
auto_box.setChecked(info.auto_enabled)
|
||||
auto_box.toggled.connect(
|
||||
lambda checked, p=info.param: self._on_uvc_auto(p, checked)
|
||||
)
|
||||
row_layout.addWidget(auto_box)
|
||||
self._uvc_auto_boxes[info.param] = auto_box
|
||||
# Init enabled state
|
||||
slider.setEnabled(not info.auto_enabled)
|
||||
spin.setEnabled(not info.auto_enabled)
|
||||
|
||||
self._uvc_sliders[info.param] = slider
|
||||
self._uvc_spins[info.param] = spin
|
||||
form.addRow(f"{label}:", row_widget)
|
||||
|
||||
return group
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Qt control slots
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_wb_mode_changed(self, index: int) -> None:
|
||||
mode: QCamera.WhiteBalanceMode = self._wb_combo.itemData(index)
|
||||
if self._camera.isWhiteBalanceModeSupported(mode):
|
||||
self._camera.setWhiteBalanceMode(mode)
|
||||
logger.debug("WB mode: %s", mode)
|
||||
else:
|
||||
logger.debug("WB mode %s not supported by this camera", mode)
|
||||
self._update_temp_visibility()
|
||||
|
||||
def _on_color_temp_changed(self, value: int) -> None:
|
||||
self._camera.setColorTemperature(value)
|
||||
logger.debug("Colour temp: %d K", value)
|
||||
|
||||
def _on_exp_mode_changed(self, index: int) -> None:
|
||||
mode: QCamera.ExposureMode = self._exp_combo.itemData(index)
|
||||
if self._camera.isExposureModeSupported(mode):
|
||||
self._camera.setExposureMode(mode)
|
||||
logger.debug("Exposure mode: %s", mode)
|
||||
self._update_exp_visibility()
|
||||
|
||||
def _on_exp_time_changed(self, value: float) -> None:
|
||||
self._camera.setManualExposureTime(value)
|
||||
logger.debug("Exposure time: %.6f s", value)
|
||||
|
||||
def _update_temp_visibility(self) -> None:
|
||||
manual = (
|
||||
self._wb_combo.currentData()
|
||||
== QCamera.WhiteBalanceMode.WhiteBalanceManual
|
||||
)
|
||||
self._temp_label.setVisible(manual)
|
||||
self._temp_spin.setVisible(manual)
|
||||
|
||||
def _update_exp_visibility(self) -> None:
|
||||
manual = (
|
||||
self._exp_combo.currentData()
|
||||
== QCamera.ExposureMode.ExposureManual
|
||||
)
|
||||
self._exp_label.setVisible(manual)
|
||||
self._exp_spin.setVisible(manual)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# UVC control slots
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_uvc_value(self, param: UvcParam, value: int) -> None:
|
||||
self._uvc.set_value(param, value)
|
||||
|
||||
def _on_uvc_auto(self, param: UvcParam, enabled: bool) -> None:
|
||||
self._uvc.set_auto(param, enabled)
|
||||
slider = self._uvc_sliders.get(param)
|
||||
spin = self._uvc_spins.get(param)
|
||||
if slider:
|
||||
slider.setEnabled(not enabled)
|
||||
if spin:
|
||||
spin.setEnabled(not enabled)
|
||||
@@ -8,13 +8,17 @@ from pathlib import Path
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QLabel, QMainWindow, QSizePolicy, QStatusBar
|
||||
|
||||
from app.camera.camera_enumerator import CameraEnumerator, CameraInfo
|
||||
from app.camera.camera_enumerator import CameraEnumerator, CameraFormat, CameraInfo
|
||||
from app.camera.camera_service import CameraService
|
||||
from app.camera.uvc import make_uvc_controller
|
||||
from app.camera.uvc.base import UvcControllerBase
|
||||
from app.camera.uvc.stub import NullUvcController
|
||||
from app.config import APP_NAME, APP_VERSION
|
||||
from app.overlay.telemetry_overlay import TelemetryOverlay
|
||||
from app.pipeline.frame_dispatcher import FrameDispatcher
|
||||
from app.telemetry.csv_logger import CsvTelemetryLogger
|
||||
from app.telemetry.telemetry_collector import TelemetryCollector
|
||||
from app.ui.camera_settings_dialog import CameraSettingsDialog
|
||||
from app.ui.camera_view import CameraView
|
||||
from app.ui.menu_bar import AppMenuBar
|
||||
|
||||
@@ -52,6 +56,9 @@ class MainWindow(QMainWindow):
|
||||
self._dispatcher = FrameDispatcher(self)
|
||||
self._telemetry = TelemetryCollector(parent=self)
|
||||
|
||||
# --- UVC controller (platform-specific, lazy-opened per camera) ---
|
||||
self._uvc: UvcControllerBase = NullUvcController()
|
||||
|
||||
# --- CSV telemetry logger ---
|
||||
self._csv_logger: CsvTelemetryLogger | None = None
|
||||
if log_path is not None:
|
||||
@@ -79,7 +86,7 @@ class MainWindow(QMainWindow):
|
||||
# --- Status bar ---
|
||||
self._status_bar = QStatusBar(self)
|
||||
self.setStatusBar(self._status_bar)
|
||||
self._status_label = QLabel("Initialising…")
|
||||
self._status_label = QLabel("Initialising\u2026")
|
||||
self._status_bar.addWidget(self._status_label)
|
||||
|
||||
# --- Wire signals ---
|
||||
@@ -113,6 +120,17 @@ class MainWindow(QMainWindow):
|
||||
self._camera_service.start(cam)
|
||||
self._menu.set_active_camera(cam)
|
||||
self._status_label.setText(f"Opening: {cam.name}")
|
||||
self._open_uvc(cam)
|
||||
|
||||
def _open_uvc(self, cam: CameraInfo) -> None:
|
||||
"""Open or reopen the UVC controller for the given camera."""
|
||||
if self._uvc.is_open():
|
||||
self._uvc.close()
|
||||
ctrl = make_uvc_controller(cam.name)
|
||||
if not ctrl.is_open():
|
||||
# factory may return a pre-opened controller or a NullUvcController
|
||||
ctrl.open(cam.name)
|
||||
self._uvc = ctrl
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Signal wiring
|
||||
@@ -125,11 +143,13 @@ class MainWindow(QMainWindow):
|
||||
# FrameDispatcher → CameraView (render) — drop if busy
|
||||
self._dispatcher.subscribe(self._camera_view.on_frame, drop_if_busy=True)
|
||||
|
||||
# FrameDispatcher → TelemetryCollector — never drop, count every frame
|
||||
# FrameDispatcher → TelemetryCollector — never drop
|
||||
self._dispatcher.subscribe(self._telemetry.on_frame, drop_if_busy=False)
|
||||
|
||||
# TelemetryCollector → overlay
|
||||
self._telemetry.metrics_updated.connect(self._telemetry_overlay.on_metrics_updated)
|
||||
self._telemetry.metrics_updated.connect(
|
||||
self._telemetry_overlay.on_metrics_updated
|
||||
)
|
||||
|
||||
# TelemetryCollector → CSV logger (throttled internally)
|
||||
if self._csv_logger is not None:
|
||||
@@ -145,10 +165,10 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# Menu signals
|
||||
self._menu.camera_selected.connect(self._on_camera_selected)
|
||||
self._menu.resolution_selected.connect(self._on_resolution_selected)
|
||||
self._menu.fps_selected.connect(self._on_fps_selected)
|
||||
self._menu.format_selected.connect(self._on_format_selected)
|
||||
self._menu.reconnect_requested.connect(self._camera_service.reconnect)
|
||||
self._menu.overlay_toggled.connect(self._camera_view.set_all_overlays_visible)
|
||||
self._menu.camera_settings_requested.connect(self._on_settings_requested)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Camera status slots
|
||||
@@ -174,11 +194,20 @@ class MainWindow(QMainWindow):
|
||||
def _on_camera_selected(self, cam: CameraInfo) -> None:
|
||||
self._start_camera(cam)
|
||||
|
||||
def _on_resolution_selected(self, width: int, height: int) -> None:
|
||||
self._camera_service.set_resolution(width, height)
|
||||
def _on_format_selected(self, fmt: CameraFormat) -> None:
|
||||
logger.info(
|
||||
"Format selected via menu: %dx%d @ %.4g fps (%s)",
|
||||
fmt.width, fmt.height, fmt.max_fps, fmt.pixel_format,
|
||||
)
|
||||
self._camera_service.set_format(fmt)
|
||||
|
||||
def _on_fps_selected(self, fps: float) -> None:
|
||||
self._camera_service.set_fps(fps)
|
||||
def _on_settings_requested(self) -> None:
|
||||
qt_cam = self._camera_service.qt_camera
|
||||
if qt_cam is None:
|
||||
logger.warning("Settings requested but no camera is active")
|
||||
return
|
||||
dlg = CameraSettingsDialog(qt_cam, self._uvc, parent=self)
|
||||
dlg.exec()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Qt overrides
|
||||
@@ -186,6 +215,8 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
self._camera_service.stop()
|
||||
if self._uvc.is_open():
|
||||
self._uvc.close()
|
||||
if self._csv_logger is not None:
|
||||
logger.info(
|
||||
"CSV telemetry: %d rows written", self._csv_logger.rows_written
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Menu bar — camera, video format, FPS and debug controls."""
|
||||
"""Menu bar — camera, video format and debug controls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,7 +8,7 @@ from PySide6.QtCore import Signal
|
||||
from PySide6.QtGui import QAction, QActionGroup
|
||||
from PySide6.QtWidgets import QMenuBar, QWidget
|
||||
|
||||
from app.camera.camera_enumerator import CameraInfo
|
||||
from app.camera.camera_enumerator import CameraFormat, CameraInfo
|
||||
from app.logging_setup import set_console_level
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -19,27 +19,26 @@ class AppMenuBar(QMenuBar):
|
||||
Application menu bar.
|
||||
|
||||
Signals:
|
||||
camera_selected(CameraInfo) — user picked a camera
|
||||
resolution_selected(int, int) — user picked (width, height)
|
||||
fps_selected(float) — user picked a target FPS
|
||||
reconnect_requested() — user hit Reconnect
|
||||
overlay_toggled(bool) — overlay show/hide
|
||||
log_toggled(bool) — console logging on/off
|
||||
camera_selected(CameraInfo) — user picked a camera
|
||||
format_selected(CameraFormat) — user picked a full format (res+fps+pixel)
|
||||
reconnect_requested() — user hit Reconnect
|
||||
overlay_toggled(bool) — overlay show/hide
|
||||
log_toggled(bool) — console logging on/off
|
||||
camera_settings_requested() — user opened Image Settings dialog
|
||||
"""
|
||||
|
||||
camera_selected = Signal(object) # CameraInfo
|
||||
resolution_selected = Signal(int, int)
|
||||
fps_selected = Signal(float)
|
||||
format_selected = Signal(object) # CameraFormat
|
||||
reconnect_requested = Signal()
|
||||
overlay_toggled = Signal(bool)
|
||||
log_toggled = Signal(bool)
|
||||
camera_settings_requested = Signal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
self._camera_group: QActionGroup | None = None
|
||||
self._resolution_group: QActionGroup | None = None
|
||||
self._fps_group: QActionGroup | None = None
|
||||
self._format_group: QActionGroup | None = None
|
||||
self._cameras: list[CameraInfo] = []
|
||||
|
||||
self._build_menus()
|
||||
@@ -72,9 +71,8 @@ class AppMenuBar(QMenuBar):
|
||||
self._camera_group.actions()[0].setChecked(True)
|
||||
|
||||
def populate_formats(self, camera_info: CameraInfo) -> None:
|
||||
"""Populate Resolution and FPS menus based on a camera's supported formats."""
|
||||
self._populate_resolutions(camera_info)
|
||||
self._populate_fps(camera_info)
|
||||
"""Populate the Resolution submenu with full format entries."""
|
||||
self._populate_format_menu(camera_info)
|
||||
|
||||
def set_active_camera(self, camera_info: CameraInfo) -> None:
|
||||
if self._camera_group is None:
|
||||
@@ -84,10 +82,24 @@ class AppMenuBar(QMenuBar):
|
||||
action.setChecked(True)
|
||||
return
|
||||
|
||||
def set_active_format(self, fmt: CameraFormat) -> None:
|
||||
"""Mark the given format as checked in the Resolution menu."""
|
||||
if self._format_group is None:
|
||||
return
|
||||
for action in self._format_group.actions():
|
||||
f: CameraFormat = action.data()
|
||||
if (
|
||||
f.width == fmt.width
|
||||
and f.height == fmt.height
|
||||
and abs(f.max_fps - fmt.max_fps) < 0.5
|
||||
and f.pixel_format == fmt.pixel_format
|
||||
):
|
||||
action.setChecked(True)
|
||||
return
|
||||
|
||||
def set_log_file_path(self, path: str) -> None:
|
||||
"""Display the log file path as a disabled menu item in Debug menu."""
|
||||
# Truncate long paths for display
|
||||
display = path if len(path) <= 60 else "…" + path[-57:]
|
||||
display = path if len(path) <= 60 else "\u2026" + path[-57:]
|
||||
self._log_file_action.setText(f"Log: {display}")
|
||||
self._log_file_action.setToolTip(path)
|
||||
|
||||
@@ -106,7 +118,12 @@ class AppMenuBar(QMenuBar):
|
||||
# Video menu
|
||||
self._video_menu = self.addMenu("Video")
|
||||
self._res_menu = self._video_menu.addMenu("Resolution")
|
||||
self._fps_menu = self._video_menu.addMenu("FPS")
|
||||
|
||||
# Image menu (camera controls)
|
||||
self._image_menu = self.addMenu("Image")
|
||||
self._settings_action = QAction("Camera Settings\u2026", self)
|
||||
self._settings_action.triggered.connect(self.camera_settings_requested)
|
||||
self._image_menu.addAction(self._settings_action)
|
||||
|
||||
# Debug menu
|
||||
debug_menu = self.addMenu("Debug")
|
||||
@@ -129,47 +146,26 @@ class AppMenuBar(QMenuBar):
|
||||
self._log_file_action.setEnabled(False)
|
||||
debug_menu.addAction(self._log_file_action)
|
||||
|
||||
def _populate_resolutions(self, camera_info: CameraInfo) -> None:
|
||||
def _populate_format_menu(self, camera_info: CameraInfo) -> None:
|
||||
"""Build Resolution submenu: one action per unique (W, H, FPS, pixel_format)."""
|
||||
self._res_menu.clear()
|
||||
self._resolution_group = QActionGroup(self)
|
||||
self._resolution_group.setExclusive(True)
|
||||
self._format_group = QActionGroup(self)
|
||||
self._format_group.setExclusive(True)
|
||||
|
||||
seen: set[tuple[int, int]] = set()
|
||||
for fmt in camera_info.formats:
|
||||
key = (fmt.width, fmt.height)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
action = QAction(f"{fmt.width} × {fmt.height}", self)
|
||||
label = (
|
||||
f"{fmt.width}\u00d7{fmt.height}"
|
||||
f" @ {fmt.max_fps:.4g}fps"
|
||||
f" ({fmt.pixel_format})"
|
||||
)
|
||||
action = QAction(label, self)
|
||||
action.setCheckable(True)
|
||||
action.setData((fmt.width, fmt.height))
|
||||
self._resolution_group.addAction(action)
|
||||
action.setData(fmt)
|
||||
self._format_group.addAction(action)
|
||||
self._res_menu.addAction(action)
|
||||
action.triggered.connect(self._on_resolution_action)
|
||||
action.triggered.connect(self._on_format_action)
|
||||
|
||||
actions = self._resolution_group.actions()
|
||||
if actions:
|
||||
actions[0].setChecked(True)
|
||||
|
||||
def _populate_fps(self, camera_info: CameraInfo) -> None:
|
||||
self._fps_menu.clear()
|
||||
self._fps_group = QActionGroup(self)
|
||||
self._fps_group.setExclusive(True)
|
||||
|
||||
seen: set[int] = set()
|
||||
for fmt in camera_info.formats:
|
||||
key = round(fmt.max_fps)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
action = QAction(f"{key} fps", self)
|
||||
action.setCheckable(True)
|
||||
action.setData(float(fmt.max_fps))
|
||||
self._fps_group.addAction(action)
|
||||
self._fps_menu.addAction(action)
|
||||
action.triggered.connect(self._on_fps_action)
|
||||
|
||||
actions = self._fps_group.actions()
|
||||
actions = self._format_group.actions()
|
||||
if actions:
|
||||
actions[0].setChecked(True)
|
||||
|
||||
@@ -184,24 +180,18 @@ class AppMenuBar(QMenuBar):
|
||||
cam: CameraInfo = action.data()
|
||||
logger.debug("Camera selected: %s", cam.name)
|
||||
self.camera_selected.emit(cam)
|
||||
self._populate_resolutions(cam)
|
||||
self._populate_fps(cam)
|
||||
self._populate_format_menu(cam)
|
||||
|
||||
def _on_resolution_action(self) -> None:
|
||||
def _on_format_action(self) -> None:
|
||||
action = self.sender()
|
||||
if action is None:
|
||||
return
|
||||
w, h = action.data()
|
||||
logger.debug("Resolution selected: %dx%d", w, h)
|
||||
self.resolution_selected.emit(w, h)
|
||||
|
||||
def _on_fps_action(self) -> None:
|
||||
action = self.sender()
|
||||
if action is None:
|
||||
return
|
||||
fps: float = action.data()
|
||||
logger.debug("FPS selected: %.1f", fps)
|
||||
self.fps_selected.emit(fps)
|
||||
fmt: CameraFormat = action.data()
|
||||
logger.debug(
|
||||
"Format selected: %dx%d @ %.4g fps (%s)",
|
||||
fmt.width, fmt.height, fmt.max_fps, fmt.pixel_format,
|
||||
)
|
||||
self.format_selected.emit(fmt)
|
||||
|
||||
def _on_log_toggled(self, enabled: bool) -> None:
|
||||
set_console_level(enabled)
|
||||
|
||||
Reference in New Issue
Block a user