26 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): on-demand (single frame or whole-project
batch into
restored/), behind aRestorerinterface. Detection is YOLO-only and restoration is DeepMosaics-only — the noisy classic-CV detector (+ thecombinedcomposite) and the cv2inpaintbaseline (filled but didn't reconstruct) were removed as "works poorly". DeepMosaics has two engines: image (per-frame) and video (BVDNet, temporal — uses neighbour frames). Its GPL-3.0 network code is 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, for the YOLO detector and the DeepMosaics restorer. CPU fallback works but is slow (esp. DeepMosaics / the temporal BVDNet).
Tech stack (decided)
| Concern | Choice |
|---|---|
| GUI | PySide6 (Qt 6) — LGPL |
| Image IO | OpenCV (opencv-python) + NumPy, unicode-safe via core/imageio.py |
| Detector | Ultralytics YOLO (LADA weights) behind a pluggable interface (YOLO-only) |
Torch/CUDA + Ultralytics enter with the YOLO detector. Keep that dependency optional
(the yolo extra in pyproject.toml pulls only Ultralytics; torch is installed
separately per the README). Both detection (YOLO) and restoration (DeepMosaics) now
require torch — there's no longer a torch-free detector.
Architecture (as implemented)
This reflects the actual code on disk. The GUI is mostly synchronous, but the heavy compute (detection + restoration) runs on a background thread so the UI stays responsive — see
ui/workers.pyand the "Background jobs" bullet (this reverses the earlier "no worker threads" rule;processEventscan't unfreeze a single multi- seconddetector.detect()/DeepMosaics call). 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). 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]
│ ├── workers.py # Job (QRunnable): runs detect/restore off-thread, results via Qt signals
│ └── image_view.py # renders an image + draws polygon/bbox overlays (QPainter); can highlight one
└── core/
├── imageio.py # unicode-safe imread/imwrite (np.fromfile + imdecode)
├── torch_info.py # probe torch/CUDA (gather/reason/install_hint) for the device badge; no Qt
├── 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" (DeepMosaics only; per-frame OR temporal)
│ ├── base.py # Restorer ABC: restore(image, dets, should_cancel) + restore_sequence (batch/temporal) + .temporal flag; Cancelled exc
│ ├── factory.py # build_restorer(name, config) -> deepmosaics | deepmosaics_video (lada = TODO)
│ ├── deepmosaics.py # DeepMosaicsRestorer (image, per-frame) + DeepMosaicsVideoRestorer (BVDNet, temporal); in-process, load once; uses _deepmosaics/
│ └── _deepmosaics/ # VENDORED DeepMosaics models/+util/ (GPL-3.0) — added to sys.path at import
└── detection/ # YOLO only
├── base.py # Detector ABC: detect(frame) -> list[Detection]
├── factory.py # build_detector(config) -> yolo (the only kind)
├── types.py # Detection (+ to_dict/from_dict), CensorType enum
├── cache.py # save/load the project detection cache (detections.json): cache_file + base_dir args
└── yolo.py # YoloDetector — Ultralytics YOLO-seg; lazy-imports torch/ultralytics
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. -
Background jobs (
ui/workers.py). Detection and restoration are CPU-heavy and would freeze the GUI, so they run on aQThreadPoolthread viaJob(aQRunnablewrappingfn(job)); results return to the GUI through queued Qt signals (tick/progress/done/failed).MainWindow._start_job(fn, total, on_tick, on_done)starts one (only one at a time —_busyguards entry points),_finish_job/_on_job_failedend it._make_detector/_make_restorer, image reads, andengine.detect/restoreall run inside the worker (_computeis the pure read+detect helper); thefnmust touch NO Qt widgets — it emits plain data that the GUI-thread slots (_apply_detection, restoretick) apply._begin_busydisablesmodel_actionfor the duration (it'd race the running detector). This is the deliberate exception to the old single-threaded rule (CPU YOLO/DeepMosaics per-call latency can't be hidden withprocessEvents). -
Device badge + CUDA diagnostics. A clickable status-bar chip (
device_badge) shows "⚡ CUDA" (green) or "🖥 CPU" (orange)._probe_devicerunscore/torch_info.gather()in a backgroundJobat startup (it imports torch AND shells out tonvidia-smi, so it's off the GUI thread) →_set_device_badge.gather()collects torch facts (version, built_cuda, cuda_available, device_name) and NVIDIA facts (gpus, driver_version, max cuda_driver). Clicking (_show_device_info) opens a diagnostic QDialog (not QMessageBox — its text wasn't copyable): a read-only monospaceQPlainTextEditwithtorch_info.analyze(info)→{summary, details, steps, command}— a verdict on why it's on CPU (CPU-only+cpubuild / no GPU / driver-too-old-for-built-CUDA) and the exact pip fix. Buttons: "Скопировать команду установки" (_copy_install_command→ the recommended cu121/cu118 command, picked byrecommend_channelfrom the driver's CUDA) and "Проверить заново" (re-runs_probe_device).core/torch_info.pyis pure (no Qt); subprocess usesCREATE_NO_WINDOWon Windows. -
Projects (
core/project.py).MainWindow._projectis the openProject; its frames come fromproject.frames_dir(navigation/cache/tags work off_files). 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 the model clears the cache (_choose_model→_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 ("Расцензурить кадр" / "Расцензурить все"). DeepMosaics-only, and it locates the mosaic itself — so restoration is fully decoupled from detection: no detector runs in either path (detections are passed as
[]). Single-frame: a toolbar action reads the current frame + runsself._restorer(built viabuild_restorer) on a background job (_restore_current;donestores_restored[path]+ shows it). "Показать оригинал/результат" toggles (_showing_restored); "Сохранить результат" writes<stem>_restored.jpgbeside the frame. Batch ("Расцензурить все" / "Все заново",_restore_all(force)) mirrors_detect_all: a single background job restores every frame and writes results to the project'srestored/dir (Project.restored_dir, basename-mirrored, kept OUT offrames/so outputs aren't re-listed/re-restored); the per-frame engine skips frames already inrestored/unlessforce(resume). The engines are DeepMosaics (restore/deepmosaics.py), run in-process from the vendored_deepmosaics/code, loading the BiSeNet locator + generator once (lazy, cached on the instance):deepmosaics(image, per-frame): reproducescleanmosaic_img_server(locate mosaic → run generator on the crop → feather back), ~0.3 s/frame cached on CPU. Image modelclean_youknow_resnet_9blocks.pth.deepmosaics_video(temporal, BVDNet):DeepMosaicsVideoRestorer,.temporal=True. Reproducescleanmosaic_video_fusion— per target frame it feeds the net a window ofT=5neighbour frames sampled at stepS=3around it (N=2each side, clamped at the sequence edges) plus its own previous output (recurrent), for temporal coherence. Because of that recurrence it must run a contiguous, ordered range — it implementsrestore_sequence(count, get_frame, get_dets, emit, should_cancel)(the batch run uses it; single-framerestoredegrades to a window of the same frame). Needs the video weightsclean_youknow_video.pth(+mosaic_position.pthbeside).INPUT_SIZE=256.
restore_sequenceis on theRestorerABC (default = independent per-frame loop);_restore_alldispatches onrestorer.temporal(temporal →restore_sequenceover the whole range; per-frame → resumable loop with skip-existing).should_cancel(=lambda: job.cancelled) is polled so "■ Стоп" stops it; engines raiseCancelled, whichJob.runreports as a clean cancel. Engine + weights are set inRestoreDialog(Файл → Движок восстановления…) — the model dropdown shows image vs video weights per selected engine — persisted, and built lazily/cached in_make_restorer. NOTE: DeepMosaics locates mosaics itself (itsmosaic_position.pth) — our detections aren't passed to it. To add another engine (e.g. LADA), implementcore/restore/base.Restorer(set.temporal+ overriderestore_sequenceif it needs neighbours) and 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). A single "■ Стоп" toolbar action (Esc) cancels the running op.
_begin_busy(total)/_end_busy()toggleself._busy+ the Stop button + the progress bar (total=None→ indeterminate). For background jobs (detection, restore)_request_cancelcallsself._job.cancel(); the worker loop checksjob.cancelledbetween frames and restore polls it viashould_cancel. The still- synchronous loops (_load_folderlisting, import copy, video extraction — itsprogresscb returnsnot self._cancel) checkself._cancelbetweenprocessEventsticks. Entry points guard withif self._busy: return(notably_move_to_favorites, which mutates_filesthat a detect-all job reads — so a snapshot/pending list is used).closeEventcancels a running job andwaitForDone(3000)before tearing down. -
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.- Detection is YOLO-only and restoration is DeepMosaics-only. If you re-add an engine
kind, implement
core/detection/base.Detector/core/restore/base.Restorer, register the string in the respectivefactory, and add it toDETECTORS/RESTORERSinconfig.py(andnormalize_config). There's no detector dropdown anymore — the toolbar just shows "Детектор: YOLO"; restoration engines are chosen inRestoreDialog.
Commands
python -m venv .venv; .\.venv\Scripts\Activate.ps1
pip install -e ".[yolo]" # YOLO needs ultralytics; install torch separately (README)
python -m hvideotool # reopen the last project (or create/open one in-app)
python -m hvideotool "C:\path\to\MyProject" --model models\lada_mosaic_detection_model_v4_accurate.pt
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. A model is auto-picked on project open viaMainWindow._ensure_model()→_auto_find_model(): it scans./models/**.ptand matches only filenames containinglada/mosaic(so it skips the COCOyolo11n-seg.pttrap), no prompt; otherwise the user picks via "Модель…" (_choose_model). - classic-CV / inpaint were removed (worked poorly). The classic-CV detector was
noisy/approximate on real footage (false positives on skin/hair/fabric/JPEG; missed
real mosaic after downscale) and the
combinedmode + cv2inpaintbaseline went with it. Detection is YOLO-only, restoration is DeepMosaics-only.normalize_configcoerces any leftoverclassic/combined/inpaintin old settings/projects toyolo/deepmosaics. - 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/). - 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. 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. - CPU-only torch must not request CUDA. A
+cputorch build raises "Torch not compiled with CUDA enabled" the moment something calls.cuda(). Both engines guard for this:YoloDetectorpickscudaonly whentorch.cuda.is_available()(even an explicityolo_device="cuda"is downgraded to cpu); bothDeepMosaicsRestorer._ensure_loadedandDeepMosaicsVideoRestorer._ensure_loadedforcegpu_id="-1"when CUDA is absent (the vendoredmodel_util.todevice/data.im2tensor/to_tensorcall.cuda()for anygpu_id != "-1", e.g. thedm_gpu="0"default). So a wrong/CPU-only torch falls back to CPU instead of crashing (the temporal BVDNet engine is heavy on CPU, though). - 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, producer/consumer worker threads, player, project session).
(The new
core/project.pyis an on-disk layout, not that thread-based "project model".) NOTE: a single backgroundJobthread for detect/restore (ui/workers.py) IS in scope now (keeps the GUI responsive) — that's different from the rejected multi-thread video pipeline. 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).