Introduce diffusion-inpaint restoration engine in HVideoTool: added support for a new restoration method that regenerates masked regions via an external SwarmUI server, requiring YOLO detections for mask creation. Updated configuration management to include diffusion parameters, enhanced the UI for engine selection, and improved documentation in README and CLAUDE.md to guide users on the new functionality.

This commit is contained in:
Leonid Pershin
2026-06-08 06:21:44 +03:00
parent 8a366ed43d
commit 15f89b395d
14 changed files with 903 additions and 70 deletions
+34 -6
View File
@@ -67,7 +67,7 @@ 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.factory import build_restorer
from ..core.restore.factory import build_restorer, restorer_needs_detections
from ..core.video.extract import extract_frames
from ..core.video.frame import Frame
from .extract_dialog import ExtractDialog
@@ -1220,13 +1220,24 @@ class MainWindow(QMainWindow):
return
path = self._current
key = str(path)
# The diffusion engine builds its mask from detections — capture them on the GUI
# thread (DeepMosaics ignores them). Warn if it needs them but none are computed.
needs_dets = restorer_needs_detections(self._cfg.restorer)
dets_for_restore = list(self._results.get(key, []))
if needs_dets and not dets_for_restore:
self.statusBar().showMessage(
"Diffusion перерисовывает по детекции — на этом кадре цензура не найдена "
"(сначала «Рассчитать кадр»)"
)
return
def fn(job):
img = imread_unicode(key)
if img is None:
raise RuntimeError(f"Не удалось прочитать: {path.name}")
restorer = self._make_restorer()
restored = restorer.restore(img, [], should_cancel=lambda: job.cancelled)
dets = dets_for_restore if restorer.needs_detections else []
restored = restorer.restore(img, dets, should_cancel=lambda: job.cancelled)
return ("restored", key, restored, restorer.name)
def done(result, cancelled):
@@ -1275,9 +1286,17 @@ class MainWindow(QMainWindow):
return
files = list(self._files) # snapshot — favorites/move mutate self._files
is_temporal = self._cfg.restorer == "deepmosaics_video"
# The diffusion engine masks the detections, so it inherently runs only on hits and
# requires detection to be computed — same gating as the explicit "найденное" mode.
needs_dets = restorer_needs_detections(self._cfg.restorer)
hits = [i for i, p in enumerate(files) if self._results.get(str(p))]
# Per-index detection snapshot (GUI-thread read) — fed to engines that need a mask.
dets_by_index = (
{i: list(self._results.get(str(files[i]), [])) for i in hits} if needs_dets else {}
)
need_hits = only_detected or needs_dets
if only_detected:
if need_hits:
if not self._results:
self.statusBar().showMessage(
"Детекция не посчитана — сначала «Детектировать все» (или «Расцензурить все»)"
@@ -1331,7 +1350,8 @@ class MainWindow(QMainWindow):
should_cancel=lambda: job.cancelled,
)
else:
indices = hits if only_detected else range(len(files))
indices = hits if need_hits else range(len(files))
use_dets = restorer.needs_detections
for i in indices:
if job.cancelled:
break
@@ -1339,7 +1359,8 @@ class MainWindow(QMainWindow):
if not force and out_path(p).is_file():
emit(i, None, verb="Пропуск") # already restored — count, don't rewrite
continue
emit(i, restorer.restore(get_frame(i), [], should_cancel=lambda: job.cancelled))
dets = dets_by_index.get(i, []) if use_dets else []
emit(i, restorer.restore(get_frame(i), dets, should_cancel=lambda: job.cancelled))
frame_cache.pop(i, None) # per-frame: don't accumulate
return None
@@ -1359,7 +1380,14 @@ class MainWindow(QMainWindow):
self._start_job(fn, total, on_done=done)
def _make_restorer(self):
key = (self._cfg.restorer, self._cfg.dm_dir, self._cfg.dm_model, self._cfg.dm_gpu)
c = self._cfg
key = (
c.restorer, c.dm_dir, c.dm_model, c.dm_gpu,
# diffusion identity — changing any of these must rebuild the engine
c.diff_backend, c.diff_url, c.diff_model, c.diff_prompt, c.diff_negative,
c.diff_steps, c.diff_cfg, c.diff_denoise, c.diff_seed,
c.diff_mask_dilate, c.diff_mask_blur,
)
if key != self._restorer_key:
self._restorer = build_restorer(self._cfg.restorer, self._cfg) # may raise
self._restorer_key = key
+168 -24
View File
@@ -1,28 +1,38 @@
"""Configure the restoration ("расцензурить") engine.
Restoration is DeepMosaics-only — pick the per-frame ("картинка") or temporal ("видео")
engine. The network code is vendored (built-in); the user picks a clean model from a
dropdown of the bundled ``models/deepmosaics`` weights (or browses to another
``clean_*.pth``) — for the video engine only ``clean_*_video.pth`` is offered.
``mosaic_position.pth`` must sit beside the chosen model. A CUDA GPU is strongly
recommended (GPU id, -1 = CPU/slow).
Two kinds of engine:
• **DeepMosaics** (картинка/видео) — vendored, in-process; *reconstructs* mosaic and
locates it itself. Pick a clean model from the bundled ``models/deepmosaics`` weights
(or browse). ``mosaic_position.pth`` must sit beside it. CUDA GPU strongly recommended.
• **Diffusion-inpaint (SwarmUI)** — *regenerates* the detected (masked) regions via an
external SwarmUI server over HTTP. Needs YOLO detections for the mask and a running
SwarmUI; the model runs in SwarmUI's process (no torch here). Per-frame (best for
stills — a video sequence will flicker).
The per-engine fields live in two group widgets that are shown/hidden by the engine combo.
"""
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFileDialog,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QSpinBox,
QVBoxLayout,
QWidget,
)
@@ -35,22 +45,40 @@ class RestoreDialog(QDialog):
super().__init__(parent)
self._cfg = config
self.setWindowTitle("Движок восстановления")
self.setMinimumWidth(560)
self.setMinimumWidth(580)
self.engine = QComboBox()
self.engine.addItem("DeepMosaics — картинка (покадрово, нужна модель+GPU)", "deepmosaics")
self.engine.addItem("DeepMosaics — видео (соседние кадры, лучше для роликов)", "deepmosaics_video")
self.engine.addItem("Diffusion-inpaint (SwarmUI) — перерисовка по маске YOLO", "diffusion")
self.engine.setCurrentIndex(max(0, self.engine.findData(config.restorer)))
self.engine.currentIndexChanged.connect(self._sync)
# Model dropdown — bundled clean models, plus the configured one if external.
root = QVBoxLayout(self)
top = QFormLayout()
top.addRow("Движок:", self.engine)
root.addLayout(top)
root.addWidget(self._build_deepmosaics_group(config))
root.addWidget(self._build_diffusion_group(config))
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
root.addWidget(buttons)
self._sync()
# ----------------------------------------------------------- DeepMosaics UI
def _build_deepmosaics_group(self, config: AppConfig) -> QWidget:
self.dm_group = QWidget()
form = QFormLayout(self.dm_group)
form.setContentsMargins(0, 0, 0, 0)
self.model_combo = QComboBox()
self._populate_models(config.dm_model)
self.dm_gpu = QLineEdit(config.dm_gpu or "0")
self.dm_gpu.setPlaceholderText("0 = первая CUDA-карта, -1 = CPU (медленно)")
# Video engine only: feed already-restored past frames into the temporal window.
self.feed_restored = QCheckBox(
"Подавать уже расцензуренные прошлые кадры в окно (эксперим.)"
)
@@ -62,8 +90,6 @@ class RestoreDialog(QDialog):
"не гарантирован; выключите для точной реализации DeepMosaics."
)
form = QFormLayout(self)
form.addRow("Движок:", self.engine)
form.addRow("Модель:", self._with_browse(self.model_combo, self._browse_model))
form.addRow("GPU id:", self.dm_gpu)
form.addRow("", self.feed_restored)
@@ -76,13 +102,94 @@ class RestoreDialog(QDialog):
)
hint.setWordWrap(True)
form.addRow(hint)
return self.dm_group
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
form.addRow(buttons)
self._sync()
# ------------------------------------------------------------ Diffusion UI
def _build_diffusion_group(self, config: AppConfig) -> QWidget:
self.diff_group = QWidget()
form = QFormLayout(self.diff_group)
form.setContentsMargins(0, 0, 0, 0)
self.diff_url = QLineEdit(config.diff_url or "http://localhost:7801")
self.diff_url.setPlaceholderText("http://localhost:7801")
self.diff_test_btn = QPushButton("Проверить соединение")
self.diff_test_btn.clicked.connect(self._test_connection)
self.diff_test_status = QLabel("")
self.diff_test_status.setWordWrap(True)
self.diff_model = QLineEdit(config.diff_model or "")
self.diff_model.setPlaceholderText("имя чекпойнта в SwarmUI (пусто = текущий)")
self.diff_prompt = QLineEdit(config.diff_prompt or "")
self.diff_prompt.setPlaceholderText("что нарисовать в области под цензурой")
self.diff_negative = QLineEdit(config.diff_negative or "")
self.diff_negative.setPlaceholderText("чего избегать (negative prompt)")
self.diff_steps = QSpinBox()
self.diff_steps.setRange(1, 150)
self.diff_steps.setValue(int(config.diff_steps))
self.diff_cfg = QDoubleSpinBox()
self.diff_cfg.setRange(0.0, 30.0)
self.diff_cfg.setSingleStep(0.5)
self.diff_cfg.setValue(float(config.diff_cfg))
self.diff_denoise = QDoubleSpinBox()
self.diff_denoise.setRange(0.0, 1.0)
self.diff_denoise.setSingleStep(0.05)
self.diff_denoise.setValue(float(config.diff_denoise))
self.diff_denoise.setToolTip("0..1 — насколько перерисовать область (1 = полностью)")
self.diff_seed = QSpinBox()
self.diff_seed.setRange(-1, 2_147_483_647)
self.diff_seed.setValue(int(config.diff_seed))
self.diff_seed.setSpecialValueText("случайный") # at -1
self.diff_dilate = QSpinBox()
self.diff_dilate.setRange(0, 200)
self.diff_dilate.setValue(int(config.diff_mask_dilate))
self.diff_dilate.setToolTip("Расширить маску на N px (закрыть край цензуры)")
self.diff_blur = QSpinBox()
self.diff_blur.setRange(0, 200)
self.diff_blur.setValue(int(config.diff_mask_blur))
self.diff_blur.setToolTip("Размытие края маски, px (мягкий стык)")
form.addRow("SwarmUI URL:", self.diff_url)
form.addRow("", self.diff_test_btn)
form.addRow("", self.diff_test_status)
form.addRow("Чекпойнт:", self.diff_model)
form.addRow("Промпт:", self.diff_prompt)
form.addRow("Negative:", self.diff_negative)
steps_row = QWidget()
h = QHBoxLayout(steps_row)
h.setContentsMargins(0, 0, 0, 0)
h.addWidget(QLabel("Шаги:"))
h.addWidget(self.diff_steps)
h.addWidget(QLabel("CFG:"))
h.addWidget(self.diff_cfg)
h.addWidget(QLabel("Denoise:"))
h.addWidget(self.diff_denoise)
h.addWidget(QLabel("Seed:"))
h.addWidget(self.diff_seed)
form.addRow("", steps_row)
mask_row = QWidget()
hm = QHBoxLayout(mask_row)
hm.setContentsMargins(0, 0, 0, 0)
hm.addWidget(QLabel("Маска: расширить, px:"))
hm.addWidget(self.diff_dilate)
hm.addWidget(QLabel("размытие, px:"))
hm.addWidget(self.diff_blur)
form.addRow("", mask_row)
hint = QLabel(
"Diffusion перерисовывает область ЦЕНЗУРЫ заново (не восстанавливает оригинал),\n"
"опираясь на маску из детекций YOLO и промпт. Нужен запущенный сервер SwarmUI\n"
"и посчитанная детекция. Покадрово — на роликах будет мерцание. Лучше для\n"
"чёрных плашек/заливки, где DeepMosaics бессилен."
)
hint.setWordWrap(True)
form.addRow(hint)
return self.diff_group
# --------------------------------------------------------------- helpers
def _populate_models(self, current: str | None) -> None:
self.model_combo.clear()
is_video = self.engine.currentData() == "deepmosaics_video"
@@ -92,7 +199,6 @@ class RestoreDialog(QDialog):
models = discover_models() # per-frame engine: image clean models only
for name, path in models:
self.model_combo.addItem(name, path)
# Keep an externally-configured model selectable even if it's outside the folder.
if current and self.model_combo.findData(current) < 0:
self.model_combo.addItem(Path(current).stem + " (внешняя)", current)
if self.model_combo.count() == 0:
@@ -111,13 +217,16 @@ class RestoreDialog(QDialog):
return w
def _sync(self) -> None:
is_dm = self.engine.currentData() in ("deepmosaics", "deepmosaics_video")
is_video = self.engine.currentData() == "deepmosaics_video"
# The model list differs per engine (image vs video weights) — repopulate.
self._populate_models(self._cfg.dm_model)
self.model_combo.setEnabled(is_dm)
self.dm_gpu.setEnabled(is_dm)
self.feed_restored.setEnabled(is_video) # only the temporal engine has a window
engine = self.engine.currentData()
is_dm = engine in ("deepmosaics", "deepmosaics_video")
is_video = engine == "deepmosaics_video"
is_diff = engine == "diffusion"
if is_dm: # repopulate model list (image vs video weights differ)
self._populate_models(self._cfg.dm_model)
self.feed_restored.setEnabled(is_video)
self.dm_group.setVisible(is_dm)
self.diff_group.setVisible(is_diff)
self.adjustSize()
def _browse_model(self) -> None:
p, _ = QFileDialog.getOpenFileName(self, "Веса DeepMosaics", "", "Веса (*.pth);;Все файлы (*.*)")
@@ -126,8 +235,43 @@ class RestoreDialog(QDialog):
self.model_combo.addItem(Path(p).stem, p)
self.model_combo.setCurrentIndex(self.model_combo.findData(p))
def _test_connection(self) -> None:
"""Ping SwarmUI (GetNewSession) with the current URL and report OK / the error."""
from ..core.restore.swarmui import SwarmUIBackend
url = self.diff_url.text().strip() or "http://localhost:7801"
self.diff_test_status.setText("Проверка…")
self.diff_test_btn.setEnabled(False)
QApplication.setOverrideCursor(Qt.WaitCursor)
QApplication.processEvents()
try:
session = SwarmUIBackend(url, timeout=15.0).ping() # short timeout for the probe
except Exception as e: # noqa: BLE001 — show the server/connection error verbatim
self.diff_test_status.setText(f"<span style='color:#c0392b'>✗ {e}</span>")
QMessageBox.warning(self, "SwarmUI: соединение", str(e))
else:
self.diff_test_status.setText(
f"<span style='color:#27ae60'>✓ Соединение OK (session: {session})</span>"
)
finally:
QApplication.restoreOverrideCursor()
self.diff_test_btn.setEnabled(True)
def apply_to_config(self) -> None:
self._cfg.restorer = self.engine.currentData()
# DeepMosaics
self._cfg.dm_model = self.model_combo.currentData()
self._cfg.dm_gpu = self.dm_gpu.text().strip() or "0"
self._cfg.dm_feed_restored = self.feed_restored.isChecked()
# Diffusion (SwarmUI)
self._cfg.diff_backend = "swarmui"
self._cfg.diff_url = self.diff_url.text().strip() or "http://localhost:7801"
self._cfg.diff_model = self.diff_model.text().strip() or None
self._cfg.diff_prompt = self.diff_prompt.text()
self._cfg.diff_negative = self.diff_negative.text()
self._cfg.diff_steps = self.diff_steps.value()
self._cfg.diff_cfg = self.diff_cfg.value()
self._cfg.diff_denoise = self.diff_denoise.value()
self._cfg.diff_seed = self.diff_seed.value()
self._cfg.diff_mask_dilate = self.diff_dilate.value()
self._cfg.diff_mask_blur = self.diff_blur.value()