Compare commits

..
8 Commits
Author SHA1 Message Date
Victor Kuznetsov 72812de03c chore(release): v0.8.4 2026-05-31 20:46:52 -07:00
Victor KuznetsovandClaude Opus 4.8 e501bec9ff feat(identify): detect visible Doubao/Jimeng marks; keep identify import torch-free
identify previously ran only the Gemini sparkle as a visible detector, so a
Doubao/Jimeng image with stripped TC260 metadata had no visible fallback. Add
`_visible_text_marks` (registry-backed) so the ByteDance Doubao 豆包AI生成 and
Jimeng 即梦AI marks are detected too, each gated by its own engine NCC threshold
via MarkDetection.detected. New signals `visible_doubao` / `visible_jimeng`
(medium), same stripped-metadata fallback role as the sparkle; excluded from
integrity-clash vendor claims; set platform only when no harder signal did.

Also make `noai/__init__` lazy (PEP 562 __getattr__): importing the light
`noai.c2pa` / `noai.constants` submodules (which identify needs) no longer
eagerly pulls `watermark_remover`, which imports torch + diffusers at module
top. `import remove_ai_watermarks.identify` drops from ~420 MB to ~21 MB in a
full gpu/detect install (torch not loaded), so it fits a 512 MB host; the
removal API resolves lazily on first access. Guarded by TestIdentifyImportIsLight.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:43:52 -07:00
Victor KuznetsovandClaude Opus 4.8 4b4049a6f1 docs(text-protection): update stale strength note (~0.05 -> ~0.30 SynthID threshold)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:53:48 -07:00
Victor KuznetsovandClaude Opus 4.8 2e23cf9c4b fix(build): pin hatchling<1.28 to keep Metadata-Version 2.4 (PyPI upload rejected 2.5)
hatchling 1.28+ emits Metadata-Version 2.5 (PEP 639); the twine in
pypa/gh-action-pypi-publish@release/v1 rejects it, which failed the v0.8.3 PyPI
upload (build + tag-match passed, upload step failed, nothing uploaded). 1.27.x
emits 2.4, which uploads fine (0.8.2). Pin the build backend; lift once the action
twine is 2.5-aware or the workflow uses uv publish.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:49:19 -07:00
Victor KuznetsovandClaude Opus 4.8 c155f81078 chore(release): v0.8.3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:41:10 -07:00
Victor KuznetsovandClaude Opus 4.8 6075ea2c55 docs(synthid): cite the genuine Gemini A/B (gemini_633uuy) for the protect_text finding
The protect_text correction was first cited only to qw1212ss_pic3, an OpenAI
image that carries no Google SynthID (so the Gemini oracle is not a valid check
for it). The updated study re-ran the 0.3 protect_text A/B on a genuine Gemini
SynthID image (gemini_633uuy, photo with a Chinese-text sign): SynthID removed
with protection ON and OFF, Gemini-oracle verified. Cite that as the load-bearing
evidence so the claim rests on a valid subject. Confirms the shipped 0.30 +
protect_text=ON default on a real Gemini target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:39:16 -07:00
Victor KuznetsovandClaude Opus 4.8 b991b11a19 docs(synthid): correct protect_text guidance -- it does NOT block removal (keep ON)
An A/B at strength 0.3 on a real e-commerce infographic (updated GPU study)
reverses the earlier claim: SynthID is a GLOBAL watermark, so 0.3 removes it
whether protect_text is on or off, and protection SALVAGES text fidelity (medium
headings/body stay readable; off, they garble). The earlier 'protect_text shields
the watermark, use --no-protect-text' was wrong -- it mistook the 0.10 strength
failure for a protection effect. Recommended SynthID config: ~0.3 + protect_text ON
(the default). Also document the oracle scope: the Gemini app 'Verify with SynthID'
is the only valid SynthID oracle; openai.com/verify is provenance-scoped (C2PA) and
does NOT measure SynthID. Corrects CLAUDE.md + README + watermark_profiles comment
shipped in cddbaf6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:50:13 -07:00
Victor KuznetsovandClaude Opus 4.8 cddbaf6413 fix(invisible): raise default strength 0.10 -> 0.30 (current SynthID threshold); flag ctrlregen experimental
An oracle-verified GPU strength study (Modal A100, native res, Gemini-app
'Verify with SynthID', n=3 fresh Gemini images, protect_text/faces off) found the
current Google SynthID survives strength 0.10/0.15/0.2 and is removed only at 0.3.
The previous 0.10 default (set from an n=1 result) no longer clears it -- Google
hardened SynthID and the threshold has climbed 0.05 -> 0.10 -> ~0.3. Bump
DEFAULT_STRENGTH to 0.30; OpenAI/ChatGPT carry C2PA not SynthID, so 0.10 is plenty
there (pass --strength 0.10). Note protect_text shields the text regions SynthID
hides in (use --no-protect-text for full removal on text-heavy images).

The same study found ctrlregen at clean-noise strength DESTROYS real images
(hallucinated micro-text in smooth regions), with no usable middle setting, so the
literature's 'clean-noise is the lever' did not hold empirically. Flag ctrlregen
EXPERIMENTAL in the CLI --pipeline help, README, and watermark_profiles; SDXL
img2img at ~0.3 stays the shippable path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:38:49 -07:00
13 changed files with 220 additions and 47 deletions

No files matched your search

+5 -5
View File
@@ -14,12 +14,12 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r
## 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`. `publish.yml` stays release-only and now verifies the release tag matches the `pyproject.toml` version (fails the build on a mismatch) before building. **Release flow:** bump the version in `pyproject.toml` + `src/remove_ai_watermarks/__init__.py` + `uv.lock` (the project's own `[[package]]` entry, ~line 2868), commit `chore(release): vX.Y.Z`, `git tag -a vX.Y.Z -m vX.Y.Z` (annotated — `git tag` without `-m` errors here), push `main` + the tag, then `gh release create vX.Y.Z` — **PyPI publish triggers on the GitHub Release `published` event, NOT on the tag push**, so the tag alone does not publish. **Sdist must exclude `data/`** (`[tool.hatch.build.targets.sdist] exclude = ["/data"]`): hatchling's default sdist bundles all VCS-tracked files, so the committed `data/` test corpora (synthid_corpus images ~65 MB + the visible-mark captures) pushed the **0.8.0** sdist past PyPI's per-project file-size limit (400 "File too large") — the wheel uploaded but the sdist was rejected, so 0.8.0 shipped wheel-only and 0.8.1 carried the fix. The wheel only ships `src/` (via `[tool.hatch.build.targets.wheel] packages`), so it was never affected. **A failed PyPI upload of one artifact still leaves the other live and you cannot re-upload the same version** — fix the build and cut the next patch.
- **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`. `publish.yml` stays release-only and now verifies the release tag matches the `pyproject.toml` version (fails the build on a mismatch) before building. **Release flow:** bump the version in `pyproject.toml` + `src/remove_ai_watermarks/__init__.py` + `uv.lock` (the project's own `[[package]]` entry, ~line 2868), commit `chore(release): vX.Y.Z`, `git tag -a vX.Y.Z -m vX.Y.Z` (annotated — `git tag` without `-m` errors here), push `main` + the tag, then `gh release create vX.Y.Z` — **PyPI publish triggers on the GitHub Release `published` event, NOT on the tag push**, so the tag alone does not publish. **Sdist must exclude `data/`** (`[tool.hatch.build.targets.sdist] exclude = ["/data"]`): hatchling's default sdist bundles all VCS-tracked files, so the committed `data/` test corpora (synthid_corpus images ~65 MB + the visible-mark captures) pushed the **0.8.0** sdist past PyPI's per-project file-size limit (400 "File too large") — the wheel uploaded but the sdist was rejected, so 0.8.0 shipped wheel-only and 0.8.1 carried the fix. The wheel only ships `src/` (via `[tool.hatch.build.targets.wheel] packages`), so it was never affected. **A failed PyPI upload of one artifact still leaves the other live and you cannot re-upload the same version** — fix the build and cut the next patch. **Build backend is pinned `hatchling<1.28`** (`[build-system] requires`): hatchling 1.28+ emits **Metadata-Version 2.5** (PEP 639), which the twine bundled in `pypa/gh-action-pypi-publish@release/v1` rejects (`"'2.5' is not a valid Metadata-Version"`) — this **failed the v0.8.3 PyPI upload on 2026-06-01** (tag-match + build passed, the upload step failed; nothing was uploaded, so the version stayed empty on PyPI). 1.27.x emits 2.4, which uploads fine (0.8.2 shipped on it). The pin is unpinned `requires = ["hatchling"]` no longer safe because `uv build` pulls the latest hatchling. Lift the pin only once the publish action's twine is ≥ 6.1.0 (2.5-aware) or the workflow moves to `uv publish`.
- `bash maintain.sh` — uv-outdated, uv-secure, ruff check/fix, ruff format, pyright, pytest -n auto
- **Strict pyright is clean across `src/` (0 errors).** The cv2/torch/diffusers boundary files (`gemini_engine`, `region_eraser`, `doubao_engine`, `face_protector`, `humanizer`, `invisible_engine`, `noai/watermark_remover`, and the whole `noai/ctrlregen/` subpackage) carry a documented per-file `# pyright:` relax pragma (or, for `ctrlregen`, a `tool.pyright.executionEnvironments` entry) 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. (`uv-secure` is clean since idna was bumped 3.11 -> 3.16, fixing GHSA-65pc-fj4g-8rjx.)
- **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.
- To add a dev tool (pytest/ruff/pyright) into the env, use `uv sync --frozen --extra dev --extra gpu`, **never `uv pip install`** — `uv 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 and `noai/__init__` (eager pipeline import) then fails. `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.
- To add a dev tool (pytest/ruff/pyright) into the env, use `uv sync --frozen --extra dev --extra gpu`, **never `uv pip install`** — `uv 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); synthetic byte blobs cover the 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.
@@ -34,7 +34,7 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r
- `noai/c2pa.py` — PNG chunk parser; use `extract_c2pa_chunk(path)` to get raw caBX payload, `has_c2pa_metadata(path)` to detect. Do not reimplement chunk parsing. `extract_c2pa_info(path)` sets `synthid_watermark`/`synthid_vendors` when the manifest is signed by a SynthID-using vendor, and `soft_binding`/`soft_binding_vendors` when a `c2pa.soft-binding` `alg` names a forensic-watermark vendor (`soft_binding_vendors_in(buffer)` is the shared byte-scan, used by both the PNG parser and the non-PNG binary path). PNG/caBX chunk reads are clamped to the remaining file size (`safe_length = min(length, remaining)`; skipped chunks use seek) so a malformed huge `length` cannot drive a multi-GB allocation (shared safety discipline matching `isobmff.scan_c2pa_region`).
- `noai/constants.py` — PNG_SIGNATURE, C2PA_CHUNK_TYPE, C2PA_SIGNATURES, C2PA_ISSUERS, `SYNTHID_C2PA_ISSUERS` (issuers that pair SynthID with C2PA: Google, OpenAI), and `C2PA_SOFT_BINDINGS` (soft-binding `alg` prefix → forensic-watermark vendor: Adobe TrustMark, Digimarc, Imatag, Steg.AI, Microsoft, ...). Add a new issuer/binding here, not inline.
- `metadata.py` — `scan_head(path, size=1MB)` is the shared input for every C2PA/AIGC/IPTC byte scan: first `size` bytes plus the payloads of any provenance metadata found beyond that window — for ISOBMFF, the late provenance boxes from `isobmff.scan_c2pa_region` (catches a manifest after a large `mdat`); for **PNG**, the late `tEXt`/`iTXt`/`zTXt`/`eXIf`/`iCCP` chunks from `_png_late_metadata` (catches an XMP/EXIF packet appended after a large `IDAT`, e.g. a TC260 AIGC label at ~2.7 MB). Behavior-neutral (`f.read(size)`) for non-ISOBMFF inputs and for any file that fits within `size`. Use it instead of `open().read(1MB)` for any new marker scan. `synthid_source(path)` returns the vendor name(s) if the C2PA manifest implies a SynthID pixel watermark, else None. Format-agnostic: PNG via the caBX parser, JPEG/WebP/AVIF/HEIF/JXL via a binary scan (C2PA marker + SynthID issuer + AI-source marker). `get_ai_metadata` surfaces the verdict, and `metadata --check` prints it as a callout. Both `get_ai_metadata` and `has_ai_metadata` guard the PIL open with `except Exception` (HEIC/unknown formats raise non-OSError) and fall through to the binary scan. `xai_signature(path)` detects xAI/Grok's EXIF-only scheme (`ImageDescription` = `Signature: <base64>` + UUID `Artist`); it feeds `has_ai_metadata`, `get_ai_metadata` (key `xai_signature`), and `identify`. `iptc_ai_system(path)` detects the IPTC Photo Metadata 2025.1 AI-disclosure XMP properties (`IPTC_AI_FIELD_MARKERS` = `AISystemUsed`/`AISystemVersionUsed`/`AIPromptInformation`/`AIPromptWriterName`) and returns the `AISystemUsed` generator name (or `"fields present"`). `remove_ai_metadata` routes **ISOBMFF video** (`.mp4`/`.mov`/`.m4v`) through the same `isobmff.strip_c2pa_boxes` as AVIF/HEIF (MP4 is ISOBMFF), and `_scrub_ai_exif` removes the xAI signature + AI-generator EXIF tags on JPEG output. `strip_c2pa_boxes` is **fail-safe** on a malformed box: it returns the original bytes unchanged with a logged warning instead of truncating the tail to EOF (detection-only `scan_c2pa_region` still stops at a malformed box). `_png_late_metadata` clamps each late-chunk read to the remaining file size (`safe_length = min(length, remaining)`) so a malformed `length` cannot drive a multi-GB allocation.
- `identify.py` — the OpenAI rollout caveat is keyed on `_vendor_of(synthid) == "OpenAI"` (not a raw substring over the issuer + verdict blob). `identify(path)` aggregates every locally-readable signal (C2PA issuer→platform, C2PA soft-binding forensic-watermark vendor, IPTC "Made with AI" + IPTC 2025.1 `AISystemUsed`, embedded SD/ComfyUI params, SynthID proxy, xAI/Grok EXIF signature via `metadata.xai_signature`, the China TC260 AIGC label via `metadata.aigc_label`, the HuggingFace `hf-job-id` job marker via `metadata.huggingface_job`, the Samsung Galaxy AI editing marker via `metadata.samsung_genai`, visible Gemini sparkle, open invisible watermark, Adobe TrustMark via `trustmark_detector`) into one `ProvenanceReport`. `is_ai_generated` is True or None (never asserted False — stripped metadata is not proof of clean origin). The `hf_job`, visible-sparkle, and Samsung `samsung_genai` signals are **medium** confidence: each lifts an otherwise-Unknown verdict to a tentative AI (`hf_only` / `visible_only` / `samsung_only`, parallel branches) but is excluded from the high-confidence `ai_from_metadata` set, so none overrides a hard metadata signal. Visible-sparkle is promoted only at confidence ≥ `_SPARKLE_THRESHOLD` (0.5; corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49). The cv2 dependency lives in `gemini_engine.detect_sparkle_confidence`, not here. **C2PA platform attribution is device-token-first, issuer-scan fallback** (`_device_platform` scans manifest bytes for `_DEVICE_C2PA_PLATFORM` tokens, then `_attribute_platform`/`_ISSUER_PLATFORM`). **Why, verified on real signed files 2026-05-26:** the old issuer-only byte-scan matched ANY issuer substring anywhere, so multi-entity manifests mis-attributed -- Leica→"Truepic" (a signing authority in the trust chain), Nikon→"Adobe Firefly" (XMP-toolkit "Adobe" + the sample's "Adobe_MAX" name), Pixel→"Google (Gemini)" ("Google LLC" cert org), Truepic→"Google". A distinctive device token wins instead. **Token distinctiveness is load-bearing:** bare `b"Truepic"` mis-fires (it appears in unrelated trust chains -- it mis-attributed the OpenAI `chatgpt-1.png` fixture), so the token is the specific `b"Truepic_Lens"` from the Lens SDK claim generator; likewise `b"Pixel Camera"` (cert CN) not bare `b"Pixel"`. `_DEVICE_C2PA_PLATFORM` lists ONLY tokens **verified against a real C2PA file**: Leica (`lc_c2pa`/`Leica Camera`), Nikon (`NIKON`), Pixel (`Pixel Camera` -- from a real Pixel 10 Pro file attached to c2pa-rs issue #1609/#1554), Sony (`sony.sig`/`sony.cert` -- Sony's own C2PA assertion namespace, verified on a real Sony PXW-Z300 file; NOT bare "Sony" which is a common EXIF Make), Truepic (`Truepic_Lens`). Canon/Bria have **no public direct-download C2PA sample** (checked exhaustively: GitHub issue/PR attachments, contentcredentials gallery, HF datasets -- all upload-to-verify or token-gated; Canon's only public file was a self-signed hobbyist CR3, not factory), so they stay unmapped until a real file is captured (same fixture discipline as Grok/Doubao). The Sony sample is video (MP4) -- our ISOBMFF C2PA path detects it; Sony Alpha stills likely share the `sony.*` namespace but are not separately verified. **Samsung Galaxy + ASUS Gallery live in a separate `_SIGNER_C2PA_PLATFORM` (scanned after `_device_platform`, before the issuer fallback), NOT in `_DEVICE_C2PA_PLATFORM`** — verified on real signed files 2026-05-29. Reason: a Galaxy phone stamps BOTH its device cert AND a `trainedAlgorithmicMedia`/genAIType AI marker on a Generative-Edit image, so treating it as a "genuine camera capture" would false-fire integrity-clash rule 2 on every Galaxy AI edit. The signer tokens (`b"Samsung Galaxy"` cert org — distinct from the EXIF `SM-xxxx` model string on ordinary Samsung photos; `b"com.asus.gallery"` claim generator) only resolve the platform label; the AI verdict still comes from the source-type / genAIType. ASUS Gallery is a C2PA-signed edit with no AI marker, so it attributes the platform without asserting `is_ai`. **Samsung's `genAIType` (in the proprietary `PhotoEditor_Re_Edit_Data` JSON) is an undocumented Galaxy-AI editing marker** (`metadata.samsung_genai`, gated on the `PhotoEditor_Re_Edit_Data` container; non-zero value = AI tool used, values {1,5} observed): medium-confidence because the field has no public spec (verified 2026-05-29: absent from C2PA spec + Samsung docs), but it co-occurred with `trainedAlgorithmicMedia` in 3/3 verified files that record a source-type and was the SOLE AI marker on a Galaxy S24 file that omits the source type. Camera C2PA marks capture authenticity, not AI (Pixel carries `computationalCapture`, not `trainedAlgorithmicMedia`), so these never set `is_ai` -- that stays driven by digital-source-type. `c2pa.cbor_text_after` (now public) is best-effort for the `generator` detail string only and can be None when the manifest keys it `claim_generator_info` (Pixel). **Issuer→generator mapping iLine truncated
- `identify.py` — the OpenAI rollout caveat is keyed on `_vendor_of(synthid) == "OpenAI"` (not a raw substring over the issuer + verdict blob). `identify(path)` aggregates every locally-readable signal (C2PA issuer→platform, C2PA soft-binding forensic-watermark vendor, IPTC "Made with AI" + IPTC 2025.1 `AISystemUsed`, embedded SD/ComfyUI params, SynthID proxy, xAI/Grok EXIF signature via `metadata.xai_signature`, the China TC260 AIGC label via `metadata.aigc_label`, the HuggingFace `hf-job-id` job marker via `metadata.huggingface_job`, the Samsung Galaxy AI editing marker via `metadata.samsung_genai`, the visible marks — Gemini sparkle plus the ByteDance Doubao 豆包AI生成 / Jimeng 即梦AI text marks via the `watermark_registry` — open invisible watermark, Adobe TrustMark via `trustmark_detector`) into one `ProvenanceReport`. `is_ai_generated` is True or None (never asserted False — stripped metadata is not proof of clean origin). The `hf_job`, visible-mark, and Samsung `samsung_genai` signals are **medium** confidence: each lifts an otherwise-Unknown verdict to a tentative AI (`hf_only` / `visible_only` / `samsung_only`, parallel branches; `visible_only` fires on any `visible_*` signal) but is excluded from the high-confidence `ai_from_metadata` set, so none overrides a hard metadata signal. **Visible-mark detection** (`check_visible`, signals `visible_sparkle` / `visible_doubao` / `visible_jimeng`): the Gemini sparkle keeps its own file-level path (`_visible_sparkle` → `gemini_engine.detect_sparkle_confidence`, promoted only at confidence ≥ `_SPARKLE_THRESHOLD` 0.5; corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49), while Doubao/Jimeng reuse the registry detectors (`_visible_text_marks` → `watermark_registry`), each gated by its own engine NCC threshold via `MarkDetection.detected` (Doubao 0.4, Jimeng 0.45). Doubao/Jimeng are normally also caught by the TC260 AIGC metadata label, so the visible path is their stripped-metadata fallback. Visible marks set `platform` only when no harder signal already did, and (like the sparkle) are excluded from integrity-clash vendor claims. The cv2 dependency lives in the engines, not here. **`import identify` is deliberately light** (~21 MB; ~36 MB with cv2 loaded by a visible-mark run, ~106 MB for a full `check_visible` run): it imports only the pure `noai.c2pa`/`noai.constants` submodules, and `noai/__init__` is lazy (see "Test and lint"), so torch/diffusers are NOT pulled at import even in a full `gpu`/`detect` install — fits a 512 MB host. The heavy paths are opt-in: `check_invisible=True` needs the `detect`/`trustmark` extras (each pulls **torch**; TrustMark also **downloads weights**), so on a core-only deploy leave `check_invisible` off (it is a no-op there anyway). Before the lazy `__init__`, the mere presence of torch in the env inflated `import identify` to ~420 MB. **C2PA platform attribution is device-token-first, issuer-scan fallback** (`_device_platform` scans manifest bytes for `_DEVICE_C2PA_PLATFORM` tokens, then `_attribute_platform`/`_ISSUER_PLATFORM`). **Why, verified on real signed files 2026-05-26:** the old issuer-only byte-scan matched ANY issuer substring anywhere, so multi-entity manifests mis-attributed -- Leica→"Truepic" (a signing authority in the trust chain), Nikon→"Adobe Firefly" (XMP-toolkit "Adobe" + the sample's "Adobe_MAX" name), Pixel→"Google (Gemini)" ("Google LLC" cert org), Truepic→"Google". A distinctive device token wins instead. **Token distinctiveness is load-bearing:** bare `b"Truepic"` mis-fires (it appears in unrelated trust chains -- it mis-attributed the OpenAI `chatgpt-1.png` fixture), so the token is the specific `b"Truepic_Lens"` from the Lens SDK claim generator; likewise `b"Pixel Camera"` (cert CN) not bare `b"Pixel"`. `_DEVICE_C2PA_PLATFORM` lists ONLY tokens **verified against a real C2PA file**: Leica (`lc_c2pa`/`Leica Camera`), Nikon (`NIKON`), Pixel (`Pixel Camera` -- from a real Pixel 10 Pro file attached to c2pa-rs issue #1609/#1554), Sony (`sony.sig`/`sony.cert` -- Sony's own C2PA assertion namespace, verified on a real Sony PXW-Z300 file; NOT bare "Sony" which is a common EXIF Make), Truepic (`Truepic_Lens`). Canon/Bria have **no public direct-download C2PA sample** (checked exhaustively: GitHub issue/PR attachments, contentcredentials gallery, HF datasets -- all upload-to-verify or token-gated; Canon's only public file was a self-signed hobbyist CR3, not factory), so they stay unmapped until a real file is captured (same fixture discipline as Grok/Doubao). The Sony sample is video (MP4) -- our ISOBMFF C2PA path detects it; Sony Alpha stills likely share the `sony.*` namespace but are not separately verified. **Samsung Galaxy + ASUS Gallery live in a separate `_SIGNER_C2PA_PLATFORM` (scanned after `_device_platform`, before the issuer fallback), NOT in `_DEVICE_C2PA_PLATFORM`** — verified on real signed files 2026-05-29. Reason: a Galaxy phone stamps BOLine truncated
- `watermark_registry.py` — **single catalog of known visible watermarks**, the unified "find known marks in their usual places, recognize, remove" entry. **Reverse-alpha based by policy**: a mark is listed only once a real alpha map has been captured for it, and removal inverts that map (`original = (wm - a*logo)/(1-a)`) — Gemini recovers cleanly with no inpaint (its sparkle alpha comes from a pure-black capture, so it is near-exact), while **Doubao and Jimeng both add an always-on THIN residual inpaint** over the glyph footprint (their text marks re-rasterize + jitter a few px per image, so a single capture cannot pixel-cancel them; the inpaint blends into the reverse-alpha-recovered pixels). Arbitrary-region inpainting still lives in `region_eraser`/`erase`. Each `KnownMark` ties a key to {usual `location`, `in_auto` flag, `recovery` (="reverse-alpha"), a `detect` adapter → uniform `MarkDetection`, a `remove` adapter}. Entries today: `gemini` (bottom-right sparkle), `doubao` (bottom-right "豆包AI生成"), and `jimeng` (bottom-right "★ 即梦AI"). `detect_marks` scans all; `best_auto_mark` picks the highest-confidence detection. **Cross-engine confidences aren't directly comparable**, so the gemini adapter applies the corpus-validated 0.5 sparkle threshold (`_GEMINI_AUTO_MIN_CONF`) for its `detected` flag — otherwise the gemini engine's loose internal threshold weakly fires (~0.36) on the Doubao text and hijacks `auto`. The shape-keyed Doubao/Jimeng NCC detectors don't cross-fire (jimeng scores ~0.22 on the Doubao strip, well under its 0.45 threshold), so `auto` picks the right one on a Doubao vs Jimeng image. `cli.cmd_visible` is registry-driven: `--mark auto` → `best_auto_mark`, `--mark <key>` → that mark; `--mark` choices come from `mark_keys()`. `_doubao_remove`/`_jimeng_remove` apply reverse-alpha only when the mark is detected AND `reverse_alpha_available`; outside that, removal is **skipped** (not inpainted). Add a new visible mark = one `KnownMark` entry + its engine (with a captured alpha map); do not re-add per-mark `if` branches in the CLI. **Alpha-on-save policy (issue #30):** `cli._write_bgr_with_alpha` rejoins the input's alpha plane **unchanged** — it must NOT zero alpha in the watermark bbox. Reverse-alpha (and `erase` inpaint) recover real pixels there, so zeroing alpha punched a transparent hole that renders as a solid **white box** on any non-transparent viewer (Gemini app exports are opaque RGBA, so every user hit it; regression-guarded by `test_visible_keeps_alpha_opaque_in_watermark_region`). The registry `remove()` still returns its region (used for `inpaint_residual` positioning), but the CLI no longer uses it to clear alpha.
- `gemini_engine.py` — visible Gemini-sparkle remover/detector (cv2/numpy, no GPU). `detect_sparkle_confidence(path)` is the file-level entry point used by `identify.py`. The public entry points normalize a grayscale (2D) or RGBA (4-channel) input to BGR up front so a non-BGR image does not crash the cv2 pipeline. **Removal is reverse-alpha with NO inpaint** (`remove_watermark` → `_reverse_alpha_blend`): the sparkle alpha is computed (`alpha = max(R,G,B)/255`) from the bundled sparkle-on-black captures `assets/gemini_bg_{96,48}.png`, which are PURE-BLACK so the alpha is near-exact — re-verified clean on `demo_banana_before.png` 2026-05-31 (the registry's optional `inpaint_residual` is a no-op on a clean removal; an earlier "Gemini smears" read was a misjudged soft-fur original, not an artifact). **The bg assets are now rebuilt from OUR OWN controlled captures** (`data/gemini_capture/captures/`, committed) by `scripts/visible_alpha_solve.py gemini`, which locates the 96px sparkle on the black capture and crops it to the two logo sizes; our capture matched the previously third-party-sourced `gemini_bg_96.png` to **NCC 0.9998**, validating the asset and making it reproducible. Gemini's multi-size fixed-slot model is genuinely different from the Doubao/Jimeng text-strip engines (so it stays a separate engine, not part of the shared-base refactor).
- `doubao_engine.py` — visible Doubao "豆包AI生成" remover/detector (cv2/numpy, no GPU). `DoubaoEngine.locate` anchors a bottom-right box by **geometry** (mark scales with image WIDTH), `extract_mask` pulls the light, low-chroma glyphs (the detection candidate) using a per-pixel channel-spread proxy `sat = roi.max(axis=2) - roi.min(axis=2)` (no HSV conversion). `detect` is **shape-consistent**: it matches the bundled alpha glyph silhouette (`assets/doubao_alpha.png`) against the candidate via zero-mean normalized correlation (`_template_match_score`, cv2 `TM_CCOEFF_NORMED`), gated at `DETECT_NCC_THRESHOLD` 0.4 over a small `DETECT_MIN_COVERAGE` floor. Keying on glyph SHAPE (not coverage heuristics) fixed #23 (corpus FP 7/1243). **Removal = reverse-alpha + thin residual inpaint** (`remove_watermark_reverse_alpha`): `original = (wm - a*logo)/(1-a)` from the bundled alpha map + `_ALPHA_LOGO_BGR` (pure white) + `_ALPHA_*_FRAC` geometry, then a deliberately THIN inpaint (`_RESIDUAL_*`, `INPAINT_NS`) over the glyph footprint clears leftover edges without smearing. **Alpha is rebuilt by `scripts/visible_alpha_solve.py` (the careful gray-self solve: cubic background fit, mean over channels, full halo, unblurred), same recipe as Jimeng** — the captures are committed in `data/doubao_capture/captures/`. **Removal aligns ALWAYS** (no `_ALPHA_NATIVE_BAND` fast-path): it tries fixed geometry AND `_aligned_alpha_map`'s `TM_CCOEFF_NORMED` scale+position search and keeps the lower-residual one — the mark is re-rasterized and a few px off per image, so fixed geometry alone leaves a visible outline even at 2048. **The locate box (`WM_*`) is generous (0.22 wide, margins 0.004) and reaches close to the corner** — a tight box (the old 0.185 / margin 0.012) let a corner-ward shift fall OUTSIDE the alignment search, so the align missed and a readable outline survived; regression-guarded by `test_recovers_shifted_mark_on_texture` (composes the alpha shifted on a known texture; old box ~29 vs new ~1 mean residual). **Issue #13 follow-up defect (found 2026-05-31): the SHIPPED Doubao removal left a clearly READABLE "豆包AI生成" outline on the real `doubao-1.png` sample, while `detect` returned conf 0.0 (it is fooled by a thin outline) so `test_reverse_alpha_removes_mark` passed and the old "56/56 clean" claim was detector-measured, not visual.** Root cause: bad alpha (under-estimated, max ~0.65) + fixed-no-inpaint + tight box; the careful rebuild + always-align + thin inpaint + wide box takes it from a readable outline to faint texture-level traces (parity with Jimeng — a single capture cannot pixel-cancel a per-image re-rasterized mark). **Lesson: a detector-only removal test is insufficient; assert visual residual (the textured-shift test).** **`extract_mask` guards a degenerate ROI (`bh < 16 or bw < 16` -> empty mask, skips cv2):** the always-align removal scores each placement with a residual `detect(out)`, and on an extremely wide/short image (e.g. 2048x1, `test_wide_short_does_not_raise`) that fed cv2's GaussianBlur a ~1-px-tall ROI and **faulted natively on Windows py3.12 (access violation, non-deterministic — one CI cell went red while a re-run passed)**; the old at-native path never ran `detect` on degenerate sizes. Real images always clear the guard (the `WM_*` box floors are `max(16, …)` height / `max(40, …)` width), so it only short-circuits slivers. `reverse_alpha_available` is just "asset present"; the registry gates removal on `detect`. The shipped third-party `_refs/zhengsuanfa_doubao_alpha_120x20.png` is NOT a usable alpha (verified 2026-05-29). Arbitrary-region inpainting is `region_eraser`/`erase`.
@@ -84,5 +84,5 @@ Who embeds what, and whether it is locally detectable (so we know which gaps are
- Metadata detection for AVIF/HEIF/JPEG-XL relies on a binary scan for `C2PA_UUID` + `IPTC_AI_MARKERS`, plus EXIF `Software` / XMP `CreatorTool` generator tags via `metadata.exif_generator` (validated with synthesized AVIF/JPEG fixtures + an XMP raw-scan fixture). C2PA removal in those containers is implemented via `noai/isobmff.py` (top-level ``uuid`` / ``jumb`` box stripper, no re-encoding), which now also drops a top-level XMP ``uuid`` box that carries an AI label (matched by AI-marker content, not by the XMP UUID, so byte-order-robust) and covers MP4/MOV/M4V/M4A by content sniff. **Non-ISOBMFF audio/video removal is via ffmpeg** (`_FFMPEG_STRIP_EXTS` -> `_strip_with_ffmpeg`): WebM/Matroska (EBML), MP3 (ID3), WAV/FLAC/OGG (RIFF/Vorbis) are stripped losslessly with `ffmpeg -map_metadata -1 -map_chapters -1 -c copy` (codec data untouched). Requires ffmpeg on PATH; raises `RuntimeError` if absent or if ffmpeg can't parse the file. Verified end-to-end (a real ffmpeg-made WAV/MP3 with a `title=Suno AI` tag -> tag gone, audio bytes preserved). **Meta-box XMP now handled (`isobmff.blank_ai_xmp_packets`, v0.6.9):** an AI-label XMP packet stored as a meta-box `mime` item (AVIF/HEIF) is blanked in place (overwritten with spaces of the same length, so `iloc` offsets and the coded image stay valid). **Still NOT built:** an `Exif` *item* inside the `meta` box (rare -- AI labels are XMP) needs full `iinf`/`iloc` surgery (offset rewrite) with corruption risk -- exiftool (R/W/C for HEIC/AVIF EXIF+XMP, verified on exiftool.org 2026-05-27) would do it but is a non-installed binary dep, so it stays a documented gap. **Audio watermark DETECTION (Resemble PerTh) was evaluated and NOT built (2026-05-26):** `resemble-perth`'s `PerthImplicitWatermarker.get_watermark()` returns a raw bit-array with **no presence/confidence flag** (clean audio decodes to arbitrary bits too), so reliably distinguishing watermarked-from-clean needs either Resemble's fixed payload or a confidence API -- neither is public, and there's no real Resemble sample to calibrate against. Same wall-class as the SynthID pixel detector: the decode exists, reliable presence-detection does not. (perth's top-level `PerthImplicitWatermarker` is also gated to None unless `librosa` is importable.)
- **SynthID detection is metadata-only.** There is no reliable *local* detector of the SynthID *pixel* watermark — Google's decoder is proprietary, no public spec or API (only a waitlisted portal). Authoritative confirmation: Google DeepMind's own paper "SynthID-Image: Image watermarking at internet scale" (Gowal et al., arXiv:2510.09263) states the verification service is restricted to "trusted testers" and does not release detector weights or a reproducible algorithm — so a local pixel detector is infeasible by design, not just unbuilt. https://arxiv.org/abs/2510.09263 We detect SynthID by its C2PA companion (`synthid_source` / `SYNTHID_C2PA_ISSUERS`), which is reliable while the manifest is intact but says nothing once C2PA is stripped. **Surface-dependent blind spot (verified 2026-05-24):** the same Google model emits different metadata per surface -- the Gemini *app* wraps outputs in Google C2PA, but the *API/playground* (AI Studio, Nano Banana / gemini-2.5-flash-image) emits the SynthID *pixel* watermark (confirmed via the Gemini-app oracle) + the visible sparkle but **no C2PA/IPTC at all**, so `synthid_source` returns None despite SynthID being present. Only the pixel oracle or the visible-sparkle detector catches those. (Meta AI is another surface mismatch: it writes the IPTC `digitalSourceType=trainedAlgorithmicMedia` marker, not C2PA and not SynthID.) Google→SynthID is long-standing; OpenAI→SynthID is confirmed by OpenAI's Help Center (ChatGPT/Codex/API "include both C2PA metadata and SynthID watermarks", updated 2026-05-21) but time-gated (pre-rollout OpenAI images carry C2PA without SynthID), so the OpenAI verdict is hedged "likely". Oracles: Gemini app "Verify with SynthID" (Google), openai.com/verify (OpenAI). **Each vendor's oracle detects only its OWN content (verified on the page 2026-05-31):** `openai.com/research/verify` states verbatim "OpenAI generation signals will only be detected if the image was generated with our tools" and "Content could also still be AI-generated by another company's model, which the tool currently does not detect" -- SynthID is shared tech but the verifier is keyed to its own vendor's payload, so a Google-SynthID image reads clean on OpenAI's verifier and vice-versa. **This explains the recurring "oracle says clean but `identify` still flags SynthID" report (#14):** the oracle reads the *pixel* watermark (gone after our SDXL pass), while `identify` reads the *C2PA-metadata proxy* (still present if the manifest survived). Different signals, not a contradiction -- strip the metadata too (`metadata --remove` / `all`) and the proxy goes quiet, but a quiet proxy is not proof the pixel watermark is gone. The spectral phase-coherence approach from `github.com/aloshdenny/reverse-SynthID` was evaluated (May 2026) and **does not work for real-content detection**: on its own shipped codebook + validation set, watermarked and cleaned images were indistinguishable (conf within noise, cleaned often higher); it only fires on pure-black 1024x1024 reference images at exact resolution (the controlled case it was calibrated on). The README's "90% / conf=0.91" reproduces only in that lab condition. Do not build a production detector on it; if revisited, it is experimental/diagnostic only and needs a per-resolution, per-model reference corpus. A from-scratch gpt-image pilot (2026-05-24) confirmed this independently: 5 independent solid-black gpt-image outputs share a near-identical fixed signature (pairwise residual correlation **0.92**, avg-template retains 97% energy), so the watermark/carrier IS strongly present and consistent on flat content — but the carrier frequencies extracted from it do NOT discriminate real content (carrier-to-random ratio: cleaned 1.86 > watermarked 1.53; a non-gpt-image image scored highest at 3.67). The signature drowns in content texture. Net: a perfectly consistent solid-color signature still yields no real-content pixel detector with magnitude/carrier methods. A corpus discrimination test (2026-05-24, `scripts/synthid_pixel_probe.py`, raw zero-mean residual NCC) independently re-confirms this: at matched resolution, SynthID positives do NOT cluster apart from negatives (within-Gemini 0.07; at 1024 px pos-vs-neg >= pos-vs-pos). The only high correlations were near-duplicate *content* (5 ChatGPT renders of one prompt at ~0.92, while a distinct ChatGPT image scored ~0 against them) — content, not a carrier. The probe is solid-fills-only and EXPERIMENTAL/DIAGNOSTIC; do not use it on real content. **Correction (deeper re-examination 2026-05-25):** the carrier IS real on solid fills — the earlier "no carrier" was a *method* artifact of using spatial / FFT-magnitude NCC, which can't see it. The carrier is a fixed *phase* at specific low frequencies, so the right metric is **per-bin phase coherence**. On 8 white `gemini-2.5-flash-image` fills (generated via the reverse-SynthID trick: identity-edit prompt "Recreate this image exactly as it is" Line truncated
- **External AI-vs-real classifier models are out of scope (decided 2026-05-24).** Generic HuggingFace detectors (`Organika/sdxl-detector` Swin Transformer, `umm-maybe/AI-image-detector`, and fine-tunes) exist and report ~0.98 on their *own* SDXL-vs-real validation sets, but they are per-generator and the model cards themselves note degraded accuracy off-distribution; they are untested on gpt-image / Gemini Nano Banana (the metadata-stripped surfaces we care about), and our own light SDXL pass would likely defeat them the same way it defeats SynthID. Detection here stays local + signal-based (metadata + visible sparkle); do not add a bundled classifier dependency.
- **SynthID v2 vs default pipeline:** **CORRECTION (2026-05-30): strength 0.05 does NOT remove the CURRENT Google SynthID (Nano Banana / Gemini 3).** Re-verified via the Gemini "Verify with SynthID" oracle on a real image: at 0.05 SynthID is still detected; at **0.10 it is removed** (OpenAI's SynthID was already cleared at 0.05). So the default strength was raised 0.05 -> **0.10** (`DEFAULT_STRENGTH` in `watermark_profiles.py`; CLI `--strength` defaults to 0.10), and that higher strength is exactly why text protection (`_run_region_hires`) runs by default (text deforms more at 0.10). Caveat: n=1 Google + n=1 OpenAI image so far -- broad oracle validation across the corpus is pending (different images may need a different strength). **Resolution dependence confirmed by a user report (#14, qw1212ss, 2026-05-31):** on 1600x1600 gpt-image outputs checked via openai.com/verify, 0.05 left SynthID detected on **7/8** images, while small images (376x429) cleared at ~100% -- so "OpenAI cleared at 0.05" was a low-resolution result; a larger canvas carries a stronger watermark and needs more strength. **Policy (do NOT chase a single magic number or build resolution/vendor-adaptive defaults): 0.10 is the default because it is what clears the watermark today; if the oracle still reads SynthID, the guidance is simply to raise `--strength` (0.12, then 0.15), using the lowest value that verifies clean.** There is no local SynthID detector, so the tool cannot self-check and auto-tune; both vendors tighten the watermark over time, so any fixed value is a moving target. README "Removing SynthID" documents the strength-ladder guidance for users. The original claim below (0.05 defeats SynthID v2) held for the specific May-2026 Gemini output tested then but is stale for current Google SynthID. **Verified end-to-end (May 2026):** local SDXL run on a Gemini 3 Pro output, checked via the Gemini app's "Verify with SynthID" feature, returned "no SynthID watermark detected". Also confirmed against **OpenAI's** SynthID (2026-05-23): a fresh ChatGPT/gpt-image output read "SynthID detected" on openai.com/verify before the local SDXL run and "SynthID not detected" after (corpus regression chain: pos `4ef377bd` -> cleaned `47188e88`). The same configuration is used in raiw-app production (`fal-ai/fast-sdxl/image-to-image`, strength 0.05, steps 50, guidance 7.5, no pre-downscale). fal's own `llms.txt` for `fast-sdxl` names the base checkpoint as `stabilityai/stable-diffusion-xl-base-1.0` (verified 2026-05-25) -- the exact checkpoint the local CLI defaults to (`DEFAULT_MODEL_ID`). So the local `invisible` default is weight-for-weight identical to prod; "fast-sdxl" is fal's optimized serving, not different weights. After the native-resolution fix the local pipeline matches prod on weights + strength + steps + guidance + resolution. SD-1.5 dreamshaper at 768 px was previously the default and does NOT defeat v2 — verified empirically against the same feature (strength 0.04, 0.10, and elastic warp α∈{5,8} all flagged positive). That SD-1.5 path was removed; only `default` (SDXL) and `ctrlregen` profiles remain. **Scope of the claim: defeating the SynthID verifier is NOT the same as forensic invisibility.** "Removing the Watermark Is Not Enough: Forensic Stealth in Generative-AI Watermark Removal" (arXiv:2605.09203, 2026-05) shows that six removal attacks across four families (UnMarker, CtrlRegen+, WatermarkAttacker, etc.) all leave forensic traces: independent detectors flag *removal-processed* images vs genuinely-clean ones at **>98% TPR at 1% FPR**. So our SDXL pass makes the oracle read "SynthID not detected," but the output can still be classifiable as "an image that went through a removal pipeline." Do not over-claim "indistinguishable from a real photo." https://arxiv.org/abs/2605.09203
- **CtrlRegen profile uses a clean-noise default strength, NOT the SDXL 0.10 (fixed 2026-05-31).** `--pipeline ctrlregen` no longer inherits the SDXL img2img `--strength` default. `resolve_strength(strength, profile)` (`watermark_profiles.py`, pure + unit-tested in `test_platform.py::TestResolveStrength`) resolves an unset `--strength` to `CTRLREGEN_DEFAULT_STRENGTH` (**1.0**) for ctrlregen and `DEFAULT_STRENGTH` (0.10) for the SDXL default; an explicit `--strength` always wins (including `0.0` -- the resolver checks `is None`, not falsiness, so it does not repeat the old `strength or DEFAULT` bug). CLI `--strength` for `invisible`/`all` now defaults to **None** (batch already did); the display (`cli.py`) and the engine (`watermark_remover.remove_watermark`) both route through `resolve_strength` so they never disagree. **Why (deep-research pass 2026-05-31, primary sources):** CtrlRegen's removal power comes from regenerating from (near) clean Gaussian noise, not the light partial-noise img2img the SDXL pass uses. CtrlRegen (ICLR 2025, arXiv:2410.05470) diagnoses verbatim that prior partial-noise regeneration "struggles with high-perturbation watermarks" because a small noise step "retains" watermark info that diffuses back into the output; the fix is a clean-noise start, which with `StableDiffusionControlNetImg2ImgPipeline` maps to strength ~1.0 (image structure held by the canny ControlNet + DINOv2 IP-Adapter, not by the watermarked latent). **Before the fix `--pipeline ctrlregen` ran at 0.10** -- a near-identity pass that loaded ControlNet + DINOv2-giant and then barely changed the image (a removal no-op). **NOT yet oracle-verified** that clean-noise ctrlregen clears the stubborn high-texture gpt-image class that 0.20 SDXL img2img could not (issue #14, qw1212ss: pic3/6/7 survived SynthID through 0.05->0.20); that is the pending controlled test (via openai.com/verify with the IP-country rate-limit bypass). **Forensic-stealth caveat applies harder here:** regeneration-family removal is the MOST detectable as "an image that went through a removal pipeline" (CtrlRegen+ 99.97% TPR@1%FPR, arXiv:2605.09203). **Two #14-investigation hypotheses the literature did NOT confirm:** (1) our "VAE round-trip drives removal, denoising strength does not" framing is only PARTIALLY supported -- arXiv:2510.09263 confirms SynthID was hardened against *weak* VAE re-generation (explaining survival) but does not name the VAE round-trip as the removal vector; (2) our "survival correlates with high-frequency CONTENT texture (Laplacian 466 vs 236)" is unconfirmed by any primary source -- the literature establishes *watermark-perturbation-strength* dependence (a different axis), so the texture correlation stays our own unverified observation, not a literature-backed fact.
- **SynthID v2 vs default pipeline:** **CORRECTION (2026-05-31, oracle-verified GPU study, SUPERSEDES the 0.10 claim below): the current Gemini SynthID survives 0.10/0.15/0.2 and is REMOVED only at strength 0.3** (Modal A100, native res, Gemini-app "Verify with SynthID", n=3 FRESH Gemini images, `protect_text`/`faces` OFF; 0.2 still present, 0.3 removed). `DEFAULT_STRENGTH` was raised **0.10 -> 0.30** to match. The "0.10 removes it" finding below was n=1 and is now stale -- the threshold has climbed 0.05 -> 0.10 -> ~0.3 as Google hardens SynthID, so re-test against fresh Gemini periodically (moving target). 0.3 costs SSIM ~0.97 vs original (modest) but softens dense/fine typography, and is **overkill for non-SynthID sources** (OpenAI/ChatGPT carry C2PA, NOT Google SynthID -- their pixels read negative on the Gemini verifier at every strength, so 0.10 is plenty there; a per-source strength via `identify` was considered and deferred in favour of the simpler single 0.3 default). **`protect_text` does NOT block SynthID removal and is recommended ON (corrected 2026-05-31 by A/B tests at 0.3, reversing an earlier wrong claim):** verified on a GENUINE Gemini-SynthID image (`gemini_633uuy`, a photo with a Chinese-text sign) AND an OpenAI infographic (`qw1212ss_pic3`), both with protect_text ON and OFF -- the Gemini-subject run is the load-bearing one (Gemini-oracle verified; the OpenAI image is not a valid SynthID subject). SynthID is a GLOBAL watermark, so regenerating the non-text regions at 0.3 destroys the payload whether protect_text is on or off -- it clears in BOTH. protect_text additionally SALVAGES text fidelity (medium headings/body stay readable; with it off the same text garbles), at ~3x runtime. Only the very smallest text still softens at 0.3 (0.3 is inherently aggressive). **Recommended SynthID config: strength ~0.3 + protect_text ON (the default).** The earlier "protect_text shields the watermark, use `--no-protect-text`" claim was wrong -- it mistook a strength failure (0.10 too weak) for a protection effect. **Oracle scope (load-bearing):** the Gemini app "Verify with SynthID" is the ONLY valid SynthID oracle (it detects Google's mark on any image); `openai.com/verify` is scoped to OpenAI provenance (its own C2PA) and is **NOT** a SynthID oracle, so a negative there is meaningless for SynthID and the older "OpenAI cleared at 0.05 on openai.com/verify" notes below are about provenance, not a pixel-SynthID measurement. **CORRECTION (2026-05-30): strength 0.05 does NOT remove the CURRENT Google SynthID (Nano Banana / Gemini 3).** Re-verified via the Gemini "Verify with SynthID" oracle on a real image: at 0.05 SynthID is still detected; at **0.10 it is removed** (OpenAI's SynthID was already cleared at 0.05). So the default strength was raised 0.05 -> **0.10** (`DEFAULT_STRENGTH` in `watermark_profiles.py`; CLI `--strength` defaults to 0.10), and that higher strength is exactly why text protection (`_run_region_hires`) runs by default (text deforms more at 0.10). Caveat: n=1 Google + n=1 OpenAI image so far -- broad oracle validation across the corpus is pending (different images may need a different strength). **Resolution dependence confirmed by a user report (#14, qw1212ss, 2026-05-31):** on 1600x1600 gpt-image outputs checked via openai.com/verify, 0.05 left SynthID detected on **7/8** images, while small images (376x429) cleared at ~100% -- so "OpenAI cleared at 0.05" was a low-resolution result; a larger canvas carries a stronger watermark and needs more strength. **Policy (do NOT chase a single magic number or build resolution/vendor-adaptive defaults): 0.10 is the default because it is what clears the watermark today; if the oracle still reads SynthID, the guidance is simply to raise `--strength` (0.12, then 0.15), using the lowest value that verifies clean.** There is no local SynthID detector, so the tool cannot self-check and auto-tune; both vendors tighten the watermark over time, so any fixed value is a moving target. README "Removing SynthID" documents the strength-ladder guidance for users. The original claim below (0.05 defeats SynthID v2) held for the specific May-2026 Gemini output tested then but is stale for current Google SynthID. **Verified end-to-end (May 2026):** local SDXL run on a Gemini 3 Pro output, checked via the Gemini app's "Verify with SynthID" feature, returned "no SynthID watermark detected". Also confirmed against **OpenAI's** SynthID (2026-05-23): a fresh ChatGPT/gpt-image output read "SynthID detected" on openai.com/verify before the local SDXL run and "SynthID not detected" after (corpus regression chain: pos `4ef377bd` -> cleaned `47188e88`). The same configuration is used in raiw-app production (`fal-ai/fast-sdxl/image-to-image`, strength 0.05, steps 50, guidance 7.5, no pre-downscale). fal's own `llms.txt` for `fast-sdxl` names the base checkpoint as `stabilityai/stable-diffusion-xl-base-1.0` (verified 2026-05-25) -- the exact checkpoint the local CLI defaultLine truncated
- **CtrlRegen profile uses a clean-noise default strength, NOT the SDXL 0.10 (fixed 2026-05-31).** **CORRECTION (2026-05-31, same oracle-verified GPU study): ctrlregen at its clean-noise strength DESTROYS real images** -- smooth/background regions fill with hallucinated micro-text garbage; the pipeline is binary (low strength = no-op, high = destroy, no usable middle) and heavy (~8.5 min / ~$0.30 vs ~25 s / ~$0.02 for SDXL). So the literature's "clean-noise is the lever" (detailed below) did NOT survive empirical testing on real content. ctrlregen is now flagged **EXPERIMENTAL** (CLI `--pipeline` help, README, and the `watermark_profiles` comment) and is **NOT for production** -- SDXL img2img at ~0.3 is the shippable path. The clean-noise-default plumbing below is kept (so the profile at least does real work if anyone opts in), but do not recommend ctrlregen. `--pipeline ctrlregen` no longer inherits the SDXL img2img `--strength` default. `resolve_strength(strength, profile)` (`watermark_profiles.py`, pure + unit-tested in `test_platform.py::TestResolveStrength`) resolves an unset `--strength` to `CTRLREGEN_DEFAULT_STRENGTH` (**1.0**) for ctrlregen and `DEFAULT_STRENGTH` (0.10) for the SDXL default; an explicit `--strength` always wins (including `0.0` -- the resolver checks `is None`, not falsiness, so it does not repeat the old `strength or DEFAULT` bug). CLI `--strength` for `invisible`/`all` now defaults to **None** (batch already did); the display (`cli.py`) and the engine (`watermark_remover.remove_watermark`) both route through `resolve_strength` so they never disagree. **Why (deep-research pass 2026-05-31, primary sources):** CtrlRegen's removal power comes from regenerating from (near) clean Gaussian noise, not the light partial-noise img2img the SDXL pass uses. CtrlRegen (ICLR 2025, arXiv:2410.05470) diagnoses verbatim that prior partial-noise regeneration "struggles with high-perturbation watermarks" because a small noise step "retains" watermark info that diffuses back into the output; the fix is a clean-noise start, which with `StableDiffusionControlNetImg2ImgPipeline` maps to strength ~1.0 (image structure held by the canny ControlNet + DINOv2 IP-Adapter, not by the watermarked latent). **Before the fix `--pipeline ctrlregen` ran at 0.10** -- a near-identity pass that loaded ControlNet + DINOv2-giant and then barely changed the image (a removal no-op). **NOT yet oracle-verified** that clean-noise ctrlregen clears the stubborn high-texture gpt-image class that 0.20 SDXL img2img could not (issue #14, qw1212ss: pic3/6/7 survived SynthID through 0.05->0.20); that is the pending controlled test (via openai.com/verify with the IP-country rate-limit bypass). **Forensic-stealth caveat applies harder here:** regeneration-family removal is the MOST detectable as "an image that went through a removal pipeline" (CtrlRegen+ 99.97% TPR@1%FPR, arXiv:2605.09203). **Two #14-investigation hypotheses the literature did NOT confirm:** (1) our "VAE round-trip drives removal, denoising strength does not" framing is only PARTIALLY supported -- arXiv:2510.09263 confirms SynthID was hardened against *weak* VAE re-generation (explaining survival) but does not name the VAE round-trip as the removal vector; (2) our "survival correlates with high-frequency CONTENT texture (Laplacian 466 vs 236)" is unconfirmed by any primary source -- the literature establishes *watermark-perturbation-strength* dependence (a different axis), so the texture correlation stays our own unverified observation, not a literature-backed fact.
+9 -5
View File
@@ -26,7 +26,7 @@ If this tool saves you time, consider [sponsoring its development](https://githu
- **Smart Face Protection** — automatic extraction and blending of human faces to prevent AI distortion
- **Batch processing** — process entire directories
- **Detection** — three-stage NCC watermark detection with confidence scoring
- **Provenance detection (`identify`)** — aggregate C2PA issuer, the C2PA soft-binding forensic-watermark vendor (Adobe TrustMark, Digimarc, Imatag, ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, embedded SD/ComfyUI params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the China TC260 AIGC label (XMP, PNG chunk, or EXIF), the HuggingFace `hf-job-id` job marker, the SynthID metadata proxy, the visible sparkle, the open SD/SDXL/FLUX invisible watermark, and (with the `trustmark` extra) the open Adobe TrustMark watermark into one origin-platform + watermark-inventory verdict (`--json` for machine output)
- **Provenance detection (`identify`)** — aggregate C2PA issuer, the C2PA soft-binding forensic-watermark vendor (Adobe TrustMark, Digimarc, Imatag, ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, embedded SD/ComfyUI params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the China TC260 AIGC label (XMP, PNG chunk, or EXIF), the HuggingFace `hf-job-id` job marker, the SynthID metadata proxy, the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" text marks), the open SD/SDXL/FLUX invisible watermark, and (with the `trustmark` extra) the open Adobe TrustMark watermark into one origin-platform + watermark-inventory verdict (`--json` for machine output)
## Examples
@@ -58,7 +58,7 @@ If this tool saves you time, consider [sponsoring its development](https://githu
> Visible overlays are used by Google Gemini / Nano Banana (sparkle logo) and by ByteDance's Doubao ("豆包AI生成" corner text) and Jimeng / Dreamina ("★ 即梦AI" wordmark). All are removed on CPU by reverse-alpha against a captured alpha map (Jimeng adds a residual inpaint over the glyph footprint, since its mark re-rasterizes per image). Other services rely on invisible watermarks and/or metadata; our diffusion-based regeneration works against any invisible watermark in pixel or frequency domain. For a visible mark from any other source (any position, any colour), use the universal `erase --region` command.
> **Detection:** `remove-ai-watermarks identify <image>` reports the origin platform and watermark inventory for all the signals above — C2PA issuer, the C2PA soft-binding forensic-watermark vendor (TrustMark / Digimarc / Imatag / ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, the China TC260 AIGC label (XMP, PNG chunk, or EXIF), the HuggingFace `hf-job-id` job marker, embedded generation params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the SynthID metadata proxy, the visible sparkle, and (with the `[detect]` / `[trustmark]` extras) the open SD/SDXL/FLUX and Adobe TrustMark invisible watermarks. SynthID and the proprietary soft-binding watermarks (Digimarc etc.) have no local decoder, so they are reported by metadata proxy / vendor name only.
> **Detection:** `remove-ai-watermarks identify <image>` reports the origin platform and watermark inventory for all the signals above — C2PA issuer, the C2PA soft-binding forensic-watermark vendor (TrustMark / Digimarc / Imatag / ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, the China TC260 AIGC label (XMP, PNG chunk, or EXIF), the HuggingFace `hf-job-id` job marker, embedded generation params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the SynthID metadata proxy, the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" text marks), and (with the `[detect]` / `[trustmark]` extras) the open SD/SDXL/FLUX and Adobe TrustMark invisible watermarks. SynthID and the proprietary soft-binding watermarks (Digimarc etc.) have no local decoder, so they are reported by metadata proxy / vendor name only.
## How it works
@@ -108,15 +108,19 @@ The removal pipeline (default profile, SDXL):
```text
image → encode to latent space (VAE) at native resolution
→ add controlled noise (forward diffusion)
→ denoise (reverse diffusion, ~50 steps at strength 0.10)
→ denoise (reverse diffusion, ~50 steps at strength 0.30)
→ decode back to pixels (VAE)
```
- Native resolution avoids shrinking the input to 1024 px first; that down-then-up round-trip was the main quality loss (issue #10). Use `--max-resolution N` only to cap GPU/MPS memory on very large inputs.
> **If SynthID still verifies after the run, raise `--strength`.** The default `0.10` is the value that clears the watermark today, but SynthID is a moving target: both Google and OpenAI tighten it over time, and a larger image carries a stronger watermark (a 1600x1600 image needs more than a 400x400 one). There is no single permanent number, and there is no local SynthID detector, so the tool cannot self-check and auto-tune. The rule is simple: if the verifier still reads SynthID, step the strength up — try `--strength 0.12`, then `0.15`. Higher strength changes more detail and text, so use the lowest value that comes back clean on the oracle ([openai.com/research/verify](https://openai.com/research/verify/) or the Gemini app's "Verify with SynthID").
> **Default strength is `0.30`, tuned to remove the current Google SynthID.** An oracle-verified study (fresh Gemini images, "Verify with SynthID") found the current SynthID survives `0.10`/`0.15`/`0.20` and clears only at `0.30`. SynthID is a moving target (the threshold has climbed `0.05` → `0.10` → `~0.30` as Google hardens it), and there is no local SynthID detector, so the tool cannot self-check and auto-tune. If the oracle still reads SynthID, raise `--strength` further; if you care more about preserving fine text, lower it. `0.30` softens dense typography somewhat, so use the lowest value that comes back clean on the oracle.
>
> **For images that survive even high strength, try `--pipeline ctrlregen`.** The default SDXL pass is a light partial-noise img2img; some images (often dense, high-texture composites) keep the watermark through it at any strength. CtrlRegen regenerates the image from near-clean Gaussian noise while holding structure with a Canny ControlNet and a DINOv2 adapter — the approach the research ([CtrlRegen, ICLR 2025](https://github.com/yepengliu/CtrlRegen)) identifies as the lever against watermarks that partial-noise regeneration cannot remove. It defaults to clean-noise strength `1.0`, downloads extra models (~several GB), runs slower, and changes the image more, so reserve it for the stubborn cases. Note: heavier regeneration is also *more* detectable as a removal-processed image (see the forensic note below).
> **Keep text protection on (the default) — it does not block SynthID removal.** SynthID is a global watermark, so strength `0.30` clears it whether or not text is protected, and text protection keeps headings and body text readable through the pass (only the very finest print still softens at `0.30`). You do not need to disable it for removal; `--no-protect-text` only trades text quality for a faster run.
>
> **OpenAI / ChatGPT images do not carry Google SynthID** (they use C2PA metadata, stripped by the metadata step), so `0.30` is overkill there; `--strength 0.10` preserves quality and the metadata strip is what matters.
>
> **`--pipeline ctrlregen` is experimental and not recommended.** On paper CtrlRegen ([ICLR 2025](https://github.com/yepengliu/CtrlRegen)) regenerates from near-clean Gaussian noise to defeat robust watermarks, but in testing on real images it **destroys content** — smooth and background regions fill with hallucinated micro-text — and it is heavy (several GB of extra models, minutes per image). It has no usable middle setting (too low removes nothing, high enough to remove wrecks the image), so the shippable path is the default SDXL pipeline at `~0.30`. CtrlRegen stays available for experimentation only.
SDXL is the default since May 2026: empirically defeats SynthID v2 on Gemini 3 Pro outputs, where the older SD-1.5 pipeline at 768 px did not. The SD-1.5 path was removed once it was verified not to handle v2. Note the scope: this defeats the SynthID *verifier*, which is not the same as being forensically indistinguishable from a real photo. Recent work ([arXiv:2605.09203](https://arxiv.org/abs/2605.09203)) shows watermark-removal pipelines leave detectable traces, so a separate "this image was processed" classifier can still flag the output.
+4 -2
View File
@@ -14,8 +14,10 @@ filter applied to every candidate below.
## Problem recap
The `invisible` pipeline is SDXL base 1.0 img2img at low strength (~0.05) to defeat
SynthID with minimal visible change. Text is protected via Differential Diffusion with a
The `invisible` pipeline is SDXL base 1.0 img2img to defeat SynthID. The default
strength has risen over time as Google hardens SynthID (0.05 -> 0.10 -> **~0.30**, the
current threshold for fresh Gemini output); higher strength deforms text more, which is
exactly why text protection matters. Text is protected via Differential Diffusion with a
per-pixel change map (`preserve` ~0.9) driven by the PP-OCRv3 DB detector
(`text_protector.py`). Large text survives; **small text (sub ~8 px strokes) softens or
garbles** (issue #14, confirmed on real content).
+7 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "remove-ai-watermarks"
version = "0.8.2"
version = "0.8.4"
description = "Remove visible and invisible AI watermarks from images (Gemini / Nano Banana, ChatGPT, Stable Diffusion)"
readme = "README.md"
requires-python = ">=3.10"
@@ -111,7 +111,12 @@ remove-ai-watermarks = "remove_ai_watermarks.cli:main"
Repository = "https://github.com/wiltodelta/remove-ai-watermarks"
[build-system]
requires = ["hatchling"]
# Pin hatchling < 1.28: 1.28+ emits Metadata-Version 2.5 (PEP 639), which the twine
# bundled in pypa/gh-action-pypi-publish@release/v1 rejects ("'2.5' is not a valid
# Metadata-Version"), failing the PyPI upload (v0.8.3, 2026-06-01). 1.27.x emits 2.4,
# which uploads fine (0.8.2 shipped on it). Lift this pin once the publish action's
# twine is upgraded to >= 6.1.0 (2.5-aware) or the workflow moves to `uv publish`.
requires = ["hatchling<1.28"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
+1 -1
View File
@@ -11,4 +11,4 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
__version__ = "0.8.2"
__version__ = "0.8.4"
+5 -5
View File
@@ -433,14 +433,14 @@ def cmd_erase(
"--strength",
type=float,
default=None,
help="Denoising strength (0.0-1.0). Default: 0.10 (SDXL), 1.0 clean-noise for ctrlregen.",
help="Denoising strength (0.0-1.0). Default: 0.30 (SDXL SynthID threshold); ctrlregen uses 1.0.",
)
@click.option("--steps", type=int, default=50, help="Number of denoising steps. Default: 50.")
@click.option(
"--pipeline",
type=click.Choice(["default", "ctrlregen"]),
default="default",
help="Pipeline profile (default=SDXL, ctrlregen=CtrlRegen).",
help="Pipeline profile (default=SDXL; ctrlregen=CtrlRegen, EXPERIMENTAL/destructive at clean-noise).",
)
@click.option(
"--device",
@@ -680,14 +680,14 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
"--strength",
type=float,
default=None,
help="Invisible watermark denoising strength. Default: 0.10 (SDXL), 1.0 clean-noise for ctrlregen.",
help="Invisible watermark denoising strength. Default: 0.30 (SDXL); ctrlregen uses 1.0.",
)
@click.option("--steps", type=int, default=50, help="Number of denoising steps for invisible removal.")
@click.option(
"--pipeline",
type=click.Choice(["default", "ctrlregen"]),
default="default",
help="Pipeline profile (default=SDXL, ctrlregen=CtrlRegen).",
help="Pipeline profile (default=SDXL; ctrlregen=CtrlRegen, EXPERIMENTAL/destructive at clean-noise).",
)
@click.option("--model", type=str, default=None, help="HuggingFace model ID for invisible removal.")
@click.option(
@@ -979,7 +979,7 @@ def _process_batch_image(
"--pipeline",
type=click.Choice(["default", "ctrlregen"]),
default="default",
help="Pipeline profile (default=SDXL, ctrlregen=CtrlRegen).",
help="Pipeline profile (default=SDXL; ctrlregen=CtrlRegen, EXPERIMENTAL/destructive at clean-noise).",
)
@click.option(
"--device",
+56 -3
View File
@@ -8,7 +8,8 @@ Aggregates every locally-readable signal into a single :class:`ProvenanceReport`
- **PNG text / EXIF generation parameters** (Stable Diffusion, ComfyUI, InvokeAI).
- **SynthID metadata proxy** -- a C2PA companion from a SynthID-using vendor
(Google / OpenAI) implies the invisible pixel watermark.
- **Visible Gemini sparkle** (optional; needs cv2/numpy, no GPU).
- **Visible marks** (optional; needs cv2/numpy, no GPU): the Gemini sparkle and
the ByteDance Doubao 豆包AI生成 / Jimeng 即梦AI text marks.
Hard limit: a stripped image (re-encoded, screenshotted, social-media upload)
loses all metadata, and the SynthID *pixel* watermark is not locally decodable
@@ -43,6 +44,8 @@ from remove_ai_watermarks.noai.constants import C2PA_AI_TOOLS, C2PA_ISSUERS
if TYPE_CHECKING:
from pathlib import Path
from remove_ai_watermarks.watermark_registry import MarkDetection
log = logging.getLogger(__name__)
# How much of a non-PNG container to binary-scan for the C2PA issuer.
@@ -334,6 +337,46 @@ def _visible_sparkle(image_path: Path) -> float | None:
return detect_sparkle_confidence(image_path)
# Visible text marks (registry keys) -> human-readable platform, mirroring the
# Gemini-sparkle phrasing. These are the stripped-metadata visual fallback for
# the China-served ByteDance generators (normally also caught by the TC260 AIGC
# metadata label); the per-engine detection thresholds live in the registry.
_VISIBLE_MARK_PLATFORM = {
"doubao": "ByteDance Doubao (visible 豆包AI生成 mark detected)",
"jimeng": "ByteDance Jimeng / Dreamina (visible 即梦AI mark detected)",
}
def _visible_text_marks(image_path: Path) -> list[MarkDetection]:
"""Detected visible Doubao/Jimeng marks (registry ``MarkDetection`` list).
The Gemini sparkle keeps its own ``_visible_sparkle`` path (file-level
confidence); these two text marks reuse the registry detectors, which apply
each engine's calibrated NCC threshold via ``MarkDetection.detected``.
Optional: needs cv2/numpy; returns ``[]`` if the engines/assets are missing
or the image can't be read.
"""
try:
from remove_ai_watermarks.image_io import imread
from remove_ai_watermarks.watermark_registry import get_mark
except Exception as exc: # cv2/engine assets missing
log.debug("visible-mark detectors unavailable: %s", exc)
return []
image = imread(image_path)
if image is None:
return []
detections: list[MarkDetection] = []
for key in _VISIBLE_MARK_PLATFORM:
try:
det = get_mark(key).detect(image)
except Exception as exc: # one engine failing must not break identify
log.debug("visible-mark %s detector failed: %s", key, exc)
continue
if det.detected:
detections.append(det)
return detections
def _invisible_watermark(image_path: Path) -> str | None:
"""Open invisible-watermark scheme name (SD/SDXL/FLUX) or None.
@@ -361,7 +404,8 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
Args:
image_path: Path to the image (PNG, JPEG, WebP, or ISOBMFF container).
check_visible: Also run the visible Gemini-sparkle detector (cv2). Set
check_visible: Also run the visible-mark detectors (cv2) -- the Gemini
sparkle and the Doubao/Jimeng text marks from the registry. Set
False for a pure-metadata, dependency-light scan.
check_invisible: Also decode open invisible watermarks (SD/SDXL/FLUX) via
the optional imwatermark library. No-op when it is not installed.
@@ -580,7 +624,16 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
if platform is None:
platform = "Google Gemini family (visible sparkle detected)"
visible_only = any(s.name == "visible_sparkle" for s in signals) and not ai_from_metadata
# ── Visible Doubao / Jimeng text marks (registry; same stripped-metadata
# fallback role as the Gemini sparkle above) ─
if check_visible:
for det in _visible_text_marks(image_path):
signals.append(Signal(f"visible_{det.key}", f"NCC confidence {det.confidence:.2f}", "medium"))
watermarks.append(f"Visible {det.label} (confidence {det.confidence:.2f})")
if platform is None:
platform = _VISIBLE_MARK_PLATFORM[det.key]
visible_only = any(s.name.startswith("visible_") for s in signals) and not ai_from_metadata
hf_only = bool(hf_job) and not ai_from_metadata
samsung_only = samsung_genai_type is not None and not ai_from_metadata
+4 -3
View File
@@ -70,9 +70,10 @@ class InvisibleEngine:
to break watermark patterns, and reconstructs via reverse diffusion.
"""
# SDXL base is the default since May 2026: empirically defeats SynthID v2
# at strength=0.10 / steps=50 / native ~1024px. See CLAUDE.md "Known
# limitations" for the regression evidence ruling out SD-1.5 pipelines.
# SDXL base is the default since May 2026; the current Google SynthID is
# removed at strength ~0.30 / steps=50 / native res (oracle-verified, n=3 fresh
# Gemini -- 0.10/0.15/0.2 still detected). See CLAUDE.md "Known limitations" for
# the strength study and the regression evidence ruling out SD-1.5 pipelines.
DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
CTRLREGEN_MODEL_ID = "yepengliu/ctrlregen"
+29 -2
View File
@@ -1,9 +1,36 @@
"""Vendored noai-watermark code for invisible watermark removal.
Original: https://github.com/mertizci/noai-watermark (MIT License)
The public API (``WatermarkRemover`` / ``remove_watermark`` / ``remove_ai_metadata``)
is exposed **lazily** via PEP 562 ``__getattr__``: importing a light submodule
(e.g. ``noai.c2pa`` / ``noai.constants`` from ``identify``) must NOT eagerly pull
``watermark_remover``, which imports torch + diffusers at module top. Keeping this
lazy is what lets ``import remove_ai_watermarks.identify`` stay cheap (~36 MB, no
torch) even in a full install where the ``gpu``/``detect`` extras are present --
otherwise the mere presence of torch in the env inflated identify to ~420 MB and
risked OOM on a 512 MB host.
"""
from remove_ai_watermarks.noai.cleaner import remove_ai_metadata
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover, remove_watermark
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from remove_ai_watermarks.noai.cleaner import remove_ai_metadata
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover, remove_watermark
__all__ = ["WatermarkRemover", "remove_ai_metadata", "remove_watermark"]
def __getattr__(name: str) -> object:
"""Resolve the public API on first access (PEP 562), not at package import."""
if name == "remove_ai_metadata":
from remove_ai_watermarks.noai.cleaner import remove_ai_metadata
return remove_ai_metadata
if name in ("WatermarkRemover", "remove_watermark"):
from remove_ai_watermarks.noai import watermark_remover
return getattr(watermark_remover, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -9,17 +9,24 @@ DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
CTRLREGEN_MODEL_ID = "yepengliu/ctrlregen"
# Single default denoising strength for the SDXL img2img scrub, overridable from
# the CLI (`--strength`). Raised from the old 0.04/0.05 because that no longer
# removes the CURRENT Google SynthID (Nano Banana / Gemini 3): verified 2026-05-30
# via the Gemini "Verify with SynthID" oracle on a real image -- 0.05 still
# detected, 0.10 not detected (OpenAI's SynthID was already cleared at 0.05). 0.10
# keeps the visible change modest while removing both. CAVEAT: confirmed on n=1
# Google + n=1 OpenAI image; broad oracle validation across the corpus is pending
# (different images may need a different strength). At this higher strength small
# text deforms more -- which is exactly why text protection (`_run_region_hires`)
# runs by default. (Fixed LOW/MEDIUM/HIGH presets were removed -- unused; the one
# knob is this default plus the per-call `--strength` override.)
DEFAULT_STRENGTH = 0.10
# the CLI (`--strength`). Raised 0.10 -> 0.30 after an oracle-verified GPU strength
# study (2026-05-31, Modal A100, native res, Gemini-app "Verify with SynthID", n=3
# FRESH Gemini images + protect_text/faces OFF): the CURRENT Google SynthID survives
# 0.10/0.15/0.2 and is only REMOVED at 0.3 (0.3 is the threshold; 0.2 still present).
# This supersedes the earlier n=1 "0.10 removes it" note, which is now stale -- Google
# has hardened SynthID and the threshold has climbed 0.05 -> 0.10 -> ~0.3 over time, so
# treat this as a moving target and re-test against fresh Gemini output periodically.
# Cost of 0.3: SSIM ~0.97 vs original (modest), but fine/dense typography softens, and
# it is OVERKILL for non-SynthID sources (OpenAI/ChatGPT carry C2PA, not Google SynthID
# -- 0.10 is plenty there). protect_text is RECOMMENDED ON for SynthID removal (A/B
# verified 2026-05-31): SynthID is GLOBAL, so 0.3 clears it whether protection is on or
# off, and protection salvages medium-text fidelity (~3x runtime); only the very finest
# text still softens at 0.3. (An earlier comment claimed protect_text shields the
# watermark -- that was wrong, it mistook the 0.10 strength failure for a protection
# effect.) The only true tension is the finest typography softening at this aggressive
# strength. (Fixed LOW/MEDIUM/HIGH presets were removed -- the one knob is this default
# plus the per-call override.)
DEFAULT_STRENGTH = 0.30
# CtrlRegen removes watermarks by regenerating from (near) clean Gaussian noise,
# NOT by the light-touch partial-noise img2img the SDXL default uses. The research
@@ -29,9 +36,19 @@ DEFAULT_STRENGTH = 0.10
# clean noise. With the StableDiffusionControlNetImg2ImgPipeline that maps to a high
# strength (~1.0 = full noise at the first timestep, structure held by the canny
# ControlNet + DINOv2 IP-Adapter, not by the watermarked latent). So the ctrlregen
# profile must NOT inherit the SDXL 0.10 default -- at 0.10 it loads ControlNet +
# DINOv2-giant and then barely changes the image (a no-op for removal). Tunable via
# profile must NOT inherit the SDXL default (`DEFAULT_STRENGTH`, a partial-noise
# value) -- at that low strength it loads ControlNet + DINOv2-giant and then barely
# changes the image (a no-op for removal). Tunable via
# `--strength`; lower it to trade removal strength for fidelity (the CtrlRegen+ regime).
#
# EXPERIMENTAL -- NOT recommended for production. The same GPU study that set the 0.3
# SDXL threshold tested ctrlregen at its clean-noise strength and found it DESTROYS
# images: smooth/background regions fill with hallucinated micro-text garbage, and it
# is heavy (~8.5 min / ~$0.30 vs ~25 s / ~$0.02 for SDXL on a large image). The pipeline
# is effectively binary -- low strength = no-op, high strength = destroys -- with no
# usable middle, so the literature's "clean-noise is the lever" (arXiv:2410.05470) did
# NOT survive empirical testing on real content. SDXL img2img at ~0.3 is the shippable
# path; ctrlregen stays opt-in and flagged experimental.
CTRLREGEN_DEFAULT_STRENGTH = 1.0
@@ -39,10 +56,10 @@ def resolve_strength(strength: float | None, profile: str) -> float:
"""Resolve the denoising strength, applying the profile-specific default when unset.
``None`` means "the user did not pass ``--strength``": the SDXL default profile
resolves to ``DEFAULT_STRENGTH`` (a light SynthID-tuned touch), while ``ctrlregen``
resolves to ``CTRLREGEN_DEFAULT_STRENGTH`` (clean-noise regeneration). An explicit
value always wins. Shared by the CLI (for display) and the engine (for execution)
so the two never disagree.
resolves to ``DEFAULT_STRENGTH`` (the SynthID-removal default, ~0.3), while
``ctrlregen`` resolves to ``CTRLREGEN_DEFAULT_STRENGTH`` (clean-noise regeneration).
An explicit value always wins. Shared by the CLI (for display) and the engine (for
execution) so the two never disagree.
"""
if strength is not None:
return strength
+64
View File
@@ -7,6 +7,8 @@ against the real committed C2PA / IPTC fixtures in data/samples/.
from __future__ import annotations
import json
import subprocess
import sys
from dataclasses import asdict
from pathlib import Path
from unittest.mock import patch
@@ -371,6 +373,68 @@ class TestIdentifyVisibleSparkle:
assert r.confidence == "high"
class TestIdentifyImportIsLight:
"""`import identify` must stay torch-free (lazy noai/__init__): the package
is deployed on a 512 MB host where eagerly pulling torch/diffusers OOMs."""
def test_import_identify_does_not_pull_torch(self):
# Only meaningful where torch is installed (the gpu/detect extra); on a
# core-only CI runner torch can't be in sys.modules anyway.
pytest.importorskip("torch")
code = "import sys, remove_ai_watermarks.identify; sys.exit(1 if 'torch' in sys.modules else 0)"
result = subprocess.run([sys.executable, "-c", code], capture_output=True, check=False) # noqa: S603
assert result.returncode == 0, f"import identify pulled torch: {result.stderr.decode()[-500:]}"
# Where the registry-backed Doubao/Jimeng visible detector resolves.
_TEXT_MARKS_TARGET = "remove_ai_watermarks.identify._visible_text_marks"
class TestIdentifyVisibleTextMarks:
"""The visible Doubao/Jimeng marks are a stripped-metadata visual fallback,
parallel to the Gemini sparkle: each lifts an Unknown verdict to medium."""
@staticmethod
def _detection(key: str, label: str, conf: float):
from remove_ai_watermarks.watermark_registry import MarkDetection
return MarkDetection(key, label, "bottom-right", True, conf, (0, 0, 10, 10))
def test_doubao_promotes_to_medium(self, tmp_clean_png: Path):
det = self._detection("doubao", "Doubao 豆包AI生成 text", 0.8)
with patch(_SPARKLE_TARGET, return_value=None), patch(_TEXT_MARKS_TARGET, return_value=[det]):
r = identify(tmp_clean_png, check_visible=True)
assert r.is_ai_generated is True
assert r.confidence == "medium"
assert r.platform is not None
assert "Doubao" in r.platform
signal = next(s for s in r.signals if s.name == "visible_doubao")
assert signal.confidence == "medium"
def test_jimeng_promotes_to_medium(self, tmp_clean_png: Path):
det = self._detection("jimeng", "Jimeng 即梦AI wordmark", 0.9)
with patch(_SPARKLE_TARGET, return_value=None), patch(_TEXT_MARKS_TARGET, return_value=[det]):
r = identify(tmp_clean_png, check_visible=True)
assert r.is_ai_generated is True
assert r.confidence == "medium"
assert r.platform is not None
assert "Jimeng" in r.platform
assert any(s.name == "visible_jimeng" for s in r.signals)
def test_check_visible_false_skips_text_marks(self, tmp_clean_png: Path):
det = self._detection("doubao", "Doubao 豆包AI生成 text", 0.99)
with patch(_SPARKLE_TARGET, return_value=None), patch(_TEXT_MARKS_TARGET, return_value=[det]) as mock:
r = identify(tmp_clean_png, check_visible=False)
mock.assert_not_called()
assert not any(s.name == "visible_doubao" for s in r.signals)
def test_metadata_keeps_high_even_with_text_mark(self, tmp_png_with_ai_metadata: Path):
det = self._detection("doubao", "Doubao 豆包AI生成 text", 0.8)
with patch(_SPARKLE_TARGET, return_value=None), patch(_TEXT_MARKS_TARGET, return_value=[det]):
r = identify(tmp_png_with_ai_metadata, check_visible=True)
assert r.confidence == "high"
# ── Caveats and serialization ───────────────────────────────────────
+1 -1
View File
@@ -131,7 +131,7 @@ class TestResolveStrength:
assert resolve_strength(None, "default") == DEFAULT_STRENGTH
def test_none_ctrlregen_uses_clean_noise_default(self):
# ctrlregen must NOT inherit the light SDXL 0.10 (that makes it a no-op);
# ctrlregen must NOT inherit the SDXL DEFAULT_STRENGTH (that makes it a no-op);
# clean-noise regeneration is the lever against robust marks.
assert resolve_strength(None, "ctrlregen") == CTRLREGEN_DEFAULT_STRENGTH
assert CTRLREGEN_DEFAULT_STRENGTH > DEFAULT_STRENGTH
Generated
+1 -1
View File
@@ -2865,7 +2865,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.8.2"
version = "0.8.4"
source = { editable = "." }
dependencies = [
{ name = "click" },