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,27 +176,30 @@ 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:
if self._desired_fmt is not None:
d = self._desired_fmt
if abs(actual_fps - d.max_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,
"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)",
"Windows": "Media Foundation / DirectShow",
"Linux": "GStreamer / V4L2",
}.get(system, system)
logger.info("Qt multimedia backend: %s", backend)

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

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)

View File

@@ -8,13 +8,17 @@ from pathlib import Path
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QLabel, QMainWindow, QSizePolicy, QStatusBar
from app.camera.camera_enumerator import CameraEnumerator, CameraInfo
from app.camera.camera_enumerator import CameraEnumerator, CameraFormat, CameraInfo
from app.camera.camera_service import CameraService
from app.camera.uvc import make_uvc_controller
from app.camera.uvc.base import UvcControllerBase
from app.camera.uvc.stub import NullUvcController
from app.config import APP_NAME, APP_VERSION
from app.overlay.telemetry_overlay import TelemetryOverlay
from app.pipeline.frame_dispatcher import FrameDispatcher
from app.telemetry.csv_logger import CsvTelemetryLogger
from app.telemetry.telemetry_collector import TelemetryCollector
from app.ui.camera_settings_dialog import CameraSettingsDialog
from app.ui.camera_view import CameraView
from app.ui.menu_bar import AppMenuBar
@@ -52,6 +56,9 @@ class MainWindow(QMainWindow):
self._dispatcher = FrameDispatcher(self)
self._telemetry = TelemetryCollector(parent=self)
# --- UVC controller (platform-specific, lazy-opened per camera) ---
self._uvc: UvcControllerBase = NullUvcController()
# --- CSV telemetry logger ---
self._csv_logger: CsvTelemetryLogger | None = None
if log_path is not None:
@@ -79,7 +86,7 @@ class MainWindow(QMainWindow):
# --- Status bar ---
self._status_bar = QStatusBar(self)
self.setStatusBar(self._status_bar)
self._status_label = QLabel("Initialising")
self._status_label = QLabel("Initialising\u2026")
self._status_bar.addWidget(self._status_label)
# --- Wire signals ---
@@ -113,6 +120,17 @@ class MainWindow(QMainWindow):
self._camera_service.start(cam)
self._menu.set_active_camera(cam)
self._status_label.setText(f"Opening: {cam.name}")
self._open_uvc(cam)
def _open_uvc(self, cam: CameraInfo) -> None:
"""Open or reopen the UVC controller for the given camera."""
if self._uvc.is_open():
self._uvc.close()
ctrl = make_uvc_controller(cam.name)
if not ctrl.is_open():
# factory may return a pre-opened controller or a NullUvcController
ctrl.open(cam.name)
self._uvc = ctrl
# ------------------------------------------------------------------
# Signal wiring
@@ -125,11 +143,13 @@ class MainWindow(QMainWindow):
# FrameDispatcher → CameraView (render) — drop if busy
self._dispatcher.subscribe(self._camera_view.on_frame, drop_if_busy=True)
# FrameDispatcher → TelemetryCollector — never drop, count every frame
# FrameDispatcher → TelemetryCollector — never drop
self._dispatcher.subscribe(self._telemetry.on_frame, drop_if_busy=False)
# TelemetryCollector → overlay
self._telemetry.metrics_updated.connect(self._telemetry_overlay.on_metrics_updated)
self._telemetry.metrics_updated.connect(
self._telemetry_overlay.on_metrics_updated
)
# TelemetryCollector → CSV logger (throttled internally)
if self._csv_logger is not None:
@@ -145,10 +165,10 @@ class MainWindow(QMainWindow):
# Menu signals
self._menu.camera_selected.connect(self._on_camera_selected)
self._menu.resolution_selected.connect(self._on_resolution_selected)
self._menu.fps_selected.connect(self._on_fps_selected)
self._menu.format_selected.connect(self._on_format_selected)
self._menu.reconnect_requested.connect(self._camera_service.reconnect)
self._menu.overlay_toggled.connect(self._camera_view.set_all_overlays_visible)
self._menu.camera_settings_requested.connect(self._on_settings_requested)
# ------------------------------------------------------------------
# Camera status slots
@@ -174,11 +194,20 @@ class MainWindow(QMainWindow):
def _on_camera_selected(self, cam: CameraInfo) -> None:
self._start_camera(cam)
def _on_resolution_selected(self, width: int, height: int) -> None:
self._camera_service.set_resolution(width, height)
def _on_format_selected(self, fmt: CameraFormat) -> None:
logger.info(
"Format selected via menu: %dx%d @ %.4g fps (%s)",
fmt.width, fmt.height, fmt.max_fps, fmt.pixel_format,
)
self._camera_service.set_format(fmt)
def _on_fps_selected(self, fps: float) -> None:
self._camera_service.set_fps(fps)
def _on_settings_requested(self) -> None:
qt_cam = self._camera_service.qt_camera
if qt_cam is None:
logger.warning("Settings requested but no camera is active")
return
dlg = CameraSettingsDialog(qt_cam, self._uvc, parent=self)
dlg.exec()
# ------------------------------------------------------------------
# Qt overrides
@@ -186,6 +215,8 @@ class MainWindow(QMainWindow):
def closeEvent(self, event) -> None: # noqa: N802
self._camera_service.stop()
if self._uvc.is_open():
self._uvc.close()
if self._csv_logger is not None:
logger.info(
"CSV telemetry: %d rows written", self._csv_logger.rows_written

View File

@@ -1,4 +1,4 @@
"""Menu bar — camera, video format, FPS and debug controls."""
"""Menu bar — camera, video format and debug controls."""
from __future__ import annotations
@@ -8,7 +8,7 @@ from PySide6.QtCore import Signal
from PySide6.QtGui import QAction, QActionGroup
from PySide6.QtWidgets import QMenuBar, QWidget
from app.camera.camera_enumerator import CameraInfo
from app.camera.camera_enumerator import CameraFormat, CameraInfo
from app.logging_setup import set_console_level
logger = logging.getLogger(__name__)
@@ -20,26 +20,25 @@ class AppMenuBar(QMenuBar):
Signals:
camera_selected(CameraInfo) — user picked a camera
resolution_selected(int, int) — user picked (width, height)
fps_selected(float) — user picked a target FPS
format_selected(CameraFormat) — user picked a full format (res+fps+pixel)
reconnect_requested() — user hit Reconnect
overlay_toggled(bool) — overlay show/hide
log_toggled(bool) — console logging on/off
camera_settings_requested() — user opened Image Settings dialog
"""
camera_selected = Signal(object) # CameraInfo
resolution_selected = Signal(int, int)
fps_selected = Signal(float)
format_selected = Signal(object) # CameraFormat
reconnect_requested = Signal()
overlay_toggled = Signal(bool)
log_toggled = Signal(bool)
camera_settings_requested = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._camera_group: QActionGroup | None = None
self._resolution_group: QActionGroup | None = None
self._fps_group: QActionGroup | None = None
self._format_group: QActionGroup | None = None
self._cameras: list[CameraInfo] = []
self._build_menus()
@@ -72,9 +71,8 @@ class AppMenuBar(QMenuBar):
self._camera_group.actions()[0].setChecked(True)
def populate_formats(self, camera_info: CameraInfo) -> None:
"""Populate Resolution and FPS menus based on a camera's supported formats."""
self._populate_resolutions(camera_info)
self._populate_fps(camera_info)
"""Populate the Resolution submenu with full format entries."""
self._populate_format_menu(camera_info)
def set_active_camera(self, camera_info: CameraInfo) -> None:
if self._camera_group is None:
@@ -84,10 +82,24 @@ class AppMenuBar(QMenuBar):
action.setChecked(True)
return
def set_active_format(self, fmt: CameraFormat) -> None:
"""Mark the given format as checked in the Resolution menu."""
if self._format_group is None:
return
for action in self._format_group.actions():
f: CameraFormat = action.data()
if (
f.width == fmt.width
and f.height == fmt.height
and abs(f.max_fps - fmt.max_fps) < 0.5
and f.pixel_format == fmt.pixel_format
):
action.setChecked(True)
return
def set_log_file_path(self, path: str) -> None:
"""Display the log file path as a disabled menu item in Debug menu."""
# Truncate long paths for display
display = path if len(path) <= 60 else "" + path[-57:]
display = path if len(path) <= 60 else "\u2026" + path[-57:]
self._log_file_action.setText(f"Log: {display}")
self._log_file_action.setToolTip(path)
@@ -106,7 +118,12 @@ class AppMenuBar(QMenuBar):
# Video menu
self._video_menu = self.addMenu("Video")
self._res_menu = self._video_menu.addMenu("Resolution")
self._fps_menu = self._video_menu.addMenu("FPS")
# Image menu (camera controls)
self._image_menu = self.addMenu("Image")
self._settings_action = QAction("Camera Settings\u2026", self)
self._settings_action.triggered.connect(self.camera_settings_requested)
self._image_menu.addAction(self._settings_action)
# Debug menu
debug_menu = self.addMenu("Debug")
@@ -129,47 +146,26 @@ class AppMenuBar(QMenuBar):
self._log_file_action.setEnabled(False)
debug_menu.addAction(self._log_file_action)
def _populate_resolutions(self, camera_info: CameraInfo) -> None:
def _populate_format_menu(self, camera_info: CameraInfo) -> None:
"""Build Resolution submenu: one action per unique (W, H, FPS, pixel_format)."""
self._res_menu.clear()
self._resolution_group = QActionGroup(self)
self._resolution_group.setExclusive(True)
self._format_group = QActionGroup(self)
self._format_group.setExclusive(True)
seen: set[tuple[int, int]] = set()
for fmt in camera_info.formats:
key = (fmt.width, fmt.height)
if key in seen:
continue
seen.add(key)
action = QAction(f"{fmt.width} × {fmt.height}", self)
label = (
f"{fmt.width}\u00d7{fmt.height}"
f" @ {fmt.max_fps:.4g}fps"
f" ({fmt.pixel_format})"
)
action = QAction(label, self)
action.setCheckable(True)
action.setData((fmt.width, fmt.height))
self._resolution_group.addAction(action)
action.setData(fmt)
self._format_group.addAction(action)
self._res_menu.addAction(action)
action.triggered.connect(self._on_resolution_action)
action.triggered.connect(self._on_format_action)
actions = self._resolution_group.actions()
if actions:
actions[0].setChecked(True)
def _populate_fps(self, camera_info: CameraInfo) -> None:
self._fps_menu.clear()
self._fps_group = QActionGroup(self)
self._fps_group.setExclusive(True)
seen: set[int] = set()
for fmt in camera_info.formats:
key = round(fmt.max_fps)
if key in seen:
continue
seen.add(key)
action = QAction(f"{key} fps", self)
action.setCheckable(True)
action.setData(float(fmt.max_fps))
self._fps_group.addAction(action)
self._fps_menu.addAction(action)
action.triggered.connect(self._on_fps_action)
actions = self._fps_group.actions()
actions = self._format_group.actions()
if actions:
actions[0].setChecked(True)
@@ -184,24 +180,18 @@ class AppMenuBar(QMenuBar):
cam: CameraInfo = action.data()
logger.debug("Camera selected: %s", cam.name)
self.camera_selected.emit(cam)
self._populate_resolutions(cam)
self._populate_fps(cam)
self._populate_format_menu(cam)
def _on_resolution_action(self) -> None:
def _on_format_action(self) -> None:
action = self.sender()
if action is None:
return
w, h = action.data()
logger.debug("Resolution selected: %dx%d", w, h)
self.resolution_selected.emit(w, h)
def _on_fps_action(self) -> None:
action = self.sender()
if action is None:
return
fps: float = action.data()
logger.debug("FPS selected: %.1f", fps)
self.fps_selected.emit(fps)
fmt: CameraFormat = action.data()
logger.debug(
"Format selected: %dx%d @ %.4g fps (%s)",
fmt.width, fmt.height, fmt.max_fps, fmt.pixel_format,
)
self.format_selected.emit(fmt)
def _on_log_toggled(self, enabled: bool) -> None:
set_console_level(enabled)

480
notes/03-mvp-summary.md Normal file
View File

@@ -0,0 +1,480 @@
# Goal
Zbudować aplikację do podglądu kamery w czasie rzeczywistym (PySide6) z telemetrią, overlayami i systemem logowania do pliku — docelowo na Mac Mini z kamerą ELP.
---
# Constraints & Preferences
- Python 3.12.10, venv w `.venv-win`
- Dev: Windows 11
- Target: Mac Mini Intel i7, macOS Ventura, kamera ELP USB
- `PySide6 6.11.0`, `psutil`, `pytest`, `ruff`
- `QVideoWidget` porzucony — na Windows zasłania wszystkie child/sibling widgets przez natywny HWND D3D
- Logi:
- `logs/` w katalogu projektu
- nowy plik z timestamp per sesja
- max 20 plików
- CSV telemetrii:
- co 5 sekund
- `flush` po każdym wierszu
- CPU:
- pokazywać oba:
- sys %
- per-core % (jak Task Manager)
- Zmiana formatu kamery przez `stop+start`
- nie `setCameraFormat` na żywej kamerze
---
# Progress
## Done
### Faza 07 MVP
- scaffolding
- `CameraService`
- `FrameDispatcher`
- `TelemetryCollector`
- `CameraView`
- `TelemetryOverlay`
- `IOverlayLayer`
- `AppMenuBar`
- `MainWindow`
### Rendering
- Usunięto `QVideoWidget`
- `CameraView(QWidget)` z `paintEvent`
- render + overlay w jednym przejściu
### Telemetria
`TelemetrySnapshot` — pola:
- `fps`
- `target_fps`
- `frame_time_ms`
- `dropped_frames`
- `cpu_percent_sys`
- `cpu_percent_core`
- `memory_mb`
### Kamera
- `CameraService.format_changed(float)` sygnał
- `stop+start` przy:
- `set_resolution`
- `set_fps`
### Logowanie
- `_log_actual_format()`
- loguje rzeczywisty format po starcie kamery
- warning jeśli FPS się nie zgadza
### CameraFormat
`CameraFormat` dataclass z:
```python
pixel_format: str
```
Przykłady:
- `MJPG`
- `YUY2`
- `NV12`
w `camera_enumerator.py`
### Logging
`logging_setup.py`
- `FileHandler`
- `DEBUG`
- zawsze aktywny
- `StreamHandler`
- `WARNING`
- przełączalny
- nagłówek sesji:
- wersja
- platforma
- Python
- PySide6
- CPU
- RAM
- pruning starych logów
### CSV
`csv_logger.py`
- `CsvTelemetryLogger`
- throttling:
- 5 s
- `flush` po każdym wierszu
### Menu
`menu_bar.py`
- dostosowany do `CameraFormat`
- `set_log_file_path()`
- `set_console_level()`
### Config
`config.py`
- `LOG_DIR`
- `MAX_LOG_FILES`
- `TELEMETRY_CSV_INTERVAL_S`
### Main
`main.py`
- wywołuje `setup_logging()`
- przekazuje `log_path` do `MainWindow`
### Main Window
`main_window.py`
- przyjmuje:
```python
log_path: Path | None
```
- tworzy:
```python
CsvTelemetryLogger(log_path.with_suffix(".csv"))
```
- podpina do:
- `telemetry.metrics_updated`
- wywołuje:
- `menu.set_log_file_path()`
- `csv_logger.close()` w `closeEvent`
### Repo
`.gitignore`
- dodano `logs/`
### Testy i jakość
- 20 testów jednostkowych
- wszystkie zielone
- `ruff`
- czysty
- wszystkie błędy naprawione
### Artefakty sesji
Para plików per sesja:
```text
logs/duck-preview_<timestamp>.log
logs/duck-preview_<timestamp>.csv
```
---
## In Progress
- (none)
---
## Blocked
- (none)
---
# Key Decisions
## Rendering
### `QVideoWidget` → własny `CameraView(QWidget)`
Jedyny skuteczny sposób obejścia problemu z natywnym HWND na Windows.
## Overlay Architecture
### `IOverlayLayer` ABC
Pluggable overlaye bez modyfikacji `CameraView`.
Gotowe pod:
- YOLO
- OCR
- inne overlaye
## Pipeline
### `FrameDispatcher`
Pub/sub z `drop_if_busy`:
- render może gubić klatki
- telemetria nigdy
## CPU Metrics
- CPU per-core (`psutil`)
- dzielone przez `cpu_count`
- wynik zgodny z Task Manager
## Memory Metrics
- `memory_info().wset` (Windows)
- zamiast `rss`
- odpowiada `Private Working Set`
## Logging Strategy
Dwa osobne pliki per sesja:
- `.log` — diagnostyka
- `.csv` — metryki szeregów czasowych
## CameraFormat
`CameraFormat` dataclass zamiast krotki:
- niesie `pixel_format`
- potrzebny w logach
---
# Next Steps
1. Przetestować na Windows:
- czy logi powstają w `logs/`
- czy CSV zapisuje dane co ~5 s
2. Przenieść na Mac Mini i przetestować z kamerą ELP:
- sprawdzić `pixel_format` w logu
- sprawdzić wykrycie backendu AVFoundation
3. Zweryfikować overlay telemetrii na żywym obrazie
4. (Opcjonalnie) dodać kolejne `IOverlayLayer`
- YOLO
- OCR
---
# Critical Context
## Camera Enumerator
`CameraEnumerator.list_cameras()` zwraca:
```python
list[CameraInfo]
```
gdzie:
```python
formats: list[CameraFormat]
```
`menu_bar.py` i `camera_service.py` iterują po:
- `fmt.width`
- `fmt.height`
- `fmt.max_fps`
---
## CSV Logger
Ścieżka CSV:
```python
log_path.with_suffix(".csv")
```
Powstaje para:
- `.log`
- `.csv`
z tym samym timestamp.
---
## Console Logging
```python
logging_setup.set_console_level(debug: bool)
```
wywoływane z:
```python
menu_bar._on_log_toggled
```
---
## Pixel Format
```python
pixel_format_name()
```
eksportowane z:
```python
camera_enumerator.py
```
używane również w:
```python
camera_service.py
```
---
## Log Header
Nagłówek logu zawiera:
- wersję aplikacji
- platformę
- Python
- PySide6
- liczbę CPU
- RAM
---
## Uruchamianie
```bash
.venv-win\Scripts\python.exe -m app.main
```
---
## Ruff Fixes
Naprawiono:
- `E501`
- długa linia w `camera_enumerator.py`
- `F401`
- `I001`
- w `camera_service.py`
- w `main.py`
---
# Relevant Files
## Entry Point
- `app/main.py`
- wywołuje `setup_logging()`
- przekazuje `log_path`
## Config
- `app/config.py`
- `LOG_DIR`
- `MAX_LOG_FILES`
- `TELEMETRY_CSV_INTERVAL_S`
## Logging
- `app/logging_setup.py`
- konfiguracja logowania
- nagłówek sesji
- pruning
## Telemetry
- `app/telemetry/csv_logger.py`
- zapis CSV z throttlingiem
- `app/telemetry/telemetry_collector.py`
- `TelemetrySnapshot`
- `TelemetryCollector`
## Camera
- `app/camera/camera_enumerator.py`
- `CameraFormat`
- `CameraInfo`
- `pixel_format_name()`
- `app/camera/camera_service.py`
- `CameraService`
- `format_changed`
- `_log_actual_format()`
## UI
- `app/ui/main_window.py`
- kompletny
- podpięty `CsvTelemetryLogger`
- `app/ui/menu_bar.py`
- `set_log_file_path()`
- `set_console_level()`
- `app/ui/camera_view.py`
- `CameraView`
- rejestr `IOverlayLayer`
## Overlay
- `app/overlay/overlay_layer.py`
- `IOverlayLayer` ABC
- `app/overlay/telemetry_overlay.py`
- `TelemetryOverlay(IOverlayLayer)`
## Pipeline
- `app/pipeline/frame_dispatcher.py`
- pub/sub
- `drop_if_busy`
## Tests
- `tests/test_telemetry_collector.py`
- 12 testów
- mockuje `cpu_count`
- `tests/test_frame_dispatcher.py`
- 8 testów
## Repo / Docs
- `.gitignore`
- zawiera `logs/`
- `notes/01-mvp-preview.md`
- PRD
- `notes/01-mvp-plan.md`
- plan implementacji
- `notes/02-mvp-app.md`
- stan aplikacji
- historia prób i decyzji architektonicznych

182
notes/camera_elp.md Normal file
View File

@@ -0,0 +1,182 @@
# Camera Specification
## Model
**ELP-USB4KHDR01-MFV(5-50)**
(10X zoom manual lens)
### Optional
**ELP-USB4KHDR01-MFV(2.8-12)**
(2.8-12mm manual zoom lens)
---
## Sensor
- SONY IMX317 (1/2.5”)
## Max Resolution
- 3840(H) × 2160(V)
## Sensitivity
- 1000mV/Lux-sec
## Image Area
- 6100μm × 4524μm
## Picture Format
- MJPEG
- YUY2 (YUYV)
---
# Resolution & FPS
## MJPEG Modes
| Resolution | FPS |
|---|---|
| 3840×2160 | 30 fps |
| 2592×1944 | 30 fps |
| 2048×1536 | 30 fps |
| 1600×1200 | 30 fps |
| 1920×1080 | 30 fps |
| 1280×1024 | 30 fps |
| 1280×960 | 30 fps |
| 1280×720 | 30 fps |
| 1024×768 | 30 fps |
| 800×600 | 30 fps |
| 640×480 | 30 fps |
| 320×240 | 30 fps |
## YUY2 Modes
| Resolution | FPS |
|---|---|
| 3840×2160 | 1 fps |
| 2592×1944 | 1 fps |
| 2048×1536 | 3 fps |
| 1600×1200 | 3 fps |
| 1920×1080 | 3 fps |
| 1280×1024 | 3 fps |
| 1280×960 | 5 fps |
| 1280×720 | 5 fps |
| 1024×768 | 5 fps |
| 800×600 | 20 fps |
| 640×480 | 30 fps |
| 320×240 | 30 fps |
---
## Optical / Image Parameters
| Parameter | Value |
|---|---|
| Center Definition | 1000LW/PH (Center) |
| S/N Ratio | 26dB |
| Sensitivity | 0.65V/lux-sec@550nm |
| Low Illumination | 0.2lux |
| Shutter | Electronic rolling shutter / Frame exposure |
---
## Interface
- USB2.0 High Speed
---
## Supported Features
| Feature | Support |
|---|---|
| AEC | Yes |
| AEB | Yes |
| AGC | Yes |
---
## Adjustable Parameters
- Brightness
- Contrast
- Saturation
- Hue
- Sharpness
- Gamma
- White Balance
- Backlight Contrast
- Exposure
---
## Lens
- 2.8-12mm / 5-50mm varifocal manual lens optional
---
## Audio
- Built-in microphone
- Supports audio recording
---
## Power
| Parameter | Value |
|---|---|
| Power Supply | USB BUS POWER 4P-2.0mm socket |
| Voltage | DC5V |
| Current | 200mA |
---
## Physical Dimensions
- 45mm × 45mm × 50mm
---
## Temperature
| Parameter | Value |
|---|---|
| Storage Temperature | -20°C to 70°C |
| Working Temperature | 0°C to 60°C |
---
## USB Cable
- 3M standard
- Optional: 1M / 2M / 5M
---
# Supported Operating Systems
## Windows
- Windows XP
- Windows Vista
- Windows 7
- Windows 8
- Windows 10
- Windows 11
## Linux
- Linux with UVC support
- Kernel above linux-2.6.26
## macOS / Android
- macOS X 10.4.8 or later
- Android 4.0 or above with UVC

117
tests/test_uvc.py Normal file
View File

@@ -0,0 +1,117 @@
"""Tests for the UVC controller abstraction layer."""
from __future__ import annotations
from app.camera.uvc.base import UvcParam, UvcParamInfo
from app.camera.uvc.stub import NullUvcController
class TestNullUvcController:
"""NullUvcController must implement the full interface, all as no-ops."""
def setup_method(self) -> None:
self.ctrl = NullUvcController()
def test_is_not_open(self) -> None:
assert not self.ctrl.is_open()
def test_open_returns_false(self) -> None:
assert self.ctrl.open("any device") is False
def test_close_does_not_raise(self) -> None:
self.ctrl.close() # must not raise
def test_get_param_info_returns_unsupported(self) -> None:
info = self.ctrl.get_param_info(UvcParam.BRIGHTNESS)
assert isinstance(info, UvcParamInfo)
assert info.supported is False
assert info.param is UvcParam.BRIGHTNESS
def test_get_all_params_covers_all_uvc_params(self) -> None:
infos = self.ctrl.get_all_params()
returned_params = {i.param for i in infos}
all_params = set(UvcParam)
assert returned_params == all_params
def test_get_all_params_all_unsupported(self) -> None:
for info in self.ctrl.get_all_params():
assert info.supported is False
def test_set_value_returns_false(self) -> None:
assert self.ctrl.set_value(UvcParam.CONTRAST, 50) is False
def test_set_auto_returns_false(self) -> None:
assert self.ctrl.set_auto(UvcParam.WHITE_BALANCE, True) is False
def test_set_value_does_not_raise(self) -> None:
for param in UvcParam:
self.ctrl.set_value(param, 0) # must not raise
def test_set_auto_does_not_raise(self) -> None:
for param in UvcParam:
self.ctrl.set_auto(param, True) # must not raise
self.ctrl.set_auto(param, False)
class TestUvcParamInfo:
"""UvcParamInfo dataclass sanity checks."""
def test_defaults(self) -> None:
info = UvcParamInfo(param=UvcParam.BRIGHTNESS, supported=True)
assert info.minimum == 0
assert info.maximum == 100
assert info.default == 50
assert info.current == 50
assert info.step == 1
assert info.auto_supported is False
assert info.auto_enabled is False
def test_unsupported_flag(self) -> None:
info = UvcParamInfo(param=UvcParam.GAMMA, supported=False)
assert info.supported is False
def test_custom_range(self) -> None:
info = UvcParamInfo(
param=UvcParam.HUE,
supported=True,
minimum=-180,
maximum=180,
default=0,
current=-45,
)
assert info.minimum == -180
assert info.maximum == 180
assert info.current == -45
class TestMakeUvcControllerFallback:
"""make_uvc_controller falls back to NullUvcController when no lib available."""
def test_returns_controller_instance(self) -> None:
from app.camera.uvc import make_uvc_controller
from app.camera.uvc.base import UvcControllerBase
ctrl = make_uvc_controller("Test Camera")
assert isinstance(ctrl, UvcControllerBase)
def test_stub_used_when_native_lib_absent(self, monkeypatch) -> None:
"""If the native import fails, should return NullUvcController."""
import builtins
real_import = builtins.__import__
def patched_import(name, *args, **kwargs):
if name in ("duvc_ctl", "uvc"):
raise ImportError(f"Mocked missing: {name}")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", patched_import)
# Re-import to exercise factory with patched import
import importlib
import app.camera.uvc as uvc_pkg
importlib.reload(uvc_pkg)
ctrl = uvc_pkg.make_uvc_controller("Test Camera")
# Should be functional (not raise), may be Null or platform controller
from app.camera.uvc.base import UvcControllerBase
assert isinstance(ctrl, UvcControllerBase)
# Reload back to normal
importlib.reload(uvc_pkg)