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
+12 -7
View File
@@ -54,15 +54,20 @@ class YoloDetector(Detector):
"(и PyTorch с CUDA отдельно — см. README)."
) from exc
# Resolve the device: explicit override, else CUDA when available.
# Resolve the device: CUDA only when actually available, else CPU. A CPU-only
# torch build raises "Torch not compiled with CUDA enabled" if asked for cuda,
# so we never request it without a working GPU (even on an explicit override).
try:
import torch
cuda_ok = torch.cuda.is_available()
except Exception: # noqa: BLE001
cuda_ok = False
device = self.cfg.yolo_device
if device is None:
try:
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
except Exception: # noqa: BLE001
device = "cpu"
device = "cuda" if cuda_ok else "cpu"
elif "cuda" in str(device) and not cuda_ok:
device = "cpu"
self._device = device
self._model = YOLO(model_path)
+8
View File
@@ -118,6 +118,14 @@ class DeepMosaicsRestorer(Restorer):
return
if str(_VENDOR) not in sys.path:
sys.path.insert(0, str(_VENDOR)) # so vendored `from models/util import …` resolve
# Fall back to CPU when CUDA isn't available: DeepMosaics calls `.cuda()`
# whenever gpu_id != "-1", which raises "Torch not compiled with CUDA enabled"
# on a CPU-only torch build.
import torch # noqa: E402
if self._gpu != "-1" and not torch.cuda.is_available():
self._gpu = "-1"
from models import loadmodel, runmodel # type: ignore # noqa: E402
import util.image_processing as impro # type: ignore # noqa: E402
+80
View File
@@ -0,0 +1,80 @@
"""Probe the PyTorch / CUDA situation, so the UI can show a device badge.
Pure (no Qt). ``gather()`` imports torch (slow / heavy) — call it off the GUI
thread. The rest are tiny formatters the UI uses to explain *why* it's on CPU and
how to enable the GPU.
"""
from __future__ import annotations
# pip index for the CUDA build (matches the README).
CUDA_WHEEL_INDEX = "https://download.pytorch.org/whl/cu121"
def gather() -> dict:
"""Collect torch/CUDA facts. Never raises — missing torch is a valid result."""
info: dict = {
"installed": False,
"version": None, # torch.__version__ (e.g. "2.12.0+cpu")
"built_cuda": None, # torch.version.cuda (None on a CPU-only build)
"cuda_available": False,
"device_name": None, # the active GPU's name, if any
"import_error": None,
}
try:
import torch
except Exception as exc: # noqa: BLE001 - report any import failure, not just ImportError
info["import_error"] = str(exc)
return info
info["installed"] = True
info["version"] = getattr(torch, "__version__", None)
try:
info["built_cuda"] = torch.version.cuda
except Exception: # noqa: BLE001
info["built_cuda"] = None
try:
info["cuda_available"] = bool(torch.cuda.is_available())
except Exception: # noqa: BLE001
info["cuda_available"] = False
if info["cuda_available"]:
try:
info["device_name"] = torch.cuda.get_device_name(0)
except Exception: # noqa: BLE001
info["device_name"] = None
return info
def device_label(info: dict) -> str:
return "CUDA" if info.get("cuda_available") else "CPU"
def reason(info: dict) -> str:
"""One-sentence human explanation of the current device choice."""
if not info.get("installed"):
return ("PyTorch не установлен — детектор YOLO и восстановление DeepMosaics "
"работают на CPU (классический детектор torch не требует).")
if info.get("cuda_available"):
name = info.get("device_name") or "GPU"
return f"PyTorch использует CUDA: {name}. Вычисления идут на видеокарте."
version = info.get("version") or "?"
built = info.get("built_cuda")
if not built:
return (f"Установлена CPU-сборка PyTorch ({version}) — без поддержки CUDA, "
"поэтому вычисления идут на процессоре (медленно).")
return (f"PyTorch собран с CUDA {built} ({version}), но GPU недоступен: нет "
"NVIDIA-видеокарты, не установлен/устарел драйвер, либо версия CUDA "
"несовместима с драйвером.")
def install_hint() -> str:
"""Steps to enable the GPU (shown when running on CPU)."""
return (
"Как включить GPU (NVIDIA):\n"
"1. Нужна видеокарта NVIDIA и свежий драйвер (проверка в консоли: nvidia-smi).\n"
"2. Переустановите PyTorch со сборкой CUDA:\n\n"
" pip uninstall -y torch torchvision\n"
f" pip install torch torchvision --index-url {CUDA_WHEEL_INDEX}\n\n"
"3. Перезапустите приложение.\n\n"
"Классический детектор работает и без CUDA. На CPU детекция и расцензуривание "
"просто медленнее."
)
+263 -98
View File
@@ -25,7 +25,7 @@ from __future__ import annotations
import shutil
from pathlib import Path
from PySide6.QtCore import Qt
from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtGui import QAction, QBrush, QColor, QKeySequence, QShortcut
from PySide6.QtWidgets import (
QAbstractItemView,
@@ -57,7 +57,6 @@ from ..core.detection.factory import build_detector
from ..core.detection.types import Detection
from ..core.imageio import imread_unicode, imwrite_unicode
from ..core.project import PROJECT_FILE, Project
from ..core.restore.base import Cancelled
from ..core.restore.factory import build_restorer
from ..core.video.extract import extract_frames
from ..core.video.frame import Frame
@@ -65,6 +64,7 @@ from .extract_dialog import ExtractDialog
from .image_view import ImageView
from .marker_slider import MarkerSlider
from .restore_dialog import RestoreDialog
from .workers import Job
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tif", ".tiff"}
_VIDEO_FILTER = "Видео (*.mp4 *.mkv *.avi *.mov *.webm *.m4v);;Все файлы (*.*)"
@@ -89,6 +89,11 @@ class MainWindow(QMainWindow):
self._nav_sync = False # guard against slider<->list signal loops
self._busy = False # a long operation is running
self._cancel = False # the user asked to stop it
self._pool = QThreadPool.globalInstance()
self._job: Job | None = None # the running background job, if any
self._tick_count = 0 # throttles scrubber-mark refreshes during detect-all
self._device_info: dict | None = None # torch/CUDA probe result (for the badge)
self._probe_job: Job | None = None
self.setWindowTitle("HVideoTool — инспектор детекции цензуры")
self.resize(1180, 720)
@@ -97,6 +102,7 @@ class MainWindow(QMainWindow):
self._build_central()
self._build_statusbar()
self._build_menu()
self._probe_device() # determine CUDA/CPU in the background and fill the badge
self.statusBar().showMessage("Создайте или откройте проект (Файл)")
# ------------------------------------------------------------------ setup
@@ -310,17 +316,80 @@ class MainWindow(QMainWindow):
self.pos_label.setText(f"{row + 1 if row >= 0 else 0} / {n}")
def _build_statusbar(self) -> None:
self.device_badge = QPushButton("⏳ устройство…")
self.device_badge.setFlat(True)
self.device_badge.setCursor(Qt.PointingHandCursor)
self.device_badge.setToolTip("Устройство вычислений (нажмите для подробностей)")
self.device_badge.clicked.connect(self._show_device_info)
self.statusBar().addPermanentWidget(self.device_badge)
self.progress = QProgressBar()
self.progress.setMaximumWidth(260)
self.progress.setVisible(False)
self.statusBar().addPermanentWidget(self.progress)
# ------------------------------------------------------------- device badge
def _probe_device(self) -> None:
"""Determine CUDA/CPU off the GUI thread (importing torch is slow)."""
from ..core import torch_info
job = Job(lambda _job: torch_info.gather())
self._probe_job = job # keep alive until `done`
job.signals.done.connect(self._set_device_badge)
job.signals.failed.connect(lambda _msg: self._set_device_badge(None))
self._pool.start(job)
def _set_device_badge(self, info: dict | None) -> None:
self._probe_job = None
self._device_info = info or {}
if self._device_info.get("cuda_available"):
name = self._device_info.get("device_name") or "GPU"
self.device_badge.setText("⚡ CUDA")
self.device_badge.setToolTip(f"Вычисления на GPU: {name} (нажмите для подробностей)")
self.device_badge.setStyleSheet("QPushButton{color:#16a085; font-weight:bold;}")
else:
self.device_badge.setText("🖥 CPU")
self.device_badge.setToolTip(
"Вычисления на CPU — нажмите, чтобы узнать почему и как включить GPU"
)
self.device_badge.setStyleSheet("QPushButton{color:#cc8400; font-weight:bold;}")
def _show_device_info(self) -> None:
from ..core import torch_info
info = self._device_info if self._device_info else torch_info.gather()
cuda = bool(info.get("cuda_available"))
lines = [
torch_info.reason(info),
"",
"Диагностика:",
f" • PyTorch: {info.get('version') or 'не установлен'}",
f" • Сборка CUDA: {info.get('built_cuda') or '— (CPU-сборка)'}",
f" • CUDA доступна: {'да' if cuda else 'нет'}",
]
if info.get("device_name"):
lines.append(f" • GPU: {info['device_name']}")
if info.get("import_error"):
lines.append(f" • Ошибка импорта torch: {info['import_error']}")
box = QMessageBox(self)
box.setIcon(QMessageBox.Information if cuda else QMessageBox.Warning)
box.setWindowTitle("Устройство: " + ("CUDA (GPU)" if cuda else "CPU"))
box.setText("\n".join(lines))
if not cuda:
box.setInformativeText(torch_info.install_hint())
box.setTextInteractionFlags(Qt.TextSelectableByMouse) # let the user copy commands
box.exec()
# ------------------------------------------------------------- cancellation
def _begin_busy(self, total: int | None = None) -> None:
"""Enter a cancellable long operation. ``total=None`` => busy spinner."""
self._busy = True
self._cancel = False
self.stop_action.setEnabled(True)
# Disable inputs that would race a running job (they clear cache / rebuild engines).
self.detector_combo.setEnabled(False)
self.model_action.setEnabled(False)
if total is None:
self.progress.setRange(0, 0) # indeterminate
else:
@@ -331,18 +400,55 @@ class MainWindow(QMainWindow):
def _end_busy(self) -> None:
self._busy = False
self.stop_action.setEnabled(False)
self.detector_combo.setEnabled(True)
self.model_action.setEnabled(True)
self.progress.setVisible(False)
self.progress.setRange(0, 100) # leave it determinate for the next user
def _request_cancel(self) -> None:
if self._busy:
self._cancel = True
if self._job is not None:
self._job.cancel() # stops the background loop at its next check
self.statusBar().showMessage("Отмена…")
def _poll_cancel(self) -> bool:
"""Cancel hook for core engines: pump the UI so Стоп registers, then report."""
QApplication.processEvents()
return self._cancel
# ------------------------------------------------------------- background jobs
def _start_job(self, fn, total: int | None, *, on_tick=None, on_done=None) -> None:
"""Run ``fn(job)`` on the thread pool; marshal results back to the GUI.
``on_tick(payload)`` handles incremental results (GUI thread); ``on_done(result,
cancelled)`` runs when the job finishes. Only one job runs at a time (callers
guard with ``self._busy``).
"""
self._begin_busy(total)
self._tick_count = 0
job = Job(fn)
self._job = job
if on_tick is not None:
job.signals.tick.connect(on_tick)
job.signals.progress.connect(self._on_job_progress)
job.signals.done.connect(lambda result: self._finish_job(result, on_done))
job.signals.failed.connect(self._on_job_failed)
self._pool.start(job)
def _on_job_progress(self, done: int, total: int, message: str) -> None:
if total > 0:
self.progress.setRange(0, total)
self.progress.setValue(done)
if message:
self.statusBar().showMessage(message)
def _finish_job(self, result, on_done) -> None:
cancelled = self._job.cancelled if self._job is not None else False
self._job = None
self._end_busy()
if on_done is not None:
on_done(result, cancelled)
def _on_job_failed(self, message: str) -> None:
self._job = None
self._end_busy()
QMessageBox.warning(self, "Ошибка", message)
# --------------------------------------------------------------- detector
def _make_detector(self):
@@ -355,12 +461,34 @@ class MainWindow(QMainWindow):
def _on_detector_changed(self, name: str) -> None:
self._cfg.detector = name
# YOLO/combined need a model — offer to pick one if missing.
# YOLO/combined need a model. Auto-pick a known one from models/ if we have it;
# only prompt when nothing suitable is found (don't nag when the path is obvious).
if name in ("yolo", "combined") and not self._cfg.model_path:
self._choose_model()
found = self._auto_find_model()
if found:
self._cfg.model_path = found
self.statusBar().showMessage(f"Модель найдена автоматически: {found}")
else:
self._choose_model()
self._persist_settings()
self._invalidate_results()
@staticmethod
def _auto_find_model() -> str | None:
"""Find a censorship YOLO model under ./models without prompting.
Matches LADA/mosaic weights by filename; deliberately ignores generic COCO
models (e.g. yolo11n-seg.pt) that would map objects to purple "noise".
"""
models_dir = Path.cwd() / "models"
if not models_dir.is_dir():
return None
for p in sorted(models_dir.rglob("*.pt")):
name = p.name.lower()
if "lada" in name or "mosaic" in name:
return str(p)
return None
def _choose_model(self) -> None:
start = self._cfg.model_path or str(Path.cwd() / "models")
path, _ = QFileDialog.getOpenFileName(self, "Выберите веса (.pt)", start, "Веса YOLO (*.pt);;Все файлы (*.*)")
@@ -681,35 +809,44 @@ class MainWindow(QMainWindow):
if self._busy:
return
path = Path(item.data(Qt.UserRole))
if str(path) not in self._results:
if self._detect(path) is None:
return
self._refresh_marks()
self._save_results() # only when a detection actually ran
self._show(path)
if str(path) in self._results:
self._show(path)
return
self._detect_one(path, then_show=True)
def _detect(self, path: Path) -> list[Detection] | None:
"""Run (or fetch cached) detections for one image. None on failure."""
key = str(path)
if key in self._results:
return self._results[key]
img = imread_unicode(key)
@staticmethod
def _compute(detector, path: Path) -> list[Detection]:
"""Pure read + detect for one image (runs on a worker thread; no Qt)."""
img = imread_unicode(str(path))
if img is None:
self.statusBar().showMessage(f"Не удалось прочитать: {path.name}")
return None
try:
detector = self._make_detector()
except Exception as exc: # noqa: BLE001 - surface config/model errors to the user
QMessageBox.warning(self, "Детектор недоступен", str(exc))
return None
self.statusBar().showMessage(f"Детекция: {path.name}")
QApplication.processEvents()
raise RuntimeError(f"Не удалось прочитать: {Path(path).name}")
dets = detector.detect(Frame(image=img, index=0, pts=0.0))
dets.sort(key=lambda d: d.score, reverse=True)
self._results[key] = dets
self._tag_file(path, len(dets))
return dets
def _detect_one(self, path: Path, *, then_show: bool) -> None:
"""Detect one image on a background thread, then cache/tag/show it."""
if self._busy:
return
def fn(job):
return (str(path), self._compute(self._make_detector(), path))
def done(result, cancelled):
if result is None:
return
key, dets = result
self._results[key] = dets
self._tag_file(Path(key), len(dets))
self._refresh_marks()
self._save_results()
if then_show or self._current == Path(key):
self._show(Path(key))
self.statusBar().showMessage(f"Детекция: {Path(key).name}{len(dets)} обл.")
self.statusBar().showMessage(f"Детекция: {path.name}")
self._start_job(fn, None, on_done=done)
def _show(self, path: Path) -> None:
"""Display the image with its cached detections (does not run the detector)."""
self._current = path
@@ -721,90 +858,115 @@ class MainWindow(QMainWindow):
self._update_restore_actions()
def _recompute_current(self) -> None:
"""Toolbar/Space: (re)run the detector on the selected frame."""
"""Toolbar/Space: (re)run the detector on the selected frame (background)."""
if self._current is None or self._busy:
return
self._results.pop(str(self._current), None)
self._detector_key = None # rebuild the detector so settings changes take effect
if self._detect(self._current) is None:
return
self._refresh_marks()
self._save_results()
self._show(self._current)
self._detect_one(self._current, then_show=True)
def _detect_all(self, force: bool = False) -> None:
"""Detect the whole folder. ``force`` clears the cache first (full regen);
otherwise already-computed frames are skipped, so it resumes/tops-up."""
"""Detect the whole folder on a background thread. ``force`` clears the cache
first (full regen); otherwise already-computed frames are skipped (resume/top-up).
The GUI stays responsive — results stream in via per-frame ticks."""
if not self._files or self._busy:
return
if force:
self._clear_results()
total = len(self._files)
self._begin_busy(total)
done = 0
try:
for i, p in enumerate(self._files, 1):
self.progress.setValue(i)
self.statusBar().showMessage(f"Детекция {i}/{total}: {p.name}")
QApplication.processEvents()
if self._cancel:
pending = [p for p in self._files if str(p) not in self._results]
if not pending:
self.statusBar().showMessage("Все кадры уже посчитаны (см. «Все заново»)")
return
total = len(pending)
def fn(job):
detector = self._make_detector() # built on the worker thread (may raise)
for i, p in enumerate(pending, 1):
if job.cancelled:
break
if self._detect(p) is None:
return # detector unavailable — message already shown
done = i
if i % 50 == 0:
self._refresh_marks() # let marks appear progressively
finally:
self._end_busy()
hits = sum(1 for p in self._files if self._results.get(str(p)))
self._refresh_marks()
self._save_results() # persist progress (works for completed and cancelled runs)
if self._cancel:
self.statusBar().showMessage(
f"Отменено на {done}/{total} · детекции на {hits} картинках"
)
else:
self.statusBar().showMessage(f"Готово: детекции на {hits} из {total} картинок")
if self._current is not None:
self._show(self._current)
try:
dets = self._compute(detector, p)
except RuntimeError:
continue # unreadable image — skip, keep going
job.tick((str(p), dets))
job.progress(i, total, f"Детекция {i}/{total}: {p.name}")
return None
def done(_result, cancelled):
hits = sum(1 for p in self._files if self._results.get(str(p)))
self._refresh_marks()
self._save_results() # persist progress (completed or cancelled)
if cancelled:
self.statusBar().showMessage(f"Отменено · детекции на {hits} картинках")
else:
self.statusBar().showMessage(
f"Готово: детекции на {hits} из {len(self._files)} картинок"
)
if self._current is not None:
self._show(self._current)
self._start_job(fn, total, on_tick=self._apply_detection, on_done=done)
def _apply_detection(self, payload) -> None:
"""GUI-thread handler for one streamed detect-all result."""
key, dets = payload
self._results[key] = dets
self._tag_file(Path(key), len(dets))
self._tick_count += 1
if self._tick_count % 25 == 0:
self._refresh_marks() # let marks appear progressively (throttled)
# ------------------------------------------------------------- restoration
def _restore_current(self) -> None:
"""Run the restorer on the current frame's detected regions and show it."""
"""Restore the current frame's regions on a background thread, then show it.
Detections are computed first (in the same job) if not cached. The DeepMosaics
engine polls ``job.cancelled`` so "■ Стоп" stops it promptly."""
if self._current is None or self._busy:
return
key = str(self._current)
if key not in self._results and self._detect(self._current) is None:
return
self._refresh_marks()
dets = self._results.get(key) or []
if not dets:
self.statusBar().showMessage("Нет найденных областей — нечего расцензуривать")
return
img = imread_unicode(key)
if img is None:
return
self._begin_busy() # indeterminate — engine drives the duration
self.statusBar().showMessage(f"Восстановление: {self._current.name}")
QApplication.processEvents()
try:
path = self._current
key = str(path)
def fn(job):
dets = self._results.get(key)
if dets is None:
dets = self._compute(self._make_detector(), path)
job.tick(("dets", key, dets)) # cache them on the GUI thread
if not dets:
return ("empty", key)
img = imread_unicode(key)
if img is None:
raise RuntimeError(f"Не удалось прочитать: {path.name}")
restorer = self._make_restorer()
restored = restorer.restore(img, dets, should_cancel=self._poll_cancel)
except Cancelled:
self.statusBar().showMessage("Восстановление отменено")
return
except Exception as exc: # noqa: BLE001 - surface model/engine errors
QMessageBox.warning(self, "Ошибка восстановления", str(exc))
return
finally:
self._end_busy()
self._restored[key] = restored
self._showing_restored = True
self.view.set_image(restored, [])
self._update_restore_actions()
self.statusBar().showMessage(
f"Расцензурено ({restorer.name}): {self._current.name}{len(dets)} обл."
)
restored = restorer.restore(img, dets, should_cancel=lambda: job.cancelled)
return ("restored", key, restored, len(dets), restorer.name)
def tick(payload):
if payload[0] == "dets":
_, k, dets = payload
self._results[k] = dets
self._tag_file(Path(k), len(dets))
self._refresh_marks()
def done(result, cancelled):
if cancelled:
self.statusBar().showMessage("Восстановление отменено")
return
if result is None:
return
if result[0] == "empty":
self.statusBar().showMessage("Нет найденных областей — нечего расцензуривать")
return
_, k, restored, n, engine = result
self._restored[k] = restored
if self._current is not None and str(self._current) == k:
self._showing_restored = True
self.view.set_image(restored, [])
self._update_restore_actions()
self.statusBar().showMessage(f"Расцензурено ({engine}): {Path(k).name}{n} обл.")
self.statusBar().showMessage(f"Восстановление: {path.name}")
self._start_job(fn, None, on_tick=tick, on_done=done)
def _make_restorer(self):
key = (self._cfg.restorer, self._cfg.dm_dir, self._cfg.dm_model,
@@ -1017,6 +1179,9 @@ class MainWindow(QMainWindow):
self._persist_settings()
def closeEvent(self, event) -> None: # noqa: N802 - Qt override
if self._job is not None: # stop a running background job before tearing down
self._job.cancel()
self._pool.waitForDone(3000)
self._save_results() # persist the detection cache on exit
if self._project is not None:
self._project.update_from_config(self._cfg)
+67
View File
@@ -0,0 +1,67 @@
"""A tiny background-job helper so heavy work doesn't freeze the GUI.
The app is otherwise synchronous, but a single ``detector.detect()`` (CPU YOLO) or
a DeepMosaics restore can block the GUI thread for seconds ``processEvents`` only
runs *between* frames, not *inside* one heavy call. So detection and restoration run
on a ``QThreadPool`` thread via :class:`Job`; results come back to the GUI through
queued Qt signals.
Contract: the job function ``fn(job)`` runs on a worker thread and may ONLY touch
plain data + the engines (no Qt widgets). It reports progress with ``job.progress``/
``job.tick`` and checks ``job.cancelled`` to stop early. Its return value is delivered
on the GUI thread via the ``done`` signal; raising :class:`Cancelled` is reported as a
clean cancel (``done`` with ``None``), any other exception via ``failed``.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from PySide6.QtCore import QObject, QRunnable, Signal
from ..core.restore.base import Cancelled
class _Signals(QObject):
progress = Signal(int, int, str) # done, total, message
tick = Signal(object) # incremental payload (delivered on GUI thread)
done = Signal(object) # final result (None if cancelled)
failed = Signal(str) # error message
class Job(QRunnable):
"""Runs ``fn(job)`` on a thread pool, marshaling progress/result to the GUI."""
def __init__(self, fn: Callable[["Job"], Any]) -> None:
super().__init__()
self.setAutoDelete(False) # the GUI keeps a reference until `done`/`failed`
self.signals = _Signals()
self._fn = fn
self._cancelled = False
# -- called from the GUI thread --
def cancel(self) -> None:
self._cancelled = True
@property
def cancelled(self) -> bool:
return self._cancelled
# -- called from the worker thread by `fn` --
def progress(self, done: int, total: int, message: str = "") -> None:
self.signals.progress.emit(done, total, message)
def tick(self, payload: Any) -> None:
self.signals.tick.emit(payload)
# -- thread entry point --
def run(self) -> None: # noqa: D401 - QRunnable override
try:
result = self._fn(self)
except Cancelled:
self.signals.done.emit(None)
except Exception as exc: # noqa: BLE001 - surface engine/model errors to the GUI
self.signals.failed.emit(str(exc))
else:
self.signals.done.emit(result)