Files
HVideoTool/hvideotool/ui/main_window.py
T

1190 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Main window: open a **project** and inspect what the detector found.
A project is a folder (``project.json`` + ``frames/`` + ``detections.json`` +
``collections/``) — see ``core/project.py``. The per-project settings (detector,
model, threshold, restore engine) live in ``project.json``; the global
``settings.json`` only seeds defaults for new projects.
Layout: a toolbar (new/open project · from-video · detector · model · calc-frame ·
detect-all · threshold), then a splitter with three panes — left: collection
controls + the file list; center: the image with overlays; right: a detail table
of every detection. Collection controls sit by the file list (they act on its
selection), keeping the toolbar to detection/entry actions only.
Viewing and detecting are decoupled, so browsing a big project stays instant even
with a slow (CPU) detector:
- selecting a file just **shows** it (with its cached result, if any);
- **double-clicking** a file, or "Рассчитать кадр", runs the detector on it;
- "Детектировать все" runs the whole project.
Both project loading and detect-all show a progress bar. Results are cached in the
project; switching detector/model clears the cache.
"""
from __future__ import annotations
import shutil
from pathlib import Path
from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtGui import QAction, QBrush, QColor, QKeySequence, QShortcut
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QComboBox,
QDialog,
QDoubleSpinBox,
QFileDialog,
QHBoxLayout,
QInputDialog,
QLabel,
QListWidget,
QListWidgetItem,
QMainWindow,
QMessageBox,
QProgressBar,
QPushButton,
QSplitter,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from .. import settings_store
from ..config import AppConfig
from ..core.detection import cache as detection_cache
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.video.extract import extract_frames
from ..core.video.frame import Frame
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);;Все файлы (*.*)"
_DETECTORS = ["classic", "yolo", "combined"]
class MainWindow(QMainWindow):
def __init__(self, config: AppConfig) -> None:
super().__init__()
self._cfg = config
self._detector = None
self._detector_key = None
self._project: Project | None = None # the open project (None until one is opened)
self._folder: Path | None = None # == project.frames_dir while a project is open
self._files: list[Path] = []
self._results: dict[str, list[Detection]] = {} # path -> detections (cache)
self._current: Path | None = None
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._showing_restored = False
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)
self._build_toolbar()
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
def _build_menu(self) -> None:
file_menu = self.menuBar().addMenu("Файл")
file_menu.addAction("Создать проект…", self._create_project)
file_menu.addAction("Открыть проект…", self._open_project_dialog)
file_menu.addAction("Импортировать папку как проект…", self._import_folder_as_project)
file_menu.addAction("Создать из ролика…", self._create_from_video)
self._recent_menu = file_menu.addMenu("Недавние проекты")
self._refresh_recent_menu()
file_menu.addSeparator()
file_menu.addAction("Рассчитать кадр", self._recompute_current).setShortcut("Space")
file_menu.addAction("Детектировать все (дозапуск)", lambda: self._detect_all(False))
file_menu.addAction("Детектировать все заново", lambda: self._detect_all(True))
file_menu.addSeparator()
file_menu.addAction("Движок восстановления…", self._open_restore_settings)
file_menu.addSeparator()
file_menu.addAction("В избранное", self._move_to_favorites).setShortcut("Ctrl+M")
file_menu.addSeparator()
file_menu.addAction("Выход", self.close)
def _build_toolbar(self) -> None:
tb = self.addToolBar("Главная")
tb.setMovable(False)
tb.addAction(QAction("Создать проект…", self, triggered=self._create_project))
tb.addAction(QAction("Открыть проект…", self, triggered=self._open_project_dialog))
from_video = QAction("Создать из ролика…", self, triggered=self._create_from_video)
from_video.setToolTip("Разложить видео на кадры в новый проект и открыть его")
tb.addAction(from_video)
tb.addSeparator()
tb.addWidget(QLabel(" Детектор: "))
self.detector_combo = QComboBox()
self.detector_combo.addItems(_DETECTORS)
self.detector_combo.setCurrentText(self._cfg.detector)
self.detector_combo.currentTextChanged.connect(self._on_detector_changed)
tb.addWidget(self.detector_combo)
self.model_action = QAction("Модель…", self, triggered=self._choose_model)
tb.addAction(self.model_action)
tb.addSeparator()
calc = QAction("Рассчитать кадр", self, triggered=self._recompute_current)
calc.setToolTip("Запустить детектор на выбранном кадре (Space / двойной клик по файлу)")
tb.addAction(calc)
detect_all = QAction("Детектировать все", self, triggered=lambda: self._detect_all(False))
detect_all.setToolTip("Рассчитать все ещё не посчитанные кадры (дозапуск; кэш сохраняется)")
tb.addAction(detect_all)
regen = QAction("Все заново", self, triggered=lambda: self._detect_all(True))
regen.setToolTip("Очистить кэш детекций и пересчитать всю папку заново")
tb.addAction(regen)
self.stop_action = QAction("■ Стоп", self, triggered=self._request_cancel)
self.stop_action.setToolTip("Отменить текущую операцию (Esc)")
self.stop_action.setEnabled(False)
tb.addAction(self.stop_action)
tb.addSeparator()
restore = QAction("Расцензурить кадр", self, triggered=self._restore_current)
restore.setToolTip("Восстановить найденные области на текущем кадре")
tb.addAction(restore)
self.toggle_restored_action = QAction("Показать оригинал", self, triggered=self._toggle_restored)
self.toggle_restored_action.setEnabled(False)
tb.addAction(self.toggle_restored_action)
self.save_restored_action = QAction("Сохранить результат", self, triggered=self._save_restored)
self.save_restored_action.setEnabled(False)
tb.addAction(self.save_restored_action)
tb.addSeparator()
tb.addWidget(QLabel(" Порог: "))
self.threshold_spin = QDoubleSpinBox()
self.threshold_spin.setRange(0.0, 1.0)
self.threshold_spin.setSingleStep(0.05)
self.threshold_spin.setValue(self._cfg.default_threshold)
self.threshold_spin.valueChanged.connect(self._on_threshold_changed)
tb.addWidget(self.threshold_spin)
def _build_central(self) -> None:
self.file_list = QListWidget()
self.file_list.setSelectionMode(QAbstractItemView.ExtendedSelection) # multi-select for moves
self.file_list.currentItemChanged.connect(self._on_file_selected)
self.file_list.itemDoubleClicked.connect(self._on_file_activated)
# One default collection ("Избранное"); the button acts on the list selection.
move_btn = QPushButton("★ В избранное")
move_btn.setToolTip("Переместить выбранные кадры в избранное проекта (Ctrl+M)")
move_btn.clicked.connect(self._move_to_favorites)
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(4, 4, 4, 4)
left_layout.setSpacing(4)
left_layout.addWidget(self.file_list, 1)
left_layout.addWidget(move_btn)
self.view = ImageView(self._cfg.overlay)
self.view.set_threshold(self._cfg.default_threshold)
center = QWidget()
clayout = QVBoxLayout(center)
clayout.setContentsMargins(0, 0, 0, 0)
clayout.setSpacing(2)
clayout.addWidget(self.view, 1)
clayout.addWidget(self._build_nav_bar())
right = QWidget()
rlayout = QVBoxLayout(right)
rlayout.setContentsMargins(4, 4, 4, 4)
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.verticalHeader().setVisible(False)
self.detail_table.setSelectionBehavior(QTableWidget.SelectRows)
self.detail_table.setEditTriggers(QTableWidget.NoEditTriggers)
self.detail_table.itemSelectionChanged.connect(self._on_detail_selected)
rlayout.addWidget(self.detail_table)
splitter = QSplitter(Qt.Horizontal)
splitter.addWidget(left)
splitter.addWidget(center)
splitter.addWidget(right)
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
splitter.setStretchFactor(2, 0)
splitter.setSizes([240, 640, 300])
self.setCentralWidget(splitter)
def _build_nav_bar(self) -> QWidget:
bar = QWidget()
h = QHBoxLayout(bar)
h.setContentsMargins(4, 2, 4, 2)
self.prev_btn = QPushButton("◀")
self.prev_btn.setToolTip("Предыдущий кадр (,)")
self.prev_btn.clicked.connect(lambda: self._step(-1))
self.next_btn = QPushButton("▶")
self.next_btn.setToolTip("Следующий кадр (.)")
self.next_btn.clicked.connect(lambda: self._step(1))
self.frame_slider = MarkerSlider(Qt.Horizontal)
self.frame_slider.setMinimum(0)
self.frame_slider.setMaximum(0)
self.frame_slider.setToolTip("Перемотка по кадрам (стрелки ← →); метки — кадры с детекцией")
self.frame_slider.valueChanged.connect(self._on_slider)
self.pos_label = QLabel("0 / 0")
self.pos_label.setMinimumWidth(90)
self.pos_label.setAlignment(Qt.AlignCenter)
self.prev_hit_btn = QPushButton("◀ детекция")
self.prev_hit_btn.setToolTip("Предыдущий кадр с детекцией ([)")
self.prev_hit_btn.clicked.connect(lambda: self._step_hit(-1))
self.next_hit_btn = QPushButton("детекция ▶")
self.next_hit_btn.setToolTip("Следующий кадр с детекцией (])")
self.next_hit_btn.clicked.connect(lambda: self._step_hit(1))
for wdg in (self.prev_btn, self.next_btn, self.frame_slider, self.pos_label,
self.prev_hit_btn, self.next_hit_btn):
h.addWidget(wdg, 1 if wdg is self.frame_slider else 0)
# Keyboard shortcuts (window-wide), chosen to not clash with list/slider arrows.
QShortcut(QKeySequence(","), self, lambda: self._step(-1))
QShortcut(QKeySequence("."), self, lambda: self._step(1))
QShortcut(QKeySequence("["), self, lambda: self._step_hit(-1))
QShortcut(QKeySequence("]"), self, lambda: self._step_hit(1))
QShortcut(QKeySequence(Qt.Key_Escape), self, self._request_cancel)
return bar
# -------------------------------------------------------------- navigation
def _step(self, delta: int) -> None:
n = self.file_list.count()
if n == 0:
return
row = max(0, min(n - 1, self.file_list.currentRow() + delta))
self.file_list.setCurrentRow(row)
def _step_hit(self, direction: int) -> None:
"""Jump to the nearest frame (in `direction`) that has detections."""
n = self.file_list.count()
if n == 0:
return
row = self.file_list.currentRow()
i = row + direction
while 0 <= i < n:
path = self.file_list.item(i).data(Qt.UserRole)
if self._results.get(path):
self.file_list.setCurrentRow(i)
return
i += direction
self.statusBar().showMessage(
"Больше нет кадров с детекцией в эту сторону "
"(сначала «Детектировать все»)"
)
def _on_slider(self, value: int) -> None:
if self._nav_sync:
return
if value != self.file_list.currentRow():
self.file_list.setCurrentRow(value)
def _update_nav(self) -> None:
n = self.file_list.count()
row = self.file_list.currentRow()
self._nav_sync = True
self.frame_slider.setMaximum(max(0, n - 1))
self.frame_slider.setValue(max(0, row))
self._nav_sync = False
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:
self.progress.setRange(0, total)
self.progress.setValue(0)
self.progress.setVisible(True)
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("Отмена…")
# ------------------------------------------------------------- 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):
d = self._cfg.detection
key = (self._cfg.detector, self._cfg.model_path, d.yolo_conf, d.yolo_imgsz)
if key != self._detector_key:
self._detector = build_detector(self._cfg) # may raise ValueError / import / file errors
self._detector_key = key
return self._detector
def _on_detector_changed(self, name: str) -> None:
self._cfg.detector = name
# 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:
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);;Все файлы (*.*)")
if path:
self._cfg.model_path = path
self._persist_settings()
self.statusBar().showMessage(f"Модель: {path}")
self._invalidate_results()
def _invalidate_results(self) -> None:
"""Detector changed — drop the in-memory cache and refresh the current image.
The on-disk cache is left as-is; it won't be reloaded for the new detector
(key mismatch) and gets overwritten once results for the new detector exist.
"""
self._detector_key = None
self._clear_results()
if self._current is not None:
self._show(self._current)
# ----------------------------------------------------------------- projects
def open_path(self, path: str) -> None:
"""Open a project at ``path`` (a project folder or its project.json)."""
p = Path(path)
if Project.is_project(p):
try:
self._open_project(Project.load(p))
except (OSError, ValueError) as exc: # noqa: BLE001 - surface to the user
QMessageBox.warning(self, "Ошибка", f"Не удалось открыть проект:\n{exc}")
elif p.is_dir():
QMessageBox.information(
self, "Не проект",
"Это обычная папка, а не проект. Используйте "
"«Импортировать папку как проект…».",
)
else:
QMessageBox.warning(self, "Ошибка", f"Путь не найден: {p}")
def _auto_open_last(self) -> None:
"""On startup, reopen the last project if it still exists (best-effort)."""
last = settings_store.last_project()
if last and Project.is_project(last):
try:
self._open_project(Project.load(last))
except (OSError, ValueError):
pass
def _new_project_root(self, default_name: str = "") -> Path | None:
"""Prompt for a parent dir + name; return a fresh (empty) project root or None."""
start = settings_store.last_dir() or str(Path.home())
parent = QFileDialog.getExistingDirectory(self, "Где создать проект", start)
if not parent:
return None
name, ok = QInputDialog.getText(self, "Новый проект", "Имя проекта:", text=default_name)
name = name.strip()
if not ok or not name:
return None
root = Path(parent) / name
if root.exists() and any(root.iterdir()):
QMessageBox.warning(self, "Папка занята", f"Папка уже существует и не пуста:\n{root}")
return None
settings_store.set_last_dir(parent)
return root
def _create_project(self) -> None:
if self._busy:
return
root = self._new_project_root()
if root is None:
return
try:
project = Project.create(root, name=root.name)
except OSError as exc:
QMessageBox.warning(self, "Ошибка", f"Не удалось создать проект:\n{exc}")
return
project.update_from_config(self._cfg) # seed from current global defaults
project.save()
self._open_project(project)
def _open_project_dialog(self) -> None:
if self._busy:
return
start = settings_store.last_dir() or str(Path.home())
folder = QFileDialog.getExistingDirectory(self, "Открыть проект (папка проекта)", start)
if not folder:
return
if not Project.is_project(folder):
QMessageBox.warning(self, "Не проект", f"В папке нет {PROJECT_FILE}:\n{folder}")
return
try:
project = Project.load(folder)
except (OSError, ValueError) as exc:
QMessageBox.warning(self, "Ошибка", f"Не удалось открыть проект:\n{exc}")
return
settings_store.set_last_dir(str(Path(folder).parent))
self._open_project(project)
def _import_folder_as_project(self) -> None:
"""Create a project and copy a folder of images into its frames/."""
if self._busy:
return
start = settings_store.last_dir() or ""
src = QFileDialog.getExistingDirectory(self, "Папка с картинками для импорта", start)
if not src:
return
src = Path(src)
images = sorted(p for p in src.iterdir() if p.suffix.lower() in _IMAGE_EXTS)
if not images:
QMessageBox.warning(self, "Пусто", f"В папке нет картинок:\n{src}")
return
root = self._new_project_root(default_name=src.name)
if root is None:
return
try:
project = Project.create(root, name=root.name, source=str(src))
except OSError as exc:
QMessageBox.warning(self, "Ошибка", f"Не удалось создать проект:\n{exc}")
return
project.update_from_config(self._cfg)
project.save()
self._begin_busy(len(images))
copied = 0
try:
for i, p in enumerate(images, 1):
self.progress.setValue(i)
self.statusBar().showMessage(f"Импорт {i}/{len(images)}: {p.name}")
QApplication.processEvents()
if self._cancel:
break
dst = self._unique_dest(project.frames_dir, p.name)
try:
shutil.copy2(str(p), str(dst))
copied += 1
except OSError:
continue
finally:
self._end_busy()
# Carry over an old sidecar detection cache (basename-keyed) if present.
old_sidecar = src / ".hvideotool_detections.json"
if old_sidecar.is_file():
try:
shutil.copy2(str(old_sidecar), str(project.cache_path))
except OSError:
pass
self.statusBar().showMessage(f"Импортировано {copied} картинок → {project.name}")
self._open_project(project)
def _refresh_recent_menu(self) -> None:
self._recent_menu.clear()
recents = settings_store.recent_projects()
if not recents:
empty = self._recent_menu.addAction("(пусто)")
empty.setEnabled(False)
return
for path in recents:
self._recent_menu.addAction(Path(path).name, lambda checked=False, p=path: self._open_recent(p))
def _open_recent(self, path: str) -> None:
if self._busy:
return
if not Project.is_project(path):
QMessageBox.warning(self, "Нет проекта", f"Проект не найден:\n{path}")
return
try:
self._open_project(Project.load(path))
except (OSError, ValueError) as exc:
QMessageBox.warning(self, "Ошибка", f"Не удалось открыть проект:\n{exc}")
def _open_project(self, project: Project) -> None:
"""Core open: set project state, apply its settings, list its frames."""
if self._busy:
return
self._project = project
project.frames_dir.mkdir(parents=True, exist_ok=True)
project.apply_to_config(self._cfg) # per-project settings -> live config
self._sync_settings_ui()
self._detector_key = None
self._restorer_key = None
self._restored.clear()
settings_store.set_last_project(str(project.root))
settings_store.add_recent_project(str(project.root))
self._refresh_recent_menu()
self.setWindowTitle(f"HVideoTool — {project.name}")
self._load_folder(project.frames_dir)
def _sync_settings_ui(self) -> None:
"""Reflect the (project's) config onto the toolbar widgets without signal loops."""
self.detector_combo.blockSignals(True)
self.detector_combo.setCurrentText(self._cfg.detector)
self.detector_combo.blockSignals(False)
self.threshold_spin.blockSignals(True)
self.threshold_spin.setValue(self._cfg.default_threshold)
self.threshold_spin.blockSignals(False)
self.view.set_threshold(self._cfg.default_threshold)
def _persist_settings(self) -> None:
"""Save settings to the global defaults and (if open) into the project."""
settings_store.save(self._cfg) # global defaults for new projects
if self._project is not None:
self._project.update_from_config(self._cfg)
self._project.save()
def _create_from_video(self) -> None:
"""Decode a video into a new project's frames/ and open the project."""
if self._busy:
return
path, _ = QFileDialog.getOpenFileName(
self, "Выберите ролик", settings_store.last_dir() or "", _VIDEO_FILTER
)
if not path:
return
dialog = ExtractDialog(self)
if dialog.exec() != QDialog.Accepted:
return
keyframes_only, step, max_dim = dialog.options()
video = Path(path)
root = video.parent / f"{video.stem}_frames"
if Project.is_project(root):
project = Project.load(root) # re-extract into the existing project
elif root.exists() and any(root.iterdir()):
QMessageBox.warning(self, "Папка занята", f"Папка уже существует и не пуста:\n{root}")
return
else:
project = Project.create(root, name=root.name, source=str(video))
project.update_from_config(self._cfg)
project.save()
out = project.frames_dir
self._begin_busy(1000) # promille of duration
def cb(done: float, total: float) -> bool:
if total > 0:
self.progress.setValue(int(1000 * min(done, total) / total))
self.statusBar().showMessage(f"Извлечение кадров: {done:.0f}/{total:.0f} с…")
QApplication.processEvents()
return not self._cancel # returning False stops extraction
try:
saved = extract_frames(
str(video), str(out), step=step, keyframes_only=keyframes_only,
max_dim=max_dim, progress=cb,
)
except Exception as exc: # noqa: BLE001 - surface decode errors to the user
QMessageBox.warning(self, "Ошибка", f"Не удалось извлечь кадры:\n{exc}")
return
finally:
cancelled = self._cancel
self._end_busy()
if saved == 0:
msg = "Извлечение отменено — кадров нет." if cancelled \
else "Из ролика не удалось извлечь ни одного кадра."
QMessageBox.warning(self, "Пусто", msg)
return
verb = "Отменено, извлечено" if cancelled else "Извлечено"
self.statusBar().showMessage(f"{verb} {saved} кадров → {out}")
self._open_project(project)
def _load_folder(self, folder: Path) -> None:
"""List images from ``folder`` (a project's frames/) into the file list."""
if not folder.is_dir():
QMessageBox.warning(self, "Ошибка", f"Папка не найдена: {folder}")
return
self.statusBar().showMessage(f"Сканирую папку: {folder}…")
QApplication.processEvents()
files = sorted(p for p in folder.iterdir() if p.suffix.lower() in _IMAGE_EXTS)
self._folder = folder
self._files = files
self._results.clear()
self._current = None
self.file_list.blockSignals(True)
self.file_list.setUpdatesEnabled(False)
self.file_list.clear()
self._begin_busy(len(files))
for i, p in enumerate(files, 1):
item = QListWidgetItem(p.name)
item.setData(Qt.UserRole, str(p))
item.setData(Qt.UserRole + 1, p.name) # base label, without the count suffix
self.file_list.addItem(item)
if i % 1000 == 0:
self.progress.setValue(i)
self.statusBar().showMessage(f"Загрузка списка: {i}/{len(files)}…")
QApplication.processEvents()
if self._cancel:
self._files = files[:i] # keep only what we listed
break
self.file_list.setUpdatesEnabled(True)
self.file_list.blockSignals(False)
self._end_busy()
loaded = self._load_cached_results() # reuse a matching on-disk cache
self._refresh_marks()
if not files:
self.view.set_image(None, [])
self._update_nav()
self.statusBar().showMessage(
"В проекте пока нет кадров — импортируйте папку или создайте из ролика"
)
return
cache_note = f" · загружен кэш детекций ({loaded})" if loaded else ""
self.statusBar().showMessage(
f"{len(files)} картинок · {folder} · двойной клик / «Рассчитать кадр» для детекции"
+ cache_note
)
self.file_list.setCurrentRow(0)
def _on_file_selected(self, current: QListWidgetItem | None, _prev=None) -> None:
self._update_nav()
if current is not None:
self._show(Path(current.data(Qt.UserRole))) # view only — no detection
def _on_file_activated(self, item: QListWidgetItem) -> None:
# Double-click: compute if not already cached, then show.
if self._busy:
return
path = Path(item.data(Qt.UserRole))
if str(path) in self._results:
self._show(path)
return
self._detect_one(path, then_show=True)
@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:
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)
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
self._showing_restored = False
img = imread_unicode(str(path))
dets = self._results.get(str(path)) # None => not yet computed
self.view.set_image(img, dets or [])
self._fill_detail_table(path, img, dets)
self._update_restore_actions()
def _recompute_current(self) -> None:
"""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
self._detect_one(self._current, then_show=True)
def _detect_all(self, force: bool = False) -> None:
"""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()
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
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:
"""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
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=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,
self._cfg.dm_python, self._cfg.dm_gpu)
if key != self._restorer_key:
self._restorer = build_restorer(self._cfg.restorer, self._cfg) # may raise
self._restorer_key = key
return self._restorer
def _open_restore_settings(self) -> None:
dlg = RestoreDialog(self._cfg, self)
if dlg.exec() != QDialog.Accepted:
return
dlg.apply_to_config()
self._persist_settings()
self._restorer_key = None # rebuild on next restore
self.statusBar().showMessage(f"Движок восстановления: {self._cfg.restorer}")
def _toggle_restored(self) -> None:
if self._current is None or str(self._current) not in self._restored:
return
self._showing_restored = not self._showing_restored
key = str(self._current)
if self._showing_restored:
self.view.set_image(self._restored[key], [])
else:
self.view.set_image(imread_unicode(key), self._results.get(key) or [])
self._update_restore_actions()
def _update_restore_actions(self) -> None:
has = self._current is not None and str(self._current) in self._restored
self.toggle_restored_action.setEnabled(has)
self.toggle_restored_action.setText(
"Показать оригинал" if self._showing_restored else "Показать результат"
)
self.save_restored_action.setEnabled(has)
def _save_restored(self) -> None:
if self._current is None or str(self._current) not in self._restored:
return
out = self._unique_dest(self._current.parent, f"{self._current.stem}_restored.jpg")
if imwrite_unicode(str(out), self._restored[str(self._current)]):
self.statusBar().showMessage(f"Сохранено: {out}")
else:
QMessageBox.warning(self, "Ошибка", "Не удалось сохранить файл.")
# -------------------------------------------------------------- favorites
def _move_to_favorites(self) -> None:
"""Move the selected frames into the project's single default collection."""
if self._busy:
return
if self._project is None:
QMessageBox.information(self, "Нет проекта", "Сначала откройте или создайте проект.")
return
items = self.file_list.selectedItems()
if not items:
QMessageBox.information(self, "Нет выбора", "Выберите кадры в списке слева.")
return
dest = self._project.favorites_dir
try:
dest.mkdir(parents=True, exist_ok=True)
except OSError as exc:
QMessageBox.warning(self, "Ошибка", f"Не удалось создать избранное:\n{exc}")
return
moved = 0
for item in items:
src = Path(item.data(Qt.UserRole))
if not src.exists():
continue
dst = self._unique_dest(dest, src.name)
try:
shutil.move(str(src), str(dst))
except OSError as exc:
QMessageBox.warning(self, "Ошибка", f"Не удалось переместить {src.name}:\n{exc}")
continue
moved += 1
self._results.pop(str(src), None)
self._files = [p for p in self._files if p != src]
self.file_list.takeItem(self.file_list.row(item))
if self._current == src:
self._current = None
if moved:
self._save_results() # cache file should forget the moved frames
self.statusBar().showMessage(f"В избранное перемещено {moved}")
cur = self.file_list.currentItem()
if cur is not None:
self._show(Path(cur.data(Qt.UserRole)))
elif self.file_list.count() == 0:
self.view.set_image(None, [])
self._refresh_marks() # rows shifted — remap marks to new indices
self._update_nav()
@staticmethod
def _unique_dest(folder: Path, name: str) -> Path:
"""Avoid clobbering: foo.jpg -> foo (1).jpg if it already exists."""
dst = folder / name
if not dst.exists():
return dst
stem, suffix = dst.stem, dst.suffix
i = 1
while (folder / f"{stem} ({i}){suffix}").exists():
i += 1
return folder / f"{stem} ({i}){suffix}"
# ----------------------------------------------------------------- detail
# Row tints in the file list: red = censorship found, green = checked & clean.
_TINT_HIT = QColor(200, 80, 80, 70)
_TINT_CLEAN = QColor(90, 160, 90, 50)
def _set_row_tag(self, item: QListWidgetItem, count: int) -> None:
base = item.data(Qt.UserRole + 1)
item.setText(f"{base} · {count}" if count else f"{base} · —")
item.setBackground(self._TINT_HIT if count else self._TINT_CLEAN)
def _tag_file(self, path: Path, count: int) -> None:
for i in range(self.file_list.count()):
item = self.file_list.item(i)
if item.data(Qt.UserRole) == str(path):
self._set_row_tag(item, count)
return
# ------------------------------------------------------------- result cache
def _results_key(self) -> dict:
"""Detector identity used to tag/validate the on-disk detection cache."""
d = self._cfg.detection
return detection_cache.make_key(
self._cfg.detector, self._cfg.model_path, d.yolo_conf, d.yolo_imgsz
)
def _save_results(self) -> None:
"""Persist the detection cache in the project (skip if nothing to save)."""
if self._project is None or not self._results:
return
detection_cache.save_results(
self._project.cache_path, self._results_key(), self._results
)
def _load_cached_results(self) -> int:
"""Load a matching on-disk cache into `_results` and tag rows. Returns count."""
if self._project is None:
return 0
cached = detection_cache.load_results(
self._project.cache_path, self._results_key(), self._project.frames_dir
)
if not cached:
return 0
self._results = cached
for i in range(self.file_list.count()):
item = self.file_list.item(i)
path = item.data(Qt.UserRole)
if path in self._results: # `in`, not truthy: empty list = checked-clean
self._set_row_tag(item, len(self._results[path]))
return len(cached)
def _clear_results(self) -> None:
"""Drop all cached detections and reset row labels/tints (keeps the detector)."""
self._results.clear()
for i in range(self.file_list.count()):
item = self.file_list.item(i)
item.setText(item.data(Qt.UserRole + 1))
item.setBackground(QBrush())
self._refresh_marks()
def _refresh_marks(self) -> None:
"""Project frames-with-detections onto the scrubber as marks."""
marks = {
i for i in range(self.file_list.count())
if self._results.get(self.file_list.item(i).data(Qt.UserRole))
}
self.frame_slider.set_marks(marks)
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)
if dets is None:
self.detail_header.setText(
f"<b>{path.name}</b> · {w}×{h} · <i>не рассчитано</i> "
"(двойной клик по файлу или «Рассчитать кадр»)"
)
self.detail_table.setRowCount(0)
self.view.set_highlight(None)
return
by_type: dict[str, int] = {}
for d in dets:
by_type[d.type.value] = by_type.get(d.type.value, 0) + 1
summary = ", ".join(f"{k}: {v}" for k, v in sorted(by_type.items())) or "ничего не найдено"
self.detail_header.setText(f"<b>{path.name}</b> · {w}×{h} · всего {len(dets)} ({summary})")
self.detail_table.blockSignals(True)
self.detail_table.setRowCount(len(dets))
for row, d in enumerate(dets):
x, y, bw, bh = d.bbox
cells = [d.type.value, 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)
self.detail_table.clearSelection()
self.detail_table.resizeColumnsToContents()
self.view.set_highlight(None)
def _on_detail_selected(self) -> None:
rows = self.detail_table.selectionModel().selectedRows()
self.view.set_highlight(rows[0].row() if rows else None)
def _on_threshold_changed(self, value: float) -> None:
self._cfg.default_threshold = value
self.view.set_threshold(value)
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)
self._project.save()
super().closeEvent(event)