# 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
