Enhance HVideoTool's detection and restoration features: added support for tracking the model used in detections, improved temporal coherence by allowing the use of already-restored frames in the restoration process, and updated the UI to reflect these changes with new indicators and configuration options. Documentation in CLAUDE.md has been updated accordingly.
This commit is contained in:
@@ -88,6 +88,7 @@ class MainWindow(QMainWindow):
|
||||
self._restorer = None # un-censor engine, built lazily from config
|
||||
self._restorer_key = None
|
||||
self._restored: dict[str, object] = {} # path -> restored image (BGR ndarray)
|
||||
self._restored_count = 0 # frames with output in restored/ (for the progress summary)
|
||||
self._showing_restored = False
|
||||
self._nav_sync = False # guard against slider<->list signal loops
|
||||
self._busy = False # a long operation is running
|
||||
@@ -218,6 +219,11 @@ class MainWindow(QMainWindow):
|
||||
clayout.setSpacing(2)
|
||||
clayout.addWidget(self.view, 1)
|
||||
clayout.addWidget(self._build_nav_bar())
|
||||
# Processing summary under the scrubber: how much of the sequence is done.
|
||||
self.stats_label = QLabel("")
|
||||
self.stats_label.setAlignment(Qt.AlignCenter)
|
||||
self.stats_label.setStyleSheet("QLabel{color:#888; padding:1px;}")
|
||||
clayout.addWidget(self.stats_label)
|
||||
|
||||
right = QWidget()
|
||||
rlayout = QVBoxLayout(right)
|
||||
@@ -225,8 +231,10 @@ class MainWindow(QMainWindow):
|
||||
self.detail_header = QLabel("Детекции")
|
||||
self.detail_header.setWordWrap(True)
|
||||
rlayout.addWidget(self.detail_header)
|
||||
self.detail_table = QTableWidget(0, 4)
|
||||
self.detail_table.setHorizontalHeaderLabels(["Категория", "Увер.", "BBox (x,y,w,h)", "Полигон"])
|
||||
self.detail_table = QTableWidget(0, 5)
|
||||
self.detail_table.setHorizontalHeaderLabels(
|
||||
["Категория", "Модель", "Увер.", "BBox (x,y,w,h)", "Полигон"]
|
||||
)
|
||||
self.detail_table.verticalHeader().setVisible(False)
|
||||
self.detail_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.detail_table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
@@ -864,6 +872,7 @@ class MainWindow(QMainWindow):
|
||||
self._end_busy()
|
||||
loaded = self._load_cached_results() # reuse a matching on-disk cache
|
||||
self._refresh_marks()
|
||||
self._refresh_restored_marks()
|
||||
|
||||
if not files:
|
||||
self.view.set_image(None, [])
|
||||
@@ -1031,6 +1040,7 @@ class MainWindow(QMainWindow):
|
||||
self._showing_restored = True
|
||||
self.view.set_image(restored, [])
|
||||
self._update_restore_actions()
|
||||
self._refresh_restored_marks()
|
||||
self.statusBar().showMessage(f"Расцензурено ({engine}): {Path(k).name}")
|
||||
|
||||
self.statusBar().showMessage(f"Восстановление: {path.name}…")
|
||||
@@ -1098,6 +1108,7 @@ class MainWindow(QMainWindow):
|
||||
self._showing_restored = True
|
||||
self.view.set_image(img, [])
|
||||
self._update_restore_actions()
|
||||
self._refresh_restored_marks()
|
||||
self.statusBar().showMessage(
|
||||
"Расцензуривание отменено" if cancelled
|
||||
else f"Готово: результаты в {out_dir.name}/ ({total} кадров)"
|
||||
@@ -1196,6 +1207,7 @@ class MainWindow(QMainWindow):
|
||||
elif self.file_list.count() == 0:
|
||||
self.view.set_image(None, [])
|
||||
self._refresh_marks() # rows shifted — remap marks to new indices
|
||||
self._refresh_restored_marks()
|
||||
self._update_nav()
|
||||
|
||||
@staticmethod
|
||||
@@ -1276,6 +1288,39 @@ class MainWindow(QMainWindow):
|
||||
if self._results.get(self.file_list.item(i).data(Qt.UserRole))
|
||||
}
|
||||
self.frame_slider.set_marks(marks)
|
||||
self._update_counts_label()
|
||||
|
||||
def _update_counts_label(self) -> None:
|
||||
"""Refresh the processing summary under the scrubber (cheap; counts only)."""
|
||||
n = len(self._files)
|
||||
detected = sum(1 for p in self._files if str(p) in self._results)
|
||||
hits = sum(1 for p in self._files if self._results.get(str(p)))
|
||||
if n == 0:
|
||||
self.stats_label.setText("")
|
||||
return
|
||||
self.stats_label.setText(
|
||||
f"Кадров: {n} · детектировано: {detected}/{n} (с цензурой: {hits})"
|
||||
f" · расцензурено: {self._restored_count}/{n}"
|
||||
)
|
||||
|
||||
def _refresh_restored_marks(self) -> None:
|
||||
"""Scan ``restored/`` and project restored frames onto the scrubber (green).
|
||||
|
||||
Done only on load / after a restore op (not per-frame) — it touches the disk.
|
||||
"""
|
||||
stems: set[str] = set()
|
||||
if self._project is not None and self._project.restored_dir.is_dir():
|
||||
stems = {p.stem for p in self._project.restored_dir.glob("*.jpg")}
|
||||
mem = set(self._restored) # single-frame restores held in memory (not on disk yet)
|
||||
rows: set[int] = set()
|
||||
if stems or mem:
|
||||
for i in range(self.file_list.count()):
|
||||
fp = Path(self.file_list.item(i).data(Qt.UserRole))
|
||||
if fp.stem in stems or str(fp) in mem:
|
||||
rows.add(i)
|
||||
self._restored_count = len(rows)
|
||||
self.frame_slider.set_restored_marks(rows)
|
||||
self._update_counts_label()
|
||||
|
||||
def _fill_detail_table(self, path: Path, img, dets: list[Detection] | None) -> None:
|
||||
h, w = (img.shape[0], img.shape[1]) if img is not None else (0, 0)
|
||||
@@ -1298,7 +1343,7 @@ class MainWindow(QMainWindow):
|
||||
self.detail_table.setRowCount(len(dets))
|
||||
for row, d in enumerate(dets):
|
||||
x, y, bw, bh = d.bbox
|
||||
cells = [d.display, f"{d.score:.2f}", f"{x},{y},{bw},{bh}", str(len(d.polygon))]
|
||||
cells = [d.display, d.model or "—", f"{d.score:.2f}", f"{x},{y},{bw},{bh}", str(len(d.polygon))]
|
||||
for col, text in enumerate(cells):
|
||||
self.detail_table.setItem(row, col, QTableWidgetItem(text))
|
||||
self.detail_table.blockSignals(False)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""A horizontal QSlider that paints marks at frames where censorship was found.
|
||||
"""A horizontal QSlider that paints marks at frames along the sequence.
|
||||
|
||||
Used as the navigation scrubber under the image: the file list / `_results`
|
||||
cache is projected onto the groove as small vertical ticks, so you can see at a
|
||||
glance where the detected regions are along the whole sequence.
|
||||
Used as the navigation scrubber under the image. Two independent layers are
|
||||
projected onto the groove as small vertical ticks, so you can see at a glance how
|
||||
the whole sequence is processed:
|
||||
|
||||
* **cyan** (upper half) — frames where censorship was *detected* (`_results`);
|
||||
* **green** (lower half) — frames that have been *restored* (``restored/``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,19 +20,29 @@ from PySide6.QtWidgets import QSlider, QStyle, QStyleOptionSlider
|
||||
class MarkerSlider(QSlider):
|
||||
def __init__(self, orientation=Qt.Horizontal, parent=None) -> None:
|
||||
super().__init__(orientation, parent)
|
||||
self._marks: set[int] = set()
|
||||
self._marks: set[int] = set() # detected (censorship found)
|
||||
self._restored_marks: set[int] = set() # restored (un-censored)
|
||||
# Bright cyan stands out against both the dark track and the orange fill.
|
||||
self._mark_color = QColor(0, 220, 255)
|
||||
self._restored_color = QColor(80, 230, 120) # green = restored
|
||||
|
||||
def set_marks(self, marks: Iterable[int]) -> None:
|
||||
"""Frames with detections (painted cyan, upper half)."""
|
||||
marks = set(marks)
|
||||
if marks != self._marks:
|
||||
self._marks = marks
|
||||
self.update()
|
||||
|
||||
def set_restored_marks(self, marks: Iterable[int]) -> None:
|
||||
"""Frames that have been restored (painted green, lower half)."""
|
||||
marks = set(marks)
|
||||
if marks != self._restored_marks:
|
||||
self._restored_marks = marks
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event) -> None:
|
||||
super().paintEvent(event)
|
||||
if not self._marks or self.maximum() <= self.minimum():
|
||||
if (not self._marks and not self._restored_marks) or self.maximum() <= self.minimum():
|
||||
return
|
||||
|
||||
opt = QStyleOptionSlider()
|
||||
@@ -45,22 +58,32 @@ class MarkerSlider(QSlider):
|
||||
return
|
||||
lo, hi = self.minimum(), self.maximum()
|
||||
half = handle.width() // 2
|
||||
# Span (almost) the full widget height so marks read over the fill/handle.
|
||||
top = self.rect().top() + 1
|
||||
bottom = self.rect().bottom() - 1
|
||||
mid = (top + bottom) // 2
|
||||
|
||||
painter = QPainter(self)
|
||||
# Detections in the upper half, restored in the lower half, so a frame that's
|
||||
# both detected and restored shows both ticks instead of one hiding the other.
|
||||
self._paint_layer(painter, self._marks, self._mark_color, top, mid,
|
||||
groove.x(), half, span, lo, hi, opt.upsideDown)
|
||||
self._paint_layer(painter, self._restored_marks, self._restored_color, mid, bottom,
|
||||
groove.x(), half, span, lo, hi, opt.upsideDown)
|
||||
painter.end()
|
||||
|
||||
def _paint_layer(self, painter, marks, color, y0, y1, gx, half, span, lo, hi, upside) -> None:
|
||||
if not marks:
|
||||
return
|
||||
pen = painter.pen()
|
||||
pen.setColor(self._mark_color)
|
||||
pen.setColor(color)
|
||||
pen.setWidth(2)
|
||||
painter.setPen(pen)
|
||||
# Many frames can collapse onto the same pixel column — dedupe to keep
|
||||
# repaint cheap on big folders (tens of thousands of frames).
|
||||
seen_x: set[int] = set()
|
||||
for m in self._marks:
|
||||
pos = QStyle.sliderPositionFromValue(lo, hi, m, span, opt.upsideDown)
|
||||
x = groove.x() + half + pos
|
||||
for m in marks:
|
||||
pos = QStyle.sliderPositionFromValue(lo, hi, m, span, upside)
|
||||
x = gx + half + pos
|
||||
if x not in seen_x:
|
||||
seen_x.add(x)
|
||||
painter.drawLine(x, top, x, bottom)
|
||||
painter.end()
|
||||
painter.drawLine(x, y0, x, y1)
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
@@ -49,10 +50,23 @@ class RestoreDialog(QDialog):
|
||||
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(
|
||||
"Подавать уже расцензуренные прошлые кадры в окно (эксперим.)"
|
||||
)
|
||||
self.feed_restored.setChecked(bool(getattr(config, "dm_feed_restored", True)))
|
||||
self.feed_restored.setToolTip(
|
||||
"Только для видеодвижка: прошлые соседние кадры в окне берутся из уже\n"
|
||||
"восстановленных результатов, а не из оригинала с мозаикой — больше\n"
|
||||
"временной связности. Сеть обучалась на мозаичных окнах, так что эффект\n"
|
||||
"не гарантирован; выключите для точной реализации 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)
|
||||
hint = QLabel(
|
||||
"Модели берутся из models/deepmosaics (рядом нужен mosaic_position.pth).\n"
|
||||
"• Картинка: clean_youknow_resnet_9blocks.pth — покадрово.\n"
|
||||
@@ -98,10 +112,12 @@ class RestoreDialog(QDialog):
|
||||
|
||||
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
|
||||
|
||||
def _browse_model(self) -> None:
|
||||
p, _ = QFileDialog.getOpenFileName(self, "Веса DeepMosaics", "", "Веса (*.pth);;Все файлы (*.*)")
|
||||
@@ -114,3 +130,4 @@ class RestoreDialog(QDialog):
|
||||
self._cfg.restorer = self.engine.currentData()
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user