Verified 0.13.0 pill removal on a 32k real-upload corpus. The metadata-OR-wordmark gate was only ~1/3 precise: TC260 metadata confirms Jimeng-class provenance, not pill presence, so the weak edge-NCC detector's false fires (textured ceilings/walls, where inpaint visibly smears) were admitted whenever metadata was present. Split into two arms (_keep_pill): the reliable bottom-right wordmark (~94% precise, survives metadata stripping) removes the pill unrestricted; the metadata-only arm removes it ONLY when the top-left footprint is flat enough for an invisible inpaint (PillEngine.footprint_is_flat, median-Sobel <= _FLAT_TEXTURE_MAX). Keeps real flat-scene pills and harmless flat false fires; leaves the damaging textured false fires untouched. Corpus: 270 -> 118 removals, ~90 true preserved, damaging FP -> ~0. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
38 KiB
Remove-AI-Watermarks
You are a principal Python engineer maintaining a CLI tool and library for removing visible and invisible AI watermarks from images.
Scope and non-goals
The mission is removing AI-provenance watermarks that a platform stamps onto content the user generated themselves — SynthID, the Gemini / Nano Banana sparkle, the Doubao / Jimeng / Samsung visible AI labels, the Chinese TC260 "由…AI生成" label, and C2PA / IPTC / EXIF "Made with AI" metadata. The point is user autonomy over their own generated output.
It deliberately does not remove watermarks that protect someone else's paid or copyrighted content — stock-agency overlays (Shutterstock, Getty, iStock, Adobe Stock), classifieds-site marks, or any tiled / diagonal "preview" watermark whose job is to gate a purchase. Stripping those makes a paid resource free off someone else's work; out of scope by principle, not by technical difficulty. The line: a visible mark is in scope when it labels the user's own AI generation, and out of scope when it protects a third party's paid asset.
Consequences for contributors (do not drift back into the stock niche just because it is technically feasible):
- Do not add stock / agency / classifieds watermark removal to
watermark_registry.pyor the eraser, and do not build tiled-overlay or multi-image watermark-estimation features aimed at them. erase --regionstays a generic user-driven tool (the user points at their own object); do not ship an automatic stock-watermark detector/remover on top of it.- New visible-mark templates are for AI-generation labels only.
(Established 2026-06-13 by user instruction: "Я пытаюсь сделать платные ресурсы бесплатными — это не то, против чего мы боремся.")
How to run
Per-command exit-code semantics (the no-signal / GPU-missing skip branches), test traps, and regression-guard paths live in docs/module-internals.md (section "CLI commands (cli.py)") — read it before changing any command's skip/exit behavior.
uv run remove-ai-watermarks all <image.png> -o <output.png>— full pipeline (visible + invisible + metadata). Same diffusion knobs asinvisible, plus the visible-pass--inpaint/--no-inpaint/--inpaint-method. Skips step 2 (invisible/SynthID) when the[gpu]extra is absent or no invisible signal is detectable; see the module doc for the distinct exit codes.uv run remove-ai-watermarks invisible <image.png> -o <out.png>— diffusion SynthID removal. Full knob set (kept identical acrossinvisible/all/batch):--strength(vendor-adaptive default),--steps,--guidance-scale(CFG, default 7.5),--pipeline sdxl|controlnet|qwen(defaultcontrolnet;qwenis a manual opt-in only — see the qwen note in the module map),--controlnet-scale,--model(HF model id, default SDXL base),--device,--seed,--hf-token,--max-resolution/--min-resolution,--upscaler lanczos|esrgan,--humanize(Analog Humanizer grain),--unsharp(final sharpen),--adaptive-polish/--no-adaptive-polish(ON by default),--tile/--no-tile+--tile-size/--tile-overlap(OFF by default),--force/--no-force(default skip = ON, runs the scrub even with no detected signal).--autois deprecated and a no-op that only warns. Skips the diffusion when no invisible signal is detectable (the no-signal gate); see the module doc.uv run remove-ai-watermarks visible <image.png> -o <out.png>— known-visible-mark removal.--method auto(default) uses reverse-alpha for the capture marks (recovers exact pixels, CPU, no GPU -- measured cleaner + lighter than inpaint on them) and inpaint only for the capture-less pill;--method reverse-alpha|inpaintforces one (inpaint uses MI-GAN with themiganextra, else cv2).--mark auto(default) removes EVERY detected mark in one pass (a Jimeng-basic image carries the top-left "AI生成" pill AND the bottom-right "★ 即梦AI" wordmark) from: Gemini sparkle, Doubao "豆包AI生成", Jimeng "★ 即梦AI", Samsung Galaxy AI "✦ Contenuti generati dall'AI", and the capture-less Jimeng "AI生成" pill (top-left, metadata-gated);--mark gemini|doubao|jimeng|samsung|jimeng_pillforces one. For arbitrary logos/objects useerase. When no known mark is detected the command writes no output and exits with the no-visible-mark code instead of re-serving the input;--no-detectforces the gemini fallback and proceeds. See the module doc for the routing/exit detail.--methodis shared acrossvisible/all/batch.uv run remove-ai-watermarks erase <image.png> --region x,y,w,h -o <out.png>— universal region eraser (any logo/object, any position).--backend cv2(default, no deps),--backend migan(MI-GAN via onnxruntime, extramigan; ~28 MB, ~1 GB RAM, near-LaMa), or--backend lama(big-LaMa, extralama; best quality but ~4.7 GB RAM);--regionis repeatable.uv run remove-ai-watermarks identify <image>— provenance verdict (platform + watermark inventory + confidence);--jsonfor machine output,--no-visibleto skip the cv2 sparkle detectoruv run remove-ai-watermarks metadata <image.png> --check— inspect AI metadata (C2PA, EXIF, PNG chunks)uv run remove-ai-watermarks metadata <image.png> --remove -o <out.png>— strip all AI metadatauv run remove-ai-watermarks batch <directory>— process every supported image in a directory (output defaults to<directory>_clean/, set with-o).--mode visible|invisible|metadata|all(defaultvisible); the invisible/all path reuses the fullinvisibleknob set above, plus--inpaint/--no-inpaintfor the visible pass. Applies the same no-signal skip per image; see the module doc.
Test and lint
- CI (
.github/workflows/test.yml): runs on push tomain+ every PR. Alintjob (ubuntu:ruff check+ruff format --check) plus atestmatrix (ubuntu/macos/windows x py3.10/3.12) that doesuv sync --frozen --extra devthenpytest. The matrix installs only core + dev (nogpuextra), so the GPU/model-running tests skip there and it exercises the metadata/identify/visible/cv2-eraser surface on all three OSes. Keepuv.lockvalid (don't break--frozen) when editingpyproject.toml. - Release flow + distribution channels (PyPI publish via
publish.yml/uv publish, the automated Homebrew-tap + HF-Space bumps indistribute.yml, conda-forge, ComfyUI Registry, the sdistdata/exclusion, hatchling pin history): seedocs/release-and-distribution.mdbefore cutting a release. bash maintain.sh— uv-outdated, uv-secure, ruff check/fix, ruff format, pyright (scopedsrc/, see the OOM note below), pytest -n auto. The helper tools live in thedevextra (pytest-xdist, plusuv-outdated/uv-securemarker-gated to py3.12+ so the py3.10 resolution stays solvable) — a bare env without--extra devdoes not have them.- Strict pyright is clean across
src/(0 errors). The cv2/torch/diffusers boundary files (gemini_engine,region_eraser,doubao_engine,humanizer,invisible_engine,noai/watermark_remover) carry a documented per-file# pyright:relax pragma that turns off only the unknown-type / untyped-third-party rules — those libs ship no usable types, so strict typing there fights the ecosystem. Pure-logic files stay fully strict;typings/piexif/__init__.pyiis a local stub sometadata.py/extractor.pyresolve piexif. Public ndarray-returning signatures on the relaxed engines are still annotatedNDArray[Any]so strict consumers (cli.py) stay clean. When touching a relaxed file, prefer fixing real issues over widening the pragma; keep the pragma scoped to genuinely-untyped boundaries. Theuv-secureCVE-resolution history (idna/aiohttp bumps, retired basicsr, the dismissed torchGHSA-rrmf-rvhw-rf47) lives indocs/release-and-distribution.md— read it before re-triaging a dependency alert. - Full-project
uv run pyright(no path) OOMs/crashes node on this ML-heavy repo (emits alibnodestack frame, no summary) — a known environment limit, not a code error. Gate withuv run --extra dev --extra gpu pyright src/(completes, authoritative) or scope to changed files; also runuv run ruff checkanduv run pytestdirectly. - Run
uv runfrom the repo root — from another cwd it falls back to a bare env without numpy/cv2/torch. - Stale
trustmarkremnant in site-packages after an extras change: thetrustmarkpackage downloads model weights INTO its own package dir, so when a narroweruv syncprunes the package, atrustmark/models/directory survives as an empty namespace package. Symptom: pyright"TrustMark" is unknown import symbolontrustmark_detector.pyandfind_spec("trustmark")returning a loader-less spec (sois_available()lies True). Fix:rm -rf .venv/lib/python3.12/site-packages/trustmark(regenerable weights cache). - To add a dev tool (pytest/ruff/pyright) into the env, use
uv sync --frozen --extra dev --extra gpu, neveruv pip install—uv pip installre-resolves and rewritesuv.lock, which silently bumpedtransformersto a build incompatible with the pinneddiffusers(cannot import name 'Qwen3VLForConditionalGeneration') and broke everyidentify/metadata import. Recovery:git checkout uv.lock && uv sync --frozen --extra gpu --extra dev. Thegpuextra holdsdiffusers/transformers/torch, so a bareuv sync(no extras) removes them;noai/__init__is now lazy (PEP 562__getattr__, so importingidentify/metadatano longer pullswatermark_remover/torch), so a bare env breaks only when the removal pipeline is actually invoked, not on import.maintain.sh'suv sync --all-extrasalso pulls the heavytrustmark/lamawheels (pytorch-lightning, onnxruntime) — fine on a good connection, but on flaky DNS sync only--extra gpu --extra devand run the lint/test steps by hand. - Metadata/C2PA tests assert against real committed fixtures in
data/samples/(chatgpt-*.png= OpenAI C2PA,firefly-1.png= Adobe,mj-*= Midjourney IPTC,doubao-1.png= ByteDance Doubao with the China TC260<TC260:AIGC>XMP label and a visible "豆包AI生成" text mark bottom-right;grok-1.jpg= xAI Grok with its EXIF-onlySignature:blob + UUIDArtistand no C2PA/SynthID/IPTC;flux-1.png/flux-1.jpg= real Black Forest Labs FLUX.2 Playground output, signed C2PA (issuer "Black Forest Labs" +trainedAlgorithmicMedia) --flux-1.jpgis the first committed JPEG-with-C2PA fixture, exercising the c2pa-python non-PNG reader path end to end; whether BFL hosted output also embeds the open DWT-DCT pixel watermark is UNRESOLVED -- our detector returns None on these fox samples, but they are high-texture carriers where even a known-embedded watermark fails the round-trip, see the content-fragility caveat indocs/watermarking-landscape.md); synthetic byte blobs cover the remaining JPEG/ISOBMFF format paths. The "non-AI / clean photo" control is no longer indata/samples/-- theclean_photoconftest fixture serves a verified-negative image from the corpusneg/set (skips if the corpus is absent). - SynthID reference corpus:
scripts/synthid_corpus.pyingests labeled images intodata/synthid_corpus/. The labeledimages/(pos/neg/cleaned/) are committed (public repo -- review every image for private content before adding;manifest.csvis kept in sync with the files on disk, one row per tracked image); only the syntheticrefs/calibration fills are gitignored. See its README for the collection protocol and verification oracles.cleaned/examples must be produced by a CURRENT shipped removal method -- the default SDXL img2img pass (optionally--max-resolution). Do NOT archive cleaned outputs from methods that are no longer in the pipeline (ctrlregen, the old text/face-protection, IP-Adapter FaceID, CodeFormer) or from the experimental opt-in paths (controlnet, face restore) as corpus examples; a cleaned reference should represent the canonical removal, and a removed method's output is not a reproducible example. Keep those experiment outputs in a local working dir, never in the committed corpus.
Configuration
- GPU/ML modules (invisible_engine, watermark_remover) are optional — guard imports with
is_available()checks - Optional detection extras:
detect(imwatermark — open SD/SDXL/FLUX watermark) andtrustmark(Adobe TrustMark decoder; pulls torch + downloads weights). Both are guarded byis_available()and skipped byidentifywhen absent. - Optional
esrganextra (spandrel only): Real-ESRGAN pre-diffusion super-resolution for small inputs (upscaler.py, CLI--upscaler esrganoninvisible/all/batch). Guarded byupscaler.is_available(); the default upscaler stays Lanczos (cv2, no deps) and the engine falls back to Lanczos when the extra is absent or the model errors. spandrel is MIT and pulls NO basicsr (only torch/torchvision/safetensors/numpy/einops); Real-ESRGAN weights are BSD-3-Clause and download on first use viatorch.hub(never bundled). Kept OUT ofall(heavy + model download). - Tests for the model-running paths are limited to availability checks (multi-GB downloads). But the pure helpers inside ML-adjacent modules are unit-tested without any download and must stay that way:
_target_size(native-vs-downscale-cap-vs-upscale-floor,test_invisible_engine.py),humanizer.unsharp_mask/adaptive_polish(test_humanizer.py), and the MPS->CPU fallback control flow via mocked pipelines (test_img2img_runner.py, 100% cover). Don't skip these as "ML, needs a model" — onlyremove_watermark/the diffusion bodies do.
Key modules
Compact map. The full per-module detail (design decisions, tuned thresholds, calibration history, incident records, and the regression-guard map) lives in docs/module-internals.md — read the relevant section there before changing any module below.
noai/c2pa.py— C2PA reading.extract_c2pa_info(path)uses the official c2pa-pythonReaderfirst (core dep, any container;read_manifest_store_jsonreturns the WHOLE store JSON — active + ingredient manifests — so an AI marker on a parent manifest is seen), and falls back to the hand-rolled caBX/CBOR parser (has_c2pa_metadata/extract_c2pa_chunk/_extract_c2pa_info_png) for synthetic/partial blobs the validator rejects or a broken/absent wheel. The registry scan (issuer / source-type / SynthID / soft-binding) is shared by both paths via_populate_registry_fields, so the return-dict shape is identical. Do not reimplement chunk parsing; chunk reads are clamped to the remaining file size by design.extract_c2pa_chunk/inject_c2pa_chunkstay PNG-only (raw caBX bytes, test/extractor use).noai/constants.py— the singleC2PA_AI_VENDORSregistry (+C2PA_SOFT_BINDINGS) from whichC2PA_ISSUERS/SYNTHID_C2PA_ISSUERS/identify._ISSUER_PLATFORMare all derived. Add a new vendor as one registry entry; never edit the derived dicts and never add inline.metadata.py—scan_head(path)is the shared (memoized) input for every C2PA/AIGC/IPTC byte scan; use it instead ofopen().read(1MB)for any new marker scan. Also home tosynthid_source,xai_signature,iptc_ai_system,aigc_label,huggingface_job,samsung_genai, andremove_ai_metadata(fail-safestrip_c2pa_boxes).exif_generatormatches a VALUE againstAI_GENERATOR_TOKENSacross EXIFSoftware/Make/Artist/ImageDescription, XMPCreatorTool, AND PNGtEXtchunks (Software/Source/Title/Description— NovelAI stamps there, not EXIF). Detection and removal must stay in parity: a generator that stamps an AI-shaped VALUE under a non-AI KEY (NovelAI'sTitle/Source) is dropped on removal by_is_ai_value(value-token match, mirrorsexif_generator), NOT by_is_ai_keyalone — else the cleaned file still reads as that generator. Add a new no-C2PA generator = oneAI_GENERATOR_TOKENSentry (use a distinctive token, e.g.reve.comnot barereve); detection and removal then both follow. Regression:tests/test_metadata.py::TestExifGenerator::{test_novelai_png_text_chunk_detected,test_novelai_removal_parity}.identify.py— aggregates every locally-readable signal into oneProvenanceReport;is_ai_generatedis True or None, never asserted False.ProvenanceReport.ai_source_kindexposes the C2PA digital-source-type split —"generated"(trainedAlgorithmicMedia, fully AI) vs"enhanced"(compositeWithTrainedAlgorithmicMedia, a real photo with an AI-composited region), else None — so a caller branches full-frame scrub vs region-targeted clean (seenoai/tiling.feather_region_composite+WatermarkRemover.remove_watermark(region=...)). The sparkle provenance threshold is the SHAREDwatermark_registry.GEMINI_SPARKLE_TRUST_CONF(imported, not a private copy) so the provenance "is there a sparkle" verdict and the removal "take the sparkle" decision can never drift.import identifyis deliberately light (lazynoai/__init__, fits a 512 MB host) — keep heavy imports out (thewatermark_registryconstant import stays light: engines are lazy there). Add capture-camera tokens to_DEVICE_C2PA_PLATFORMonly when verified against a real C2PA file; editing-app/AI-device signer tokens go to_SIGNER_C2PA_PLATFORM; generator/issuer platforms toC2PA_AI_VENDORSinconstants.py. Integrity-clash detection is high-precision by design (only hard generator stamps feed it, source-grouped independence).watermark_registry.py— the single catalog of known visible watermarks (gemini / doubao / jimeng / samsung / jimeng_pill).--mark autoremoves EVERY detected mark in one pass viaremove_auto_marks(marks coexist -- a Jimeng-basic image has the top-left pill AND the bottom-right wordmark;best_auto_marksingle-strongest would leave one). Thejimeng_pillis CAPTURE-LESS (has_capture=False,pill_engine.py): the top-left "AI生成" label has no alpha map, so it is detect-by-synthetic-silhouette + inpaint-only (every method resolves to inpaint). Its weak edge-NCC detector (~7% raw false-fire) is gated inremove_auto_marksvia_keep_pill(32k real-upload corpus validation 2026-07): the pill never rides on a Doubao detection, and has two confirmation arms because metadata confirms the platform, not pill presence. (1) Bottom-right "★ 即梦AI" wordmark fired — ~94% precise and survives metadata-STRIPPED uploads (screenshots / re-saves, ~61% of pills carry a detectable wordmark): remove unrestricted. (2) TC260 metadata only (CLI_aigc_metadata_present→pill_metadata, no wordmark) — only ~27% precise, its false fires are textured ceilings/walls that inpaint visibly SMEARS, so remove only when the top-left footprint is flat enough for an invisible inpaint (pill_engine.footprint_is_flat, median-Sobel texture ≤_FLAT_TEXTURE_MAX). This keeps real flat-scene pills (incl. metadata-only ones the wordmark misses) plus harmless flat false fires, and leaves the damaging textured false fires untouched. Do NOT drop the wordmark arm or loosen the metadata arm back to a bare metadata-OR gate. Two removal methods, selected byKnownMark.remove(method=...)/ CLI--method:reverse-alpharecovers exact pixels from the captured alpha map (lighter, better on structured backgrounds),inpainterases the mark footprint (footprint_mask→ MI-GAN, or cv2 without themiganextra).auto(default) =resolve_removal_method: reverse-alpha for capture marks, inpaint for capture-less (deterministic, model-independent). Reverse-alpha is the default where a capture exists because it recovers the true pixels and is measured CLEANER than MI-GAN inpaint on the capture marks (doubao/gemini/jimeng — MI-GAN adds a rainbow/smudge artifact on structure) AND lighter (no model/RAM); inpaint is reserved for the capture-less pill. The inpaint backend is chosen bypreferred_inpaint_backend: MI-GAN when its extra is installed, else cv2 — big-LaMa is NOT auto-selected (both run on onnxruntime, so availability cannot express intent; big-LaMa stays an expliciterase --backend lamaopt-in). The inpaint mask MUST be the full NCC-aligned alpha silhouette (footprint_mask), NOT the per-imageextract_masksignature — the signature under-segments and leaves glyph residue (corpus-validated 2026-07). Add a new reverse-alpha text mark = one_text_mark(...)row + aTextMarkConfigwith a captured alpha map; do not re-add per-markifbranches. A capture-less inpaint mark (no alpha map, likejimeng_pill) is instead ahas_capture=FalseKnownMark+ a small engine exposingdetect/footprint_mask(geometry + synthetic silhouette) + a metadata/cross-mark gate for its detector -- this path does NOT need a flat capture (removal quality comes from the inpaint backend, so a font-rendered silhouette is data-safe and sufficient).cli._write_bgr_with_alphamust NOT zero alpha in the watermark bbox (issue #30 white-box regression). A new REVERSE-ALPHA visible mark is HARD-BLOCKED on real controlled flat captures — the alpha map is solved byscripts/visible_alpha_solve.pyfrom solid black + gray (+white) captures of the mark produced by the actual app/device at native resolution, committed underdata/<engine>_capture/captures/. Do NOT synthesize the alpha by font-rendering the wordmark — the user rejected synthetic reconstruction as below the quality bar (2026-06-22), and per-image alpha falloff/variation needs the real capture. Corpus images are NOT a substitute (data-safety: no corpus-derived committed assets; and reverse-alpha needs the flat capture, not real content). So if no flat capture exists for a mark, the work is parked — do not propose synthetic, do not derive from user uploads. (Meta AI, more Samsung locales, and any Grok visible mark are all parked on this; Grok additionally needs confirming it even HAS a visible mark — its known signal is EXIF-onlyxai_signature.)gemini_engine.py— visible Gemini-sparkle remover/detector (cv2/numpy, no GPU): top-K size-weighted fusion candidate selection (_SELECT_TOPK), corner-promote, over/under-subtraction guards, false-positive gate, self-verify repair. Detection scores the top-K size-weighted matches by full fusion (spatial+gradient+variance) and keeps the highest — NOT the raw-NCC argmax, which re-admits the tiny-patch FPs the size weight suppresses (the osachub 2026-06-12 sub-0.85 corner-sparkle regression; seedocs/module-internals.md). Keep the 0.85 corner-promote NCC gate; a margin/chroma-gated lower promote was measured and REJECTED 2026-06-11 (~33% FP on non-Google content). Gate any removal candidate on a physical brightness check, not the detector alone._text_mark_engine.py— shared base for the three reverse-alpha text-mark engines (extracted 2026-06-09); the per-engine modules are config-only subclasses. New text mark = aTextMarkConfig+ a thin subclass + one registry row. Gemini stays a separate engine (different model).pill_engine.py— the CAPTURE-LESS Jimeng-basic "AI生成" pill (top-left, issue #54). No alpha map:detectis edge-NCC of a synthetic font-rendered silhouette (assets/jimeng_pill.png, regenerate viascripts/render_pill_silhouette.py; committed, data-safe -- corpus stays out of the repo) in the top-left ROI, calibrated on 61 local real positives to threshold 0.22;footprint_maskis a generous FIXED top-left geometry box (NOT the NCC match position -- the synthetic silhouette localizes only approximately, the corner is negative space, so a geometry box removes cleanly while a match box leaves outline residue).footprint_texture/footprint_is_flat(median-Sobel over that box,_FLAT_TEXTURE_MAX) back the metadata-only safe-inpaint gate. Removal is inpaint-only (MI-GAN/cv2). Detector precision is weak (~7% raw false-fire), so it is registry-gated inremove_auto_marksvia_keep_pill: never on Doubao; the bottom-right wordmark removes it unrestricted (~94% precise, survives metadata-STRIPPED uploads); TC260-metadata-only removes it ONLY on a flat footprint (its textured false fires -- ceilings/walls -- are what inpaint smears). Do NOT loosen those gates.doubao_engine.py/jimeng_engine.py/samsung_engine.py— thinTextMarkEnginesubclasses: Doubao "豆包AI生成" (bottom-right), Jimeng "★ 即梦AI" (bottom-right), Samsung Galaxy AI "✦ Contenuti generati dall'AI" (bottom-LEFT, locale-specific — Italian variant calibrated). Removal = reverse-alpha (always-align) + thin residual inpaint, with an over-subtraction guard ported fromgemini_engine(2026-06-20):_reverse_alpha_oversubtractspredicts the reverse-alpha output PER PIXEL over the glyph body from the INPUT, and when the recovered body lands more than_OVERSUB_DARK_MARGIN(25) gray levels below the local ring it abandons the reverse-alpha pixels and inpaints the footprint from the original surroundings (_inpaint_footprint) — fixing the dark-pit ghost on dark/mid-tone backgrounds (roadmap P0#8). Predicting per-pixel from the input (not the produced output) keeps a clean full-strength mark byte-identical (no false trip). A detector-only removal test is insufficient — assert visual residual (the textured-shift tests +tests/test_text_mark_oversubtraction.py).region_eraser.py— universal region eraser (eraseCLI) and the inpaint backend for the visible-mark fallback. Three backends:cv2(default, no deps),migan(MI-GAN ONNX, extramigan, MIT, ~28 MB / ~0.95 GB peak / ~0.19 s — the droplet-friendly tier, the preferred backend for the auto inpaint fallback viawatermark_registry.preferred_inpaint_backend),lama(big-LaMa ONNX, extralama, ~200 MB / ~4.7 GB peak — best quality, does not fit a minimal droplet, explicit opt-in only). MI-GAN mask polarity is INVERTED (0=hole/255=known) vs this package's 255-erase convention;erase_miganinverts before feeding the model (feeding 255=hole regenerates the whole frame into stripes — corpus-validated). Both ONNX models download on first use, never bundled.invisible_watermark.py— decodes the OPEN DWT-DCT watermarks (SD / SDXL / FLUX) viaimwatermark(extradetect, pulls torch). Fragile two ways: (1) does not survive JPEG re-encode/resize; (2) carrier-fragile on a broad class of pristine images -- a clean encode->decode round-trip recovers 48/48 on chatgpt/firefly/random but FAILS (28-39/48, below the_MATCH_48=44 gate) on the FLUX fox, doubao, a flat FLUX generation, AND a clean synthetic flat fill with no watermark. The failure does NOT track texture; it goes with a degenerate all-ones decode that is a CARRIER ARTIFACT, not a watermark (synthetic clean image reproduces it). Sodetect_invisible_watermarkis positive-only: trust a hit; aNoneis inconclusive unless a same-carrier positive-control embed first recovers >=44. Verified 2026-06-19; full caveat indocs/watermarking-landscape.md.trustmark_detector.py— Adobe TrustMark open decoder (extratrustmark). Do NOT remove the JPEG re-encode false-positive gate — a lone TrustMark hit without it is almost always content noise.noai/watermark_remover.py—WatermarkRemoverwith three diffusion pipelines selected by the explicitpipelinector arg, never inferred frommodel_id:sdxl(plain SDXL img2img),controlnet(SDXL + canny ControlNet, the DEFAULT since 2026-06-09), andqwen(Qwen-Image 20B MMDiT img2img, Apache-2.0, CUDA/cloud-class — best text preservation (incl. CJK);_load_qwen_pipeline/_run_qwen, bf16, no MPS fallback; call shape in the pure_build_qwen_kwargsusingtrue_cfg_scale). Removal comes from the img2imgstrength; ControlNet only preserves text/face STRUCTURE — SynthID CAN survive controlnet on photoreal content at low strength. Qwen CERTIFIED oracle floors (2026-06-20): OpenAI 0.10 (seed-robust, clean on seeds 0-4), Gemini 0.25 (seed 0 verified, pin a seed — Gemini oracle rate-limits volume; higher than the controlnet Gemini floor 0.15).resolve_strength(..., pipeline="qwen")carries the Qwen ladder (_QWEN_VENDOR_STRENGTH), so--pipeline qwengets the 0.25 Gemini floor automatically (the old manual--strength 0.25workaround is retired)._build_qwen_kwargspasses an explicitheight/widthfrom the input (floored to /16 via_qwen_target_size) — without it the pipeline defaults to a 1024x1024 SQUARE and silently squishes non-square inputs (fixed 2026-06-20).qwenis a MANUAL opt-in only — there is NO auto-router. Measured (scripts/fidelity_metrics.py, OCR-CER / ArcFace / LPIPS / Laplacian-var, NOT eyeball): qwen beats controlnet on ONE niche only — clean body text on a plain background, no faces (openai_1/2 CER 0.241 vs 0.385). controlnet wins FACES (it always has) AND display/decorative text in a scene (abba poster: controlnet CER 0.114 vs qwen 0.379 — canny holds letter shapes, qwen re-renders and garbles them). So a content--pipeline autorouter and a faces+text mixed dual-pass were prototyped and DROPPED (2026-06-20): on the canonical faces+text case controlnet wins every metric incl. text, so mixed loses; and "text→qwen" can't be auto-decided (it is body-vs-display text that matters, undetectable cheaply). qwen stays for callers who KNOW their content is clean-text-heavy and face-free. No face-restore extra ships, by validated decision (every restore approach looked MORE AI-generated).remove_watermark(region=(x,y,w,h), region_feather=...)runs the regeneration but feather-composites only the AI box back over the original (vianoai/tiling.feather_region_composite), preserving the real photo elsewhere — the AI-enhanced composite path (identifyai_source_kind == "enhanced"); the box is supplied by the caller (a C2PA composite manifest carries no reliable machine-readable region, so we do not fabricate one).noai/tiling.py— sliding-window tiled diffusion for large inputs (CLI--tile).WatermarkRemover.remove_watermarkbranches torun_tiledwhentileis set AND the long side exceedstile_size, refactoring the single-pass_generateinto a per-tile_generate_one(the ControlNet edge map is rebuilt per tile inside it). Pure helpersplan_tiles(uniform-size tiles, last one flush to the edge) andfeather_weights(strictly-positive separable taper -> partition-of-unity blend) are unit-tested without the model. Also home tofeather_region_composite(base, regenerated, box, *, feather)— the pure region-targeted compositor for AI-enhanced composites (ai_source_kind == "enhanced"): blends the regenerated AI box back over the original with a feathered seam, leaving the real photo OUTSIDE the box pixel-exact. It backsWatermarkRemover.remove_watermark(region=...)(regenerate ONLY the AI region, not the whole frame); the no-model lossless region path staysregion_eraser.erase. New tile/region-blend tuning goes in these pure helpers; do not inline blend math into the runner.auto_config.py+ the content-detection layer were REMOVED 2026-06-09;--autois a deprecated no-op (controlnet is the default pipeline and the adaptive polish is ON by default and self-gates to a no-op where there is no detail deficit).upscaler.py— optional Real-ESRGAN pre-diffusion super-resolution for small inputs (extraesrgan, spandrel only). Manual opt-in; the default--upscalerstayslanczosand the engine always falls back to Lanczos on absence/error. ESRGAN can degrade faces and thin text.image_io.py— Unicode-safe cv2 IO (issue #17). Every cv2 file read/write in the package routes throughimread/imwrite; do not callcv2.imread/cv2.imwritedirectly.to_bgr(image)is the shared channel normalizer — use it instead of inliningcvtColorbranches.
For the Doubao alpha-distillation history (why content-image reverse-alpha distillation fails by physics and controlled captures were required), see docs/research-doubao-distillation.md.
Watermarking landscape
Who embeds what (C2PA / IPTC / EXIF / TC260 AIGC / xAI signature / open and proprietary invisible watermarks), whether each is locally detectable, the C2PA 2.4 durable-credentials implications, and the regulatory driver table live in docs/watermarking-landscape.md (research 2026-05-24, updated through 2026-06-10). Read it before adding a new identify signal, vendor token, or metadata marker. See identify.py for what we read today.
Known limitations
Compact list. Full measurements, incident history, and oracle-validation runs live in docs/known-limitations.md — read the relevant section there before changing the diffusion pipelines, strength defaults, resolution handling, or metadata coverage.
invisibleprocesses at native resolution for inputs >= 1024px long side and auto-upscales smaller inputs to a 1024px floor (--min-resolution 0disables;--max-resolution Nis an opt-in cap to bound GPU/MPS memory). MPS OOM is memory-tier dependent, not a hard limit: ~24 GB unified memory falls back to CPU (slow but weight-identical output), 32 GB runs native on MPS. The native-vs-cap-vs-floor decision lives in the pure helperinvisible_engine._target_size— keep the logic there, unit-tested without the model. For large inputs that OOM,--tileis the lossless alternative to--max-resolution: sliding-window diffusion at native resolution, each tile near SDXL's 1024 training size, feather-blended over the overlap (noai/tiling.py). It only engages when the long side exceeds--tile-size; the geometry (plan_tiles) and the blend window (feather_weights) are pure and unit-tested (tests/test_tiling.py). Caveat: each tile is an independent low-strength regeneration, so at the certified removal strengths (0.20-0.30) tile drift is minimal but not zero; tiling is a memory workaround, not a quality upgrade over a single native pass.- fp16 VAE black-output (issues #29/#41): the fp16-fixed SDXL VAE (
madebyollin/sdxl-vae-fp16-fix) is swapped in for the default SDXL checkpoint on cuda/xpu fp16, plus a model-agnostic backstop that detects a degenerate (all-black) fp16 output and re-runs once in fp32. cpu/mps run fp32 and never reproduce the bug. - Pyright first run is slow (2-3 min) due to ML deps (torch/diffusers/transformers stubs); full-project
uv run pyrightcan stall for many minutes — scope it to changed files. - A third-party PIL plugin autoload (e.g. an HEIF/AVIF plugin) can raise a non-OSError (
ModuleNotFoundError), notUnidentifiedImageError, when opening a file. Code that opens user-supplied or unknown-format files shouldexcept Exception, not justOSError/UnidentifiedImageError. - rich was dropped: the CLI + analysis scripts print plain text (
click.echo/ thescripts/_plain_console.pyshim).richis NOT a dependency — importing it breaks the core+dev CI sync; new scripts must use the shim. No Unicode glyphs / colors / progress bars in CLI output by design. - AVIF/HEIF/JPEG-XL metadata detection is a binary scan; C2PA removal in those containers (and MP4/MOV/M4V) is
noai/isobmff.py; non-ISOBMFF audio/video (WebM/MP3/WAV/FLAC/OGG) strips losslessly via ffmpeg on PATH. An AI-generator token in anExifmeta-box item (bytes inmdat/idat) is now blanked in place byisobmff.blank_ai_exif_tokens(same-length space overwrite, piexif-validated so a coincidental II/MM run in pixels is ignored — noiinf/ilocsurgery, mirrorsblank_ai_xmp_packets); it scrubs the AI-token value only, leaving camera/editor EXIF intact. Still NOT built: Resemble PerTh audio detection (no presence/confidence flag exists). - SynthID technical reference:
docs/synthid.md— primary-source-cited doc covering mechanism (post-hoc encoder/decoder pair, 136-bit payload at 512x512, pixel-space, model weights NOT modified), robustness numbers (arXiv:2510.09263: ~99.98% TPR@0.1%FPR across 30 transforms including JPEG/crop/resize/color/noise), removal attacks and forensic detectability (arXiv:2605.09203: all 6 attacks detectable at >98% TPR@1%FPR), detectability limits (no public decoder, metadata-proxy only), oracle scope, and adoption landscape. Read that doc first before adding notes here. - SynthID detection is metadata-only. No local pixel detector is possible by design (Google's decoder is proprietary, trusted-testers only); we read the C2PA companion proxy, which goes quiet once metadata is stripped — a quiet proxy is not proof the pixel watermark is gone. Each vendor has its OWN oracle and it detects only that vendor's content: the Gemini app "Verify with SynthID" for Google,
openai.com/verifyfor OpenAI. Validate the OpenAI arm FIRST —openai.com/verifyis more accessible (fewer per-check restrictions) and the strongest automation candidate (Playwright / Chrome MCP); the Gemini flow is more manual. Ordering/throughput choice, not a substitution (seedocs/synthid.md). SynthID survives JPEG re-encode, so GitHub issue attachments remain valid pixel-watermark test subjects. Every spectral/phase detection approach evaluated (reverse-SynthID, our own probes) works only on controlled solid fills, never on real content. - External AI-vs-real classifier models are out of scope (decided 2026-05-24): per-generator, degrade off-distribution, and our own light SDXL pass would likely defeat them. Detection stays local + signal-based.
- Default strength is VENDOR-ADAPTIVE, one ladder for BOTH pipelines (since 2026-06-09):
resolve_strength(strength, vendor)picks OpenAI 0.20 / Gemini 0.30 / unknown 0.30 when--strengthis unset; explicit--strengthalways wins. Removal at low strength is content x pipeline dependent, and near-threshold removal is SEED-NON-DETERMINISTIC — pick a strength with margin and oracle-revalidate per content type. Certified controlnet floors (Modal cert 2026-06-04): OpenAI 0.20 (resolution-independent), Gemini 0.30 (only <= 1536px; native large Gemini needs ~0.35+ or a cap). controlnetis the default pipeline;--pipeline sdxlis the lighter opt-down. Neither pipeline clears all content at low strength (photoreal survives controlnet, flat graphics survive sdxl — the lever is higher strength). A removal-priority caller MUST oracle-validate strength across content types; prod recipe: controlnet + per-vendor floor + FIXED seed. Forensic-stealth caveat (arXiv:2605.09203): defeating the SynthID verifier is NOT forensic invisibility — removal-processed images are flaggable at >98% TPR@1%FPR.