151 lines
5.1 KiB
Markdown
151 lines
5.1 KiB
Markdown
# MVP Implementation Plan — Duck Preview
|
|
|
|
## Stack
|
|
|
|
- **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()`
|
|
|
|
---
|
|
|
|
## DI Wiring (ręczne, w app.py)
|
|
|
|
```
|
|
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/
|
|
│ ├── __init__.py
|
|
│ ├── __main__.py
|
|
│ ├── app.py
|
|
│ ├── main_window.py
|
|
│ ├── camera/
|
|
│ │ ├── __init__.py
|
|
│ │ └── service.py
|
|
│ ├── dispatcher/
|
|
│ │ ├── __init__.py
|
|
│ │ └── frame_dispatcher.py
|
|
│ ├── rendering/
|
|
│ │ ├── __init__.py
|
|
│ │ ├── video_widget.py
|
|
│ │ └── overlay.py
|
|
│ └── telemetry/
|
|
│ ├── __init__.py
|
|
│ └── collector.py
|
|
└── tests/
|
|
├── __init__.py
|
|
├── test_dispatcher.py
|
|
└── test_collector.py
|
|
```
|
|
|
|
---
|
|
|
|
## Edge cases / uwagi
|
|
|
|
- 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
|