"""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 · model · calc-frame · detect-all · restore · 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. Detection is YOLO-only; restoration is DeepMosaics-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 the model clears the cache. """ from __future__ import annotations import contextlib import os import shutil from pathlib import Path from PySide6.QtCore import Qt, QThreadPool from PySide6.QtGui import QAction, QBrush, QColor, QFont, QKeySequence, QShortcut from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QDialog, QDoubleSpinBox, QFileDialog, QHBoxLayout, QInputDialog, QLabel, QListWidget, QListWidgetItem, QMainWindow, QMenu, QMessageBox, QPlainTextEdit, QProgressBar, QPushButton, QSplitter, QTableWidget, QTableWidgetItem, QToolButton, QVBoxLayout, QWidget, ) from .. import settings_store from ..config import AppConfig from ..core.detection import cache as detection_cache from ..core.detection import registry as model_registry 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);;Все файлы (*.*)" 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._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._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 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.addAction("Расцензурить все (дозапуск)", lambda: self._restore_all(False)) file_menu.addAction("Расцензурить все заново", lambda: self._restore_all(True)) 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._models_menu = QMenu(self) self.models_button = QToolButton() self.models_button.setPopupMode(QToolButton.InstantPopup) self.models_button.setMenu(self._models_menu) self.models_button.setToolTip("Выбрать активные YOLO-модели (models/yolo/<категория>)") tb.addWidget(self.models_button) self._rebuild_models_menu() 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) restore_all = QAction("Расцензурить все", self, triggered=lambda: self._restore_all(False)) restore_all.setToolTip("Расцензурить все кадры в папку restored/ (дозапуск; видеодвижок — весь диапазон)") tb.addAction(restore_all) restore_regen = QAction("Все заново (расцензур)", self, triggered=lambda: self._restore_all(True)) restore_regen.setToolTip("Перерасцензурить все кадры заново (перезапись restored/)") tb.addAction(restore_regen) 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()) # 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) 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, 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) 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")) a = torch_info.analyze(info) self._install_command = a["command"] # what the Copy button will copy lines = [ "ВЕРДИКТ:", a["summary"], "", "Диагностика:", *(f" • {d}" for d in a["details"]), "", "Что делать:", a["steps"], ] # A real dialog (not QMessageBox) so the text — incl. the install command — is # selectable, and a Copy button drops the pip command straight onto the clipboard. dlg = QDialog(self) dlg.setWindowTitle("Почему " + ("GPU" if cuda else "CPU") + " — диагностика PyTorch/CUDA") dlg.resize(620, 480) layout = QVBoxLayout(dlg) text = QPlainTextEdit() text.setReadOnly(True) text.setPlainText("\n".join(lines)) mono = QFont("Consolas") mono.setStyleHint(QFont.Monospace) text.setFont(mono) layout.addWidget(text, 1) buttons = QHBoxLayout() if not cuda: copy_btn = QPushButton("Скопировать команду установки") copy_btn.clicked.connect(self._copy_install_command) buttons.addWidget(copy_btn) recheck = QPushButton("Проверить заново") recheck.setToolTip("Перепроверить torch/CUDA (например, после переустановки)") recheck.clicked.connect(lambda: (self._probe_device(), dlg.accept())) buttons.addWidget(recheck) buttons.addStretch(1) close_btn = QPushButton("Закрыть") close_btn.clicked.connect(dlg.accept) buttons.addWidget(close_btn) layout.addLayout(buttons) dlg.exec() def _copy_install_command(self) -> None: command = getattr(self, "_install_command", None) if not command: from ..core import torch_info command = torch_info.install_command() QApplication.clipboard().setText(command) self.statusBar().showMessage("Команда установки скопирована в буфер обмена") # ------------------------------------------------------------- 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.models_button.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.models_button.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 = (tuple(sorted(self._cfg.detector_models)), 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 # --------------------------------------------------------- model selection def _ensure_models(self) -> None: """Drop selected models that vanished; default to all discovered if none picked. Called on project open. The factory raises a clear message if a detect runs with nothing selected, so we don't prompt here. """ entries = model_registry.discover_models() available = {e.path for e in entries} kept = [m for m in self._cfg.detector_models if m in available] if not kept and entries: # first run / fresh project — enable everything found kept = [e.path for e in entries] if kept != self._cfg.detector_models: self._cfg.detector_models = kept self._persist_settings() def _rebuild_models_menu(self) -> None: """Repopulate the toolbar "Модели" menu with a checkable item per discovered model.""" self._models_menu.clear() selected = set(self._cfg.detector_models) entries = model_registry.discover_models() if not entries: self._models_menu.addAction("(нет моделей в models/yolo/<категория>)").setEnabled(False) else: last_cat = None for e in entries: if e.category != last_cat: self._models_menu.addSection(e.category) last_cat = e.category act = self._models_menu.addAction(e.name) act.setCheckable(True) act.setChecked(e.path in selected) act.toggled.connect(lambda on, p=e.path: self._on_model_toggled(p, on)) self._models_menu.addSeparator() self._models_menu.addAction("Добавить модель…", self._add_model) self._models_menu.addAction("Открыть папку моделей", self._open_models_dir) self._models_menu.addAction("Обновить список", self._rebuild_models_menu) self._update_models_button() def _update_models_button(self) -> None: n = len([m for m in self._cfg.detector_models if Path(m).is_file()]) self.models_button.setText(f"Модели ({n}) ▾") def _on_model_toggled(self, path: str, on: bool) -> None: sel = [m for m in self._cfg.detector_models if m != path] if on: sel.append(path) self._cfg.detector_models = sel self._persist_settings() self._invalidate_results() # different model set => recompute self._update_models_button() def _add_model(self) -> None: """Copy a chosen .pt into models/yolo// and tick it.""" path, _ = QFileDialog.getOpenFileName( self, "Выберите веса YOLO (.pt)", str(Path.cwd()), "Веса YOLO (*.pt)" ) if not path: return category, ok = QInputDialog.getText( self, "Категория модели", "Категория (папка под models/yolo, напр. mosaic, face):", text="misc" ) if not ok: return category = (category.strip() or "misc") dest_dir = model_registry.yolo_root() / category dest_dir.mkdir(parents=True, exist_ok=True) dest = self._unique_dest(dest_dir, Path(path).name) try: shutil.copy2(path, dest) except OSError as exc: QMessageBox.warning(self, "Ошибка", f"Не удалось скопировать модель:\n{exc}") return self._cfg.detector_models = [*self._cfg.detector_models, str(dest)] self._persist_settings() self._rebuild_models_menu() self._invalidate_results() self.statusBar().showMessage(f"Модель добавлена: {dest.name} → {category}") def _open_models_dir(self) -> None: root = model_registry.yolo_root() root.mkdir(parents=True, exist_ok=True) with contextlib.suppress(OSError, AttributeError): os.startfile(str(root)) # noqa: S606 - Windows: open in Explorer 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: 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): with contextlib.suppress(OSError, ValueError): self._open_project(Project.load(last)) 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(): with contextlib.suppress(OSError): shutil.copy2(str(old_sidecar), str(project.cache_path)) 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._ensure_models() # prune missing / default-select discovered models 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._rebuild_models_menu() # reflect this project's model selection 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: 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._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() self._refresh_restored_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)) 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)) # If the frame being viewed was just computed, show its overlay live. if not self._showing_restored and self._current is not None and str(self._current) == key: self._show(self._current) 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 on a background thread, then show it. DeepMosaics locates the mosaic itself, so no detection step is needed — we just run the engine on the frame (if there's no mosaic the frame comes back unchanged). The 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): 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) return ("restored", key, restored, restorer.name) def done(result, cancelled): if cancelled: self.statusBar().showMessage("Восстановление отменено") return if result is None: return _, k, restored, 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._refresh_restored_marks() self.statusBar().showMessage(f"Расцензурено ({engine}): {Path(k).name}") self.statusBar().showMessage(f"Восстановление: {path.name}…") self._start_job(fn, None, on_done=done) def _restore_all(self, force: bool = False) -> None: """Restore every frame on a background thread, writing results to ``restored/``. DeepMosaics locates the mosaic itself, so no detection runs here. The per-frame engine skips frames already restored (resume) unless ``force``. The temporal engine (DeepMosaics-video) runs the whole contiguous sequence in order via ``restore_sequence`` (its recurrence needs neighbours), so ``force`` is implied. """ if not self._files or self._project is None or self._busy: return files = list(self._files) # snapshot — favorites/move mutate self._files total = len(files) out_dir = self._project.restored_dir out_dir.mkdir(parents=True, exist_ok=True) def out_path(p: Path) -> Path: return out_dir / f"{p.stem}.jpg" def fn(job): restorer = self._make_restorer() # built on the worker (may raise) frame_cache: dict[int, object] = {} # small cache so the temporal window reuses reads def get_frame(i): img = frame_cache.get(i) if img is None: img = imread_unicode(str(files[i])) if img is None: raise RuntimeError(f"Не удалось прочитать: {files[i].name}") if len(frame_cache) > 24: frame_cache.clear() frame_cache[i] = img return img def emit(i, restored): imwrite_unicode(str(out_path(files[i])), restored) job.progress(i + 1, total, f"Расцензуривание {i + 1}/{total}: {files[i].name}") if restorer.temporal: restorer.restore_sequence( total, get_frame, lambda _i: [], emit, should_cancel=lambda: job.cancelled ) else: for i, p in enumerate(files): if job.cancelled: break if not force and out_path(p).is_file(): job.progress(i + 1, total, f"Пропуск {i + 1}/{total}: {p.name}") continue emit(i, restorer.restore(get_frame(i), [], should_cancel=lambda: job.cancelled)) frame_cache.pop(i, None) # per-frame: don't accumulate return None def done(_result, cancelled): if self._current is not None: # live-preview the current frame's result, if any rp = out_path(self._current) if rp.is_file(): img = imread_unicode(str(rp)) if img is not None: self._restored[str(self._current)] = img 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} кадров)" ) self.statusBar().showMessage("Пакетное расцензуривание…") 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) 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._refresh_restored_marks() 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_models, 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) 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) if dets is None: self.detail_header.setText( f"{path.name} · {w}×{h} · не рассчитано " "(двойной клик по файлу или «Рассчитать кадр»)" ) self.detail_table.setRowCount(0) self.view.set_highlight(None) return by_type: dict[str, int] = {} for d in dets: by_type[d.display] = by_type.get(d.display, 0) + 1 summary = ", ".join(f"{k}: {v}" for k, v in sorted(by_type.items())) or "ничего не найдено" self.detail_header.setText(f"{path.name} · {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.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) 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: 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)