Files
HVideoTool/CLAUDE.md
T

20 KiB
Raw Blame History

CLAUDE.md

Guidance for Claude Code (and other agents) working in this repository.

Memory: EchoVault (read this first)

This project uses the EchoVault MCP server for persistent, cross-session memory. Prior sessions store architectural decisions, fixed bugs, and gotchas there. Follow this protocol every session:

  1. At session start — load context. Call memory_context (project is auto-detected from cwd) before doing any work. Use memory_search for specific topics (e.g. "detector model", "classic-cv", "false positives").
  2. During work — search before re-deciding. When the task touches an area that may have prior context, memory_search it first instead of re-deriving decisions.
  3. Before ending a session — save what matters. Call memory_save when you made a design decision, fixed a bug (include root cause + fix), found a non-obvious gotcha, or the user corrected/clarified a requirement. Pick the right category (decision / bug / pattern / learning / context). Do not save trivia, things obvious from the code, or duplicates.

EchoVault is the source of truth for why things are the way they are; this file is the stable, high-level map. When they disagree, trust on-disk code first, then EchoVault, then this file — and update whichever is stale.

What this project is

HVideoTool is a Windows-first desktop GUI utility that detects already-applied censorship (mosaic, pixelation, blur, black bars) in images, and draws outlines over the detected censored regions. You open a project (see below); it runs each image through a detector, draws the regions, and shows a detailed per-image list of what it found.

Projects (this session). Work is organized into projects — a project is a folder holding project.json (metadata + per-project settings) · frames/ (the images) · detections.json (the detection cache) · collections/Избранное (the single default "favorites" collection). See core/project.py. The per-project settings (detector, model, threshold, restore engine) live in project.json; the global settings.json only seeds the defaults for new projects. The old "open a bare folder" flow is now "Импортировать папку как проект…" (copies images into a new project's frames/).

Scope is still narrow. It used to extract frames from video, detect, and play back with overlays (a thread-based "project model"). That video playback pipeline was removed; the new "projects" are just an on-disk layout, NOT worker threads or playback. The tool remains a synchronous, single-threaded image inspector.

Keep this scope sharp:

  • Primary job is detection + overlay/inspection. A restoration ("расцензурить") step was added later (user-requested): per-frame, on-demand, behind a Restorer interface. Two engines: a cv2 inpaint baseline (fills, does NOT reconstruct) and DeepMosaics — real generative mosaic removal, its GPL-3.0 network code vendored under core/restore/_deepmosaics/ and run in-process (user supplies only the weights). Because of that vendoring the whole project is GPL-3.0. LADA (BasicVSR++) is a possible future engine, not wired.
  • Still no diffusion / ControlNet / SDXL. (xinsir/controlnet-union-sdxl-1.0 was rejected early — a generative conditioning model, not a censorship restorer. Don't reintroduce it.) Restoration, if upgraded, uses a mosaic-removal model (DeepMosaics/ LADA), not a general text-to-image diffusion pipeline.
  • It detects already-censored regions, not "content that should be censored" (i.e. not an NSFW classifier).
  • Video is only a one-shot frame-extraction convenience (see below): "Создать из ролика…" makes a new project and decodes the clip into its frames/. Detection and restoration operate on the project's images.

Target environment

  • OS: Windows 11 x64 (primary). Use PowerShell syntax in commands.
  • Python: 3.11+.
  • GPU: NVIDIA + CUDA via PyTorch, only for the YOLO detector. CPU fallback works but is slow. The classic detector needs no torch and no GPU.

Tech stack (decided)

Concern Choice
GUI PySide6 (Qt 6) — LGPL
Image IO OpenCV (opencv-python) + NumPy, unicode-safe via core/imageio.py
Detector classic-CV heuristic; Ultralytics YOLO (LADA) behind a pluggable interface

Torch/CUDA + Ultralytics enter only with the YOLO detector. Keep that dependency optional (the yolo extra in pyproject.toml pulls only Ultralytics; torch is installed separately per the README). The classic detector must keep running with no torch present.

Architecture (as implemented)

This reflects the actual code on disk. It is a synchronous, single-threaded GUI app — no worker threads. Work is organized into projects (core/project.py): a project folder holds project.json (metadata + per-project settings), frames/ (the images), detections.json (the detection cache, at the project root — no longer a sidecar next to the images), and collections/Избранное (the favorites collection). The only video touch is a one-shot "Создать из ролика…" that creates a new project and decodes a clip into its frames/ via the ffmpeg CLI (cv2.VideoCapture fallback; NOT PyAV). Detection runs on the GUI thread (lazily per image, or via "Детектировать все"). When code and this file disagree, trust the code.

hvideotool/
├── __main__.py          # entry point + CLI (optional project path, --detector, --model)
├── app.py               # QApplication bootstrap; run(config, target=None) — opens/auto-reopens a project
├── config.py            # AppConfig + DetectionConfig/OverlayConfig (thresholds live here)
├── settings_store.py    # new-project DEFAULTS + last/recent projects to ~/HVideoTool/settings.json
├── ui/
│   ├── main_window.py   # the whole UI: toolbar + [file list | image view | detail table]
│   └── image_view.py    # renders an image + draws polygon/bbox overlays (QPainter); can highlight one
└── core/
    ├── imageio.py       # unicode-safe imread/imwrite (np.fromfile + imdecode)
    ├── project.py       # Project: layout (project.json/frames/detections.json/collections) + per-project settings
    ├── video/
    │   ├── extract.py   # extract_frames(): ffmpeg CLI (cv2 fallback) -> JPGs; keyframe/step modes + downscale
    │   └── frame.py     # Frame dataclass (image BGR, index, pts) — the detector input type
    ├── restore/         # "un-censor" detected regions (per-frame)
    │   ├── base.py      #   Restorer ABC: restore(image, detections, should_cancel=None) -> image; Cancelled exc
    │   ├── factory.py   #   build_restorer(name, config) -> inpaint | deepmosaics (lada = TODO)
    │   ├── inpaint.py   #   InpaintRestorer (cv2) — baseline, fills not reconstructs
    │   ├── deepmosaics.py #  DeepMosaicsRestorer — in-process, loads models once; uses _deepmosaics/
    │   ├── _deepmosaics/ #  VENDORED DeepMosaics models/+util/ (GPL-3.0) — added to sys.path at import
    │   └── mask.py      #   detections_to_mask(shape, dets, dilate)
    └── detection/
        ├── base.py      # Detector ABC: detect(frame) -> list[Detection]
        ├── factory.py   # build_detector(config) -> classic | yolo | combined
        ├── types.py     # Detection (+ to_dict/from_dict), CensorType enum
        ├── cache.py     # save/load the project detection cache (detections.json): cache_file + base_dir args
        ├── classic_cv.py  # ClassicCVDetector — heuristic; accepts a `types` filter
        ├── yolo.py      # YoloDetector — Ultralytics YOLO-seg; lazy-imports torch/ultralytics
        └── composite.py # CompositeDetector — merge detectors + IoU dedup

How it works

  • MainWindow holds the config, builds the detector lazily via build_detector (cached by detector+model+conf in _make_detector), and keeps _results: dict[path -> list[Detection]] as the detection cache.
  • Projects (core/project.py). MainWindow._project is the open Project; _folder is kept as a synonym for project.frames_dir so the rest of the code (navigation, cache, tags) didn't need rewiring. Entry points: "Создать проект…" (_create_project), "Открыть проект…" (_open_project_dialog), "Импортировать папку как проект…" (_import_folder_as_project — copies images into a new project's frames/, carries over an old .hvideotool_detections.json sidecar if present), and a "Недавние проекты" submenu. _open_project(project) is the core open: it apply_to_configs the project's settings, syncs the toolbar widgets without signal loops (_sync_settings_ui), titles the window, records last/recent, and lists frames/. On startup app.run opens the CLI target or auto-reopens settings_store.last_project() (_auto_open_last).
  • Per-project settings. Detector/model/threshold/restore engine live in project.json (Project.settings, _SETTING_KEYS). _persist_settings() writes both the global defaults (for new projects) and the open project. Global settings.json is now only defaults + last/recent projects.
  • Listing image files (_IMAGE_EXTS) from frames/ uses a progress bar (bulk insert with setUpdatesEnabled(False) + periodic processEvents), so a big project doesn't freeze silently.
  • Viewing and detecting are decoupled on purpose (so browsing stays instant even with a slow CPU detector): selecting a file only shows it with its cached result (header reads "не рассчитано" if none). Detection runs on double-click, the "Рассчитать кадр" action (Space → _recompute_current, force-recomputes current), or "Детектировать все" (whole folder, progress bar). Do NOT re-add auto-detect-on-select. Results cache in _results; the file-list row gets a count suffix when computed. Switching detector/model clears the cache (_invalidate_results).
  • Detection cache (persisted). _results is mirrored to the project's detections.json (core/detection/cache.py, at project.cache_path; keyed by basename so it survives moving the project). cache.save_results/load_results take the cache file and the image base_dir (= project.frames_dir) separately, since the cache lives at the project root, not next to the images. It's tagged with the detector identity (_results_key = detector + model + conf/imgsz); on open, _load_cached_results reloads it only on a key match (else ignored, not shown as current). Saved (_save_results, skipped when _results is empty so it never clobbers a good cache with nothing) after detect-all (incl. cancel → partial), single detect/recompute, move-to-collection, and on closeEvent. "Детектировать все" is incremental (skips already-cached frames → resumes/top-ups); "Все заново" (_detect_all(force=True)) clears the cache first (full regen); "Рассчитать кадр"/Space always recomputes the one current frame. Empty list in the cache = "checked, clean" (tinted green, no mark); in _results distinguishes it from "not computed".
  • Favorites (curation). Curation was simplified (user request) to a single default collection — no create/select/browse UI. The "★ В избранное" button (left pane, under the file list) / "В избранное" menu item / Ctrl+M → _move_to_favorites moves (shutil.move, not copy) the ExtendedSelection-selected frames into project.favorites_dir (collections/Избранное, FAVORITES_DIR in core/project.py, created lazily on first move), removing them from list/_files/cache. _unique_dest avoids clobbering (foo.jpgfoo (1).jpg). Use case: flag good frames into a training/example set while inspecting detections.
  • Restoration ("Расцензурить кадр"). Toolbar action runs self._restorer (built via build_restorer) on the current frame's detections (computing them first if needed), caches the result in _restored[path], and shows it overlay-free. "Показать оригинал/ результат" toggles (_showing_restored); "Сохранить результат" writes <stem>_restored.jpg beside the frame. The baseline is cv2 inpaint; the real engine is DeepMosaics (restore/deepmosaics.py), run in-process from the vendored _deepmosaics/ code: it loads the BiSeNet locator + clean generator once (lazy, cached on the instance) and per frame reproduces their cleanmosaic_img_server (locate mosaic → run generator on the crop → feather back), ~0.3 s/frame cached on CPU vs ~7 s when it spawned a subprocess. Use the image model clean_youknow_resnet_9blocks.pth — the video model (BVDNet) is rejected per frame (needs a neighbour). should_cancel is polled at entry (raises Cancelled). The engine + weights are set in RestoreDialog (Файл → Движок восстановления…), persisted, and built lazily/cached in _make_restorer (like _make_detector). NOTE: DeepMosaics locates mosaics itself (its mosaic_position.pth, expected beside the clean weights) — our detections aren't passed to it. _show resets _showing_restored
    • _update_restore_actions. To add another engine (e.g. LADA), implement core/restore/base.Restorer and register it in restore/factory.build_restorer.
  • Navigation bar under the image (_build_nav_bar): prev/next frame (◀ ▶, keys ,/.), a scrubber frame_slider across the whole sequence, a pos_label ("row / n"), and jump-to-detection (◀ детекция / детекция ▶, keys [/], _step_hit scans _results for the next non-empty frame). The slider and file list are kept in sync via _update_nav guarded by _nav_sync (avoids signal loops); all navigation ultimately drives file_list.setCurrentRow. The scrubber is a custom MarkerSlider (ui/marker_slider.py) that paints cyan ticks at frames with detections (_refresh_marks projects _results onto row indices; per-pixel deduped so big folders stay cheap). File-list rows are tinted too (_tag_file): red = censorship found, green = checked & clean. Both reset on _invalidate_results.
  • Cancellation (cooperative, no threads). A single "■ Стоп" toolbar action (Esc) cancels the running long op. _begin_busy(total) / _end_busy() toggle self._busy
    • the Stop button + the progress bar (total=None → indeterminate); _request_cancel sets self._cancel; the loop-based ops (_detect_all, _load_folder) and video extraction (its progress cb returns not self._cancel) check the flag between processEvents ticks. Single-image restore passes should_cancel=self._poll_cancel (which pumps processEvents then returns the flag) into Restorer.restore; only DeepMosaics actually polls it (kills its subprocess + raises Cancelled) — cv2 ops are instant. Entry points guard with if self._busy: return (notably _move_to_collection, which mutates _files that _detect_all iterates). This keeps the synchronous, single-threaded model — do NOT reintroduce worker threads for cancellation.
  • image_view.ImageView draws the image scaled-to-fit plus overlays. Overlay visibility/threshold are applied at paint time. Selecting a row in the detail table calls set_highlight(i) — that detection is drawn boldly (even below threshold) and the rest dim. The detail table lists ALL detections (sorted by score), so sub-threshold hits are still visible for debugging; the threshold only affects what's drawn.

Separation of concerns

  • ui/ must not import torch / ultralytics directly. It builds detectors only via core/detection/factory.build_detector and talks to core/ through the Detector interface and the Detection/CensorType types.
  • New detector kinds: implement core/detection/base.Detector, register the string in core/detection/factory.build_detector, and add it to _DETECTORS in ui/main_window.py.

Commands

python -m venv .venv; .\.venv\Scripts\Activate.ps1
pip install -e .                       # classic detector needs no torch/CUDA

python -m hvideotool                   # reopen the last project (or create/open one in-app)
python -m hvideotool "C:\path\to\MyProject" --detector yolo --model models\lada_mosaic_detection_model_v4_accurate.pt

pip install -e ".[yolo]"               # + install torch separately, see README

No formal test suite. Headless sanity check: set QT_QPA_PLATFORM=offscreen, build a MainWindow, Project.create(tmp) + copy a few images into frames/, _open_project(project), drive file_list.setCurrentRow(...), and read detail_table / detail_header. Or run build_detector(config).detect(...) on a frame directly.

Conventions

  • Match the style of surrounding code; keep core/ free of Qt where reasonable.
  • Type hints on public functions and the Detector interface.
  • Model weights (.pt) and large media are not committed — keep them in models/ and .gitignored.
  • User-facing strings / README are in Russian; code identifiers and this file in English.

Gotchas

  • Wrong YOLO model = "noise". The YOLO detector needs a censorship model (LADA models\lada_mosaic_detection_model_v4_accurate.pt). If model_path points at a generic COCO model (e.g. the yolo11n-seg.pt in the repo root, which Ultralytics auto-downloads / is the training base), it detects people/objects and maps them to CensorType.UNKNOWN → purple boxes that look like noise. This was a real user trap.
  • classic-CV is approximate and noisy on real video. Its mosaic heuristic (low block-reconstruction residual + 2D gradient + contrast) fires on textured real footage (skin/hair/fabric/JPEG) → many false positives, while simultaneously missing real mosaic after the proc_max_dim=720 downscale softens block edges (measured: contrast/grad fall below mosaic_contrast_min/mosaic_grad_min). For real-video mosaic use yolo/combined + LADA. For anime there is no good public model.
  • Domain matters. LADA is trained on REAL video (JAV). It detects some anime mosaic but not all. The real anime fix is retraining a YOLO11-seg (see scripts/training/), not tuning more classic thresholds.
  • YOLO detector = LADA weights (HF ladaapp/lada). YOLO segmentation model, classes {0: mosaic_nsfw, 1: mosaic_sfw_head} → both map to CensorType.MOSAIC (_name_to_type matches "mosaic" in the class name). Detects mosaic only; black bars / blur stay with classic. Weights + Ultralytics are AGPL-3.0 (accepted). yolo.py lazy-imports torch/ultralytics.
  • No model weights in the repo. Code must fail with a clear, actionable message when the model path is missing — not a raw stack trace (factory._require_model, YoloDetector.__init__).
  • CUDA/torch install is environment-specific. Don't add torch to core deps; it stays out (the yolo extra pulls only Ultralytics) and is installed separately.
  • QImage from a numpy buffer must be .copy()d (see ImageView.set_image), otherwise it aliases a buffer that gets freed → garbage/crash.
  • Always use core/imageio.py (imread_unicode/imwrite_unicode) for images — cv2.imread/imwrite silently fail on non-ASCII Windows paths.
  • Don't reintroduce any generative / ControlNet dependency, nor the removed video playback pipeline (PyAV, worker threads, player). (The new core/project.py is an on-disk layout, not that thread-based "project model".) The one allowed video touch is core/video/extract.py (one-shot decode → a new project's frames/, behind "Создать из ролика…"): ffmpeg CLI — _find_ffmpeg() prefers PATH, else the binary bundled by the imageio-ffmpeg dep, else cv2 fallback. Keyframe-only -skip_frame nokey is ~10× faster than every-frame; -hwaccel does NOT help (GPU transfer overhead). Use ffmpeg/cv2, not PyAV, and keep it synchronous. Decoding every frame is the inherent cost — the speed lever is decoding fewer frames (keyframes).