"""Main window: open a folder of images and inspect what the detector found. Layout: a toolbar (open folder · 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 folder 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 folder. Both folder loading and detect-all show a progress bar. Results are cached; switching detector/model clears the cache. """ from __future__ import annotations import shutil from pathlib import Path from PySide6.QtCore import Qt 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.factory import build_detector from ..core.detection.types import Detection from ..core.imageio import imread_unicode, imwrite_unicode 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 _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._folder: Path | None = None self._files: list[Path] = [] self._results: dict[str, list[Detection]] = {} # path -> detections (cache) self._current: Path | None = None self._collection: Path | None = None # active destination folder for moves 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.setWindowTitle("HVideoTool — инспектор детекции цензуры") self.resize(1180, 720) self._build_toolbar() self._build_central() self._build_statusbar() self._build_menu() self._refresh_collections() self.statusBar().showMessage("Откройте папку с картинками") # ------------------------------------------------------------------ setup def _build_menu(self) -> None: file_menu = self.menuBar().addMenu("Файл") file_menu.addAction("Открыть папку…", self._choose_folder) file_menu.addAction("Создать из ролика…", self._create_from_video) file_menu.addSeparator() file_menu.addAction("Рассчитать кадр", self._recompute_current).setShortcut("Space") file_menu.addAction("Детектировать все", self._detect_all) file_menu.addSeparator() file_menu.addAction("Движок восстановления…", self._open_restore_settings) file_menu.addSeparator() file_menu.addAction("Создать коллекцию…", self._create_collection) file_menu.addAction("В коллекцию", self._move_to_collection).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._choose_folder)) 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) tb.addAction(QAction("Детектировать все", self, triggered=self._detect_all)) 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) # Collection controls live next to the file list — they act on its selection. self.collection_combo = QComboBox() self.collection_combo.setToolTip("Активная коллекция, куда перемещаются кадры") self.collection_combo.activated.connect(self._on_collection_selected) new_coll = QPushButton("Создать") new_coll.clicked.connect(self._create_collection) move_btn = QPushButton("В коллекцию →") move_btn.setToolTip("Переместить выбранные кадры в активную коллекцию (Ctrl+M)") move_btn.clicked.connect(self._move_to_collection) coll_row = QHBoxLayout() coll_row.setContentsMargins(0, 0, 0, 0) coll_row.addWidget(QLabel("Коллекция:")) coll_row.addWidget(self.collection_combo, 1) coll_row.addWidget(new_coll) left = QWidget() left_layout = QVBoxLayout(left) left_layout.setContentsMargins(4, 4, 4, 4) left_layout.setSpacing(4) left_layout.addLayout(coll_row) 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)) 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.progress = QProgressBar() self.progress.setMaximumWidth(260) self.progress.setVisible(False) self.statusBar().addPermanentWidget(self.progress) # --------------------------------------------------------------- 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 — offer to pick one if missing. if name in ("yolo", "combined") and not self._cfg.model_path: self._choose_model() settings_store.save(self._cfg) self._invalidate_results() 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 settings_store.save(self._cfg) self.statusBar().showMessage(f"Модель: {path}") self._invalidate_results() def _invalidate_results(self) -> None: """Detector changed — drop the cache and refresh the current image.""" self._detector_key = None 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() if self._current is not None: self._show(self._current) # --------------------------------------------------------------- handlers def open_path(self, folder: str) -> None: self._load_folder(Path(folder)) def _choose_folder(self) -> None: start = settings_store.last_dir() or "" folder = QFileDialog.getExistingDirectory(self, "Открыть папку с картинками", start) if folder: self._load_folder(Path(folder)) def _create_from_video(self) -> None: """Decode a video into a folder of frames (a collection) and open it.""" 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) out = video.parent / f"{video.stem}_frames" self.progress.setRange(0, 1000) # promille of duration self.progress.setValue(0) self.progress.setVisible(True) 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 True 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 self.progress.setVisible(False) QMessageBox.warning(self, "Ошибка", f"Не удалось извлечь кадры:\n{exc}") return finally: self.progress.setVisible(False) if saved == 0: QMessageBox.warning(self, "Пусто", "Из ролика не удалось извлечь ни одного кадра.") return self.statusBar().showMessage(f"Извлечено {saved} кадров → {out}") self._load_folder(out) def _load_folder(self, folder: Path) -> None: 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 settings_store.set_last_dir(str(folder)) self.file_list.blockSignals(True) self.file_list.setUpdatesEnabled(False) self.file_list.clear() self.progress.setRange(0, len(files)) self.progress.setVisible(True) 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() self.file_list.setUpdatesEnabled(True) self.file_list.blockSignals(False) self.progress.setVisible(False) self._refresh_marks() self._refresh_collections() if not files: self.view.set_image(None, []) self._update_nav() self.statusBar().showMessage(f"В папке нет картинок: {folder}") return self.statusBar().showMessage(f"{len(files)} картинок · {folder} · двойной клик / «Рассчитать кадр» для детекции") 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. path = Path(item.data(Qt.UserRole)) if str(path) not in self._results and self._detect(path) is None: return self._refresh_marks() self._show(path) def _detect(self, path: Path) -> list[Detection] | None: """Run (or fetch cached) detections for one image. None on failure.""" key = str(path) if key in self._results: return self._results[key] img = imread_unicode(key) if img is None: self.statusBar().showMessage(f"Не удалось прочитать: {path.name}") return None try: detector = self._make_detector() except Exception as exc: # noqa: BLE001 - surface config/model errors to the user QMessageBox.warning(self, "Детектор недоступен", str(exc)) return None self.statusBar().showMessage(f"Детекция: {path.name}…") QApplication.processEvents() dets = detector.detect(Frame(image=img, index=0, pts=0.0)) dets.sort(key=lambda d: d.score, reverse=True) self._results[key] = dets self._tag_file(path, len(dets)) return dets 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.""" if self._current is None: return self._results.pop(str(self._current), None) self._detector_key = None # rebuild the detector so settings changes take effect if self._detect(self._current) is None: return self._refresh_marks() self._show(self._current) def _detect_all(self) -> None: if not self._files: return total = len(self._files) self.progress.setRange(0, total) self.progress.setVisible(True) try: for i, p in enumerate(self._files, 1): self.progress.setValue(i) self.statusBar().showMessage(f"Детекция {i}/{total}: {p.name}") QApplication.processEvents() if self._detect(p) is None: return # detector unavailable — message already shown finally: self.progress.setVisible(False) hits = sum(1 for p in self._files if self._results.get(str(p))) self._refresh_marks() self.statusBar().showMessage(f"Готово: детекции на {hits} из {total} картинок") if self._current is not None: self._show(self._current) # ------------------------------------------------------------- restoration def _restore_current(self) -> None: """Run the restorer on the current frame's detected regions and show it.""" if self._current is None: return key = str(self._current) if key not in self._results and self._detect(self._current) is None: return self._refresh_marks() dets = self._results.get(key) or [] if not dets: self.statusBar().showMessage("Нет найденных областей — нечего расцензуривать") return img = imread_unicode(key) if img is None: return self.statusBar().showMessage(f"Восстановление: {self._current.name}…") QApplication.processEvents() try: restorer = self._make_restorer() restored = restorer.restore(img, dets) except Exception as exc: # noqa: BLE001 - surface model/engine errors QMessageBox.warning(self, "Ошибка восстановления", str(exc)) return self._restored[key] = restored self._showing_restored = True self.view.set_image(restored, []) self._update_restore_actions() self.statusBar().showMessage( f"Расцензурено ({restorer.name}): {self._current.name} — {len(dets)} обл." ) 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() settings_store.save(self._cfg) 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 dest_dir = self._collection or self._current.parent out = self._unique_dest(dest_dir, 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, "Ошибка", "Не удалось сохранить файл.") # ------------------------------------------------------------ collections def _collections_base(self) -> Path: """Where new collections are created: next to the opened folder, else home.""" if self._folder is not None: return self._folder.parent return Path.home() / "HVideoTool" / "collections" def _refresh_collections(self) -> None: """Repopulate the collection combo from sibling folders of the opened folder. Keeps the active collection selected (and present even if it lives elsewhere). """ base = self._collections_base() subdirs = [] if base.exists(): subdirs = sorted( (p for p in base.iterdir() if p.is_dir() and p != self._folder), key=lambda p: p.name.lower(), ) self.collection_combo.blockSignals(True) self.collection_combo.clear() self.collection_combo.addItem("— не выбрана —", None) for p in subdirs: self.collection_combo.addItem(p.name, str(p)) # Make sure the active collection is listed even if it's outside base. if self._collection is not None and self.collection_combo.findData(str(self._collection)) < 0: self.collection_combo.addItem(self._collection.name, str(self._collection)) self.collection_combo.addItem("Выбрать папку…", "__browse__") self._select_active_in_combo() self.collection_combo.blockSignals(False) def _select_active_in_combo(self) -> None: idx = self.collection_combo.findData(str(self._collection)) if self._collection else 0 self.collection_combo.setCurrentIndex(max(0, idx)) def _on_collection_selected(self, _index: int) -> None: data = self.collection_combo.currentData() if data == "__browse__": self._browse_collection() return self._collection = Path(data) if data else None if self._collection is not None: self.statusBar().showMessage(f"Активная коллекция: {self._collection}") def _browse_collection(self) -> None: start = str(self._collections_base()) folder = QFileDialog.getExistingDirectory(self, "Выбрать коллекцию", start) if folder: self._collection = Path(folder) self._refresh_collections() self.statusBar().showMessage(f"Активная коллекция: {folder}") else: self._select_active_in_combo() # revert the combo to the current collection def _create_collection(self) -> None: name, ok = QInputDialog.getText(self, "Создать коллекцию", "Имя коллекции:") name = name.strip() if not ok or not name: return path = self._collections_base() / name try: path.mkdir(parents=True, exist_ok=True) except OSError as exc: QMessageBox.warning(self, "Ошибка", f"Не удалось создать коллекцию:\n{exc}") return self._collection = path self._refresh_collections() self.statusBar().showMessage(f"Активная коллекция: {path}") def _move_to_collection(self) -> None: if self._collection is None: QMessageBox.information( self, "Нет коллекции", "Сначала выберите коллекцию в списке или создайте новую («Создать…»).", ) return items = self.file_list.selectedItems() if not items: QMessageBox.information(self, "Нет выбора", "Выберите кадры в списке слева.") return moved = 0 for item in items: src = Path(item.data(Qt.UserRole)) if not src.exists(): continue dst = self._unique_dest(self._collection, 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 self.statusBar().showMessage(f"Перемещено {moved} → {self._collection.name}") 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 _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): 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) return 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"{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.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"{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.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) settings_store.save(self._cfg)