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:

View File

@@ -0,0 +1,48 @@
"""UVC camera controls — platform-aware factory."""
from __future__ import annotations
import logging
import platform
from app.camera.uvc.base import UvcControllerBase, UvcParam, UvcParamInfo
logger = logging.getLogger(__name__)
def make_uvc_controller(device_name: str) -> UvcControllerBase:
"""
Return the best available UVC controller for the current platform.
Falls back to NullUvcController if the required native library is absent.
"""
system = platform.system()
if system == "Windows":
try:
from app.camera.uvc.windows import WindowsUvcController # noqa: PLC0415
ctrl = WindowsUvcController(device_name)
logger.info("UVC: Windows controller (duvc-ctl) loaded for '%s'", device_name)
return ctrl
except ImportError:
logger.warning(
"UVC: duvc-ctl not installed — install with: pip install duvc-ctl"
)
elif system == "Darwin":
try:
from app.camera.uvc.macos import MacUvcController # noqa: PLC0415
ctrl = MacUvcController(device_name)
logger.info("UVC: macOS controller loaded for '%s'", device_name)
return ctrl
except ImportError:
logger.warning(
"UVC: pyuvc not installed — UVC controls unavailable on macOS"
)
else:
logger.warning("UVC: platform '%s' not supported — controls unavailable", system)
from app.camera.uvc.stub import NullUvcController # noqa: PLC0415
return NullUvcController()
__all__ = ["make_uvc_controller", "UvcControllerBase", "UvcParam", "UvcParamInfo"]

77
app/camera/uvc/base.py Normal file
View File

@@ -0,0 +1,77 @@
"""UVC controller abstract base class."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum, auto
class UvcParam(Enum):
"""UVC video proc amp controls supported by the ELP camera."""
BRIGHTNESS = auto()
CONTRAST = auto()
SATURATION = auto()
HUE = auto()
SHARPNESS = auto()
GAMMA = auto()
WHITE_BALANCE = auto()
BACKLIGHT_COMPENSATION = auto()
EXPOSURE = auto()
@dataclass
class UvcParamInfo:
"""Range and current value of a single UVC control."""
param: UvcParam
supported: bool
minimum: int = 0
maximum: int = 100
default: int = 50
current: int = 50
step: int = 1
auto_supported: bool = False
auto_enabled: bool = False
class UvcControllerBase(ABC):
"""
Abstract interface for platform-specific UVC camera controls.
All set_* methods are best-effort: if a control is unsupported the
call should silently do nothing (log at DEBUG level).
"""
@abstractmethod
def open(self, device_name: str) -> bool:
"""Open connection to the named camera device. Returns True on success."""
@abstractmethod
def close(self) -> None:
"""Release the camera device handle."""
@abstractmethod
def is_open(self) -> bool:
"""Return True if the device is currently open."""
@abstractmethod
def get_param_info(self, param: UvcParam) -> UvcParamInfo:
"""Return range + current value for the given control."""
@abstractmethod
def get_all_params(self) -> list[UvcParamInfo]:
"""Return info for all known UVC controls."""
@abstractmethod
def set_value(self, param: UvcParam, value: int) -> bool:
"""
Set a control to an integer value.
Returns True if the call succeeded.
"""
@abstractmethod
def set_auto(self, param: UvcParam, enabled: bool) -> bool:
"""
Enable/disable auto mode for a control (e.g. auto white balance).
Returns True if supported and the call succeeded.
"""

172
app/camera/uvc/macos.py Normal file
View File

@@ -0,0 +1,172 @@
"""macOS UVC controller — backed by pyuvc (libuvc bindings)."""
from __future__ import annotations
import logging
from app.camera.uvc.base import UvcControllerBase, UvcParam, UvcParamInfo
from app.camera.uvc.stub import NullUvcController
logger = logging.getLogger(__name__)
# pyuvc provides access to UVC controls via libuvc.
# Install: pip install pyuvc (requires libusb + libjpeg-turbo via brew)
#
# If pyuvc is not installed, fall back silently to NullUvcController.
try:
import uvc # type: ignore[import-untyped]
_PYUVC_AVAILABLE = True
except ImportError:
_PYUVC_AVAILABLE = False
logger.debug("pyuvc not available — macOS UVC controls disabled")
# Map UvcParam → pyuvc control name string
_CONTROL_NAME: 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 temperature",
UvcParam.BACKLIGHT_COMPENSATION: "Backlight Compensation",
UvcParam.EXPOSURE: "Absolute Exposure Time",
}
class MacUvcController(UvcControllerBase):
"""
UVC camera controls on macOS via pyuvc / libuvc.
pyuvc: https://github.com/pupil-labs/pyuvc
Install: pip install pyuvc (requires: brew install libusb jpeg-turbo)
"""
def __init__(self, device_name: str) -> None:
if not _PYUVC_AVAILABLE:
raise ImportError("pyuvc not installed")
self._device_name = device_name
self._cap: object | None = None
self._controls: dict[str, object] = {}
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def open(self, device_name: str) -> bool:
self.close()
if not _PYUVC_AVAILABLE:
return False
try:
device_list = uvc.device_list()
target = None
for d in device_list:
if device_name.lower() in d.get("name", "").lower():
target = d
break
if target is None and device_list:
target = device_list[0]
logger.warning(
"UVC: camera '%s' not found by name, using '%s'",
device_name, target.get("name"),
)
if target is None:
logger.warning("UVC: no UVC devices found on macOS")
return False
self._cap = uvc.Capture(target["uid"])
# Index controls by name for fast lookup
self._controls = {c.display_name: c for c in self._cap.controls}
logger.info(
"UVC: opened '%s', controls: %s",
target.get("name"), list(self._controls.keys()),
)
return True
except Exception as exc:
logger.warning("UVC macOS open failed: %s", exc)
self._cap = None
return False
def close(self) -> None:
if self._cap is not None:
try:
self._cap.close()
except Exception:
pass
self._cap = None
self._controls.clear()
def is_open(self) -> bool:
return self._cap is not None
# ------------------------------------------------------------------
# Query
# ------------------------------------------------------------------
def get_param_info(self, param: UvcParam) -> UvcParamInfo:
ctrl_name = _CONTROL_NAME.get(param)
if ctrl_name is None or ctrl_name not in self._controls:
return UvcParamInfo(param=param, supported=False)
try:
ctrl = self._controls[ctrl_name]
auto_supported = hasattr(ctrl, "auto_mode")
auto_enabled = bool(ctrl.auto_mode) if auto_supported else False
return UvcParamInfo(
param=param,
supported=True,
minimum=int(ctrl.min_val),
maximum=int(ctrl.max_val),
default=int(ctrl.def_val),
current=int(ctrl.value),
step=int(getattr(ctrl, "step", 1)),
auto_supported=auto_supported,
auto_enabled=auto_enabled,
)
except Exception as exc:
logger.debug("UVC get_param_info(%s): %s", param.name, exc)
return UvcParamInfo(param=param, supported=False)
def get_all_params(self) -> list[UvcParamInfo]:
return [self.get_param_info(p) for p in UvcParam]
# ------------------------------------------------------------------
# Set
# ------------------------------------------------------------------
def set_value(self, param: UvcParam, value: int) -> bool:
ctrl_name = _CONTROL_NAME.get(param)
if ctrl_name is None or ctrl_name not in self._controls:
return False
try:
self._controls[ctrl_name].value = value
logger.debug("UVC set %s = %d", param.name, value)
return True
except Exception as exc:
logger.warning("UVC set_value(%s, %d): %s", param.name, value, exc)
return False
def set_auto(self, param: UvcParam, enabled: bool) -> bool:
ctrl_name = _CONTROL_NAME.get(param)
if ctrl_name is None or ctrl_name not in self._controls:
return False
try:
ctrl = self._controls[ctrl_name]
if hasattr(ctrl, "auto_mode"):
ctrl.auto_mode = enabled
logger.debug("UVC auto %s = %s", param.name, enabled)
return True
return False
except Exception as exc:
logger.warning("UVC set_auto(%s, %s): %s", param.name, enabled, exc)
return False
def make_mac_uvc_controller(device_name: str) -> UvcControllerBase:
"""Factory: returns MacUvcController or NullUvcController if pyuvc absent."""
if not _PYUVC_AVAILABLE:
return NullUvcController()
ctrl = MacUvcController(device_name)
if ctrl.open(device_name):
return ctrl
return NullUvcController()

43
app/camera/uvc/stub.py Normal file
View File

@@ -0,0 +1,43 @@
"""Null (no-op) UVC controller — used when no native library is available."""
from __future__ import annotations
import logging
from app.camera.uvc.base import UvcControllerBase, UvcParam, UvcParamInfo
logger = logging.getLogger(__name__)
class NullUvcController(UvcControllerBase):
"""
Fallback controller that reports all controls as unsupported.
Used on platforms where no UVC library is installed or when device
enumeration fails.
"""
def open(self, device_name: str) -> bool: # noqa: ARG002
return False
def close(self) -> None:
pass
def is_open(self) -> bool:
return False
def get_param_info(self, param: UvcParam) -> UvcParamInfo:
return UvcParamInfo(param=param, supported=False)
def get_all_params(self) -> list[UvcParamInfo]:
return [UvcParamInfo(param=p, supported=False) for p in UvcParam]
def set_value(self, param: UvcParam, value: int) -> bool: # noqa: ARG002
logger.debug("NullUvcController: set_value ignored (%s=%d)", param.name, value)
return False
def set_auto(self, param: UvcParam, enabled: bool) -> bool: # noqa: ARG002
logger.debug(
"NullUvcController: set_auto ignored (%s, auto=%s)", param.name, enabled
)
return False

175
app/camera/uvc/windows.py Normal file
View File

@@ -0,0 +1,175 @@
"""Windows UVC controller — backed by duvc-ctl (DirectShow)."""
from __future__ import annotations
import logging
import duvc_ctl as duvc # type: ignore[import-untyped] # noqa: PLC0415 (lazy import)
from app.camera.uvc.base import UvcControllerBase, UvcParam, UvcParamInfo
logger = logging.getLogger(__name__)
# Map our UvcParam → duvc_ctl VidProp string name
_VIDPROP: 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",
UvcParam.BACKLIGHT_COMPENSATION: "backlight_compensation",
}
# Map UvcParam → duvc_ctl CamProp string for camera-side controls
_CAMPROP: dict[UvcParam, str] = {
UvcParam.EXPOSURE: "exposure",
}
class WindowsUvcController(UvcControllerBase):
"""
UVC camera controls on Windows via duvc-ctl (DirectShow).
duvc-ctl docs: https://allanhanan.github.io/duvc-ctl/
"""
def __init__(self, device_name: str) -> None:
self._device_name = device_name
self._cam: duvc.CameraController | None = None
self._supported: dict[UvcParam, bool] = {}
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def open(self, device_name: str) -> bool:
self.close()
try:
devices = duvc.list_devices()
target = None
for d in devices:
if device_name.lower() in d.name.lower() or d.name.lower() in device_name.lower():
target = d
break
if target is None and devices:
target = devices[0] # fallback to first device
logger.warning(
"UVC: camera '%s' not found by name, using '%s'",
device_name, target.name,
)
if target is None:
logger.warning("UVC: no devices found on Windows")
return False
self._cam = duvc.CameraController(target)
self._cam.__enter__()
self._refresh_supported()
logger.info("UVC: opened device '%s'", target.name)
return True
except Exception as exc:
logger.warning("UVC: failed to open device: %s", exc)
self._cam = None
return False
def close(self) -> None:
if self._cam is not None:
try:
self._cam.__exit__(None, None, None)
except Exception:
pass
self._cam = None
self._supported.clear()
def is_open(self) -> bool:
return self._cam is not None
# ------------------------------------------------------------------
# Query
# ------------------------------------------------------------------
def get_param_info(self, param: UvcParam) -> UvcParamInfo:
if not self.is_open() or not self._supported.get(param, False):
return UvcParamInfo(param=param, supported=False)
try:
prop_name = _VIDPROP.get(param) or _CAMPROP.get(param)
if prop_name is None:
return UvcParamInfo(param=param, supported=False)
rng = self._cam.get_property_range(prop_name)
current = getattr(self._cam, prop_name, rng.get("default", 0))
auto_supported = param in (UvcParam.WHITE_BALANCE, UvcParam.EXPOSURE)
return UvcParamInfo(
param=param,
supported=True,
minimum=int(rng.get("min", 0)),
maximum=int(rng.get("max", 100)),
default=int(rng.get("default", 50)),
current=int(current),
step=int(rng.get("step", 1)),
auto_supported=auto_supported,
)
except Exception as exc:
logger.debug("UVC get_param_info(%s): %s", param.name, exc)
return UvcParamInfo(param=param, supported=False)
def get_all_params(self) -> list[UvcParamInfo]:
return [self.get_param_info(p) for p in UvcParam]
# ------------------------------------------------------------------
# Set
# ------------------------------------------------------------------
def set_value(self, param: UvcParam, value: int) -> bool:
if not self.is_open():
return False
try:
prop_name = _VIDPROP.get(param) or _CAMPROP.get(param)
if prop_name is None:
return False
setattr(self._cam, prop_name, value)
logger.debug("UVC set %s = %d", param.name, value)
return True
except Exception as exc:
logger.warning("UVC set_value(%s, %d): %s", param.name, value, exc)
return False
def set_auto(self, param: UvcParam, enabled: bool) -> bool:
if not self.is_open():
return False
try:
if param == UvcParam.WHITE_BALANCE:
prop = "white_balance"
elif param == UvcParam.EXPOSURE:
prop = "exposure"
else:
return False
mode = duvc.CamMode.AUTO if enabled else duvc.CamMode.MANUAL
self._cam.set_camera_property(prop, mode=mode)
logger.debug("UVC auto %s = %s", param.name, enabled)
return True
except Exception as exc:
logger.warning("UVC set_auto(%s, %s): %s", param.name, enabled, exc)
return False
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _refresh_supported(self) -> None:
if self._cam is None:
return
try:
info = self._cam.get_supported_properties()
vid = set(info.get("video", []))
cam = set(info.get("camera", []))
for param in UvcParam:
prop = _VIDPROP.get(param) or _CAMPROP.get(param, "")
self._supported[param] = prop in vid or prop in cam
except Exception as exc:
logger.debug("UVC _refresh_supported: %s", exc)
for param in UvcParam:
self._supported[param] = False