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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user