# 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.py` — `VideoPlayer` Nowa klasa `VideoPlayer(QObject)` — identyczny interfejs sygnałowy jak `CameraService`: ```python 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) ```python 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): ```python 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 ```python 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.py` — `InferenceManager` #### Sygnały ```python 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 ```python 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): ```python 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 ```python _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` ```python 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 ```python 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.py` — `BboxOverlay` ```python 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`: ```python 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 ```python 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 ```python 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 ```python 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 ```python @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 ```python 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_updated` → `MainWindow._on_inference_stats_updated` → `TelemetryCollector.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 ```bash # 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 ```bash # 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