20 KiB
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:
- At session start — load context. Call
memory_context(project is auto-detected from cwd) before doing any work. Usememory_searchfor specific topics (e.g. "detector model", "classic-cv", "false positives"). - During work — search before re-deciding. When the task touches an area that
may have prior context,
memory_searchit first instead of re-deriving decisions. - Before ending a session — save what matters. Call
memory_savewhen 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 rightcategory(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). Seecore/project.py. The per-project settings (detector, model, threshold, restore engine) live inproject.json; the globalsettings.jsononly seeds the defaults for new projects. The old "open a bare folder" flow is now "Импортировать папку как проект…" (copies images into a new project'sframes/).
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
Restorerinterface. Two engines: a cv2 inpaint baseline (fills, does NOT reconstruct) and DeepMosaics — real generative mosaic removal, its GPL-3.0 network code vendored undercore/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.0was 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
classicdetector 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 holdsproject.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), andcollections/Избранное(the favorites collection). The only video touch is a one-shot "Создать из ролика…" that creates a new project and decodes a clip into itsframes/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
MainWindowholds the config, builds the detector lazily viabuild_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._projectis the openProject;_folderis kept as a synonym forproject.frames_dirso 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'sframes/, carries over an old.hvideotool_detections.jsonsidecar if present), and a "Недавние проекты" submenu._open_project(project)is the core open: itapply_to_configs the project's settings, syncs the toolbar widgets without signal loops (_sync_settings_ui), titles the window, records last/recent, and listsframes/. On startupapp.runopens the CLItargetor auto-reopenssettings_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. Globalsettings.jsonis now only defaults + last/recent projects. - Listing image files (
_IMAGE_EXTS) fromframes/uses a progress bar (bulk insert withsetUpdatesEnabled(False)+ periodicprocessEvents), 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).
_resultsis mirrored to the project'sdetections.json(core/detection/cache.py, atproject.cache_path; keyed by basename so it survives moving the project).cache.save_results/load_resultstake the cache file and the imagebase_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_resultsreloads it only on a key match (else ignored, not shown as current). Saved (_save_results, skipped when_resultsis empty so it never clobbers a good cache with nothing) after detect-all (incl. cancel → partial), single detect/recompute, move-to-collection, and oncloseEvent. "Детектировать все" 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 _resultsdistinguishes 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_favoritesmoves (shutil.move, not copy) theExtendedSelection-selected frames intoproject.favorites_dir(collections/Избранное,FAVORITES_DIRincore/project.py, created lazily on first move), removing them from list/_files/cache._unique_destavoids clobbering (foo.jpg→foo (1).jpg). Use case: flag good frames into a training/example set while inspecting detections. - Restoration ("Расцензурить кадр"). Toolbar action runs
self._restorer(built viabuild_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.jpgbeside 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 theircleanmosaic_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 modelclean_youknow_resnet_9blocks.pth— the video model (BVDNet) is rejected per frame (needs a neighbour).should_cancelis polled at entry (raisesCancelled). The engine + weights are set inRestoreDialog(Файл → Движок восстановления…), persisted, and built lazily/cached in_make_restorer(like_make_detector). NOTE: DeepMosaics locates mosaics itself (itsmosaic_position.pth, expected beside the clean weights) — our detections aren't passed to it._showresets_showing_restored_update_restore_actions. To add another engine (e.g. LADA), implementcore/restore/base.Restorerand register it inrestore/factory.build_restorer.
- Navigation bar under the image (
_build_nav_bar): prev/next frame (◀ ▶, keys,/.), a scrubberframe_slideracross the whole sequence, apos_label("row / n"), and jump-to-detection (◀ детекция / детекция ▶, keys[/],_step_hitscans_resultsfor the next non-empty frame). The slider and file list are kept in sync via_update_navguarded by_nav_sync(avoids signal loops); all navigation ultimately drivesfile_list.setCurrentRow. The scrubber is a customMarkerSlider(ui/marker_slider.py) that paints cyan ticks at frames with detections (_refresh_marksprojects_resultsonto 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()toggleself._busy- the Stop button + the progress bar (
total=None→ indeterminate);_request_cancelsetsself._cancel; the loop-based ops (_detect_all,_load_folder) and video extraction (itsprogresscb returnsnot self._cancel) check the flag betweenprocessEventsticks. Single-image restore passesshould_cancel=self._poll_cancel(which pumpsprocessEventsthen returns the flag) intoRestorer.restore; only DeepMosaics actually polls it (kills its subprocess + raisesCancelled) — cv2 ops are instant. Entry points guard withif self._busy: return(notably_move_to_collection, which mutates_filesthat_detect_alliterates). This keeps the synchronous, single-threaded model — do NOT reintroduce worker threads for cancellation.
- the Stop button + the progress bar (
image_view.ImageViewdraws the image scaled-to-fit plus overlays. Overlay visibility/threshold are applied at paint time. Selecting a row in the detail table callsset_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 importtorch/ultralyticsdirectly. It builds detectors only viacore/detection/factory.build_detectorand talks tocore/through theDetectorinterface and theDetection/CensorTypetypes.- New detector kinds: implement
core/detection/base.Detector, register the string incore/detection/factory.build_detector, and add it to_DETECTORSinui/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
Detectorinterface. - Model weights (
.pt) and large media are not committed — keep them inmodels/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). Ifmodel_pathpoints at a generic COCO model (e.g. theyolo11n-seg.ptin the repo root, which Ultralytics auto-downloads / is the training base), it detects people/objects and maps them toCensorType.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=720downscale softens block edges (measured: contrast/grad fall belowmosaic_contrast_min/mosaic_grad_min). For real-video mosaic useyolo/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 toCensorType.MOSAIC(_name_to_typematches "mosaic" in the class name). Detects mosaic only; black bars / blur stay with classic. Weights + Ultralytics are AGPL-3.0 (accepted).yolo.pylazy-importstorch/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
yoloextra pulls only Ultralytics) and is installed separately. - QImage from a numpy buffer must be
.copy()d (seeImageView.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/imwritesilently 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.pyis an on-disk layout, not that thread-based "project model".) The one allowed video touch iscore/video/extract.py(one-shot decode → a new project'sframes/, behind "Создать из ролика…"): ffmpeg CLI —_find_ffmpeg()prefers PATH, else the binary bundled by theimageio-ffmpegdep, else cv2 fallback. Keyframe-only-skip_frame nokeyis ~10× faster than every-frame;-hwacceldoes 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).