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