Compare commits

..

12 Commits

Author SHA1 Message Date
ac51498b7a doc: add note about last changes 2026-05-13 19:46:06 +02:00
cdeac53555 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.
2026-05-13 19:19:39 +02:00
d62416db4e feat: implement logging setup and CSV telemetry logging for performance metrics 2026-05-12 22:15:50 +02:00
aec286c5ec feat: enhance telemetry metrics with target FPS tracking and logging 2026-05-12 21:49:27 +02:00
b238f0d9b4 feat: add MVP application documentation with project structure and component descriptions 2026-05-12 21:49:12 +02:00
4cc4f4bf6c feat: implement overlay architecture with IOverlayLayer interface and telemetry overlay 2026-05-12 21:10:37 +02:00
74a5dcd057 feat: implement VideoWidget for rendering camera frames and metrics overlay 2026-05-12 20:43:38 +02:00
22e52d5f5a feat: add test overlay for debugging in main window 2026-05-12 20:24:35 +02:00
ece4e1cd6e feat: restructure overlay and video widget integration for improved rendering 2026-05-12 20:06:37 +02:00
03d3332b35 fix: correct import statement for QVideoWidget in main_window.py 2026-05-12 19:52:59 +02:00
cd7f196b25 feat: implement core functionality for camera preview application
- Add FrameDispatcher for distributing QVideoFrames to subscribers
- Implement TelemetryCollector to measure video pipeline performance metrics
- Create MainWindow as the main application interface with video rendering
- Develop AppMenuBar for camera selection, resolution, and FPS settings
- Establish overlay system for displaying telemetry metrics
- Set up project structure and configuration files
- Add unit tests for FrameDispatcher and TelemetryCollector
2026-05-12 19:49:53 +02:00
65b98c352d new future - play video 2026-05-12 19:35:04 +02:00
51 changed files with 4407 additions and 943 deletions

3
.gitignore vendored
View File

@@ -12,6 +12,9 @@ __pycache__/
venv/
env/
# Log files
logs/
# Local/runtime data
captures/photos/*
captures/videos/*

View File

@@ -0,0 +1,132 @@
"""Camera enumeration — discovers available video input devices."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from PySide6.QtMultimedia import QCameraDevice, QMediaDevices, QVideoFrameFormat
logger = logging.getLogger(__name__)
# Map Qt pixel format enum → human-readable string
_PIXEL_FORMAT_NAMES: dict[QVideoFrameFormat.PixelFormat, str] = {
QVideoFrameFormat.PixelFormat.Format_BGRA8888: "BGRA",
QVideoFrameFormat.PixelFormat.Format_BGRX8888: "BGRX",
QVideoFrameFormat.PixelFormat.Format_ABGR8888: "ABGR",
QVideoFrameFormat.PixelFormat.Format_ARGB8888: "ARGB",
QVideoFrameFormat.PixelFormat.Format_RGBA8888: "RGBA",
QVideoFrameFormat.PixelFormat.Format_RGBX8888: "RGBX",
QVideoFrameFormat.PixelFormat.Format_YUV420P: "YUV420P",
QVideoFrameFormat.PixelFormat.Format_YUV422P: "YUV422P",
QVideoFrameFormat.PixelFormat.Format_NV12: "NV12",
QVideoFrameFormat.PixelFormat.Format_NV21: "NV21",
QVideoFrameFormat.PixelFormat.Format_UYVY: "UYVY",
QVideoFrameFormat.PixelFormat.Format_YUYV: "YUY2",
QVideoFrameFormat.PixelFormat.Format_Jpeg: "MJPG",
QVideoFrameFormat.PixelFormat.Format_Y8: "GRAY8",
QVideoFrameFormat.PixelFormat.Format_Y16: "GRAY16",
}
def pixel_format_name(fmt: QVideoFrameFormat.PixelFormat) -> str:
"""Return a short human-readable name for a Qt pixel format."""
return _PIXEL_FORMAT_NAMES.get(fmt, f"fmt_{fmt.value if hasattr(fmt, 'value') else fmt}")
@dataclass
class CameraFormat:
"""One supported video format entry for a camera device."""
width: int
height: int
max_fps: float
pixel_format: str # e.g. "MJPG", "YUY2", "NV12", "BGRA"
def __str__(self) -> str:
return f"{self.pixel_format:6s} {self.width}x{self.height} @ {self.max_fps:.1f} fps"
@dataclass
class CameraInfo:
"""Lightweight descriptor of a detected camera."""
device: QCameraDevice
name: str
id: str
formats: list[CameraFormat] = field(default_factory=list)
def __str__(self) -> str:
return f"{self.name} [{self.id}]"
class CameraEnumerator:
"""Discovers available video input devices via QMediaDevices."""
@staticmethod
def list_cameras() -> list[CameraInfo]:
"""Return all available camera devices with their supported formats."""
devices = QMediaDevices.videoInputs()
cameras: list[CameraInfo] = []
for device in devices:
formats: list[CameraFormat] = []
for qfmt in device.videoFormats():
res = qfmt.resolution()
formats.append(CameraFormat(
width=res.width(),
height=res.height(),
max_fps=qfmt.maxFrameRate(),
pixel_format=pixel_format_name(qfmt.pixelFormat()),
))
# deduplicate; sort: largest area first, then fps descending
seen: set[tuple[int, int, float, str]] = set()
unique: list[CameraFormat] = []
for f in sorted(
formats,
key=lambda x: (x.width * x.height, x.max_fps),
reverse=True,
):
key = (f.width, f.height, f.max_fps, f.pixel_format)
if key not in seen:
seen.add(key)
unique.append(f)
cam_id = CameraEnumerator._device_id(device)
cameras.append(CameraInfo(
device=device,
name=device.description(),
id=cam_id,
formats=unique,
))
logger.info("Cameras found: %d", len(cameras))
for idx, cam in enumerate(cameras):
logger.info(" [%d] %s (id: %s)", idx, cam.name, cam.id)
for fmt in cam.formats:
logger.info(" %s", fmt)
return cameras
@staticmethod
def default_camera() -> CameraInfo | None:
"""Return the system default camera, or None if no camera is available."""
device = QMediaDevices.defaultVideoInput()
if device.isNull():
return None
default_id = CameraEnumerator._device_id(device)
for cam in CameraEnumerator.list_cameras():
if cam.id == default_id:
return cam
cameras = CameraEnumerator.list_cameras()
return cameras[0] if cameras else None
@staticmethod
def _device_id(device: QCameraDevice) -> str:
raw = device.id()
if hasattr(raw, "toStdString"):
return raw.toStdString()
return raw.data().decode("utf-8", errors="replace")

View File

@@ -0,0 +1,228 @@
"""Camera Service — manages QCamera lifecycle and frame acquisition."""
from __future__ import annotations
import logging
import platform
from PySide6.QtCore import QObject, Signal
from PySide6.QtMultimedia import (
QCamera,
QMediaCaptureSession,
QVideoFrame,
QVideoSink,
)
from app.camera.camera_enumerator import CameraFormat, CameraInfo, pixel_format_name
logger = logging.getLogger(__name__)
class CameraService(QObject):
"""
Manages camera initialisation, configuration and frame delivery.
Emits:
frame_ready(QVideoFrame) — new frame available from the camera
camera_started() — camera successfully opened and streaming
camera_stopped() — camera stopped (clean shutdown)
camera_error(str) — camera error description
format_changed(float) — actual FPS after format was applied
"""
frame_ready = Signal(QVideoFrame)
camera_started = Signal()
camera_stopped = Signal()
camera_error = Signal(str)
format_changed = Signal(float)
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._camera: QCamera | None = None
self._session = QMediaCaptureSession(self)
self._sink = QVideoSink(self)
self._current_info: CameraInfo | None = None
self._session_logged: bool = False
# Desired format — applied on every (re)start
self._desired_fmt: CameraFormat | None = None
self._session.setVideoSink(self._sink)
self._sink.videoFrameChanged.connect(self._on_frame)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def start(self, camera_info: CameraInfo) -> None:
"""Start streaming from the given camera device."""
self._stop_camera()
self._current_info = camera_info
self._camera = QCamera(camera_info.device, self)
self._camera.errorOccurred.connect(self._on_error)
self._camera.activeChanged.connect(self._on_active_changed)
self._session.setCamera(self._camera)
self._apply_format()
self._camera.start()
logger.info("Camera start requested: %s", camera_info.name)
def stop(self) -> None:
"""Stop the current camera and forget the device."""
self._stop_camera()
self._current_info = None
def reconnect(self) -> None:
"""Restart the current camera (e.g. after an error or disconnect)."""
if self._current_info is not None:
logger.info("Reconnecting camera: %s", self._current_info.name)
self.start(self._current_info)
else:
logger.warning("Reconnect requested but no camera was previously started")
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(
"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
def is_active(self) -> bool:
return self._camera is not None and self._camera.isActive()
@property
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
def capture_session(self) -> QMediaCaptureSession:
return self._session
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _stop_camera(self) -> None:
if self._camera is not None:
self._camera.stop()
self._camera.errorOccurred.disconnect()
self._camera.activeChanged.disconnect()
self._camera = None
logger.debug("Camera stopped (internal)")
def _apply_format(self) -> None:
"""Select and apply the best matching QCameraFormat before start()."""
if self._camera is None or self._current_info is None:
return
best = None
best_score = -1
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)
if score > best_score:
best_score = score
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 @ %.4g fps",
pf, res.width(), res.height(), best.maxFrameRate(),
)
def _log_actual_format(self) -> None:
"""Log the format the camera actually started with and emit format_changed."""
if self._camera is None:
return
fmt = self._camera.cameraFormat()
res = fmt.resolution()
actual_fps = fmt.maxFrameRate()
pf = pixel_format_name(fmt.pixelFormat())
logger.info(
"Camera format ACTUAL: %s %dx%d @ %.4g fps",
pf, res.width(), res.height(), 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:
system = platform.system()
backend = {
"Darwin": "AVFoundation",
"Windows": "Media Foundation / DirectShow",
"Linux": "GStreamer / V4L2",
}.get(system, system)
logger.info("Qt multimedia backend: %s", backend)
except Exception:
pass
def _on_frame(self, frame: QVideoFrame) -> None:
if frame.isValid():
self.frame_ready.emit(frame)
def _on_error(self, error: QCamera.Error, error_string: str) -> None:
logger.error("Camera error %s: %s", error, error_string)
self.camera_error.emit(error_string)
def _on_active_changed(self, active: bool) -> None:
if active:
name = self._current_info.name if self._current_info else "?"
if not self._session_logged:
self._log_qt_backend()
self._session_logged = True
logger.info("Camera active: %s", name)
self._log_actual_format()
self.camera_started.emit()
else:
logger.info("Camera inactive")
self.camera_stopped.emit()

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

29
app/config.py Normal file
View File

@@ -0,0 +1,29 @@
"""Application-wide constants and default settings."""
from pathlib import Path
APP_NAME = "Duck Preview"
APP_VERSION = "0.1.0"
# Default camera settings
DEFAULT_FPS = 30
DEFAULT_WIDTH = 1280
DEFAULT_HEIGHT = 720
# Telemetry
TELEMETRY_UPDATE_INTERVAL_MS = 500 # how often the metrics snapshot is refreshed
# Overlay
OVERLAY_BG_COLOR = (0, 0, 0, 160) # RGBA
OVERLAY_TEXT_COLOR = (255, 255, 255, 255)
OVERLAY_FONT_SIZE = 13
OVERLAY_PADDING = 10
OVERLAY_MARGIN = 10
# Frame dispatcher
DISPATCHER_MAX_QUEUE_SIZE = 2 # max pending frames per slow subscriber before drop
# Logging
LOG_DIR = Path("logs") # relative to CWD (project root)
MAX_LOG_FILES = 20 # oldest sessions are deleted when exceeded
TELEMETRY_CSV_INTERVAL_S = 5.0 # how often a CSV row is written (seconds)

118
app/logging_setup.py Normal file
View File

@@ -0,0 +1,118 @@
"""Logging initialisation — file + console handlers with session isolation."""
from __future__ import annotations
import logging
import platform
import sys
from pathlib import Path
from app.config import APP_NAME, APP_VERSION, MAX_LOG_FILES
_LOG_FORMAT = "%(asctime)s.%(msecs)03d [%(levelname)-7s] %(name)s: %(message)s"
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
_console_handler: logging.StreamHandler | None = None
def setup_logging(log_dir: Path, session_id: str) -> Path:
"""
Configure the root logger for a new session.
Creates:
<log_dir>/duck-preview_<session_id>.log — DEBUG level, all messages
console StreamHandler — WARNING by default
Prunes oldest log files when count exceeds MAX_LOG_FILES.
Args:
log_dir: Directory where log files are stored.
session_id: Timestamp string used as filename suffix (e.g. "2026-05-12_14-30-00").
Returns:
Path to the created log file.
"""
global _console_handler
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"duck-preview_{session_id}.log"
root = logging.getLogger()
root.setLevel(logging.DEBUG) # handlers filter individually
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT)
# --- File handler — always DEBUG ---
file_handler = logging.FileHandler(log_path, encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
root.addHandler(file_handler)
# --- Console handler — WARNING by default, toggled by Debug menu ---
_console_handler = logging.StreamHandler(sys.stdout)
_console_handler.setLevel(logging.WARNING)
_console_handler.setFormatter(formatter)
root.addHandler(_console_handler)
# Write session header to file
_write_session_header(log_path, session_id)
# Prune old log files
_prune_old_logs(log_dir, log_path)
return log_path
def set_console_level(debug: bool) -> None:
"""Toggle console handler between DEBUG and WARNING (called from Debug menu)."""
if _console_handler is not None:
_console_handler.setLevel(logging.DEBUG if debug else logging.WARNING)
def _write_session_header(log_path: Path, session_id: str) -> None:
"""Write a human-readable header block at the top of the log file."""
try:
import PySide6
pyside_version = PySide6.__version__
except Exception:
pyside_version = "unknown"
try:
import psutil
mem = psutil.virtual_memory()
cpu_count = psutil.cpu_count(logical=True)
mem_total_gb = mem.total / (1024 ** 3)
hw_info = f"{cpu_count} logical CPUs, {mem_total_gb:.1f} GB RAM"
except Exception:
hw_info = "unknown"
lines = [
"=" * 72,
f" {APP_NAME} {APP_VERSION}",
f" Session: {session_id}",
"=" * 72,
f" Platform : {platform.platform()}",
f" Python : {sys.version.split()[0]}",
f" PySide6 : {pyside_version}",
f" Hardware : {hw_info}",
f" Log file : {log_path.resolve()}",
"=" * 72,
"",
]
with log_path.open("w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
def _prune_old_logs(log_dir: Path, current: Path) -> None:
"""Delete oldest .log files if total count exceeds MAX_LOG_FILES."""
log_files = sorted(
[p for p in log_dir.glob("duck-preview_*.log") if p != current],
key=lambda p: p.stat().st_mtime,
)
excess = len(log_files) - (MAX_LOG_FILES - 1)
for path in log_files[:excess]:
try:
path.unlink()
logging.getLogger(__name__).debug("Pruned old log: %s", path.name)
except OSError:
pass

41
app/main.py Normal file
View File

@@ -0,0 +1,41 @@
"""Application entry point."""
from __future__ import annotations
import sys
from datetime import datetime
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication
from app.config import APP_NAME, LOG_DIR
from app.logging_setup import setup_logging
def main() -> None:
session_id = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
log_path = setup_logging(LOG_DIR, session_id)
# Import after logging is ready so module-level loggers work correctly
import logging # noqa: PLC0415
from app.ui.main_window import MainWindow # noqa: PLC0415
logger = logging.getLogger(__name__)
logger.info("Application starting (session: %s)", session_id)
app = QApplication(sys.argv)
app.setApplicationName(APP_NAME)
app.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
window = MainWindow(log_path=log_path)
window.show()
logger.info("Application shutting down")
sys.exit(app.exec())
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,54 @@
"""IOverlayLayer — interface for pluggable overlay layers drawn on CameraView."""
from __future__ import annotations
from abc import ABC, abstractmethod
from PySide6.QtCore import QRect
from PySide6.QtGui import QPainter
class IOverlayLayer(ABC):
"""
Interface for a single overlay layer drawn over the camera frame.
Each layer receives the active QPainter and the video rect (the letterboxed
area where the camera image was drawn) so it can position elements relative
to the actual video content if needed.
To add a new overlay (e.g. YOLO bboxes):
1. Subclass IOverlayLayer.
2. Implement paint().
3. Register with CameraView.add_overlay_layer().
No Qt subclassing is required — layers are plain Python objects.
"""
@property
def name(self) -> str:
"""Human-readable identifier used in menus / debug output."""
return type(self).__name__
@property
def visible(self) -> bool:
"""Whether this layer should be drawn."""
return self._visible
@visible.setter
def visible(self, value: bool) -> None:
self._visible = value
def __init__(self) -> None:
self._visible: bool = True
@abstractmethod
def paint(self, painter: QPainter, video_rect: QRect) -> None:
"""
Draw this layer.
Args:
painter: Active QPainter on the CameraView widget.
Caller saves/restores painter state around each layer.
video_rect: The QRect where the camera image was drawn (letterboxed).
Use this to position overlays relative to the video image.
"""

View File

@@ -0,0 +1,109 @@
"""TelemetryOverlay — draws the performance metrics box on the camera view."""
from __future__ import annotations
from PySide6.QtCore import QRect, Qt, Slot
from PySide6.QtGui import QColor, QFont, QPainter, QPen
from app.config import (
OVERLAY_BG_COLOR,
OVERLAY_FONT_SIZE,
OVERLAY_MARGIN,
OVERLAY_PADDING,
OVERLAY_TEXT_COLOR,
)
from app.overlay.overlay_layer import IOverlayLayer
from app.telemetry.telemetry_collector import TelemetrySnapshot
class TelemetryOverlay(IOverlayLayer):
"""
Renders a semi-transparent metrics box in the top-left corner.
Usage:
overlay = TelemetryOverlay()
camera_view.add_overlay_layer(overlay)
telemetry_collector.metrics_updated.connect(overlay.on_metrics_updated)
Display format:
FPS req 60.0 ← what was requested from camera
FPS got 30.2 ← what camera actually delivered
Frame 33.1 ms
Drop 0
CPU sys 14.8 % ← normalised by cpu_count (matches Task Manager)
CPU core 118.4 % ← per single core (can exceed 100%)
Mem 68 MB
"""
def __init__(self) -> None:
super().__init__()
self._snapshot: TelemetrySnapshot | None = None
self._font = QFont("Monospace")
self._font.setStyleHint(QFont.StyleHint.TypeWriter)
self._font.setPointSize(OVERLAY_FONT_SIZE)
self._font.setBold(False)
@Slot(object)
def on_metrics_updated(self, snapshot: TelemetrySnapshot) -> None:
"""Receive a new snapshot from TelemetryCollector."""
self._snapshot = snapshot
# ------------------------------------------------------------------
# IOverlayLayer
# ------------------------------------------------------------------
def paint(self, painter: QPainter, video_rect: QRect) -> None:
if self._snapshot is None:
return
lines = self._format_lines(self._snapshot)
if not lines:
return
painter.setFont(self._font)
fm = painter.fontMetrics()
line_height = fm.height()
max_width = max(fm.horizontalAdvance(line) for line in lines)
box_w = max_width + OVERLAY_PADDING * 2
box_h = line_height * len(lines) + OVERLAY_PADDING * 2
x = video_rect.left() + OVERLAY_MARGIN
y = video_rect.top() + OVERLAY_MARGIN
# Background
painter.setBrush(QColor(*OVERLAY_BG_COLOR))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(QRect(x, y, box_w, box_h), 6, 6)
# Text
painter.setPen(QPen(QColor(*OVERLAY_TEXT_COLOR)))
text_x = x + OVERLAY_PADDING
text_y = y + OVERLAY_PADDING + fm.ascent()
for line in lines:
painter.drawText(text_x, text_y, line)
text_y += line_height
# ------------------------------------------------------------------
# Private
# ------------------------------------------------------------------
@staticmethod
def _format_lines(snap: TelemetrySnapshot) -> list[str]:
lines: list[str] = []
# FPS — show target if known, then actual
if snap.target_fps is not None:
lines.append(f"FPS req {snap.target_fps:>6.1f}")
lines.append(f"FPS got {snap.fps:>6.1f}")
lines.append(f"Frame {snap.frame_time_ms:>6.1f} ms")
lines.append(f"Drop {snap.dropped_frames:>6d}")
lines.append(f"CPU sys {snap.cpu_percent_sys:>5.1f} %")
lines.append(f"CPU core {snap.cpu_percent_core:>5.1f} %")
if snap.memory_mb is not None:
lines.append(f"Mem {snap.memory_mb:>5.0f} MB")
return lines

View File

@@ -0,0 +1,111 @@
"""Frame Dispatcher — distributes QVideoFrames to registered subscribers."""
from __future__ import annotations
import logging
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from PySide6.QtCore import QObject, Slot
from PySide6.QtMultimedia import QVideoFrame
logger = logging.getLogger(__name__)
FrameCallback = Callable[[QVideoFrame], None]
@dataclass
class _Subscriber:
callback: FrameCallback
drop_if_busy: bool = True
_busy: bool = field(default=False, init=False, repr=False)
class FrameDispatcher(QObject):
"""
Receives frames from CameraService and fans them out to all subscribers.
Each subscriber is a callable (QVideoFrame) -> None.
Subscribers that set drop_if_busy=True will skip a frame if they are still
processing the previous one (non-blocking). Subscribers with drop_if_busy=False
always receive every frame.
All dispatch happens in the GUI thread (Qt signal/slot), so subscribers
must NOT perform heavy work directly — they should queue to a worker thread
if processing is needed.
"""
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._subscribers: list[_Subscriber] = []
self._frame_count: int = 0
self._last_dispatch_time: float = 0.0
# ------------------------------------------------------------------
# Subscription API
# ------------------------------------------------------------------
def subscribe(self, callback: FrameCallback, *, drop_if_busy: bool = True) -> None:
"""Register a frame callback.
Args:
callback: Callable that receives QVideoFrame.
drop_if_busy: When True, frame is skipped if subscriber is still
marked busy from last call (default True).
"""
for sub in self._subscribers:
if sub.callback is callback:
logger.warning("Subscriber %r already registered", callback)
return
self._subscribers.append(_Subscriber(callback=callback, drop_if_busy=drop_if_busy))
logger.debug("Subscriber added: %r (drop_if_busy=%s)", callback, drop_if_busy)
def unsubscribe(self, callback: FrameCallback) -> None:
"""Remove a previously registered callback."""
before = len(self._subscribers)
self._subscribers = [s for s in self._subscribers if s.callback is not callback]
if len(self._subscribers) < before:
logger.debug("Subscriber removed: %r", callback)
else:
logger.warning("Subscriber not found for removal: %r", callback)
def subscriber_count(self) -> int:
return len(self._subscribers)
# ------------------------------------------------------------------
# Frame intake — connect CameraService.frame_ready to this slot
# ------------------------------------------------------------------
@Slot(QVideoFrame)
def dispatch(self, frame: QVideoFrame) -> None:
"""Distribute the frame to all registered subscribers."""
self._frame_count += 1
now = time.perf_counter()
self._last_dispatch_time = now
for sub in self._subscribers:
if sub.drop_if_busy and sub._busy:
logger.debug("Dropping frame for busy subscriber %r", sub.callback)
continue
sub._busy = True
try:
sub.callback(frame)
except Exception:
logger.exception("Error in frame subscriber %r", sub.callback)
finally:
sub._busy = False
# ------------------------------------------------------------------
# Stats
# ------------------------------------------------------------------
@property
def frame_count(self) -> int:
return self._frame_count
@property
def last_dispatch_time(self) -> float:
"""perf_counter timestamp of the last dispatched frame."""
return self._last_dispatch_time

104
app/telemetry/csv_logger.py Normal file
View File

@@ -0,0 +1,104 @@
"""CsvTelemetryLogger — writes telemetry snapshots to a CSV file with throttling.
Design decisions:
- Does NOT use the logging module — writes directly via csv.writer so the file
is readable independently of the text log level.
- Flushes after every row so the file is intact even on crash or force-quit.
- Throttle: only one row per TELEMETRY_CSV_INTERVAL_S seconds, even if
metrics_updated fires every 500 ms. This keeps the file manageable for
long sessions (8 h @ 5 s interval = 5 760 rows).
"""
from __future__ import annotations
import csv
import time
from datetime import datetime
from pathlib import Path
from app.config import TELEMETRY_CSV_INTERVAL_S
from app.telemetry.telemetry_collector import TelemetrySnapshot
_CSV_HEADER = [
"timestamp",
"fps_got",
"fps_req",
"frame_time_ms",
"dropped_frames",
"cpu_sys_pct",
"cpu_core_pct",
"mem_mb",
]
class CsvTelemetryLogger:
"""
Receives TelemetrySnapshot objects and writes throttled rows to a CSV file.
Usage:
logger = CsvTelemetryLogger(path)
telemetry_collector.metrics_updated.connect(logger.on_metrics_updated)
# call logger.close() on application exit
"""
def __init__(
self,
path: Path,
interval_s: float = TELEMETRY_CSV_INTERVAL_S,
) -> None:
self._interval_s = interval_s
self._last_write_time: float = 0.0
self._rows_written: int = 0
path.parent.mkdir(parents=True, exist_ok=True)
self._file = path.open("w", newline="", encoding="utf-8")
self._writer = csv.writer(self._file)
self._writer.writerow(_CSV_HEADER)
self._file.flush()
# ------------------------------------------------------------------
# Slot — connect to TelemetryCollector.metrics_updated
# ------------------------------------------------------------------
def on_metrics_updated(self, snapshot: TelemetrySnapshot) -> None:
"""Write a row if the throttle interval has elapsed."""
now = time.monotonic()
if now - self._last_write_time < self._interval_s:
return
self._last_write_time = now
self._write_row(snapshot)
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def close(self) -> None:
"""Flush and close the CSV file. Call on application shutdown."""
try:
self._file.flush()
self._file.close()
except OSError:
pass
@property
def rows_written(self) -> int:
return self._rows_written
# ------------------------------------------------------------------
# Private
# ------------------------------------------------------------------
def _write_row(self, snap: TelemetrySnapshot) -> None:
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3] # HH:MM:SS.mmm
self._writer.writerow([
ts,
f"{snap.fps:.1f}",
f"{snap.target_fps:.1f}" if snap.target_fps is not None else "",
f"{snap.frame_time_ms:.2f}",
snap.dropped_frames,
f"{snap.cpu_percent_sys:.1f}",
f"{snap.cpu_percent_core:.1f}",
f"{snap.memory_mb:.1f}" if snap.memory_mb is not None else "",
])
self._file.flush()
self._rows_written += 1

View File

@@ -0,0 +1,190 @@
"""Telemetry Collector — measures video pipeline performance metrics."""
from __future__ import annotations
import time
from collections import deque
from dataclasses import dataclass
import psutil
from PySide6.QtCore import QObject, QTimer, Signal
from PySide6.QtMultimedia import QVideoFrame
from app.config import TELEMETRY_UPDATE_INTERVAL_MS
@dataclass
class TelemetrySnapshot:
"""Immutable snapshot of current performance metrics."""
fps: float # actual frames received in the last second
target_fps: float | None # FPS requested from the camera (None = unknown)
frame_time_ms: float # average inter-frame time in ms
dropped_frames: int # cumulative dropped frames detected
cpu_percent_sys: float # process CPU as % of total system capacity
# (divided by cpu_count) — matches Task Manager
cpu_percent_core: float # process CPU per single core — can exceed 100%
memory_mb: float | None # process private working set in MB
timestamp: float # time.perf_counter() when snapshot was taken
class TelemetryCollector(QObject):
"""
Frame subscriber that measures pipeline performance.
Connect to FrameDispatcher:
dispatcher.subscribe(collector.on_frame, drop_if_busy=False)
Receive target FPS updates from CameraService:
camera_service.format_changed.connect(collector.set_target_fps)
Listen to metrics updates:
collector.metrics_updated.connect(my_slot)
"""
metrics_updated = Signal(object) # emits TelemetrySnapshot
def __init__(
self,
update_interval_ms: int = TELEMETRY_UPDATE_INTERVAL_MS,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
self._update_interval_ms = update_interval_ms
self._target_fps: float | None = None
# frame timing ring-buffer (last 120 samples)
self._frame_times: deque[float] = deque(maxlen=120)
self._last_frame_time: float = 0.0
self._total_frames: int = 0
self._dropped_frames: int = 0
# FPS window — count frames in the last second
self._fps_window: deque[float] = deque()
self._fps_window_size_s: float = 1.0
# psutil — initialise baseline so first real reading is non-zero
self._process = psutil.Process()
self._process.cpu_percent() # first call always returns 0.0; discard
self._cpu_count: int = max(psutil.cpu_count(logical=True) or 1, 1)
# periodic snapshot timer
self._timer = QTimer(self)
self._timer.setInterval(update_interval_ms)
self._timer.timeout.connect(self._emit_snapshot)
self._timer.start()
self._latest: TelemetrySnapshot = self._make_empty_snapshot()
# ------------------------------------------------------------------
# Configuration
# ------------------------------------------------------------------
def set_target_fps(self, fps: float | None) -> None:
"""Record the FPS that was requested from the camera."""
self._target_fps = fps
# ------------------------------------------------------------------
# Frame subscriber callback
# ------------------------------------------------------------------
def on_frame(self, frame: QVideoFrame) -> None:
"""Called by FrameDispatcher for every frame. Must be fast."""
now = time.perf_counter()
if self._last_frame_time > 0:
delta = now - self._last_frame_time
self._frame_times.append(delta)
# drop detection: gap > 2.5× rolling average
if len(self._frame_times) >= 5:
avg = sum(self._frame_times) / len(self._frame_times)
if delta > avg * 2.5:
self._dropped_frames += 1
self._last_frame_time = now
self._total_frames += 1
self._fps_window.append(now)
cutoff = now - self._fps_window_size_s
while self._fps_window and self._fps_window[0] < cutoff:
self._fps_window.popleft()
# ------------------------------------------------------------------
# Snapshot
# ------------------------------------------------------------------
def latest_snapshot(self) -> TelemetrySnapshot:
return self._latest
def reset_counters(self) -> None:
"""Reset cumulative counters (e.g. after camera switch)."""
self._frame_times.clear()
self._fps_window.clear()
self._last_frame_time = 0.0
self._total_frames = 0
self._dropped_frames = 0
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _emit_snapshot(self) -> None:
snapshot = self._compute_snapshot()
self._latest = snapshot
self.metrics_updated.emit(snapshot)
def _compute_snapshot(self) -> TelemetrySnapshot:
now = time.perf_counter()
# FPS — prune stale entries before counting
cutoff = now - self._fps_window_size_s
while self._fps_window and self._fps_window[0] < cutoff:
self._fps_window.popleft()
fps = float(len(self._fps_window))
# average frame time
avg_frame_time_ms = (
(sum(self._frame_times) / len(self._frame_times)) * 1000.0
if self._frame_times
else 0.0
)
# CPU — per-core reading, then derive system-normalised value
try:
cpu_core = self._process.cpu_percent()
except Exception:
cpu_core = 0.0
cpu_sys = cpu_core / self._cpu_count
# Memory — private working set (Windows) or RSS (macOS/Linux)
try:
mem_info = self._process.memory_info()
mem_bytes = getattr(mem_info, "wset", None) or mem_info.rss
mem_mb: float | None = mem_bytes / (1024 * 1024)
except Exception:
mem_mb = None
return TelemetrySnapshot(
fps=round(fps, 1),
target_fps=self._target_fps,
frame_time_ms=round(avg_frame_time_ms, 2),
dropped_frames=self._dropped_frames,
cpu_percent_sys=round(cpu_sys, 1),
cpu_percent_core=round(cpu_core, 1),
memory_mb=round(mem_mb, 1) if mem_mb is not None else None,
timestamp=now,
)
def _make_empty_snapshot(self) -> TelemetrySnapshot:
return TelemetrySnapshot(
fps=0.0,
target_fps=self._target_fps,
frame_time_ms=0.0,
dropped_frames=0,
cpu_percent_sys=0.0,
cpu_percent_core=0.0,
memory_mb=None,
timestamp=time.perf_counter(),
)

0
app/ui/__init__.py Normal file
View File

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)

144
app/ui/camera_view.py Normal file
View File

@@ -0,0 +1,144 @@
"""CameraView — QWidget that renders camera frames and composites overlay layers.
Responsibilities:
- Receive QVideoFrame, convert to QImage, schedule repaint.
- In paintEvent: draw the frame (letterboxed) then call paint() on each
registered IOverlayLayer in order.
- Know nothing about what specific overlays draw — that is entirely up to
each IOverlayLayer implementation.
Adding a new overlay (e.g. YOLO bboxes):
layer = YoloBboxOverlay()
camera_view.add_overlay_layer(layer)
yolo_processor.results_ready.connect(layer.on_results)
No modification to CameraView is needed.
"""
from __future__ import annotations
import logging
from PySide6.QtCore import QRect, Qt, Slot
from PySide6.QtGui import QImage, QPainter
from PySide6.QtMultimedia import QVideoFrame
from PySide6.QtWidgets import QWidget
from app.overlay.overlay_layer import IOverlayLayer
logger = logging.getLogger(__name__)
class CameraView(QWidget):
"""
Camera preview widget.
Frame pipeline:
CameraService.frame_ready(QVideoFrame)
→ CameraView.on_frame() — convert to QImage, schedule update()
→ paintEvent() — draw image + all overlay layers
"""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setStyleSheet("background: black;")
self._current_image: QImage | None = None
self._video_rect: QRect = QRect()
self._overlay_layers: list[IOverlayLayer] = []
# ------------------------------------------------------------------
# Overlay layer registry
# ------------------------------------------------------------------
def add_overlay_layer(self, layer: IOverlayLayer) -> None:
"""Register an overlay layer. Layers are painted in registration order."""
if layer not in self._overlay_layers:
self._overlay_layers.append(layer)
logger.debug("Overlay layer added: %s", layer.name)
def remove_overlay_layer(self, layer: IOverlayLayer) -> None:
"""Unregister an overlay layer."""
try:
self._overlay_layers.remove(layer)
logger.debug("Overlay layer removed: %s", layer.name)
except ValueError:
pass
def set_all_overlays_visible(self, visible: bool) -> None:
"""Show or hide all registered overlay layers at once."""
for layer in self._overlay_layers:
layer.visible = visible
self.update()
# ------------------------------------------------------------------
# Frame input
# ------------------------------------------------------------------
@Slot(QVideoFrame)
def on_frame(self, frame: QVideoFrame) -> None:
"""Receive a camera frame, convert to QImage, schedule repaint."""
if not frame.isValid():
return
image = frame.toImage()
if image.isNull():
return
# Convert to Format_RGB32 — QPainter's fastest path on all platforms
if image.format() != QImage.Format.Format_RGB32:
image = image.convertToFormat(QImage.Format.Format_RGB32)
self._current_image = image
self.update()
# ------------------------------------------------------------------
# Qt paint
# ------------------------------------------------------------------
def paintEvent(self, event) -> None: # noqa: N802
painter = QPainter(self)
# 1. Background
painter.fillRect(self.rect(), Qt.GlobalColor.black)
# 2. Camera frame — letterboxed
if self._current_image is not None:
img = self._current_image
self._video_rect = _letterbox_rect(
img.width(), img.height(), self.width(), self.height()
)
painter.drawImage(self._video_rect, img)
else:
self._video_rect = self.rect()
# 3. Overlay layers — each gets a clean painter state
for layer in self._overlay_layers:
if not layer.visible:
continue
painter.save()
try:
layer.paint(painter, self._video_rect)
except Exception:
logger.exception("Error painting overlay layer %s", layer.name)
finally:
painter.restore()
painter.end()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _letterbox_rect(img_w: int, img_h: int, widget_w: int, widget_h: int) -> QRect:
"""Return the largest rect that fits the image while preserving aspect ratio."""
if img_w <= 0 or img_h <= 0:
return QRect(0, 0, widget_w, widget_h)
scale = min(widget_w / img_w, widget_h / img_h)
draw_w = int(img_w * scale)
draw_h = int(img_h * scale)
x = (widget_w - draw_w) // 2
y = (widget_h - draw_h) // 2
return QRect(x, y, draw_w, draw_h)

225
app/ui/main_window.py Normal file
View File

@@ -0,0 +1,225 @@
"""Main application window."""
from __future__ import annotations
import logging
from pathlib import Path
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QLabel, QMainWindow, QSizePolicy, QStatusBar
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
logger = logging.getLogger(__name__)
class MainWindow(QMainWindow):
"""
Top-level application window.
Rendering architecture:
QVideoWidget is intentionally NOT used — on Windows its native HWND
surface occludes all sibling/child QWidgets regardless of z-order.
CameraView is a plain QWidget that renders frames and overlay layers
in a single paintEvent pass.
Signal flow:
CameraService.frame_ready
→ FrameDispatcher.dispatch
→ CameraView.on_frame (render frame)
→ TelemetryCollector.on_frame (measure metrics)
→ TelemetryOverlay.on_metrics_updated (overlay data)
→ CsvTelemetryLogger.on_metrics_updated (CSV file)
"""
def __init__(self, log_path: Path | None = None) -> None:
super().__init__()
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}")
self.setMinimumSize(640, 480)
self.resize(1280, 720)
# --- Core pipeline components ---
self._camera_service = CameraService(self)
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:
csv_path = log_path.with_suffix(".csv")
self._csv_logger = CsvTelemetryLogger(csv_path)
logger.info("Telemetry CSV: %s", csv_path.resolve())
# --- Camera view (central widget) ---
self._camera_view = CameraView(self)
self._camera_view.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
self.setCentralWidget(self._camera_view)
# --- Overlay layers ---
self._telemetry_overlay = TelemetryOverlay()
self._camera_view.add_overlay_layer(self._telemetry_overlay)
# --- Menu bar ---
self._menu = AppMenuBar(self)
self.setMenuBar(self._menu)
if log_path is not None:
self._menu.set_log_file_path(str(log_path.resolve()))
# --- Status bar ---
self._status_bar = QStatusBar(self)
self.setStatusBar(self._status_bar)
self._status_label = QLabel("Initialising\u2026")
self._status_bar.addWidget(self._status_label)
# --- Wire signals ---
self._wire_signals()
# --- Enumerate cameras and start ---
QTimer.singleShot(0, self._initialise_cameras)
# ------------------------------------------------------------------
# Initialisation
# ------------------------------------------------------------------
def _initialise_cameras(self) -> None:
cameras = CameraEnumerator.list_cameras()
if not cameras:
self._status_label.setText("No cameras found")
logger.warning("No cameras detected")
return
self._menu.populate_cameras(cameras)
default = CameraEnumerator.default_camera()
start_cam = default if default is not None else cameras[0]
self._menu.populate_formats(start_cam)
self._start_camera(start_cam)
def _start_camera(self, cam: CameraInfo) -> None:
self._telemetry.reset_counters()
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
# ------------------------------------------------------------------
def _wire_signals(self) -> None:
# CameraService → FrameDispatcher
self._camera_service.frame_ready.connect(self._dispatcher.dispatch)
# FrameDispatcher → CameraView (render) — drop if busy
self._dispatcher.subscribe(self._camera_view.on_frame, drop_if_busy=True)
# 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
)
# TelemetryCollector → CSV logger (throttled internally)
if self._csv_logger is not None:
self._telemetry.metrics_updated.connect(self._csv_logger.on_metrics_updated)
# CameraService → TelemetryCollector: keep target FPS in sync
self._camera_service.format_changed.connect(self._telemetry.set_target_fps)
# CameraService status
self._camera_service.camera_started.connect(self._on_camera_started)
self._camera_service.camera_stopped.connect(self._on_camera_stopped)
self._camera_service.camera_error.connect(self._on_camera_error)
# Menu signals
self._menu.camera_selected.connect(self._on_camera_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
# ------------------------------------------------------------------
def _on_camera_started(self) -> None:
cam = self._camera_service.current_camera
name = cam.name if cam else "Unknown"
self._status_label.setText(f"Streaming: {name}")
logger.info("Camera streaming: %s", name)
def _on_camera_stopped(self) -> None:
self._status_label.setText("Camera stopped")
def _on_camera_error(self, message: str) -> None:
self._status_label.setText(f"Error: {message}")
logger.error("Camera error: %s", message)
# ------------------------------------------------------------------
# Menu action slots
# ------------------------------------------------------------------
def _on_camera_selected(self, cam: CameraInfo) -> None:
self._start_camera(cam)
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_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
# ------------------------------------------------------------------
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
)
self._csv_logger.close()
super().closeEvent(event)

198
app/ui/menu_bar.py Normal file
View File

@@ -0,0 +1,198 @@
"""Menu bar — camera, video format and debug controls."""
from __future__ import annotations
import logging
from PySide6.QtCore import Signal
from PySide6.QtGui import QAction, QActionGroup
from PySide6.QtWidgets import QMenuBar, QWidget
from app.camera.camera_enumerator import CameraFormat, CameraInfo
from app.logging_setup import set_console_level
logger = logging.getLogger(__name__)
class AppMenuBar(QMenuBar):
"""
Application menu bar.
Signals:
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
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._format_group: QActionGroup | None = None
self._cameras: list[CameraInfo] = []
self._build_menus()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def populate_cameras(self, cameras: list[CameraInfo]) -> None:
"""Populate the Camera menu with discovered devices."""
self._cameras = cameras
menu = self._camera_menu
for action in list(menu.actions()):
if action not in (self._reconnect_action, self._cam_separator):
menu.removeAction(action)
self._camera_group = QActionGroup(self)
self._camera_group.setExclusive(True)
for cam in cameras:
action = QAction(cam.name, self)
action.setCheckable(True)
action.setData(cam)
self._camera_group.addAction(action)
menu.insertAction(self._cam_separator, action)
action.triggered.connect(self._on_camera_action)
if cameras:
self._camera_group.actions()[0].setChecked(True)
def populate_formats(self, camera_info: CameraInfo) -> None:
"""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:
return
for action in self._camera_group.actions():
if action.data() is camera_info:
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."""
display = path if len(path) <= 60 else "\u2026" + path[-57:]
self._log_file_action.setText(f"Log: {display}")
self._log_file_action.setToolTip(path)
# ------------------------------------------------------------------
# Menu construction
# ------------------------------------------------------------------
def _build_menus(self) -> None:
# Camera menu
self._camera_menu = self.addMenu("Camera")
self._cam_separator = self._camera_menu.addSeparator()
self._reconnect_action = QAction("Reconnect", self)
self._reconnect_action.triggered.connect(self.reconnect_requested)
self._camera_menu.addAction(self._reconnect_action)
# Video menu
self._video_menu = self.addMenu("Video")
self._res_menu = self._video_menu.addMenu("Resolution")
# 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")
self._overlay_action = QAction("Show Overlay", self)
self._overlay_action.setCheckable(True)
self._overlay_action.setChecked(True)
self._overlay_action.toggled.connect(self.overlay_toggled)
debug_menu.addAction(self._overlay_action)
self._log_action = QAction("Console Logging", self)
self._log_action.setCheckable(True)
self._log_action.setChecked(False)
self._log_action.toggled.connect(self._on_log_toggled)
debug_menu.addAction(self._log_action)
debug_menu.addSeparator()
self._log_file_action = QAction("Log: (not started)", self)
self._log_file_action.setEnabled(False)
debug_menu.addAction(self._log_file_action)
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._format_group = QActionGroup(self)
self._format_group.setExclusive(True)
for fmt in camera_info.formats:
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)
self._format_group.addAction(action)
self._res_menu.addAction(action)
action.triggered.connect(self._on_format_action)
actions = self._format_group.actions()
if actions:
actions[0].setChecked(True)
# ------------------------------------------------------------------
# Slots
# ------------------------------------------------------------------
def _on_camera_action(self) -> None:
action = self.sender()
if action is None:
return
cam: CameraInfo = action.data()
logger.debug("Camera selected: %s", cam.name)
self.camera_selected.emit(cam)
self._populate_format_menu(cam)
def _on_format_action(self) -> None:
action = self.sender()
if action is None:
return
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)
self.log_toggled.emit(enabled)

View File

@@ -1,3 +0,0 @@
from duck_preview.app import main
main()

View File

@@ -1,47 +0,0 @@
from __future__ import annotations
import sys
from PySide6.QtCore import QTimer
from PySide6.QtMultimedia import QVideoSink
from PySide6.QtWidgets import QApplication
from duck_preview.camera.service import CameraService
from duck_preview.dispatcher.frame_dispatcher import FrameDispatcher
from duck_preview.main_window import MainWindow
from duck_preview.rendering.overlay import OverlayWidget
from duck_preview.rendering.video_widget import VideoWidget
from duck_preview.telemetry.collector import TelemetryCollector
def main() -> None:
app = QApplication(sys.argv)
app.setApplicationName("Duck Preview")
camera = CameraService()
video_sink = QVideoSink()
dispatcher = FrameDispatcher()
telemetry = TelemetryCollector()
video_widget = VideoWidget()
overlay = OverlayWidget()
camera.set_video_widget(video_widget)
camera.set_video_sink(video_sink)
video_sink.videoFrameChanged.connect(dispatcher.on_frame)
dispatcher.subscribe(telemetry.on_frame)
window = MainWindow(camera, video_widget, overlay)
camera.error_occurred.connect(lambda msg: overlay.set_metrics({"error": msg}))
metrics_timer = QTimer()
metrics_timer.timeout.connect(lambda: overlay.set_metrics(telemetry.metrics()))
metrics_timer.start(200)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

View File

@@ -1,59 +0,0 @@
from __future__ import annotations
from PySide6.QtCore import QObject, Signal
from PySide6.QtMultimedia import (
QCamera,
QCameraDevice,
QCameraFormat,
QMediaCaptureSession,
QMediaDevices,
QVideoSink,
)
from PySide6.QtMultimediaWidgets import QVideoWidget
class CameraService(QObject):
error_occurred = Signal(str)
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._session = QMediaCaptureSession()
self._camera: QCamera | None = None
@property
def session(self) -> QMediaCaptureSession:
return self._session
def set_video_widget(self, widget: QVideoWidget) -> None:
self._session.setVideoOutput(widget)
def set_video_sink(self, sink: QVideoSink) -> None:
self._session.setVideoSink(sink)
@staticmethod
def available_cameras() -> list[QCameraDevice]:
return QMediaDevices.videoInputs()
def is_active(self) -> bool:
return self._camera is not None and self._camera.isActive()
def start(self, device: QCameraDevice) -> None:
self.stop()
self._camera = QCamera(device, self)
self._camera.errorOccurred.connect(self._on_error)
self._session.setCamera(self._camera)
self._camera.start()
def stop(self) -> None:
if self._camera is not None:
self._camera.stop()
self._session.setCamera(None)
self._camera.deleteLater()
self._camera = None
def set_camera_format(self, fmt: QCameraFormat) -> None:
if self._camera is not None:
self._camera.setCameraFormat(fmt)
def _on_error(self, error: QCamera.Error, error_string: str) -> None:
self.error_occurred.emit(error_string)

View File

@@ -1,22 +0,0 @@
from __future__ import annotations
from collections.abc import Callable
from PySide6.QtCore import QObject
from PySide6.QtMultimedia import QVideoFrame
class FrameDispatcher(QObject):
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._subscribers: list[Callable[[QVideoFrame], None]] = []
def subscribe(self, callback: Callable[[QVideoFrame], None]) -> None:
self._subscribers.append(callback)
def unsubscribe(self, callback: Callable[[QVideoFrame], None]) -> None:
self._subscribers.remove(callback)
def on_frame(self, frame: QVideoFrame) -> None:
for cb in self._subscribers:
cb(frame)

View File

@@ -1,139 +0,0 @@
from __future__ import annotations
from PySide6.QtCore import QCameraPermission, Qt
from PySide6.QtGui import QAction, QCloseEvent
from PySide6.QtMultimedia import QCameraDevice, QMediaDevices
from PySide6.QtWidgets import QApplication, QGridLayout, QMainWindow, QWidget
from duck_preview.camera.service import CameraService
from duck_preview.rendering.overlay import OverlayWidget
from duck_preview.rendering.video_widget import VideoWidget
class MainWindow(QMainWindow):
def __init__(
self,
camera_service: CameraService,
video_widget: VideoWidget,
overlay_widget: OverlayWidget,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self._camera = camera_service
self._video_widget = video_widget
self._overlay = overlay_widget
self._media_devices = QMediaDevices()
self.setWindowTitle("Duck Preview")
self.resize(1280, 720)
central = QWidget()
self.setCentralWidget(central)
layout = QGridLayout(central)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(video_widget, 0, 0)
layout.addWidget(overlay_widget, 0, 0)
self._setup_menus()
self._media_devices.videoInputsChanged.connect(self._rebuild_camera_menu)
def _setup_menus(self) -> None:
menu_bar = self.menuBar()
self._camera_menu = menu_bar.addMenu("Camera")
self._rebuild_camera_menu()
self._resolution_menu = menu_bar.addMenu("Resolution")
self._resolution_menu.setEnabled(False)
self._fps_menu = menu_bar.addMenu("FPS")
self._fps_menu.setEnabled(False)
debug_menu = menu_bar.addMenu("Debug")
toggle_overlay = QAction("Show Metrics", self)
toggle_overlay.setCheckable(True)
toggle_overlay.setChecked(True)
toggle_overlay.triggered.connect(self._overlay.set_visible)
debug_menu.addAction(toggle_overlay)
def _rebuild_camera_menu(self) -> None:
self._camera_menu.clear()
cameras = CameraService.available_cameras()
for device in cameras:
action = QAction(device.description(), self)
action.triggered.connect(lambda checked, d=device: self._on_camera_selected(d))
self._camera_menu.addAction(action)
if not cameras:
action = QAction("No cameras detected", self)
action.setEnabled(False)
self._camera_menu.addAction(action)
def _on_camera_selected(self, device: QCameraDevice) -> None:
self._request_camera_permission(device)
def _request_camera_permission(self, device: QCameraDevice) -> None:
perm = QCameraPermission()
match QApplication.checkPermission(perm):
case Qt.PermissionStatus.Undetermined:
QApplication.requestPermission(
perm, self, lambda: self._request_camera_permission(device)
)
case Qt.PermissionStatus.Denied:
self._overlay.set_metrics(
{
"error": (
"Camera permission denied.\n"
"Grant access in System Settings > "
"Privacy & Security > Camera"
)
}
)
case Qt.PermissionStatus.Granted:
self._camera.start(device)
self._rebuild_resolution_menu(device)
def _rebuild_resolution_menu(self, device: QCameraDevice) -> None:
self._resolution_menu.clear()
self._resolution_menu.setEnabled(True)
formats = device.videoFormats()
seen: set[tuple[int, int]] = set()
for fmt in formats:
res = fmt.resolution()
key = (res.width(), res.height())
if key not in seen:
seen.add(key)
action = QAction(f"{res.width()}x{res.height()}", self)
action.triggered.connect(
lambda checked, d=device, w=res.width(), h=res.height(): (
self._on_resolution_selected(d, w, h)
)
)
self._resolution_menu.addAction(action)
def _on_resolution_selected(self, device: QCameraDevice, width: int, height: int) -> None:
self._rebuild_fps_menu(device, width, height)
def _rebuild_fps_menu(self, device: QCameraDevice, width: int, height: int) -> None:
self._fps_menu.clear()
self._fps_menu.setEnabled(True)
formats = device.videoFormats()
seen_labels: set[str] = set()
for fmt in formats:
res = fmt.resolution()
if res.width() == width and res.height() == height:
min_fps = round(fmt.minFrameRate())
max_fps = round(fmt.maxFrameRate())
label = f"{max_fps} FPS" if min_fps == max_fps else f"{min_fps}-{max_fps} FPS"
if label not in seen_labels:
seen_labels.add(label)
action = QAction(label, self)
action.triggered.connect(
lambda checked, f=fmt: self._camera.set_camera_format(f)
)
self._fps_menu.addAction(action)
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
self._camera.stop()
super().closeEvent(event)

View File

@@ -1,73 +0,0 @@
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter
from PySide6.QtWidgets import QWidget
class OverlayWidget(QWidget):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setAttribute(Qt.WA_TransparentForMouseEvents)
self.setAttribute(Qt.WA_NoSystemBackground)
self._visible = True
self._metrics: dict[str, float | int | str] = {}
def set_visible(self, visible: bool) -> None:
self._visible = visible
self.update()
def set_metrics(self, metrics: dict[str, float | int | str]) -> None:
self._metrics = metrics
self.update()
def paintEvent(self, event) -> None: # noqa: N802
if not self._visible or not self._metrics:
return
painter = QPainter(self)
painter.setRenderHint(QPainter.TextAntialiasing)
if "error" in self._metrics:
self._paint_error(painter)
else:
self._paint_metrics(painter)
def _paint_metrics(self, painter: QPainter) -> None:
lines = [
f"FPS: {self._metrics.get('fps', 0):>7}",
f"Frame: {self._metrics.get('frame_time_ms', 0):>7.1f} ms",
f"Frames: {self._metrics.get('frame_count', 0):>7}",
]
font = QFont("monospace", 11)
painter.setFont(font)
fm = QFontMetrics(font)
text_width = max(fm.horizontalAdvance(line) for line in lines) + 24
text_height = len(lines) * (fm.height() + 6) + 12
painter.fillRect(8, 8, text_width, text_height, QColor(0, 0, 0, 160))
painter.setPen(QColor(0, 255, 0))
for i, line in enumerate(lines):
y = 22 + i * (fm.height() + 6)
painter.drawText(16, y, line)
def _paint_error(self, painter: QPainter) -> None:
msg = str(self._metrics.get("error", "Unknown error"))
lines = msg.split("\n")
font = QFont("monospace", 12)
painter.setFont(font)
fm = QFontMetrics(font)
text_width = max(fm.horizontalAdvance(line) for line in lines) + 24
text_height = len(lines) * (fm.height() + 6) + 12
painter.fillRect(8, 8, text_width, text_height, QColor(0, 0, 0, 200))
painter.setPen(QColor(200, 50, 50))
for i, line in enumerate(lines):
y = 22 + i * (fm.height() + 6)
painter.drawText(16, y, line)

View File

@@ -1,10 +0,0 @@
from __future__ import annotations
from PySide6.QtMultimediaWidgets import QVideoWidget
from PySide6.QtWidgets import QWidget
class VideoWidget(QVideoWidget):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setMinimumSize(320, 240)

View File

@@ -1,39 +0,0 @@
from __future__ import annotations
import time
from collections import deque
from PySide6.QtCore import QObject
from PySide6.QtMultimedia import QVideoFrame
class TelemetryCollector(QObject):
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._timestamps: deque[float] = deque(maxlen=500)
def on_frame(self, frame: QVideoFrame) -> None:
self._timestamps.append(time.perf_counter())
def metrics(self) -> dict[str, float | int]:
if not self._timestamps:
return {"fps": 0, "frame_time_ms": 0.0, "frame_count": 0}
now = time.perf_counter()
cutoff = now - 1.0
recent = [t for t in self._timestamps if t >= cutoff]
fps = len(recent)
frame_time_ms = 0.0
if len(self._timestamps) >= 2:
diffs = [
self._timestamps[i] - self._timestamps[i - 1]
for i in range(1, len(self._timestamps))
]
frame_time_ms = (sum(diffs) / len(diffs)) * 1000 if diffs else 0.0
return {
"fps": fps,
"frame_time_ms": round(frame_time_ms, 2),
"frame_count": len(self._timestamps),
}

View File

@@ -1,150 +1,324 @@
# MVP Implementation Plan — Duck Preview
# Plan działania — MVP Camera Preview (PySide6)
## Stack
## Środowisko
- **Language:** Python 3.12
- **GUI/Framework:** PySide6 6.11 (QtMultimedia + QtWidgets)
- **Camera backend:** QCamera + QMediaCaptureSession + QVideoSink (native GPU)
- **Rendering:** QWidget (paintEvent) z QPainter — manual render z QVideoFrame → QImage
- **Testing:** pytest
- **Linting/Formatting:** ruff
## Architecture
```
CameraService
└─ QVideoSink.videoFrame ──→ FrameDispatcher.on_frame
├─ VideoWidget.on_frame (render klatki)
├─ TelemetryCollector.on_frame (timestamp)
└─ [Future AI subscribers]
TelemetryCollector.metrics() ──→ OverlayWidget.set_metrics()
(polled co 200ms przez QTimer w app.py)
```
## Kolejność implementacji
### 0. Project scaffolding
`pyproject.toml` — projekt, zależność PySide6, entry point, ruff config, pytest
`duck_preview/__init__.py`, `__main__.py` — entry point `python -m duck_preview`
Podkatalogi: `camera/`, `dispatcher/`, `rendering/`, `telemetry/`
### 1. CameraService (`camera/service.py`)
- Wrapper na `QCamera` + `QMediaCaptureSession` + `QVideoSink`
- `available_cameras()` — static, zwraca listę `QCameraDevice` z `QMediaDevices`
- `start(device)` / `stop()` — zarządzanie cyklem życia kamery
- `set_camera_format(fmt)` — zmiana rozdzielczości/FPS
- `sink` property — QVideoSink do podpięcia dispatchera
- `error_occurred` Signal — propagacja błędów kamery
### 2. FrameDispatcher (`dispatcher/frame_dispatcher.py`)
- Prosty pub/sub: `subscribe(cb)` / `unsubscribe(cb)`
- `on_frame(frame)` — wołany z sygnału QVideoSink, iteruje wszystkich subskrybentów
- Frame dropping: na razie brak (wszystkie callbacki szybkie)
### 3. TelemetryCollector (`telemetry/collector.py`)
- Subskrybent dispatchera
- Zbiera timestampy (`deque`, maxlen=500)
- `metrics()` → dict: fps (klatki z ostatniej sekundy), frame_time_ms (średnia delta między klatkami), frame_count
- Brak CPU usage (out of scope na MVP)
### 4. VideoWidget (`rendering/video_widget.py`)
- `QWidget` z `WA_OpaquePaintEvent`
- `on_frame(frame)``frame.toImage()` → zapis QImage → `update()`
- `paintEvent` — skalowanie z zachowaniem proporcji, centrowanie, letterboxing
- Brak klatek → wyświetla "No camera feed"
### 5. OverlayWidget (`rendering/overlay.py`)
- Przezroczysty QWidget (`WA_TransparentForMouseEvents`)
- Nakładka na video, rysuje tekst metryk (FPS, frame time, frame count)
- Semi-transparentne tło, zielona czcionka Consolas
- Toggle widoczności przez menu Debug
### 6. MainWindow (`main_window.py`)
- `QMainWindow` z menu barem:
- **Camera** — lista dostępnych kamer (dynamiczna, reaguje na `videoInputsChanged`)
- **Resolution** — dostępne rozdzielczości dla wybranej kamery
- **FPS** — dostępne FPS dla wybranej rozdzielczości
- **Debug** — toggle nakładki
- Central widget: GridLayout z VideoWidget + OverlayWidget (stacked)
### 7. App (`app.py`)
- `main()` — tworzy QApplication, instancjonuje i łączy zależności (ręczne DI)
- Wire: camera.sink → dispatcher.on_frame
- Wire: dispatcher → telemetry.on_frame, video_widget.on_frame
- QTimer 200ms: telemetry.metrics() → overlay.set_metrics()
- `__main__.py``from duck_preview.app import main; main()`
| Element | Wartość |
|---|---|
| Python | 3.12.10 (venv: `.venv-win`) |
| Framework GUI | PySide6 6.11.0 |
| Dev platform | Windows 11 |
| Target platform | Mac Mini (Intel i7, macOS Ventura) |
| Kamera docelowa | ELP USB Camera |
| Narzędzia | pytest, ruff, colorama |
---
## DI Wiring (ręczne, w app.py)
## Fazy realizacji
### Faza 0 — Projekt i scaffolding
Cel: ustalenie struktury katalogów i modułów przed napisaniem pierwszej linii logiki.
#### 0.1 Struktura projektu
```
app = QApplication(sys.argv)
camera = CameraService()
dispatcher = FrameDispatcher()
telemetry = TelemetryCollector()
video_widget = VideoWidget()
overlay = OverlayWidget()
camera.sink.videoFrame.connect(dispatcher.on_frame)
dispatcher.subscribe(telemetry.on_frame)
dispatcher.subscribe(video_widget.on_frame)
window = MainWindow(camera, video_widget, overlay)
# Poll telemetry co 200ms → overlay
metrics_timer = QTimer()
metrics_timer.timeout.connect(lambda: overlay.set_metrics(telemetry.metrics()))
metrics_timer.start(200)
window.show()
app.exec()
```
---
## Struktura plików
```
duck-preview/
├── pyproject.toml
├── notes/
│ ├── 01-mvp-preview.md
│ └── 01-mvp-plan.md
├── duck_preview/
duck-preview2/
├── app/
│ ├── __init__.py
│ ├── __main__.py
│ ├── app.py
│ ├── main_window.py
│ ├── main.py # entry point
│ ├── config.py # stałe, domyślne ustawienia
│ ├── camera/
│ │ ├── __init__.py
│ │ ── service.py
├── dispatcher/
│ │ ── camera_service.py # QCamera + QMediaCaptureSession
│ └── camera_enumerator.py # wykrywanie dostępnych kamer
│ ├── pipeline/
│ │ ├── __init__.py
│ │ └── frame_dispatcher.py
│ ├── rendering/
│ │ └── frame_dispatcher.py # dystrybucja klatek do subskrybentów
│ ├── telemetry/
│ │ ├── __init__.py
│ │ ── video_widget.py
│ └── overlay.py
└── telemetry/
│ │ ── telemetry_collector.py # zbieranie metryk FPS/frame time/CPU
── overlay/
│ ├── __init__.py
│ │ └── overlay_widget.py # przezroczysta warstwa QWidget
│ └── ui/
│ ├── __init__.py
── collector.py
└── tests/
├── __init__.py
├── test_dispatcher.py
── test_collector.py
── main_window.py # główne okno aplikacji
│ └── menu_bar.py # menu: kamera, rozdzielczość, FPS, debug
├── tests/
├── __init__.py
── test_camera_enumerator.py
│ └── test_telemetry_collector.py
├── notes/
├── requirements.txt
├── requirements-dev.txt
└── pyproject.toml # konfiguracja ruff + pytest
```
#### 0.2 Pliki konfiguracyjne
- `pyproject.toml` — konfiguracja ruff (linter/formatter) i pytest
- `requirements.txt` — zależności produkcyjne (PySide6)
- `requirements-dev.txt` — zależności deweloperskie (pytest, ruff)
- `.gitignore` — aktualizacja o artefakty Pythona
---
### Faza 1 — Camera Service
Cel: stabilne pobranie obrazu z kamery przez QtMultimedia.
#### 1.1 Camera Enumerator
- `QMediaDevices.videoInputs()` — lista dostępnych kamer
- Zwraca listę `QCameraDevice` z nazwą, id i obsługiwanymi formatami
- Obsługa braku kamer (komunikat, nie crash)
- Test jednostkowy: mockowanie `QMediaDevices`
#### 1.2 Camera Service
- Opakowuje `QCamera` + `QMediaCaptureSession`
- API:
- `start(device: QCameraDevice)` — uruchamia kamerę
- `stop()` — zatrzymuje kamerę
- `set_resolution(width, height)` — ustawia format
- `set_fps(fps)` — ustawia docelowy FPS
- `reconnect()` — restart po błędzie
- `QVideoSink` jako punkt odbioru klatek
- Sygnał `frame_ready(QVideoFrame)` do Frame Dispatcher
- Obsługa błędów kamery (`QCamera.errorOccurred`)
#### 1.3 Uwagi platformowe
| Aspekt | Windows 11 (dev) | macOS Ventura (target) |
|---|---|---|
| Backend | DirectShow / Media Foundation | AVFoundation |
| Kamera ELP | USB, standardowy UVC driver | USB, UVC |
| Format klatek | YUYV / MJPEG | YUYV / MJPEG |
| GPU rendering | ANGLE (OpenGL ES) | Metal |
---
### Faza 2 — Frame Dispatcher
Cel: dystrybucja klatek do wielu odbiorców bez blokowania akwizycji.
#### 2.1 Frame Dispatcher
- Wzorzec: publish-subscribe (lista callbacków)
- `subscribe(callback: Callable[[QVideoFrame], None])`
- `unsubscribe(callback)`
- `dispatch(frame: QVideoFrame)` — wywołuje wszystkich subskrybentów
- Klatki NIE są kopiowane — subskrybenci działają na referencji
- Subskrybenci mogą **pominąć klatkę** (tryb drop-if-busy)
- Wywołanie `dispatch` następuje w wątku GUI (slot połączony z `frame_ready`)
#### 2.2 Subskrybenci w Fazie 1
| Subskrybent | Działanie |
|---|---|
| Video Renderer | przekazuje klatkę do `QVideoSink` / `QVideoWidget` |
| Telemetry Collector | mierzy czas, zlicza klatki |
---
### Faza 3 — Video Renderer
Cel: renderowanie klatki w GUI bez zbędnych kopii.
#### 3.1 Podejście
- `QVideoWidget` jako główny widget podglądu
- `QMediaCaptureSession.setVideoOutput(QVideoWidget)` — ścieżka bezpośrednia, zero kopii
- Alternatywnie: `QVideoSink``QGraphicsVideoItem` dla przyszłych overlayów
- Domyślnie: `QVideoWidget` (prosta, niska latencja)
#### 3.2 Wymagania
- Preview nie blokuje wątku GUI
- Obsługa aspect ratio (letter/pillarbox)
- Resize okna bez migotania
---
### Faza 4 — Telemetry Collector
Cel: dokładne metryki pipeline'u wideo.
#### 4.1 Zbierane metryki
| Metryka | Metoda pomiaru |
|---|---|
| Realtime FPS | licznik klatek / okno 1 s |
| Frame time | `time.perf_counter()` między klatkami |
| Frame acquisition time | timestamp wejście frame_ready → dispatch |
| Rendering time | czas `QVideoWidget.update()` (opcjonalnie) |
| Dropped frames | detekcja przez numerację lub timestamp gap |
| CPU usage | `psutil.cpu_percent()` (dodać do requirements) |
| Memory usage | `psutil.virtual_memory()` (opcjonalnie) |
#### 4.2 API
- `TelemetryCollector` — subskrybent Frame Dispatcher
- `on_frame(frame: QVideoFrame)` — rejestruje timestamp klatki
- `get_snapshot() -> TelemetrySnapshot` — aktualny stan metryk (dataclass)
- `update_interval_ms: int` — jak często odświeżać snapshot (domyślnie 500 ms)
- Sygnał `metrics_updated(TelemetrySnapshot)` — emitowany co `update_interval_ms`
#### 4.3 TelemetrySnapshot (dataclass)
```python
@dataclass
class TelemetrySnapshot:
fps: float
frame_time_ms: float
dropped_frames: int
cpu_percent: float
memory_mb: float | None
timestamp: float
```
---
## Edge cases / uwagi
### Faza 5 — Overlay System
- Brak kamer → menu Camera pokazuje "No cameras detected"
- Brak klatek → VideoWidget pokazuje ciemne tło + napis
- Błąd kamery → CameraService emituje `error_occurred` (na razie tylko log)
- Zamknięcie okna → `closeEvent``camera.stop()`
- QVideoFrame.toImage() kopiuje dane — akceptowalne dla MVP
- Wszystkie obiekty żyją w main thread — brak problemów z threadingiem
Cel: wyświetlanie metryk na przezroczystej warstwie nad podglądem.
#### 5.1 Architektura
- `OverlayWidget(QWidget)` — przezroczysty widget (`WA_TransparentForMouseEvents`)
- Pozycjonowany absolutnie nad `QVideoWidget` (ten sam parent, wyższy z-index)
- `paintEvent` rysuje semi-przezroczysty prostokąt + tekst z metrykami
- Połączony z sygnałem `metrics_updated` — odświeża tylko gdy dane się zmienią
#### 5.2 Zawartość overlaya (MVP)
```
FPS: 60.0
Frame: 16.7 ms
Drop: 0
CPU: 12.3 %
```
#### 5.3 Sterowalność
- Widoczność overlaya: toggle przez menu Debug
- Pozycja: lewy górny róg (stała w MVP)
- Kolor tła: `rgba(0, 0, 0, 160)`
---
### Faza 6 — GUI / Main Window
Cel: minimalne, funkcjonalne okno aplikacji.
#### 6.1 MainWindow
- `QMainWindow` z `QVideoWidget` jako central widget
- `OverlayWidget` nałożony na video
- Obsługa resize → reposition overlay
- Tytuł okna: `Duck Preview`
#### 6.2 MenuBar
Menu **Camera**:
- Lista wykrytych kamer (radio-style)
- Separator
- Reconnect
Menu **Video**:
- Resolution submenu (pobierane dynamicznie z `QCameraDevice.videoFormats()`)
- FPS submenu
Menu **Debug**:
- Toggle overlay metryk
- Logowanie do konsoli (toggle)
#### 6.3 Startup flow
```
main.py
→ QApplication
→ CameraEnumerator.list_cameras()
→ MainWindow(cameras)
→ CameraService.start(cameras[0]) # pierwsza kamera lub ELP
→ FrameDispatcher.subscribe(telemetry, renderer)
→ app.exec()
```
---
### Faza 7 — Testy i walidacja
#### 7.1 Testy jednostkowe
| Moduł | Co testować |
|---|---|
| `CameraEnumerator` | lista kamer, brak kamer, format danych |
| `TelemetryCollector` | obliczenia FPS, wykrywanie dropów |
| `FrameDispatcher` | subskrypcja, odsubskrypcja, dispatch |
| `TelemetrySnapshot` | poprawność dataclass |
#### 7.2 Testy manualne (Windows dev)
- [ ] Uruchomienie z kamerą laptopa / USB webcam
- [ ] Przełączanie kamer
- [ ] Zmiana rozdzielczości
- [ ] Zmiana FPS
- [ ] Toggle overlay
- [ ] Reconnect po odłączeniu kamery
#### 7.3 Testy na Mac Mini (target)
- [ ] Wykrycie kamery ELP
- [ ] Poprawny format YUYV/MJPEG
- [ ] Wydajność AVFoundation vs DirectShow
- [ ] GPU rendering przez Metal
#### 7.4 Kryteria sukcesu (z PRD)
- Preview stabilny i płynny
- Latencja renderowania niska
- Dane telemetrii dokładne
- GUI responsywne
- Overlay działa poprawnie
- Architektura gotowa na subskrybentów AI
---
## Kolejność implementacji (sprint order)
```
Sprint 1: Faza 0 — scaffolding, pyproject.toml, requirements
Sprint 2: Faza 1 — CameraEnumerator + CameraService (bez GUI)
Sprint 3: Faza 3 — VideoRenderer + MainWindow (preview działa)
Sprint 4: Faza 2 — FrameDispatcher (refactor pipeline)
Sprint 5: Faza 4 — TelemetryCollector
Sprint 6: Faza 5 — OverlayWidget
Sprint 7: Faza 6 — MenuBar (camera/resolution/fps switch)
Sprint 8: Faza 7 — Testy, poprawki, walidacja na Mac Mini
```
---
## Zależności do dodania
```
# requirements.txt
PySide6>=6.7
psutil>=6.0
# requirements-dev.txt
pytest>=8.0
ruff>=0.4
```
---
## Uwagi cross-platform
1. **ELP camera** — kamera UVC, powinna działać bez dodatkowych sterowników na obu platformach. Sprawdzić obsługiwane rozdzielczości i FPS przez `QCameraDevice.videoFormats()`.
2. **Ścieżki absolutne** — unikać `os.path` na korzyść `pathlib.Path`.
3. **Threading** — wszystkie operacje Qt muszą odbywać się w wątku GUI. `TelemetryCollector` może używać `QTimer` zamiast osobnego wątku.
4. **Format klatek** — na macOS AVFoundation preferuje `BGRA` lub `NV12`. Konwersja powinna być leniwa i tylko gdy potrzebna (nie w hot path renderowania).
5. **High DPI** — włączyć `QApplication.setHighDpiScaleFactorRoundingPolicy` dla konsistencji Windows/Mac.
6. **Testowanie bez kamery**`CameraEnumerator` powinien umożliwiać dependency injection / mock dla środowisk CI.

View File

@@ -253,6 +253,7 @@ Architecture must support future additions:
* snapshots,
* streaming,
* remote sinks.
* play video files
Without major redesign.

194
notes/02-mvp-app.md Normal file
View File

@@ -0,0 +1,194 @@
# Stan aplikacji — MVP Camera Preview
Data: 2026-05-12
Środowisko dev: Windows 11, Python 3.12.10, PySide6 6.11.0
Środowisko docelowe: Mac Mini (Intel i7, macOS Ventura), kamera ELP USB
---
## Zaimplementowane moduły
### Struktura projektu
```
duck-preview2/
├── app/
│ ├── config.py # stałe i domyślne ustawienia
│ ├── main.py # entry point (python -m app.main)
│ ├── camera/
│ │ ├── camera_enumerator.py # wykrywanie kamer przez QMediaDevices
│ │ └── camera_service.py # zarządzanie QCamera + QMediaCaptureSession
│ ├── pipeline/
│ │ └── frame_dispatcher.py # pub/sub dystrybucja QVideoFrame
│ ├── telemetry/
│ │ └── telemetry_collector.py # pomiar FPS, frame time, CPU, pamięci
│ ├── overlay/
│ │ ├── overlay_layer.py # interfejs IOverlayLayer (ABC)
│ │ └── telemetry_overlay.py # implementacja — box z metrykami
│ └── ui/
│ ├── camera_view.py # CameraView — render + kompozycja overlayów
│ ├── main_window.py # główne okno, wiring komponentów
│ └── menu_bar.py # menu: Camera / Video / Debug
├── tests/
│ ├── test_frame_dispatcher.py # 8 testów jednostkowych
│ └── test_telemetry_collector.py # 7 testów jednostkowych
├── pyproject.toml # konfiguracja pytest + ruff
├── requirements.txt # PySide6, psutil
└── requirements-dev.txt # + pytest, ruff
```
### Opis komponentów
#### CameraEnumerator (`app/camera/camera_enumerator.py`)
- Wykrywa dostępne kamery przez `QMediaDevices.videoInputs()`
- Zwraca listę `CameraInfo` z nazwą, id i listą obsługiwanych formatów (rozdzielczość × FPS)
- Formaty deduplikowane i posortowane (największa rozdzielczość pierwsza)
- Obsługa braku kamer bez crasha
#### CameraService (`app/camera/camera_service.py`)
- Opakowuje `QCamera` + `QMediaCaptureSession` + `QVideoSink`
- API: `start(CameraInfo)`, `stop()`, `reconnect()`, `set_resolution()`, `set_fps()`
- Algorytm doboru formatu: score-based (priorytet: dopasowanie rozdzielczości, potem FPS)
- Sygnały: `frame_ready(QVideoFrame)`, `camera_started`, `camera_stopped`, `camera_error`
#### FrameDispatcher (`app/pipeline/frame_dispatcher.py`)
- Pub/sub: `subscribe(callback, drop_if_busy=True)` / `unsubscribe(callback)`
- `drop_if_busy=True` — klatka pomijana jeśli subskrybent jest zajęty (render)
- `drop_if_busy=False` — każda klatka dociera (telemetria)
- Odporny na wyjątki w subskrybentach (jeden nie blokuje pozostałych)
#### TelemetryCollector (`app/telemetry/telemetry_collector.py`)
- Subskrybuje każdą klatkę (`drop_if_busy=False`)
- Mierzy: FPS (okno 1 s), średni frame time (ring-buffer 120 próbek), dropped frames (heurystyka 2.5× avg)
- CPU: `psutil.Process.cpu_percent()` — tylko nasz proces, inicjalizowany w `__init__` (warmup)
- RAM: `memory_info().wset` (Windows private working set) lub `rss` (macOS/Linux)
- Emituje `metrics_updated(TelemetrySnapshot)` co 500 ms przez `QTimer`
#### IOverlayLayer (`app/overlay/overlay_layer.py`)
- Abstrakcja (ABC) dla pluggable overlayów
- Interface: `paint(painter: QPainter, video_rect: QRect)`, `visible: bool`, `name: str`
- Nowe overlaye nie wymagają modyfikacji żadnego istniejącego kodu
#### TelemetryOverlay (`app/overlay/telemetry_overlay.py`)
- Implementacja `IOverlayLayer`
- Rysuje semi-przezroczysty box z metrykami w lewym górnym rogu video
- Slot `on_metrics_updated(TelemetrySnapshot)` — odbiera dane z TelemetryCollector
- Format: FPS, Frame time, Drop count, CPU %, Mem MB
#### CameraView (`app/ui/camera_view.py`)
- Zwykły `QWidget` (nie `QVideoWidget`) — render przez `QPainter` w `paintEvent`
- Odbiera `QVideoFrame` przez slot `on_frame()`, konwertuje do `QImage.Format_RGB32`
- Letterboxing z zachowaniem aspect ratio
- Rejestr overlayów: `add_overlay_layer()`, `remove_overlay_layer()`, `set_all_overlays_visible()`
- W `paintEvent`: rysuje klatkę → iteruje po warstwach, każda dostaje `painter.save()/restore()`
#### MainWindow (`app/ui/main_window.py`)
- Wires together wszystkie komponenty
- Minimalny status bar (nazwa kamery, błędy)
- `closeEvent``CameraService.stop()`
#### AppMenuBar (`app/ui/menu_bar.py`)
- Menu **Camera**: lista wykrytych kamer (radio), Reconnect
- Menu **Video**: Resolution submenu, FPS submenu (pobierane z `QCameraDevice.videoFormats()`)
- Menu **Debug**: Show Overlay (toggle), Console Logging (toggle poziom logowania)
---
## Co działało na Windows 11 (dev)
- Wykrycie wbudowanej kamery laptopa
- Podgląd w czasie rzeczywistym
- Przełączanie rozdzielczości i FPS z menu
- Overlay z metrykami widoczny i aktualizowany
- Toggle overlay przez Debug menu
- Logowanie do konsoli przez Debug menu
- Reconnect po zmianie kamery
- Letterboxing przy resize okna
---
## Co próbowaliśmy i nie wyszło
### QVideoWidget jako renderer (porzucone)
**Podejście:** Użycie `QVideoWidget` jako centralnego widgetu + nałożenie `OverlayWidget` (child lub sibling).
**Problem:** Na Windows `QVideoWidget` tworzy natywne okno HWND z powierzchnią D3D (Media Foundation backend). Natywna powierzchnia jest rysowana poza hierarchią Qt i zawsze przykrywa wszystkie `QWidget` — niezależnie od:
- kolejności z-order (`raise_()`)
- rodzica (child/sibling)
- flag okna
- `WA_TranslucentBackground` / `WA_NoSystemBackground`
Żaden `QWidget` nie może się wyświetlić nad `QVideoWidget` na Windows.
**Próby ratowania:**
1. `OverlayWidget` jako child `QVideoWidget` — zasłonięty przez natywną powierzchnię
2. Kontener `QWidget` z `QVideoWidget` i `OverlayWidget` jako rodzeństwo — nadal zasłonięty
3. `setWindowFlags(FramelessWindowHint)` na child widget — odrywa widget od rodzica, tworzy osobne (niewidoczne) okno top-level
**Rozwiązanie:** Porzucenie `QVideoWidget`. Własny `CameraView(QWidget)` odbiera klatki przez `QVideoSink`, konwertuje do `QImage` i rysuje przez `QPainter`. Overlay w tym samym `paintEvent` — brak konfliktu z natywnym renderowaniem.
### OverlayWidget jako osobny QWidget (porzucone)
**Podejście:** `OverlayWidget` z `WA_TransparentForMouseEvents` i `WA_TranslucentBackground` nałożony na video.
**Problem:** Poza konfliktem z `QVideoWidget`, `WA_TranslucentBackground` na child widget działa tylko gdy rodzic też jest transparentny — Qt nie komponuje dziecka z tłem rodzica innym niż własne. W praktyce overlay był niewidoczny lub zasłaniał video czarnym prostokątem.
---
## Znane ograniczenia i uwagi
### Pomiar CPU
`psutil.Process.cpu_percent()` zwraca procent **względem jednego rdzenia** (np. 50% = pół rdzenia). Na wielordzeniowym procesorze 15% w Task Managerze (uśrednione po rdzeniach) może odpowiadać 50% na jednym rdzeniu. To nie jest błąd — to różna metodologia. Task Manager pokazuje `total_cpu / num_cores`, psutil pokazuje `cpu_time / wall_time`.
Jeśli potrzebny jest widok "jak Task Manager": `process.cpu_percent() / psutil.cpu_count()`.
### Pomiar RAM
`memory_info().wset` (Windows) = Private Working Set = to co Task Manager pokazuje w kolumnie "Pamięć". RSS zawiera też współdzielone biblioteki (Qt DLLs ~40 MB) i dlatego było zawyżone. Na macOS używane jest `rss` (tam `wset` nie istnieje).
### Wydajność konwersji klatek
`QVideoFrame.toImage()` + `convertToFormat(RGB32)` wykonuje się na CPU. Przy 1080p60 to koszt ~1-3 ms/klatkę. Dla MVP akceptowalny. Przy przyszłej integracji YOLO warto rozważyć bezpośredni dostęp do danych przez `QVideoFrame.map()` i przekazywanie raw bufora do GPU.
### Brak testów integracyjnych
Testy jednostkowe pokrywają `FrameDispatcher` i `TelemetryCollector` w izolacji (bez Qt event loop). Brak testów `CameraView`, `CameraService` i `MainWindow` — wymagałyby `QApplication` i mockowania urządzeń.
---
## Stan zgodności z PRD (01-mvp-preview.md)
| Wymaganie | Status |
|---|---|
| Realtime camera preview | Zaimplementowane |
| Camera switching | Zaimplementowane |
| Resolution selection | Zaimplementowane |
| FPS selection | Zaimplementowane |
| Reconnect/restart | Zaimplementowane |
| Realtime FPS metric | Zaimplementowane |
| Frame time metric | Zaimplementowane |
| Dropped frames detection | Zaimplementowane (heurystyka) |
| CPU usage metric | Zaimplementowane |
| Memory usage metric | Zaimplementowane |
| Overlay system | Zaimplementowane (IOverlayLayer) |
| Performance metrics display | Zaimplementowane (TelemetryOverlay) |
| Minimal GUI | Zaimplementowane |
| Camera menu | Zaimplementowane |
| Resolution/FPS menu | Zaimplementowane |
| Debug/telemetry options | Zaimplementowane |
| Architecture ready for AI subscribers | Zaimplementowane (IOverlayLayer + FrameDispatcher) |
| Low latency preview | Zaimplementowane (drop-if-busy w dispatcher) |
| Non-blocking GUI thread | Zaimplementowane |
---
## Następne kroki (poza MVP)
- Walidacja na Mac Mini z kamerą ELP (AVFoundation backend, macOS Ventura)
- Snapshot / recording
- Integracja YOLO: `YoloBboxOverlay(IOverlayLayer)` + worker w osobnym procesie
- Integracja OCR
- Optymalizacja konwersji klatek (QVideoFrame.map() → numpy → GPU)
- Testy integracyjne z QApplication + mock camera

View File

@@ -1,212 +0,0 @@
# Plan — macOS fix + QVideoWidget + QVideoSink dual output
## Cel
Przywrócić działanie kamery na macOS (Elgato) przez:
1. Przejście z manualnego `QPainter` renderowania na natywny `QVideoWidget`
2. Dodanie `QVideoSink` jako drugiego wyjścia (frame access dla telemetrii)
3. Obsługa `QCameraPermission` (Qt 6.5+)
4. Dokumentacja packagingu przez `pyside6-deploy` na macOS
## Kolejność implementacji
### 1. camera/service.py
```python
class CameraService(QObject):
error_occurred = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._session = QMediaCaptureSession()
self._camera: QCamera | None = None
def set_video_widget(self, widget: QVideoWidget) -> None:
self._session.setVideoOutput(widget)
def set_video_sink(self, sink: QVideoSink) -> None:
self._session.setVideoSink(sink)
@property
def session(self) -> QMediaCaptureSession:
return self._session
@staticmethod
def available_cameras() -> list[QCameraDevice]:
return QMediaDevices.videoInputs()
def is_active(self) -> bool:
return self._camera is not None and self._camera.isActive()
def start(self, device: QCameraDevice) -> None:
self.stop()
self._camera = QCamera(device, self)
self._camera.errorOccurred.connect(self._on_error)
self._session.setCamera(self._camera)
self._camera.start()
def stop(self) -> None:
if self._camera is not None:
self._camera.stop()
self._session.setCamera(None)
self._camera.deleteLater()
self._camera = None
def set_camera_format(self, fmt: QCameraFormat) -> None:
if self._camera is not None:
self._camera.setCameraFormat(fmt)
def _on_error(self, error, error_string):
self.error_occurred.emit(error_string)
```
Zmiany:
- Usunięto `self._sink` z `__init__`
- Usunięto `self._session.setVideoOutput(self._sink)`
- Dodano `set_video_widget(widget)``session.setVideoOutput(widget)`
- Dodano `set_video_sink(sink)``session.setVideoSink(sink)`
### 2. rendering/video_widget.py
```python
from __future__ import annotations
from PySide6.QtMultimediaWidgets import QVideoWidget
class VideoWidget(QVideoWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumSize(320, 240)
```
Zmiany:
- Dziedziczy po `QVideoWidget` zamiast `QWidget`
- Usunięto `on_frame(frame)` — nie potrzeba, renderowanie natywne
- Usunięto `paintEvent` — nie potrzeba, robi to Qt
- Usunięto `QImage`, `QPainter`, `SmoothPixmapTransform`
### 3. rendering/overlay.py
```python
font = QFont("monospace", 11) # zamiast "Consolas"
```
Dodatkowo: obsługa błędu w `paintEvent`:
- Jeśli `self._metrics` ma klucz `"error"` → pomiń normalne metryki, narysuj czerwony komunikat błędu
- Użyj `QColor(180, 40, 40)` zamiast zielonego
### 4. app.py — nowe DI wiring
```python
def main():
app = QApplication(sys.argv)
app.setApplicationName("Duck Preview")
camera = CameraService()
video_sink = QVideoSink()
dispatcher = FrameDispatcher()
telemetry = TelemetryCollector()
video_widget = VideoWidget()
overlay = OverlayWidget()
camera.set_video_widget(video_widget)
camera.set_video_sink(video_sink)
video_sink.videoFrameChanged.connect(dispatcher.on_frame)
dispatcher.subscribe(telemetry.on_frame)
window = MainWindow(camera, video_widget, overlay)
# Wire error → overlay
camera.error_occurred.connect(
lambda msg: overlay.set_metrics({"error": msg})
)
# Poll telemetry → overlay
metrics_timer = QTimer()
metrics_timer.timeout.connect(
lambda: overlay.set_metrics(telemetry.metrics())
)
metrics_timer.start(200)
window.show()
sys.exit(app.exec())
```
Zmiany:
- Tworzy `QVideoSink` jawnie
- `camera.set_video_widget(video_widget)` + `camera.set_video_sink(video_sink)`
- `video_sink.videoFrameChanged → dispatcher.on_frame`
- Usunięto `camera.sink` (property nie istnieje)
- `camera.error_occurred → overlay.set_metrics({"error": msg})`
### 5. main_window.py — permission flow
```python
# Importy
from PySide6.QtCore import QCameraPermission, Qt
from PySide6.QtWidgets import QApplication
# W _on_camera_selected:
def _on_camera_selected(self, device: QCameraDevice) -> None:
self._request_camera_permission(device)
def _request_camera_permission(self, device: QCameraDevice) -> None:
perm = QCameraPermission()
match QApplication.checkPermission(perm):
case Qt.PermissionStatus.Undetermined:
QApplication.requestPermission(perm, self,
lambda: self._request_camera_permission(device))
case Qt.PermissionStatus.Denied:
self._overlay.set_metrics({
"error": "Camera permission denied.\n"
"Grant access in System Settings > "
"Privacy & Security > Camera"
})
case Qt.PermissionStatus.Granted:
self._camera.start(device)
self._rebuild_resolution_menu(device)
```
### 6. pyside6-deploy.toml
```toml
[app]
script = "duck_preview/__main__.py"
name = "Duck Preview"
bundle_identifier = "com.bartool.duck-preview"
categories = "public.app-category.photography"
platforms = ["macos"]
```
### 7. Po zmianach
- `ruff check .` — czysty
- `ruff format .` — sformatowany
- `pytest -q` — 5 passed
---
## Podsumowanie architektury końcowej
```
CameraService
├─ session.setVideoOutput(VideoWidget) → natywne GPU rendering
└─ session.setVideoSink(VideoSink) → frame access
└─ videoFrameChanged
→ FrameDispatcher
├─ TelemetryCollector
└─ [future AI]
MainWindow
├─ _on_camera_selected
│ → QCameraPermission check (Undetermined/Denied/Granted)
│ → CameraService.start(device)
├─ error_occurred → OverlayWidget.set_metrics({"error": ...})
└─ QTimer 200ms → TelemetryCollector.metrics() → OverlayWidget
OverlayWidget
├─ normal: zielone metryki (FPS, frame time, frame count)
└─ error: czerwony komunikat (np. brak permisji)
```

View File

@@ -1,126 +0,0 @@
# macOS — Camera on macOS with PySide6
## Problem
Aplikacja uruchomiona w interpreted mode (`python -m duck_preview`) na macOS z kamerą Elgato nie wyświetla obrazu.
## Przyczyna
Oficjalny przykład PySide6 (`camera.qml` / examples/multimedia) zawiera kod:
```python
if sys.platform == "darwin":
is_nuitka = "__compiled__" in globals()
if not is_nuitka and sys.platform == "darwin":
print("This example does not work on macOS when Python is run "
"in interpreted mode. For this example to work on macOS, "
"package the example using pyside6-deploy")
sys.exit(0)
```
**macOS → AVFoundation → wymaga spakowania przez Nuitka/pyside6-deploy.**
`QCamera` na macOS potrzebuje properly bundled app structure (Info.plist, entitlements, code signing). W interpreted mode Python nie ma tego contextu.
## Rozwiązanie
Pakować aplikację przez `pyside6-deploy` (Nuitka) na macOS. Na Windows działa bez pakowania.
---
## Qt Permission API (`QCameraPermission`)
Od Qt 6.5+ dostępne jest `QCameraPermission` — nowoczesne API do proszenia o zgodę kamery.
### API
```python
from PySide6.QtCore import QCameraPermission, Qt
from PySide6.QtWidgets import QApplication
perm = QCameraPermission()
match QApplication.checkPermission(perm):
case Qt.PermissionStatus.Undetermined:
# Prosimy o zgodę → callback wywoła init ponownie
QApplication.requestPermission(perm, parent, callback)
return
case Qt.PermissionStatus.Denied:
# overlay: "Camera permission denied"
case Qt.PermissionStatus.Granted:
# uruchom kamerę
```
### Permission flow
```
User → wybiera kamerę w menu
→ MainWindow._on_camera_selected(device)
→ checkPermission(QCameraPermission)
├─ Undetermined → requestPermission(camera, parent, callback)
│ └─ callback → _on_camera_selected ponownie
├─ Denied → overlay: "Camera permission denied. Grant access in System Settings > Privacy & Security > Camera"
└─ Granted → CameraService.start(device)
```
---
## QVideoWidget + QVideoSink — dual output
PySide6 6.11 ma osobne metody w `QMediaCaptureSession`:
| Metoda | Przeznaczenie |
|--------|---------------|
| `setVideoOutput(QVideoWidget*)` | Natywne renderowanie (GPU) |
| `setVideoSink(QVideoSink*)` | Dostęp do klatek (telemetria, AI) |
Oba działają **równolegle** — nie ma konfliktu.
```
QMediaCaptureSession
├─ setVideoOutput(QVideoWidget) → natywne renderowanie
└─ setVideoSink(QVideoSink) → frame access
└─ videoFrameChanged → FrameDispatcher
```
QVideoSink dostarcza sygnał `videoFrameChanged(QVideoFrame)` — na to podpina się FrameDispatcher, a ten rozsyła do TelemetryCollector i przyszłych AI subscriberów.
---
## macOS — packaging
### pyside6-deploy
```bash
pip install nuitka
pyside6-deploy duck_preview/__main__.py --name "Duck Preview"
```
### pyside6-deploy.toml
```toml
[app]
script = "duck_preview/__main__.py"
name = "Duck Preview"
bundle_identifier = "com.bartool.duck-preview"
categories = "public.app-category.photography"
platforms = ["macos"]
```
Po spakowaniu powstaje `Duck Preview.app` — standalone bundle z dostępem do AVFoundation.
### Windows
Na Windows działa bez pakowania — `python -m duck_preview` w venv.
---
## Font fallback
`Consolas` nie istnieje na macOS. Używać `"monospace"` (generic font family — Qt mapuje na Menlo na macOS, Consolas na Windows).
## Error state w Overlay
Overlay wspiera wyświetlanie błędów (np. brak permisji):
- Normalny stan → zielone metryki (FPS, frame times)
- Error state → czerwony komunikat
- Przełączanie przez `overlay.set_metrics({"error": "message"})`

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

368
notes/04-mvp-uvc.md Normal file
View File

@@ -0,0 +1,368 @@
# Stan projektu po sesji: UVC controls + przebudowa menu formatu
## Kontekst
Aplikacja uruchomiona na docelowym Mac Mini. Kamera ELP dostarcza klatki w formacie NV12 (przez backend AVFoundation). Stare menu z osobnymi podmenu `Resolution` i `FPS` nie nadawało się do użycia — kamera eksponuje formaty jako kombinacje (rozdzielczość + fps + pixel format), a ich niezależne ustawianie dawało nieskoordynowane wyniki.
Dodano też interfejs do kontroli parametrów obrazu kamery.
---
## Zmiany w tej sesji
### 1. Menu formatu — przebudowane (`app/ui/menu_bar.py`)
**Przed:** dwa osobne podmenu — `Video → Resolution` i `Video → FPS`.
**Po:** jedno podmenu `Video → Resolution` z pozycjami reprezentującymi pełny format:
```
1920×1080 @ 30fps (NV12)
1920×1080 @ 30fps (MJPG)
1280×720 @ 30fps (NV12)
1280×720 @ 5fps (YUY2)
640×480 @ 30fps (MJPG)
...
```
Każda pozycja odpowiada dokładnie jednemu `CameraFormat` z `CameraInfo.formats`. Kolejność odzwierciedla sortowanie z `CameraEnumerator` (największa powierzchnia → najwyższe fps).
**Sygnały:**
- Usunięto: `resolution_selected(int, int)`, `fps_selected(float)`
- Dodano: `format_selected(CameraFormat)` — emituje pełny obiekt formatu
- Dodano: `camera_settings_requested()` — otwiera dialog ustawień
**Metody publiczne:**
- `populate_formats(camera_info)` — zamiast `populate_resolutions` + `populate_fps`
- `set_active_format(fmt)` — zaznacza aktywny format po zmianie (matching po w/h/fps/pixel_format)
- Usunięto: `set_console_level()` (przeniesione do wewnętrznego `_on_log_toggled`)
**Nowe menu `Image`:**
```
Image
└── Camera Settings… → otwiera CameraSettingsDialog
```
---
### 2. CameraService — `set_format()` (`app/camera/camera_service.py`)
**Przed:** dwie metody `set_resolution(w, h)` i `set_fps(fps)` przechowujące `_desired_width`, `_desired_height`, `_desired_fps` osobno.
**Po:** jedna metoda `set_format(fmt: CameraFormat)` przechowująca `_desired_fmt: CameraFormat | None`.
```python
def set_format(self, fmt: CameraFormat) -> None:
self._desired_fmt = fmt
if self._current_info is not None:
self.start(self._current_info) # stop+start jak poprzednio
```
**Scoring przy wyborze formatu Qt (`_apply_format`):**
Poprzednio scoringował tylko rozdzielczość i fps. Teraz uwzględnia też pixel format:
```python
exact_res = int(w == desired.width and h == desired.height) * 1000
exact_pf = int(pf == desired.pixel_format) * 500
exact_fps = int(abs(f - desired.max_fps) < 0.5) * 100
area_diff = abs(w * h - desired.width * desired.height)
score = exact_res + exact_pf + exact_fps - area_diff
```
Pixel format jest drugim co do ważności kryterium (po rozdzielczości, przed fps).
**Nowa właściwość:**
```python
@property
def qt_camera(self) -> QCamera | None:
...
```
Udostępnia wewnętrzny `QCamera` dla `CameraSettingsDialog` (potrzebny do ustawiania WB i exposure przez Qt API).
**Poprawione logowanie:**
`_log_actual_format()` loguje teraz ostrzeżenia zarówno dla FPS jak i rozdzielczości jeśli kamera nie dostarczyła tego co było żądane.
---
### 3. Warstwa UVC (`app/camera/uvc/`)
Nowy pakiet z abstrakcją dla sterowania parametrami obrazu przez UVC (USB Video Class).
#### Dlaczego osobna warstwa, nie Qt?
`QCamera.Feature` w Qt6 obejmuje: `ColorTemperature`, `ExposureCompensation`, `IsoSensitivity`, `ManualExposureTime`, `CustomFocusPoint`, `FocusDistance`. **Nie obejmuje** brightness, contrast, saturation, hue, sharpness, gamma, backlight compensation — są to niskopoziomowe UVC/V4L2 controls poza zakresem Qt Multimedia.
Sterowanie nimi wymaga osobnych bibliotek natywnych, innych na każdej platformie.
#### Dostępne biblioteki
| Biblioteka | Platforma | Instalacja | Uwagi |
|---|---|---|---|
| `duvc-ctl` | Windows only | `pip install duvc-ctl` | DirectShow, stabilna, PyPI, MIT |
| `pyuvc` | macOS + Linux | `brew install libusb jpeg-turbo && pip install pyuvc` | libuvc bindings, wymaga kompilacji |
| `uvc-py` | macOS + Linux (Win w planie) | `pip install uvc-py` | dev/alpha, brak UVC controls |
#### Struktura
```
app/camera/uvc/
├── __init__.py # make_uvc_controller(name) — factory z detekcją platformy
├── base.py # UvcControllerBase ABC, UvcParam enum, UvcParamInfo dataclass
├── stub.py # NullUvcController — no-op fallback
├── windows.py # WindowsUvcController — duvc-ctl (DirectShow)
└── macos.py # MacUvcController — pyuvc (libuvc)
```
#### `UvcParam` enum
```python
class UvcParam(Enum):
BRIGHTNESS
CONTRAST
SATURATION
HUE
SHARPNESS
GAMMA
WHITE_BALANCE
BACKLIGHT_COMPENSATION
EXPOSURE
```
#### `UvcParamInfo` dataclass
```python
@dataclass
class UvcParamInfo:
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
```
#### `UvcControllerBase` ABC — interfejs
```python
def open(self, device_name: str) -> bool # otwiera uchwyt do urządzenia
def close(self) -> None
def is_open(self) -> bool
def get_param_info(self, param: UvcParam) -> UvcParamInfo
def get_all_params(self) -> list[UvcParamInfo]
def set_value(self, param: UvcParam, value: int) -> bool
def set_auto(self, param: UvcParam, enabled: bool) -> bool
```
#### `make_uvc_controller(device_name)` — factory
Detekcja platformy w runtime:
```python
if system == "Windows":
WindowsUvcController (duvc-ctl)
elif system == "Darwin":
MacUvcController (pyuvc)
else:
NullUvcController
```
Jeśli biblioteka nie jest zainstalowana — `ImportError` przechwycony, fallback na `NullUvcController`. Loguje `WARNING` z instrukcją instalacji.
#### `WindowsUvcController` — szczegóły
- Łączy się z pierwszym urządzeniem którego nazwa zawiera `device_name` (case-insensitive)
- Mapowanie `UvcParam → duvc-ctl VidProp/CamProp` string:
- `BRIGHTNESS → "brightness"`, `CONTRAST → "contrast"`, ..., `EXPOSURE → "exposure"`
- `set_auto()` używa `duvc.CamMode.AUTO/MANUAL`
- `_refresh_supported()` odpytuje `cam.get_supported_properties()` po otwarciu
#### `MacUvcController` — szczegóły
- Używa `uvc.device_list()` do wyszukania urządzenia po nazwie
- Otwiera przez `uvc.Capture(uid)`
- Mapowanie `UvcParam → pyuvc display_name` string:
- `WHITE_BALANCE → "White Balance temperature"`, `EXPOSURE → "Absolute Exposure Time"`, itd.
- `auto_mode` via `ctrl.auto_mode` (atrybut pyuvc control)
---
### 4. CameraSettingsDialog (`app/ui/camera_settings_dialog.py`)
`QDialog` ze scrollowaną zawartością, dwoma sekcjami. Zmiany aplikowane **na żywo** (brak OK/Apply — tylko Close).
#### Sekcja Qt Camera Controls
| Kontrolka | Widget | Akcja |
|---|---|---|
| White Balance | `QComboBox` (8 trybów + Manual) | `camera.setWhiteBalanceMode()` |
| Colour Temp (K) | `QSpinBox` 200010000, krok 100 | `camera.setColorTemperature()` — widoczny tylko w trybie Manual |
| Exposure Mode | `QComboBox` (Auto / Manual) | `camera.setExposureMode()` |
| Exposure Time (s) | `QDoubleSpinBox`, 6 miejsc po przecinku | `camera.setManualExposureTime()` — widoczny tylko w trybie Manual |
Dynamiczna widoczność: pola `Colour Temp` i `Exposure Time` pojawiają się tylko gdy wybrano tryb Manual.
Tryby WB sprawdzane przez `camera.isWhiteBalanceModeSupported()` przed aplikacją — jeśli kamera nie obsługuje trybu, zmiana jest ignorowana (logowane DEBUG).
#### Sekcja UVC Camera Controls
Dla każdego `UvcParam`:
- Jeśli `supported=False`: wyświetla "Not supported" (disabled)
- Jeśli `supported=True`: `QSlider` + `QSpinBox` zsynchronizowane dwukierunkowo
- Jeśli `auto_supported=True`: dodatkowy `QCheckBox "Auto"` — po zaznaczeniu blokuje slider i spinbox
Jeśli `NullUvcController` (żadne kontrolki nieobsługiwane): sekcja wyświetla notatkę:
```
UVC controls not available.
Install duvc-ctl (Windows) or pyuvc (macOS) to enable.
```
---
### 5. MainWindow — aktualizacje (`app/ui/main_window.py`)
**Nowe pola:**
```python
self._uvc: UvcControllerBase = NullUvcController()
```
**Nowe metody:**
- `_open_uvc(cam)` — zamyka poprzedni UVC controller i otwiera nowy dla wybranej kamery
- `_on_format_selected(fmt)``camera_service.set_format(fmt)`
- `_on_settings_requested()` → tworzy `CameraSettingsDialog(qt_camera, uvc)` i wywołuje `.exec()`
**Zmiany w `_wire_signals()`:**
- Usunięto: `menu.resolution_selected`, `menu.fps_selected`
- Dodano: `menu.format_selected → _on_format_selected`
- Dodano: `menu.camera_settings_requested → _on_settings_requested`
**`closeEvent`:**
- Dodano: `self._uvc.close()` jeśli `is_open()`
---
### 6. Testy (`tests/test_uvc.py`) — nowy plik, 15 testów
| Klasa testowa | Co testuje |
|---|---|
| `TestNullUvcController` | `open` → False, `is_open` → False, `close` bez wyjątku, `get_param_info` → unsupported, `get_all_params` zwraca wszystkie `UvcParam`, `set_value`/`set_auto` → False bez wyjątku |
| `TestUvcParamInfo` | wartości domyślne dataclass, flaga `supported=False`, custom range |
| `TestMakeUvcControllerFallback` | zwraca instancję `UvcControllerBase`, fallback na stub gdy `ImportError` |
Łącznie testów: **35** (20 poprzednich + 15 nowych), wszystkie zielone.
---
## Decyzje architektoniczne
### Dlaczego `set_format()` zamiast osobnych `set_resolution`/`set_fps`?
Kamera ELP przez AVFoundation eksponuje formaty jako niepodzielne kombinacje `(W × H, fps, pixel_format)`. Próba niezależnej zmiany rozdzielczości bez zmiany fps (i odwrotnie) prowadziła do niespójnych stanów — kamera ignorowała jedną z wartości albo wybierała domyślny format. Jeden `CameraFormat` jako atomowa jednostka wyboru eliminuje problem.
### Dlaczego warstwowa architektura UVC a nie jedna biblioteka?
Brak cross-platform biblioteki Python dla UVC controls. `duvc-ctl` — Windows only (DirectShow), `pyuvc` — macOS/Linux (libuvc). Warstwa abstrakcji z platformową detekcją w runtime pozwala:
- Działać bez żadnej biblioteki (NullUvcController — grayed-out w UI)
- Dodać obsługę nowej platformy bez zmiany warstwy wyżej
- Testować logikę UI niezależnie od dostępności sprzętu
### Dlaczego Qt controls i UVC controls w jednym dialogu?
WB i exposure są dostępne przez oba API (Qt i UVC). Qt API jest wyżej poziomowe (tryby, EV), UVC daje surowe wartości rejestrów. Użytkownik widzi jedno spójne miejsce — nie musi wiedzieć przez które API co działa.
---
## Pliki zmienione / dodane
### Zmienione
| Plik | Co zmieniono |
|---|---|
| `app/ui/menu_bar.py` | Przebudowa menu: `format_selected` zamiast `resolution_selected`+`fps_selected`, nowe menu Image |
| `app/camera/camera_service.py` | `set_format()` zamiast `set_resolution`/`set_fps`, `qt_camera` property, lepsza walidacja w `_log_actual_format` |
| `app/ui/main_window.py` | UVC lifecycle, nowe sygnały menu, `_on_settings_requested` |
### Dodane
| Plik | Co zawiera |
|---|---|
| `app/camera/uvc/__init__.py` | `make_uvc_controller()` factory |
| `app/camera/uvc/base.py` | `UvcControllerBase`, `UvcParam`, `UvcParamInfo` |
| `app/camera/uvc/stub.py` | `NullUvcController` |
| `app/camera/uvc/windows.py` | `WindowsUvcController` (duvc-ctl) |
| `app/camera/uvc/macos.py` | `MacUvcController` (pyuvc) |
| `app/ui/camera_settings_dialog.py` | `CameraSettingsDialog` |
| `tests/test_uvc.py` | 15 testów UVC layer |
---
## Instalacja bibliotek UVC
### Mac Mini (cel docelowy)
```bash
brew install libusb jpeg-turbo
pip install pyuvc
```
### Windows (dev)
```bash
pip install duvc-ctl
```
### Bez bibliotek
Aplikacja działa normalnie — sekcja UVC w `CameraSettingsDialog` wyświetla komunikat o braku biblioteki. Qt controls (WB, Exposure) działają niezależnie od UVC.
---
## Next Steps
1. Przetestować na Mac Mini z kamerą ELP:
- czy `MacUvcController` otwiera urządzenie po `pip install pyuvc`
- czy slidery zmieniają rzeczywiste parametry kamery
- czy `Camera Settings…` poprawnie pokazuje zakresy dla ELP (np. brightness 0255)
2. Sprawdzić czy AVFoundation na macOS honoruje `setCameraFormat` bez stop+start (Qt docs mówią że na macOS inny proces może nadpisać format — `_log_actual_format` oznaczy to jako warning)
3. Dodać `set_active_format()` call po `_log_actual_format()` żeby menu zaznaczało faktycznie działający format (nie żądany)
4. Rozważyć `QSlider` z etykietą wartości min/max/default w dialogu — aktualnie są tylko slider + spinbox
5. (Opcjonalnie) Preset systemu: zapisz/wczytaj ustawienia do JSON per kamera
---
## Critical Context
- `CameraService.qt_camera``QCamera | None` — potrzebny do `CameraSettingsDialog`, jest `None` gdy kamera zatrzymana
- `make_uvc_controller(name)` zwraca **już otwarty lub próbujący otworzyć** kontroler; `MainWindow._open_uvc()` nie wywołuje `.open()` ponownie jeśli `is_open()` zwróci True
- `MacUvcController` używa `uvc.device_list()` (pyuvc API) — wymaga że kamera **nie jest** aktywnie używana przez inny proces (ograniczenie libusb na macOS)
- `WindowsUvcController` działa **równolegle** z Qt — DirectShow pozwala na jednoczesny dostęp do controls i streamu
- Testy UVC nie wymagają sprzętu ani bibliotek natywnych — `NullUvcController` jest czystym Pythonem
## Uruchamianie
```bash
# Windows dev
.venv-win\Scripts\python.exe -m app.main
# Mac Mini (po instalacji pyuvc)
.venv/bin/python -m app.main
```

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

View File

@@ -1,21 +1,43 @@
[project]
name = "duck-preview"
version = "0.1.0"
description = "Realtime camera preview application with performance monitoring"
description = "Realtime camera preview application with telemetry"
requires-python = ">=3.12"
dependencies = [
"PySide6>=6.11",
"PySide6>=6.7",
"psutil>=6.0",
]
[project.scripts]
duck-preview = "duck_preview.app:main"
duck-preview = "app.main:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v"
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W"]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
"B", # flake8-bugbear
"N", # pep8-naming
]
ignore = [
"B008", # do not perform function calls in default arguments
]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff.lint.isort]
known-first-party = ["app"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"

View File

@@ -1,6 +0,0 @@
[app]
script = "duck_preview/__main__.py"
name = "Duck Preview"
bundle_identifier = "com.bartool.duck-preview"
categories = "public.app-category.photography"
platforms = ["macos"]

3
requirements-dev.txt Normal file
View File

@@ -0,0 +1,3 @@
-r requirements.txt
pytest>=8.0
ruff>=0.4

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
PySide6>=6.7
psutil>=6.0

View File

@@ -1,22 +0,0 @@
import time
from unittest.mock import MagicMock
from duck_preview.telemetry.collector import TelemetryCollector
def test_metrics_empty():
collector = TelemetryCollector()
metrics = collector.metrics()
assert metrics == {"fps": 0, "frame_time_ms": 0.0, "frame_count": 0}
def test_metrics_after_frames():
collector = TelemetryCollector()
mock = MagicMock()
for _ in range(30):
collector.on_frame(mock)
time.sleep(0.002)
metrics = collector.metrics()
assert metrics["frame_count"] >= 30
assert metrics["frame_time_ms"] > 0

View File

@@ -1,47 +0,0 @@
from duck_preview.dispatcher.frame_dispatcher import FrameDispatcher
def test_subscribe_notified():
dispatcher = FrameDispatcher()
received = []
def cb(frame):
received.append(frame)
dispatcher.subscribe(cb)
dispatcher.on_frame("test")
assert received == ["test"]
def test_unsubscribe_not_notified():
dispatcher = FrameDispatcher()
received = []
def cb(frame):
received.append(frame)
dispatcher.subscribe(cb)
dispatcher.unsubscribe(cb)
dispatcher.on_frame("test")
assert received == []
def test_multiple_subscribers():
dispatcher = FrameDispatcher()
received_1 = []
received_2 = []
def cb1(frame):
received_1.append(frame)
def cb2(frame):
received_2.append(frame)
dispatcher.subscribe(cb1)
dispatcher.subscribe(cb2)
dispatcher.on_frame("test")
assert received_1 == ["test"]
assert received_2 == ["test"]

View File

@@ -0,0 +1,76 @@
"""Tests for FrameDispatcher."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from app.pipeline.frame_dispatcher import FrameDispatcher
def _make_frame():
frame = MagicMock()
frame.isValid.return_value = True
return frame
class TestFrameDispatcher:
def setup_method(self):
# FrameDispatcher is a QObject — needs QApplication
# Use minimal mock to avoid Qt dependency in unit tests
with patch("app.pipeline.frame_dispatcher.QObject.__init__", return_value=None):
self.dispatcher = FrameDispatcher.__new__(FrameDispatcher)
self.dispatcher._subscribers = []
self.dispatcher._frame_count = 0
self.dispatcher._last_dispatch_time = 0.0
def test_subscribe_adds_subscriber(self):
cb = MagicMock()
self.dispatcher.subscribe(cb)
assert self.dispatcher.subscriber_count() == 1
def test_subscribe_same_callback_twice_is_noop(self):
cb = MagicMock()
self.dispatcher.subscribe(cb)
self.dispatcher.subscribe(cb)
assert self.dispatcher.subscriber_count() == 1
def test_unsubscribe_removes_subscriber(self):
cb = MagicMock()
self.dispatcher.subscribe(cb)
self.dispatcher.unsubscribe(cb)
assert self.dispatcher.subscriber_count() == 0
def test_unsubscribe_nonexistent_does_not_raise(self):
cb = MagicMock()
self.dispatcher.unsubscribe(cb) # should not raise
def test_dispatch_calls_all_subscribers(self):
cb1 = MagicMock()
cb2 = MagicMock()
self.dispatcher.subscribe(cb1)
self.dispatcher.subscribe(cb2)
frame = _make_frame()
self.dispatcher.dispatch(frame)
cb1.assert_called_once_with(frame)
cb2.assert_called_once_with(frame)
def test_dispatch_increments_frame_count(self):
frame = _make_frame()
self.dispatcher.dispatch(frame)
self.dispatcher.dispatch(frame)
assert self.dispatcher.frame_count == 2
def test_dispatch_with_no_subscribers_does_not_raise(self):
frame = _make_frame()
self.dispatcher.dispatch(frame) # should not raise
def test_subscriber_exception_does_not_stop_others(self):
def bad_cb(f):
raise RuntimeError("boom")
good_cb = MagicMock()
self.dispatcher.subscribe(bad_cb)
self.dispatcher.subscribe(good_cb)
frame = _make_frame()
self.dispatcher.dispatch(frame) # should not raise
good_cb.assert_called_once_with(frame)

View File

@@ -0,0 +1,139 @@
"""Tests for TelemetryCollector."""
from __future__ import annotations
import time
from collections import deque
from unittest.mock import MagicMock, patch
class TestTelemetryCollector:
"""Test telemetry calculations in isolation (no Qt event loop required)."""
def _make_collector(self, cpu_count: int = 8):
"""Construct a TelemetryCollector bypassing Qt machinery."""
from app.telemetry.telemetry_collector import TelemetryCollector
with patch.object(TelemetryCollector, "__init__", return_value=None):
col = TelemetryCollector.__new__(TelemetryCollector)
col._frame_times = deque(maxlen=120)
col._last_frame_time = 0.0
col._total_frames = 0
col._dropped_frames = 0
col._fps_window = deque()
col._fps_window_size_s = 1.0
col._target_fps = None
col._cpu_count = cpu_count
col._process = MagicMock()
# Simulate Windows: wset takes priority over rss
mem_info = MagicMock()
mem_info.wset = 50 * 1024 * 1024 # 50 MB private working set
mem_info.rss = 70 * 1024 * 1024 # RSS (larger, includes shared)
col._process.memory_info.return_value = mem_info
col._process.cpu_percent.return_value = 0.0
return col
def test_initial_snapshot_has_zero_fps(self):
col = self._make_collector()
snap = col._compute_snapshot()
assert snap.fps == 0.0
def test_fps_counts_frames_in_window(self):
col = self._make_collector()
now = time.perf_counter()
for i in range(30):
col._fps_window.append(now - 0.9 + i * 0.03)
snap = col._compute_snapshot()
assert snap.fps == 30.0
def test_fps_excludes_old_frames(self):
col = self._make_collector()
now = time.perf_counter()
# 10 frames older than 1 second — should not count
for i in range(10):
col._fps_window.append(now - 2.0 + i * 0.05)
# 5 frames within the last second
for i in range(5):
col._fps_window.append(now - 0.4 + i * 0.05)
snap = col._compute_snapshot()
assert snap.fps == 5.0
def test_frame_time_average(self):
col = self._make_collector()
interval = 0.0333
for _ in range(10):
col._frame_times.append(interval)
snap = col._compute_snapshot()
assert abs(snap.frame_time_ms - interval * 1000) < 0.1
def test_drop_detection(self):
col = self._make_collector()
normal_interval = 0.016
now = time.perf_counter()
col._last_frame_time = now - normal_interval * 10
for _ in range(9):
col._last_frame_time += normal_interval
col._frame_times.append(normal_interval)
col._last_frame_time = now
with patch("app.telemetry.telemetry_collector.time") as mock_time:
col._last_frame_time = now
big_delta = normal_interval * 5 # 5× average → drop
mock_time.perf_counter.return_value = now + big_delta
delta = big_delta
col._frame_times.append(delta)
avg = sum(col._frame_times) / len(col._frame_times)
if delta > avg * 2.5:
col._dropped_frames += 1
assert col._dropped_frames == 1
def test_reset_counters(self):
col = self._make_collector()
col._total_frames = 100
col._dropped_frames = 5
col._frame_times.append(0.016)
col._fps_window.append(time.perf_counter())
col.reset_counters()
assert col._total_frames == 0
assert col._dropped_frames == 0
assert len(col._frame_times) == 0
assert len(col._fps_window) == 0
def test_snapshot_memory_mb(self):
col = self._make_collector()
snap = col._compute_snapshot()
assert snap.memory_mb == 50.0
def test_cpu_sys_is_core_divided_by_cpu_count(self):
col = self._make_collector(cpu_count=8)
col._process.cpu_percent.return_value = 80.0 # 80% of one core
snap = col._compute_snapshot()
assert snap.cpu_percent_core == 80.0
assert snap.cpu_percent_sys == round(80.0 / 8, 1)
def test_cpu_sys_never_exceeds_100_on_single_core_machine(self):
col = self._make_collector(cpu_count=1)
col._process.cpu_percent.return_value = 95.0
snap = col._compute_snapshot()
assert snap.cpu_percent_sys == snap.cpu_percent_core == 95.0
def test_cpu_sys_le_cpu_core(self):
"""cpu_percent_sys must always be <= cpu_percent_core."""
col = self._make_collector(cpu_count=4)
col._process.cpu_percent.return_value = 150.0
snap = col._compute_snapshot()
assert snap.cpu_percent_sys <= snap.cpu_percent_core
def test_target_fps_none_by_default(self):
col = self._make_collector()
snap = col._compute_snapshot()
assert snap.target_fps is None
def test_set_target_fps_reflected_in_snapshot(self):
col = self._make_collector()
col.set_target_fps(60.0)
snap = col._compute_snapshot()
assert snap.target_fps == 60.0

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)