232 lines
14 KiB
Markdown
232 lines
14 KiB
Markdown
# 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) -> inpaint (deepmosaics/lada = not wired yet)
|
||
│ ├── inpaint.py # InpaintRestorer (cv2) — baseline, fills not reconstructs
|
||
│ └── 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
|
||
`<stem>_restored.jpg` into the active collection (or beside the frame). The baseline
|
||
is cv2 inpaint — honest: it fills, doesn't reconstruct. To add a real engine,
|
||
implement `core/restore/base.Restorer`, register it in `restore/factory.build_restorer`,
|
||
and swap `self._restorer`. `_show` resets `_showing_restored` + `_update_restore_actions`.
|
||
- **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).
|