Files
duck-preview/notes/05-mvp-yolo.md

16 KiB
Raw Blame History

Stan projektu po sesji: YOLO inference + odtwarzanie wideo

Poprzedni stan: 04-mvp-uvc.md


Kontekst

Po uruchomieniu aplikacji na Mac Mini z kamerą ELP, kolejny krok to weryfikacja wytrenowanego modelu YOLO. Wymagania:

  • Model działa w osobnym procesie — crash workera nie wywala GUI
  • Inference nie blokuje i nie spowalnia podglądu kamery
  • Worker ignoruje klatki dopóki analizuje poprzednią (drop-if-busy)
  • Możliwość wczytania pliku wideo zamiast kamery do oceny modelu
  • Bbox narysowany na nowej warstwie overlay
  • W przyszłości OCR będzie działał w tym samym procesie co YOLO

Nowe pakiety

app/video/

app/video/
├── __init__.py
└── video_player.py

app/inference/

app/inference/
├── __init__.py
├── worker.py           # funkcja uruchamiana w subprocess
├── worker_manager.py   # InferenceManager (QObject) — IPC, polling, auto-restart
└── bbox_overlay.py     # BboxOverlay(IOverlayLayer) — rysuje bbox+label+conf

Szczegółowy opis zmian

1. app/video/video_player.pyVideoPlayer

Nowa klasa VideoPlayer(QObject) — identyczny interfejs sygnałowy jak CameraService:

frame_ready     = Signal(QVideoFrame)
playback_started = Signal()
playback_stopped = Signal()
playback_error   = Signal(str)

Wewnętrznie: QMediaPlayer + QVideoSink. Obsługuje formaty: .mp4, .avi, .mov, .mkv, .m4v, .webm (cokolwiek obsługuje FFmpeg backend Qt).

Odtwarzanie w czasie rzeczywistym (1×). Brak seek/pauzy — tylko Open + Stop.

MainWindow podłącza do FrameDispatcher albo CameraService.frame_ready, albo VideoPlayer.frame_ready — nigdy obu naraz. Przełączanie przez _switch_to_camera() / _switch_to_video().


2. app/inference/worker.py — worker process

Struktury IPC (NamedTuple — pickle-safe)

class FramePacket(NamedTuple):
    frame_id: int
    raw_bytes: bytes    # RGB, (H×W×3)
    width: int
    height: int
    channels: int       # zawsze 3

class WorkerReadyPacket(NamedTuple):
    device: str         # "cpu" | "mps" — wysyłany raz po załadowaniu modelu

class ResultPacket(NamedTuple):
    frame_id: int
    detections: list    # list of (x1, y1, x2, y2, conf, label)
    width: int
    height: int
    elapsed_ms: float   # czas wywołania model() w ms

Protokół IPC

input_queue  ← FramePacket
output_queue → WorkerReadyPacket  (raz, zaraz po załadowaniu modelu)
             → ResultPacket       (po każdej analizowanej klatce)
             → None               (tylko przy błędzie ładowania modelu)

_select_device() — wybór urządzenia

Wywoływany raz przy starcie workera (nie per-frame jak wcześniej):

def _select_device() -> str:
    if platform.system() == "Darwin":
        if torch.backends.mps.is_available():
            return "mps"   # Metal GPU na macOS
    return "cpu"

device jest przekazywany do _load_model() i do każdego wywołania _infer(). Eliminuje redundantne wykrywanie urządzenia przy każdej klatce.

_infer() — pomiar czasu

t0 = time.perf_counter()
results = model(frame_np, device=device, verbose=False)
elapsed_ms = (time.perf_counter() - t0) * 1000.0

elapsed_ms trafia do ResultPacket i jest logowany w managerze przy detekcjach.


3. app/inference/worker_manager.pyInferenceManager

Sygnały

detections_ready      = Signal(object, object)  # list[Detection], tuple[int,int]
detection_count_updated = Signal(int)           # łączna liczba klatek z detekcją
inference_stats_updated = Signal(str, float)    # device, avg_elapsed_ms
inference_device_changed = Signal(str)          # emitowany raz po WorkerReadyPacket
inference_started     = Signal()
inference_stopped     = Signal()
inference_error       = Signal(str)

Mechanizm drop-if-busy

def submit_frame(self, frame: QVideoFrame) -> None:
    if not self.is_running or self._paused or self._busy:
        return   # klatka odrzucona cicho
    # konwersja + put_nowait → self._busy = True

self._busy wraca do False dopiero gdy _poll_output() odbierze ResultPacket. Gwarantuje że nigdy nie ma więcej niż jedna klatka w locie.

Konwersja klatki w GUI thread

Zamiast frame.bits(0) (dawało tylko płaszczyznę Y dla NV12):

image = frame.toImage()                           # Qt dekoduje NV12/YUV/MJPG → RGB
image = image.convertToFormat(Format_RGB32)       # packed BGRX
arr = np.frombuffer(image.bits(), dtype=np.uint8).reshape((H, W, 4))
rgb = arr[:, :, [2, 1, 0]].copy()                # BGRX → RGB, drop X

Obsługuje każdy pixel format jaki kamera może dostarczyć.

Rolling average elapsed_ms

_elapsed_window: deque[float]  # maxlen=10
avg_ms = sum(_elapsed_window) / len(_elapsed_window)

Emitowany przez inference_stats_updated(device, avg_ms) po każdym ResultPacket.

_poll_output() — obsługa WorkerReadyPacket

if isinstance(item, WorkerReadyPacket):
    self._current_device = item.device
    self.inference_device_changed.emit(item.device)
    continue

Odróżnienie od ResultPacket przez isinstance — nie wymaga sentinel wartości.

Auto-restart

  • Watchdog co 2s sprawdza process.is_alive()
  • Timeout 10s bez odpowiedzi → terminate + restart
  • Max 3 restartów (konfigurowalny przez INFERENCE_MAX_RESTARTS)
  • Po przekroczeniu: QMessageBox.critical + overlay wyłączony

Logowanie — tylko detekcje

if detections:
    logger.info(
        "frame %d: %d detection(s) in %.1f ms — %s",
        packet.frame_id, len(detections), packet.elapsed_ms, conf_summary,
    )

Klatki bez detekcji: brak logu. conf_summary = "label 0.94, label 0.81".


4. app/inference/bbox_overlay.pyBboxOverlay

class Detection(NamedTuple):
    x1: float; y1: float; x2: float; y2: float
    conf: float
    label: str

Współrzędne w pikselach oryginalnej klatki. paint() skaluje do video_rect:

scale_x = video_rect.width() / src_w
scale_y = video_rect.height() / src_h
wx1 = video_rect.x() + int(det.x1 * scale_x)
# ...

Każdy bbox: prostokąt w kolorze BBOX_COLOR + label "label 0.87" na tle BBOX_LABEL_BG_COLOR nad lewym górnym rogiem boxa (lub wewnątrz gdy brakuje miejsca).

BboxOverlay.visible = False domyślnie — pojawia się dopiero po włączeniu inference toggle.


5. Menu — zmiany w app/ui/menu_bar.py

Dodano dwa nowe menu przed istniejącymi:

File
  ├── Open Video…         QFileDialog (.mp4 .avi .mov .mkv .m4v .webm)
  └── Close Video         disabled gdy źródło = kamera

Model
  ├── Load Model…         QFileDialog (.pt .pth)
  ├── Enable Inference    QAction checkable, disabled do momentu załadowania modelu
  └── Model: (none)       disabled — info o załadowanym pliku

Nowe sygnały:

  • video_file_selected(str) — ścieżka pliku wideo
  • video_closed() — powrót do kamery
  • model_file_selected(str) — ścieżka modelu
  • inference_toggled(bool) — włącz/wyłącz inference

6. app/ui/main_window.py — integracja

Przełączanie źródła klatek

def _switch_to_camera(self):
    video_player.frame_ready.disconnect(dispatcher.dispatch)
    camera_service.frame_ready.connect(dispatcher.dispatch)

def _switch_to_video(self):
    camera_service.frame_ready.disconnect(dispatcher.dispatch)
    video_player.frame_ready.connect(dispatcher.dispatch)

Dispatcher i wszyscy subskrybenci (CameraView, TelemetryCollector, InferenceManager) są podłączeni do dispatchera — źródło klatek jest dla nich transparentne.

Inference toggle

def _on_inference_toggled(self, enabled: bool):
    if enabled:
        inference.resume()
        dispatcher.subscribe(inference.submit_frame, drop_if_busy=True)
        bbox_overlay.visible = True
        detection_label.setVisible(True)
    else:
        inference.pause()
        dispatcher.unsubscribe(inference.submit_frame)
        bbox_overlay.clear()
        bbox_overlay.visible = False
        detection_label.setVisible(False)
        telemetry.clear_inference_stats()

pause() nie zatrzymuje procesu — tylko blokuje submit_frame. Proces pozostaje załadowany w pamięci.

Status bar — counter detekcji

self._detection_label = QLabel("")   # addPermanentWidget (prawa strona)

Pokazywany tylko gdy inference włączone. Aktualizowany przez inference.detection_count_updated(int)"Detections: 17 frames".


7. Telemetria + overlay — nowe pola inference

TelemetrySnapshot — nowe pola

@dataclass
class TelemetrySnapshot:
    # ... istniejące pola ...
    inference_device: str | None = None    # "cpu" | "mps" | None
    inference_time_ms: float | None = None  # rolling avg, None gdy wyłączone

TelemetryCollector — nowe metody

def set_inference_stats(self, device: str, avg_ms: float) -> None: ...
def clear_inference_stats(self) -> None: ...

Wywoływane z MainWindow przy każdym inference_stats_updated i przy wyłączeniu inference.

TelemetryOverlay — nowe wiersze

FPS req   30.0
FPS got   29.8
Frame     33.5 ms
Drop         0
CPU sys    8.2 %
CPU core  65.7 %
Mem        71 MB
Inf.dev    mps        ← widoczny tylko gdy model załadowany
Inf.time   87 ms      ← rolling avg ostatnich 10 klatek

Wiersze Inf.dev i Inf.time znikają gdy inference jest wyłączone (clear_inference_stats() → pola None_format_lines ich nie emituje).


8. Bugfixes (zidentyfikowane po uruchomieniu)

Unexpected frame size: 921600 bytes for 1280×720

Przyczyna: frame.bits(0) zwraca tylko płaszczyznę 0 (luma Y) dla formatów planarnych NV12/YUV420P — 1280 × 720 × 1 = 921600 zamiast 1280 × 720 × 3.

Naprawa: zamiana na frame.toImage() → Format_RGB32 → bits(). Qt dekoduje każdy format wewnętrznie. Identyczna ścieżka jak CameraView.on_frame().

Subscriber not found for removal (WARNING w logu)

Przyczyna: _on_inference_toggled(False) wywoływał dispatcher.unsubscribe() zanim subscriber był dodany (pierwsze wyłączenie przed włączeniem).

Naprawa: zmiana poziomu logu z WARNING na DEBUG w FrameDispatcher.unsubscribe() — brak subscribera nie jest błędem.


Decyzje architektoniczne

Osobny proces zamiast wątku

multiprocessing.Process(context="spawn") zamiast QThread lub threading.Thread:

  • Osobny GIL — inference nie blokuje Python event loop GUI
  • Crash workera (segfault, OOM) nie wywala aplikacji
  • spawn zamiast fork — wymagane na macOS od Python 3.12 (Apple deprecuje fork)

toImage() zamiast bits(0)

QVideoFrame.bits(plane) daje surowe bajty jednej płaszczyzny. W formatach planarnych (NV12: Y w plane 0, UV w plane 1) to tylko część obrazu. toImage() wywołuje wewnętrzny dekoder Qt i zawsze zwraca kompletny obraz niezależnie od pixel formatu.

WorkerReadyPacket zamiast osobnego IPC kanału

Worker wysyła WorkerReadyPacket(device) do tej samej output_queue zaraz po załadowaniu modelu. Manager odróżnia go przez isinstance. Eliminuje potrzebę dodatkowej kolejki lub pipe tylko dla metadanych startu.

Inference stats przez TelemetryCollector, nie bezpośrednio do overlay

InferenceManager.inference_stats_updatedMainWindow._on_inference_stats_updatedTelemetryCollector.set_inference_stats()TelemetrySnapshot.inference_*TelemetryOverlay.paint().

Alternatywa: bezpośrednie połączenie InferenceManager → TelemetryOverlay. Wybrano pośrednie przez TelemetryCollector bo:

  • TelemetrySnapshot jest jedyną strukturą danych opisującą stan systemu
  • CSV logger automatycznie dostaje inference stats bez dodatkowego kodu
  • Overlay ma jeden spójny model danych

Pliki dodane

Plik Zawartość
app/video/__init__.py pusty
app/video/video_player.py VideoPlayer(QObject)
app/inference/__init__.py pusty
app/inference/worker.py run_worker(), FramePacket, WorkerReadyPacket, ResultPacket, _select_device(), _infer()
app/inference/worker_manager.py InferenceManager(QObject)
app/inference/bbox_overlay.py Detection(NamedTuple), BboxOverlay(IOverlayLayer)
tests/test_bbox_overlay.py 16 testów — Detection, stan overlay, mapowanie współrzędnych bbox
tests/test_inference_manager.py 13 testów — drop-if-busy, pause/resume, restart counter, is_running

Pliki zmienione

Plik Co zmieniono
app/config.py INFERENCE_WORKER_TIMEOUT_S, INFERENCE_MAX_RESTARTS, INFERENCE_POLL_INTERVAL_MS, INFERENCE_WATCHDOG_INTERVAL_MS, BBOX_COLOR, BBOX_LABEL_BG_COLOR, BBOX_LABEL_TEXT_COLOR, BBOX_LINE_WIDTH, BBOX_FONT_SIZE, VIDEO_FILE_EXTENSIONS, MODEL_FILE_EXTENSIONS
app/ui/menu_bar.py Menu File (Open Video…, Close Video), menu Model (Load Model…, Enable Inference, Model info)
app/ui/main_window.py VideoPlayer lifecycle, InferenceManager lifecycle, source switching, detection counter w statusbar, _on_inference_stats_updated
app/telemetry/telemetry_collector.py TelemetrySnapshot.inference_device, TelemetrySnapshot.inference_time_ms, set_inference_stats(), clear_inference_stats()
app/overlay/telemetry_overlay.py Wiersze Inf.dev i Inf.time w _format_lines()
app/pipeline/frame_dispatcher.py unsubscribe() brak subscribera: WARNING → DEBUG
pyproject.toml [project.optional-dependencies] inference = ["ultralytics>=8.0", "numpy>=1.24"]
tests/test_telemetry_collector.py _make_collector() uzupełniony o _inference_device=None, _inference_time_ms=None

Łączna liczba testów

69 testów, wszystkie zielone.

Plik Liczba testów
test_frame_dispatcher.py 8
test_telemetry_collector.py 12
test_uvc.py 15
test_bbox_overlay.py 16
test_inference_manager.py 18

Instalacja

# Wymagane do inference:
pip install ultralytics numpy
# lub:
pip install -e ".[inference]"

Aplikacja startuje bez tych pakietów — Load Model… zostaje aktywne, ale InferenceManager.start() zgłosi błąd jeśli ultralytics nie jest zainstalowany (obsłużony przez try/except ImportError w workerze → output_queue.put(None) → manager emituje inference_error).


Uruchamianie

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

# Mac Mini
.venv/bin/python -m app.main

# Mac Mini z plikiem wideo od razu (CLI nie zaimplementowany — użyj File → Open Video…)
.venv/bin/python -m app.main

Next Steps

  1. Przetestować na Mac Mini z kamerą ELP:

    • czy _select_device() wykrywa MPS i loguje "MPS (Metal) available"
    • czy Inf.dev mps pojawia się w overlayzie
    • czy Inf.time jest znacząco niższy niż na CPU
  2. OCR w tym samym procesie co YOLO:

    • Worker process może obsługiwać wiele zadań — dodać OcrTask do FramePacket
    • lub uruchomić OCR jako osobny subscriber FrameDispatcher w osobnym procesie
  3. Dodać możliwość regulacji progu confidence (conf_threshold) przez menu/dialog — przekazać jako parametr do run_worker() w FramePacket lub przy starcie

  4. set_active_format() call po _log_actual_format() żeby menu zaznaczało faktycznie działający format (nie żądany) — z poprzedniej sesji


Critical Context

  • WorkerReadyPacket jest rozróżniany od ResultPacket przez isinstance — nie używaj None jako sentinela dla obu typów
  • _select_device() wywołany raz przy starcie — jeśli zmienisz device w trakcie działania, trzeba zrestartować workera
  • BboxOverlay.on_detections(detections, source_size)source_size to tuple[int, int] (width, height) klatki która była inferowana, nie aktualnego widgetu; potrzebne do poprawnego skalowania przy zmianie rozdzielczości
  • InferenceManager.pause() nie zatrzymuje procesu — submit_frame tylko sprawdza flagę; model pozostaje załadowany, można szybko wznowić
  • multiprocessing.get_context("spawn") — wymagane na macOS/Windows; fork jest domyślny na Linux ale niebezpieczny z Qt