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

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import platform
from PySide6.QtCore import QObject, Signal
from PySide6.QtMultimedia import (
@@ -12,8 +13,7 @@ from PySide6.QtMultimedia import (
QVideoSink,
)
from app.camera.camera_enumerator import CameraInfo, pixel_format_name
from app.config import DEFAULT_FPS, DEFAULT_HEIGHT, DEFAULT_WIDTH
from app.camera.camera_enumerator import CameraFormat, CameraInfo, pixel_format_name
logger = logging.getLogger(__name__)
@@ -46,9 +46,7 @@ class CameraService(QObject):
self._session_logged: bool = False
# Desired format — applied on every (re)start
self._desired_width: int = DEFAULT_WIDTH
self._desired_height: int = DEFAULT_HEIGHT
self._desired_fps: float = float(DEFAULT_FPS)
self._desired_fmt: CameraFormat | None = None
self._session.setVideoSink(self._sink)
self._sink.videoFrameChanged.connect(self._on_frame)
@@ -84,19 +82,17 @@ class CameraService(QObject):
else:
logger.warning("Reconnect requested but no camera was previously started")
def set_resolution(self, width: int, height: int) -> None:
"""Request a new resolution — restarts camera to apply reliably."""
self._desired_width = width
self._desired_height = height
def set_format(self, fmt: CameraFormat) -> None:
"""
Request a specific video format (resolution + fps + pixel format).
Restarts the camera to apply the change reliably.
"""
self._desired_fmt = fmt
if self._current_info is not None:
logger.info("Resolution change: %dx%d — restarting camera", width, height)
self.start(self._current_info)
def set_fps(self, fps: float) -> None:
"""Request a new frame rate — restarts camera to apply reliably."""
self._desired_fps = fps
if self._current_info is not None:
logger.info("FPS change: %.1f — restarting camera", fps)
logger.info(
"Format change: %dx%d @ %.4g fps (%s) — restarting camera",
fmt.width, fmt.height, fmt.max_fps, fmt.pixel_format,
)
self.start(self._current_info)
@property
@@ -107,6 +103,11 @@ class CameraService(QObject):
def current_camera(self) -> CameraInfo | None:
return self._current_info
@property
def qt_camera(self) -> QCamera | None:
"""Expose underlying QCamera for settings dialog."""
return self._camera
def video_sink(self) -> QVideoSink:
return self._sink
@@ -133,26 +134,35 @@ class CameraService(QObject):
best = None
best_score = -1
for fmt in self._current_info.device.videoFormats():
res = fmt.resolution()
w, h = res.width(), res.height()
f = fmt.maxFrameRate()
desired = self._desired_fmt
for qfmt in self._current_info.device.videoFormats():
res = qfmt.resolution()
w, h = res.width(), res.height()
f = qfmt.maxFrameRate()
pf = pixel_format_name(qfmt.pixelFormat())
if desired is not None:
# Exact match preferred
exact_res = int(w == desired.width and h == desired.height) * 1000
exact_fps = int(abs(f - desired.max_fps) < 0.5) * 100
exact_pf = int(pf == desired.pixel_format) * 500
area_diff = abs(w * h - desired.width * desired.height)
score = exact_res + exact_fps + exact_pf - area_diff
else:
# No preference — pick largest area, then highest fps
score = w * h + int(f)
score = (
int(w == self._desired_width and h == self._desired_height) * 1000
+ int(abs(f - self._desired_fps) < 1) * 100
- abs(w * h - self._desired_width * self._desired_height)
)
if score > best_score:
best_score = score
best = fmt
best = qfmt
if best is not None:
self._camera.setCameraFormat(best)
res = best.resolution()
pf = pixel_format_name(best.pixelFormat())
logger.info(
"Camera format requested: %s %dx%d @ %.1f fps",
"Camera format requested: %s %dx%d @ %.4g fps",
pf, res.width(), res.height(), best.maxFrameRate(),
)
@@ -166,28 +176,31 @@ class CameraService(QObject):
pf = pixel_format_name(fmt.pixelFormat())
logger.info(
"Camera format ACTUAL: %s %dx%d @ %.1f fps",
"Camera format ACTUAL: %s %dx%d @ %.4g fps",
pf, res.width(), res.height(), actual_fps,
)
if abs(actual_fps - self._desired_fps) > 0.5:
logger.warning(
"Requested %.1f fps but camera delivering %.1f fps "
"(camera may not support this resolution+fps combination)",
self._desired_fps, actual_fps,
)
if self._desired_fmt is not None:
d = self._desired_fmt
if abs(actual_fps - d.max_fps) > 0.5:
logger.warning(
"Requested %.4g fps but camera delivering %.4g fps",
d.max_fps, actual_fps,
)
if res.width() != d.width or res.height() != d.height:
logger.warning(
"Requested %dx%d but camera delivering %dx%d",
d.width, d.height, res.width(), res.height(),
)
self.format_changed.emit(actual_fps)
def _log_qt_backend(self) -> None:
"""Log the Qt multimedia backend in use (once per session)."""
try:
# QMediaDevices doesn't expose backend name directly in Qt6,
# but we can infer it from platform
import platform # noqa: PLC0415
system = platform.system()
backend = {
"Darwin": "AVFoundation",
"Windows": "Media Foundation (DirectShow fallback)",
"Linux": "GStreamer / V4L2",
"Darwin": "AVFoundation",
"Windows": "Media Foundation / DirectShow",
"Linux": "GStreamer / V4L2",
}.get(system, system)
logger.info("Qt multimedia backend: %s", backend)
except Exception: