mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-15 05:50:45 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5298dcc6a3 | ||
|
|
d88b87ca4e | ||
|
|
9be66752c5 | ||
|
|
69559226d7 | ||
|
|
29da3c52b6 | ||
|
|
e4f558dccf |
No files matched your search
@@ -40,7 +40,7 @@ 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=)`: `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, 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). Wired into `watermark_remover._run_differential` via the community `pipeline_stable_diffusion_xl_differential_img2img` (loaded with `custom_revision="0.38.0"` — HF resolves the **PyPI** version string, not the `v0.38.0` git tag); gated to the SDXL `DEFAULT_MODEL_ID` only (`_can_protect_text`), falls back to plain img2img otherwise. **Autonomous by default** (`protect_text=True` in `invisible_engine`/`watermark_remover`, mirroring `protect_faces`): the detector runs per image and `_run_differential` falls back to plain img2img when **no boxes** are found, so text-free inputs pay only the cheap cv2 detection (no differential-pipeline load). CLI exposes a single off-switch `--no-protect-text` on `invisible`/`all` (passed as `protect_text=not no_protect_text`); the unavailable-model case logs at debug, not warning, since it is now the default path. The diff pipeline upcasts the VAE to fp32 internally, so do **not** add `upcast_vae()`/`enable_attention_slicing` (both produced NaN/black on fp16 MPS). `build_change_map` is unit-tested without any model download (`tests/test_text_protector.py`).
|
||||
- `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 an **integer** factor (`_REGION_HIRES_SCALE` 3, 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** (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
|
||||
- `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.** 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.
|
||||
|
||||
@@ -74,10 +74,11 @@ Who embeds what, and whether it is locally detectable (so we know which gaps are
|
||||
## Known limitations
|
||||
|
||||
- `invisible` pipeline processes at **native resolution by default** (`max_resolution=0`), matching the hosted raiw.cc backend (fal fast-sdxl, no pre-downscale). The old forced downscale-to-1024 -> upscale-back round-trip was the main quality loss (issue #10) and is gone; at strength ~0.05 SDXL img2img does not need the ~1024 downscale. `--max-resolution N` re-introduces an opt-in long-side cap purely to bound GPU/MPS memory on very large inputs (it reintroduces the lossy round-trip). For huge images that OOM at native, tile-based diffusion is still the proper long-term fix. **Concrete MPS data point (verified 2026-05-25 on a 1254x1254 gpt-image SDXL run, fp32, 20 GB MPS ceiling):** native res OOMs at the *UNet* step (peak ~17 GiB), not only the VAE decode, and the auto-fallback in `img2img_runner` reloads on CPU and finishes (slow, ~13 min) -- the output is still weight-identical and defeats SynthID, so "looks hung/crashed" on Mac is usually this CPU fallback, not a pipeline error. Adding `enable_vae_tiling()` alone does NOT prevent it (the peak is the UNet, not the VAE). The fast Mac workarounds are fp16 on MPS (roughly halves memory) or `--max-resolution` to cap the long side; neither is wired as the default. The native-vs-downscale decision lives in the pure helper `invisible_engine._target_size(w, h, max_resolution)` (returns `None` for native, a clamped target tuple otherwise) so it is unit-tested (`tests/test_invisible_engine.py::TestTargetSize`, the #10/#15 regression guard) without loading the model -- keep that logic in the helper, don't re-inline it.
|
||||
- **fp16 VAE black-output fix (issue #29, 2026-05-30):** on a **CUDA/XPU fp16** backend the stock SDXL VAE overflows to NaN and the *plain* img2img path decodes to an **all-black** image (reproduced on the raiw.cc result: a 1086x1448 input -> a uniformly black 4.6 KB PNG, mean 0). `watermark_remover._load_pipeline` now swaps in the fp16-fixed SDXL VAE (`madebyollin/sdxl-vae-fp16-fix` = `_SDXL_FP16_VAE_ID`) when `_needs_fp16_vae_fix(model_id, DEFAULT_MODEL_ID, is_fp16)` is true -- only the default SDXL checkpoint on fp16. **cpu/mps run fp32** (the stock VAE is fine there, which is why the bug never reproduces on Mac), and the **differential / region-hires** pipeline already upcasts the VAE itself (see the `text_protector` bullet). A custom non-SDXL `model_id` keeps its own VAE (the fp16-fix VAE is SDXL-architecture-specific). The decision is a pure helper, unit-tested without a download (`tests/test_platform.py::TestFp16VaeFix`); the actual black->clean recovery needs a CUDA GPU and was NOT verifiable on this MPS machine -- confirm on the backend / an NVIDIA box.
|
||||
- Pyright first run is slow (2-3 min) due to ML deps (torch/diffusers/transformers stubs); full-project `uv run pyright` can stall for many minutes — scope it to changed files.
|
||||
- `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 `console.print` parses `[word]` as a style tag and silently drops unknown ones.** A literal bracketed token in a print string disappears: `pip install 'remove-ai-watermarks[gpu]'` rendered as `...remove-ai-watermarks'` (the `[gpu]` extra eaten), which sent users a broken install command (surfaced via #19). Escape the literal bracket as `\[gpu]` (in a normal Python string that is `"\\[gpu]"`) in any rich string carrying user-facing brackets. Regression-guarded by `tests/test_cli.py::TestGpuHintMarkup`.
|
||||
- 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). 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 deteLine 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:** the SDXL-based default profile (since May 2026) defeats SynthID v2. **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-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). 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
|
||||
@@ -110,7 +110,7 @@ SDXL is the default since May 2026: empirically defeats SynthID v2 on Gemini 3 P
|
||||
|
||||
**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 pass switches to Differential Diffusion so a per-pixel change map keeps the text regions largely intact while the background is regenerated normally. 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** (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.
|
||||
|
||||
### Stripping C2PA, EXIF, and "Made with AI" metadata
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
description = "Remove visible and invisible AI watermarks from images (Gemini / Nano Banana, ChatGPT, Stable Diffusion)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Remove-AI-Watermarks: Unified tool for removing visible and invisible AI watermarks."""
|
||||
|
||||
__version__ = "0.7.0"
|
||||
__version__ = "0.7.2"
|
||||
@@ -21,6 +21,7 @@ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeEl
|
||||
from rich.table import Table
|
||||
|
||||
from remove_ai_watermarks import __version__, watermark_registry
|
||||
from remove_ai_watermarks.noai.watermark_profiles import DEFAULT_STRENGTH
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
@@ -362,7 +363,7 @@ def cmd_erase(
|
||||
@click.option(
|
||||
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
|
||||
)
|
||||
@click.option("--strength", type=float, default=0.05, help="Denoising strength (0.0-1.0). Default: 0.05.")
|
||||
@click.option("--strength", type=float, default=DEFAULT_STRENGTH, help="Denoising strength (0.0-1.0). Default: 0.10.")
|
||||
@click.option("--steps", type=int, default=50, help="Number of denoising steps. Default: 50.")
|
||||
@click.option(
|
||||
"--pipeline",
|
||||
@@ -482,14 +483,20 @@ def cmd_metadata(
|
||||
output: Path | None,
|
||||
keep_standard: bool,
|
||||
) -> None:
|
||||
"""Check or remove AI-generation metadata from images.
|
||||
"""Check or remove AI-generation metadata (images, video, and audio).
|
||||
|
||||
Strips EXIF AI tags, PNG text chunks, and C2PA provenance manifests.
|
||||
Strips EXIF AI tags, PNG text chunks, C2PA provenance manifests, and the
|
||||
China TC260 AIGC label. Beyond images (PNG/JPEG/WebP/AVIF/HEIF/JXL) it also
|
||||
strips provenance metadata from MP4/MOV/M4V/M4A containers and, via ffmpeg,
|
||||
from WebM/MP3/WAV/FLAC/OGG. The coded image, audio, and video data are left
|
||||
untouched.
|
||||
"""
|
||||
from remove_ai_watermarks.metadata import get_ai_metadata, has_ai_metadata, remove_ai_metadata
|
||||
|
||||
# No _validate_image() here: unlike the image-only commands, metadata also
|
||||
# accepts video/audio containers, so the image-format warning would misfire.
|
||||
# click's `exists=True` on the argument already enforces the file exists.
|
||||
_banner()
|
||||
source = _validate_image(source)
|
||||
|
||||
if check or (not remove):
|
||||
has_ai = has_ai_metadata(source)
|
||||
@@ -593,7 +600,9 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
|
||||
@click.option(
|
||||
"--inpaint-method", type=click.Choice(["ns", "telea", "gaussian"]), default="ns", help="Inpainting method."
|
||||
)
|
||||
@click.option("--strength", type=float, default=0.05, help="Invisible watermark denoising strength (0.0-1.0).")
|
||||
@click.option(
|
||||
"--strength", type=float, default=DEFAULT_STRENGTH, help="Invisible watermark denoising strength. Default: 0.10."
|
||||
)
|
||||
@click.option("--steps", type=int, default=50, help="Number of denoising steps for invisible removal.")
|
||||
@click.option(
|
||||
"--pipeline",
|
||||
|
||||
@@ -19,6 +19,8 @@ import warnings
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from remove_ai_watermarks.noai.watermark_profiles import DEFAULT_STRENGTH
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
@@ -133,7 +135,7 @@ class InvisibleEngine:
|
||||
Args:
|
||||
image_path: Path to the watermarked image.
|
||||
output_path: Output path (None = overwrite source).
|
||||
strength: Denoising strength (0.0-1.0). Default 0.04.
|
||||
strength: Denoising strength (0.0-1.0). None -> DEFAULT_STRENGTH (0.10).
|
||||
steps: Number of denoising steps.
|
||||
guidance_scale: Classifier-free guidance scale.
|
||||
seed: Random seed for reproducibility.
|
||||
@@ -277,7 +279,7 @@ class InvisibleEngine:
|
||||
self,
|
||||
input_dir: Path,
|
||||
output_dir: Path,
|
||||
strength: float = 0.04,
|
||||
strength: float = DEFAULT_STRENGTH,
|
||||
steps: int = 50,
|
||||
) -> list[Path]:
|
||||
"""Remove invisible watermarks from all images in a directory."""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Watermark removal model profiles, strength presets, and profile detection.
|
||||
"""Watermark removal model profiles, the default strength, and profile detection.
|
||||
|
||||
Pure configuration and lookup functions with no ML dependencies.
|
||||
"""
|
||||
@@ -8,12 +8,18 @@ from __future__ import annotations
|
||||
DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
|
||||
CTRLREGEN_MODEL_ID = "yepengliu/ctrlregen"
|
||||
|
||||
LOW_STRENGTH = 0.04
|
||||
MEDIUM_STRENGTH = 0.35
|
||||
HIGH_STRENGTH = 0.7
|
||||
|
||||
_HIGH_PERTURBATION = ("stegasamp", "stegastamp", "treering", "ringid")
|
||||
_LOW_PERTURBATION = ("stablesignature", "dwtectsvd", "rivagan", "ssl", "hidden")
|
||||
# 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
|
||||
|
||||
|
||||
def get_model_id_for_profile(profile: str) -> str:
|
||||
@@ -31,21 +37,3 @@ def detect_model_profile(model_id: str) -> str:
|
||||
if "ctrlregen" in model_id.lower():
|
||||
return "ctrlregen"
|
||||
return "default"
|
||||
|
||||
|
||||
def get_recommended_strength(watermark_type: str) -> float:
|
||||
"""Get recommended strength for different watermark types.
|
||||
|
||||
Args:
|
||||
watermark_type: Type of watermark. One of: 'low', 'medium', 'high',
|
||||
or specific names like 'stegastamp', 'treering', etc.
|
||||
|
||||
Returns:
|
||||
Recommended strength value.
|
||||
"""
|
||||
wt = watermark_type.lower()
|
||||
if any(name in wt for name in _HIGH_PERTURBATION):
|
||||
return HIGH_STRENGTH
|
||||
if any(name in wt for name in _LOW_PERTURBATION):
|
||||
return LOW_STRENGTH
|
||||
return MEDIUM_STRENGTH
|
||||
@@ -31,9 +31,7 @@ from PIL import Image
|
||||
from remove_ai_watermarks.noai.watermark_profiles import (
|
||||
CTRLREGEN_MODEL_ID,
|
||||
DEFAULT_MODEL_ID,
|
||||
HIGH_STRENGTH,
|
||||
LOW_STRENGTH,
|
||||
MEDIUM_STRENGTH,
|
||||
DEFAULT_STRENGTH,
|
||||
detect_model_profile,
|
||||
)
|
||||
|
||||
@@ -63,6 +61,24 @@ def is_watermark_removal_available() -> bool:
|
||||
return _HAS_TORCH and _HAS_DIFFUSERS
|
||||
|
||||
|
||||
# Drop-in fp16-safe replacement for the SDXL VAE. The stock SDXL VAE overflows
|
||||
# to NaN in fp16 and decodes to an all-black image (issue #29: the raiw.cc black
|
||||
# result on a CUDA fp16 backend). This community VAE is numerically rescaled to
|
||||
# stay in fp16 range. SDXL-architecture only.
|
||||
_SDXL_FP16_VAE_ID = "madebyollin/sdxl-vae-fp16-fix"
|
||||
|
||||
|
||||
def _needs_fp16_vae_fix(model_id: str, default_model_id: str, is_fp16: bool) -> bool:
|
||||
"""Whether the plain img2img pipeline must swap in the fp16-fixed SDXL VAE.
|
||||
|
||||
Gated to the default SDXL checkpoint running in fp16: cpu/mps run fp32 (the
|
||||
stock VAE is fine there) and the differential pipeline upcasts the VAE on its
|
||||
own, so only this path on a fp16 GPU (CUDA/XPU) hits the NaN/black decode.
|
||||
A custom non-SDXL ``model_id`` keeps its own VAE (the fix is SDXL-specific).
|
||||
"""
|
||||
return is_fp16 and model_id == default_model_id
|
||||
|
||||
|
||||
_CUDA_FIX_ENV_KEY = "NOAI_CUDA_FIXED"
|
||||
|
||||
|
||||
@@ -277,9 +293,7 @@ class WatermarkRemover:
|
||||
|
||||
DEFAULT_MODEL_ID = DEFAULT_MODEL_ID
|
||||
CTRLREGEN_MODEL_ID = CTRLREGEN_MODEL_ID
|
||||
LOW_STRENGTH = LOW_STRENGTH
|
||||
MEDIUM_STRENGTH = MEDIUM_STRENGTH
|
||||
HIGH_STRENGTH = HIGH_STRENGTH
|
||||
DEFAULT_STRENGTH = DEFAULT_STRENGTH
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -374,6 +388,14 @@ class WatermarkRemover:
|
||||
if self.hf_token:
|
||||
load_kwargs["token"] = self.hf_token
|
||||
|
||||
# Avoid the SDXL fp16 NaN/all-black decode (issue #29) by loading the
|
||||
# fp16-fixed VAE for the default SDXL checkpoint on a fp16 GPU.
|
||||
if _needs_fp16_vae_fix(self.model_id, self.DEFAULT_MODEL_ID, self.torch_dtype == torch.float16):
|
||||
from diffusers import AutoencoderKL
|
||||
|
||||
self._set_progress("Loading fp16-fixed SDXL VAE (avoids black output)...")
|
||||
load_kwargs["vae"] = AutoencoderKL.from_pretrained(_SDXL_FP16_VAE_ID, torch_dtype=torch.float16)
|
||||
|
||||
self._pipeline = AutoImg2ImgPipeline.from_pretrained( # type: ignore
|
||||
self.model_id,
|
||||
**load_kwargs,
|
||||
@@ -446,7 +468,7 @@ class WatermarkRemover:
|
||||
if output_path is None:
|
||||
output_path = image_path
|
||||
|
||||
strength = strength or self.LOW_STRENGTH
|
||||
strength = strength or self.DEFAULT_STRENGTH
|
||||
|
||||
if not 0.0 <= strength <= 1.0:
|
||||
raise ValueError(f"Strength must be between 0.0 and 1.0, got {strength}")
|
||||
@@ -481,7 +503,7 @@ class WatermarkRemover:
|
||||
generator,
|
||||
)
|
||||
elif protect_text and self._can_protect_text():
|
||||
cleaned_image = self._run_differential(
|
||||
cleaned_image = self._run_region_hires(
|
||||
init_image,
|
||||
strength,
|
||||
num_inference_steps,
|
||||
@@ -621,6 +643,90 @@ class WatermarkRemover:
|
||||
self._diff_pipeline = None
|
||||
return self._load_differential_pipeline()
|
||||
|
||||
# Region high-res text scrub: defaults tuned so each text block is upscaled
|
||||
# enough that strokes exceed the VAE's ~8px latent cell, capped so a single
|
||||
# region never blows past the GPU/MPS memory budget.
|
||||
_REGION_HIRES_SCALE = 3.0
|
||||
_REGION_MAX_MEGAPIXELS = 1.3
|
||||
|
||||
def _run_region_hires(
|
||||
self,
|
||||
init_image: Image.Image,
|
||||
strength: float,
|
||||
num_inference_steps: int,
|
||||
guidance_scale: float,
|
||||
generator: Any,
|
||||
) -> Image.Image:
|
||||
"""Scrub the whole image, then RE-scrub each detected text block at high
|
||||
resolution and composite it back.
|
||||
|
||||
Unlike the Differential-Diffusion path (which freezes text in latent space
|
||||
and so leaves the watermark intact there), every pixel here is regenerated
|
||||
-- the watermark is removed everywhere. Small text survives because each
|
||||
text block is upscaled before its img2img pass, so strokes span more than
|
||||
one VAE latent cell (the ~8px floor that softens text at native scale);
|
||||
the scrubbed crop is downscaled and feather-composited back. Falls back to
|
||||
the plain global scrub when no text is detected.
|
||||
"""
|
||||
import math
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import text_protector
|
||||
|
||||
base = self._run_img2img(init_image, strength, num_inference_steps, guidance_scale, generator)
|
||||
|
||||
bgr = cv2.cvtColor(np.array(init_image), cv2.COLOR_RGB2BGR)
|
||||
try:
|
||||
boxes = text_protector.TextProtector().detect_text_boxes(bgr)
|
||||
except Exception as exc:
|
||||
logger.warning("Text detection failed (%s); keeping the global scrub.", exc)
|
||||
return base
|
||||
if not boxes:
|
||||
self._set_progress("No text detected; global scrub only.")
|
||||
return base
|
||||
|
||||
width, height = init_image.size
|
||||
regions = text_protector.merge_text_regions(boxes, height, width)
|
||||
orig_bgr = cv2.cvtColor(np.array(init_image), cv2.COLOR_RGB2BGR)
|
||||
out_bgr = cv2.cvtColor(np.array(base), cv2.COLOR_RGB2BGR)
|
||||
budget = self._REGION_MAX_MEGAPIXELS * 1_000_000
|
||||
|
||||
done = 0
|
||||
for x, y, w, h in regions:
|
||||
area = max(1, w * h)
|
||||
# INTEGER scale so the upscale -> scrub -> downscale round-trip is an
|
||||
# exact dimensional inverse (a fractional factor truncates and shifts
|
||||
# the composited text ~1-2px, which is invisible but tanks alignment).
|
||||
scale = int(min(self._REGION_HIRES_SCALE, math.sqrt(budget / area)))
|
||||
if scale < 2:
|
||||
# Region too large to even double within the budget: upscaling
|
||||
# buys nothing here; the global scrub covers it (documented limit
|
||||
# for very large text areas -- tiling is the future fix).
|
||||
continue
|
||||
crop = orig_bgr[y : y + h, x : x + w]
|
||||
up = cv2.resize(crop, (w * scale, h * scale), interpolation=cv2.INTER_LANCZOS4)
|
||||
up_pil = Image.fromarray(cv2.cvtColor(up, cv2.COLOR_BGR2RGB))
|
||||
scrubbed = self._run_img2img(up_pil, strength, num_inference_steps, guidance_scale, generator)
|
||||
down = cv2.resize(cv2.cvtColor(np.array(scrubbed), cv2.COLOR_RGB2BGR), (w, h), interpolation=cv2.INTER_AREA)
|
||||
# The up -> scrub -> down round-trip can offset the re-rendered text by
|
||||
# a pixel or two (the diffusion pipeline rounds dims to a multiple of
|
||||
# 8, so the inverse resize is not perfectly centered). Phase-correlate
|
||||
# the patch back to the original crop and translate it so the glyphs
|
||||
# land exactly where they were -- otherwise a sub-pixel shift garbles
|
||||
# the composite even though the text is crisp.
|
||||
cg = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY).astype(np.float32)
|
||||
dg = cv2.cvtColor(down, cv2.COLOR_BGR2GRAY).astype(np.float32)
|
||||
(sx, sy), _resp = cv2.phaseCorrelate(cg, dg)
|
||||
if abs(sx) > 0.1 or abs(sy) > 0.1:
|
||||
m = np.float32([[1, 0, -sx], [0, 1, -sy]])
|
||||
down = cv2.warpAffine(down, m, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
|
||||
out_bgr = text_protector.feather_paste(out_bgr, down, x, y)
|
||||
done += 1
|
||||
self._set_progress(f"Re-scrubbed {done}/{len(regions)} text region(s) at high resolution.")
|
||||
return Image.fromarray(cv2.cvtColor(out_bgr, cv2.COLOR_BGR2RGB))
|
||||
|
||||
def _run_differential(
|
||||
self,
|
||||
init_image: Image.Image,
|
||||
@@ -769,7 +875,7 @@ class WatermarkRemover:
|
||||
def remove_watermark(
|
||||
image_path: Path,
|
||||
output_path: Path | None = None,
|
||||
strength: float = 0.04,
|
||||
strength: float = DEFAULT_STRENGTH,
|
||||
model_id: str | None = None,
|
||||
device: str | None = None,
|
||||
hf_token: str | None = None,
|
||||
|
||||
@@ -151,6 +151,87 @@ def build_change_map(
|
||||
return change_map
|
||||
|
||||
|
||||
def merge_text_regions(
|
||||
boxes: list[NDArray[Any]],
|
||||
height: int,
|
||||
width: int,
|
||||
dilate_frac: float = 0.012,
|
||||
pad_frac: float = 0.02,
|
||||
max_regions: int = 8,
|
||||
) -> list[tuple[int, int, int, int]]:
|
||||
"""Group detected text polygons into a few padded axis-aligned rectangles.
|
||||
|
||||
The DB detector returns one box per word/line; the region-high-res text scrub
|
||||
runs a separate diffusion pass per region, so we coalesce nearby boxes into a
|
||||
handful of *local* blocks (a light dilation merges within a paragraph but not
|
||||
across the whole image, so each block stays small enough to upscale within a
|
||||
memory budget). Returns ``(x, y, w, h)`` rects, largest-area first, clipped to
|
||||
the image and capped at ``max_regions``.
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
mask = np.zeros((height, width), np.uint8)
|
||||
if not boxes:
|
||||
return []
|
||||
cv2.fillPoly(mask, [np.asarray(b, np.int32) for b in boxes], 1)
|
||||
k = max(1, int(min(height, width) * dilate_frac))
|
||||
mask = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_RECT, (k, k)))
|
||||
n, _labels, stats, _c = cv2.connectedComponentsWithStats(mask, 8)
|
||||
pad = int(min(height, width) * pad_frac)
|
||||
rects: list[tuple[int, int, int, int]] = []
|
||||
for i in range(1, n):
|
||||
x, y, w, h = (
|
||||
int(stats[i, cv2.CC_STAT_LEFT]),
|
||||
int(stats[i, cv2.CC_STAT_TOP]),
|
||||
int(stats[i, cv2.CC_STAT_WIDTH]),
|
||||
int(stats[i, cv2.CC_STAT_HEIGHT]),
|
||||
)
|
||||
x0, y0 = max(0, x - pad), max(0, y - pad)
|
||||
x1, y1 = min(width, x + w + pad), min(height, y + h + pad)
|
||||
rects.append((x0, y0, x1 - x0, y1 - y0))
|
||||
rects.sort(key=lambda r: -(r[2] * r[3]))
|
||||
return rects[:max_regions]
|
||||
|
||||
|
||||
def feather_paste(
|
||||
base: NDArray[Any],
|
||||
patch: NDArray[Any],
|
||||
x: int,
|
||||
y: int,
|
||||
feather: int = 8,
|
||||
) -> NDArray[Any]:
|
||||
"""Alpha-composite ``patch`` into ``base`` at ``(x, y)`` with a feathered edge.
|
||||
|
||||
Used to drop a separately re-scrubbed (high-resolution) text region back into
|
||||
the globally-scrubbed image without a visible seam. Returns a new array;
|
||||
``base`` is not modified. ``patch`` is clipped to ``base`` bounds.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
out = base.copy()
|
||||
bh, bw = base.shape[:2]
|
||||
ph, pw = patch.shape[:2]
|
||||
x0, y0 = max(0, x), max(0, y)
|
||||
x1, y1 = min(bw, x + pw), min(bh, y + ph)
|
||||
if x1 <= x0 or y1 <= y0:
|
||||
return out
|
||||
patch_roi = patch[y0 - y : y1 - y, x0 - x : x1 - x].astype(np.float32)
|
||||
base_roi = out[y0:y1, x0:x1].astype(np.float32)
|
||||
rh, rw = base_roi.shape[:2]
|
||||
alpha = np.ones((rh, rw), np.float32)
|
||||
f = max(0, min(feather, rh // 2, rw // 2))
|
||||
if f > 0:
|
||||
ramp = np.linspace(0.0, 1.0, f, dtype=np.float32)
|
||||
alpha[:f, :] *= ramp[:, None]
|
||||
alpha[rh - f :, :] *= ramp[::-1, None]
|
||||
alpha[:, :f] *= ramp[None, :]
|
||||
alpha[:, rw - f :] *= ramp[None, ::-1]
|
||||
a3 = alpha[:, :, None]
|
||||
out[y0:y1, x0:x1] = (patch_roi * a3 + base_roi * (1.0 - a3)).astype(base.dtype)
|
||||
return out
|
||||
|
||||
|
||||
class TextProtector:
|
||||
"""Detect text regions with PP-OCRv3 for diffusion change-map protection."""
|
||||
|
||||
|
||||
+25
-28
@@ -15,7 +15,6 @@ from remove_ai_watermarks.noai.utils import get_image_format, is_supported_forma
|
||||
from remove_ai_watermarks.noai.watermark_profiles import (
|
||||
detect_model_profile,
|
||||
get_model_id_for_profile,
|
||||
get_recommended_strength,
|
||||
)
|
||||
from remove_ai_watermarks.noai.watermark_remover import get_device, is_watermark_removal_available
|
||||
|
||||
@@ -121,33 +120,6 @@ class TestModelProfiles:
|
||||
def test_detect_ctrlregen(self):
|
||||
assert detect_model_profile("yepengliu/ctrlregen") == "ctrlregen"
|
||||
|
||||
def test_recommended_strength_high(self):
|
||||
assert get_recommended_strength("treering") == 0.7
|
||||
|
||||
def test_recommended_strength_low(self):
|
||||
assert get_recommended_strength("stablesignature") == 0.04
|
||||
|
||||
def test_recommended_strength_medium(self):
|
||||
assert get_recommended_strength("unknown_type") == 0.35
|
||||
|
||||
@pytest.mark.parametrize("wm_type", ["stegastamp", "stegasamp", "treering", "ringid"])
|
||||
def test_high_perturbation_watermark_types(self, wm_type):
|
||||
"""Robust spatial watermarks need aggressive (0.7) regeneration."""
|
||||
assert get_recommended_strength(wm_type) == 0.7
|
||||
|
||||
@pytest.mark.parametrize("wm_type", ["stablesignature", "dwtectsvd", "rivagan", "ssl", "hidden"])
|
||||
def test_low_perturbation_watermark_types(self, wm_type):
|
||||
"""Fragile frequency/latent watermarks break at low (0.04) strength."""
|
||||
assert get_recommended_strength(wm_type) == 0.04
|
||||
|
||||
def test_strength_match_is_case_insensitive(self):
|
||||
assert get_recommended_strength("TreeRing") == 0.7
|
||||
assert get_recommended_strength("StableSignature") == 0.04
|
||||
|
||||
def test_strength_matches_substring_in_descriptive_name(self):
|
||||
# e.g. a CLI passing "treering_v2" or "synthid-stegastamp" still maps.
|
||||
assert get_recommended_strength("treering_v2") == 0.7
|
||||
|
||||
|
||||
# ── Format utilities ────────────────────────────────────────────────
|
||||
|
||||
@@ -232,3 +204,28 @@ class TestPlatformPaths:
|
||||
# If we get here without error, asset loading works
|
||||
assert engine._alpha_small.shape == (48, 48)
|
||||
assert engine._alpha_large.shape == (96, 96)
|
||||
|
||||
|
||||
class TestFp16VaeFix:
|
||||
"""The plain SDXL img2img pipeline must swap in the fp16-fixed VAE on fp16
|
||||
GPUs to avoid the NaN/all-black decode (issue #29). Pure decision logic, no
|
||||
torch or model download needed."""
|
||||
|
||||
DEFAULT = "stabilityai/stable-diffusion-xl-base-1.0"
|
||||
|
||||
def test_default_sdxl_on_fp16_needs_fix(self):
|
||||
from remove_ai_watermarks.noai.watermark_remover import _needs_fp16_vae_fix
|
||||
|
||||
assert _needs_fp16_vae_fix(self.DEFAULT, self.DEFAULT, is_fp16=True) is True
|
||||
|
||||
def test_fp32_does_not_need_fix(self):
|
||||
"""cpu/mps run fp32, where the stock SDXL VAE is fine."""
|
||||
from remove_ai_watermarks.noai.watermark_remover import _needs_fp16_vae_fix
|
||||
|
||||
assert _needs_fp16_vae_fix(self.DEFAULT, self.DEFAULT, is_fp16=False) is False
|
||||
|
||||
def test_non_default_model_keeps_own_vae(self):
|
||||
"""A custom (non-SDXL) checkpoint must not get the SDXL-specific VAE."""
|
||||
from remove_ai_watermarks.noai.watermark_remover import _needs_fp16_vae_fix
|
||||
|
||||
assert _needs_fp16_vae_fix("runwayml/stable-diffusion-v1-5", self.DEFAULT, is_fp16=True) is False
|
||||
@@ -11,7 +11,75 @@ from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.text_protector import _DET_MAX_LONG_SIDE, _detection_input_size, build_change_map
|
||||
from remove_ai_watermarks.text_protector import (
|
||||
_DET_MAX_LONG_SIDE,
|
||||
_detection_input_size,
|
||||
build_change_map,
|
||||
feather_paste,
|
||||
merge_text_regions,
|
||||
)
|
||||
|
||||
|
||||
def _quad(x0, y0, x1, y1):
|
||||
"""An axis-aligned 4-vertex polygon as the detector returns."""
|
||||
return np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], np.int32)
|
||||
|
||||
|
||||
class TestMergeTextRegions:
|
||||
def test_empty(self):
|
||||
assert merge_text_regions([], 256, 256) == []
|
||||
|
||||
def test_far_apart_boxes_stay_separate(self):
|
||||
boxes = [_quad(10, 10, 60, 30), _quad(10, 200, 60, 220)]
|
||||
regions = merge_text_regions(boxes, 256, 256, dilate_frac=0.005, pad_frac=0.0)
|
||||
assert len(regions) == 2
|
||||
|
||||
def test_close_boxes_merge(self):
|
||||
# two boxes on the same line, a few px apart -> one block
|
||||
boxes = [_quad(10, 10, 60, 30), _quad(64, 10, 110, 30)]
|
||||
# dilate_frac sized to close the few-px inter-word gap on one line
|
||||
regions = merge_text_regions(boxes, 256, 256, dilate_frac=0.03)
|
||||
assert len(regions) == 1
|
||||
|
||||
def test_rects_in_bounds_and_padded(self):
|
||||
boxes = [_quad(100, 100, 150, 130)]
|
||||
(x, y, w, h) = merge_text_regions(boxes, 256, 256, pad_frac=0.05)[0]
|
||||
assert x >= 0
|
||||
assert y >= 0
|
||||
assert x + w <= 256
|
||||
assert y + h <= 256
|
||||
assert w > 50 # padded beyond the raw 50px box
|
||||
|
||||
def test_caps_region_count(self):
|
||||
boxes = [_quad(20 * i, 0, 20 * i + 8, 8) for i in range(20)]
|
||||
regions = merge_text_regions(boxes, 64, 512, dilate_frac=0.002, pad_frac=0.0, max_regions=5)
|
||||
assert len(regions) <= 5
|
||||
|
||||
|
||||
class TestFeatherPaste:
|
||||
def test_patch_lands_at_location_center(self):
|
||||
base = np.zeros((100, 100, 3), np.uint8)
|
||||
patch = np.full((40, 40, 3), 200, np.uint8)
|
||||
out = feather_paste(base, patch, 30, 30, feather=6)
|
||||
# center of the pasted region is (near) the patch value
|
||||
assert out[50, 50, 0] >= 190
|
||||
# far corner untouched
|
||||
assert out[2, 2, 0] == 0
|
||||
|
||||
def test_does_not_mutate_base(self):
|
||||
base = np.zeros((50, 50, 3), np.uint8)
|
||||
feather_paste(base, np.full((20, 20, 3), 255, np.uint8), 10, 10)
|
||||
assert base.sum() == 0
|
||||
|
||||
def test_shape_preserved(self):
|
||||
base = np.zeros((50, 60, 3), np.uint8)
|
||||
out = feather_paste(base, np.full((10, 10, 3), 100, np.uint8), 5, 5)
|
||||
assert out.shape == base.shape
|
||||
|
||||
def test_partial_out_of_bounds_no_crash(self):
|
||||
base = np.zeros((40, 40, 3), np.uint8)
|
||||
out = feather_paste(base, np.full((30, 30, 3), 150, np.uint8), 25, 25, feather=4)
|
||||
assert out.shape == (40, 40, 3)
|
||||
|
||||
|
||||
class TestDetectionInputSize:
|
||||
|
||||
Reference in new issue
Block a user