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
+7 -12
View File
@@ -1,9 +1,9 @@
"""Widget that renders an image and draws detection overlays.
Overlay visibility and the confidence threshold are applied at paint time, so
toggling them is instant. One detection can be *highlighted* (selected in the
detail table) — it is drawn boldly even if below the threshold, while the others
dim, so the user can inspect exactly what the detector found.
The confidence threshold is applied at paint time, so changing it is instant. One
detection can be *highlighted* (selected in the detail table) — it is drawn boldly
even if below the threshold, while the others dim, so the user can inspect exactly
what the detector found.
"""
from __future__ import annotations
@@ -24,7 +24,6 @@ class ImageView(QWidget):
self._cfg = overlay_cfg
self._qimage: QImage | None = None
self._dets: list[Detection] = []
self._overlay_enabled = True
self._threshold = 0.0
self._highlight: int | None = None
self.setMinimumSize(480, 360)
@@ -42,10 +41,6 @@ class ImageView(QWidget):
self._highlight = None
self.update()
def set_overlay_enabled(self, enabled: bool) -> None:
self._overlay_enabled = enabled
self.update()
def set_threshold(self, threshold: float) -> None:
self._threshold = threshold
self.update()
@@ -59,13 +54,13 @@ class ImageView(QWidget):
r, g, b = self._cfg.colors.get(ctype.value, (255, 0, 0))
return QColor(r, g, b)
def paintEvent(self, event) -> None: # noqa: N802 - Qt signature
def paintEvent(self, event) -> None:
painter = QPainter(self)
painter.fillRect(self.rect(), QColor(18, 18, 18))
if self._qimage is None:
painter.setPen(QColor(160, 160, 160))
painter.drawText(self.rect(), Qt.AlignCenter, "Откройте папку с картинками (Файл → Открыть папку…)")
painter.drawText(self.rect(), Qt.AlignCenter, "Откройте проект (Файл → Открыть проект…)")
painter.end()
return
@@ -77,7 +72,7 @@ class ImageView(QWidget):
painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
painter.drawImage(QRectF(ox, oy, dw, dh), self._qimage)
if self._overlay_enabled and self._dets:
if self._dets:
painter.setRenderHint(QPainter.Antialiasing, True)
for i, d in enumerate(self._dets):
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
``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.
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.
Viewing and detecting are decoupled, so browsing a big project stays instant even
with a slow (CPU) detector:
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 detector/model clears the cache.
project; switching the model clears the cache.
"""
from __future__ import annotations
import contextlib
import shutil
from pathlib import Path
@@ -77,13 +78,12 @@ class MainWindow(QMainWindow):
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._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
@@ -547,7 +547,7 @@ class MainWindow(QMainWindow):
if Project.is_project(p):
try:
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}")
elif p.is_dir():
QMessageBox.information(
@@ -562,10 +562,8 @@ class MainWindow(QMainWindow):
"""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:
with contextlib.suppress(OSError, ValueError):
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."""
@@ -662,10 +660,8 @@ class MainWindow(QMainWindow):
# Carry over an old sidecar detection cache (basename-keyed) if present.
old_sidecar = src / ".hvideotool_detections.json"
if old_sidecar.is_file():
try:
with contextlib.suppress(OSError):
shutil.copy2(str(old_sidecar), str(project.cache_path))
except OSError:
pass
self.statusBar().showMessage(f"Импортировано {copied} картинок → {project.name}")
self._open_project(project)
@@ -764,7 +760,7 @@ class MainWindow(QMainWindow):
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
except Exception as exc:
QMessageBox.warning(self, "Ошибка", f"Не удалось извлечь кадры:\n{exc}")
return
finally:
@@ -788,7 +784,6 @@ class MainWindow(QMainWindow):
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
@@ -850,7 +845,7 @@ class MainWindow(QMainWindow):
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 = detector.detect(Frame(image=img))
dets.sort(key=lambda d: d.score, reverse=True)
return dets
@@ -1057,8 +1052,7 @@ class MainWindow(QMainWindow):
self._start_job(fn, total, on_done=done)
def _make_restorer(self):
key = (self._cfg.restorer, self._cfg.dm_dir, self._cfg.dm_model,
self._cfg.dm_python, self._cfg.dm_gpu)
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
@@ -1266,7 +1260,7 @@ class MainWindow(QMainWindow):
self.view.set_threshold(value)
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
self._job.cancel()
self._pool.waitForDone(3000)
-5
View File
@@ -27,11 +27,6 @@ class MarkerSlider(QSlider):
self._marks = marks
self.update()
def clear_marks(self) -> None:
if self._marks:
self._marks = set()
self.update()
def paintEvent(self, event) -> None:
super().paintEvent(event)
if not self._marks or self.maximum() <= self.minimum():
+3 -3
View File
@@ -33,7 +33,7 @@ class _Signals(QObject):
class Job(QRunnable):
"""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__()
self.setAutoDelete(False) # the GUI keeps a reference until `done`/`failed`
self.signals = _Signals()
@@ -56,12 +56,12 @@ class Job(QRunnable):
self.signals.tick.emit(payload)
# -- thread entry point --
def run(self) -> None: # noqa: D401 - QRunnable override
def run(self) -> None:
try:
result = self._fn(self)
except Cancelled:
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))
else:
self.signals.done.emit(result)