Files
duck-preview/notes/01-mvp-plan.md

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ę 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__.pyfrom 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 → closeEventcamera.stop()
  • QVideoFrame.toImage() kopiuje dane — akceptowalne dla MVP
  • Wszystkie obiekty żyją w main thread — brak problemów z threadingiem