Добавлено описание и документация для HVideoTool, включая функционал, требования, установку и запуск приложения для обнаружения цензуры на изображениях.

This commit is contained in:
Leonid Pershin
2026-06-06 15:11:53 +03:00
parent 10bf87aa47
commit 33f20fe681
28 changed files with 2256 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""HVideoTool — detect and outline already-applied censorship in local videos."""
__version__ = "0.1.0"
+39
View File
@@ -0,0 +1,39 @@
"""Command-line entry point: ``python -m hvideotool``.
Runs with no arguments — open a folder of images in-app. CLI flags are optional
overrides; choices persist to ~/HVideoTool/settings.json.
"""
from __future__ import annotations
import argparse
import sys
from . import settings_store
from .app import run
from .config import AppConfig
def main() -> int:
parser = argparse.ArgumentParser(
prog="hvideotool",
description="Инспектор детекции уже наложенной цензуры на картинках.",
)
parser.add_argument("folder", nargs="?", help="папка с картинками для немедленного открытия")
parser.add_argument("--detector", choices=["classic", "yolo", "combined"], default=None)
parser.add_argument("--model", dest="model_path", default=None, help="путь к весам (YOLO)")
args = parser.parse_args()
config = AppConfig()
settings_store.apply(config) # persisted choices first
if args.detector:
config.detector = args.detector
if args.model_path:
config.model_path = args.model_path
return run(config, folder=args.folder)
if __name__ == "__main__":
sys.exit(main())
+20
View File
@@ -0,0 +1,20 @@
"""QApplication bootstrap."""
from __future__ import annotations
import sys
from PySide6.QtWidgets import QApplication
from .config import AppConfig
from .ui.main_window import MainWindow
def run(config: AppConfig, folder: str | None = None) -> int:
app = QApplication(sys.argv)
app.setApplicationName("HVideoTool")
window = MainWindow(config)
window.show()
if folder:
window.open_path(folder)
return app.exec()
+67
View File
@@ -0,0 +1,67 @@
"""Application configuration and tunable defaults.
Plain dataclasses. The detection thresholds matter most here — this tool is now
an image-folder inspector for tuning the detectors, so keep them easy to tweak.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class DetectionConfig:
"""Parameters for the detector. Thresholds tuned for the classic-CV detector."""
proc_max_dim: int = 720 # downscale longer side to this before detection (speed)
min_area_frac: float = 0.0008 # ignore regions smaller than this fraction of the image
# --- solid bars (black or white, achromatic, rectangular) ---
black_intensity: int = 40 # V below this = dark-bar candidate
white_intensity: int = 225 # V above this = light-bar candidate
bar_saturation_max: int = 45 # S below this = achromatic (excludes colored fills)
bar_min_extent: float = 0.80 # contour area / bbox area — how rectangular a bar must be
# --- mosaic / pixelation ---
mosaic_block_sizes: tuple[int, ...] = (8, 12, 16, 24) # candidate tile sizes (px, proc space)
mosaic_residual_max: float = 6.0 # max reconstruction error to count as "blocky"
mosaic_contrast_min: float = 14.0 # min local contrast (excludes flat gradients)
mosaic_grad_min: float = 8.0 # min edge energy in BOTH x and y (excludes straight edges)
mosaic_min_side: int = 24 # reject thin regions (px) — kills edge false-positives
# --- blur ---
blur_window: int = 31 # sliding window for local sharpness (odd)
blur_sharpness_ratio: float = 0.35 # below this fraction of median sharpness => blurry
blur_contrast_min: float = 8.0 # min local contrast (excludes flat regions)
# --- YOLO detector (used only when detector == "yolo"/"combined") ---
yolo_conf: float = 0.2 # confidence threshold (LADA recommends ~0.2)
yolo_imgsz: int = 640 # inference image size
yolo_device: str | None = None # None => auto ("cuda" if available, else "cpu")
@dataclass(frozen=True)
class OverlayConfig:
"""How detections are drawn over the image."""
# RGB per CensorType value
colors: dict[str, tuple[int, int, int]] = field(
default_factory=lambda: {
"mosaic": (231, 76, 60), # red
"blur": (241, 196, 15), # yellow
"black_bar": (26, 188, 156), # teal
"unknown": (155, 89, 182), # purple
}
)
line_width: int = 2
fill_alpha: int = 48 # 0..255 translucency of the region fill
show_labels: bool = True
@dataclass
class AppConfig:
detection: DetectionConfig = field(default_factory=DetectionConfig)
overlay: OverlayConfig = field(default_factory=OverlayConfig)
detector: str = "classic" # "classic" | "yolo" | "combined"
model_path: str | None = None # weights path, used by the YOLO detector
default_threshold: float = 0.20 # initial overlay confidence threshold
View File
+26
View File
@@ -0,0 +1,26 @@
"""Detector interface. Implement this to plug in a new model (e.g. YOLO)."""
from __future__ import annotations
from abc import ABC, abstractmethod
from ..video.frame import Frame
from .types import Detection
class Detector(ABC):
"""Abstract censorship detector.
Implementations must be safe to call repeatedly on consecutive frames. They
receive a :class:`Frame` and return detections in *source-frame* pixel
coordinates (the same resolution as ``frame.image``).
"""
@property
def name(self) -> str:
return type(self).__name__
@abstractmethod
def detect(self, frame: Frame) -> list[Detection]:
"""Return detected censored regions for ``frame`` (possibly empty)."""
raise NotImplementedError
+216
View File
@@ -0,0 +1,216 @@
"""Weights-free, heuristic censorship detector (classic computer vision).
APPROXIMATE BY DESIGN. This detector uses hand-tuned CV heuristics, not a
trained model. Its purpose is to make the whole pipeline runnable end-to-end
and to exercise the :class:`Detector` interface. For real-world accuracy,
replace it with a trained model (see ``yolo.py``, to be implemented) — the rest
of the app does not need to change.
Heuristics:
- black_bar: large, near-uniform very dark regions (classic censor bars).
- mosaic: regions that reconstruct well from a coarse block grid (low
residual) yet have high coarse-scale contrast (i.e. blocky, not flat).
- blur: regions with local high-frequency energy far below the frame median,
while still being textured (excludes genuinely flat areas).
"""
from __future__ import annotations
import cv2
import numpy as np
from ...config import DetectionConfig
from ..video.frame import Frame
from .base import Detector
from .types import CensorType, Detection
class ClassicCVDetector(Detector):
def __init__(
self,
config: DetectionConfig | None = None,
types: "set[CensorType] | None" = None,
) -> None:
self.cfg = config or DetectionConfig()
# Which censorship kinds to look for. Default: all. The composite detector
# restricts this to black_bar/blur (mosaic comes from the YOLO model).
self.types = (
types if types is not None
else {CensorType.MOSAIC, CensorType.BLUR, CensorType.BLACK_BAR}
)
# ------------------------------------------------------------------ public
def detect(self, frame: Frame) -> list[Detection]:
bgr = frame.image
h0, w0 = bgr.shape[:2]
scale = self._proc_scale(w0, h0)
proc = (
cv2.resize(bgr, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA)
if scale != 1.0
else bgr
)
gray = cv2.cvtColor(proc, cv2.COLOR_BGR2GRAY)
ph, pw = gray.shape
min_area = self.cfg.min_area_frac * pw * ph
dets: list[Detection] = []
for ctype, fn, factor in (
(CensorType.BLACK_BAR, self._detect_bars, 1.0),
(CensorType.MOSAIC, self._detect_mosaic, 4.0),
(CensorType.BLUR, self._detect_blur, 6.0),
):
if ctype not in self.types:
continue
try:
dets += fn(proc, gray, min_area * factor)
except Exception:
# A failing heuristic must not break playback; skip it for this frame.
continue
# Map proc-space coordinates back to source-frame pixels.
inv = 1.0 / scale
for d in dets:
x, y, w, h = d.bbox
d.bbox = (round(x * inv), round(y * inv), round(w * inv), round(h * inv))
d.polygon = [(round(px * inv), round(py * inv)) for px, py in d.polygon]
return self._dedup(dets)
# ----------------------------------------------------------------- helpers
def _proc_scale(self, w: int, h: int) -> float:
longest = max(w, h)
if longest <= self.cfg.proc_max_dim:
return 1.0
return self.cfg.proc_max_dim / longest
@staticmethod
def _local_std(g: np.ndarray, win: int) -> np.ndarray:
"""Per-pixel standard deviation over a (win x win) box window."""
mean = cv2.boxFilter(g, -1, (win, win))
sqmean = cv2.boxFilter(g * g, -1, (win, win))
var = np.maximum(sqmean - mean * mean, 0.0)
return np.sqrt(var)
def _mask_to_detections(
self,
mask: np.ndarray,
ctype: CensorType,
min_area: float,
base_score: float,
min_extent: float = 0.0,
min_side: int = 0,
) -> list[Detection]:
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8))
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8))
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
out: list[Detection] = []
for c in contours:
area = cv2.contourArea(c)
if area < min_area:
continue
x, y, w, h = cv2.boundingRect(c)
if min(w, h) < min_side:
continue # reject thin strips (e.g. edge false-positives)
extent = area / float(w * h + 1e-6) # how rectangular the blob is
if extent < min_extent:
continue
approx = cv2.approxPolyDP(c, 0.01 * cv2.arcLength(c, True), True)
poly = [(int(p[0][0]), int(p[0][1])) for p in approx]
score = float(np.clip(base_score + 0.25 * extent, 0.0, 1.0))
out.append(Detection(type=ctype, score=score, bbox=(x, y, w, h), polygon=poly))
return out
# --------------------------------------------------------------- detectors
def _detect_bars(self, bgr, gray, min_area) -> list[Detection]:
# Solid censor bars are achromatic (black OR white) rectangles. Requiring
# low saturation + high rectangularity excludes large flat *colored* fills
# that are common in drawn/anime backgrounds.
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
sat, val = hsv[:, :, 1], hsv[:, :, 2]
achromatic = sat < self.cfg.bar_saturation_max
dark = (val < self.cfg.black_intensity) & achromatic
light = (val > self.cfg.white_intensity) & achromatic
mask = (dark | light).astype(np.uint8) * 255
return self._mask_to_detections(
mask, CensorType.BLACK_BAR, min_area, base_score=0.55,
min_extent=self.cfg.bar_min_extent,
)
def _detect_mosaic(self, bgr, gray, min_area) -> list[Detection]:
g = gray.astype(np.float32)
h, w = gray.shape
win = 17
# Lowest reconstruction residual across candidate tile sizes AND grid phases.
# Real mosaics aren't aligned to the origin, so we try a few offsets per size
# (phase-invariant) and keep the best fit.
best_residual = np.full((h, w), np.inf, np.float32)
for b in self.cfg.mosaic_block_sizes:
half = b // 2
for oy, ox in ((0, 0), (half, 0), (0, half), (half, half)):
sub = g[oy:, ox:]
sh, sw = sub.shape
if sh < b or sw < b:
continue
small = cv2.resize(sub, (max(1, sw // b), max(1, sh // b)), interpolation=cv2.INTER_AREA)
restored = cv2.resize(small, (sw, sh), interpolation=cv2.INTER_NEAREST)
region = best_residual[oy:oy + sh, ox:ox + sw]
np.minimum(region, np.abs(sub - restored), out=region)
best_residual = cv2.boxFilter(best_residual, -1, (win, win))
contrast = self._local_std(g, win)
# Mosaic has edges in BOTH directions; a lone straight boundary (flat-region
# border, bar edge) has edge energy in only one — exclude those.
gx = cv2.boxFilter(np.abs(cv2.Sobel(g, cv2.CV_32F, 1, 0, ksize=3)), -1, (win, win))
gy = cv2.boxFilter(np.abs(cv2.Sobel(g, cv2.CV_32F, 0, 1, ksize=3)), -1, (win, win))
both_dirs = (gx > self.cfg.mosaic_grad_min) & (gy > self.cfg.mosaic_grad_min)
blocky = best_residual < self.cfg.mosaic_residual_max
textured = contrast > self.cfg.mosaic_contrast_min
mask = (blocky & textured & both_dirs).astype(np.uint8) * 255
return self._mask_to_detections(
mask, CensorType.MOSAIC, min_area, base_score=0.50, min_side=self.cfg.mosaic_min_side
)
def _detect_blur(self, bgr, gray, min_area) -> list[Detection]:
g = gray.astype(np.float32)
win = self.cfg.blur_window | 1 # force odd
lap = cv2.Laplacian(g, cv2.CV_32F, ksize=3)
sharpness = cv2.boxFilter(lap * lap, -1, (win, win)) # local high-freq energy
median = float(np.median(sharpness)) + 1e-6
contrast = self._local_std(g, win)
blurry = sharpness < median * self.cfg.blur_sharpness_ratio
textured = contrast > self.cfg.blur_contrast_min
mask = (blurry & textured).astype(np.uint8) * 255
return self._mask_to_detections(
mask, CensorType.BLUR, min_area, base_score=0.40, min_side=self.cfg.mosaic_min_side
)
# ----------------------------------------------------------------- dedup
def _dedup(self, dets: list[Detection]) -> list[Detection]:
"""Greedy IoU suppression; prefer black_bar > mosaic > blur, then score."""
priority = {
CensorType.BLACK_BAR: 3,
CensorType.MOSAIC: 2,
CensorType.BLUR: 1,
CensorType.UNKNOWN: 0,
}
dets = sorted(dets, key=lambda d: (priority[d.type], d.score), reverse=True)
kept: list[Detection] = []
for d in dets:
if all(self._iou(d.bbox, k.bbox) < 0.5 for k in kept):
kept.append(d)
return kept
@staticmethod
def _iou(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> float:
ax, ay, aw, ah = a
bx, by, bw, bh = b
ix = max(ax, bx)
iy = max(ay, by)
ix2 = min(ax + aw, bx + bw)
iy2 = min(ay + ah, by + bh)
iw, ih = max(0, ix2 - ix), max(0, iy2 - iy)
inter = iw * ih
union = aw * ah + bw * bh - inter
return inter / union if union > 0 else 0.0
+51
View File
@@ -0,0 +1,51 @@
"""Composite detector: runs several detectors and merges their results.
Used for the "combined" mode = YOLO (mosaic) + classic-CV (black bars / blur).
Detections from all sub-detectors are concatenated, then de-duplicated by IoU
(higher score wins) so overlapping hits from different detectors don't stack.
"""
from __future__ import annotations
from ..video.frame import Frame
from .base import Detector
from .types import Detection
class CompositeDetector(Detector):
def __init__(self, detectors: list[Detector], iou_threshold: float = 0.6) -> None:
if not detectors:
raise ValueError("CompositeDetector requires at least one detector")
self._detectors = detectors
self._iou = iou_threshold
@property
def name(self) -> str:
return "Composite(" + " + ".join(d.name for d in self._detectors) + ")"
def detect(self, frame: Frame) -> list[Detection]:
merged: list[Detection] = []
for detector in self._detectors:
try:
merged += detector.detect(frame)
except Exception: # noqa: BLE001 - one detector failing must not kill the frame
continue
return self._dedup(merged)
def _dedup(self, dets: list[Detection]) -> list[Detection]:
dets = sorted(dets, key=lambda d: d.score, reverse=True)
kept: list[Detection] = []
for d in dets:
if all(self._iou_of(d.bbox, k.bbox) < self._iou for k in kept):
kept.append(d)
return kept
@staticmethod
def _iou_of(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> float:
ax, ay, aw, ah = a
bx, by, bw, bh = b
ix, iy = max(ax, bx), max(ay, by)
ix2, iy2 = min(ax + aw, bx + bw), min(ay + ah, by + bh)
inter = max(0, ix2 - ix) * max(0, iy2 - iy)
union = aw * ah + bw * bh - inter
return inter / union if union > 0 else 0.0
+41
View File
@@ -0,0 +1,41 @@
"""Detector factory: build a Detector from AppConfig.
Kept separate from ``app.py`` so both the app bootstrap and the UI can build
detectors without an import cycle. Raises ``ValueError`` (not ``SystemExit``) on
bad config so the GUI can show the message instead of exiting.
"""
from __future__ import annotations
from ...config import AppConfig
from .base import Detector
from .classic_cv import ClassicCVDetector
from .types import CensorType
def _require_model(config: AppConfig) -> str:
if not config.model_path:
raise ValueError(
"Для детектора YOLO укажите путь к весам (.pt) в Параметрах "
"или скачайте модель LADA — см. README."
)
return config.model_path
def build_detector(config: AppConfig) -> Detector:
if config.detector == "classic":
return ClassicCVDetector(config.detection)
if config.detector == "yolo":
from .yolo import YoloDetector # lazy: pulls torch/ultralytics
return YoloDetector(_require_model(config), config.detection)
if config.detector == "combined":
# YOLO handles mosaic; classic-CV handles black bars / blur.
from .composite import CompositeDetector
from .yolo import YoloDetector
return CompositeDetector([
YoloDetector(_require_model(config), config.detection),
ClassicCVDetector(config.detection, types={CensorType.BLACK_BAR, CensorType.BLUR}),
])
raise ValueError(f"Неизвестный детектор: {config.detector!r}")
+42
View File
@@ -0,0 +1,42 @@
"""Detection result types shared across detectors and the UI."""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
class CensorType(str, Enum):
"""Kind of already-applied censorship a detection represents."""
MOSAIC = "mosaic"
BLUR = "blur"
BLACK_BAR = "black_bar"
UNKNOWN = "unknown"
@dataclass
class Detection:
"""A single detected censored region, in source-frame pixel coordinates."""
type: CensorType
score: float # confidence, 0..1
bbox: tuple[int, int, int, int] # x, y, w, h
polygon: list[tuple[int, int]] = field(default_factory=list) # contour points
def to_dict(self) -> dict:
return {
"type": self.type.value,
"score": self.score,
"bbox": list(self.bbox),
"polygon": [list(p) for p in self.polygon],
}
@classmethod
def from_dict(cls, data: dict) -> "Detection":
return cls(
type=CensorType(data["type"]),
score=float(data["score"]),
bbox=tuple(data["bbox"]), # type: ignore[arg-type]
polygon=[tuple(p) for p in data.get("polygon", [])],
)
+104
View File
@@ -0,0 +1,104 @@
"""Ultralytics YOLO detector.
Wraps an Ultralytics YOLO model (detection or segmentation) behind the
:class:`Detector` interface. Designed for the LADA mosaic-detection weights
(https://huggingface.co/ladaapp/lada), which are YOLO *segmentation* models with
a single ``mosaic`` class — but it works with any Ultralytics ``.pt`` whose class
names map onto :class:`CensorType`.
Heavy imports (``ultralytics``/``torch``) happen lazily in ``__init__`` so the
rest of the app — and the classic-CV detector — never pull them in.
Licensing: Ultralytics YOLO and the LADA weights are AGPL-3.0. See README.
"""
from __future__ import annotations
import os
import numpy as np
from ...config import DetectionConfig
from ..video.frame import Frame
from .base import Detector
from .types import CensorType, Detection
def _name_to_type(name: str) -> CensorType:
n = name.lower()
if "mosaic" in n or "pixel" in n:
return CensorType.MOSAIC
if "blur" in n:
return CensorType.BLUR
if "bar" in n or "black" in n:
return CensorType.BLACK_BAR
return CensorType.UNKNOWN
class YoloDetector(Detector):
def __init__(self, model_path: str, config: DetectionConfig | None = None) -> None:
self.cfg = config or DetectionConfig()
if not os.path.isfile(model_path):
raise FileNotFoundError(
f"Файл весов не найден: {model_path}\n"
"Скачайте модель детекции мозаики LADA, например:\n"
" curl.exe -L -o models\\lada_mosaic_detection_model_v4_accurate.pt "
'"https://huggingface.co/ladaapp/lada/resolve/main/'
'lada_mosaic_detection_model_v4_accurate.pt?download=true"'
)
try:
from ultralytics import YOLO
except ImportError as exc: # pragma: no cover - environment dependent
raise ImportError(
"Не установлен ultralytics. Установите: pip install -e \".[yolo]\" "
"(и PyTorch с CUDA отдельно — см. README)."
) from exc
# Resolve the device: explicit override, else CUDA when available.
device = self.cfg.yolo_device
if device is None:
try:
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
except Exception: # noqa: BLE001
device = "cpu"
self._device = device
self._model = YOLO(model_path)
@property
def name(self) -> str:
return f"YoloDetector(device={self._device})"
def detect(self, frame: Frame) -> list[Detection]:
results = self._model.predict(
source=frame.image, # BGR ndarray; ultralytics handles it
conf=self.cfg.yolo_conf,
imgsz=self.cfg.yolo_imgsz,
device=self._device,
verbose=False,
)
if not results:
return []
res = results[0]
boxes = getattr(res, "boxes", None)
if boxes is None or len(boxes) == 0:
return []
names = res.names # {class_index: class_name}
xyxy = boxes.xyxy.cpu().numpy()
confs = boxes.conf.cpu().numpy()
classes = boxes.cls.cpu().numpy().astype(int)
# Segmentation polygons in source-pixel coords, one per detection (if any).
polygons = res.masks.xy if getattr(res, "masks", None) is not None else None
out: list[Detection] = []
for i in range(len(xyxy)):
x1, y1, x2, y2 = xyxy[i]
bbox = (int(x1), int(y1), int(x2 - x1), int(y2 - y1))
poly: list[tuple[int, int]] = []
if polygons is not None and i < len(polygons):
poly = [(int(px), int(py)) for px, py in polygons[i]]
ctype = _name_to_type(names.get(int(classes[i]), ""))
out.append(Detection(type=ctype, score=float(confs[i]), bbox=bbox, polygon=poly))
return out
+32
View File
@@ -0,0 +1,32 @@
"""Unicode-safe image read/write.
``cv2.imread``/``cv2.imwrite`` mishandle non-ASCII paths on Windows. These
helpers go through ``np.fromfile``/``ndarray.tofile`` + ``imdecode``/``imencode``
so paths with Cyrillic (etc.) work regardless of the system locale.
"""
from __future__ import annotations
import os
import cv2
import numpy as np
def imread_unicode(path: str) -> np.ndarray | None:
try:
data = np.fromfile(path, dtype=np.uint8)
except OSError:
return None
if data.size == 0:
return None
return cv2.imdecode(data, cv2.IMREAD_COLOR)
def imwrite_unicode(path: str, image: np.ndarray, params: list[int] | None = None) -> bool:
ext = os.path.splitext(path)[1] or ".jpg"
ok, buf = cv2.imencode(ext, image, params or [])
if not ok:
return False
buf.tofile(path)
return True
View File
+168
View File
@@ -0,0 +1,168 @@
"""Extract frames from a video into a folder of JPGs.
Two engines:
* **ffmpeg** (preferred, used when the ``ffmpeg`` binary is on PATH) — one
subprocess does decode + sampling + optional downscale + JPEG encode, which is
faster than pulling frames into Python one by one, and unlocks the big win:
*keyframe-only* extraction (``-skip_frame nokey`` decodes only I-frames, ~10×
faster than decoding every frame).
* **OpenCV** fallback (``cv2.VideoCapture``) when ffmpeg is absent.
Decoding H.264/HEVC frame-by-frame is inherently the cost; hardware accel doesn't
help for this (GPU transfer overhead). The only way to be dramatically faster is
to decode fewer frames — hence the keyframe mode.
"""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
from typing import Callable
import cv2
from ..imageio import imwrite_unicode
# progress(done_seconds, total_seconds) -> return False to cancel.
Progress = Callable[[float, float], bool]
def _find_ffmpeg() -> str | None:
"""ffmpeg on PATH, else the binary bundled with imageio-ffmpeg, else None."""
exe = shutil.which("ffmpeg")
if exe:
return exe
try:
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
except Exception: # noqa: BLE001 - package missing or no bundled binary
return None
def _video_duration_seconds(video_path: str) -> float:
cap = cv2.VideoCapture(str(video_path))
try:
fps = cap.get(cv2.CAP_PROP_FPS) or 0.0
frames = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0.0
return frames / fps if fps > 0 else 0.0
finally:
cap.release()
def _quality_to_qscale(jpg_quality: int) -> int:
"""Map JPEG quality 0..100 to ffmpeg -q:v (2 best .. 31 worst)."""
q = round(2 + (100 - max(0, min(100, jpg_quality))) / 100 * 29)
return max(2, min(31, q))
def extract_frames(
video_path: str,
out_dir: str,
step: int = 15,
keyframes_only: bool = False,
max_dim: int = 0,
jpg_quality: int = 92,
progress: Progress | None = None,
) -> int:
"""Save sampled frames of ``video_path`` into ``out_dir`` as JPGs.
``keyframes_only`` decodes only keyframes (fast). Otherwise keeps every
``step``-th frame. ``max_dim`` (>0) caps the longest side. ``progress`` is
called with (done_seconds, total_seconds); returning ``False`` cancels.
Returns the number of frames written.
"""
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
ffmpeg = _find_ffmpeg()
if ffmpeg:
return _extract_ffmpeg(ffmpeg, video_path, out, step, keyframes_only, max_dim, jpg_quality, progress)
return _extract_cv2(video_path, out, step, max_dim, jpg_quality, progress)
# --------------------------------------------------------------------- ffmpeg
def _build_vf(step: int, keyframes_only: bool, max_dim: int) -> str | None:
filters: list[str] = []
if not keyframes_only and step > 1:
filters.append(f"select=not(mod(n\\,{int(step)}))")
if max_dim and max_dim > 0:
# Cap the longest side to max_dim, preserve aspect, never upscale.
filters.append(f"scale='min({max_dim},iw)':'min({max_dim},ih)':force_original_aspect_ratio=decrease")
return ",".join(filters) if filters else None
def _extract_ffmpeg(
ffmpeg: str, video_path: str, out: Path, step: int,
keyframes_only: bool, max_dim: int, jpg_quality: int, progress: Progress | None,
) -> int:
total = _video_duration_seconds(video_path)
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin"]
if keyframes_only:
cmd += ["-skip_frame", "nokey"] # input option: decode only keyframes
cmd += ["-i", video_path]
vf = _build_vf(step, keyframes_only, max_dim)
if vf:
cmd += ["-vf", vf]
cmd += ["-vsync", "0", "-q:v", str(_quality_to_qscale(jpg_quality)),
"-progress", "pipe:1", str(out / "%06d.jpg")]
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL, text=True, bufsize=1,
)
try:
assert proc.stdout is not None
for line in proc.stdout:
if progress is None:
continue
line = line.strip()
if line.startswith("out_time_us=") or line.startswith("out_time_ms="):
raw = line.split("=", 1)[1]
try:
# out_time_us is microseconds; out_time_ms is *also* microseconds
# in ffmpeg (historical misnomer). Both -> seconds via /1e6.
done = int(raw) / 1_000_000 if raw.isdigit() else 0.0
except ValueError:
done = 0.0
if progress(done, total) is False:
proc.terminate()
break
finally:
proc.wait()
return len(list(out.glob("*.jpg")))
# ---------------------------------------------------------------------- opencv
def _extract_cv2(
video_path: str, out: Path, step: int, max_dim: int, jpg_quality: int, progress: Progress | None,
) -> int:
cap = cv2.VideoCapture(str(video_path))
if not cap.isOpened():
raise RuntimeError(f"Не удалось открыть видео: {video_path}")
step = max(1, int(step))
fps = cap.get(cv2.CAP_PROP_FPS) or 0.0
total = (cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0.0) / fps if fps > 0 else 0.0
params = [cv2.IMWRITE_JPEG_QUALITY, int(jpg_quality)]
idx = saved = 0
try:
while True:
if not cap.grab():
break
if idx % step == 0:
ok, frame = cap.retrieve()
if ok:
if max_dim and max(frame.shape[:2]) > max_dim:
s = max_dim / max(frame.shape[:2])
frame = cv2.resize(frame, None, fx=s, fy=s, interpolation=cv2.INTER_AREA)
imwrite_unicode(str(out / f"{idx:06d}.jpg"), frame, params)
saved += 1
idx += 1
if progress is not None and idx % 30 == 0:
done = idx / fps if fps > 0 else 0.0
if progress(done, total) is False:
break
finally:
cap.release()
return saved
+14
View File
@@ -0,0 +1,14 @@
"""Decoded video frame passed from the reader to the detector."""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
@dataclass
class Frame:
image: np.ndarray # BGR, HxWx3, uint8 (OpenCV convention)
index: int # 0-based frame counter since the last open/seek
pts: float # presentation timestamp, seconds
+58
View File
@@ -0,0 +1,58 @@
"""Persist a handful of user choices to ~/HVideoTool/settings.json.
Slimmed down for the image-inspector tool: it remembers the detector, the model
path, the overlay threshold, and the last opened folder.
"""
from __future__ import annotations
import json
from pathlib import Path
from .config import AppConfig
_PATH = Path.home() / "HVideoTool" / "settings.json"
def _read() -> dict:
try:
return json.loads(_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def _write(data: dict) -> None:
_PATH.parent.mkdir(parents=True, exist_ok=True)
_PATH.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def apply(config: AppConfig) -> None:
"""Overlay persisted settings onto ``config`` (mutates it in place)."""
data = _read()
if data.get("detector"):
config.detector = data["detector"]
if "model_path" in data:
config.model_path = data["model_path"]
if "threshold" in data:
config.default_threshold = float(data["threshold"])
def save(config: AppConfig) -> None:
"""Persist the configurable settings, preserving other keys (e.g. last_dir)."""
data = _read()
data.update(
detector=config.detector,
model_path=config.model_path,
threshold=config.default_threshold,
)
_write(data)
def last_dir() -> str | None:
return _read().get("last_dir")
def set_last_dir(path: str) -> None:
data = _read()
data["last_dir"] = path
_write(data)
View File
+62
View File
@@ -0,0 +1,62 @@
"""Options dialog for "Создать из ролика…" — sampling mode, step, downscale.
Keeps the speed levers in one place: keyframe-only (fast) vs every-Nth-frame, the
step, and an optional max-side downscale (smaller files → less disk/AV pressure).
"""
from __future__ import annotations
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QDialogButtonBox,
QFormLayout,
QLabel,
QSpinBox,
QWidget,
)
class ExtractDialog(QDialog):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("Создать из ролика")
self.mode = QComboBox()
self.mode.addItem("Только ключевые кадры (быстро)", userData=True)
self.mode.addItem("Каждый N-й кадр", userData=False)
self.mode.currentIndexChanged.connect(self._sync)
self.step = QSpinBox()
self.step.setRange(1, 100000)
self.step.setValue(15)
self.max_dim = QSpinBox()
self.max_dim.setRange(0, 8192)
self.max_dim.setSingleStep(120)
self.max_dim.setValue(0)
self.max_dim.setSpecialValueText("оригинал")
form = QFormLayout(self)
form.addRow("Режим:", self.mode)
form.addRow("Брать каждый N-й кадр:", self.step)
form.addRow("Макс. сторона, px:", self.max_dim)
hint = QLabel(
"Ключевые кадры — в разы быстрее (декодируются только I-кадры),\n"
"но реже по времени. Даунскейл уменьшает файлы и нагрузку на диск."
)
hint.setWordWrap(True)
form.addRow(hint)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
form.addRow(buttons)
self._sync()
def _sync(self) -> None:
self.step.setEnabled(not self.mode.currentData())
def options(self) -> tuple[bool, int, int]:
"""Return (keyframes_only, step, max_dim)."""
return bool(self.mode.currentData()), self.step.value(), self.max_dim.value()
+128
View File
@@ -0,0 +1,128 @@
"""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.
"""
from __future__ import annotations
import cv2
import numpy as np
from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtGui import QBrush, QColor, QFont, QImage, QPainter, QPen, QPolygonF
from PySide6.QtWidgets import QWidget
from ..config import OverlayConfig
from ..core.detection.types import CensorType, Detection
class ImageView(QWidget):
def __init__(self, overlay_cfg: OverlayConfig, parent: QWidget | None = None) -> None:
super().__init__(parent)
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)
# ------------------------------------------------------------------ slots
def set_image(self, image_bgr: np.ndarray | None, dets: list[Detection]) -> None:
if image_bgr is None:
self._qimage = None
else:
rgb = np.ascontiguousarray(cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB))
h, w, ch = rgb.shape
# .copy() so the QImage owns its pixels (the numpy buffer can be freed).
self._qimage = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888).copy()
self._dets = dets
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()
def set_highlight(self, index: int | None) -> None:
self._highlight = index
self.update()
# ------------------------------------------------------------------ paint
def _color(self, ctype: CensorType) -> QColor:
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
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.end()
return
iw, ih = self._qimage.width(), self._qimage.height()
scale = min(self.width() / iw, self.height() / ih)
dw, dh = iw * scale, ih * scale
ox, oy = (self.width() - dw) / 2, (self.height() - dh) / 2
painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
painter.drawImage(QRectF(ox, oy, dw, dh), self._qimage)
if self._overlay_enabled and self._dets:
painter.setRenderHint(QPainter.Antialiasing, True)
for i, d in enumerate(self._dets):
highlighted = i == self._highlight
# A highlighted detection is always drawn; others respect the threshold.
if not highlighted and d.score < self._threshold:
continue
dim = self._highlight is not None and not highlighted
self._draw_detection(painter, d, ox, oy, scale, highlighted, dim)
painter.end()
def _draw_detection(
self, painter: QPainter, d: Detection, ox: float, oy: float,
scale: float, highlighted: bool, dim: bool,
) -> None:
color = self._color(d.type)
width = self._cfg.line_width * (2 if highlighted else 1)
pen_color = QColor(color)
if dim:
pen_color.setAlpha(70)
painter.setPen(QPen(pen_color, width))
fill = QColor(color)
fill.setAlpha(0 if dim else (self._cfg.fill_alpha * 2 if highlighted else self._cfg.fill_alpha))
painter.setBrush(QBrush(fill))
points = d.polygon if len(d.polygon) >= 3 else self._bbox_points(d.bbox)
poly = QPolygonF([QPointF(ox + x * scale, oy + y * scale) for x, y in points])
painter.drawPolygon(poly)
if self._cfg.show_labels and not dim:
x, y, _w, _h = d.bbox
self._draw_label(painter, f"{d.type.value} {d.score:.2f}", ox + x * scale, oy + y * scale, color)
@staticmethod
def _bbox_points(bbox: tuple[int, int, int, int]) -> list[tuple[int, int]]:
x, y, w, h = bbox
return [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
def _draw_label(self, painter: QPainter, text: str, x: float, y: float, color: QColor) -> None:
font = QFont()
font.setPointSize(9)
painter.setFont(font)
metrics = painter.fontMetrics()
tw = metrics.horizontalAdvance(text) + 8
th = metrics.height() + 2
bg = QRectF(x, max(0.0, y - th), tw, th)
painter.fillRect(bg, color)
painter.setPen(QColor(0, 0, 0))
painter.drawText(bg, Qt.AlignCenter, text)
+494
View File
@@ -0,0 +1,494 @@
"""Main window: open a folder of images and inspect what the detector found.
Layout: a toolbar (open folder · detector · model · calc-frame · detect-all ·
threshold), then a splitter with three panes — the file list (left), the image
with overlays (center), and a detail table of every detection (right).
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
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QComboBox,
QDialog,
QDoubleSpinBox,
QFileDialog,
QInputDialog,
QLabel,
QListWidget,
QListWidgetItem,
QMainWindow,
QMessageBox,
QProgressBar,
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
from ..core.video.extract import extract_frames
from ..core.video.frame import Frame
from .extract_dialog import ExtractDialog
from .image_view import ImageView
_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.setWindowTitle("HVideoTool — инспектор детекции цензуры")
self.resize(1180, 720)
self._build_toolbar()
self._build_central()
self._build_statusbar()
self._build_menu()
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._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()
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)
tb.addSeparator()
tb.addAction(QAction("Создать коллекцию…", self, triggered=self._create_collection))
move = QAction("В коллекцию →", self, triggered=self._move_to_collection)
move.setToolTip("Переместить выбранные кадры в активную коллекцию (Ctrl+M)")
tb.addAction(move)
self.collection_label = QLabel(" коллекция: —")
tb.addWidget(self.collection_label)
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)
self.view = ImageView(self._cfg.overlay)
self.view.set_threshold(self._cfg.default_threshold)
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(self.file_list)
splitter.addWidget(self.view)
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_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()):
self.file_list.item(i).setText(self.file_list.item(i).data(Qt.UserRole + 1))
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)
if not files:
self.view.set_image(None, [])
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:
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._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
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)
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._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.statusBar().showMessage(f"Готово: детекции на {hits} из {total} картинок")
if self._current is not None:
self._show(self._current)
# ------------------------------------------------------------ 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 _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._update_collection_label()
self.statusBar().showMessage(f"Активная коллекция: {path}")
def _update_collection_label(self) -> None:
self.collection_label.setText(
f" коллекция: {self._collection.name}" if self._collection else " коллекция: —"
)
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, [])
@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
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} · —")
return
def _fill_detail_table(self, path: Path, img, dets: list[Detection] | None) -> None:
h, w = (img.shape[0], img.shape[1]) if img is not None else (0, 0)
if dets is None:
self.detail_header.setText(
f"<b>{path.name}</b> · {w}×{h} · <i>не рассчитано</i> "
"(двойной клик по файлу или «Рассчитать кадр»)"
)
self.detail_table.setRowCount(0)
self.view.set_highlight(None)
return
by_type: dict[str, int] = {}
for d in dets:
by_type[d.type.value] = by_type.get(d.type.value, 0) + 1
summary = ", ".join(f"{k}: {v}" for k, v in sorted(by_type.items())) or "ничего не найдено"
self.detail_header.setText(f"<b>{path.name}</b> · {w}×{h} · всего {len(dets)} ({summary})")
self.detail_table.blockSignals(True)
self.detail_table.setRowCount(len(dets))
for row, d in enumerate(dets):
x, y, bw, bh = d.bbox
cells = [d.type.value, f"{d.score:.2f}", f"{x},{y},{bw},{bh}", str(len(d.polygon))]
for col, text in enumerate(cells):
self.detail_table.setItem(row, col, QTableWidgetItem(text))
self.detail_table.blockSignals(False)
self.detail_table.clearSelection()
self.detail_table.resizeColumnsToContents()
self.view.set_highlight(None)
def _on_detail_selected(self) -> None:
rows = self.detail_table.selectionModel().selectedRows()
self.view.set_highlight(rows[0].row() if rows else None)
def _on_threshold_changed(self, value: float) -> None:
self._cfg.default_threshold = value
self.view.set_threshold(value)
settings_store.save(self._cfg)