Files
duck-preview/notes/04-mvp-uvc.md

13 KiB
Raw Blame History

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.

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:

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ść:

@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

class UvcParam(Enum):
    BRIGHTNESS
    CONTRAST
    SATURATION
    HUE
    SHARPNESS
    GAMMA
    WHITE_BALANCE
    BACKLIGHT_COMPENSATION
    EXPOSURE

UvcParamInfo dataclass

@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

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:

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:

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)

brew install libusb jpeg-turbo
pip install pyuvc

Windows (dev)

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_cameraQCamera | 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

# Windows dev
.venv-win\Scripts\python.exe -m app.main

# Mac Mini (po instalacji pyuvc)
.venv/bin/python -m app.main