40 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. Restoration has two engine families: DeepMosaics (default — reconstructs mosaic, locates it itself) and diffusion-inpaint (SwarmUI — regenerates the masked region; opt-in, see below). The noisy classic-CV detector (+ thecombinedcomposite) and the cv2inpaintbaseline (filled but didn't reconstruct) were removed as "works poorly". Detection is multi-model (ADetailer-style): drop YOLO weights undermodels/yolo/<category>/, tick which ones are active in the toolbar "Модели" menu, and a detect runs all ticked models and merges results (each tagged with its category → its own overlay colour). 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. - Diffusion-inpaint is now allowed (the earlier ban was lifted by the user). It is a
second restoration engine (
restorer="diffusion"), NOT a replacement for DeepMosaics and NOT the default. It regenerates the censored region with an external diffusion server (SwarmUI) over HTTP — it does not reconstruct the original, it draws plausible new content from a prompt + the YOLO mask. So it's best where DeepMosaics is helpless (black bars / solid fill), per-frame only (a video sequence flickers), and needs detections (the mask). The diffusion model runs in SwarmUI's process, so this path adds no torch dependency to the app. The backend is abstract (DiffusionBackend); SwarmUI is the first impl — ComfyUI/A1111 could be added later as another backend. (xinsir/controlnet-union-sdxl-1.0is still not used — it's a conditioning model, a poor fit; "diffusion-inpaint" here means a standard SD/SDXL inpaint via SwarmUI.) - 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, multi-model ensemble (models/yolo//*.pt) |
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 (reconstruct) OR diffusion-inpaint (regenerate)
│ ├── base.py # Restorer ABC: restore(image, dets, should_cancel) + restore_sequence (batch/temporal) + .temporal/.needs_detections flags; Cancelled exc
│ ├── factory.py # build_restorer(name, config) -> deepmosaics | deepmosaics_video | diffusion (lada = TODO); restorer_needs_detections(name)
│ ├── 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
│ ├── mask.py # detections_to_mask() — rasterise dets → uint8 mask (dilate/blur) for diffusion inpaint
│ ├── diffusion.py # DiffusionRestorer (needs_detections=True, per-frame) + DiffusionBackend ABC + InpaintParams
│ └── swarmui.py # SwarmUIBackend — HTTP to a SwarmUI server (stdlib urllib, no torch dep); GetNewSession + GenerateText2Image
└── detection/ # YOLO only, multi-model
├── base.py # Detector ABC: detect(frame) -> list[Detection]
├── factory.py # build_detector(config) -> MultiYoloDetector over config.detector_models
├── registry.py # discover_models()/category_of() — scans models/yolo/<category>/*.pt
├── multi.py # MultiYoloDetector — runs several YoloDetectors, concatenates results
├── types.py # Detection (type + score + bbox + polygon + label/category; .display); CensorType enum
├── cache.py # save/load the detection cache (detections.json); key = set of model basenames + conf/imgsz
└── yolo.py # YoloDetector — Ultralytics YOLO-seg; lazy torch/ultralytics; tags dets with a category label
How it works
-
Toolbar layout (
_build_toolbar). To avoid a long flat row spilling into Qt's "⋯" overflow, related actions are grouped intoQToolButtondropdowns (two helpers:_dropdown_button= InstantPopup menu-only;_split_button= MenuButtonPopup, click runs the primary action, the arrow opens related ones). Top-level groups: "Проект ▾" (создать/открыть/из ролика/импортировать) · "Детекторы: Модели (N) ▾" (the model picker, unchanged) · split "Рассчитать кадр ▾" (menu: детектировать все дозапуск/ заново) · split "Расцензурить кадр ▾" (menu: расцензурить все/найденное/заново, движок…, открыть папку результатов, «Сохранить результат…» =save_restored_action) · checkable "Показать расцензуренное" (toggle_restored_action, keyR, kept visible so the original⇄restored state shows as a pressed button) · "■ Стоп" (kept visible, reachable instantly) · a stretch spacer pushes "Порог:" to the right edge. The full action list also lives in the menu bar "Файл" (_build_menu). When adding an action, put it in the matching dropdown — don't add another flat top-level button. -
MainWindowholds the config, builds the detector lazily viabuild_detector(cached by the selected-model set + conf/imgsz in_make_detector), and keeps_results: dict[path -> list[Detection]]as the detection cache. -
Multi-model selection.
config.detector_modelsis the list of ticked YOLO weights (paths undermodels/yolo/<category>/). The toolbar "Модели"QToolButton/QMenu(_rebuild_models_menu, items grouped by category viaaddSection) toggles them (_on_model_toggled→ persist +_invalidate_results); "Добавить модель…" copies a.ptintomodels/yolo/<category>/. On project open_ensure_modelsprunes vanished paths and, if nothing is selected, default-ticks every discovered model.build_detectorbuilds oneYoloDetectorper selected model (taggedlabel=category) wrapped in aMultiYoloDetector. EachDetectioncarrieslabel(category) andmodel(the producing.ptstem, tagged inYoloDetector— shown as its own "Модель" column in the detail table since a category folder may hold several models); overlay colour + table group byDetection.display(label, else the CensorType) viaOverlayConfig.colors+ a stablepalettefallback. -
Cross-model NMS (optional). By default
MultiYoloDetectorjust concatenates all models' detections (different categories are meant to coexist). The "Модели" menu has a checkable "Объединять пересечения (NMS)" (config.cross_model_nms+nms_iou,_on_nms_toggled): when on,MultiYoloDetector(nms_iou=…)runs a greedy category-agnostic IoU NMS (multi._nms/_iou) that drops the lower-score box of any overlapping pair — kills the duplicate rects you get when overlapping models fire (e.g. penis + cockAndBall). It changes the detection result, so it's part of the in-memory detector identity (_make_detectorkey) and the on-disk cache key — butcache.make_keyaddsnms_iouonly when NMS is on, so the default (off) key is unchanged and an existing cache stays valid; turning NMS on yields a distinct key (recompute) without clobbering the non-NMS cache. Toggling also_invalidate_results(drops the in-memory cache). Off = concatenate. -
Per-model display thresholds (optional). The toolbar "Порог" spin is the global overlay threshold; the "Модели" menu "Пороги по моделям…" (
_edit_model_thresholds) stores per-model overrides inconfig.model_thresholds(keyed by.ptstem).ImageView(set_model_thresholds/_eff_threshold) draws a detection only if its score clears its model's override, else the global threshold. Display-only — does not change detection or the "с цензурой" counts (a frame is a hit if it has any detection, threshold-independent). -
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). Progress messages get an ETA suffix ("· осталось ~Xм Yс") from_eta_suffix(done, total)using_job_start(set in_start_job,time.monotonic) — average-rate estimate, blank at 0 %/100 %. -
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). Bothdetections.jsonandproject.jsonare written atomically (sibling.tmp+os.replace, seecache._atomic_write_textandProject.save) — a crash mid-write can't corrupt/truncate a large cache (29k entries) and lose all detection work.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 ("Расцензурить кадр" / "Расцензурить все"). The default DeepMosaics engine locates the mosaic itself — so for it restoration is decoupled from detection: no detector runs, detections are passed as
[]. (The diffusion engine is the exception — it masks the detections, so the UI feeds it_results; see the diffusion bullet above andrestorer.needs_detections.) Single-frame: a toolbar action reads the current frame + runsself._restorer(built viabuild_restorer) on a background job (_restore_current;donestores_restored[path], auto-saves it to the project'srestored/(so a single restore persists like the batch, not just in memory), and shows it). "Показать расцензуренное/оригинал" (_toggle_restored, keyR) is a GLOBAL view mode (_showing_restored): when on,_showdisplays each frame's restored version if one exists — loaded lazily from memory_restoredor diskrestored/via_restored_image_for(so the whole batch result is browsable, not just the last frame) — else falls back to the original; overlays are hidden on restored. The mode persists across navigation (reset to off on project open); a batch/single restore auto-switches it on._has_restored/_restored_disk_pathare the cheap (no-decode) existence checks driving the toggle/save enabled-state. "Сохранить результат" additionally exports<stem>_restored.jpgbeside the frame (an explicit one-off export, via_restored_image_for). "Открыть папку результатов" opensrestored/in Explorer. Batch ("Расцензурить все" / "Все заново" / "Расцензурить найденное",_restore_all(force, only_detected)) mirrors_detect_all: a single background job restores frames 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).only_detected("Расцензурить найденное") uses the YOLO detection cache to skip frames known clean: per-frame restores only the frames with detections; the temporal engine restricts the run to the contiguous span[first hit … last hit](recurrence needs continuity). It's a separate, faster action — NOT the default — because LADA misses some mosaic (esp. anime), so it can miss censorship YOLO didn't flag; "Расцензурить все" stays the thorough option. Returns early (status hint) if detection isn't computed or no frame has a detection. 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.feed_restored(configdm_feed_restored, default on, checkbox inRestoreDialog): when set, the past neighbours in the window (j<i) are taken from the engine's own already-restored outputs (a small rolling cache,reach=N*Sdeep) instead of the original mosaic frames — stronger temporal coherence. The centre (the frame being cleaned) and future neighbours (j>i, not yet restored) stay original. Slightly out-of-distribution for the BVDNet (trained on mosaic windows), so it's a toggle; off = faithful DeepMosaics.
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. Diffusion-inpaint engine (restorer="diffusion",core/restore/diffusion.py). A second engine family that regenerates the censored region instead of reconstructing it.DiffusionRestorer(needs_detections=True, per-frame): builds an inpaint mask from the frame's YOLO detections (mask.detections_to_mask, withdiff_mask_dilate/diff_mask_blur) and hands(image, mask, InpaintParams)to a pluggableDiffusionBackend. First backend isSwarmUIBackend(swarmui.py): stdlib-urllibHTTP to a running SwarmUI server (GetNewSession→GenerateText2Imagewith base64 init+mask images,diff_prompt/diff_negative/diff_steps/diff_cfg/diff_denoise/diff_seed/diff_model) → decode the returned image. The diffusion model runs in SwarmUI's process, so no torch dep is added here. Because it needs a mask, the UI feeds it real detections (_restore_currentcaptures_results[path];_restore_allbuildsdets_by_indexfor the hit frames) and gates it like "Расцензурить найденное" (requires detection computed + at least one hit) viarestorer_needs_detections. Frames with no detection come back unchanged. Per-frame only → flickers on video; best for black bars / solid fill where DeepMosaics can't help. Config fieldsdiff_*persist inproject.json+settings.json; engine chosen inRestoreDialog(its diffusion field group shows when the engine is selected). The dialog has a "Проверить соединение" button (RestoreDialog._test_connection→SwarmUIBackend.ping(), a freshGetNewSessionwith a short 15s timeout) that reports ✓/✗ inline — lets the user verify SwarmUI is reachable without running a restore. -
Navigation bar under the image (
_build_nav_bar): prev/next frame (◀ ▶, keys,/.), a scrubberframe_slideracross the whole sequence, a clickablepos_label(a flatQPushButton"row / n" →_jump_to_frame, a "go to frame N"QInputDialog— needed on 29k-frame projects where the scrubber is ~50 frames/px), 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._step(◀ ▶) skips rows hidden by the filter. -
File-list filter (
filter_combo, left pane). A combobox above the list shows only a subset of the (possibly huge) frame list: Все · С цензурой · Чистые · Не рассчитано · Расцензуренные · Без расцензуривания._filter_mode+_row_matches_filter(path)(over_results/_row_restored);_on_filter_changedre-labels (each_relabel_rowcallsitem.setHidden(...)) and jumps off a now-hidden current row. Pure show/hide — doesn't touch_files/cache. The scrubber is a customMarkerSlider(ui/marker_slider.py) that paints two mark layers: cyan ticks (upper half) at frames with detections (_refresh_marksprojects_results) and green ticks (lower half) at restored frames (_refresh_restored_marksscansrestored/+ in-memory_restored; called on load and after each restore op, not per-frame); per-pixel deduped so big folders stay cheap. Under the scrubber a progress summarystats_labelreads "Кадров: N · детектировано: D/N (с цензурой: H) · расцензурено: R/N" (_update_counts_label, cheap counts;_restored_countcached by_refresh_restored_marks). File-list rows are labelled too via a single_relabel_row(used by_tag_file/_relabel_all): tint red = censorship found, green = checked & clean; a trailing ✓ marks frames with a restored version (_row_restored, populated by_refresh_restored_marks). The ✓ is independent of detection — it survives_clear_results. Detection tints 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 (multi-model); restoration is DeepMosaics (default) or
diffusion-inpaint (SwarmUI). New detection kinds plug in by adding more
.ptundermodels/yolo/<category>/— no code change. The toolbar shows a "Модели" menu of checkable models (no detector dropdown); restoration engines are chosen inRestoreDialog. A new restoration engine kind = implementcore/restore/base.Restorerand register it inrestore/factory.build_restorer; a new diffusion backend = implementcore/restore/diffusion.DiffusionBackend(keep it out-of-process — no torch dep in the app). If you re-add a different detector kind, implementcore/detection/base.Detectorand register it in itsfactory. (No legacy-settings migration is kept while in active development — oldsettings.json/project.jsonkeys are simply ignored, not coerced.)
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, but there's a headless smoke test: scripts/smoke_test.py
(run with QT_QPA_PLATFORM=offscreen + PYTHONIOENCODING=utf-8) covers the pure core
(atomic cache round-trip, cross-model NMS, list-filter predicate, ETA formatting,
extract-dialog options, per-project settings round-trip) and an offscreen MainWindow
build on a throwaway project — no torch/weights (detections are injected into _results).
Exits non-zero on failure; run it after touching core/UI plumbing. Ad-hoc check: 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
- Models live under
models/yolo/<category>/. Discovery (detection/registry.py) scans that tree; the category folder is the detection label + overlay colour (e.g.models/yolo/mosaic/lada.pt→ "mosaic",models/yolo/face/…→ "face"). On open_ensure_modelsdefault-ticks all discovered models if the project has no selection. Put a censorship model inmosaic/(LADAlada_mosaic_detection_model_v4_accurate.pt); a generic COCO model (e.g.yolo11n-seg.pt) would detect people/objects → noise. Selecting many models multiplies per-frame time (each runs in turn). - 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 (default) or diffusion-inpaint. (The removed cv2inpaintwas a classic fill; the new diffusion-inpaint is a different thing — a real generative SD/SDXL inpaint via SwarmUI.) No backward-compat shims while in active development — stale keys in oldsettings.json/project.jsonare just ignored (a project with no valid model selection default-ticks all discovered models). - 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. - Diffusion-inpaint runs out-of-process (SwarmUI), so
ui/and the diffusion path add no torch/diffusers dependency —swarmui.pyuses only stdliburllib. Keep it that way: the diffusion model lives in the SwarmUI server, we just POST image+mask+prompt. Don't adddiffusers/in-process SD to the app. The diffusion engine needs detections (it masks them) — the UI feeds them viadets_by_index/ captured_resultsand gates it like "Расцензурить найденное" (restorer_needs_detections+Restorer.needs_detections); DeepMosaics still gets[](it self-locates). A new diffusion backend = anotherDiffusionBackendimpl, not new app deps. - Don't reintroduce the removed video playback pipeline (PyAV, producer/consumer worker
threads, player, project session). (Generative/diffusion inpaint via an external server
IS now allowed — see the diffusion engine; the old blanket "no generative" ban is lifted.)
(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).ExtractDialog.options()returns(keyframes_only, step, max_dim, jpg_quality); jpg_quality (1–100, default 92, via-q:v_quality_to_qscale/ cv2IMWRITE_JPEG_QUALITY) trades quality for a bit of encode speed + smaller files. Default sampling is every frame (step=1, not keyframes).