From 4b0b370ac0fba378f39ad1ef2ef78a6c52940a12 Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Mon, 1 Jun 2026 10:28:34 -0700 Subject: [PATCH] fix(invisible): disable protect-text/protect-faces by default; add docs/synthid.md Both text and face protection were shielding SynthID from removal. The text-protection high-res re-scrub regenerates pixels at an upscaled resolution where the per-region pass may not be strong enough to re-destroy the SynthID payload, allowing it to survive in text areas. Face protection has an even more direct mechanism: it pastes back the original (pre-diffusion, watermarked) face pixels after the global pass, guaranteeing SynthID survives in face regions regardless of strength. Both --protect-text and --protect-faces are now off by default and opt-in. Rename from --no-protect-text / --no-protect-faces to --protect-text / --protect-faces. Extract shared click.option decorators to module-level constants (_protect_text_option, _protect_faces_option) to eliminate copy-paste between cmd_invisible and cmd_all. Add docs/synthid.md: primary-source-cited technical reference for SynthID-Image covering mechanism (post-hoc encoder/decoder, 136-bit payload, pixel-space, no model-weight modification), robustness numbers (arXiv:2510.09263: ~99.98% TPR at 0.1% FPR across 30 transforms), removal attacks and forensic detectability (arXiv:2605.09203: all 6 attacks detectable >98% TPR@1%FPR), detectability limits, oracle scope, adoption landscape, and practical implications including the protect-text/faces SynthID-preservation finding. Verified June 2026 on gpt-image 1600x1600 via openai.com/verify: with --protect-text SynthID detected; without, SynthID removed. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 10 +- README.md | 8 +- docs/synthid.md | 429 +++++++++++++++++++ src/remove_ai_watermarks/cli.py | 63 ++- src/remove_ai_watermarks/invisible_engine.py | 4 +- 5 files changed, 473 insertions(+), 41 deletions(-) create mode 100644 docs/synthid.md diff --git a/CLAUDE.md b/CLAUDE.md index 120beac..ccaa1b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,8 +42,8 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r - `region_eraser.py` — universal region eraser (`erase` CLI). `erase(image, boxes=|mask=, backend=)` normalizes grayscale (2D) and RGBA (4-channel) inputs up front (`erase_cv2` splits off any alpha plane and re-attaches it on the result): `boxes_to_mask` → `cv2.inpaint` (`cv2` backend, default, no deps) or big-LaMa via onnxruntime (`lama` backend, extra `lama`, `Carve/LaMa-ONNX` Apache-2.0 model downloaded on first use, never bundled). `erase_lama` crops a padded region around the mask, runs LaMa at its fixed 512² input, pastes only masked pixels back (untouched areas stay pixel-exact). Lazy `_get_lama_session` singleton; `lama_available()` guards the optional import. **LaMa-ONNX costs ~3.5-4 GB peak RAM and ~5-6 s/call on CPU** (FFC working set, not arena — `enable_cpu_mem_arena=False` does not help), so it does NOT fit a minimal droplet; the cv2 backend (tens of MB, ~30 ms) does. LaMa quality at low RAM = serverless/GPU, mirroring how raiw.cc offloads SDXL to fal. - `invisible_watermark.py` — `detect_invisible_watermark(path)` decodes the OPEN DWT-DCT watermarks (public decoder, no key) embedded by Stable Diffusion / SDXL / FLUX via the `imwatermark` library. Known fixed patterns (verified against upstream source) live in `_BITS_48` (SDXL 48-bit, FLUX.2 48-bit) and `_SD1_STRING` ("StableDiffusionV1", SD 1.x/2.x). Optional dep (extra `detect`); returns None when absent. The `detect` extra pulls **torch** transitively (invisible-watermark declares torch a hard dep, and `WatermarkDecoder` eagerly imports `rivaGan` -> `torch` at import time), so detection needs torch present even though dwtDct runs CPU-only on cv2/numpy/pywavelets — no GPU and no separate `gpu` extra required. **Unlike SynthID this is locally detectable**, but the watermark is fragile (does not survive JPEG re-encode/resize — verified gone after JPEG q90), so it confirms origin only on pristine files. Add new known patterns here. The file carries a top-of-module pyright pragma because imwatermark/cv2 ship no type stubs. - `trustmark_detector.py` — `detect_trustmark(path)` decodes the OPEN, keyless **Adobe TrustMark** watermark (the soft binding behind Adobe Durable Content Credentials, `alg` `com.adobe.trustmark.P`) via the optional `trustmark` package (extra `trustmark`; pulls torch, downloads model weights on first use). Mirrors `invisible_watermark.py` (lazy singleton guarded by a double-checked `threading.Lock` so concurrent callers do not double-download the weights, top-of-module pyright pragma, returns None when absent). It detects *provenance*, not AI origin as such (TrustMark also marks human-authored content), so `identify` lists it as a watermark without setting `is_ai_generated`. Other soft-binding vendors (Digimarc/Imatag/Steg.AI/...) have no public decoder — they are only *named* via the `C2PA_SOFT_BINDINGS` scan, not decoded. **False-positive gate (added 2026-05-29):** TrustMark's `wm_present` is a BCH error-correction validity flag that spuriously validates on a content-correlated fraction of un-watermarked images — AI-generated textures trip it far more than camera photos (verified 2026-05-29 on real files: it fires on Gemini/OpenAI/Doubao output that *cannot* carry Adobe's watermark, with a random-bytes decoded secret, while signal-free camera photos did not trip it). A genuine TrustMark is a *durable* soft binding engineered to survive re-encoding, so `detect_trustmark` re-decodes after a mild JPEG round-trip (`_survives_reencode`, `_REENCODE_QUALITY` 95) and requires the same schema both times; every observed false positive collapsed (none survived even q95), so the gate is the durability property the watermark guarantees. The second decode runs only on the rare initial hit, so the cost is negligible. Do NOT remove the gate to "catch more" — a lone TrustMark hit without it is almost always content noise. -- `text_protector.py` — text-region protection for the `invisible` SDXL img2img pass (issue #21: CJK/small text deforms at watermark-removal strengths). `is_available()` gates on `cv2.dnn.TextDetectionModel_DB`; `TextProtector.detect_text_boxes(bgr)` runs the **PP-OCRv3 DB** ONNX detector (~2.4 MB, Apache-2.0, opencv_zoo, returns rotated quad polygons) — downloaded+cached to `~/.cache/remove-ai-watermarks` on first use via atomic temp-rename, never bundled, **no torch (cv2.dnn only)**. **Detection is script-agnostic** (DB segments text *regions*, not characters), so Latin / Cyrillic / CJK / Hangul / Arabic / digits all detect identically — language was never the recall lever, **resolution was**. `_detection_input_size(h, w)` (pure, unit-tested) detects at the **native long side capped at `_DET_MAX_LONG_SIDE` (1536), never upscaled**: the old fixed 736 downscaled large canvases so small text fell below the detector and was missed (issue #14, e.g. ~16 px text on a 2048 image). `scripts/text_detection_benchmark.py` measures recall across scripts × sizes × canvas: the cap fix lifts overall hit-rate 0.91 → 1.00 (worst cell 2048/16 px: 0.06 → 1.00) at ~100 ms CPU. Very large canvases with tiny text may still need tiling (documented limit, not built). `build_change_map(boxes, h, w, preserve=0.9, feather=15)` paints a Differential-Diffusion change map. **Polarity (verified empirically):** white(1.0)=PRESERVE original pixels, black(0.0)=MAX change; map is black bg + `preserve` inside text polygons, Gaussian-feathered edges, clipped to [0,1]. `preserve` stays below a hard 1.0 freeze by default so text still scrubs lightly (SynthID survives cropping). **Default text protection is `watermark_remover._run_region_hires`, NOT the differential change map.** Differential Diffusion froze text in latent space (`preserve`<1.0), so the watermark survived *inside* text — violating the "remove SynthID everywhere" requirement; and the SDXL VAE's 8px latent cell softens sub-8px strokes regardless of `preserve` (architectural limit, confirmed by the DD authors — see `docs/text-protection-research.md`). `_run_region_hires` instead: (1) scrubs the whole image (plain img2img), (2) RE-scrubs each detected text block at HIGH resolution and feather-composites it back. `merge_text_regions(boxes,h,w)` groups boxes into local blocks; each crop is upscaled by `_REGION_HIRES_SCALE` 3.0 (applied as an integer factor via int(...), capped so a region stays under `_REGION_MAX_MEGAPIXELS` 1.3 to avoid OOM; skipped if it can't reach 2x — very large text areas then fall back to the global scrub, tiling is the future fix), img2img-scrubbed, downscaled, **phase-correlated back to the original crop to null the ~1-2px round-trip offset** (the shift is applied only on a confident, small correlation -- `response > 0.3` and `|shift| < 4` -- so a spurious large offset on a flat crop no longer garbles the composite; and after a CPU fallback the generator is dropped before the per-region passes to avoid an MPS-vs-CPU generator device mismatch) (a sub-pixel shift garbles the composite even when text is crisp; integer scale alone did NOT fix it because the diffusion pipeline rounds dims to a multiple of 8), then `feather_paste`d. Every pixel is regenerated, so the watermark is removed everywhere AND small text stays crisp (high-res strokes span >1 latent cell). Validated on synthetic 18px multilingual text: text-region SSIM 0.28 (plain) → 0.48 (region-hires), visually garbled → readable across Latin/Cyrillic/CJK, residual shift ~0.5px. Gated to the SDXL `DEFAULT_MODEL_ID` + detector (`_can_protect_text`); no text → plain global scrub (text-free inputs pay only the cheap cv2 detection). CLI off-switch `--no-protect-text` on `invisible`/`all`. `merge_text_regions` + `feather_paste` are pure, unit-tested without a model (`tests/test_text_protector.py`). **MUST still be confirmed by the SynthID oracle** (openai.com/verify / Gemini app) that a region-rescrubbed text zone reads watermark-free before trusting it in prod. The legacy `_run_differential` / `build_change_map` / `_load_differential_pipeline` (community `pipeline_stable_diffusion_xl_differential_img2img`, `custom_revision="0.38.0"`) remain in the file but are no longer the default; the diff pipeline upcasts the VAE to fp32 internally, so do **not** add `upcast_vae()`/`enable_attention_slicing` there (NaN/black on fp16 MPS). `build_change_map` is still unit-tested. -- `face_protector.py` — YOLO detect + soft-blend pattern; mirror this for any "protect region during diffusion" features. The expensive extract+blend already runs only when a face is found, but the YOLO detector itself always loads+runs to decide; CLI off-switch `--no-protect-faces` on `invisible`/`all` skips it entirely (mirrors `--no-protect-text`), for images known to have no people. +- `text_protector.py` — text-region protection for the `invisible` SDXL img2img pass (issue #21: CJK/small text deforms at watermark-removal strengths). `is_available()` gates on `cv2.dnn.TextDetectionModel_DB`; `TextProtector.detect_text_boxes(bgr)` runs the **PP-OCRv3 DB** ONNX detector (~2.4 MB, Apache-2.0, opencv_zoo, returns rotated quad polygons) — downloaded+cached to `~/.cache/remove-ai-watermarks` on first use via atomic temp-rename, never bundled, **no torch (cv2.dnn only)**. **Detection is script-agnostic** (DB segments text *regions*, not characters), so Latin / Cyrillic / CJK / Hangul / Arabic / digits all detect identically — language was never the recall lever, **resolution was**. `_detection_input_size(h, w)` (pure, unit-tested) detects at the **native long side capped at `_DET_MAX_LONG_SIDE` (1536), never upscaled**: the old fixed 736 downscaled large canvases so small text fell below the detector and was missed (issue #14, e.g. ~16 px text on a 2048 image). `scripts/text_detection_benchmark.py` measures recall across scripts × sizes × canvas: the cap fix lifts overall hit-rate 0.91 → 1.00 (worst cell 2048/16 px: 0.06 → 1.00) at ~100 ms CPU. Very large canvases with tiny text may still need tiling (documented limit, not built). `build_change_map(boxes, h, w, preserve=0.9, feather=15)` paints a Differential-Diffusion change map. **Polarity (verified empirically):** white(1.0)=PRESERVE original pixels, black(0.0)=MAX change; map is black bg + `preserve` inside text polygons, Gaussian-feathered edges, clipped to [0,1]. `preserve` stays below a hard 1.0 freeze by default so text still scrubs lightly (SynthID survives cropping). **Default text protection is `watermark_remover._run_region_hires`, NOT the differential change map.** Differential Diffusion froze text in latent space (`preserve`<1.0), so the watermark survived *inside* text — violating the "remove SynthID everywhere" requirement; and the SDXL VAE's 8px latent cell softens sub-8px strokes regardless of `preserve` (architectural limit, confirmed by the DD authors — see `docs/text-protection-research.md`). `_run_region_hires` instead: (1) scrubs the whole image (plain img2img), (2) RE-scrubs each detected text block at HIGH resolution and feather-composites it back. `merge_text_regions(boxes,h,w)` groups boxes into local blocks; each crop is upscaled by `_REGION_HIRES_SCALE` 3.0 (applied as an integer factor via int(...), capped so a region stays under `_REGION_MAX_MEGAPIXELS` 1.3 to avoid OOM; skipped if it can't reach 2x — very large text areas then fall back to the global scrub, tiling is the future fix), img2img-scrubbed, downscaled, **phase-correlated back to the original crop to null the ~1-2px round-trip offset** (the shift is applied only on a confident, small correlation -- `response > 0.3` and `|shift| < 4` -- so a spurious large offset on a flat crop no longer garbles the composite; and after a CPU fallback the generator is dropped before the per-region passes to avoid an MPS-vs-CPU generator device mismatch) (a sub-pixel shift garbles the composite even when text is crisp; integer scale alone did NOT fix it because the diffusion pipeline rounds dims to a multiple of 8), then `feather_paste`d. Every pixel is regenerated, so the watermark is removed everywhere AND small text stays crisp (high-res strokes span >1 latent cell). Validated on synthetic 18px multilingual text: text-region SSIM 0.28 (plain) → 0.48 (region-hires), visually garbled → readable across Latin/Cyrillic/CJK, residual shift ~0.5px. Gated to the SDXL `DEFAULT_MODEL_ID` + detector (`_can_protect_text`); no text → plain global scrub (text-free inputs pay only the cheap cv2 detection). CLI opt-in `--protect-text` on `invisible`/`all` (**OFF by default** — see SynthID bullet). `merge_text_regions` + `feather_paste` are pure, unit-tested without a model (`tests/test_text_protector.py`). **The high-res re-scrub can shield SynthID in text regions** (verified 2026-06-01: same gpt-image, with `--protect-text` → SynthID detected by oracle; without → SynthID removed). The mechanism: the global pass at step 1 removes SynthID everywhere, but the per-region high-res re-scrub at step 2 regenerates those pixels from a higher-resolution crop -- if the per-region strength is insufficient at the effective upscaled resolution, SynthID can reconstitute. Until this is resolved, `protect_text` is EXPERIMENTAL and OFF by default. The legacy `_run_differential` / `build_change_map` / `_load_differential_pipeline` (community `pipeline_stable_diffusion_xl_differential_img2img`, `custom_revision="0.38.0"`) remain in the file but are no longer the default; the diff pipeline upcasts the VAE to fp32 internally, so do **not** add `upcast_vae()`/`enable_attention_slicing` there (NaN/black on fp16 MPS). `build_change_map` is still unit-tested. +- `face_protector.py` — YOLO detect + soft-blend pattern; mirror this for any "protect region during diffusion" features. The expensive extract+blend already runs only when a face is found, but the YOLO detector itself always loads+runs to decide; CLI opt-in `--protect-faces` on `invisible`/`all` (**OFF by default**, experimental). **Face protection has an even more direct SynthID-preservation mechanism than text protection:** it extracts face regions from the ORIGINAL (watermarked) image BEFORE the diffusion pass, then blends those original pixels BACK after the global pass. Those restored pixels are the unprocessed originals -- SynthID is guaranteed to survive in face regions (not just possibly, as with text re-scrub). Any image with faces processed with `--protect-faces` will have SynthID intact in the face areas regardless of strength. - `humanizer.py` — optional post-process "humanize" effects (cv2/numpy). The chromatic-shift step replicates the border instead of wrapping opposite-edge pixels, so a shifted channel no longer bleeds the far edge into the near one. - `image_io.py` — Unicode-safe cv2 IO (issue #17). `imread(path, flags=None)` / `imwrite(path, img)` wrap `np.fromfile`+`cv2.imdecode` / `cv2.imencode`+`tofile` so non-ASCII paths work on Windows -- bare `cv2.imread`/`cv2.imwrite` use the platform ANSI code-page API there and fail (empty decode + `can't open/read file`) on Chinese/Cyrillic/accented filenames. `imread` keeps `cv2.imread` semantics (defaults to `IMREAD_COLOR`, returns `None` on missing/empty/undecodable). **Every cv2 file read/write in the package routes through here; do not call `cv2.imread`/`cv2.imwrite` directly.** `imwrite` returns `False` on an unwritable path (`OSError` caught) instead of raising, matching `cv2.imwrite` semantics. macOS/Linux already accept UTF-8 paths, so it is behavior-neutral there (the bug only reproduces on Windows). cv2/numpy are imported lazily inside the functions, so the module is cheap to import in a bare env. @@ -82,7 +82,9 @@ Who embeds what, and whether it is locally detectable (so we know which gaps are - `ultralytics` monkey-patches `PIL.Image.open` and tries to autoload `pi_heif`. When `pi_heif` is missing, opening files raises `ModuleNotFoundError`, not `UnidentifiedImageError`. Code that opens user-supplied or unknown-format files should `except Exception`, not just `OSError`/`UnidentifiedImageError`. - **rich was dropped (CLI + scripts print plain text via `click.echo`).** `cli.py` renders through small `_Console`/`_Table`/`_Progress` shims; the analysis scripts (`scripts/synthid_corpus.py`, `synthid_pixel_probe.py`, `text_detection_benchmark.py`, `corpus_gap_scan.py`) import `Console`/`Table` from the shared `scripts/_plain_console.py` shim (markup like `[bold]`/`[/]` is stripped, tables render aligned). Consequences: (1) `rich` is NOT a dependency, so anything that imports it breaks a clean `uv sync --frozen` (CI installs core+dev only) — this exact gap red-failed CI after the refactor when those 4 scripts still imported rich; if you add a script, use the `_plain_console` shim, not rich. (2) The old `[gpu]`-bracket-eaten bug (#19) is gone — plain `click.echo` prints `pip install 'remove-ai-watermarks[gpu]'` verbatim, no escaping needed (regression-guarded by `tests/test_cli.py::TestGpuHintMarkup`). (3) No Unicode glyphs / colors / progress bars in CLI output by design. - 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" on a synthetic pure-white PNG — this bypasses the recitation block that rejects text prompts for pure colors), phase coherence at the white carriers `(0,±7..±12,±20..±23)` = **0.86** vs **0.31** random; single-image leave-one-out phase-match **+0.83** vs real photos **-0.24**. (Black `2.5-flash` fills clip to std≈0 — SynthID can't push values below 0, so no carrier in black; the repo's dark carriers come from nano-banana-pro.) **But it does not generalize:** (a) carriers are model-version + resolution + color specific — the repo's v4 codebook (built for `gemini-3.1-flash-image-preview` + `nano-banana-pro-preview`) scores ~0.527 on my 2.5-flash white fills, indistinguishable from negatives (~0.50), i.e. carriers shift across model versions and need a per-model codebook; (b) on real content (30 `2.5-flash` images) the carrier collapses — set phase coherence at carriers 0.37 ≈ random 0.42, and the repo's v4 detector gives content 0.518 ≈ negatives 0.504 (no separation; a faint +0.24 single-image lean is likely a brightness confound). Net: the spectral/phase approach is a real *controlled-fill* characterizer, NOT an arbitrary-real-content detector, and is brittle to model version. Metadata proxy + visible sparkle + online oracles remain the ceiling for real content. +- **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.** 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. **SynthID is durable to JPEG re-encode by design, so a GitHub-recompressed issue attachment is still a valid SynthID test subject** (verified 2026-06-01 on issue #14's pic3: the GitHub-served JPEG survived re-encoding and openai.com/verify still detected SynthID). Do NOT dismiss issue-attachment JPEGs as "not faithful originals" when reproducing a SynthID-survival report: the recompression strips the **C2PA metadata** (so `identify` reads Unknown on the attachment) but NOT the **pixel watermark** that openai.com/verify reads. A true byte-original only matters for the metadata/C2PA path, not for the pixel-SynthID-removal test. (Contrast the open imwatermark above, which IS fragile to JPEG.) 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" on a synthetic pure-white PNG — this bypasses the recitation block that rejects text prompts for pure colors), phase coherence at the white carriers `(0,±7..±12,±20..±23)` = **0.86** vs **0.31** random; single-image leave-one-out phase-match **+0.83** vs real photos **-0.24**. (Black `2.5-flash` fills clip to std≈0 — SynthID can't push values below 0, so no carrier in black; the repo's dark carriers come from nano-banana-pro.) **But it does not generalize:** (a) carriers are model-version + resolution + color specific — the repo's v4 codebook (built for `gemini-3.1-flash-image-preview` + `nano-banana-pro-preview`) scores ~0.527 on my 2.5-flash white fills, indistinguishable from negatives (~0.50), i.e. carriers shift across model versions and need a per-model codebook; (b) on real content (30 `2.5-flash` images) the carrier collapses — set phase coherence at carriers 0.37 ≈ random 0.42, and the repo's v4 detector gives content 0.518 ≈ negatives 0.504 (no separation; a faint +0.24 single-image lean is likely a brightness confound). Net: the spectral/phase approach is a real *controlled-fill* characterizer, NOT an arbitrary-real-content detector, and is brittle to model version. Metadata proxy + visible sparkle + online oracles remain the ceiling for real content. - **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-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 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 +- **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` is OFF by default (CORRECTED 2026-06-01, supersedes earlier A/B findings):** verified on a gpt-image at 1600x1600 (issue #14, June 2026 oracle study): same image, with `--protect-text` → SynthID detected by openai.com/verify; without → SynthID removed. The 2026-05-31 A/B finding ("protect_text does not block removal") was Gemini-SynthID-only and did not generalize to OpenAI gpt-image at 1600x1600. Mechanism: the global pass removes SynthID everywhere, but the per-region hires re-scrub regenerates those pixels from an upscaled crop -- at that effective resolution the per-region pass may be insufficient to re-destroy the payload. Both `protect_text` and `protect_faces` are now **EXPERIMENTAL, opt-in** (`--protect-text` / `--protect-faces`), OFF by default. **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 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).** **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. diff --git a/README.md b/README.md index 5b8d2c7..a52734b 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ image → encode to latent space (VAE) at native resolution > **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. > -> **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. +> **Text and face protection are OFF by default.** The high-resolution text re-scrub can shield SynthID in text regions, leaving the watermark intact there even after the global pass clears it everywhere else (verified June 2026: same image, with `--protect-text` → SynthID detected; without → SynthID removed). Both features are opt-in with `--protect-text` / `--protect-faces` and considered **experimental**. If you enable them, verify the result with the oracle. > > **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. > @@ -126,11 +126,13 @@ SDXL is the default since May 2026: empirically defeats SynthID v2 on Gemini 3 P > **Oracle vs `identify` can disagree, and that is expected.** An online verifier reads the actual SynthID *pixel* watermark and detects only its own vendor's content — [openai.com/research/verify](https://openai.com/research/verify/) states "OpenAI generation signals will only be detected if the image was generated with our tools". Our `identify` cannot decode the pixel watermark (no vendor ships a local decoder), so it infers SynthID from the **C2PA metadata** instead. So after the SDXL pass the oracle can read "no SynthID" (pixel watermark gone) while `identify` still reports SynthID from a surviving C2PA manifest. They measure different signals. Run `metadata --remove` (or `all`) to also strip the manifest; note that a quiet metadata proxy is not proof the pixel watermark itself is gone. -**Face Protection**: before diffusion, YOLO detects people in the image and extracts them. After diffusion, the original faces are blended back with a soft elliptical mask to prevent AI distortion of facial features. Pass `--no-protect-faces` to skip the detector entirely (useful when the image has no people). +> **Technical deep-dive:** see [`docs/synthid.md`](docs/synthid.md) for a primary-source-cited breakdown of how SynthID works mechanically (post-hoc encoder/decoder, 136-bit payload, pixel-space embedding), what it empirically survives (JPEG, crop, resize: ~99.98% TPR at 0.1% FPR from arXiv:2510.09263), what removes it, and the forensic-stealth tradeoff (all known removal attacks are detectable at >98% TPR@1%FPR per arXiv:2605.09203). + +**Face Protection** (experimental, opt-in `--protect-faces`): before diffusion, YOLO detects people in the image and extracts them; after diffusion the original faces are blended back. Off by default — enable only when face fidelity matters more than SynthID removal completeness. **Analog Humanizer**: optional film grain and chromatic aberration injection that mimics a photo of a screen, raising the bar for AI-generated image classifiers. (It frustrates generic classifiers but does not guarantee forensic invisibility — see the [arXiv:2605.09203](https://arxiv.org/abs/2605.09203) note above.) -**Text Protection** (automatic): SDXL img2img regenerates every pixel, so small text and glyphs get deformed at the strengths that defeat SynthID. The SDXL pipeline guards against this by default: a PP-OCRv3 text detector (a 2.4 MB ONNX model run on CPU via OpenCV's DNN module, downloaded and cached on first use) locates text regions, and if any are found the whole image is scrubbed normally and then each text block is **re-scrubbed at high resolution** and composited back. Because the text is regenerated (not copied), the watermark is still removed inside it, while running the text region at higher resolution keeps small strokes crisp through the regeneration. Detection is **language-agnostic** (it finds text regions, not characters), so Latin, Cyrillic, CJK, Hangul, Arabic, and digits are all protected, and it runs at the image's native resolution so small text on large images is not missed. Text-free images run the standard pass at no extra cost. Pass `--no-protect-text` to turn it off. SDXL default pipeline only. +**Text Protection** (experimental, opt-in `--protect-text`): re-scrubs detected text blocks at high resolution after the global pass to keep small glyphs crisp. **Off by default** because the high-resolution re-scrub can preserve SynthID in text regions even after the global pass removes it elsewhere. Enable only when text fidelity matters more than watermark removal completeness, and verify the oracle result. SDXL pipeline only. ### Stripping C2PA, EXIF, and "Made with AI" metadata diff --git a/docs/synthid.md b/docs/synthid.md new file mode 100644 index 0000000..b89cade --- /dev/null +++ b/docs/synthid.md @@ -0,0 +1,429 @@ +# SynthID-Image: technical reference + +This document covers how Google SynthID for images works mechanically, what it +survives, what removes it, and the current deployment landscape. It is written +for engineers working on watermark detection and removal -- specifically to +inform decisions about strength settings, test methodology, and what oracle +results mean. + +Primary sources are cited inline. Marketing-only claims are flagged separately +from independently-verified results. + +--- + +## 1. Mechanism + +### 1.1 Post-hoc, model-independent design + +SynthID-Image is **not** baked into a diffusion model's weights. It is a +post-hoc, model-independent system: a separate encoder `f` is applied to an +already-generated image, and a separate decoder `g` reads it back. + +> "We deliberately designed SynthID-Image as a post-hoc, model-independent +> approach, a choice largely based on deployment considerations." +> -- Gowal et al., arXiv:2510.09263 + +The formal definition from the paper: + +> "A post-hoc watermarking scheme is a pair f, g consisting of an encoder +> function f: X -> X, which adds an identification mark, and a decoder +> function g: X -> {+-1}, which tries to detect if the mark is present." + +This is the key architectural fact: **the generative model (Imagen, Gemini's +image model) is not modified**. The watermark is stamped onto the pixel output +after generation, by a separate neural network. This means: + +- The watermark is in **pixel space**, not in the model's latent activations. +- Replacing the generative model does not remove the watermarking capability. +- The encoder/decoder pair can be updated independently of the generative model. + +The paper does not disclose the internal architecture of the encoder/decoder +networks (layer types, capacity). The external variant SynthID-O is available +to partners; the production internal variant is not published. + +### 1.2 How it differs from classical DWT-DCT watermarks + +The open watermarks used by Stable Diffusion / SDXL / FLUX (via the +`imwatermark` library) use classical **DWT-DCT** frequency-domain embedding: a +fixed bit pattern is added to specific frequency coefficients of the image's +wavelet transform. This is fast, key-free, and locally detectable with a public +decoder. + +SynthID-Image uses **jointly-trained deep learning models**: + +> "SynthID uses two deep learning models -- for watermarking and identifying -- +> that have been trained together on a diverse set of images. The combined model +> is optimised on a range of objectives, including correctly identifying +> watermarked content and improving imperceptibility by visually aligning the +> watermark to the original content." +> -- Google DeepMind blog, 2023 + +The practical difference for robustness: the deep learning encoder learns to +spread the signal across the image in a way that is optimized to survive a +specific perturbation distribution seen during training. Classical DWT-DCT +embeds in fixed, predictable frequency bins, making it brittle to any +operation that hits those bins (e.g., JPEG re-quantization wipes it cleanly at +quality <= 90). + +### 1.3 Payload capacity + +SynthID-O (the external/partnership variant) encodes: + +- **136 bits** within a **512x512 pixel image** + +For comparison (from the same paper): + +| Method | Bits | Resolution | +|-------------|------|------------| +| SynthID-O | 136 | 512x512 | +| StegaStamp | 100 | 400x400 | +| TrustMark | 100 | 256x256 | +| WAM | 32 | 256x256 | + +The payload carries an identification mark (not a user-readable secret). The +paper separates watermark **detection** (is this watermarked?) from payload +**recovery** (what does the payload say?): the detection path is what oracles +like the Gemini app's "Verify with SynthID" exercise. + +### 1.4 Where in the pipeline it lives + +``` +[Diffusion model] + | + raw pixel output + | + [SynthID encoder f] <-- separate neural net, stamps the watermark + | + watermarked image + | + [served / downloaded] + | + [SynthID decoder g] <-- separate neural net, run by Google's verifier only + | + present / not present +``` + +The VAE decoder of the diffusion model is **not** involved in watermarking. +Some in-generation watermark approaches (like the research method "Tree Ring") +inject the signal into the initial noise latent so it propagates through the +diffusion process and appears in the final image; SynthID-Image does not do +this -- it is applied after the VAE has already decoded latents to pixels. + +--- + +## 2. Robustness + +### 2.1 What the paper claims it survives (primary-source verified) + +The SynthID-Image paper (arXiv:2510.09263) evaluates SynthID-O against **30 +image transformations** grouped into 6 categories: + +| Category | Examples | +|-------------|-----------------------------------------------| +| Color | brightness, contrast, saturation, hue shifts | +| Combination | combinations of multiple transforms | +| Noise | Gaussian noise, impulse noise, median filter | +| Overlay | text overlays, logos, stickers | +| Quality | JPEG compression, WebP, format conversion | +| Spatial | crop, resize, rotate, flip, padding | + +**TPR at 0.1% FPR -- SynthID-O vs. baselines (resized to 512x512):** + +| Category | SynthID-O | Best baseline (WAM) | Worst baseline (StegaStamp spatial) | +|------------------|-----------|---------------------|--------------------------------------| +| Identity (none) | 100.00% | 100.00% | 100.00% | +| Aggregated | 99.98% | 90.62% | ~70% | +| Color | 100.00% | 81.29% | ~75% | +| Combination | 99.96% | 96.08% | ~22% | +| Noise | 99.98% | 100.00% | ~92% | +| Overlay | 100.00% | 100.00% | 100.00% | +| Quality | 99.99% | -- | ~89% | +| Spatial (worst) | 99.97% | 76.04% | 15.25% | + +The "Spatial worst" row is the hardest case (aggressive crop + resize). +SynthID-O retains 99.97% TPR; StegaStamp collapses to 15.25%. This is where +the deep-learning approach gains the most over classical methods. + +Google's marketing page states the watermark is: + +> "designed to stand up to modifications like cropping, adding filters, changing +> frame rates, or lossy compression." +> -- deepmind.google/models/synthid/ + +The marketing claim is broadly consistent with the paper's numbers for these +specific categories. + +**JPEG and format conversion specifically** fall under the "Quality" category, +where SynthID-O achieves 99.99% TPR. This is the empirical basis for the fact +that **GitHub-recompressed JPEGs from issue attachments are valid SynthID test +subjects**: the re-encoding does not remove the pixel watermark. + +### 2.2 Stated limits (vendor claim, not independently verified) + +> "SynthID isn't foolproof against extreme image manipulations." +> -- Google DeepMind blog, 2023 + +This is the only public failure-mode statement Google has made. No specific +perturbation type, threshold, or quantitative boundary is named. The +Limitations section of the paper (Section 10) was not recoverable from the +public HTML version of arXiv:2510.09263v1 due to a rendering failure in the +conversion (the body text of Section 10 is absent from the HTML). + +**What is known empirically from independent work and our own testing:** + +- **Diffusion regeneration / img2img** at sufficient strength degrades or + removes the watermark. Our testing (May-June 2026, Gemini oracle): + - strength 0.05: insufficient for current Gemini SynthID (survives) + - strength 0.10: removes Gemini SynthID (verified via Gemini app oracle, n=1) + - strength 0.30: current DEFAULT; removes Gemini SynthID (verified n=3 via + Gemini app oracle on fresh Gemini images, June 2026 oracle study) + - strength 0.30: **does NOT reliably remove OpenAI gpt-image SynthID on + 1600x1600 images** (verified via openai.com/verify, issue #14 reports, + June 2026) + - strength 0.35 and 0.40: not yet oracle-verified on 1600x1600 gpt-image; + 0.40 visibly corrupts text-heavy images + - **Resolution dependence confirmed**: same strength removes watermark on + small images (376x429) but not on large ones (1600x1600) -- larger images + appear to carry a stronger or more spatially distributed signal + - The production SynthID has been progressively hardened: 0.05 worked earlier + (pre-May 2026 Gemini), then 0.10 was needed, now 0.30 for Gemini and still + failing at 0.30 for 1600x1600 gpt-image. It is a moving target. + +- **Heavy JPEG compression** (quality < ~50-60): not specifically tested with + oracle verification; the DL approach is more robust than DWT-DCT but Google + acknowledges limits at "extreme" manipulation. + +### 2.3 Removal attacks and forensic detectability + +The paper arXiv:2605.09203 ("Removing the Watermark Is Not Enough", +Goonatilake & Ateniese, 2026) evaluates 6 removal attacks against a ResNet-50 +forensic detector. All attacks defeat the watermark verifier but are detected +by the forensic classifier: + +| Attack | Family | AUROC | TPR @ 1% FPR | TPR @ 0.1% FPR | +|-----------------|------------------|--------|--------------|----------------| +| UnMarker | Distortion | 0.9994 | 99.81% | 98.28% | +| WatermarkAttacker| Regeneration | 0.9997 | 99.95% | 99.38% | +| CtrlRegen+ | Regeneration | 0.9999 | 99.97% | 99.64% | +| NFPA | Inversion/Pert. | 0.9984 | 99.24% | 62.10% | +| Boundary Leak. | Inversion/Pert. | 0.9991 | 99.24% | 88.34% | +| WiTS | Erosion | 0.9999 | 99.80% | 99.55% | + +The forensic detector is a standard ResNet-50 fine-tuned end-to-end; no exotic +architecture needed. The key finding: + +> "These removers do not return images to a clean forensic state. They often +> trade an explicit watermark for an implicit watermark: a detectable artifact +> introduced by the removal process itself." + +This means: even when our SDXL img2img pass defeats the SynthID pixel +watermark (oracle reads negative), the output may still be classifiable as +"an image that went through a removal pipeline" by an independent detector -- +even if that detector is not trained on SynthID specifically. **Defeating the +verifier does not restore forensic deniability.** + +CtrlRegen+ is the most detectable removal method (AUROC 0.9999), which is +notable because it is also the most powerful removal attack. The paper notes +that diffusion regeneration "leaves a strong reconstruction signature from the +diffusion prior." + +--- + +## 3. Detectability and verifier access + +### 3.1 No public local detector + +The SynthID decoder is proprietary and not released: + +> "SynthID-Image has been used to watermark over ten billion images and video +> frames across Google's services and its corresponding verification service is +> available to trusted testers." +> -- Gowal et al., arXiv:2510.09263 + +There is no public API, no released decoder weights, and no reproducible +algorithm for local detection. The verification service (SynthID Detector) is: + +> "a verification portal" in early testing with "journalists and media +> professionals" on a waitlist +> -- deepmind.google/models/synthid/ + +The external variant SynthID-O is available "through partnerships" only. Our +tool cannot locally detect SynthID presence or absence -- this is by design, +not a gap we can fill. + +### 3.2 How our tool detects SynthID (metadata proxy) + +We detect SynthID indirectly: if the image's C2PA manifest is signed by a +known SynthID-using issuer (Google, OpenAI), we infer SynthID is present. This +is a **metadata proxy**, not a pixel watermark decode. It works while the C2PA +manifest is intact, and is silent once the manifest is stripped or the image +is re-encoded without C2PA (e.g., a screenshot, a social-media re-upload, or +after `metadata --remove`). + +This is why: +- `identify` on a GitHub-recompressed issue attachment returns Unknown (C2PA is + gone) even though the pixel SynthID is still present and detectable by + openai.com/verify. +- A quiet `identify` output is not proof that SynthID was removed -- it only + means the metadata signal is gone. + +### 3.3 Oracle scope: each vendor detects only their own + +From openai.com/research/verify (verbatim, verified 2026-05-31): + +> "OpenAI generation signals will only be detected if the image was generated +> with our tools." +> "Content could also still be AI-generated by another company's model, which +> the tool currently does not detect." + +SynthID technology is used by multiple vendors, but each verifier is keyed to +its own payload: + +| Oracle | Detects | Does NOT detect | +|-------------------------------|------------------|-------------------------| +| Gemini app "Verify with SynthID" | Google SynthID | OpenAI SynthID | +| openai.com/research/verify | OpenAI SynthID | Google SynthID | + +A Google-SynthID image reads clean on openai.com/verify. An OpenAI image reads +clean in the Gemini oracle. They are different payloads within the same +framework. + +--- + +## 4. Adoption and current state (as of June 2026) + +### 4.1 Google products + +Google has watermarked **over 10 billion** images and video frames. The +deployment split by surface matters for our tool: + +| Surface | SynthID pixel | C2PA metadata | Visible sparkle | +|--------------------------------------|---------------|---------------|-----------------| +| Gemini app (generated images) | YES | YES (Google) | YES | +| Gemini API / AI Studio / Nano Banana | YES | NO | YES | + +The Gemini API surface is a key blind spot: it embeds the pixel watermark and +the visible sparkle but **no C2PA or IPTC at all**. Our `identify` returns +Unknown on API-generated images unless the visible sparkle is detected (via +`check_visible=True`) or the user runs the Gemini app oracle. + +### 4.2 OpenAI + +OpenAI confirmed SynthID adoption (Help Center, updated 2026-05-21): + +> "ChatGPT images include both C2PA metadata and SynthID watermarks." + +This is time-gated: pre-rollout ChatGPT/gpt-image images carry C2PA without +SynthID. Our C2PA proxy therefore over-reports SynthID presence on old images +(hence the `_OPENAI_CAVEAT` hedging flag in the codebase). + +### 4.3 Other vendors + +- **Kakao** (South Korea): SynthID adopter as of May 2026 (Google announcement) +- **NVIDIA Cosmos**: SynthID for video (not still images; different pipeline) +- **Meta AI**: does NOT use SynthID; uses IPTC `digitalSourceType` marker instead + +### 4.4 Version evolution (v1 vs v2 hardening) + +Google has not publicly documented version numbers for the SynthID image +watermark in a way that maps to our testing observations. What is known +empirically from oracle tests: + +- **Before May 2026 (Gemini)**: strength 0.05 removed the watermark +- **May 2026 (Gemini)**: strength 0.05 insufficient; 0.10 required +- **Current (Gemini, June 2026)**: strength 0.10 insufficient for fresh images; + 0.30 verified clean (Gemini app oracle, n=3, A100 GPU, native resolution) +- **Current (OpenAI gpt-image 1600x1600, June 2026)**: strength 0.30 still + detected by openai.com/verify (issue #14, user qw1212ss report) + +The progression suggests Google has progressively hardened the watermark -- the +embedding signal strength or spatial distribution has increased across model +generations. No Google announcement confirms this; the observation is purely +empirical from oracle tests. + +--- + +## 5. Practical implications for this tool + +### 5.1 Text and face protection: OFF by default + +**Text protection (`--protect-text`) can preserve SynthID in text regions.** +Verified June 2026 on gpt-image at 1600x1600: same image processed twice -- +with `--protect-text` the oracle detected SynthID; without it, SynthID was +removed. The mechanism: the global img2img pass clears SynthID everywhere, but +the text-protection high-resolution re-scrub regenerates those pixels from an +upscaled crop. At the effective resolution of the upscaled crop, the per-region +pass may be insufficient to re-destroy the payload, reconstituting SynthID in +text regions. + +**Face protection (`--protect-faces`) has an even more direct preservation +mechanism.** The pipeline extracts face regions from the ORIGINAL (watermarked) +image BEFORE the diffusion pass, runs the global pass (which removes SynthID +everywhere), then blends the original face pixels BACK onto the result +(`invisible_engine.py`: `original_faces = protector.extract_faces(cv_img)` +before `remove_watermark`, then `protector.restore_faces(out_cv, original_faces)` +after). Those restored pixels are the original watermarked pixels -- SynthID is +guaranteed to survive in face regions, not just possibly. The text-protection +case is at least re-generating (uncertain); face protection is literally +restoring the original SynthID-bearing pixels. + +Both `--protect-text` and `--protect-faces` are therefore **experimental and +OFF by default**. Enable only when text/face fidelity matters more than +watermark removal completeness, and always verify the result with the oracle. + +### 5.2 Strength setting + +There is no single permanent correct strength. The default 0.30 was set based +on the June 2026 oracle study (Gemini, n=3). Known gaps: + +- **OpenAI gpt-image at 1600x1600**: 0.30 does not clear it (oracle-verified, + June 2026). 0.35 and 0.40 untested with oracle. 0.40 visibly corrupts text. +- **Resolution matters**: the same strength that clears a 376x429 image fails + at 1600x1600 (qw1212ss observation, issue #14, multiple images) + +If the watermark survives at 0.30, the correct guidance is to try 0.35 then +0.40, using the lowest value that reads clean on the vendor oracle. + +### 5.3 Test methodology + +- **GitHub-recompressed JPEGs from issue attachments are valid SynthID test + subjects.** JPEG re-encoding removes C2PA metadata but does NOT remove the + SynthID pixel watermark (verified June 2026 on issue #14 pic3). Do not + dismiss these as "not faithful originals" for SynthID-removal tests. +- **The correct oracle for OpenAI images is openai.com/verify**, not the Gemini + app. The two oracles detect different payloads. +- **A quiet `identify` output after processing is not proof of removal.** It + means the metadata proxy is gone. The pixel watermark state is unknown without + an oracle check. +- **After removal, the output may carry forensic artifacts** detectable by an + independent classifier even if the vendor oracle reads negative. Defeating the + verifier is not the same as being forensically indistinguishable from clean + content (arXiv:2605.09203). + +### 5.4 ctrlregen and img2img: the tradeoff + +Both the paper and our testing confirm: higher img2img strength removes the +watermark but introduces detectable regeneration artifacts. The Goonatilake & +Ateniese paper shows CtrlRegen+ (the most powerful remover) is simultaneously +the most forensically detectable (AUROC 0.9999). The tradeoff is unavoidable +with current diffusion-based approaches. + +--- + +## References + +1. Gowal et al. (2025). **SynthID-Image: Image watermarking at internet scale.** + arXiv:2510.09263. https://arxiv.org/abs/2510.09263 + +2. Google DeepMind. **Identifying AI-generated images with SynthID.** Blog post, + 2023. https://deepmind.google/blog/identifying-ai-generated-images-with-synthid/ + +3. Google DeepMind. **SynthID.** Product page. + https://deepmind.google/models/synthid/ + +4. Goonatilake & Ateniese (2026). **Removing the Watermark Is Not Enough: + Forensic Stealth in Generative-AI Watermark Removal.** arXiv:2605.09203. + https://arxiv.org/abs/2605.09203 + +5. OpenAI. **Verify tool for AI-generated images.** openai.com/research/verify. + Accessed 2026-05-31. diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index 952cfbc..15cd700 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -136,6 +136,25 @@ def _validate_image(path: Path) -> Path: _ALPHA_FORMATS = {".png", ".webp"} +# Shared option decorators for commands that run the invisible-watermark pipeline. +# Both cmd_invisible and cmd_all expose these flags; defining them once avoids +# copy-paste drift. +_protect_text_option = click.option( + "--protect-text", + is_flag=True, + default=False, + help=( + "Enable text region protection (experimental: re-scrubs text blocks at high resolution). " + "May prevent SynthID removal in text areas -- verify with oracle before relying on it." + ), +) +_protect_faces_option = click.option( + "--protect-faces", + is_flag=True, + default=False, + help="Enable face protection (experimental: YOLO detect + blend original faces back).", +) + def _watermark_region(det: DetectionResult, width: int, height: int) -> tuple[int, int, int, int]: """Pick a watermark bbox: detector's region if confident, else the default config slot.""" @@ -459,18 +478,8 @@ def cmd_erase( default=0, help="Cap long side (px) before diffusion; 0 = native (best quality, like raiw.cc). Raise only on GPU/MPS OOM.", ) -@click.option( - "--no-protect-text", - is_flag=True, - default=False, - help="Disable automatic text protection (text/CJK is preserved by default on the SDXL pipeline).", -) -@click.option( - "--no-protect-faces", - is_flag=True, - default=False, - help="Disable face protection (skips the YOLO face detector; use when the image has no people).", -) +@_protect_text_option +@_protect_faces_option @click.pass_context def cmd_invisible( ctx: click.Context, @@ -484,8 +493,8 @@ def cmd_invisible( hf_token: str | None, humanize: float, max_resolution: int, - no_protect_text: bool, - no_protect_faces: bool, + protect_text: bool, + protect_faces: bool, ) -> None: """Remove invisible AI watermarks (SynthID, StableSignature, TreeRing). @@ -531,8 +540,8 @@ def cmd_invisible( guidance_scale=None, seed=seed, humanize=humanize, - protect_text=not no_protect_text, - protect_faces=not no_protect_faces, + protect_text=protect_text, + protect_faces=protect_faces, max_resolution=max_resolution, ) elapsed = time.monotonic() - t0 @@ -707,18 +716,8 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo default=0, help="Cap long side (px) before diffusion; 0 = native (best quality, like raiw.cc). Raise only on GPU/MPS OOM.", ) -@click.option( - "--no-protect-text", - is_flag=True, - default=False, - help="Disable automatic text protection (text/CJK is preserved by default on the SDXL pipeline).", -) -@click.option( - "--no-protect-faces", - is_flag=True, - default=False, - help="Disable face protection (skips the YOLO face detector; use when the image has no people).", -) +@_protect_text_option +@_protect_faces_option @click.pass_context def cmd_all( ctx: click.Context, @@ -735,8 +734,8 @@ def cmd_all( hf_token: str | None, humanize: float, max_resolution: int, - no_protect_text: bool, - no_protect_faces: bool, + protect_text: bool, + protect_faces: bool, ) -> None: """Remove ALL watermarks: visible + invisible + metadata. @@ -827,8 +826,8 @@ def cmd_all( num_inference_steps=steps, seed=seed, humanize=humanize, - protect_text=not no_protect_text, - protect_faces=not no_protect_faces, + protect_text=protect_text, + protect_faces=protect_faces, max_resolution=max_resolution, ) console.print(" Invisible watermark removed") diff --git a/src/remove_ai_watermarks/invisible_engine.py b/src/remove_ai_watermarks/invisible_engine.py index 6fa3411..e0f97b6 100644 --- a/src/remove_ai_watermarks/invisible_engine.py +++ b/src/remove_ai_watermarks/invisible_engine.py @@ -125,8 +125,8 @@ class InvisibleEngine: guidance_scale: float | None = None, seed: int | None = None, humanize: float = 0.0, - protect_faces: bool = True, - protect_text: bool = True, + protect_faces: bool = False, + protect_text: bool = False, max_resolution: int = 0, ) -> Path: """Remove invisible watermark from an image.