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