feat: Enhance inference management with device tracking and telemetry updates

This commit is contained in:
2026-05-13 22:39:08 +02:00
parent 83346dc985
commit 6c401b62bb
7 changed files with 630 additions and 27 deletions

505
notes/05-mvp-yolo.md Normal file
View File

@@ -0,0 +1,505 @@
# 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