Files
remove-ai-watermarks/CLAUDE.md
T
Victor Kuznetsov 49869ab02b feat(identify): detect Dreamina C2PA + Tencent Cloud AIGC schema
Two AI-provenance metadata types mined from the retained corpus that
identify previously read as no-signal:

- Dreamina (ByteDance's international Jimeng brand) signs C2PA as
  "Bytedance Pte. Ltd." with a "Dreamina/x.y" claim generator and NO
  digitalSourceType, so the generator name is the only AI signal. Add a
  C2paAiVendor row with a new asserts_ai flag (identity-AI: presence
  asserts AI without trainedAlgorithmicMedia) plus the derived
  C2PA_IDENTITY_AI_ORGS view, folded into identify's c2pa_is_ai. Keyed on
  the Dreamina generator token, not the "Bytedance Pte" issuer, so non-AI
  CapCut edits signed by the same entity stay unattributed. 7/7 corpus
  files now attribute to ByteDance.

- Tencent Cloud's TC260 AIGC variant uses a ServiceProvider/ServiceUser
  schema (vs the producer-side ContentProducer schema), embedded in EXIF
  ImageDescription; add those field names to _TC260_FIELDS so the generic
  {"AIGC":{...}} gate accepts it. 11/11 corpus files now flagged.

Test-first: reproducing tests in test_identify.py / test_metadata.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 10:54:56 +03:00

50 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.py or the eraser, and do not build tiled-overlay or multi-image watermark-estimation features aimed at them.
  • erase --region stays 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 as invisible, plus the visible-pass --backend auto|cv2|migan|lama (default auto) and --sensitivity auto|strict|assume-ai (default auto) for the localize -> fill visible removal (see the visible bullet). 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 across invisible/all/batch): --strength (vendor-adaptive default), --steps, --guidance-scale (CFG, default 7.5), --pipeline sdxl|controlnet|qwen (default controlnet; qwen is 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). --auto is 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 by localize -> fill: each detected mark is localized to a binary full-frame footprint mask, then one shared, swappable fill inpaints that mask. --backend auto|cv2|migan|lama (default auto) picks the fill: cv2 (classical inpaint, no deps, the floor), migan (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), lama (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); auto = LaMa > MI-GAN > cv2, best available. --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_pill forces one. --sensitivity auto|strict|assume-ai (default auto) sets how hard a borderline mark is trusted: auto relaxes a mark's gate only on same-product evidence (metadata provenance for that vendor, or a confidently detected sibling mark of the same product — clean images stay untouched); strict never relaxes; assume-ai relaxes every mark (the caller asserts the image is AI, e.g. a metadata-stripped screenshot uploaded to a remover — corpus-measured Gemini recall ~46% -> ~92%, at the cost of a small fill on some clean corners). Metadata provenance is read automatically and feeds auto; the library cannot infer AI from a stripped image, so only assume-ai reaches the high recall there. For arbitrary logos/objects use erase. 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-detect forces the gemini fallback and proceeds. See the module doc for the routing/exit detail. --backend and --sensitivity are shared across visible/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, extra migan; ~28 MB, ~1 GB RAM, near-LaMa), or --backend lama (big-LaMa, extra lama; best quality but ~4.7 GB RAM); --region is repeatable.
  • uv run remove-ai-watermarks identify <image> — provenance verdict (platform + watermark inventory + confidence); --json for machine output, --no-visible to skip the cv2 sparkle detector
  • uv 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 metadata
  • uv 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 (default visible); the invisible/all path reuses the full invisible knob set above, plus --backend and --sensitivity for the visible localize -> fill pass. Applies the same no-signal skip per image; see the module doc.

Test and lint

  • CI (.github/workflows/test.yml): runs on push to main + every PR. A lint job (ubuntu: ruff check + ruff format --check) plus a test matrix (ubuntu/macos/windows x py3.10/3.12) that does uv sync --frozen --extra dev then pytest. The matrix installs only core + dev (no gpu extra), so the GPU/model-running tests skip there and it exercises the metadata/identify/visible/cv2-eraser surface on all three OSes. Keep uv.lock valid (don't break --frozen) when editing pyproject.toml.
  • Release flow + distribution channels (PyPI publish via publish.yml/uv publish, the automated Homebrew-tap + HF-Space bumps in distribute.yml, conda-forge, ComfyUI Registry, the sdist data/ exclusion, hatchling pin history): see docs/release-and-distribution.md before cutting a release.
  • bash maintain.sh — uv-outdated, uv-secure, ruff check/fix, ruff format, pyright (scoped src/, see the OOM note below), pytest -n auto. The helper tools live in the dev extra (pytest-xdist, plus uv-outdated/uv-secure marker-gated to py3.12+ so the py3.10 resolution stays solvable) — a bare env without --extra dev does 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__.pyi is a local stub so metadata.py/extractor.py resolve piexif. Public ndarray-returning signatures on the relaxed engines are still annotated NDArray[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. The uv-secure CVE-resolution history (idna/aiohttp bumps, retired basicsr, the dismissed torch GHSA-rrmf-rvhw-rf47) lives in docs/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 a libnode stack frame, no summary) — a known environment limit, not a code error. Gate with uv run --extra dev --extra gpu pyright src/ (completes, authoritative) or scope to changed files; also run uv run ruff check and uv run pytest directly.
  • Run uv run from the repo root — from another cwd it falls back to a bare env without numpy/cv2/torch.
  • Stale trustmark remnant in site-packages after an extras change: the trustmark package downloads model weights INTO its own package dir, so when a narrower uv sync prunes the package, a trustmark/models/ directory survives as an empty namespace package. Symptom: pyright "TrustMark" is unknown import symbol on trustmark_detector.py and find_spec("trustmark") returning a loader-less spec (so is_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, never uv pip installuv pip install re-resolves and rewrites uv.lock, which silently bumped transformers to a build incompatible with the pinned diffusers (cannot import name 'Qwen3VLForConditionalGeneration') and broke every identify/metadata import. Recovery: git checkout uv.lock && uv sync --frozen --extra gpu --extra dev. The gpu extra holds diffusers/transformers/torch, so a bare uv sync (no extras) removes them; noai/__init__ is now lazy (PEP 562 __getattr__, so importing identify/metadata no longer pulls watermark_remover/torch), so a bare env breaks only when the removal pipeline is actually invoked, not on import. maintain.sh's uv sync --all-extras also pulls the heavy trustmark/lama wheels (pytorch-lightning, onnxruntime) — fine on a good connection, but on flaky DNS sync only --extra gpu --extra dev and 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-only Signature: blob + UUID Artist and 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.jpg is 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 in docs/watermarking-landscape.md); synthetic byte blobs cover the remaining JPEG/ISOBMFF format paths. The "non-AI / clean photo" control is no longer in data/samples/ -- the clean_photo conftest fixture serves a verified-negative image from the corpus neg/ set (skips if the corpus is absent).
  • SynthID reference corpus: scripts/synthid_corpus.py ingests labeled images into data/synthid_corpus/. The labeled images/ (pos/ neg/ cleaned/) are committed (public repo -- review every image for private content before adding; manifest.csv is kept in sync with the files on disk, one row per tracked image); only the synthetic refs/ 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) and trustmark (Adobe TrustMark decoder; pulls torch + downloads weights). Both are guarded by is_available() and skipped by identify when absent.
  • Optional esrgan extra (spandrel only): Real-ESRGAN pre-diffusion super-resolution for small inputs (upscaler.py, CLI --upscaler esrgan on invisible/all/batch). Guarded by upscaler.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 via torch.hub (never bundled). Kept OUT of all (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" — only remove_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.mdread the relevant section there before changing any module below.

  • noai/c2pa.py — C2PA reading. extract_c2pa_info(path) uses the official c2pa-python Reader first (core dep, any container; read_manifest_store_json returns 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_chunk stay PNG-only (raw caBX bytes, test/extractor use).
  • noai/constants.py — the single C2PA_AI_VENDORS registry (+ C2PA_SOFT_BINDINGS) from which C2PA_ISSUERS / SYNTHID_C2PA_ISSUERS / C2PA_IDENTITY_AI_ORGS / identify._ISSUER_PLATFORM are all derived. Add a new vendor as one registry entry; never edit the derived dicts and never add inline. A vendor's asserts_ai=True flag means its mere presence asserts AI generation even without a trainedAlgorithmicMedia digital-source-type (a pure-generator brand with a distinctive issuer/generator string, e.g. Dreamina — ByteDance's international Jimeng brand, signed as "Bytedance Pte. Ltd." with a "Dreamina/x.y" claim generator and no source-type); NEVER set it for common-word issuers (Adobe/Google/OpenAI/Microsoft) that appear incidentally in unrelated bytes — those stay source-type-gated in identify._attribute_platform.
  • metadata.pyscan_head(path) is the shared (memoized) input for every C2PA/AIGC/IPTC byte scan; use it instead of open().read(1MB) for any new marker scan. Also home to synthid_source, xai_signature, iptc_ai_system, aigc_label, huggingface_job, samsung_genai, and remove_ai_metadata (fail-safe strip_c2pa_boxes). remove_ai_metadata is the SINGLE metadata stripper (the legacy PIL-re-encoding noai/cleaner was deleted; the diffusion core and the public noai.remove_ai_metadata re-export now point here). It strips losslessly per container: ISOBMFF (HEIC/AVIF/MP4) blanks tokens / strips boxes in place; JPEG uses _strip_jpeg_metadata_lossless — a marker-segment walk that drops the AI-bearing APP segments (C2PA APP11, AI XMP APP1, IPTC APP13) and scrubs AI EXIF tags via piexif, copying the entropy-coded scan verbatim so the pixels are bit-identical (no DCT re-encode). This is what lets a --strip-metadata on a q100 removal output NOT crush it back to q75. PNG/WebP re-saves are already pixel-lossless. Regression: tests/test_metadata.py::TestHasAiMetadata::test_jpeg_metadata_strip_is_pixel_lossless (real grok/flux fixtures). exif_generator matches a VALUE against AI_GENERATOR_TOKENS across EXIF Software/Make/Artist/ImageDescription, XMP CreatorTool, AND PNG tEXt chunks (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's Title/Source) is dropped on removal by _is_ai_value (value-token match, mirrors exif_generator), NOT by _is_ai_key alone — else the cleaned file still reads as that generator. Add a new no-C2PA generator = one AI_GENERATOR_TOKENS entry (use a distinctive token, e.g. reve.com not bare reve); 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 one ProvenanceReport; is_ai_generated is True or None, never asserted False. ProvenanceReport.ai_source_kind exposes 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 (see noai/tiling.feather_region_composite + WatermarkRemover.remove_watermark(region=...)). The sparkle provenance threshold is the SHARED watermark_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 identify is deliberately light (lazy noai/__init__, fits a 512 MB host) — keep heavy imports out (the watermark_registry constant import stays light: engines are lazy there). Add capture-camera tokens to _DEVICE_C2PA_PLATFORM only when verified against a real C2PA file; editing-app/AI-device signer tokens go to _SIGNER_C2PA_PLATFORM; generator/issuer platforms to C2PA_AI_VENDORS in constants.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). Removal is LOCALIZE -> FILL for every mark: each mark is localized to a binary full-frame footprint mask (a Localization), then ONE shared, swappable fill inpaints that mask via fill(image, mask, backend=...) (delegates to region_eraser.erase). Reverse-alpha (the old original = (wm - a*logo)/(1-a) inversion of a captured alpha map + thin residual inpaint) is GONE for ALL marks; why it was dropped is recorded in docs/module-internals.md. Backends: cv2 (classical inpaint, no deps, the floor), migan (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), lama (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); auto = LaMa > MI-GAN > cv2, best available. The captured alpha maps (scripts/visible_alpha_solve.py) are still used to DETECT the marks and to shape the mask, but NOT for pixel recovery. --mark auto removes EVERY detected mark in one pass via remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto") (marks coexist -- a Jimeng-basic image has the top-left pill AND the bottom-right wordmark; a single-strongest pick would leave one). Three orthogonal axes: backend (the fill), sensitivity (how hard to trust a borderline mark: auto/strict/assume_ai, see the Sensitivity literal), and provenance (vendor keys metadata confirms -- the evidence that drives auto). Perception / decision / action are separated: _build_candidates(image) runs every detector at BOTH trust levels (strict + relaxed) and packages raw verdicts + features into Candidates (no policy); the pure arbiter decide(candidates, Context(sensitivity, provenance)) -> [Decision] makes every keep/drop call (per-mark resolve_relax + the pill gate) with no image/IO, so it is unit-testable in isolation; then each winner is localized -> filled. Do NOT put policy back into the engines (the one exception, the Gemini FP gate, stays in gemini_engine because identify shares that confidence). detect_marks(..., provenance=frozenset()) stays strict (identify verdict, precision over recall); KnownMark.remove/detect/localize(..., provenance: bool) take the already-resolved boolean. How auto/assume decide (this is metadata-INDEPENDENT for recall): the visual detectors are pixel-based and need no metadata; the recall gain comes from RELAXING the false-positive gate, not from metadata. strict never relaxes (clean images untouched); auto relaxes a mark only on same-product evidence -- metadata provenance for that vendor OR a confidently detected sibling mark of the SAME product (_PRODUCT_OF; Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax); assume_ai relaxes every mark (the caller asserts AI -- a metadata-stripped screenshot uploaded to a remover). Corpus finding: Gemini sparkle removal on Google-C2PA images is ~46% under strict/metadata-free auto and ~92% under assume_ai (recovering marks the vendor moved or re-rendered); the library CANNOT infer AI from a stripped image, so only the caller's assume_ai reaches the high recall there. A wrong relaxation just fills a small corner near-losslessly (the localize -> fill benign failure mode), which is what makes assume_ai acceptable. Metadata provenance mapping (feeds auto, read by cli._visible_provenance): Google/Gemini C2PA issuer -> gemini; China-AIGC (TC260) label -> doubao/jimeng; samsung_genai -> samsung. The jimeng_pill is CAPTURE-LESS (pill_engine.py): the top-left "AI生成" label has no captured alpha map, so it is detect-by-synthetic-silhouette; its footprint is a fixed top-left geometry box. Its weak edge-NCC detector (~7% raw false-fire) is gated in remove_auto_marks via _keep_pill (32k real-upload corpus validation 2026-07): the pill never rides on a Doubao detection, and has confirmation arms because metadata/intent 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 confirms Jimeng ("jimeng" in provenance, no wordmark) OR the caller asserts AI (sensitivity == "assume_ai") — the metadata-only arm is only ~27% precise and its false fires are textured ceilings/walls that the fill visibly SMEARS, so remove only when the top-left footprint is flat enough for an invisible fill (pill_engine.footprint_is_flat, median-Sobel texture ≤ _FLAT_TEXTURE_MAX) — the flatness guard holds even under assume_ai. 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 flatness guard. cli._write_bgr_with_alpha must NOT zero alpha in the watermark bbox (issue #30 white-box regression). The localizer is cheap CPU (cv2/numpy), so a memory-tight caller runs it anywhere; the heavy MI-GAN/LaMa fill is opt-in and chosen by the caller (a small worker can use cv2; a GPU/model worker can use MI-GAN/LaMa). Adding a new mark still needs a real detection template: a new alpha-solved silhouette from scripts/visible_alpha_solve.py (solid black + gray (+white) captures of the mark produced by the actual app/device at native resolution, committed under data/<engine>_capture/captures/) for a captured mark, or a synthetic font-rendered silhouette for a capture-less one like the pill. Do NOT synthesize a captured mark's detection template by font-rendering the wordmark (the user rejected synthetic reconstruction as below the quality bar, 2026-06-22), and do NOT derive one from user uploads (data-safety: no corpus-derived committed assets). 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-only xai_signature.)
  • gemini_engine.py — visible Gemini-sparkle detector + localizer (cv2/numpy, no GPU): top-K size-weighted fusion candidate selection (_SELECT_TOPK), corner-promote, false-positive gate (the provenance prior relaxes the gate + lowers the trust threshold when a Google/Gemini C2PA issuer confirms the vendor). White-core rescue: the FP gate demotes a low-gradient match (soft edges), but a real FAINT sparkle also has soft edges -- so the gate keeps a low-grad match that is a strong (conf ≥ _SPARKLE_KEEP_CONF 0.52), bright (margin), near-WHITE-core sparkle (_core_saturation_SPARKLE_WHITE_SAT 0.20): a real sparkle core is white, a clean bright corner that shape-matches (sky/sun) is colored. This recovers ~14/20 metadata-stripped faint sparkles under the DEFAULT strict/auto (no flag, no metadata) at ~1.25% clean false-fire (baseline 0.55%); the ~0.51-scoring bright-bg FPs stay demoted (below 0.52). A learned classifier on the SAME features was measured WORSE than the tuned gate (2026-07 tier-1: MLP 86.7% recall vs 90.8% at equal FP), so the heuristic stays; a patch-CNN with richer features is the only lever left (roadmapped P2, low expected value -- the wall is fundamental). 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; see docs/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). Removal is localize -> fill: footprint_mask returns the sparkle footprint (the captured alpha thresholded LOW so the faint halo is included, then dilated by a sparkle-relative margin), and the shared watermark_registry.fill inpaints it. The captured alpha maps are used only to detect and to shape the mask, not for pixel recovery.
  • _text_mark_engine.py — shared base for the three text-mark engines (extracted 2026-06-09); the per-engine modules are config-only subclasses. Detection still matches the glyph silhouette (NCC, keys on glyph shape). The removal mask is TEMPLATE-FREE: it is the bounding box of the top-hat glyph blob (extract_mask), filled solid + dilated, so the shared fill inpaints the whole wordmark rectangle. This drops the fixed alpha-template placement, so a re-rendered or differently-placed mark is still masked; the captured alpha maps are now used only for the detection silhouette, not for removal. New text mark = a TextMarkConfig + 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: detect is edge-NCC of a synthetic font-rendered silhouette (assets/jimeng_pill.png, regenerate via scripts/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_mask is 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 fills 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-fill gate. Removal is the shared localize -> fill (MI-GAN/cv2). Detector precision is weak (~7% raw false-fire), so it is registry-gated in remove_auto_marks via _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 the fill smears). Do NOT loosen those gates.
  • doubao_engine.py / jimeng_engine.py / samsung_engine.py — thin TextMarkEngine subclasses: Doubao "豆包AI生成" (bottom-right), Jimeng "★ 即梦AI" (bottom-right), Samsung Galaxy AI "✦ Contenuti generati dall'AI" (bottom-LEFT, locale-specific — Italian variant calibrated). Detection matches the glyph silhouette (NCC); removal localizes the glyph blob to a solid dilated box (extract_mask) and hands it to the shared fill. Corpus validation: doubao and jimeng localize + remove at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit); clean images with no vendor signature had 0% false removal. Samsung detection is calibrated only for the Italian "Contenuti generati dall'AI" string (a pre-existing limit, unchanged by the localize -> fill refactor but now surfaced because detection gates removal): non-Italian Samsung locales are not detected, and thus not removed, even though the fill mask itself is locale-independent; other locales need their own captured detection template.
  • region_eraser.py — universal region eraser (erase CLI) and the shared fill backend behind watermark_registry.fill for the visible localize -> fill removal. Three backends: cv2 (default, no deps, the floor), migan (MI-GAN ONNX, extra migan, MIT, ~28 MB / ~0.95 GB peak / ~0.19 s — the droplet-friendly tier, the preferred default fill when the extra is installed), lama (big-LaMa ONNX, extra lama, ~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_migan inverts before feeding the model (feeding 255=hole regenerates the whole frame into stripes — corpus-validated). Both ONNX models download on first use, never bundled. The erase command keeps its own --backend/--inpaint-method (unchanged).
  • invisible_watermark.py — decodes the OPEN DWT-DCT watermarks (SD / SDXL / FLUX) via imwatermark (extra detect, 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). So detect_invisible_watermark is positive-only: trust a hit; a None is inconclusive unless a same-carrier positive-control embed first recovers >=44. Verified 2026-06-19; full caveat in docs/watermarking-landscape.md.
  • trustmark_detector.py — Adobe TrustMark open decoder (extra trustmark). Do NOT remove the JPEG re-encode false-positive gate — a lone TrustMark hit without it is almost always content noise.
  • noai/watermark_remover.pyWatermarkRemover with three diffusion pipelines selected by the explicit pipeline ctor arg, never inferred from model_id: sdxl (plain SDXL img2img), controlnet (SDXL + canny ControlNet, the DEFAULT since 2026-06-09), and qwen (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_kwargs using true_cfg_scale). Removal comes from the img2img strength; 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 qwen gets the 0.25 Gemini floor automatically (the old manual --strength 0.25 workaround is retired). _build_qwen_kwargs passes an explicit height/width from 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). qwen is 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 auto router 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 (via noai/tiling.feather_region_composite), preserving the real photo elsewhere — the AI-enhanced composite path (identify ai_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_watermark branches to run_tiled when tile is set AND the long side exceeds tile_size, refactoring the single-pass _generate into a per-tile _generate_one (the ControlNet edge map is rebuilt per tile inside it). Pure helpers plan_tiles (uniform-size tiles, last one flush to the edge) and feather_weights (strictly-positive separable taper -> partition-of-unity blend) are unit-tested without the model. Also home to feather_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 backs WatermarkRemover.remove_watermark(region=...) (regenerate ONLY the AI region, not the whole frame); the no-model lossless region path stays region_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; --auto is 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 (extra esrgan, spandrel only). Manual opt-in; the default --upscaler stays lanczos and 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 through imread/imwrite; do not call cv2.imread/cv2.imwrite directly. to_bgr(image) is the shared channel normalizer — use it instead of inlining cvtColor branches. read_bgr_and_alpha/write_bgr_with_alpha (+ ALPHA_FORMATS) are the alpha-preserving IO helpers shared by the CLI and the library api (moved here from cli so both use ONE implementation; the write MUST NOT zero alpha in the mark bbox — issue #30 white box). cv2/numpy import lazily, so importing image_io is cheap. imread has a Pillow fallback (_pil_read) for HEIC/AVIF: cv2 can't decode those containers, so when its decode returns None it opens via Pillow (AVIF native; HEIC via the core pillow-heif dep, whose libheif also covers AVIF) and converts to the same BGR/BGRA layout the flags imply — so the pixel/removal path reads iPhone HEIC and AVIF, not just the metadata path. Normal PNG/JPEG/WebP never reach the fallback. Corpus-verified: 54/55 HEIC+AVIF now decode (the 1 miss is a truncated upload). imwrite PRESERVES the input format at max quality ("work with originals"): the removal only touches the mark footprint (cv2 AND MI-GAN fills composite over the original — untouched pixels are bit-exact), so the container re-encode must not degrade the rest. JPEG is written at quality 100 / 4:4:4 (no chroma subsampling) — PSNR ~55 dB vs the old default-95's ~48; HEIC/AVIF write via Pillow (_pil_write) since cv2 has NO encoder for them (writing .heic via cv2 RAISES — a HEIC input used to crash on save). imwrite never raises (catches cv2.error). api.remove_visible copies the original bytes verbatim on a no-op (nothing removed + same output format) rather than a lossy re-encode, so a clean image round-trips byte-identical. noai/constants.SUPPORTED_FORMATS now includes .heic/.heif/.avif alongside png/jpg/jpeg/webp (pillow-heif is core, so read+write both work), so batch discovers them and the CLI no longer warns on an iPhone HEIC; JPEG-XL stays OUT (metadata/strip-only, no pixel decoder without pillow-jxl). The invisible/SynthID path is inherently a full-frame diffusion regeneration (every pixel changes by design — you cannot "work with originals" there), but it no longer piles gratuitous re-encodes on top: watermark_remover saves the regenerated output through image_io.imwrite (not raw PIL.save, which defaults to JPEG q75), invisible_engine writes its pre-diffusion temp as lossless PNG (not a re-compressed copy of a JPEG input), and the output metadata strip goes through the byte-level metadata.remove_ai_metadata (see its bullet) which for JPEG does NOT re-encode the DCT at all — pixels stay bit-identical.
  • api.py — the high-level convenience API, re-exported lazily at the package top level via __init__.__getattr__ (PEP 562, so import remove_ai_watermarks stays cheap): remove_visible(source, output=None, *, sensitivity="auto", backend="auto", strip_metadata=True, write_noop=True) -> (result_bgr, [labels]) (source = path OR BGR ndarray; a PATH auto-reads metadata provenance and preserves alpha, an ARRAY does neither; write_noop=True writes a clean passthrough copy when nothing is removed, False leaves output untouched so a "no mark = produce nothing" caller like the CLI visible command does not clobber a pre-existing file there) and visible_provenance(path) -> frozenset[str] (the single metadata→vendor-keys mapper; cli._visible_provenance is a thin None-guarded wrapper over it). remove_visible is the ONE path the CLI and library sharecli.cmd_visible's --mark auto branch delegates entirely to it (read → provenance → remove_auto_marks → write → strip_metadata), so there is no CLI-vs-library drift; strip_metadata defaults True to match visible --strip-metadata. This is where a library caller should start — NOT the engines directly (GeminiEngine/TextMarkEngine have no remove_watermark any more; removal is registry remove_auto_marks/KnownMark.remove; the old single-strongest best_auto_mark is gone — removal takes EVERY mark). identify is NOT top-level re-exported (it collides with the identify submodule); use from remove_ai_watermarks.identify import identify.

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.mdread the relevant section there before changing the diffusion pipelines, strength defaults, resolution handling, or metadata coverage.

  • Visible-mark fill quality is background/backend-dependent. The fill only touches the mark footprint (no outside-box damage) and whether the mark is removed is fill-independent — cv2/MI-GAN/LaMa all strip the shape; only the recovered region's quality differs. Flat backgrounds: all clean (cv2 often crispest). Textured/regular-structured (fabric, grid): cv2 smears, MI-GAN can ghost/hallucinate, LaMa best. The old reverse-alpha recovered true pixels so it was sometimes cleaner on structure, but localize -> fill trades that for robustness (moved/re-rendered marks, no per-mark capture); auto = LaMa > MI-GAN > cv2 with a one-time cv2-fallback warning. Head-to-head vs v0.12.1 on the full visible set: doubao/jimeng identical (100%/100%), gemini strict coverage a few points lower (the metadata-stripped faint ones now mostly recovered by the default white-core rescue in the gemini FP gate; the residual via assume-ai), clearance ~98% both. Detail in docs/known-limitations.md.
  • invisible processes at native resolution for inputs >= 1024px long side and auto-upscales smaller inputs to a 1024px floor (--min-resolution 0 disables; --max-resolution N is 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 helper invisible_engine._target_size — keep the logic there, unit-tested without the model. For large inputs that OOM, --tile is 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 pyright can 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), not UnidentifiedImageError, when opening a file. Code that opens user-supplied or unknown-format files should except Exception, not just OSError/UnidentifiedImageError.
  • rich was dropped: the CLI + analysis scripts print plain text (click.echo / the scripts/_plain_console.py shim). rich is 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.
  • HEIC/AVIF are decodable on BOTH paths now: the pixel/removal path via the image_io.imread Pillow fallback (+ core pillow-heif), and metadata detection via a plugin-free binary scan. C2PA removal in those containers (and MP4/MOV/M4V) is noai/isobmff.py; JPEG-XL stays metadata/strip-only (Pillow can't decode it without pillow-jxl, not a dep). Non-ISOBMFF audio/video (WebM/MP3/WAV/FLAC/OGG) strips losslessly via ffmpeg on PATH. An AI-generator token in an Exif meta-box item (bytes in mdat/idat) is now blanked in place by isobmff.blank_ai_exif_tokens (same-length space overwrite, piexif-validated so a coincidental II/MM run in pixels is ignored — no iinf/iloc surgery, mirrors blank_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). non-ISOBMFF audio/video (WebM/MP3/WAV/FLAC/OGG) strips losslessly via ffmpeg on PATH. An AI-generator token in an Exif meta-box item (bytes in mdat/idat) is now blanked in place by isobmff.blank_ai_exif_tokens (same-length space overwrite, piexif-validated so a coincidental II/MM run in pixels is ignored — no iinf/iloc surgery, mirrors blank_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/verify for OpenAI. Validate the OpenAI arm FIRSTopenai.com/verify is 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 (see docs/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 --strength is unset; explicit --strength always 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).
  • controlnet is the default pipeline; --pipeline sdxl is 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.