16 KiB
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:
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.py — InferenceManager
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.py — BboxOverlay
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 wideovideo_closed()— powrót do kamerymodel_file_selected(str)— ścieżka modeluinference_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
spawnzamiastfork— wymagane na macOS od Python 3.12 (Apple deprecujefork)
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:
TelemetrySnapshotjest 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
-
Przetestować na Mac Mini z kamerą ELP:
- czy
_select_device()wykrywa MPS i loguje"MPS (Metal) available" - czy
Inf.dev mpspojawia się w overlayzie - czy
Inf.timejest znacząco niższy niż na CPU
- czy
-
OCR w tym samym procesie co YOLO:
- Worker process może obsługiwać wiele zadań — dodać
OcrTaskdoFramePacket - lub uruchomić OCR jako osobny subscriber
FrameDispatcherw osobnym procesie
- Worker process może obsługiwać wiele zadań — dodać
-
Dodać możliwość regulacji progu confidence (
conf_threshold) przez menu/dialog — przekazać jako parametr dorun_worker()wFramePacketlub przy starcie -
set_active_format()call po_log_actual_format()żeby menu zaznaczało faktycznie działający format (nie żądany) — z poprzedniej sesji
Critical Context
WorkerReadyPacketjest rozróżniany odResultPacketprzezisinstance— nie używajNonejako sentinela dla obu typów_select_device()wywołany raz przy starcie — jeśli zmienisz device w trakcie działania, trzeba zrestartować workeraBboxOverlay.on_detections(detections, source_size)—source_sizetotuple[int, int](width, height) klatki która była inferowana, nie aktualnego widgetu; potrzebne do poprawnego skalowania przy zmianie rozdzielczościInferenceManager.pause()nie zatrzymuje procesu —submit_frametylko sprawdza flagę; model pozostaje załadowany, można szybko wznowićmultiprocessing.get_context("spawn")— wymagane na macOS/Windows;forkjest domyślny na Linux ale niebezpieczny z Qt