diff --git a/app/camera/camera_service.py b/app/camera/camera_service.py index af3720d..b5b70fc 100644 --- a/app/camera/camera_service.py +++ b/app/camera/camera_service.py @@ -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: diff --git a/app/camera/uvc/__init__.py b/app/camera/uvc/__init__.py new file mode 100644 index 0000000..5cabb5f --- /dev/null +++ b/app/camera/uvc/__init__.py @@ -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"] diff --git a/app/camera/uvc/base.py b/app/camera/uvc/base.py new file mode 100644 index 0000000..d568bab --- /dev/null +++ b/app/camera/uvc/base.py @@ -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. + """ diff --git a/app/camera/uvc/macos.py b/app/camera/uvc/macos.py new file mode 100644 index 0000000..fe53c44 --- /dev/null +++ b/app/camera/uvc/macos.py @@ -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() diff --git a/app/camera/uvc/stub.py b/app/camera/uvc/stub.py new file mode 100644 index 0000000..c47359d --- /dev/null +++ b/app/camera/uvc/stub.py @@ -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 diff --git a/app/camera/uvc/windows.py b/app/camera/uvc/windows.py new file mode 100644 index 0000000..1524e69 --- /dev/null +++ b/app/camera/uvc/windows.py @@ -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 diff --git a/app/ui/camera_settings_dialog.py b/app/ui/camera_settings_dialog.py new file mode 100644 index 0000000..f5bcee8 --- /dev/null +++ b/app/ui/camera_settings_dialog.py @@ -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) diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 591829a..6c6cbb8 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -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 diff --git a/app/ui/menu_bar.py b/app/ui/menu_bar.py index b3cddef..fa65051 100644 --- a/app/ui/menu_bar.py +++ b/app/ui/menu_bar.py @@ -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__) @@ -19,27 +19,26 @@ class AppMenuBar(QMenuBar): Application menu bar. Signals: - camera_selected(CameraInfo) — user picked a camera - resolution_selected(int, int) — user picked (width, height) - fps_selected(float) — user picked a target FPS - reconnect_requested() — user hit Reconnect - overlay_toggled(bool) — overlay show/hide - log_toggled(bool) — console logging on/off + camera_selected(CameraInfo) — user picked a camera + 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) diff --git a/notes/03-mvp-summary.md b/notes/03-mvp-summary.md new file mode 100644 index 0000000..0f5907f --- /dev/null +++ b/notes/03-mvp-summary.md @@ -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 0–7 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_.log +logs/duck-preview_.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 diff --git a/notes/camera_elp.md b/notes/camera_elp.md new file mode 100644 index 0000000..2bf170e --- /dev/null +++ b/notes/camera_elp.md @@ -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 \ No newline at end of file diff --git a/tests/test_uvc.py b/tests/test_uvc.py new file mode 100644 index 0000000..d562f76 --- /dev/null +++ b/tests/test_uvc.py @@ -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)