Добавлено описание и документация для HVideoTool, включая функционал, требования, установку и запуск приложения для обнаружения цензуры на изображениях.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""HVideoTool — detect and outline already-applied censorship in local videos."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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", [])],
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user