Refactor HVideoTool's configuration and project management: updated project handling in core/project.py, streamlined restoration settings in config.py, and improved documentation in CLAUDE.md. Removed unused parameters and enhanced type hints for better clarity.

This commit is contained in:
Leonid Pershin
2026-06-07 06:33:00 +03:00
parent 9c471ca701
commit ac02ca27a8
16 changed files with 81 additions and 105 deletions
+3 -3
View File
@@ -160,9 +160,9 @@ hvideotool/
команду установки" (`_copy_install_command` → the recommended cu121/cu118 command, picked by команду установки" (`_copy_install_command` → the recommended cu121/cu118 command, picked by
`recommend_channel` from the driver's CUDA) and "Проверить заново" (re-runs `_probe_device`). `recommend_channel` from the driver's CUDA) and "Проверить заново" (re-runs `_probe_device`).
`core/torch_info.py` is pure (no Qt); subprocess uses `CREATE_NO_WINDOW` on Windows. `core/torch_info.py` is pure (no Qt); subprocess uses `CREATE_NO_WINDOW` on Windows.
- **Projects (`core/project.py`).** `MainWindow._project` is the open `Project`; - **Projects (`core/project.py`).** `MainWindow._project` is the open `Project`; its
`_folder` is kept as a synonym for `project.frames_dir` so the rest of the code frames come from `project.frames_dir` (navigation/cache/tags work off `_files`). Entry
(navigation, cache, tags) didn't need rewiring. Entry points: "Создать проект…" points: "Создать проект…"
(`_create_project`), "Открыть проект…" (`_open_project_dialog`), "Импортировать папку (`_create_project`), "Открыть проект…" (`_open_project_dialog`), "Импортировать папку
как проект…" (`_import_folder_as_project` — copies images into a new project's как проект…" (`_import_folder_as_project` — copies images into a new project's
`frames/`, carries over an old `.hvideotool_detections.json` sidecar if present), and `frames/`, carries over an old `.hvideotool_detections.json` sidecar if present), and
+1 -2
View File
@@ -49,9 +49,8 @@ class AppConfig:
# --- restoration ("расцензурить") --- # --- restoration ("расцензурить") ---
restorer: str = "deepmosaics" # "deepmosaics" | "deepmosaics_video" restorer: str = "deepmosaics" # "deepmosaics" | "deepmosaics_video"
dm_dir: str | None = None # DeepMosaics repo dir (contains deepmosaic.py) dm_dir: str | None = None # optional extra dir to search for mosaic_position.pth
dm_model: str | None = None # DeepMosaics clean weights (clean_*.pth) dm_model: str | None = None # DeepMosaics clean weights (clean_*.pth)
dm_python: str | None = None # python exe for DeepMosaics (None = current)
dm_gpu: str = "0" # CUDA device id, "-1" for CPU dm_gpu: str = "0" # CUDA device id, "-1" for CPU
+3 -3
View File
@@ -3,10 +3,10 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import StrEnum
class CensorType(str, Enum): class CensorType(StrEnum):
"""Kind of already-applied censorship a detection represents.""" """Kind of already-applied censorship a detection represents."""
MOSAIC = "mosaic" MOSAIC = "mosaic"
@@ -33,7 +33,7 @@ class Detection:
} }
@classmethod @classmethod
def from_dict(cls, data: dict) -> "Detection": def from_dict(cls, data: dict) -> Detection:
return cls( return cls(
type=CensorType(data["type"]), type=CensorType(data["type"]),
score=float(data["score"]), score=float(data["score"]),
+1 -3
View File
@@ -16,8 +16,6 @@ from __future__ import annotations
import os import os
import numpy as np
from ...config import DetectionConfig from ...config import DetectionConfig
from ..video.frame import Frame from ..video.frame import Frame
from .base import Detector from .base import Detector
@@ -61,7 +59,7 @@ class YoloDetector(Detector):
import torch import torch
cuda_ok = torch.cuda.is_available() cuda_ok = torch.cuda.is_available()
except Exception: # noqa: BLE001 except Exception:
cuda_ok = False cuda_ok = False
device = self.cfg.yolo_device device = self.cfg.yolo_device
if device is None: if device is None:
+2 -3
View File
@@ -41,7 +41,6 @@ _SETTING_KEYS = (
"restorer", "restorer",
"dm_dir", "dm_dir",
"dm_model", "dm_model",
"dm_python",
"dm_gpu", "dm_gpu",
) )
@@ -93,7 +92,7 @@ class Project:
name: str | None = None, name: str | None = None,
settings: dict | None = None, settings: dict | None = None,
source: str | None = None, source: str | None = None,
) -> "Project": ) -> Project:
"""Create a new project folder (with ``frames/``) and write ``project.json``.""" """Create a new project folder (with ``frames/``) and write ``project.json``."""
root = Path(root) root = Path(root)
proj = cls( proj = cls(
@@ -108,7 +107,7 @@ class Project:
return proj return proj
@classmethod @classmethod
def load(cls, path: Path | str) -> "Project": def load(cls, path: Path | str) -> Project:
"""Load a project from its folder or directly from its ``project.json``.""" """Load a project from its folder or directly from its ``project.json``."""
path = Path(path) path = Path(path)
root = path.parent if path.name == PROJECT_FILE else path root = path.parent if path.name == PROJECT_FILE else path
+27 -25
View File
@@ -1,20 +1,22 @@
"""DeepMosaics restorer — real generative mosaic removal, in-process. """DeepMosaics restorers — real generative mosaic removal, in-process.
The DeepMosaics network code (GPL-3.0) is vendored under ``_deepmosaics/`` (see The DeepMosaics network code (GPL-3.0) is vendored under ``_deepmosaics/`` (see its
its NOTICE/LICENSE). We load the models **once** and run the per-frame clean path NOTICE/LICENSE). We load the models **once** and run in-process — far faster than
in-process — far faster than spawning a subprocess per frame (which reloaded the spawning a subprocess per frame (which reloaded the models every time). Only the model
models every time). Only the model *weights* are user-supplied. *weights* are user-supplied. Both engines locate the mosaic themselves (BiSeNet
``mosaic_position.pth``); detections are not passed to them.
Per-frame clean = DeepMosaics' ``cleanmosaic_img_server`` logic, reimplemented Two engines:
here (so we don't pull in their video/ffmpeg modules): - :class:`DeepMosaicsRestorer` (per-frame): reproduces ``cleanmosaic_img_server`` —
locate mosaic (BiSeNet ``mosaic_position.pth``) → run the clean generator on the locate mosaic → run the image generator on the crop → feather it back. Image weights
crop → feather it back. DeepMosaics finds the mosaic itself; our detections are ``clean_youknow_resnet_9blocks.pth``.
used for navigation, not passed to it. - :class:`DeepMosaicsVideoRestorer` (temporal/BVDNet): reproduces
``cleanmosaic_video_fusion`` — a window of neighbouring frames + recurrence, for
temporal coherence. Video weights ``clean_youknow_video.pth``; needs a contiguous
sequence (see :meth:`Restorer.restore_sequence`).
Setup (see README → Восстановление): download the **image** clean weights Setup (see README → Восстановление): drop the chosen ``clean_*.pth`` + ``mosaic_position.pth``
``clean_youknow_resnet_9blocks.pth`` + ``mosaic_position.pth`` into one folder and into one folder (``models/deepmosaics``) and pick it in the restore dialog.
point the app at the clean-model file. The video model ``clean_youknow_video.pth``
(BVDNet) needs neighbour frames and does NOT work per-frame.
""" """
from __future__ import annotations from __future__ import annotations
@@ -31,8 +33,8 @@ from .base import (
Cancelled, Cancelled,
DetGetter, DetGetter,
FrameGetter, FrameGetter,
ResultSink,
Restorer, Restorer,
ResultSink,
) )
_VENDOR = Path(__file__).parent / "_deepmosaics" _VENDOR = Path(__file__).parent / "_deepmosaics"
@@ -94,9 +96,8 @@ def _netg_kind(model_name: str) -> str:
class DeepMosaicsRestorer(Restorer): class DeepMosaicsRestorer(Restorer):
def __init__( def __init__(
self, self,
deepmosaics_dir: str | None, # kept for factory/config compatibility (weights hint) deepmosaics_dir: str | None, # optional extra dir to find mosaic_position.pth
model_path: str | None, model_path: str | None,
python_exe: str | None = None, # unused now (in-process)
gpu_id: str = "0", gpu_id: str = "0",
) -> None: ) -> None:
if not model_path or not Path(model_path).is_file(): if not model_path or not Path(model_path).is_file():
@@ -136,12 +137,13 @@ class DeepMosaicsRestorer(Restorer):
# Fall back to CPU when CUDA isn't available: DeepMosaics calls `.cuda()` # Fall back to CPU when CUDA isn't available: DeepMosaics calls `.cuda()`
# whenever gpu_id != "-1", which raises "Torch not compiled with CUDA enabled" # whenever gpu_id != "-1", which raises "Torch not compiled with CUDA enabled"
# on a CPU-only torch build. # on a CPU-only torch build.
import torch # noqa: E402 import torch
if self._gpu != "-1" and not torch.cuda.is_available(): if self._gpu != "-1" and not torch.cuda.is_available():
self._gpu = "-1" self._gpu = "-1"
from models import loadmodel, runmodel # type: ignore # noqa: E402 import util.image_processing as impro # type: ignore
import util.image_processing as impro # type: ignore # noqa: E402
from models import loadmodel, runmodel # type: ignore
self._runmodel = runmodel self._runmodel = runmodel
self._impro = impro self._impro = impro
@@ -206,7 +208,6 @@ class DeepMosaicsVideoRestorer(Restorer):
self, self,
deepmosaics_dir: str | None, deepmosaics_dir: str | None,
model_path: str | None, model_path: str | None,
python_exe: str | None = None, # unused (in-process); kept for factory parity
gpu_id: str = "0", gpu_id: str = "0",
) -> None: ) -> None:
chosen: Path | None = None chosen: Path | None = None
@@ -244,13 +245,14 @@ class DeepMosaicsVideoRestorer(Restorer):
if str(_VENDOR) not in sys.path: if str(_VENDOR) not in sys.path:
sys.path.insert(0, str(_VENDOR)) sys.path.insert(0, str(_VENDOR))
import torch # noqa: E402 import torch
if self._gpu != "-1" and not torch.cuda.is_available(): if self._gpu != "-1" and not torch.cuda.is_available():
self._gpu = "-1" # CPU fallback (see DeepMosaicsRestorer for why) self._gpu = "-1" # CPU fallback (see DeepMosaicsRestorer for why)
from models import loadmodel, runmodel # type: ignore # noqa: E402 import util.data as data # type: ignore
import util.data as data # type: ignore # noqa: E402 import util.image_processing as impro # type: ignore
import util.image_processing as impro # type: ignore # noqa: E402
from models import loadmodel, runmodel # type: ignore
self._torch = torch self._torch = torch
self._runmodel = runmodel self._runmodel = runmodel
+3 -3
View File
@@ -20,20 +20,20 @@ if TYPE_CHECKING: # avoid importing AppConfig at runtime here (not needed)
from ...config import AppConfig from ...config import AppConfig
def build_restorer(name: str = "deepmosaics", config: "AppConfig | None" = None) -> Restorer: def build_restorer(name: str = "deepmosaics", config: AppConfig | None = None) -> Restorer:
if config is None: if config is None:
raise ValueError("Для DeepMosaics нужны настройки (config).") raise ValueError("Для DeepMosaics нужны настройки (config).")
if name == "deepmosaics": if name == "deepmosaics":
from .deepmosaics import DeepMosaicsRestorer from .deepmosaics import DeepMosaicsRestorer
return DeepMosaicsRestorer( return DeepMosaicsRestorer(
config.dm_dir, config.dm_model, config.dm_python, config.dm_gpu config.dm_dir, config.dm_model, config.dm_gpu
) )
if name == "deepmosaics_video": if name == "deepmosaics_video":
from .deepmosaics import DeepMosaicsVideoRestorer from .deepmosaics import DeepMosaicsVideoRestorer
return DeepMosaicsVideoRestorer( return DeepMosaicsVideoRestorer(
config.dm_dir, config.dm_model, config.dm_python, config.dm_gpu config.dm_dir, config.dm_model, config.dm_gpu
) )
if name == "lada": if name == "lada":
raise ValueError( raise ValueError(
+6 -10
View File
@@ -44,7 +44,7 @@ def _run_nvidia_smi() -> dict:
out["gpus"].append(parts[0]) out["gpus"].append(parts[0])
if len(parts) > 1 and parts[1]: if len(parts) > 1 and parts[1]:
out["driver_version"] = parts[1] out["driver_version"] = parts[1]
except Exception: # noqa: BLE001 - any failure => "not found" except Exception:
return out return out
# Max CUDA version the driver supports (only in the plain header). # Max CUDA version the driver supports (only in the plain header).
try: try:
@@ -54,7 +54,7 @@ def _run_nvidia_smi() -> dict:
m = re.search(r"CUDA Version:\s*([\d.]+)", r2.stdout) m = re.search(r"CUDA Version:\s*([\d.]+)", r2.stdout)
if m: if m:
out["cuda_driver"] = m.group(1) out["cuda_driver"] = m.group(1)
except Exception: # noqa: BLE001 except Exception:
pass pass
return out return out
@@ -76,23 +76,23 @@ def gather() -> dict:
} }
try: try:
import torch import torch
except Exception as exc: # noqa: BLE001 - report any import failure except Exception as exc:
info["import_error"] = str(exc) info["import_error"] = str(exc)
else: else:
info["installed"] = True info["installed"] = True
info["version"] = getattr(torch, "__version__", None) info["version"] = getattr(torch, "__version__", None)
try: try:
info["built_cuda"] = torch.version.cuda info["built_cuda"] = torch.version.cuda
except Exception: # noqa: BLE001 except Exception:
info["built_cuda"] = None info["built_cuda"] = None
try: try:
info["cuda_available"] = bool(torch.cuda.is_available()) info["cuda_available"] = bool(torch.cuda.is_available())
except Exception: # noqa: BLE001 except Exception:
info["cuda_available"] = False info["cuda_available"] = False
if info["cuda_available"]: if info["cuda_available"]:
try: try:
info["device_name"] = torch.cuda.get_device_name(0) info["device_name"] = torch.cuda.get_device_name(0)
except Exception: # noqa: BLE001 except Exception:
info["device_name"] = None info["device_name"] = None
smi = _run_nvidia_smi() smi = _run_nvidia_smi()
@@ -103,10 +103,6 @@ def gather() -> dict:
return info return info
def device_label(info: dict) -> str:
return "CUDA" if info.get("cuda_available") else "CPU"
def _ver_tuple(v: str | None) -> tuple[int, ...]: def _ver_tuple(v: str | None) -> tuple[int, ...]:
try: try:
return tuple(int(x) for x in str(v).split(".")[:2]) return tuple(int(x) for x in str(v).split(".")[:2])
+2 -2
View File
@@ -18,8 +18,8 @@ from __future__ import annotations
import shutil import shutil
import subprocess import subprocess
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Callable
import cv2 import cv2
@@ -38,7 +38,7 @@ def _find_ffmpeg() -> str | None:
import imageio_ffmpeg import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe() return imageio_ffmpeg.get_ffmpeg_exe()
except Exception: # noqa: BLE001 - package missing or no bundled binary except Exception:
return None return None
+2 -3
View File
@@ -1,4 +1,4 @@
"""Decoded video frame passed from the reader to the detector.""" """Image passed to the detector — the ``Detector.detect`` input type."""
from __future__ import annotations from __future__ import annotations
@@ -10,5 +10,4 @@ import numpy as np
@dataclass @dataclass
class Frame: class Frame:
image: np.ndarray # BGR, HxWx3, uint8 (OpenCV convention) image: np.ndarray # BGR, HxWx3, uint8 (OpenCV convention)
index: int # 0-based frame counter since the last open/seek index: int = 0 # 0-based position in the sequence (informational)
pts: float # presentation timestamp, seconds
+1 -2
View File
@@ -39,7 +39,7 @@ def apply(config: AppConfig) -> None:
config.default_threshold = float(data["threshold"]) config.default_threshold = float(data["threshold"])
if data.get("restorer"): if data.get("restorer"):
config.restorer = data["restorer"] config.restorer = data["restorer"]
for key in ("dm_dir", "dm_model", "dm_python", "dm_gpu"): for key in ("dm_dir", "dm_model", "dm_gpu"):
if key in data: if key in data:
setattr(config, key, data[key]) setattr(config, key, data[key])
@@ -54,7 +54,6 @@ def save(config: AppConfig) -> None:
restorer=config.restorer, restorer=config.restorer,
dm_dir=config.dm_dir, dm_dir=config.dm_dir,
dm_model=config.dm_model, dm_model=config.dm_model,
dm_python=config.dm_python,
dm_gpu=config.dm_gpu, dm_gpu=config.dm_gpu,
) )
_write(data) _write(data)
+7 -12
View File
@@ -1,9 +1,9 @@
"""Widget that renders an image and draws detection overlays. """Widget that renders an image and draws detection overlays.
Overlay visibility and the confidence threshold are applied at paint time, so The confidence threshold is applied at paint time, so changing it is instant. One
toggling them is instant. One detection can be *highlighted* (selected in the detection can be *highlighted* (selected in the detail table) — it is drawn boldly
detail table) — it is drawn boldly even if below the threshold, while the others even if below the threshold, while the others dim, so the user can inspect exactly
dim, so the user can inspect exactly what the detector found. what the detector found.
""" """
from __future__ import annotations from __future__ import annotations
@@ -24,7 +24,6 @@ class ImageView(QWidget):
self._cfg = overlay_cfg self._cfg = overlay_cfg
self._qimage: QImage | None = None self._qimage: QImage | None = None
self._dets: list[Detection] = [] self._dets: list[Detection] = []
self._overlay_enabled = True
self._threshold = 0.0 self._threshold = 0.0
self._highlight: int | None = None self._highlight: int | None = None
self.setMinimumSize(480, 360) self.setMinimumSize(480, 360)
@@ -42,10 +41,6 @@ class ImageView(QWidget):
self._highlight = None self._highlight = None
self.update() self.update()
def set_overlay_enabled(self, enabled: bool) -> None:
self._overlay_enabled = enabled
self.update()
def set_threshold(self, threshold: float) -> None: def set_threshold(self, threshold: float) -> None:
self._threshold = threshold self._threshold = threshold
self.update() self.update()
@@ -59,13 +54,13 @@ class ImageView(QWidget):
r, g, b = self._cfg.colors.get(ctype.value, (255, 0, 0)) r, g, b = self._cfg.colors.get(ctype.value, (255, 0, 0))
return QColor(r, g, b) return QColor(r, g, b)
def paintEvent(self, event) -> None: # noqa: N802 - Qt signature def paintEvent(self, event) -> None:
painter = QPainter(self) painter = QPainter(self)
painter.fillRect(self.rect(), QColor(18, 18, 18)) painter.fillRect(self.rect(), QColor(18, 18, 18))
if self._qimage is None: if self._qimage is None:
painter.setPen(QColor(160, 160, 160)) painter.setPen(QColor(160, 160, 160))
painter.drawText(self.rect(), Qt.AlignCenter, "Откройте папку с картинками (Файл → Открыть папку…)") painter.drawText(self.rect(), Qt.AlignCenter, "Откройте проект (Файл → Открыть проект…)")
painter.end() painter.end()
return return
@@ -77,7 +72,7 @@ class ImageView(QWidget):
painter.setRenderHint(QPainter.SmoothPixmapTransform, True) painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
painter.drawImage(QRectF(ox, oy, dw, dh), self._qimage) painter.drawImage(QRectF(ox, oy, dw, dh), self._qimage)
if self._overlay_enabled and self._dets: if self._dets:
painter.setRenderHint(QPainter.Antialiasing, True) painter.setRenderHint(QPainter.Antialiasing, True)
for i, d in enumerate(self._dets): for i, d in enumerate(self._dets):
highlighted = i == self._highlight highlighted = i == self._highlight
+17 -23
View File
@@ -5,23 +5,24 @@ A project is a folder (``project.json`` + ``frames/`` + ``detections.json`` +
model, threshold, restore engine) live in ``project.json``; the global model, threshold, restore engine) live in ``project.json``; the global
``settings.json`` only seeds defaults for new projects. ``settings.json`` only seeds defaults for new projects.
Layout: a toolbar (new/open project · from-video · detector · model · calc-frame · Layout: a toolbar (new/open project · from-video · model · calc-frame · detect-all ·
detect-all · threshold), then a splitter with three panes — left: collection restore · threshold), then a splitter with three panes — left: collection controls +
controls + the file list; center: the image with overlays; right: a detail table the file list; center: the image with overlays; right: a detail table of every
of every detection. Collection controls sit by the file list (they act on its detection. Collection controls sit by the file list (they act on its selection),
selection), keeping the toolbar to detection/entry actions only. keeping the toolbar to detection/entry actions only.
Viewing and detecting are decoupled, so browsing a big project stays instant even Detection is YOLO-only; restoration is DeepMosaics-only. Viewing and detecting are
with a slow (CPU) detector: 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); - selecting a file just **shows** it (with its cached result, if any);
- **double-clicking** a file, or "Рассчитать кадр", runs the detector on it; - **double-clicking** a file, or "Рассчитать кадр", runs the detector on it;
- "Детектировать все" runs the whole project. - "Детектировать все" runs the whole project.
Both project loading and detect-all show a progress bar. Results are cached in the Both project loading and detect-all show a progress bar. Results are cached in the
project; switching detector/model clears the cache. project; switching the model clears the cache.
""" """
from __future__ import annotations from __future__ import annotations
import contextlib
import shutil import shutil
from pathlib import Path from pathlib import Path
@@ -77,13 +78,12 @@ class MainWindow(QMainWindow):
self._detector = None self._detector = None
self._detector_key = None self._detector_key = None
self._project: Project | None = None # the open project (None until one is opened) 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._files: list[Path] = []
self._results: dict[str, list[Detection]] = {} # path -> detections (cache) self._results: dict[str, list[Detection]] = {} # path -> detections (cache)
self._current: Path | None = None self._current: Path | None = None
self._restorer = None # un-censor engine, built lazily from config self._restorer = None # un-censor engine, built lazily from config
self._restorer_key = None self._restorer_key = None
self._restored: dict[str, "object"] = {} # path -> restored image (BGR ndarray) self._restored: dict[str, object] = {} # path -> restored image (BGR ndarray)
self._showing_restored = False self._showing_restored = False
self._nav_sync = False # guard against slider<->list signal loops self._nav_sync = False # guard against slider<->list signal loops
self._busy = False # a long operation is running self._busy = False # a long operation is running
@@ -547,7 +547,7 @@ class MainWindow(QMainWindow):
if Project.is_project(p): if Project.is_project(p):
try: try:
self._open_project(Project.load(p)) self._open_project(Project.load(p))
except (OSError, ValueError) as exc: # noqa: BLE001 - surface to the user except (OSError, ValueError) as exc:
QMessageBox.warning(self, "Ошибка", f"Не удалось открыть проект:\n{exc}") QMessageBox.warning(self, "Ошибка", f"Не удалось открыть проект:\n{exc}")
elif p.is_dir(): elif p.is_dir():
QMessageBox.information( QMessageBox.information(
@@ -562,10 +562,8 @@ class MainWindow(QMainWindow):
"""On startup, reopen the last project if it still exists (best-effort).""" """On startup, reopen the last project if it still exists (best-effort)."""
last = settings_store.last_project() last = settings_store.last_project()
if last and Project.is_project(last): if last and Project.is_project(last):
try: with contextlib.suppress(OSError, ValueError):
self._open_project(Project.load(last)) self._open_project(Project.load(last))
except (OSError, ValueError):
pass
def _new_project_root(self, default_name: str = "") -> Path | None: def _new_project_root(self, default_name: str = "") -> Path | None:
"""Prompt for a parent dir + name; return a fresh (empty) project root or None.""" """Prompt for a parent dir + name; return a fresh (empty) project root or None."""
@@ -662,10 +660,8 @@ class MainWindow(QMainWindow):
# Carry over an old sidecar detection cache (basename-keyed) if present. # Carry over an old sidecar detection cache (basename-keyed) if present.
old_sidecar = src / ".hvideotool_detections.json" old_sidecar = src / ".hvideotool_detections.json"
if old_sidecar.is_file(): if old_sidecar.is_file():
try: with contextlib.suppress(OSError):
shutil.copy2(str(old_sidecar), str(project.cache_path)) shutil.copy2(str(old_sidecar), str(project.cache_path))
except OSError:
pass
self.statusBar().showMessage(f"Импортировано {copied} картинок → {project.name}") self.statusBar().showMessage(f"Импортировано {copied} картинок → {project.name}")
self._open_project(project) self._open_project(project)
@@ -764,7 +760,7 @@ class MainWindow(QMainWindow):
str(video), str(out), step=step, keyframes_only=keyframes_only, str(video), str(out), step=step, keyframes_only=keyframes_only,
max_dim=max_dim, progress=cb, max_dim=max_dim, progress=cb,
) )
except Exception as exc: # noqa: BLE001 - surface decode errors to the user except Exception as exc:
QMessageBox.warning(self, "Ошибка", f"Не удалось извлечь кадры:\n{exc}") QMessageBox.warning(self, "Ошибка", f"Не удалось извлечь кадры:\n{exc}")
return return
finally: finally:
@@ -788,7 +784,6 @@ class MainWindow(QMainWindow):
self.statusBar().showMessage(f"Сканирую папку: {folder}") self.statusBar().showMessage(f"Сканирую папку: {folder}")
QApplication.processEvents() QApplication.processEvents()
files = sorted(p for p in folder.iterdir() if p.suffix.lower() in _IMAGE_EXTS) files = sorted(p for p in folder.iterdir() if p.suffix.lower() in _IMAGE_EXTS)
self._folder = folder
self._files = files self._files = files
self._results.clear() self._results.clear()
self._current = None self._current = None
@@ -850,7 +845,7 @@ class MainWindow(QMainWindow):
img = imread_unicode(str(path)) img = imread_unicode(str(path))
if img is None: if img is None:
raise RuntimeError(f"Не удалось прочитать: {Path(path).name}") raise RuntimeError(f"Не удалось прочитать: {Path(path).name}")
dets = detector.detect(Frame(image=img, index=0, pts=0.0)) dets = detector.detect(Frame(image=img))
dets.sort(key=lambda d: d.score, reverse=True) dets.sort(key=lambda d: d.score, reverse=True)
return dets return dets
@@ -1057,8 +1052,7 @@ class MainWindow(QMainWindow):
self._start_job(fn, total, on_done=done) self._start_job(fn, total, on_done=done)
def _make_restorer(self): def _make_restorer(self):
key = (self._cfg.restorer, self._cfg.dm_dir, self._cfg.dm_model, key = (self._cfg.restorer, self._cfg.dm_dir, self._cfg.dm_model, self._cfg.dm_gpu)
self._cfg.dm_python, self._cfg.dm_gpu)
if key != self._restorer_key: if key != self._restorer_key:
self._restorer = build_restorer(self._cfg.restorer, self._cfg) # may raise self._restorer = build_restorer(self._cfg.restorer, self._cfg) # may raise
self._restorer_key = key self._restorer_key = key
@@ -1266,7 +1260,7 @@ class MainWindow(QMainWindow):
self.view.set_threshold(value) self.view.set_threshold(value)
self._persist_settings() self._persist_settings()
def closeEvent(self, event) -> None: # noqa: N802 - Qt override def closeEvent(self, event) -> None:
if self._job is not None: # stop a running background job before tearing down if self._job is not None: # stop a running background job before tearing down
self._job.cancel() self._job.cancel()
self._pool.waitForDone(3000) self._pool.waitForDone(3000)
-5
View File
@@ -27,11 +27,6 @@ class MarkerSlider(QSlider):
self._marks = marks self._marks = marks
self.update() self.update()
def clear_marks(self) -> None:
if self._marks:
self._marks = set()
self.update()
def paintEvent(self, event) -> None: def paintEvent(self, event) -> None:
super().paintEvent(event) super().paintEvent(event)
if not self._marks or self.maximum() <= self.minimum(): if not self._marks or self.maximum() <= self.minimum():
+3 -3
View File
@@ -33,7 +33,7 @@ class _Signals(QObject):
class Job(QRunnable): class Job(QRunnable):
"""Runs ``fn(job)`` on a thread pool, marshaling progress/result to the GUI.""" """Runs ``fn(job)`` on a thread pool, marshaling progress/result to the GUI."""
def __init__(self, fn: Callable[["Job"], Any]) -> None: def __init__(self, fn: Callable[[Job], Any]) -> None:
super().__init__() super().__init__()
self.setAutoDelete(False) # the GUI keeps a reference until `done`/`failed` self.setAutoDelete(False) # the GUI keeps a reference until `done`/`failed`
self.signals = _Signals() self.signals = _Signals()
@@ -56,12 +56,12 @@ class Job(QRunnable):
self.signals.tick.emit(payload) self.signals.tick.emit(payload)
# -- thread entry point -- # -- thread entry point --
def run(self) -> None: # noqa: D401 - QRunnable override def run(self) -> None:
try: try:
result = self._fn(self) result = self._fn(self)
except Cancelled: except Cancelled:
self.signals.done.emit(None) self.signals.done.emit(None)
except Exception as exc: # noqa: BLE001 - surface engine/model errors to the GUI except Exception as exc:
self.signals.failed.emit(str(exc)) self.signals.failed.emit(str(exc))
else: else:
self.signals.done.emit(result) self.signals.done.emit(result)
+3 -3
View File
@@ -19,9 +19,9 @@ dependencies = [
] ]
[project.optional-dependencies] [project.optional-dependencies]
# Trained-model detector (future work). PyTorch must be installed separately # YOLO detector (Ultralytics). PyTorch must be installed separately with the correct
# with the correct CUDA build — see README. Installing this extra only pulls in # CUDA build — see README. This extra only pulls in Ultralytics; it does NOT install
# Ultralytics; it does NOT install torch. # torch. Both detection (YOLO) and restoration (DeepMosaics) need torch at runtime.
yolo = ["ultralytics>=8.0"] yolo = ["ultralytics>=8.0"]
[project.scripts] [project.scripts]