35 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". 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. - 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, 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 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, 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 ("Расцензурить кадр" / "Расцензурить все"). 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], 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. -
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) and restoration is DeepMosaics-only. 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. If you re-add a different engine kind, implementcore/detection/base.Detector/core/restore/base.Restorerand register it in the respectivefactory. (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-only. 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. - 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).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).