Enhance HVideoTool with background processing and device detection: implemented a background job system for detection and restoration tasks, updated the UI to display CUDA/CPU status, and improved device diagnostics. Documentation in README and CLAUDE.md reflects these changes.

This commit is contained in:
Leonid Pershin
2026-06-07 05:24:06 +03:00
parent e27dfdf518
commit cc518cc3e6
7 changed files with 497 additions and 129 deletions
+63 -24
View File
@@ -85,15 +85,18 @@ torch present.
## Architecture (as implemented)
> This reflects the actual code on disk. It is a synchronous, single-threaded GUI app
> — no worker threads. Work is organized into **projects** (`core/project.py`): a
> project folder holds `project.json` (metadata + per-project settings), `frames/` (the
> images), `detections.json` (the detection cache, at the project root — no longer a
> sidecar next to the images), and `collections/Избранное` (the favorites collection). The only
> video touch is a one-shot "Создать из ролика…" that creates a new project and decodes
> a clip into its `frames/` via the **ffmpeg CLI** (cv2.VideoCapture fallback; NOT
> PyAV). Detection runs on the GUI thread (lazily per image, or via "Детектировать
> все"). When code and this file disagree, trust the code.
> This reflects the actual code on disk. The GUI is mostly synchronous, but the
> **heavy compute (detection + restoration) runs on a background thread** so the UI
> stays responsive — see `ui/workers.py` and the "Background jobs" bullet (this reverses
> the earlier "no worker threads" rule; `processEvents` can't unfreeze a single multi-
> second `detector.detect()`/DeepMosaics call). Work is organized into **projects**
> (`core/project.py`): a project folder holds `project.json` (metadata + per-project
> settings), `frames/` (the images), `detections.json` (the detection cache, at the
> project root — no longer a sidecar next to the images), and `collections/Избранное`
> (the favorites collection). The only video touch is a one-shot "Создать из ролика…"
> that creates a new project and decodes a clip into its `frames/` via the **ffmpeg
> CLI** (cv2.VideoCapture fallback; NOT PyAV). When code and this file disagree, trust
> the code.
```
hvideotool/
@@ -103,9 +106,11 @@ hvideotool/
├── settings_store.py # new-project DEFAULTS + last/recent projects to ~/HVideoTool/settings.json
├── ui/
│ ├── main_window.py # the whole UI: toolbar + [file list | image view | detail table]
│ ├── workers.py # Job (QRunnable): runs detect/restore off-thread, results via Qt signals
│ └── image_view.py # renders an image + draws polygon/bbox overlays (QPainter); can highlight one
└── core/
├── imageio.py # unicode-safe imread/imwrite (np.fromfile + imdecode)
├── torch_info.py # probe torch/CUDA (gather/reason/install_hint) for the device badge; no Qt
├── project.py # Project: layout (project.json/frames/detections.json/collections) + per-project settings
├── video/
│ ├── extract.py # extract_frames(): ffmpeg CLI (cv2 fallback) -> JPGs; keyframe/step modes + downscale
@@ -132,6 +137,24 @@ hvideotool/
- `MainWindow` holds the config, builds the detector lazily via `build_detector`
(cached by detector+model+conf in `_make_detector`), and keeps `_results: dict[path
-> list[Detection]]` as the detection cache.
- **Background jobs (`ui/workers.py`).** Detection and restoration are CPU-heavy and
would freeze the GUI, so they run on a `QThreadPool` thread via `Job` (a `QRunnable`
wrapping `fn(job)`); results return to the GUI through queued Qt signals
(`tick`/`progress`/`done`/`failed`). `MainWindow._start_job(fn, total, on_tick, on_done)`
starts one (only one at a time — `_busy` guards entry points), `_finish_job`/
`_on_job_failed` end it. `_make_detector`/`_make_restorer`, image reads, and
`engine.detect/restore` all run **inside the worker** (`_compute` is the pure
read+detect helper); the `fn` must touch NO Qt widgets — it emits plain data that the
GUI-thread slots (`_apply_detection`, restore `tick`) apply. `_begin_busy` disables
`detector_combo`/`model_action` for the duration (they'd race the running detector).
This is the deliberate exception to the old single-threaded rule (CPU YOLO/DeepMosaics
per-call latency can't be hidden with `processEvents`).
- **Device badge.** A clickable status-bar chip (`device_badge`) shows "⚡ CUDA" (green)
or "🖥 CPU" (orange). `_probe_device` runs `core/torch_info.gather()` in a background
`Job` at startup (importing torch is slow, so it's off the GUI thread) → `_set_device_badge`.
Clicking (`_show_device_info`) opens a diagnostic dialog: `torch_info.reason()` explains
why CPU (no torch / CPU-only `+cpu` build / built-with-CUDA-but-no-GPU) plus
`install_hint()` (the cu121 pip command). `core/torch_info.py` is pure (no Qt).
- **Projects (`core/project.py`).** `MainWindow._project` is the open `Project`;
`_folder` is kept as a synonym for `project.frames_dir` so the rest of the code
(navigation, cache, tags) didn't need rewiring. Entry points: "Создать проект…"
@@ -182,7 +205,9 @@ hvideotool/
training/example set while inspecting detections.
- **Restoration ("Расцензурить кадр").** Toolbar action runs `self._restorer` (built via
`build_restorer`) on the current frame's detections (computing them first if needed),
caches the result in `_restored[path]`, and shows it overlay-free. "Показать оригинал/
**on a background job** (`_restore_current` builds an `fn` that detects-if-needed +
restores in the worker; a `tick` caches freshly-computed detections, `done` stores
`_restored[path]` + shows it). "Показать оригинал/
результат" toggles (`_showing_restored`); "Сохранить результат" writes
`<stem>_restored.jpg` beside the frame. The baseline
is cv2 inpaint; the real engine is **DeepMosaics** (`restore/deepmosaics.py`), run
@@ -191,7 +216,8 @@ hvideotool/
`cleanmosaic_img_server` (locate mosaic → run generator on the crop → feather back),
~0.3 s/frame cached on CPU vs ~7 s when it spawned a subprocess. Use the **image**
model `clean_youknow_resnet_9blocks.pth` — the video model (BVDNet) is rejected per
frame (needs a neighbour). `should_cancel` is polled at entry (raises `Cancelled`).
frame (needs a neighbour). `should_cancel` (= `lambda: job.cancelled`) is polled so
"■ Стоп" stops it; the engine raises `Cancelled`, which `Job.run` reports as a clean cancel.
The engine + weights are set in `RestoreDialog` (Файл → Движок восстановления…),
persisted, and built lazily/cached in `_make_restorer` (like `_make_detector`). NOTE:
DeepMosaics locates mosaics itself (its `mosaic_position.pth`, expected beside the
@@ -208,17 +234,16 @@ hvideotool/
detections (`_refresh_marks` projects `_results` onto row indices; per-pixel deduped
so big folders stay cheap). File-list rows are tinted too (`_tag_file`): red =
censorship found, green = checked & clean. Both reset on `_invalidate_results`.
- **Cancellation (cooperative, no threads).** A single "■ Стоп" toolbar action (Esc)
cancels the running long op. `_begin_busy(total)` / `_end_busy()` toggle `self._busy`
+ the Stop button + the progress bar (`total=None` → indeterminate); `_request_cancel`
sets `self._cancel`; the loop-based ops (`_detect_all`, `_load_folder`) and video
extraction (its `progress` cb returns `not self._cancel`) check the flag between
`processEvents` ticks. Single-image restore passes `should_cancel=self._poll_cancel`
(which pumps `processEvents` then returns the flag) into `Restorer.restore`; only
DeepMosaics actually polls it (kills its subprocess + raises `Cancelled`) — cv2 ops are
instant. Entry points guard with `if self._busy: return` (notably `_move_to_collection`,
which mutates `_files` that `_detect_all` iterates). This keeps the synchronous,
single-threaded model — do NOT reintroduce worker threads for cancellation.
- **Cancellation (cooperative).** A single "■ Стоп" toolbar action (Esc) cancels the
running op. `_begin_busy(total)` / `_end_busy()` toggle `self._busy` + the Stop button +
the progress bar (`total=None` → indeterminate). For **background jobs** (detection,
restore) `_request_cancel` calls `self._job.cancel()`; the worker loop checks
`job.cancelled` between frames and restore polls it via `should_cancel`. The still-
synchronous loops (`_load_folder` listing, import copy, video extraction — its
`progress` cb returns `not self._cancel`) check `self._cancel` between `processEvents`
ticks. Entry points guard with `if self._busy: return` (notably `_move_to_favorites`,
which mutates `_files` that a detect-all job reads — so a snapshot/pending list is used).
`closeEvent` cancels a running job and `waitForDone(3000)` before tearing down.
- `image_view.ImageView` draws the image scaled-to-fit plus overlays. Overlay
visibility/threshold are applied at paint time. Selecting a row in the detail table
calls `set_highlight(i)` — that detection is drawn boldly (even below threshold) and
@@ -267,6 +292,10 @@ frame directly.
a generic COCO model (e.g. the `yolo11n-seg.pt` in the repo root, which Ultralytics
auto-downloads / is the training base), it detects people/objects and maps them to
`CensorType.UNKNOWN` → purple boxes that look like noise. This was a real user trap.
**Switching to yolo/combined without a model auto-picks one** via
`MainWindow._auto_find_model()`: it scans `./models/**.pt` and matches only filenames
containing `lada`/`mosaic` (so it skips the COCO `yolo11n-seg.pt` trap), no prompt; it
falls back to the "Модель…" file dialog only when nothing suitable is found.
- **classic-CV is approximate and noisy on real video.** Its mosaic heuristic (low
block-reconstruction residual + 2D gradient + contrast) fires on textured real
footage (skin/hair/fabric/JPEG) → many false positives, while simultaneously missing
@@ -286,13 +315,23 @@ frame directly.
`YoloDetector.__init__`).
- **CUDA/torch install is environment-specific.** Don't add torch to core deps; it
stays out (the `yolo` extra pulls only Ultralytics) and is installed separately.
- **CPU-only torch must not request CUDA.** A `+cpu` torch build raises "Torch not
compiled with CUDA enabled" the moment something calls `.cuda()`. Both engines guard
for this: `YoloDetector` picks `cuda` only when `torch.cuda.is_available()` (even an
explicit `yolo_device="cuda"` is downgraded to cpu); `DeepMosaicsRestorer._ensure_loaded`
forces `gpu_id="-1"` when CUDA is absent (its vendored `model_util.todevice` /
`data.im2tensor` call `.cuda()` for any `gpu_id != "-1"`, e.g. the `dm_gpu="0"` default).
So a wrong/CPU-only torch falls back to CPU instead of crashing.
- **QImage from a numpy buffer must be `.copy()`d** (see `ImageView.set_image`),
otherwise it aliases a buffer that gets freed → garbage/crash.
- **Always use `core/imageio.py`** (`imread_unicode`/`imwrite_unicode`) for images —
`cv2.imread`/`imwrite` silently fail on non-ASCII Windows paths.
- Don't reintroduce any generative / ControlNet dependency, nor the removed video
*playback pipeline* (PyAV, worker threads, player). (The new `core/project.py` is an
on-disk layout, not that thread-based "project model".) The one allowed video touch is
*playback pipeline* (PyAV, producer/consumer worker threads, player, project session).
(The new `core/project.py` is an on-disk layout, not that thread-based "project
model".) NOTE: a **single** background `Job` thread for detect/restore (`ui/workers.py`)
IS in scope now (keeps the GUI responsive) — that's different from the rejected
multi-thread video pipeline. The one allowed video touch is
`core/video/extract.py` (one-shot decode → a new project's `frames/`, behind "Создать
из ролика…"): ffmpeg CLI — `_find_ffmpeg()` prefers PATH, else the binary bundled by
the `imageio-ffmpeg` dep, else cv2 fallback. Keyframe-only `-skip_frame nokey` is