5.1 KiB
5.1 KiB
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ęQCameraDevicezQMediaDevicesstart(device)/stop()— zarządzanie cyklem życia kameryset_camera_format(fmt)— zmiana rozdzielczości/FPSsinkproperty — QVideoSink do podpięcia dispatcheraerror_occurredSignal — 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)
QWidgetzWA_OpaquePaintEventon_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)
QMainWindowz 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
- Camera — lista dostępnych kamer (dynamiczna, reaguje na
- 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