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)
|
||||
Reference in New Issue
Block a user