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

369 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```