# 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 folder of images; it runs each through a detector, draws the regions, and shows a detailed per-image list of what it found. > **Scope was deliberately narrowed (this session).** It used to extract frames from > video, detect, and play back with overlays (a "project model" with worker threads). > That whole video pipeline was **removed** — the tool is now a simple **image-folder > inspector** for viewing/debugging detector output on test images. Pre-extract video > to frames externally if you need that. 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. The shipped engine is a cv2 **inpaint baseline** (fills, does NOT truly reconstruct); a generative engine (DeepMosaics / LADA BasicVSR++) is the intended real engine but needs weights + a CUDA GPU and is not wired yet. - 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); detection and restoration operate on image folders. ## 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, no project/cache files. The only video touch is a one-shot > "Создать из ролика…" that decodes a clip to a folder of JPGs via the **ffmpeg CLI** > (cv2.VideoCapture fallback; NOT PyAV); detection still works on image folders only. > 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 folder arg, --detector, --model) ├── app.py # QApplication bootstrap; run(config, folder=None) ├── config.py # AppConfig + DetectionConfig/OverlayConfig (thresholds live here) ├── settings_store.py # persist detector/model/threshold/last_dir 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) ├── 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) -> image │ ├── factory.py # build_restorer(name, config) -> inpaint | deepmosaics (lada = TODO) │ ├── inpaint.py # InpaintRestorer (cv2) — baseline, fills not reconstructs │ ├── deepmosaics.py # DeepMosaicsRestorer — shells out to user-installed DeepMosaics │ └── 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 ├── 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. - "Открыть папку" lists image files (`_IMAGE_EXTS`) with a progress bar (bulk insert with `setUpdatesEnabled(False)` + periodic `processEvents`), so a big folder 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`). - **Collections (curation).** A combo in the left pane (next to the file list, since it acts on the list selection — not on the toolbar, to keep that uncluttered) (`collection_combo`) picks the active destination; `_refresh_collections()` repopulates it from sibling folders of the opened folder (`_collections_base()` = the opened folder's parent, else `~/HVideoTool/collections`) on load/create, so previously-made collections are reselectable. Items carry the path in itemData; "— не выбрана —" = None, "Выбрать папку…" = `"__browse__"` sentinel → `_browse_collection()` for an arbitrary folder (kept in the combo even if outside base). "Создать…" makes a new one and selects it. The file list is `ExtendedSelection`; "В коллекцию" / Ctrl+M **moves** (`shutil.move`, not copy) the selected frames there, removing them from list/`_files`/cache. `_unique_dest` avoids clobbering (`foo.jpg` → `foo (1).jpg`). Use case: sort 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 `_restored.jpg` into the active collection (or beside the frame). The baseline is cv2 inpaint; the real engine is **DeepMosaics** (`restore/deepmosaics.py`), which shells out to a user-installed DeepMosaics repo (`deepmosaic.py --mode clean`, stdin closed so its error `input()` can't hang; reads the newest image from a temp result_dir). The engine + paths are set in `RestoreDialog` (Файл → Движок восстановления…), persisted, and built lazily/cached in `_make_restorer` (like `_make_detector`). NOTE: DeepMosaics locates mosaics itself (its `mosaic_position.pth`) — 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`. - `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 ```powershell python -m venv .venv; .\.venv\Scripts\Activate.ps1 pip install -e . # classic detector needs no torch/CUDA python -m hvideotool # open a folder in-app python -m hvideotool "C:\path\to\images" --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`, `open_path(folder)`, 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 `.gitignore`d. - 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`](https://huggingface.co/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 *pipeline* (PyAV, project/cache, worker threads, playback). The one allowed video touch is `core/video/extract.py` (one-shot decode → JPG folder, 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).