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:
2026-05-13 19:19:39 +02:00
parent d62416db4e
commit cdeac53555
12 changed files with 1756 additions and 118 deletions

View 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)