Merge pull request #60 from wiltodelta/claude/mystifying-cori-a4c1e7

v0.14.0: visible-mark localize→fill rewrite, backend priority, fixes
This commit is contained in:
Victor Kuznetsov
2026-07-11 09:53:07 +03:00
committed by GitHub
48 changed files with 2351 additions and 2457 deletions
+14 -12
View File
File diff suppressed because one or more lines are too long
+46 -33
View File
@@ -27,8 +27,8 @@ It does **not** target watermarks that protect someone else's paid or copyrighte
## Features
- **Visible watermark removal** — a registry of known marks in their usual places: the Gemini / Nano Banana sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, and the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-left, locale-specific). Each is removed by **reverse-alpha blending** against a captured alpha map (`original = (wm α·logo)/(1−α)`), recovering the true pixels rather than inpainting a guess. The Gemini sparkle recovers cleanly on its own on bright backgrounds; it adapts the alpha to each image's sparkle opacity, so a more-opaque-than-captured sparkle is still fully removed (and on a dark background, where the fixed alpha would over-subtract and leave a dark spot, it automatically inpaints the small sparkle footprint instead); the Doubao, Jimeng, and Samsung text marks re-rasterize slightly per image, so a thin residual inpaint over the glyph footprint clears the leftover edges (the alpha maps are reproducibly rebuilt from controlled captures by `scripts/visible_alpha_solve.py`). Fast, offline, no GPU. `visible --mark auto` finds and removes the strongest detected mark. (For arbitrary logos/objects, see `erase`.)
- **Universal region eraser (`erase`)** — remove any logo / watermark / object inside boxes you specify, regardless of position or colour. Default cv2 inpainting (CPU, instant); optional big-LaMa via onnxruntime (`lama` extra) for higher quality
- **Visible watermark removal** — a registry of known marks in their usual places: the Gemini / Nano Banana sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, and the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-left, locale-specific). Each mark is **localized to a footprint mask, then filled**: the engine finds the mark, builds a binary mask over its footprint, and one shared, swappable fill inpaints that region. Choose the fill with `--backend`: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN, light, the memory-tight pick where LaMa will not fit), or `lama` (big-LaMa, best quality, heavier, auto-preferred when a learned backend is available); the default `auto` uses LaMa > MI-GAN > cv2, best available. The localizer is cheap CPU (cv2/numpy), so it runs anywhere; the heavier MI-GAN/LaMa fill is opt-in. Detection keys on each mark's own shape (NCC against a captured silhouette; the alpha captures rebuilt by `scripts/visible_alpha_solve.py` are used to detect and to shape the mask, not for pixel recovery). The visual detector needs no metadata, but a borderline (faint or moved) mark is only trusted with corroboration: `--sensitivity` (default `auto`) relaxes a mark's gate when local metadata confirms the vendor or a same-product sibling mark is found; `--sensitivity assume-ai` relaxes every mark on the assertion that the image is AI, recovering the moved or re-rendered marks the conservative gate skips on a metadata-stripped screenshot (`strict` never relaxes). `visible --mark auto` finds and removes every detected mark in one pass. Fast, offline, no GPU. (For arbitrary logos/objects, see `erase`.)
- **Universal region eraser (`erase`)** — remove any logo / watermark / object inside boxes you specify, regardless of position or color. Default cv2 inpainting (CPU, instant); optional big-LaMa via onnxruntime (`lama` extra) for higher quality
- **Invisible watermark removal** — SynthID, StableSignature, TreeRing via diffusion-based regeneration (needs a local GPU, or run it with no setup on [raiw.cc](https://raiw.cc))
- **AI metadata stripping** — EXIF, PNG text chunks, C2PA provenance manifests (PNG / JPEG / AVIF / HEIF / JPEG-XL, **MP4 / MOV / M4V / M4A** at the container level, and **WebM / MP3 / WAV / FLAC / OGG** losslessly via ffmpeg), XMP DigitalSourceType
- **"Made with AI" label removal** — removes the AI-disclosure metadata that platforms read to apply automatic labels (useful for clearing a false-positive label from a human-edited photograph)
@@ -48,7 +48,7 @@ It does **not** target watermarks that protect someone else's paid or copyrighte
| AI model | Visible watermark | Invisible watermark | Metadata | Our approach |
| --- | --- | --- | --- | --- |
| **Google Gemini / Nano Banana / Gemini 3 Pro** | ✅ Sparkle logo | ✅ SynthID v1 + v2 (default SDXL pipeline, native resolution) | ✅ C2PA + EXIF | Alpha reversal + diffusion + metadata strip |
| **Google Gemini / Nano Banana / Gemini 3 Pro** | ✅ Sparkle logo | ✅ SynthID v1 + v2 (default SDXL pipeline, native resolution) | ✅ C2PA + EXIF | Localize + fill + diffusion + metadata strip |
| **OpenAI DALL-E 3 / ChatGPT** | — | — | ✅ C2PA manifest | Metadata strip |
| **OpenAI ChatGPT Images 2.0** (gpt-image-2) | — | ✅ SynthID + content-specific pixel watermark (since May 2026; no local decoder, openai.com/verify oracle) | ✅ C2PA manifest (verified) | Diffusion regeneration + metadata strip |
| **Stable Diffusion / SDXL (AUTOMATIC1111, ComfyUI)** | — | ✅ DWT-DCT (imwatermark — locally detectable) | ✅ PNG text chunks | Diffusion regeneration + metadata strip |
@@ -59,14 +59,14 @@ It does **not** target watermarks that protect someone else's paid or copyrighte
| **xAI Grok (Aurora)** | — | — | ✅ EXIF signature scheme (no C2PA): `Signature:` blob + UUID `Artist` | Detected (`identify`); metadata strip |
| **Midjourney** | — | — | ✅ EXIF + XMP (prompt, model, seed) | Metadata strip |
| **Meta AI** | — | — | ✅ IPTC "Made with AI" (digitalSourceType) | Metadata strip (removes the label) |
| **Doubao** (ByteDance) / China AIGC generators | ✅ "豆包AI生成" text strip (bottom-right) | — | ✅ TC260 AIGC label (`<TC260:AIGC>` XMP, `AIGC` PNG chunk, or EXIF JSON) **+ C2PA** signed by ByteDance Volcano Engine (`volcengine`) | Reverse-alpha (captured α map) + thin residual inpaint, NCC-aligned across resolutions, + metadata strip |
| **Jimeng / Dreamina** (即梦AI, ByteDance) | ✅ "★ 即梦AI" wordmark (bottom-right) | — | ✅ TC260 AIGC label + C2PA (Volcano Engine) | Reverse-alpha (captured α map) + residual inpaint over the glyph footprint, NCC-aligned across resolutions, + metadata strip |
| **Samsung Galaxy AI** (Generative Edit, Sketch to Image, ...) | ✅ "✦ Contenuti generati dall'AI" strip (bottom-left, locale-specific) | — | ✅ C2PA (signer "Samsung Galaxy") + `trainedAlgorithmicMedia` / proprietary `genAIType` marker | Reverse-alpha (captured α map) + thin residual inpaint, NCC-aligned across resolutions, + metadata strip |
| **Doubao** (ByteDance) / China AIGC generators | ✅ "豆包AI生成" text strip (bottom-right) | — | ✅ TC260 AIGC label (`<TC260:AIGC>` XMP, `AIGC` PNG chunk, or EXIF JSON) **+ C2PA** signed by ByteDance Volcano Engine (`volcengine`) | Localize glyph footprint + fill + metadata strip |
| **Jimeng / Dreamina** (即梦AI, ByteDance) | ✅ "★ 即梦AI" wordmark (bottom-right) | — | ✅ TC260 AIGC label + C2PA (Volcano Engine) | Localize glyph footprint + fill + metadata strip |
| **Samsung Galaxy AI** (Generative Edit, Sketch to Image, ...) | ✅ "✦ Contenuti generati dall'AI" strip (bottom-left, Italian-locale detection) | — | ✅ C2PA (signer "Samsung Galaxy") + `trainedAlgorithmicMedia` / proprietary `genAIType` marker | Localize glyph footprint + fill + metadata strip |
| **Black Forest Labs** (FLUX API) | — | — | ✅ C2PA (`Black Forest Labs API` + `c2pa.ai_generated_content` + `trainedAlgorithmicMedia`) | Metadata strip |
| **StableSignature** (Meta) | — | ✅ In-model watermark | — | Diffusion regeneration |
| **TreeRing** | — | ✅ Latent space watermark | — | Diffusion regeneration |
> Visible overlays are used by Google Gemini / Nano Banana (sparkle logo), by ByteDance's Doubao ("豆包AI生成" corner text) and Jimeng / Dreamina ("★ 即梦AI" wordmark), and by Samsung Galaxy AI ("✦ Contenuti generati dall'AI" strip, bottom-left, locale-specific). All are removed on CPU by reverse-alpha against a captured alpha map (Jimeng and Samsung add a thin residual inpaint over the glyph footprint, since their marks re-rasterize per image). Other services rely on invisible watermarks and/or metadata; our diffusion-based regeneration works against any invisible watermark in pixel or frequency domain. For a visible mark from any other source (any position, any colour), use the universal `erase --region` command.
> Visible overlays are used by Google Gemini / Nano Banana (sparkle logo), by ByteDance's Doubao ("豆包AI生成" corner text) and Jimeng / Dreamina ("★ 即梦AI" wordmark), and by Samsung Galaxy AI ("✦ Contenuti generati dall'AI" strip, bottom-left, locale-specific). All are removed by localizing the mark to a footprint mask and inpainting it with one shared fill (cv2 by default, MI-GAN or big-LaMa via `--backend`); the localizer is CPU-cheap and the heavier fills are opt-in. Other services rely on invisible watermarks and/or metadata; our diffusion-based regeneration works against any invisible watermark in pixel or frequency domain. For a visible mark from any other source (any position, any color), use the universal `erase --region` command.
> **Detection:** `remove-ai-watermarks identify <image>` reports the origin platform and watermark inventory for all the signals above — C2PA issuer, the C2PA soft-binding forensic-watermark vendor (TrustMark / Digimarc / Imatag / ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, the China TC260 AIGC label (XMP, PNG chunk, EXIF, or JPEG segment), the HuggingFace `hf-job-id` job marker, embedded generation params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the SynthID metadata proxy, the C2PA cloud-manifest reference (Adobe Durable Content Credentials, when the embedded manifest is stripped), the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" / Samsung Galaxy AI "Contenuti generati dall'AI" text marks), and (with the `[detect]` / `[trustmark]` extras) the open SD/SDXL/FLUX and Adobe TrustMark invisible watermarks. SynthID and the proprietary soft-binding watermarks (Digimarc etc.) have no local decoder, so they are reported by metadata proxy / vendor name only.
@@ -80,25 +80,19 @@ Google Gemini (internally codenamed **Nano Banana**) adds a visible sparkle logo
watermarked = α × logo + (1 α) × original
```
We reverse this with a known alpha map (extracted from Gemini / Nano Banana output on a pure-black background):
A multi-scale NCC (Normalized Cross-Correlation) detector finds the sparkle position and scale dynamically in the bottom-right region (with a false-positive gate), so it works even if the image was resized or cropped. Removal is **localize then fill**: the sparkle footprint is built from the captured alpha thresholded low (so the faint halo is included) and dilated by a sparkle-relative margin, and that mask is inpainted by the shared fill. The captured alpha maps are used only to detect the sparkle and to shape the mask, not to recover pixels. If local metadata already confirms Google (a Google / Gemini C2PA issuer), the detection trust gate is relaxed so a moved or re-rendered sparkle is still caught. Pick the fill with `--backend` (cv2 default, MI-GAN or big-LaMa opt-in).
```text
original = (watermarked α × logo) / (1 α)
```
A three-stage NCC (Normalized Cross-Correlation) detector finds the watermark position and scale dynamically, so it works even if the image was resized or cropped. After removal, residual sparkle-edge artifacts are cleaned via gradient-masked inpainting.
**Speed**: ~0.05s per image. No GPU needed.
**Speed**: fast on CPU with the default cv2 fill. No GPU needed.
### Removing the Doubao "豆包AI生成" text watermark
Doubao (ByteDance) stamps every output with a light, semi-transparent "豆包AI生成" text strip in the bottom-right corner — the visible AIGC label mandated by China's TC260 standard. It is a fixed semi-transparent white overlay, so it is removed by **reverse-alpha blending**: `original = (watermarked - α·logo) / (1 - α)`, recovering the true pixels instead of hallucinating them. The α map is solved from controlled black/gray captures (rebuildable with `scripts/visible_alpha_solve.py`). Like the Jimeng mark, Doubao re-rasterizes its text slightly per image, so reverse-alpha is followed by a thin residual inpaint over the glyph footprint to clear the leftover edges, and the α template is NCC-aligned to the actual mark (handling per-image scale/position jitter). Detection matches the same glyph silhouette against the corner (normalized correlation), so it keys on the "豆包AI生成" shape, not on textured corners.
Doubao (ByteDance) stamps every output with a light, semi-transparent "豆包AI生成" text strip in the bottom-right corner — the visible AIGC label mandated by China's TC260 standard. Detection matches the glyph silhouette against the corner (normalized correlation), so it keys on the "豆包AI生成" shape, not on textured corners. Removal is **localize then fill**: the light glyph blob is localized to a solid, dilated footprint mask (template-free, so a re-rendered or differently-placed mark is still masked) and the shared fill inpaints it. On corpus images the filled region blends into its surroundings within a few LAB levels, with no color shift and no dark pit. Pick the fill with `--backend` (cv2 default, MI-GAN or big-LaMa opt-in).
**Speed**: ~0.05s, no GPU needed.
**Speed**: fast on CPU with the default cv2 fill, no GPU needed.
### Removing the Jimeng "★ 即梦AI" wordmark
Jimeng / Dreamina (即梦AI, also ByteDance, distinct from Doubao) stamps a "★ 即梦AI" wordmark — a four-point sparkle followed by the 即梦AI characters — in the bottom-right corner. It is a fixed semi-transparent **pure-white** overlay, solved from controlled black / gray / white captures the same way as Doubao. `visible --mark auto` detects and removes it (or force it with `--mark jimeng`). One difference from Doubao: Jimeng re-rasterizes its mark slightly differently per image, so a single alpha map does not cancel it pixel-for-pixel — reverse-alpha knocks the mark down and a residual inpaint over the glyph footprint clears the remaining outline. The two ByteDance marks do not confuse `auto`: detection keys on each mark's own glyph shape (the Jimeng detector scores far below its threshold on a Doubao strip, and vice versa).
Jimeng / Dreamina (即梦AI, also ByteDance, distinct from Doubao) stamps a "★ 即梦AI" wordmark — a four-point sparkle followed by the 即梦AI characters — in the bottom-right corner. Detection keys on the wordmark's glyph shape (NCC against a captured silhouette). `visible --mark auto` detects and removes it (or force it with `--mark jimeng`). Removal is **localize then fill**: the glyph blob is localized to a solid, dilated footprint mask and the shared fill inpaints it, so a re-rendered or differently-placed mark is still cleared. On corpus images the filled region blends into its surroundings within a few LAB levels, no color shift. The two ByteDance marks do not confuse `auto`: detection keys on each mark's own glyph shape (the Jimeng detector scores far below its threshold on a Doubao strip, and vice versa).
```bash
remove-ai-watermarks visible jimeng.png -o clean.png # --mark auto picks Jimeng
@@ -107,7 +101,7 @@ remove-ai-watermarks visible jimeng.png --mark jimeng -o clean.png
### Removing the Samsung Galaxy AI "✦ Contenuti generati dall'AI" mark
Samsung's on-device Generative AI edits (Generative Edit, Sketch to Image, Portrait Studio) burn a visible sparkle + "generated with AI" string into the **bottom-left** corner — a faint, low-opacity semi-transparent white overlay. It is solved from controlled black / gray / white captures the same way as Jimeng and removed by reverse-alpha plus a thin residual inpaint over the glyph footprint (the mark re-rasterizes per image, and the flat captures are smaller than real photos, so the alpha template is NCC-aligned and width-scaled to the actual mark). `visible --mark auto` detects and removes it (or force it with `--mark samsung`); being bottom-left it never confuses the bottom-right Gemini/Doubao/Jimeng marks. The string is **locale-specific** — this build is calibrated for the Italian "Contenuti generati dall'AI" variant; other locales need their own captured template (open a sample on issue #37).
Samsung's on-device Generative AI edits (Generative Edit, Sketch to Image, Portrait Studio) burn a visible sparkle + "generated with AI" string into the **bottom-left** corner — a faint, low-opacity semi-transparent white overlay. Detection matches the glyph silhouette (NCC), and removal is **localize then fill**: the glyph blob is localized to a solid, dilated footprint mask and the shared fill inpaints it. `visible --mark auto` detects and removes it (or force it with `--mark samsung`); being bottom-left it never confuses the bottom-right Gemini/Doubao/Jimeng marks. **Detection is locale-specific** — this build detects only the Italian "Contenuti generati dall'AI" variant, so other Samsung locales are not detected (and thus not removed) and need their own captured detection template (open a sample on issue #37). The fill mask itself is locale-independent.
```bash
remove-ai-watermarks visible samsung.jpg -o clean.jpg # --mark auto picks Samsung
@@ -116,7 +110,7 @@ remove-ai-watermarks visible samsung.jpg --mark samsung -o clean.jpg
### Universal region eraser
For any visible mark the dedicated engines do not cover — a logo anywhere, any colour — `erase --region x,y,w,h` inpaints the box you specify. The default `cv2` backend is instant and dependency-free; the optional `lama` backend (big-LaMa via onnxruntime, `lama` extra, ~200 MB model downloaded on first use) gives much cleaner fills on textured regions at the cost of ~3-4 GB RAM per call.
For any visible mark the dedicated engines do not cover — a logo anywhere, any color — `erase --region x,y,w,h` inpaints the box you specify. The default `cv2` backend is instant and dependency-free; the optional `lama` backend (big-LaMa via onnxruntime, `lama` extra, ~200 MB model downloaded on first use) gives much cleaner fills on textured regions at the cost of ~3-4 GB RAM per call.
### Removing SynthID and other invisible watermarks
@@ -335,15 +329,24 @@ remove-ai-watermarks batch ./images/ --mode all
# of a clean origin. Add --json for machine-readable output.
remove-ai-watermarks identify image.png
# Visible watermark only — fast, offline, CPU. --mark auto (default) finds the
# strongest known mark (Gemini sparkle / Doubao "豆包AI生成" / Jimeng "即梦AI" /
# Visible watermark only — fast, offline, CPU. --mark auto (default) removes every
# detected known mark (Gemini sparkle / Doubao "豆包AI生成" / Jimeng "即梦AI" /
# Samsung Galaxy AI "Contenuti generati dall'AI"); force one with
# --mark gemini / doubao / jimeng / samsung. Removed by reverse-alpha (true-pixel recovery).
# --mark gemini / doubao / jimeng / samsung. Removal localizes each mark to a
# footprint mask and inpaints it with a shared fill; --backend auto|cv2|migan|lama
# (default auto) picks the fill (auto = LaMa > MI-GAN > cv2, best available).
# --sensitivity auto|strict|assume-ai (default auto) sets how hard a borderline
# mark is trusted; use assume-ai on a metadata-stripped screenshot to recover a
# moved or faint mark the conservative gate would skip.
# If no known visible mark is found, it writes no output and exits 2 (not 0),
# pointing you to `all` (for an invisible/metadata mark) or `erase` (for an
# arbitrary logo) instead of handing back the unchanged image.
remove-ai-watermarks visible image.png -o clean.png
# Metadata-stripped screenshot: assert it is AI to relax detection and recover
# a moved/faint mark (a wrong guess just fills a small corner near-losslessly).
remove-ai-watermarks visible screenshot.png --sensitivity assume-ai -o clean.png
# Erase arbitrary region(s) — universal, any logo/watermark/object, any position.
# Default cv2 inpainting (CPU). --backend lama uses big-LaMa (extra 'lama').
remove-ai-watermarks erase image.png --region 1640,1930,400,100 -o clean.png
@@ -390,20 +393,30 @@ remove-ai-watermarks batch ./images/ --mode all
### Python API
One high-level call removes every detected visible mark (Gemini sparkle, Doubao / Jimeng / Samsung text, the Jimeng pill) by localize then fill. For a file it reads metadata provenance automatically and preserves the alpha channel; `import remove_ai_watermarks` stays cheap (the heavy deps load lazily on first use).
```python
from remove_ai_watermarks.gemini_engine import GeminiEngine
import remove_ai_watermarks as raiw
# Clean a file -> writes clean.png, returns the array and what was removed.
result, removed = raiw.remove_visible("watermarked.png", "clean.png")
print("removed:", removed) # e.g. ['Google Gemini sparkle']
# An empty list means nothing known was found (route to `all` or `erase` instead).
# Array in, array out (BGR numpy). Pick the fill backend: cv2 (default), migan, lama.
import cv2
clean, removed = raiw.remove_visible(cv2.imread("in.png"), backend="cv2")
engine = GeminiEngine()
image = cv2.imread("watermarked.png")
# Metadata-stripped screenshot you know is AI-generated: relax detection to recover a
# moved or faint mark (a wrong guess just fills a small corner near-losslessly).
raiw.remove_visible("screenshot.png", "clean.png", sensitivity="assume_ai")
# Detect
result = engine.detect_watermark(image)
print(f"Detected: {result.detected} (confidence: {result.confidence:.1%})")
# What the file's metadata confirms (drives the default `auto` sensitivity):
raiw.visible_provenance("in.png") # e.g. frozenset({'gemini'})
# Remove
clean = engine.remove_watermark(image)
cv2.imwrite("clean.png", clean)
# Full provenance verdict (platform + watermark inventory + confidence):
from remove_ai_watermarks.identify import identify
report = identify("in.png")
```
#### Invisible removal (diffusion)
@@ -487,7 +500,7 @@ Won't fix:
## Limitations
- **Visible-mark and metadata removal is lossless.** Reverse-alpha recovers the original pixels under the mark; metadata stripping never touches image data.
- **Visible-mark removal is localized inpainting; metadata removal is lossless.** Each visible mark is localized to a footprint mask and filled by inpainting (cv2, MI-GAN, or big-LaMa), so only the small masked region is reconstructed and it blends into its surroundings within a few LAB levels; a slightly-off localization just fills a small region near-losslessly rather than leaving a color-shifted smear. Metadata stripping never touches image data.
- **The invisible (SynthID) path is lossy and not guaranteed.** It runs a low-strength SDXL img2img regeneration, so it softens fine detail and is content-dependent. There is no public SynthID decoder, so the tool cannot verify removal locally; confirm with the Gemini app's "Verify with SynthID" oracle and raise `--strength` if it still detects. A vendor can change the scheme at any time, so treat this as an arms race, not a permanent fix.
- **Large images: native by default, opt-in tiling for OOM.** The SynthID path runs at the diffusion model's native resolution; on a memory-constrained GPU/MPS you can either cap the long side with `--max-resolution` (lossy downscale) or pass `--tile` to regenerate in overlapping, feather-blended tiles at native resolution (lossless, no seam). Tiling is a memory workaround, not a quality upgrade over a single native pass: each tile is an independent low-strength regeneration. (Nano Banana 2 is natively 1024px; GPT Image 2 supports 4K experimentally.)
- **Out of scope:** defeating trained AI-vs-real classifiers like Hive (see [Threat model](#threat-model)), visible-logo removal from video, and any guarantee that a stripped copy is untraceable server-side.
+25 -1
View File
@@ -9,6 +9,30 @@ measurements, incident history, oracle runs, and the reasoning behind each
decision. Read the relevant section here before changing the diffusion
pipelines, strength defaults, or metadata coverage.
## Visible-mark fill quality is background- and backend-dependent
Visible-mark removal is localize -> fill (the reverse-alpha pixel recovery was
dropped; see `docs/module-internals.md`). The fill only touches the mark's
footprint, so there is never collateral damage outside it, and whether the mark
is *removed* is fill-independent -- cv2, MI-GAN and LaMa all strip the mark's
shape. What varies is the *quality of the recovered region*, and it depends on
the background:
- **Flat backgrounds:** every backend is clean; cv2 is often the crispest.
- **Textured / regular-structured backgrounds** (fabric, foliage, a lattice or
grid): an inpaint can only guess the hidden pixels. `cv2` (the classical
no-deps floor) visibly smears; `migan` (light, learned) can leave a ghost or
hallucinate structure; `lama` (heavy, learned) is the most reliable and
recovers structure best.
The old reverse-alpha recovered the *true* pixels under a well-captured, static
mark, so on structured backgrounds it was sometimes cleaner than any inpaint.
The trade for localize -> fill is robustness (it also handles moved / re-rendered
marks and needs no per-mark alpha capture) and a simpler, swappable pipeline.
`auto` resolves best-first (`LaMa > MI-GAN > cv2`) and warns once when it falls
back to cv2 because no learned backend is installed; a memory-tight deployment
that cannot afford LaMa's ~4.7 GB peak pins `--backend migan` explicitly.
## Invisible-pipeline resolution handling (native / 1024 floor / `--max-resolution`; MPS memory tiers)
`invisible` pipeline processes at **native resolution for inputs whose long side is >= 1024px**, and **auto-upscales smaller inputs UP to a 1024px floor** (`min_resolution=1024`, the default; `--min-resolution 0` disables) before diffusion -- SDXL img2img distorts badly on a tiny latent (a 381x512 portrait wrecks at native, the #36 follow-up), and the output is restored to the original input size so the floor is a transparent quality boost (it adds time/memory on small inputs). The floor upscale uses Lanczos by default; **`--upscaler esrgan`** (opt-in, the `esrgan` extra) runs Real-ESRGAN first for better detail before the Lanczos resize to the exact target (`upscaler.py` / `InvisibleEngine._esrgan_upscale`, falls back to Lanczos if the extra is absent). `max_resolution=0` (default) means no downscale cap, matching the hosted raiw.cc backend (fal fast-sdxl, no pre-downscale). The old forced downscale-to-1024 -> upscale-back round-trip for LARGE images was the main quality loss (issue #10) and is gone; at strength ~0.05 SDXL img2img does not need a downscale.
@@ -20,7 +44,7 @@ pipelines, strength defaults, or metadata coverage.
`--tile` (OFF by default; `--tile-size` default 1024, `--tile-overlap` default 128) processes the diffusion pass in overlapping sliding-window tiles instead of one forward pass, so a large image is regenerated at **native resolution** without the OOM and without the lossy `--max-resolution` downscale round-trip. It engages only when the long side exceeds `--tile-size`; a sub-tile image runs a single pass unchanged. `WatermarkRemover.remove_watermark` refactors the single-image `_generate` into a per-tile `_generate_one` (the ControlNet canny edge map is rebuilt per tile, so structure preservation works tile-local) and routes it through `noai.tiling.run_tiled` when tiling is active. The geometry and blend math are pure helpers, unit-tested without the model (`tests/test_tiling.py`):
- `plan_tiles(w, h, tile_size, overlap)` lays out a row-major grid where every tile is exactly `tile_size` (the last tile on each axis is pulled back flush to the far edge, simply overlapping its predecessor more). Uniform tile size keeps each diffusion pass at SDXL's preferred dimension.
- `feather_weights(w, h, overlap)` is a separable linear taper, ~1 in the interior and ramping toward each edge, kept **strictly positive** so the normalised accumulate-and-divide blend (`accum / weight_sum`) is a partition of unity: a region covered by one feathered edge (an image corner) still divides cleanly. Identical (unchanged) tiles therefore reconstruct the input exactly -- the seam-free guarantee, asserted in `test_identity_generate_reconstructs_image`.
- `feather_weights(w, h, overlap)` is a separable linear taper, ~1 in the interior and ramping toward each edge, kept **strictly positive** so the normalized accumulate-and-divide blend (`accum / weight_sum`) is a partition of unity: a region covered by one feathered edge (an image corner) still divides cleanly. Identical (unchanged) tiles therefore reconstruct the input exactly -- the seam-free guarantee, asserted in `test_identity_generate_reconstructs_image`.
CAVEAT: each tile is an **independent** low-strength regeneration. At the certified removal strengths (0.20-0.30) the per-tile drift is small and the feather blend hides the seams, but tiling is a memory workaround, not a quality upgrade over a single native pass -- a 32 GB MPS box that clears the native UNet peak should prefer no tiling. The MPS->CPU fallback still applies per tile; if the first tile falls back to CPU, the device stays CPU for the rest of the image.
+36 -64
View File
@@ -29,7 +29,7 @@ module.
**AI-generated vs AI-enhanced** (`ProvenanceReport.ai_source_kind`, roadmap item): the C2PA digital-source-type is split into `"generated"` (trainedAlgorithmicMedia, fully synthetic) vs `"enhanced"` (compositeWithTrainedAlgorithmicMedia, a real photo with an AI-composited region) — the two byte strings are unambiguous (`compositeWithTrainedAlgorithmicMedia` capitalizes the inner "Trained", so a lowercase `trainedAlgorithmicMedia` match is standalone full generation; full generation wins when both appear). `ai_source_kind` is set only when the AI verdict actually came from the C2PA source type (a non-C2PA AI signal — IPTC/AIGC/local gen/xAI — leaves it None). It lets a caller branch a full-frame scrub (`generated`) from a region-targeted clean that preserves the real photo (`enhanced`; see `noai/tiling.feather_region_composite`). The CLI verdict line reads "AI-generated (fully synthetic)" vs "AI-enhanced (real content with an AI-composited region)".
**Visible-mark detection** (`check_visible`, signals `visible_sparkle` / `visible_doubao` / `visible_jimeng` / `visible_samsung`): the Gemini sparkle keeps its own file-level path (`_visible_sparkle``gemini_engine.detect_sparkle_confidence`, promoted only at confidence ≥ `_SPARKLE_THRESHOLD`, which is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (0.5) — imported, not a private copy, so the provenance detect threshold and the removal `best_auto_mark` / `_gemini_detect` arbitration gate can never drift (the detect-vs-remove desync from roadmap P0#7; regression-guarded by `tests/test_identify.py::TestSparkleDetectRemoveAlignment`, which composites the real demo sparkle at borderline opacities and asserts identify and `best_auto_mark` AGREE on either side of the line). Lowering the gate to recover faint sub-0.5 sparkles was evaluated 2026-06-20 and REJECTED: a real Doubao text mark scores ~0.40-0.42 as a gemini match with a HIGHER core-ring brightness margin than a genuine faint sparkle, so neither confidence nor the brightness gate separates them in the [0.35, 0.5) band — lowering trades a rare miss for false-positive removals on clean images. Corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49), while Doubao/Jimeng/Samsung reuse the registry detectors (`_visible_text_marks``watermark_registry`, iterating `_VISIBLE_MARK_PLATFORM`), each gated by its own engine NCC threshold via `MarkDetection.detected` (Doubao 0.4, Jimeng 0.45, Samsung 0.4). Doubao/Jimeng are normally also caught by the TC260 AIGC metadata label and Samsung by its C2PA + `genAIType` marker, so the visible path is their stripped-metadata fallback. Visible marks set `platform` only when no harder signal already did, and (like the sparkle) are excluded from integrity-clash vendor claims. The cv2 dependency lives in the engines, not here.
**Visible-mark detection** (`check_visible`, signals `visible_sparkle` / `visible_doubao` / `visible_jimeng` / `visible_samsung`): the Gemini sparkle keeps its own file-level path (`_visible_sparkle``gemini_engine.detect_sparkle_confidence`, promoted only at confidence ≥ `_SPARKLE_THRESHOLD`, which is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (0.5) — imported, not a private copy, so the provenance detect threshold and the removal `detect_marks` / `_gemini_detect` arbitration gate can never drift (the detect-vs-remove desync from roadmap P0#7; regression-guarded by `tests/test_identify.py::TestSparkleDetectRemoveAlignment`, which composites the real demo sparkle at borderline opacities and asserts identify and `detect_marks` AGREE on either side of the line). Lowering the gate to recover faint sub-0.5 sparkles was evaluated 2026-06-20 and REJECTED: a real Doubao text mark scores ~0.40-0.42 as a gemini match with a HIGHER core-ring brightness margin than a genuine faint sparkle, so neither confidence nor the brightness gate separates them in the [0.35, 0.5) band — lowering trades a rare miss for false-positive removals on clean images. Corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49), while Doubao/Jimeng/Samsung reuse the registry detectors (`_visible_text_marks``watermark_registry`, iterating `_VISIBLE_MARK_PLATFORM`), each gated by its own engine NCC threshold via `MarkDetection.detected` (Doubao 0.4, Jimeng 0.45, Samsung 0.4). Doubao/Jimeng are normally also caught by the TC260 AIGC metadata label and Samsung by its C2PA + `genAIType` marker, so the visible path is their stripped-metadata fallback. Visible marks set `platform` only when no harder signal already did, and (like the sparkle) are excluded from integrity-clash vendor claims. The cv2 dependency lives in the engines, not here.
**`import identify` is deliberately light** (~26 MB; ~36 MB with cv2 loaded by a visible-mark run, ~106 MB for a full `check_visible` run): it imports the `noai.c2pa`/`noai.constants` submodules, and `noai/__init__` is lazy (see "Test and lint"), so torch/diffusers are NOT pulled at import even in a full `gpu`/`detect` install — fits a 512 MB host. `noai.c2pa` does eagerly import the **c2pa-python** binary (Rust + cryptography, ~+5 MB RSS, no torch) for the primary `Reader` path — light enough to stay on the dependency-light host; a broken/absent wheel degrades to the byte-scan parser (`reader_available()` False). The heavy paths are opt-in: `check_invisible=True` needs the `detect`/`trustmark` extras (each pulls **torch**; TrustMark also **downloads weights**), so on a core-only deploy leave `check_invisible` off (it is a no-op there anyway). Before the lazy `__init__`, the mere presence of torch in the env inflated `import identify` to ~420 MB.
@@ -61,13 +61,17 @@ module.
`watermark_registry.py`**single catalog of known visible watermarks**, the unified "find known marks in their usual places, recognize, remove" entry.
**Reverse-alpha based by policy**: a mark is listed only once a real alpha map has been captured for it, and removal inverts that map (`original = (wm - a*logo)/(1-a)`) — Gemini recovers cleanly with no inpaint (its sparkle alpha comes from a pure-black capture, so it is near-exact), while **Doubao, Jimeng, and Samsung all add an always-on THIN residual inpaint** over the glyph footprint (their text marks re-rasterize + jitter a few px per image, so a single capture cannot pixel-cancel them; the inpaint blends into the reverse-alpha-recovered pixels). Arbitrary-region inpainting still lives in `region_eraser`/`erase`. Each `KnownMark` ties a key to {usual `location`, `in_auto` flag, `recovery` (="reverse-alpha"), a `detect` adapter → uniform `MarkDetection`, a `remove` adapter}. Entries today: `gemini` (bottom-right sparkle), `doubao` (bottom-right "豆包AI生成"), `jimeng` (bottom-right "★ 即梦AI"), and `samsung` (bottom-**LEFT** "✦ Contenuti generati dall'AI", Samsung Galaxy AI, Italian locale). `detect_marks` scans all; `best_auto_mark` picks the highest-confidence detection.
**Localize -> fill by policy (replaced reverse-alpha):** each mark is localized to a binary full-frame footprint mask (a `Localization`), and one shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). This replaced the old reverse-alpha removal (invert a captured alpha map, `original = (wm - a*logo)/(1-a)`, plus a thin residual inpaint) for ALL marks — gemini, doubao, jimeng, samsung, and jimeng_pill. **Why it changed:** reverse-alpha depended on a fixed captured alpha map at a fixed position, so it broke whenever a vendor moved or re-rendered its mark; and it was not color-lossless even with the right map (it amplifies 8-bit quantization and JPEG-chroma error by `1/(1-a)`), which showed up as "the color just changed, not removed" reports. Localize -> fill has a benign failure mode: a slightly-off localization just inpaints a small region near-losslessly instead of leaving a color-shifted smear. The captured alpha maps are still used to DETECT the marks and to shape the mask (gemini's footprint), but NOT for pixel recovery. Fill backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. Each `KnownMark` ties a key to {usual `location`, `in_auto` flag, a `_detect` callable → uniform `MarkDetection`, a `_mask` callable → full-frame footprint mask}; `KnownMark.remove(image, *, backend="auto", provenance=False, force=False)`. Entries today: `gemini` (bottom-right sparkle), `doubao` (bottom-right "豆包AI生成"), `jimeng` (bottom-right "★ 即梦AI"), `samsung` (bottom-**LEFT** "✦ Contenuti generati dall'AI", Samsung Galaxy AI, Italian locale), and the capture-less `jimeng_pill` (top-left "AI生成"). `detect_marks(image, *, provenance=frozenset())` scans all (strict, for the identify verdict); `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` removes every detected mark in one pass. **Sensitivity (`auto`/`strict`/`assume_ai`)** decides how hard a borderline mark is trusted: the visual detectors are pixel-based (no metadata needed) and the recall gain comes from relaxing the false-positive gate, not from metadata. `resolve_relax` turns the policy + evidence into the per-mark relaxation boolean — `strict` never relaxes, `assume_ai` always relaxes (the caller asserts AI, e.g. a metadata-stripped screenshot; ~46% -> ~92% Gemini recall, at the cost of a small near-lossless fill on some clean corners), `auto` relaxes only on same-product evidence (metadata provenance for that vendor, or a confidently strict-detected sibling of the same `_PRODUCT_OF` — Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax). **Perception / decision / action are separated three ways** (the removal path only; `identify` keeps calling `KnownMark.detect` directly, so its verdict is untouched): `_build_candidates(image)` is PERCEPTION — it runs each detector at both trust levels and packages raw verdicts + the pill's flatness feature into `Candidate`s, no policy; `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` is the pure DECISION arbiter — all keep/drop policy (`resolve_relax` cross-mark corroboration + the pill gate) in one image-free, unit-testable function (`tests/test_watermark_registry.py::TestArbiter`); then `remove_auto_marks` does the ACTION, localizing -> filling each winner. The Gemini FP gate deliberately stays inside `gemini_engine` (not the arbiter) because `identify` reads that same gated confidence — pulling it out would drift the provenance verdict. Behavior is byte-identical to the pre-arbiter two-pass (corpus-verified: strict/auto 46%, assume_ai 92% Gemini recall unchanged).
**Cross-engine confidences aren't directly comparable**, so the gemini adapter applies the corpus-validated 0.5 sparkle threshold (`_GEMINI_AUTO_MIN_CONF`) for its `detected` flag — otherwise the gemini engine's loose internal threshold weakly fires (~0.36) on the Doubao text and hijacks `auto`. The shape-keyed Doubao/Jimeng/Samsung NCC detectors don't cross-fire (jimeng scores ~0.22 on the Doubao strip, well under its 0.45 threshold; Samsung is bottom-left so it shares no corner with the others, and scored 0.0 on Doubao/Jimeng captures and they 0.0 on a real Samsung photo), so `auto` picks the right one. `cli.cmd_visible` is registry-driven: `--mark auto``best_auto_mark`, `--mark <key>` → that mark; `--mark` choices come from `mark_keys()`.
**Head-to-head validation (v0.12.1 reverse-alpha vs the current localize -> fill):** run over the full labelled visible-mark set, with the cv2 / MI-GAN / LaMa fills each compared against the old reverse-alpha. **doubao and jimeng are identical** across every backend -- 100% coverage and 100% clearance either way. **gemini** strict coverage is a few points below reverse-alpha's (the deliberate false-positive tightening), but the metadata-stripped faint ones are now mostly recovered by the DEFAULT white-core rescue in the FP gate (`gemini_engine`: a bright near-WHITE core distinguishes a real faint sparkle from a colored bright corner -- ~14/20 recovered at ~1.25% clean false-fire; a learned classifier on the same features measured worse, 2026-07 tier-1), the residual under `assume_ai`; clearance is equal (~98% both), and neither version touches pixels outside the mark box (outside-box PSNR ~99). **Clearance is fill-independent** -- cv2, MI-GAN and LaMa all strip the mark's shape equally, so the re-detect metric does not separate them; the difference is purely the *visual fill quality* on the recovered region, and it is background-dependent. reverse-alpha recovered textured and especially regular/structured backgrounds (a lattice, a grid) more cleanly than any inpaint; **LaMa closes most of that gap** (the best learned backend), **MI-GAN can ghost or hallucinate structure**, and **cv2 smears** (the last-resort floor). This is why `auto` resolves `LaMa > MI-GAN > cv2` (`preferred_inpaint_backend`) and warns once on the cv2 fallback; on flat backgrounds every backend is clean.
**`cli._remove_visible_auto` is the shared visible-removal helper used by `cmd_all`/`cmd_batch` too** (they no longer hardcode `GeminiEngine`), so `all`/`batch` remove Doubao/Jimeng/Samsung text marks, not just the Gemini sparkle (regression-guarded by `test_all_visible_step_uses_registry`). The three text-mark adapters were consolidated 2026-06-09: a single `_text_mark(key, label, location)` builds the registry row from one parameterized `_text_mark_detect`/`_text_mark_remove` pair (reverse-alpha only when detected/forced AND `reverse_alpha_available`, else skipped — no inpaint); the gemini adapters stay bespoke. Add a new visible mark = one `_text_mark(...)` row + its `TextMarkConfig` (with a captured alpha map); do not re-add per-mark `if` branches or copy-paste adapters.
**Provenance prior:** when local metadata already confirms the vendor, the mark's detection trust gate is relaxed (a confirmed vendor means the mark is present with high prior, so a mark the conservative detector would demote as a content false positive is trusted). `detect_marks` / `remove_auto_marks` take a `provenance` frozenset and `KnownMark.remove` a `provenance` flag. Mapping: a Google/Gemini C2PA issuer relaxes gemini (skips its false-positive gate and lowers the trust threshold from 0.5 to 0.35); a China-AIGC (TC260) label relaxes doubao/jimeng; `samsung_genai` relaxes samsung. Corpus finding: on Google-C2PA images, Gemini sparkle recall rose from ~46% (plain detector) to ~90% with the provenance prior (recovering marks the vendor moved or re-rendered). The localizer is cheap CPU (cv2/numpy), so a memory-tight caller runs it anywhere; the heavy MI-GAN/LaMa fill is opt-in and chosen by the caller.
**Alpha-on-save policy (issue #30):** `cli._write_bgr_with_alpha` rejoins the input's alpha plane **unchanged** — it must NOT zero alpha in the watermark bbox. Reverse-alpha (and `erase` inpaint) recover real pixels there, so zeroing alpha punched a transparent hole that renders as a solid **white box** on any non-transparent viewer (Gemini app exports are opaque RGBA, so every user hit it; regression-guarded by `test_visible_keeps_alpha_opaque_in_watermark_region`). The registry `remove()` still returns its region (used for `inpaint_residual` positioning), but the CLI no longer uses it to clear alpha.
**Cross-engine confidences aren't directly comparable**, so the gemini adapter applies the corpus-validated 0.5 sparkle threshold (`_GEMINI_AUTO_MIN_CONF`) for its `detected` flag (lowered to 0.35 under the Google/Gemini provenance prior) — otherwise the gemini engine's loose internal threshold weakly fires (~0.36) on the Doubao text and hijacks `auto`. The shape-keyed Doubao/Jimeng/Samsung NCC detectors don't cross-fire (jimeng scores ~0.22 on the Doubao strip, well under its 0.45 threshold; Samsung is bottom-left so it shares no corner with the others, and scored 0.0 on Doubao/Jimeng captures and they 0.0 on a real Samsung photo), so `auto` picks the right one. `cli.cmd_visible` is registry-driven: `--mark auto``remove_auto_marks` (removes every detected mark), `--mark <key>` → that mark; `--mark` choices come from `mark_keys()`.
**`cli._remove_visible_auto` is the shared visible-removal helper used by `cmd_all`/`cmd_batch` too** (they no longer hardcode `GeminiEngine`), so `all`/`batch` remove Doubao/Jimeng/Samsung text marks, not just the Gemini sparkle (regression-guarded by `test_all_visible_step_uses_registry`). The three text-mark adapters were consolidated 2026-06-09: a single `_text_mark(key, label, location)` builds the registry row from one parameterized `_text_mark_detect`/`_text_mark_remove` pair (the remove adapter localizes the glyph footprint and hands it to the shared `fill` only when detected/forced, else skipped); the gemini adapters stay bespoke. Add a new visible mark = one `_text_mark(...)` row + its `TextMarkConfig` (with a captured alpha map for the detection silhouette); do not re-add per-mark `if` branches or copy-paste adapters.
**Alpha-on-save policy (issue #30):** `cli._write_bgr_with_alpha` rejoins the input's alpha plane **unchanged** — it must NOT zero alpha in the watermark bbox. The fill reconstructs real pixels there, so zeroing alpha punched a transparent hole that renders as a solid **white box** on any non-transparent viewer (Gemini app exports are opaque RGBA, so every user hit it; regression-guarded by `test_visible_keeps_alpha_opaque_in_watermark_region`). The registry `remove()` still returns its region, but the CLI no longer uses it to clear alpha.
## `gemini_engine.py`
@@ -83,93 +87,61 @@ module.
The cost (mislabel ~8-33% of non-Gemini content as Gemini) outweighs the benefit -- the visible sparkle is a medium-confidence stripped-metadata fallback, and intact Gemini is caught by C2PA in `identify` regardless. Remaining square misses are an accepted known limitation; a real fix would need a sparkle-specific discriminator (template match on a background-subtracted image, or a hard fixed-margin position prior), which is open research, not a threshold tweak.
**Removal is reverse-alpha with an over-subtraction guard** (`remove_watermark``_reverse_alpha_blend`, else `_inpaint_footprint`): the sparkle alpha is computed (`alpha = max(R,G,B)/255`) from the bundled sparkle-on-black captures `assets/gemini_bg_{96,48}.png` (the capture max is ~130, NOT 255 — the sparkle is a ~51%-opaque white overlay, so `alpha` maxes at ~0.51, which is CORRECT for the capture, not under-exposed). The alpha is near-exact only when the real mark's effective opacity matches the capture, which holds on bright/flat backgrounds — re-verified clean on `demo_banana_before.png` 2026-05-31.
**Removal is localize -> fill** (`footprint_mask``watermark_registry.fill`): `footprint_mask` returns the sparkle footprint = the captured alpha (computed `alpha = max(R,G,B)/255` from the bundled sparkle-on-black captures `assets/gemini_bg_{96,48}.png`, capture max ~130 for the ~51%-opaque overlay) thresholded LOW so the faint halo is included, then dilated by a sparkle-relative margin. That binary mask is inpainted by the shared fill (cv2 / MI-GAN / big-LaMa). The captured alpha maps are used only to detect and to shape the mask, not for pixel recovery. This replaced the old reverse-alpha removal path; because the fill only reconstructs the masked footprint from its surroundings (rather than dividing by `1-a`), the whole reverse-alpha removal tail — the over-subtraction guard (`_reverse_alpha_oversubtracts`, the dark-background black-pit fix), the under-subtraction alpha-gain estimate (`_estimate_alpha_gain`), and the self-verify repair — is GONE, along with the near-white `1/(1-a)` ill-conditioning and the "color changed, not removed" failure mode those guards patched around. A slightly-off localization now just fills a small region near-losslessly instead of leaving a color-shifted smear.
**Issue #30 (dark-background black pit):** on a dark/textured background (e.g. grass, ~73) the real sparkle's effective opacity is LOWER than the captured 0.51, so the fixed-alpha reverse blend OVER-subtracts (`watermarked - a*logo` goes negative) and drives the footprint to black — the white sparkle becomes a black diamond. `remove_watermark` now detects this via `_reverse_alpha_oversubtracts` (fraction of footprint pixels with `alpha >= _FOOTPRINT_ALPHA` 0.1 whose numerator < 0 exceeds `_OVERSUB_FOOTPRINT_FRAC` 0.05) and **inpaints the footprint** (`_inpaint_footprint`, cv2 NS over the dilated alpha mask) from the surrounding pixels instead.
**False-positive gate (added 2026-06-03):** `detect_watermark`'s shape-only NCC (`spatial*0.5 + gradient*0.3 + var*0.2`) fires on ornate/flat content (text strips, banners, hatching) that coincidentally matches the diamond shape — a real Gemini sparkle is a bright WHITE overlay, so its core sits above the local background, but the NCC is contrast-invariant and cannot see that. The fusion now **demotes** (caps confidence to 0.30) any low-confidence (`< _SPARKLE_FP_CONF` 0.65) match that shows NEITHER real-sparkle signature: a bright core (`_core_ring_margin >= _SPARKLE_FP_MARGIN` 5) OR a crisp star silhouette (`gradient_score >= _SPARKLE_FP_GRAD` 0.55). I.e. demote when `low_margin OR low_grad`. Real sparkles escape via high confidence (white-bg sparkles score ≥0.79 despite a low margin — the NCC shape match is strong), high margin (dark/mid backgrounds, incl. the #36 faint-corner case, lift well clear), OR high gradient (a real sparkle is grad ~0.971.0). **The gradient condition (added 2026-06-26) closes the bright-background FP class** the margin check alone missed: a snow+sky photo and a white-background product render both scored ~0.51 at `identify`, because a bright background gives the match a HIGH core-ring margin (it genuinely IS brighter than its surroundings), so the brightness gate read it as a real overlay — but a smooth luminance blob that shape-NCC-matches the rough diamond has low gradient fidelity (the two FPs measured grad 0.105 and 0.463 vs ≥0.8 for real sparkles), so the gradient floor demotes them. The OR is **strictly a superset** of the old margin-only demotion (it only ADDS demotions on bright backgrounds, where a real sparkle keeps grad ~0.97), so it cannot regress a dark/mid sparkle (kept by margin) or a white-bg one (kept by confidence ≥ 0.65). The gate is **monotonic** (only ever removes detections, never adds), so it cannot regress the verified-negative corpus (already 0 FPs); the 2026-06-26 corpus re-sweep flipped only OpenAI/ChatGPT content (no Gemini sparkle exists there) and already-`cleaned/` outputs, all sub-0.5 (below the `identify` threshold), so no provenance verdict changed. The original gate demoted 16/495 flagged sparkles on the validation corpus (13 carried no AI metadata = content FPs; the 3 AI-meta were visually FPs / a near-invisible white-on-white sparkle whose AI verdict is held by metadata anyway). `_core_ring_margin` uses the `_core_and_bg` helper (core 75th-pct brightness vs background-ring median). This gate is detection-side and unchanged by the localize -> fill refactor; the provenance prior skips it when a Google/Gemini C2PA issuer confirms the vendor. Regression-guarded by `test_gemini_engine.py::TestSparkleFalsePositiveGate` (incl. `test_bright_background_low_gradient_match_demoted`).
**Behavior-neutral on the working case:** a bright background over-subtracts at ~0% so reverse-alpha is used and the output is byte-identical to before (verified: demo_banana 0.0 frac vs issue-#30 grass 0.61 frac; regression-guarded by `test_gemini_engine.py::TestOverSubtractionGuard`, which composites the sparkle at a reduced effective alpha to reproduce the mismatch).
**The reverse-alpha removal tail is retired.** The self-verify repair (`_verify_and_repair`), the offset+scale alignment search, and the near-white `1/(1-a)` ill-conditioning survivors were all artifacts of solving the sparkle by inverting the alpha map. Under localize -> fill the footprint is reconstructed from its surroundings by the shared fill, so those failure classes and their guards no longer exist. The lesson from that era still holds and generalizes: a re-detect-confidence audit metric is gameable by reshaping the residual, so judge a visible removal by physical inspection of the footprint, not the detector alone.
**Under-subtraction (the symmetric case, fixed 2026-06-03):** some real Gemini sparkles are rendered MORE opaque than the captured ~0.51, so the fixed-alpha reverse blend UNDER-subtracts and leaves a bright sparkle residual the detector still fires on (measured on the spaces corpus: a visible-removal audit through the registry path left a detectable sparkle on a meaningful fraction of marks, all under-removals, NOT a background-brightness class — failures and successes had the same input confidence and the same background-luma distribution; the discriminator was the removal delta itself). `remove_watermark` now estimates a per-image alpha gain (`_estimate_alpha_gain`: effective sparkle opacity at the bright core vs the local background ring, `a_eff/a_cap`, clamped `[1.0, _ALPHA_GAIN_MAX` 1.94`]`) and scales the alpha to match before the over-sub/blend branch. The gain cleanly separates on the corpus (under-removed marks ~1.47, cleanly-removed ~1.00), and a deadband (`_ALPHA_GAIN_DEADBAND` 1.05) keeps a matching sparkle **byte-identical** to the pre-fix output, so the fix is purely additive (0 regressions on the audit set; the over-sub guard still runs on the scaled alpha as the safety net for an over-shooting estimate). Regression-guarded by `test_gemini_engine.py::TestUnderSubtractionGain` (composites a more-opaque-than-capture sparkle; **asserts on footprint pixels, NOT the detector** — the detector's NCC is degenerate on a flat synthetic background, so a re-detect conf is meaningless there; the real corpus removal drops the detector from ~0.80 to ~0.27).
**False-positive gate (added 2026-06-03):** `detect_watermark`'s shape-only NCC (`spatial*0.5 + gradient*0.3 + var*0.2`) fires on ornate/flat content (text strips, banners, hatching) that coincidentally matches the diamond shape — a real Gemini sparkle is a bright WHITE overlay, so its core sits above the local background, but the NCC is contrast-invariant and cannot see that. The fusion now **demotes** (caps confidence to 0.30) any low-confidence (`< _SPARKLE_FP_CONF` 0.65) match that shows NEITHER real-sparkle signature: a bright core (`_core_ring_margin >= _SPARKLE_FP_MARGIN` 5) OR a crisp star silhouette (`gradient_score >= _SPARKLE_FP_GRAD` 0.55). I.e. demote when `low_margin OR low_grad`. Real sparkles escape via high confidence (white-bg sparkles score ≥0.79 despite a low margin — the NCC shape match is strong), high margin (dark/mid backgrounds, incl. the #36 faint-corner case, lift well clear), OR high gradient (a real sparkle is grad ~0.971.0). **The gradient condition (added 2026-06-26) closes the bright-background FP class** the margin check alone missed: a snow+sky photo and a white-background product render both scored ~0.51 at `identify`, because a bright background gives the match a HIGH core-ring margin (it genuinely IS brighter than its surroundings), so the brightness gate read it as a real overlay — but a smooth luminance blob that shape-NCC-matches the rough diamond has low gradient fidelity (the two FPs measured grad 0.105 and 0.463 vs ≥0.8 for real sparkles), so the gradient floor demotes them. The OR is **strictly a superset** of the old margin-only demotion (it only ADDS demotions on bright backgrounds, where a real sparkle keeps grad ~0.97), so it cannot regress a dark/mid sparkle (kept by margin) or a white-bg one (kept by confidence ≥ 0.65). The gate is **monotonic** (only ever removes detections, never adds), so it cannot regress the verified-negative corpus (already 0 FPs); the 2026-06-26 corpus re-sweep flipped only OpenAI/ChatGPT content (no Gemini sparkle exists there) and already-`cleaned/` outputs, all sub-0.5 (below the `identify` threshold), so no provenance verdict changed. On the spaces corpus the original gate demoted 16/495 flagged sparkles (13 carried no AI metadata = content FPs; the 3 AI-meta were visually FPs / a near-invisible white-on-white sparkle whose AI verdict is held by metadata anyway), and dropped the removal-audit failures 20→15 (post-removal flat footprints the NCC re-fired on). `_core_ring_margin` and `_estimate_alpha_gain` share the `_core_and_bg` helper (core 75th-pct brightness vs background-ring median). Regression-guarded by `test_gemini_engine.py::TestSparkleFalsePositiveGate` (incl. `test_bright_background_low_gradient_match_demoted`).
**Self-verify repair (added 2026-06-04):** the gain estimate corrects most under-subtractions, but a tail of strong sparkles still survived reverse-alpha (position jitter, or a gain the `[1.0, 1.94]` clamp could not fully reach). After the reverse blend, `remove_watermark` re-detects via `_verify_and_repair`; when a sparkle at or above `_VERIFY_FALLBACK_CONF` 0.5 (the registry's real fail line) remains, it inpaints the footprint and **keeps that only when it lowers the re-detect confidence** — purely additive (the common clean removal re-detects below 0.5 and is returned untouched, so it can never regress). On the spaces corpus this rescued **4 of the 15 remaining gemini removal-audit failures** (15→11, doubao/jimeng still 0), verified through the registry/CLI path. Costs one extra `detect_watermark` per removal (two when the fallback fires). Regression-guarded by `test_gemini_engine.py::TestVerifyAndRepair` (stubs `detect_watermark` to drive the keep-best control flow, since the NCC is degenerate on flat synthetics).
**An offset+scale alignment search was prototyped on the remaining 11 fails and REJECTED (2026-06-04):** an audit "ceiling" test suggested it could rescue 4 more (e.g. a5a9 0.577→0.417), but direct inspection showed those were NCC-gaming, not removal — the lower-scoring placement left the sparkle as bright or BRIGHTER (a5a9: first-pass slot 99.5th-pct ~76 at background level, the "aligned win" slot ~164), it just reshaped the residual so the contrast-invariant shape-NCC scored lower. A slot-brightness sanity gate rejected every one, so alignment contributed 0 genuine rescues and was removed (the footprint inpaint stays because it physically reconstructs the slot from its darker surroundings, so its rescues are real).
**Lesson: the visible-audit pass/fail metric (re-detect conf < 0.5) is gameable by reshaping the residual — optimizing it directly finds NCC-gaming placements, not clean removals; gate any removal candidate on a physical brightness check, not the detector alone.**
The 11 survivors are near-white ill-conditioning (reverse-alpha divides by `1-a`≈0.02) or detector false positives (before≈after≈0.51) that no reverse-alpha placement fixes. The registry's optional `inpaint_residual` (edge cleanup) is a no-op on a clean reverse-alpha removal (and on the same corpus it lowered the re-detect conf on 3 marks, raised it on 10, no-op on 466 — net-neutral on pass/fail, so the self-verify repair, not it, drives the removal tail); an earlier "Gemini smears" read was a misjudged soft-fur original, not an artifact.
**The bg assets are now rebuilt from OUR OWN controlled captures** (`data/gemini_capture/captures/`, committed) by `scripts/visible_alpha_solve.py gemini`, which locates the 96px sparkle on the black capture and crops it to the two logo sizes; our capture matched the previously third-party-sourced `gemini_bg_96.png` to **NCC 0.9998**, validating the asset and making it reproducible. Gemini's multi-size fixed-slot model is genuinely different from the Doubao/Jimeng text-strip engines (so it stays a separate engine, not part of the shared-base refactor).
**The bg assets are rebuilt from OUR OWN controlled captures** (`data/gemini_capture/captures/`, committed) by `scripts/visible_alpha_solve.py gemini`, which locates the 96px sparkle on the black capture and crops it to the two logo sizes; our capture matched the previously third-party-sourced `gemini_bg_96.png` to **NCC 0.9998**, validating the asset and making it reproducible. Gemini's multi-size fixed-slot model is genuinely different from the Doubao/Jimeng text-strip engines (so it stays a separate engine, not part of the shared-base refactor).
## `_text_mark_engine.py`
`_text_mark_engine.py`**shared base for the three reverse-alpha text-mark engines (Doubao/Jimeng/Samsung), extracted 2026-06-09** (they were ~90% byte-identical clones). `TextMarkEngine(config: TextMarkConfig)` owns the whole `locate → extract_mask → detect → _fixed/_aligned_alpha_map → _apply_reverse_alpha → remove_watermark_reverse_alpha` pipeline (+ the asset-keyed `load_alpha_template`/`glyph_silhouette`/`template_match_score` caches). Each engine module is now a thin subclass: it supplies only its `TextMarkConfig` (the tuned constants, the bundled asset, and the bounded structural deltas — `corner` br/bl, `margin_floor` 4/2, `morph_open_size` 5/3, `min_gw` 8/16) plus the test-facing module shims (`_alpha_template`/`_glyph_silhouette`/`_template_match_score` + the constants). Behavior is byte-exact vs the old per-engine code (the three engine test suites pass unchanged). Gemini stays a SEPARATE engine (its multi-size fixed-slot sparkle model is genuinely different). Add a new text mark = a new `TextMarkConfig` + a thin subclass + one registry `_text_mark(...)` row. The engine bullets below describe each mark's calibration history; the LOGIC lives here. **Small-image detection guard (`_MIN_DETECT_SHORT_SIDE` 200, added 2026-06-26):** `detect` returns not-detected when the image short side is below 200px. Below that the glyph template degrades to the `min_gw` floor (~8px) and `TM_CCOEFF_NORMED` on a few pixels is noise, so an unrelated small geometric shape can spuriously correlate with the CJK silhouette — a 48×48 app-icon chevron scored Doubao 0.41 / Jimeng 0.47 (both above their thresholds), a pure small-size artifact (the same icon upscaled collapses to ~0.060.10 NCC at ≥256px). A real AI-generation label is stamped on a full-resolution render (the captured samples are 10862048px wide, the smallest positive test image is 1086px), so the floor sits far below any genuine mark while killing the icon/thumbnail band (≤96px); `identify` falls back to "unknown" (the safe default) and removal, gated on detection, is suppressed too. Regression-guarded by `test_{doubao,jimeng,samsung}_engine.py::TestDetect::test_small_image_guarded_from_false_positive`.
`_text_mark_engine.py`**shared base for the three text-mark engines (Doubao/Jimeng/Samsung), extracted 2026-06-09** (they were ~90% byte-identical clones). `TextMarkEngine(config: TextMarkConfig)` owns the `locate → extract_mask → detect` detection pipeline plus the removal that localizes the glyph blob to a footprint mask and hands it to the shared `watermark_registry.fill` (+ the asset-keyed `load_alpha_template`/`glyph_silhouette`/`template_match_score` caches). Detection still matches the glyph silhouette (NCC against the captured template); the removal MASK is TEMPLATE-FREE — it is the bounding box of the top-hat glyph blob from `extract_mask`, filled solid + dilated, so a re-rendered or differently-placed mark is still masked. This dropped the fixed alpha-template placement; the captured alpha maps are now used only for the detection silhouette, not for removal. Each engine module is a thin subclass supplying only its `TextMarkConfig` (the tuned constants, the bundled asset, and the bounded structural deltas — `corner` br/bl, `margin_floor` 4/2, `morph_open_size` 5/3, `min_gw` 8/16) plus the test-facing module shims (`_alpha_template`/`_glyph_silhouette`/`_template_match_score` + the constants). Gemini stays a SEPARATE engine (its multi-size fixed-slot sparkle model is genuinely different). Add a new text mark = a new `TextMarkConfig` + a thin subclass + one registry `_text_mark(...)` row. The engine bullets below describe each mark's calibration history; the LOGIC lives here. **Small-image detection guard (`_MIN_DETECT_SHORT_SIDE` 200, added 2026-06-26):** `detect` returns not-detected when the image short side is below 200px. Below that the glyph template degrades to the `min_gw` floor (~8px) and `TM_CCOEFF_NORMED` on a few pixels is noise, so an unrelated small geometric shape can spuriously correlate with the CJK silhouette — a 48×48 app-icon chevron scored Doubao 0.41 / Jimeng 0.47 (both above their thresholds), a pure small-size artifact (the same icon upscaled collapses to ~0.060.10 NCC at ≥256px). A real AI-generation label is stamped on a full-resolution render (the captured samples are 10862048px wide, the smallest positive test image is 1086px), so the floor sits far below any genuine mark while killing the icon/thumbnail band (≤96px); `identify` falls back to "unknown" (the safe default) and removal, gated on detection, is suppressed too. Regression-guarded by `test_{doubao,jimeng,samsung}_engine.py::TestDetect::test_small_image_guarded_from_false_positive`.
**`_apply_reverse_alpha` runs on the glyph crop only:** the blend is a no-op outside the glyph `region` (x, y, w, h) (`(wm - 0)/(1 - 0) == wm`, and a uint8→float32→uint8 round-trip is exact). It copies the frame through and computes the reverse-alpha math on the `region` crop only — byte-identical to the old full-frame pass (verified: Doubao 130 + Jimeng 22 placements, 0 mismatches) but O(glyph) not O(image). The full-frame pass cost ~275 ms on a 12 MP frame for a glyph that is <0.1% of it, once per candidate placement (fixed + aligned ≈ 2×/removal); the crop drops that to ~2 ms. Mirror of the Gemini `_core_and_bg` crop.
**Removal is localize -> fill.** The engine localizes the glyph blob (`extract_mask` over the located box) into a solid, dilated footprint mask and hands it to the shared `watermark_registry.fill` (cv2 / MI-GAN / big-LaMa). The template-free mask (bounding box of the glyph blob, not the fixed alpha template) means a re-rendered or moved mark is still covered, and the fill reconstructs the box from its surroundings. On corpus images doubao and jimeng localize + remove at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit); clean images with no vendor signature had 0% false removal.
**Over-subtraction guard (`_reverse_alpha_oversubtracts` → `_inpaint_footprint`, ported from `gemini_engine` 2026-06-20, roadmap P0#8):** on a dark or mid-tone background the captured alpha can over-estimate THIS image's mark opacity, and reverse-alpha leaves a darker-than-background glyph ghost (a "dark pit") instead of recovering the true pixels — the sparkle-only fix (commit 41f6797) left the text marks unhandled. After `remove_watermark_reverse_alpha` selects the winning placement, the guard PREDICTS the reverse-alpha output PER PIXEL over the glyph body from the INPUT (`(obs - a*logo)/(1-a)`, exactly the remover's math) and, when the predicted body lands more than `_OVERSUB_DARK_MARGIN` (25) gray levels below the local background ring, abandons the reverse-alpha output for the footprint and inpaints it from the ORIGINAL surroundings (`_inpaint_footprint`, a wider dilate/radius than the thin residual pass). Predicting per-pixel from the INPUT (not the produced output, which depends on which placement the remover picked) is what keeps a cleanly captured full-strength mark byte-identical — it predicts back to the background everywhere, so the guard never trips on it (verified across Doubao/Jimeng/Samsung on white/mid/dark/midgray backgrounds). A faint mark predicts a body far below the ring and diverts to the inpaint. Regression-guarded by `tests/test_text_mark_oversubtraction.py` (predicate True on faint / False on clean, end-to-end no-dark-pit acceptance, clean-mark byte-identity, textured-background recovery). A flat synthetic background cannot exhibit the residual-inpaint failure (inpaint-from-flat is perfect regardless), so the value shows on textured/real content where the footprint inpaint samples un-darkened original pixels instead of the darkened reverse-alpha halo.
**`_fixed/_aligned_alpha_map` and `extract_mask` return footprint-sized arrays, not full frames (memory):** the alpha-map helpers return the glyph-sized alpha **block** (`(gh, gw)` float32) plus its placement `(ax, ay, gw, gh)`, and `extract_mask` returns the box-sized glyph mask (`(loc.h, loc.w)` uint8) — both used to allocate a full `(h, w)` array that is read only inside the small glyph/box. A full-frame float32 alpha map is ~48 MB on a 12 MP frame and two were held at once during removal (fixed + aligned ≈ 96 MB of mostly-zeros); the box mask was a ~12 MB uint8 allocation rebuilt per text-mark `detect` on the memory-tight `identify` path. `_apply_reverse_alpha` consumes the block directly; the residual inpaint embeds it into one full-frame uint8 mask only at `cv2.inpaint` time (which needs a full-frame mask). Byte-identical to the old full-frame path — the block equals the old map's `[ay:ay+gh, ax:ax+gw]` slice and the box equals the old mask cropped to `loc.bbox` (regression-guarded by `tests/test_text_mark_memory.py`, which reconstructs the old full-frame path inline and asserts equality, so the proof survives a cv2/asset bump). `remove_watermark_reverse_alpha` tracks the winning `region` alongside `best_amap` to place that mask.
**The reverse-alpha removal machinery is retired.** The old per-glyph reverse-alpha blend (`_apply_reverse_alpha`), the fixed/aligned alpha-map helpers, the over-subtraction guard (`_reverse_alpha_oversubtracts``_inpaint_footprint`, the dark-pit fix on dark/mid-tone backgrounds), and the always-align placement search are all gone — the fill reconstructs the footprint from its surroundings rather than inverting the captured alpha, so the dark-pit and color-shift failure modes those guards patched around no longer arise. `extract_mask` still returns a box-sized (`(loc.h, loc.w)`) mask rather than a full frame, which keeps the memory-tight `identify` detect path cheap.
## `doubao_engine.py`
`doubao_engine.py`**a thin `_text_mark_engine.TextMarkEngine` subclass (config only) since 2026-06-09.** visible Doubao "豆包AI生成" remover/detector (cv2/numpy, no GPU). `DoubaoEngine.locate` anchors a bottom-right box by **geometry** (mark scales with image WIDTH), `extract_mask` pulls the light, low-chroma glyphs (the detection candidate) using a per-pixel channel-spread proxy `sat = roi.max(axis=2) - roi.min(axis=2)` (no HSV conversion). `detect` is **shape-consistent**: it matches the bundled alpha glyph silhouette (`assets/doubao_alpha.png`) against the candidate via zero-mean normalized correlation (`_template_match_score`, cv2 `TM_CCOEFF_NORMED`), gated at `DETECT_NCC_THRESHOLD` 0.4 over a small `DETECT_MIN_COVERAGE` floor. Keying on glyph SHAPE (not coverage heuristics) fixed #23 (corpus FP 7/1243).
`doubao_engine.py`**a thin `_text_mark_engine.TextMarkEngine` subclass (config only) since 2026-06-09.** visible Doubao "豆包AI生成" detector + localizer (cv2/numpy, no GPU). `DoubaoEngine.locate` anchors a bottom-right box by **geometry** (mark scales with image WIDTH), `extract_mask` pulls the light, low-chroma glyphs (the detection candidate) using a per-pixel channel-spread proxy `sat = roi.max(axis=2) - roi.min(axis=2)` (no HSV conversion). `detect` is **shape-consistent**: it matches the bundled glyph silhouette (`assets/doubao_alpha.png`) against the candidate via zero-mean normalized correlation (`_template_match_score`, cv2 `TM_CCOEFF_NORMED`), gated at `DETECT_NCC_THRESHOLD` 0.4 over a small `DETECT_MIN_COVERAGE` floor. Keying on glyph SHAPE (not coverage heuristics) fixed #23 (corpus FP 7/1243).
**Removal = reverse-alpha + thin residual inpaint** (`remove_watermark_reverse_alpha`): `original = (wm - a*logo)/(1-a)` from the bundled alpha map + `_ALPHA_LOGO_BGR` (pure white) + `_ALPHA_*_FRAC` geometry, then a deliberately THIN inpaint (`_RESIDUAL_*`, `INPAINT_NS`) over the glyph footprint clears leftover edges without smearing.
**Removal is localize -> fill:** the glyph blob is localized to a solid, dilated footprint mask (`extract_mask` over the located box) and the shared `watermark_registry.fill` inpaints it. On corpus images this removes at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit).
**Alpha is rebuilt by `scripts/visible_alpha_solve.py` (the careful gray-self solve: cubic background fit, mean over channels, full halo, unblurred), same recipe as Jimeng** — the captures are committed in `data/doubao_capture/captures/`.
**The detection template (`assets/doubao_alpha.png`) is rebuilt by `scripts/visible_alpha_solve.py`** (the careful gray-self solve: cubic background fit, mean over channels, full halo, unblurred), same recipe as Jimeng — the captures are committed in `data/doubao_capture/captures/`. It is used only as the detection silhouette, not for pixel recovery.
**Removal aligns ALWAYS** (no `_ALPHA_NATIVE_BAND` fast-path): it tries fixed geometry AND `_aligned_alpha_map`'s `TM_CCOEFF_NORMED` scale+position search and keeps the lower-residual one — the mark is re-rasterized and a few px off per image, so fixed geometry alone leaves a visible outline even at 2048.
**The locate box (`WM_*`) is generous (0.22 wide, margins 0.004) and reaches close to the corner** — a tight box (the old 0.185 / margin 0.012) let a corner-ward shift fall OUTSIDE the alignment search, so the align missed and a readable outline survived; regression-guarded by `test_recovers_shifted_mark_on_texture` (composes the alpha shifted on a known texture; old box ~29 vs new ~1 mean residual).
**Issue #13 follow-up defect (found 2026-05-31): the SHIPPED Doubao removal left a clearly READABLE "豆包AI生成" outline on the real `doubao-1.png` sample, while `detect` returned conf 0.0 (it is fooled by a thin outline) so `test_reverse_alpha_removes_mark` passed and the old "56/56 clean" claim was detector-measured, not visual.**
Root cause: bad alpha (under-estimated, max ~0.65) + fixed-no-inpaint + tight box; the careful rebuild + always-align + thin inpaint + wide box takes it from a readable outline to faint texture-level traces (parity with Jimeng — a single capture cannot pixel-cancel a per-image re-rasterized mark).
**Lesson: a detector-only removal test is insufficient; assert visual residual (the textured-shift test).** **`extract_mask` guards a degenerate ROI (`bh < 16 or bw < 16` -> empty mask, skips cv2):** the always-align removal scores each placement with a residual `detect(out)`, and on an extremely wide/short image (e.g. 2048x1, `test_wide_short_does_not_raise`) that fed cv2's GaussianBlur a ~1-px-tall ROI and **faulted natively on Windows py3.12 (access violation, non-deterministic — one CI cell went red while a re-run passed)**; the old at-native path never ran `detect` on degenerate sizes. Real images always clear the guard (the `WM_*` box floors are `max(16, …)` height / `max(40, …)` width), so it only short-circuits slivers. `reverse_alpha_available` is just "asset present"; the registry gates removal on `detect`. The shipped third-party `_refs/zhengsuanfa_doubao_alpha_120x20.png` is NOT a usable alpha (verified 2026-05-29). Arbitrary-region inpainting is `region_eraser`/`erase`.
**The locate box (`WM_*`) is generous (0.22 wide, margins 0.004) and reaches close to the corner** so a re-rasterized, corner-ward-shifted mark still falls inside the localized box; regression-guarded by `test_recovers_shifted_mark_on_texture` (composes the mark shifted on a known texture). **`extract_mask` guards a degenerate ROI (`bh < 16 or bw < 16` -> empty mask, skips cv2)** — an extremely wide/short image (e.g. 2048x1, `test_wide_short_does_not_raise`) once fed cv2's GaussianBlur a ~1-px-tall ROI and **faulted natively on Windows py3.12**; real images always clear the guard (the `WM_*` box floors are `max(16, …)` height / `max(40, …)` width), so it only short-circuits slivers. The registry gates removal on `detect`. The shipped third-party `_refs/zhengsuanfa_doubao_alpha_120x20.png` is NOT a usable template (verified 2026-05-29). Arbitrary-region inpainting is `region_eraser`/`erase`. **Lesson from the reverse-alpha era (still holds): a detector-only removal test is insufficient; assert visual residual (the textured-shift test).**
## `jimeng_engine.py`
`jimeng_engine.py`**a thin `TextMarkEngine` subclass (config only) since 2026-06-09.** visible Jimeng / Dreamina "★ 即梦AI" remover/detector (cv2/numpy, no GPU), built 2026-05-30 from issue #13's solid captures (@powersee). Shares the base with `doubao_engine`: `locate` anchors a bottom-right box by **geometry** (scales with WIDTH), `extract_mask` pulls the light low-chroma glyphs (white top-hat + grayish + min-luma), `detect` matches the bundled "即梦AI" glyph silhouette (`assets/jimeng_alpha.png`) via `TM_CCOEFF_NORMED` over a coverage floor. Threshold `DETECT_NCC_THRESHOLD` **0.45** cleanly separates real Jimeng marks (>=0.81) from the Doubao strip (0.21) and other AI output (0.0), so the two ByteDance marks don't cross-fire in `--mark auto`.
`jimeng_engine.py`**a thin `TextMarkEngine` subclass (config only) since 2026-06-09.** visible Jimeng / Dreamina "★ 即梦AI" detector + localizer (cv2/numpy, no GPU), built 2026-05-30 from issue #13's solid captures (@powersee). Shares the base with `doubao_engine`: `locate` anchors a bottom-right box by **geometry** (scales with WIDTH), `extract_mask` pulls the light low-chroma glyphs (white top-hat + grayish + min-luma), `detect` matches the bundled "即梦AI" glyph silhouette (`assets/jimeng_alpha.png`) via `TM_CCOEFF_NORMED` over a coverage floor. Threshold `DETECT_NCC_THRESHOLD` **0.45** cleanly separates real Jimeng marks (>=0.81) from the Doubao strip (0.21) and other AI output (0.0), so the two ByteDance marks don't cross-fire in `--mark auto`.
**Logo is pure white (255,255,255)** (`_ALPHA_LOGO_BGR`; the white capture + an L-pair-solve confirm ~254.6); compositing is **sRGB, not linear** (a linear-light solve tripled the cross-residual).
**The detection template (`assets/jimeng_alpha.png`) is rebuilt by `scripts/visible_alpha_solve.py` from the GRAY capture** (`data/jimeng_capture/captures/`, the solid captures committed): `a = (I - B)/(255 - B)`, B a per-capture **cubic** background fit over the non-glyph pixels, **averaged over channels, full halo extent (down to a~0.02), unblurred**. Gray (bg ~132) is the deliberate choice over black: it is the best proxy for real content (the mark sits on bright photo areas, not on black). The captured template is used only as the detection silhouette, not for pixel recovery. Solver geometry at `_ALPHA_NATIVE_WIDTH` 2048: `_ALPHA_WIDTH_FRAC` 0.202, `_ALPHA_HEIGHT_FRAC` 0.058, margins ~0.029.
**Alpha rebuilt by `scripts/visible_alpha_solve.py` from the GRAY capture** (`data/jimeng_capture/captures/`, the solid captures now committed): `a = (I - B)/(255 - B)`, B a per-capture **cubic** background fit over the non-glyph pixels, **averaged over channels, full halo extent (down to a~0.02), unblurred**. Gray (bg ~132) is the deliberate choice over black: it is the best proxy for real content (the mark sits on bright photo areas, not on black), and the careful build drops the gray self-residual to ~1.3.
**Removal is localize -> fill:** the glyph blob is localized to a solid, dilated footprint mask and the shared `watermark_registry.fill` inpaints it; the `WM_*` locate box is generous so a re-rasterized, corner-ward-shifted mark stays inside the localized box (the same widen that fixed Doubao). On corpus images this removes at ~100% with clean footprints (blends within a few LAB levels, no color shift). The registry gates removal on `detect`.
**The mask quality, not the method, was the earlier limit** — a max-channel / quadratic-bg / blurred / halo-truncated build (and a black-dominated LS) left a visible outline (lesson from issue #13: when reverse-alpha leaves a ghost, suspect the captured alpha map before adding heuristics or switching method). Geometry emitted by the solver at `_ALPHA_NATIVE_WIDTH` 2048: `_ALPHA_WIDTH_FRAC` 0.202, `_ALPHA_HEIGHT_FRAC` 0.058, margins ~0.029.
**Removal = reverse-alpha + a deliberately THIN residual inpaint** (`remove_watermark_reverse_alpha`, `_RESIDUAL_DILATE` 5 over the `_RESIDUAL_ALPHA_FLOOR` 0.05 footprint, `_RESIDUAL_INPAINT_RADIUS` 2, `INPAINT_NS`): a single 2048 alpha cannot pixel-cancel the mark re-rasterized at another resolution (alpha maps from independent captures correlate 0.998, not 1.0; off-native reverse-alpha alone only halves the mark), so a tight inpaint clears the residual edges WITHOUT the texture/edge smear a wide full-footprint pass caused.
**Placement ALWAYS tries fixed geometry AND `_aligned_alpha_map`'s NCC scale+position search, keeping the lower-residual** — the mark re-rasterizes + jitters a few px per image even at the captured width, so fixed geometry alone misses (there is no `_ALPHA_NATIVE_BAND` fast-path; the scale search `_ALPHA_ALIGN_SEARCH` is fine-stepped, and the `WM_*` locate box is generous so a corner-ward shift stays inside the search — the same widen that fixed Doubao). Verified clean on the solid captures (native 2048; faint self-residual ~1.3 visible only on a dead-flat field, hidden by real texture) and a real 1440-wide Jimeng download (off-native, table edge preserved). `reverse_alpha_available` is just "asset present"; the registry gates on `detect`.
**No committed real sample** (the real content download stays gitignored; only the solid calibration captures are committed) — `tests/test_jimeng_engine.py` synthesizes a mark from the bundled alpha asset, and `test_recovers_shifted_mark_on_texture` guards the align-on-shift path that the Doubao defect exposed. Jimeng images are independently caught by the China TC260 AIGC label in `metadata`/`identify`, so this engine is the visible-mark *removal* path, not a new `identify` signal.
**No committed real sample** (only the solid calibration captures are committed) — `tests/test_jimeng_engine.py` synthesizes a mark from the bundled template, and `test_recovers_shifted_mark_on_texture` guards the localize-on-shift path that the Doubao defect exposed. Jimeng images are independently caught by the China TC260 AIGC label in `metadata`/`identify`, so this engine is the visible-mark *removal* path, not a new `identify` signal.
## `samsung_engine.py`
`samsung_engine.py`**a thin `TextMarkEngine` subclass (config only) since 2026-06-09.** visible Samsung Galaxy AI "✦ Contenuti generati dall'AI" remover/detector (cv2/numpy, no GPU), built 2026-06-05 from issue #37's flat captures (@f-liva). Shares the base but anchored **bottom-LEFT** (Doubao/Jimeng are bottom-right): `locate` anchors a bottom-left box by **geometry** (scales with WIDTH), `extract_mask` pulls the light low-chroma glyphs (white top-hat + grayish + min-luma — `LOGO_MIN_LUMA` is lowered to **110** because the mark is faint, peak alpha ~0.38, so on a mid/dark background its glyph luma is lower than Jimeng's), `detect` matches the bundled glyph silhouette (`assets/samsung_alpha.png`) via `TM_CCOEFF_NORMED` over a coverage floor. Threshold `DETECT_NCC_THRESHOLD` **0.40** (real marks ~0.79 on a real photo, ~0.57/0.71 on the black/gray captures; 0.0 on Doubao/Jimeng captures, and Doubao/Jimeng score 0.0 on a real Samsung photo — no cross-fire, also because the corner differs).
`samsung_engine.py`**a thin `TextMarkEngine` subclass (config only) since 2026-06-09.** visible Samsung Galaxy AI "✦ Contenuti generati dall'AI" detector + localizer (cv2/numpy, no GPU), built 2026-06-05 from issue #37's flat captures (@f-liva). Shares the base but anchored **bottom-LEFT** (Doubao/Jimeng are bottom-right): `locate` anchors a bottom-left box by **geometry** (scales with WIDTH), `extract_mask` pulls the light low-chroma glyphs (white top-hat + grayish + min-luma — `LOGO_MIN_LUMA` is lowered to **110** because the mark is faint, peak alpha ~0.38, so on a mid/dark background its glyph luma is lower than Jimeng's), `detect` matches the bundled glyph silhouette (`assets/samsung_alpha.png`) via `TM_CCOEFF_NORMED` over a coverage floor. Threshold `DETECT_NCC_THRESHOLD` **0.40** (real marks ~0.79 on a real photo, ~0.57/0.71 on the black/gray captures; 0.0 on Doubao/Jimeng captures, and Doubao/Jimeng score 0.0 on a real Samsung photo — no cross-fire, also because the corner differs).
**Logo is pure white (255,255,255)** (`_ALPHA_LOGO_BGR`; white capture confirms).
**The detection template (`assets/samsung_alpha.png`) is solved by `scripts/visible_alpha_solve.py samsung` from the GRAY capture** (`data/samsung_capture/captures/`, the flat black/gray/white captures committed; the solver gained a `corner="bl"` mode + left-margin logging for this), same careful recipe as Jimeng (cubic background, mean-channel, full halo, unblurred). Geometry emitted at `_ALPHA_NATIVE_WIDTH` **1086** (the flat-edit capture width): `_ALPHA_WIDTH_FRAC` 0.3195, `_ALPHA_HEIGHT_FRAC` 0.0378, `_ALPHA_MARGIN_LEFT_FRAC` 0.0110, `_ALPHA_MARGIN_BOTTOM_FRAC` 0.0064. Used only as the detection silhouette, not for pixel recovery.
**Alpha solved by `scripts/visible_alpha_solve.py samsung` from the GRAY capture** (`data/samsung_capture/captures/`, the flat black/gray/white captures committed; the solver gained a `corner="bl"` mode + left-margin logging for this), same careful recipe as Jimeng (cubic background, mean-channel, full halo, unblurred). Geometry emitted at `_ALPHA_NATIVE_WIDTH` **1086** (the flat-edit capture width): `_ALPHA_WIDTH_FRAC` 0.3195, `_ALPHA_HEIGHT_FRAC` 0.0378, `_ALPHA_MARGIN_LEFT_FRAC` 0.0110, `_ALPHA_MARGIN_BOTTOM_FRAC` 0.0064.
**Removal is localize -> fill:** the glyph blob is localized to a solid, dilated footprint mask and the shared `watermark_registry.fill` inpaints it. Verified on a real 2958-wide @f-liva photo: re-detect 0.79→0.00, no readable text or outline on the recovered wooden table — checked **visually**, not just by the detector. The registry gates removal on `detect`.
**Removal = reverse-alpha + a deliberately THIN residual inpaint** (`remove_watermark_reverse_alpha`, same `_RESIDUAL_*` recipe as Jimeng) with **always-try fixed AND `_aligned_alpha_map` NCC scale+position search, keep the lower-residual** (`_ALPHA_ALIGN_SEARCH` widened to (0.85, 1.18, 23) because the flat captures are far off the real-photo width).
**Detection is locale-specific** (the string differs per language); this build detects only the Italian "Contenuti generati dall'AI" variant, so non-Italian Samsung locales are not detected — and, because detection gates removal, not removed — even though the fill mask itself is locale-independent. Other locales need their own captured detection template. This is a pre-existing limit, unchanged by the localize -> fill refactor.
**Resolution caveat:** the flat captures arrived at 1086 wide while real photos are ~2958 wide (the mark scales with width, so the captured glyph ~334px is ~2.7x smaller than the ~903px real-photo glyph); width-scale + NCC-align still removes it cleanly (verified on a real 2958-wide @f-liva photo: re-detect 0.79→0.00, no readable text or outline on the recovered wooden table — checked **visually**, not just by the detector, per the Gemini self-verify lesson), but a flat capture at the real photo resolution would make the alpha pixel-sharp instead of upscaled (open quality upgrade, noted in `data/samsung_capture/README.md`).
**The mark is locale-specific** (text differs per language); this build is the Italian "Contenuti generati dall'AI" variant — other locales need their own captured template. `reverse_alpha_available` is just "asset present"; the registry gates on `detect`.
**No committed real sample** (the real photo stays gitignored; only the flat calibration captures are committed) — `tests/test_samsung_engine.py` synthesizes a mark from the bundled alpha asset (bottom-left geometry), with `test_recovers_shifted_mark_on_texture` guarding the align-on-shift path. Samsung Galaxy AI edits are independently caught by C2PA + the `genAIType` marker in `metadata`/`identify`, so this engine is the visible-mark *removal* path; it also feeds `identify` as the medium-confidence `visible_samsung` signal via the registry (the stripped-metadata fallback).
**No committed real sample** (only the flat calibration captures are committed) — `tests/test_samsung_engine.py` synthesizes a mark from the bundled template (bottom-left geometry), with `test_recovers_shifted_mark_on_texture` guarding the localize-on-shift path. Samsung Galaxy AI edits are independently caught by C2PA + the `genAIType` marker in `metadata`/`identify`, so this engine is the visible-mark *removal* path; it also feeds `identify` as the medium-confidence `visible_samsung` signal via the registry (the stripped-metadata fallback).
## `region_eraser.py`
`region_eraser.py` — universal region eraser (`erase` CLI) AND the inpaint backend for the visible-mark fallback (`watermark_registry._inpaint_remove`). `erase(image, boxes=|mask=, backend=)` accepts grayscale (2D) and RGBA (4-channel) inputs on **all** backends (each splits off any alpha plane and re-attaches it unchanged, and promotes grayscale to BGR): `boxes_to_mask` → one of three backends.
`region_eraser.py` — universal region eraser (`erase` CLI) AND the shared fill backend behind `watermark_registry.fill` for the visible localize -> fill removal. `erase(image, boxes=|mask=, backend=)` accepts grayscale (2D) and RGBA (4-channel) inputs on **all** backends (each splits off any alpha plane and re-attaches it unchanged, and promotes grayscale to BGR): `boxes_to_mask` → one of three backends.
- `cv2` (default, no deps): `cv2.inpaint`.
- `migan` (extra `migan`, `andraniksargsyan/migan` ONNX, MIT, ~28 MB): `erase_migan`. The MI-GAN ONNX crops around the mask bbox and re-composites internally, so the FULL image is fed at native resolution; only masked pixels are pasted back. **Mask polarity is INVERTED** vs this package's 255-erase convention — the shipped ONNX wants 0=hole / 255=known, so `erase_migan` feeds `(mask<=127)*255`; feeding 255=hole regenerates the whole frame into stripes (corpus-validated 2026-07, cost hours to find). ~0.95 GB peak / ~0.19 s. This is the **preferred** inpaint-fallback backend.
- `migan` (extra `migan`, `andraniksargsyan/migan` ONNX, MIT, ~28 MB): `erase_migan`. The MI-GAN ONNX crops around the mask bbox and re-composites internally, so the FULL image is fed at native resolution; only masked pixels are pasted back. **Mask polarity is INVERTED** vs this package's 255-erase convention — the shipped ONNX wants 0=hole / 255=known, so `erase_migan` feeds `(mask<=127)*255`; feeding 255=hole regenerates the whole frame into stripes (corpus-validated 2026-07, cost hours to find). ~0.95 GB peak / ~0.19 s. This is the **preferred default fill** for the visible localize -> fill path.
- `lama` (extra `lama`, `Carve/LaMa-ONNX` Apache-2.0, ~200 MB): `erase_lama` crops a padded region around the mask, runs at LaMa's fixed 512² input, pastes only masked pixels back. Best quality but ~4.7 GB peak — explicit opt-in only, NOT auto-selected.
Lazy `_get_{lama,migan}_session` singletons; `{lama,migan}_available()` guard the optional imports (both == onnxruntime present). Note both extras install the same onnxruntime, so the two `*_available()` checks are identical — the registry's `preferred_inpaint_backend` therefore prefers MI-GAN whenever onnxruntime is present, and big-LaMa is reachable only by explicit `--backend lama`.
Lazy `_get_{lama,migan}_session` singletons; `{lama,migan}_available()` guard the optional imports (both == onnxruntime present). Note both extras install the same onnxruntime, so the two `*_available()` checks are identical — the fill's `auto` backend therefore resolves to MI-GAN whenever onnxruntime is present, else cv2, and big-LaMa is reachable only by an explicit `lama` backend (`--backend lama` on `erase`, or the shared fill's `backend="lama"`).
**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.
@@ -207,7 +179,7 @@ At the shared low removal strength the canny edge-conditioning keeps the regener
## `noai/tiling.py`
Pure sliding-window tiling for the diffusion path (no torch import; numpy/PIL only). `plan_tiles(w, h, tile_size, overlap)` returns a row-major grid of uniform-size `Tile` boxes — every tile is exactly `tile_size` (the SDXL training size), with the last tile on each axis pulled back flush to the far edge (`_axis_positions` clamps a pathological `overlap >= tile` to `tile - 1` so the step stays >= 1). `feather_weights(w, h, overlap)` is a separable linear taper (1 in the interior, ramping toward each edge) floored at `_WEIGHT_EPS` so it is **strictly positive everywhere** — that makes the normalised `accum / weight_sum` blend a partition of unity, so identical/unchanged tiles reconstruct the input exactly (the seam-free guarantee). `run_tiled(generate_tile, image, tile_size, overlap, set_progress)` is the orchestration loop: crop each planned tile, call `generate_tile` (one diffusion pass on a single PIL tile — injected, so this stays decoupled from the pipeline), resize a latent-grid-rounded result back to the exact tile size, and feather-accumulate. All three are unit-tested without the model (`tests/test_tiling.py`: axis math, grid coverage, taper shape/symmetry/positivity, identity reconstruction, per-tile call count, and the resize-back path). New blend tuning belongs in these pure helpers, not inlined into the runner.
Pure sliding-window tiling for the diffusion path (no torch import; numpy/PIL only). `plan_tiles(w, h, tile_size, overlap)` returns a row-major grid of uniform-size `Tile` boxes — every tile is exactly `tile_size` (the SDXL training size), with the last tile on each axis pulled back flush to the far edge (`_axis_positions` clamps a pathological `overlap >= tile` to `tile - 1` so the step stays >= 1). `feather_weights(w, h, overlap)` is a separable linear taper (1 in the interior, ramping toward each edge) floored at `_WEIGHT_EPS` so it is **strictly positive everywhere** — that makes the normalized `accum / weight_sum` blend a partition of unity, so identical/unchanged tiles reconstruct the input exactly (the seam-free guarantee). `run_tiled(generate_tile, image, tile_size, overlap, set_progress)` is the orchestration loop: crop each planned tile, call `generate_tile` (one diffusion pass on a single PIL tile — injected, so this stays decoupled from the pipeline), resize a latent-grid-rounded result back to the exact tile size, and feather-accumulate. All three are unit-tested without the model (`tests/test_tiling.py`: axis math, grid coverage, taper shape/symmetry/positivity, identity reconstruction, per-tile call count, and the resize-back path). New blend tuning belongs in these pure helpers, not inlined into the runner.
`feather_region_composite(base, regenerated, box, *, feather)` is the pure region-targeted compositor for **AI-enhanced composites** (roadmap P1#8; `identify` `ai_source_kind == "enhanced"`, digitalSourceType `compositeWithTrainedAlgorithmicMedia`). It blends `regenerated` over `base` inside `box = (x, y, w, h)` with a separable linear taper of `feather` px at the box edges (the taper anchors to ~0 at the boundary, so unlike `feather_weights` it is NOT floored — the result equals `base` EXACTLY outside the box), preserving dtype and supporting HxW or HxWxC. It backs `WatermarkRemover.remove_watermark(region=..., region_feather=...)`: the remover regenerates the frame (or tiles), then composites only the AI box back over the original input, so the real photo outside the box stays pixel-exact and only the AI region is scrubbed. The box is caller-supplied (a C2PA composite manifest carries no reliable machine-readable region); the no-model lossless region path remains `region_eraser.erase`. Unit-tested in `tests/test_tiling.py::TestFeatherRegionComposite` (outside-box exactness, interior == regenerated, hard-paste at feather 0, monotonic seam ramp, dtype/grayscale/clamp/empty-box/shape-mismatch).
@@ -250,7 +222,7 @@ Full per-command behavior for the skip/exit branches summarized in `CLAUDE.md`'s
### `all`
Full pipeline (visible + invisible + metadata). Same diffusion knobs as `invisible`, plus the visible-pass `--inpaint/--no-inpaint`/`--inpaint-method`. **When the `[gpu]` extra is absent, step 2 (invisible/SynthID) is skipped**`all` still writes an output (visible mark + metadata stripped) but prints a prominent end-of-run banner ("the invisible (SynthID) watermark was NOT removed") AND exits **non-zero** (1), so a skipped SynthID pass is not mistaken for a clean result (the recurring #14/#47 trap, where the old quiet inline warning was missed). `invisible` already hard-errors without the extra; only `all` continued, hence the loud end-banner. Regression-guarded by `tests/test_cli.py::TestAllCommand::test_all_loud_warning_and_nonzero_exit_when_gpu_missing`. **No-signal skip (P0#5):** step 2 also runs the same `has_invisible_target` gate (see `invisible` below) — when no invisible watermark is detectable and `--force` is not set, step 2 is skipped and the pixels are left intact, but unlike the GPU-missing skip this is a **SUCCESS (exit 0)**: the visible pass + metadata strip still ran and a file is written (the message says so without claiming the image is clean). Distinct exit semantics by design: GPU-missing = couldn't do the work (non-zero); no-signal = nothing to do (zero). Regression-guarded by `test_all_skips_invisible_on_no_signal_but_succeeds`. **Test trap:** any `all` test that exercises the full pipeline MUST `patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True)` — CI installs core+dev only (no `[gpu]`), so an unpatched `all` test takes the skip branch and now hits the non-zero exit. This passed locally (gpu present → `is_available()` True) but red-failed every matrix cell on the v0.11.0 commit (`test_all_basic`/`test_all_visible_step_uses_registry` asserted exit 0); both now patch `is_available` True.
Full pipeline (visible + invisible + metadata). Same diffusion knobs as `invisible`, plus the visible-pass `--backend auto|cv2|migan|lama` (default `auto`) that picks the fill for the localize -> fill visible removal. **When the `[gpu]` extra is absent, step 2 (invisible/SynthID) is skipped**`all` still writes an output (visible mark + metadata stripped) but prints a prominent end-of-run banner ("the invisible (SynthID) watermark was NOT removed") AND exits **non-zero** (1), so a skipped SynthID pass is not mistaken for a clean result (the recurring #14/#47 trap, where the old quiet inline warning was missed). `invisible` already hard-errors without the extra; only `all` continued, hence the loud end-banner. Regression-guarded by `tests/test_cli.py::TestAllCommand::test_all_loud_warning_and_nonzero_exit_when_gpu_missing`. **No-signal skip (P0#5):** step 2 also runs the same `has_invisible_target` gate (see `invisible` below) — when no invisible watermark is detectable and `--force` is not set, step 2 is skipped and the pixels are left intact, but unlike the GPU-missing skip this is a **SUCCESS (exit 0)**: the visible pass + metadata strip still ran and a file is written (the message says so without claiming the image is clean). Distinct exit semantics by design: GPU-missing = couldn't do the work (non-zero); no-signal = nothing to do (zero). Regression-guarded by `test_all_skips_invisible_on_no_signal_but_succeeds`. **Test trap:** any `all` test that exercises the full pipeline MUST `patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True)` — CI installs core+dev only (no `[gpu]`), so an unpatched `all` test takes the skip branch and now hits the non-zero exit. This passed locally (gpu present → `is_available()` True) but red-failed every matrix cell on the v0.11.0 commit (`test_all_basic`/`test_all_visible_step_uses_registry` asserted exit 0); both now patch `is_available` True.
### `invisible`
@@ -258,8 +230,8 @@ Diffusion SynthID removal. The `--tile/--no-tile` knob is the *lossless* alterna
### `visible`
Known-visible-mark removal. Two methods via `--method` (default `auto`): `reverse-alpha` inverts the captured alpha map to recover exact pixels (CPU, no GPU, better on structured backgrounds); `inpaint` erases the mark footprint (`footprint_mask` MI-GAN when the `migan` extra is installed, else cv2; see `preferred_inpaint_backend` — big-LaMa is NOT auto-selected). `auto` = `registry.resolve_removal_method`: reverse-alpha for capture marks, inpaint for capture-less (deterministic, model-independent -- reverse-alpha is measured cleaner + lighter than MI-GAN on the capture marks, so inpaint is reserved for the pill); the resolved method is echoed in the status line, and `--method` is shared across `visible`/`all`/`batch`. The inpaint mask is the full NCC-aligned alpha silhouette (`footprint_mask`), NOT the per-image `extract_mask` signature — the signature under-segments and leaves glyph residue (corpus-validated 2026-07; the mask is dilated to absorb alpha-alignment slop). `--mark auto` (default) removes EVERY detected mark in one pass (`registry.remove_auto_marks`, not the single strongest -- a Jimeng-basic image carries both the top-left pill and the bottom-right wordmark) from: the Gemini sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-LEFT, Italian locale), and the capture-less Jimeng "AI生成" pill (top-left, `pill_engine`, inpaint-only). The pill's weak edge-NCC detector is gated in `remove_auto_marks` via `_keep_pill` (32k real-upload corpus validation 2026-07): never on Doubao, and two confirmation arms since metadata confirms the platform, not pill presence. (1) The bottom-right wordmark fired — ~94% precise and survives metadata-STRIPPED uploads (screenshots / re-saves) — removes the pill unrestricted. (2) TC260 metadata only (CLI `_aigc_metadata_present``pill_metadata`, no wordmark) — ~27% precise, its false fires are textured ceilings/walls that inpaint visibly SMEARS — removes the pill ONLY when the top-left footprint is flat enough for an invisible inpaint (`pill_engine.footprint_is_flat`, median-Sobel ≤ `_FLAT_TEXTURE_MAX`). No confirmation → never removed. `--mark gemini|doubao|jimeng|samsung|jimeng_pill` forces one (choices come from the registry). Under `reverse-alpha`, Gemini/Doubao recover pixels exactly with no inpaint at native; **Jimeng and Samsung add an always-on thin residual inpaint over the glyph footprint** (their marks re-rasterize per image, so reverse-alpha alone leaves a faint outline). For arbitrary logos/objects use `erase`. **When `--mark auto` finds no known mark (the common case — ~74% of real uploads carry no registered visible mark), the command does NOT silently re-serve the input as a finished result.** It runs a cheap metadata-only `identify`, prints actionable guidance (if the image carries an invisible/metadata mark, e.g. an OpenAI/Gemini C2PA image, it points to `all`; otherwise it does NOT imply the image is clean -- it warns that an invisible pixel watermark like SynthID cannot be detected once the metadata proxy is gone and routes to both `all` and `erase --region`), writes NO output file, and exits **`EXIT_NO_VISIBLE_MARK` (2)** — distinct from success (0) and a hard error (1) so a wrapping service (raiw.cc) can surface the message instead of treating the unchanged image as done (the production "it didn't work" / score-0 trap). Same handling for an explicit `--mark <name>` that is not detected. Helper `cli._no_visible_mark_exit`; regression-guarded by `tests/test_cli.py::TestVisibleCommand::test_visible_auto_no_mark_exits_two_with_eraser_hint` and `test_visible_auto_no_mark_routes_to_all_when_metadata`. `--no-detect` still forces the gemini fallback and proceeds (exit 0).
Known-visible-mark removal by **localize -> fill**: each detected mark is localized to a binary full-frame footprint mask, then one shared, swappable fill inpaints that mask. `--backend auto|cv2|migan|lama` (default `auto`) picks the fill: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available (LaMa is auto-preferred when a learned backend is present; a memory-tight deploy pins migan). `--sensitivity auto|strict|assume-ai` (default `auto`) controls how hard a borderline mark is trusted (see the registry section: the visual detectors are metadata-independent; `auto` relaxes a mark only on same-product evidence, `assume-ai` relaxes every mark on the caller's AI assertion — the only path to high recall on a metadata-stripped screenshot). `--backend` and `--sensitivity` are shared across `visible`/`all`/`batch`. Detection keys on each mark's own shape, and under `auto` the trust gate is relaxed when local metadata confirms the vendor (a Google/Gemini C2PA issuer relaxes gemini, a China-AIGC label relaxes doubao/jimeng, `samsung_genai` relaxes samsung), so a moved or re-rendered mark is still caught. `--mark auto` (default) removes EVERY detected mark in one pass (`registry.remove_auto_marks`, not the single strongest -- a Jimeng-basic image carries both the top-left pill and the bottom-right wordmark) from: the Gemini sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-LEFT, Italian-locale detection), and the capture-less Jimeng "AI生成" pill (top-left, `pill_engine`). The pill's weak edge-NCC detector is gated in `remove_auto_marks` via `_keep_pill` (32k real-upload corpus validation 2026-07): never on Doubao, and two confirmation arms since metadata confirms the platform, not pill presence. (1) The bottom-right wordmark fired — ~94% precise and survives metadata-STRIPPED uploads (screenshots / re-saves) — removes the pill unrestricted. (2) TC260 metadata confirms Jimeng (`"jimeng" in provenance`, from `cli._visible_provenance`) OR the caller asserts AI (`sensitivity == "assume_ai"`), no wordmark — ~27% precise, its false fires are textured ceilings/walls that the fill visibly SMEARS — removes the pill ONLY when the top-left footprint is flat enough for an invisible fill (`pill_engine.footprint_is_flat`, median-Sobel ≤ `_FLAT_TEXTURE_MAX`; the flatness guard holds even under `assume_ai`). No confirmation → never removed. `--mark gemini|doubao|jimeng|samsung|jimeng_pill` forces one (choices come from the registry). Corpus validation: doubao and jimeng localize + remove at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit); clean images with no vendor signature had 0% false removal. For arbitrary logos/objects use `erase`. **When `--mark auto` finds no known mark (the common case — ~74% of real uploads carry no registered visible mark), the command does NOT silently re-serve the input as a finished result.** It runs a cheap metadata-only `identify`, prints actionable guidance (if the image carries an invisible/metadata mark, e.g. an OpenAI/Gemini C2PA image, it points to `all`; otherwise it does NOT imply the image is clean -- it warns that an invisible pixel watermark like SynthID cannot be detected once the metadata proxy is gone and routes to both `all` and `erase --region`), writes NO output file, and exits **`EXIT_NO_VISIBLE_MARK` (2)** — distinct from success (0) and a hard error (1) so a wrapping service (raiw.cc) can surface the message instead of treating the unchanged image as done (the production "it didn't work" / score-0 trap). Same handling for an explicit `--mark <name>` that is not detected. Helper `cli._no_visible_mark_exit`; regression-guarded by `tests/test_cli.py::TestVisibleCommand::test_visible_auto_no_mark_exits_two_with_eraser_hint` and `test_visible_auto_no_mark_routes_to_all_when_metadata`. `--no-detect` still forces the gemini fallback and proceeds (exit 0).
### `batch`
Process every supported image in a directory (output defaults to `<directory>_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the **full `invisible` knob set** (`--strength`/`--steps`/`--guidance-scale`/`--pipeline`/`--controlnet-scale`/`--model`/`--device`/`--max-resolution`/`--min-resolution`/`--upscaler`/`--seed`/`--hf-token`/`--humanize`/`--unsharp`/`--adaptive-polish`/`--tile`/`--tile-size`/`--tile-overlap`/`--force`), plus `--inpaint/--no-inpaint` for the visible pass. `--adaptive-polish` is ON by default; `--auto` is deprecated and a no-op that only warns. **No-signal skip (P0#5):** in invisible/all mode each image runs the same `has_invisible_target` gate — a signal-less image is skipped (no diffusion); in `invisible` mode the input is copied through to the output dir so it stays complete, in `all` mode the visible-removed result is kept and metadata is still stripped. `--force` scrubs every image regardless. One engine cached per pipeline; the polish is resolved once before the loop.
Process every supported image in a directory (output defaults to `<directory>_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the **full `invisible` knob set** (`--strength`/`--steps`/`--guidance-scale`/`--pipeline`/`--controlnet-scale`/`--model`/`--device`/`--max-resolution`/`--min-resolution`/`--upscaler`/`--seed`/`--hf-token`/`--humanize`/`--unsharp`/`--adaptive-polish`/`--tile`/`--tile-size`/`--tile-overlap`/`--force`), plus `--backend` for the visible localize -> fill pass. `--adaptive-polish` is ON by default; `--auto` is deprecated and a no-op that only warns. **No-signal skip (P0#5):** in invisible/all mode each image runs the same `has_invisible_target` gate — a signal-less image is skipped (no diffusion); in `invisible` mode the input is copied through to the output dir so it stays complete, in `all` mode the visible-removed result is kept and metadata is still stripped. `--force` scrubs every image regardless. One engine cached per pipeline; the polish is resolved once before the loop.
+2 -2
View File
@@ -10,9 +10,9 @@
**Conclusion (historical): pure reverse-alpha distilled from content images does NOT work, and the blocker is the WRONG kind of data, not too little of it.**
The earlier framing ("need ~5-8 PRISTINE same-resolution originals") is obsolete -- a local corpus of pristine originals holds plenty. Curate them with `DoubaoEngine.detect` + an NCC filter against a clean glyph template, keeping only marks at offset ≈ (0,0): that yields e.g. **15 pixel-aligned 2048² marks** (sub-pixel drift, not the ±50 px the old lossy/mixed-res scrapes had), plus 1086x1448 / 1792x2400 clusters. With those, LaMa-clean `O` + weighted-LS (and per-pixel I-on-O regression) for `α` (+ logo colour) was tried end-to-end and **still leaves a persistent ghost outline.**
The earlier framing ("need ~5-8 PRISTINE same-resolution originals") is obsolete -- a local corpus of pristine originals holds plenty. Curate them with `DoubaoEngine.detect` + an NCC filter against a clean glyph template, keeping only marks at offset ≈ (0,0): that yields e.g. **15 pixel-aligned 2048² marks** (sub-pixel drift, not the ±50 px the old lossy/mixed-res scrapes had), plus 1086x1448 / 1792x2400 clusters. With those, LaMa-clean `O` + weighted-LS (and per-pixel I-on-O regression) for `α` (+ logo color) was tried end-to-end and **still leaves a persistent ghost outline.**
Diagnosed why, empirically (cached stacks, `/tmp/doubao_distill`): (1) the mark is a clean white overlay with **no dark halo** -- over glyph pixels ~54% are brighter than the clean bg, only ~4% darker -- so the white-logo model `I=(1-α)O+α·255` is correct; (2) but content backgrounds are almost never dark *under* the mark (median darkest available bg over glyph pixels = **58/255**; only ~13% of mark pixels are ever observed on a bg < 40), so on bright backgrounds the equation is ill-conditioned and `α` is unidentifiable; (3) LaMa's `O` is a plausible **hallucination**, not the true pre-mark background, which compounds the error, and per-pixel regression on ~15 obs overfits into colour noise.
Diagnosed why, empirically (cached stacks, `/tmp/doubao_distill`): (1) the mark is a clean white overlay with **no dark halo** -- over glyph pixels ~54% are brighter than the clean bg, only ~4% darker -- so the white-logo model `I=(1-α)O+α·255` is correct; (2) but content backgrounds are almost never dark *under* the mark (median darkest available bg over glyph pixels = **58/255**; only ~13% of mark pixels are ever observed on a bg < 40), so on bright backgrounds the equation is ill-conditioned and `α` is unidentifiable; (3) LaMa's `O` is a plausible **hallucination**, not the true pre-mark background, which compounds the error, and per-pixel regression on ~15 obs overfits into color noise.
**Why Gemini's engine is clean (verified in GeminiWatermarkTool `src/core/watermark_engine.cpp`): its alpha map is the watermark stamped on a PURE-BLACK background**, where `watermarked = α·255 + (1-α)·0 = α·255`, so `alpha = capture/255` exactly -- no estimation. (`gemini_bg_*.png` is literally the sparkle in grey on black.) So the real Doubao unlock is the same controlled capture, **not more content images**. Black/white/gray seeds exist (`data/doubao_capture/seeds/seed_*_1x1_2048x2048.png`); a capture run (feed a black seed through doubao.com edit mode, download the *original*) was requested from the #13 reporter 2026-05-29. With ~2-3 black captures we get `α = capture/255` for free, Gemini-quality.
+10
View File
@@ -42,3 +42,13 @@ Grok JPEG downloads (Aurora model) carry **no C2PA, no XMP, no SynthID, no IPTC*
- **Regulatory driver (context, not a code change):** AI-content labeling mandates are expanding, which pushes more generators toward exactly the C2PA + watermark signals we read. The full per-jurisdiction table lives in README "## Legal" -- keep it there, not duplicated here. Newly added + primary-source verified 2026-05-26: **EU AI Act Article 50** machine-readable marking applicable **2026-08-02** (verified against the article text); **South Korea AI Framework Act Art. 31(3)** in force since **22 January 2026** (verified via Kim & Chang + FPF/Korea Times; Enforcement Decree accepts an invisible-watermark label); **California AB 853** (amends the CA AI Transparency Act) latent-disclosure duty operative **2026-08-02**, requiring a disclosure "permanent or extraordinarily difficult to remove" (verified against the leginfo bill text -- this is the exact disclosure our tool strips); **India IT Amendment Rules 2026** in force **2026-02-20** (verified via Chambers), which prominently-label + permanent-provenance-id all synthetic media AND **expressly prohibit removing/suppressing the label or metadata** -- the first major all-content removal ban outside China.
**Removal liability (README "## Legal" disclaimer):** the tool is lawful general-purpose software; liability sits with the remover and is intent-gated -- downstream acts (fraud/deception/IP), plus US DMCA 17 USC 1202 (removing copyright-management info to conceal infringement), plus the removal-as-such bans in China + India. When extending the README table, verify each date/article against the statute/bill text before committing, not against search summaries.
## Visible AI-generation marks + detection methods (deep-research 2026-07-10, adversarially verified)
**Google Gemini visible "sparkle" -- tier-dependent, and spec-undocumented by Google.** Google primary sources (the Nano Banana Pro blog and the gemini.google image-generation page, both WebFetch-verified) confirm Gemini images carry BOTH the invisible SynthID (on ALL Google-AI media) AND a visible sparkle, but the visible mark is **tier-gated**: applied for FREE and Google AI **Pro** users, and **REMOVED** for Google AI **Ultra** subscribers, inside **Google AI Studio**, and on **API / dev** output. So a Google-C2PA image with NO visible sparkle is expected (Ultra / API), not evidence it is clean -- this reinforces the `identify` "no visible mark != clean" rule. The ONLY official verifier is the SynthID flow (upload to the Gemini app, ask if it is AI-generated), which reads the INVISIBLE mark; there is **no official visible-sparkle detector**, and Google publishes **no** glyph geometry / size / opacity / color / locale / placement spec. So our capture-based sparkle template is the only source of truth and cannot be validated against a vendor spec -- keep reverse-engineering from real captures (do not expect a published spec).
**The faint-visible-mark precision/recall wall is fundamental, not a heuristic artifact.** The visible-watermark-detection literature has moved to LEARNED segmentation / object-detection (WDNet WACV'21 arXiv:2012.07616; SLBR ACM MM'21, open code+weights; the PRCV'18 large-scale detector; Su et al. survey 2025), but three verified findings bound what a learned detector actually buys: (1) a claim that a confidence threshold "cleanly separates" true from false matches even with a learned CNN front-end was **REFUTED** in verification (arXiv:1705.08593) -- the precision/recall wall persists even with learned features. (2) Learned detectors need a LARGE, pattern-diverse labeled dataset trained on synthetic composites (PRCV'18: 60k images / 80 watermark classes; CLWD: 60k / 160 marks), and off-distribution degradation is a documented real axis (models trained on limited-pattern LVW transfer worse; diversity of training patterns drives generalization). (3) Inference is cheap (WDNet ~8 ms at 256x256) -- the cost is the data pipeline, not runtime. Net: a learned detector shifts the frontier but does NOT remove the wall; for a SINGLE mark the cheapest next step is a small patch classifier (real-sparkle vs false-positive) on top of the existing NCC localizer, not a full segmentation model. SLBR is a ready baseline. The current NCC + false-positive gate (core-ring brightness margin + gradient-NCC crispness + white-core saturation) is a sound operating point, and the residual miss is the information-theoretic wall the literature confirms.
**Visible-mark landscape beyond the registry.** Meta stamps a visible "Imagined with AI" mark (bottom-LEFT, a small symbol) on its OWN Meta AI / "Imagine" output; for third-party images it relies on C2PA / IPTC, not a visible mark. Samsung Galaxy AI additionally uses a **four-star icon** variant in a corner alongside the localized text wordmark `samsung_engine` calibrates (only the Italian text variant is covered) -- the icon is a distinct, uncovered variant. Every source agrees visible + metadata marks are trivially removable (crop / screenshot, ~2 s), which is the tool's premise.
**Regulatory driver -- China GB 45438-2025 is the strongest VISIBLE-mark mandate.** The CAC / TC260 "Measures for Labeling AI-Generated Synthesized Content" (issued March 2025, **effective 2025-09-01**, technical standard **GB 45438-2025**, building on the TC260 Aug-2023 practice guide) MANDATE a **visible** label for AI images -- a visible textual mark whose height must be **>= 5% of the image's shortest side** -- plus the metadata (implicit) label. So every major Chinese platform now ships visible "AI生成"-style text marks (we cover Doubao / Jimeng; expect more CJK-text marks under this driver). By contrast EU AI Act Article 50 mandates only the MACHINE-READABLE mark (enforceable 2026-08-02, grace to 2026-12-02); a visible label is proposed and modality-specific (visible for images) but is NOT a hard "fixed icon" mandate -- a claim that Art 50 requires a clearly-visible fixed icon for images was refuted in verification. Primary-source dates verified against the article/standard text, not search summaries.
+6 -2
View File
@@ -3,8 +3,12 @@
set -euo pipefail
uv sync --all-extras
uv run uv-outdated
uv run uv-secure --ignore-unfixed
# uv-outdated / uv-secure run via uvx (isolated env), NOT `uv run`: resolving them
# inside the project env crashes (uv-secure -> "annotated-doc raised exception") and,
# with set -e, aborts the whole gate before ruff/pyright/tests. uvx sidesteps the
# in-project dependency conflict (see CLAUDE.md "Test and lint").
uvx uv-outdated
uvx uv-secure --ignore-unfixed
uv run ruff check --fix
uv run ruff format
# Scoped to src/: a full-project pyright run OOM-crashes node on this ML-heavy
+7 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "remove-ai-watermarks"
version = "0.13.0"
version = "0.14.0"
description = "AI watermark remover: strip visible and invisible AI watermarks (Gemini / Nano Banana sparkle, SynthID) and provenance metadata (C2PA, EXIF) from images"
readme = "README.md"
requires-python = ">=3.10"
@@ -46,6 +46,12 @@ classifiers = [
]
dependencies = [
"pillow>=10.0.0",
# HEIC/AVIF pixel decode for the removal path (iPhone photos, modern exports):
# OpenCV cannot decode these containers, so image_io.imread falls back to Pillow
# and pillow-heif (bundled libheif, prebuilt wheels) registers the HEIF+AVIF
# openers. The metadata path already handles them via a plugin-free binary scan;
# this closes the same gap for the pixel path so `visible`/`all` work on them.
"pillow-heif>=0.13.0",
"piexif>=1.1.3",
"numpy>=1.24.0",
"opencv-python-headless>=4.8.0",
+31 -2
View File
@@ -1,7 +1,21 @@
"""Remove-AI-Watermarks: Unified tool for removing visible and invisible AI watermarks."""
"""Remove-AI-Watermarks: Unified tool for removing visible and invisible AI watermarks.
High-level API (lazy, so ``import remove_ai_watermarks`` stays cheap)::
import remove_ai_watermarks as raiw
raiw.remove_visible("in.png", "out.png") # clean a file (provenance auto)
result, removed = raiw.remove_visible(bgr_array) # array -> array
raiw.visible_provenance("in.png") # -> frozenset of confirmed vendors
For a provenance verdict use the ``identify`` submodule::
from remove_ai_watermarks.identify import identify
report = identify("in.png")
"""
import os as _os
import warnings as _warnings
from typing import TYPE_CHECKING
# transformers prints a noisy deprecation for the Siglip2ImageProcessorFast
# alias when it is imported (by the optional GPU/ML path). Silence it before
@@ -11,4 +25,19 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
__version__ = "0.13.0"
__version__ = "0.14.0"
__all__ = ["__version__", "remove_visible", "visible_provenance"]
if TYPE_CHECKING:
from remove_ai_watermarks.api import remove_visible, visible_provenance
def __getattr__(name: str) -> object:
"""Lazily resolve the high-level API (PEP 562), so the heavy imports (cv2, the
metadata/identify stack) load only when a caller actually reaches for them."""
if name in ("remove_visible", "visible_provenance"):
from remove_ai_watermarks import api
return getattr(api, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+84 -287
View File
@@ -1,20 +1,22 @@
"""Shared base for the reverse-alpha visible text-mark engines.
"""Shared base for the visible text-mark detectors/localizers (localize -> fill).
The Doubao "豆包AI生成", Jimeng "★ 即梦AI", and Samsung "✦ Contenuti generati
dall'AI" marks are the SAME algorithm: anchor a bottom-corner box by width-relative
geometry, extract the light low-saturation glyph candidate, detect by matching the
bundled alpha-glyph silhouette via ``TM_CCOEFF_NORMED``, and remove by inverting the
alpha blend ``original = (wm - a*logo)/(1-a)`` (always trying fixed AND NCC-aligned
placement, keeping the lower-residual one) plus a thin footprint inpaint.
geometry, extract the light low-saturation glyph candidate (white top-hat), detect
by matching the bundled alpha-glyph silhouette via ``TM_CCOEFF_NORMED``, and build a
removal MASK from the glyph blob's bounding box (:meth:`footprint_mask`) for the
shared fill (region_eraser). The mask is template-FREE -- the top-hat glyph bbox, not
a fixed alpha-template placement -- so a re-rendered or differently-placed mark (e.g.
a non-Italian Samsung string) is still masked. The old reverse-alpha pixel recovery
(``original = (wm - a*logo)/(1-a)``) is gone.
They differ ONLY in a bounded set of tuned values captured by :class:`TextMarkConfig`:
the constants, the bundled asset, the corner (Doubao/Jimeng bottom-right, Samsung
bottom-left), and a few structural knobs (the morphology-open kernel size and the
minimum glyph width used by the alignment / template-match). Each engine module is a
thin :class:`TextMarkEngine` subclass plus the test-facing module constants/helpers.
the constants, the bundled silhouette asset, the corner (Doubao/Jimeng bottom-right,
Samsung bottom-left), and a few structural knobs. Each engine module is a thin
:class:`TextMarkEngine` subclass plus the test-facing module constants/helpers.
Gemini stays a SEPARATE engine (``gemini_engine``): its multi-size fixed-slot sparkle
model is genuinely different, not a tuned variant of this one.
Gemini stays a SEPARATE engine (``gemini_engine``): its multi-size sparkle model is
genuinely different, not a tuned variant of this one.
"""
# cv2/numpy boundary: third-party libs ship no usable element types; relax the
@@ -37,28 +39,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Reverse-alpha over-subtraction guard (ported from gemini_engine, 2026-06-20).
# The reverse-alpha blend ``(wm - a*logo)/(1-a)`` over-subtracts when the captured
# alpha over-estimates THIS image's mark opacity: on a dark or mid-tone background
# it drives the glyph footprint into a visibly DARKER-than-background ghost (a
# "dark pit") instead of recovering the true pixels. The retained-corpus mining
# (2026-06-20) showed the sparkle-only fix (commit 41f6797) left this unhandled
# for the Doubao/Jimeng text marks. Mirror the sparkle gate: when the recovered
# glyph body lands more than this many gray levels below the local background
# ring, abandon the reverse-alpha output for the footprint and inpaint it from
# the surroundings instead. Calibrated to the same 25-level margin the sparkle
# gate uses -- clean text-mark removals recover within ~10 of the ring, the dark
# pit lands tens of levels below.
_OVERSUB_DARK_MARGIN = 25.0
# Glyph-body / background-ring sampling for the guard. The ring is a pad around
# the glyph box (excluding the box); the body is the bright-core glyph pixels.
_OVERSUB_RING_PAD_FRAC = 0.6 # ring pad as a fraction of the glyph-box height
_OVERSUB_BODY_ALPHA_FLOOR = 0.15 # alpha above which a block pixel counts as glyph body
# Footprint inpaint when the guard trips: dilate the glyph mask wider than the
# thin residual pass so the whole darkened ghost is reconstructed, not just its edge.
_OVERSUB_INPAINT_DILATE = 9
_OVERSUB_INPAINT_RADIUS = 4
# Minimum image short side (px) for text-mark DETECTION. Below this the glyph
# template degrades to the ``min_gw`` floor (~8 px) and TM_CCOEFF_NORMED on a few
# pixels is noise, so an unrelated small geometric shape can spuriously correlate
@@ -72,10 +52,17 @@ _OVERSUB_INPAINT_RADIUS = 4
# positive; removal is gated on detection, so it is suppressed too.
_MIN_DETECT_SHORT_SIDE = 200
# Provenance-confirmed NCC relaxation. When external metadata already confirms the
# vendor (so the mark is present with high prior), a faint or slightly re-rendered
# glyph that scores just below the standard NCC gate is still trusted. 0.7 recovers
# the near-threshold marks without dropping so low that an unrelated corner texture
# on a (provenance-confirmed) image would match -- the coverage gate still applies.
_PROVENANCE_NCC_FACTOR = 0.7
@dataclass(frozen=True)
class TextMarkConfig:
"""All per-mark tuning for a reverse-alpha text-mark engine."""
"""All per-mark tuning for a text-mark detector/localizer."""
name: str # short label for log lines (e.g. "Doubao")
asset_name: str # bundled alpha PNG under assets/ (e.g. "doubao_alpha.png")
@@ -94,18 +81,12 @@ class TextMarkConfig:
# detection
detect_min_coverage: float
detect_ncc_threshold: float
# alpha-map geometry (fraction of WIDTH) emitted by scripts/visible_alpha_solve.py
# alpha-map glyph geometry (fraction of WIDTH) emitted by
# scripts/visible_alpha_solve.py, sizing the detection silhouette for
# template_match_score
alpha_width_frac: float
alpha_height_frac: float
alpha_margin_x_frac: float
alpha_margin_bottom_frac: float
alpha_align_search: tuple[float, float, int] # np.linspace(start, stop, num) scale search
min_gw: int # minimum glyph width for the template match / align search (8 br, 16 Samsung)
alpha_logo_bgr: tuple[float, float, float] = (255.0, 255.0, 255.0)
# residual inpaint over the glyph footprint (thin)
residual_alpha_floor: float = 0.05
residual_dilate: int = 5
residual_inpaint_radius: int = 2
min_gw: int # minimum glyph width for the template match (8 br, 16 Samsung)
@dataclass
@@ -184,7 +165,7 @@ def template_match_score(box_mask: NDArray[Any], image_width: int, config: TextM
class TextMarkEngine:
"""Reverse-alpha visible text-mark remover (locate -> mask -> detect -> reverse-alpha)."""
"""Visible text-mark detector/localizer (locate -> mask -> detect; mask feeds the fill)."""
def __init__(self, config: TextMarkConfig) -> None:
self.config = config
@@ -262,9 +243,16 @@ class TextMarkEngine:
# ── Detect ──────────────────────────────────────────────────────────
def detect(self, image: NDArray[Any]) -> TextMarkDetection:
def detect(self, image: NDArray[Any], *, provenance: bool = False) -> TextMarkDetection:
"""Detect the mark by matching the alpha-template glyph silhouette against
the corner candidate (``TM_CCOEFF_NORMED``); keys on glyph SHAPE, not coverage."""
the corner candidate (``TM_CCOEFF_NORMED``); keys on glyph SHAPE, not coverage.
``provenance`` signals that external metadata already confirms this vendor
(China-AIGC / byteimg for Doubao/Jimeng, ``samsung_genai`` for Samsung); the
NCC gate exists to keep a corner texture on an UNRELATED image from matching
the glyph silhouette, so when provenance confirms the vendor it is relaxed by
``_PROVENANCE_NCC_FACTOR`` to recover a faint or slightly re-rendered mark.
"""
c = self.config
det = TextMarkDetection()
if image is None or image.size == 0:
@@ -288,260 +276,69 @@ class TextMarkEngine:
det.coverage = coverage
if coverage >= c.detect_min_coverage:
score = self._template_match_score(box, image.shape[1])
threshold = c.detect_ncc_threshold * (_PROVENANCE_NCC_FACTOR if provenance else 1.0)
det.confidence = score
det.detected = score >= c.detect_ncc_threshold
logger.debug("%s detect: coverage=%.3f ncc=%.2f detected=%s", c.name, coverage, score, det.detected)
det.detected = score >= threshold
logger.debug(
"%s detect: coverage=%.3f ncc=%.2f thr=%.2f detected=%s",
c.name,
coverage,
score,
threshold,
det.detected,
)
return det
# ── Reverse-alpha (recovery + thin residual inpaint) ────────────────
def reverse_alpha_available(self, image: NDArray[Any]) -> bool:
"""True if the bundled alpha map is loadable (NCC alignment places it at any
resolution; the caller still gates on ``detect`` so a clean corner is untouched)."""
return image is not None and image.size > 0 and self._alpha_template() is not None
def _fixed_alpha_map(self, image: NDArray[Any]) -> tuple[NDArray[Any], tuple[int, int, int, int]] | None:
"""Place the template by fixed width-relative geometry (pixel-exact at the
captured width).
Returns the glyph-sized alpha BLOCK (shape ``(gh, gw)``) plus its placement
``(ax, ay, gw, gh)``, not a full-frame ``(h, w)`` map. The map is non-zero
only inside the glyph box and every consumer reads exactly that box, so a
full-frame float32 map was O(image*4 bytes) of mostly zeros -- ~48 MB on a
12 MP frame, and two were held at once (fixed + aligned). The block is
byte-identical to the old full-frame map's ``[ay:ay+gh, ax:ax+gw]`` slice.
"""
c = self.config
at = self._alpha_template()
if at is None:
return None
h, w = image.shape[:2]
# Clamp both dims so a wide/short image cannot overflow the slice assignment.
gw = min(w, max(1, int(c.alpha_width_frac * w)))
gh = min(h, max(1, int(c.alpha_height_frac * w)))
if c.corner == "br":
ax = max(0, w - int(c.alpha_margin_x_frac * w) - gw)
else: # bottom-left
ax = min(max(0, int(c.alpha_margin_x_frac * w)), max(0, w - gw))
ay = max(0, h - int(c.alpha_margin_bottom_frac * w) - gh)
block = cv2.resize(at, (gw, gh), interpolation=cv2.INTER_LINEAR)
return block, (ax, ay, gw, gh)
def _aligned_alpha_map(self, image: NDArray[Any]) -> tuple[NDArray[Any], tuple[int, int, int, int]] | None:
"""Register the captured template to the actual mark via a TM_CCOEFF_NORMED
scale + position search. Returns the glyph-sized alpha BLOCK and its
placement ``(ax, ay, gw, gh)`` (see :meth:`_fixed_alpha_map` for why the
block, not a full-frame map), or None."""
c = self.config
at = self._alpha_template()
sil = self._glyph_silhouette()
if at is None or sil is None:
return None
w = image.shape[1]
loc = self.locate(image)
bx, by, bw, bh = loc.bbox
box_mask = self.extract_mask(image, loc) # box-sized (== old full-frame cropped to bbox)
expected = c.alpha_width_frac * w
best: tuple[float, int, int, int, int] | None = None
for scale in np.linspace(*c.alpha_align_search):
gw, gh = int(expected * scale), int(c.alpha_height_frac * w * scale)
if gw < c.min_gw or gh < 4 or gw >= bw or gh >= bh:
continue
t = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_NEAREST)
_, score, _, top_left = cv2.minMaxLoc(cv2.matchTemplate(box_mask, t, cv2.TM_CCOEFF_NORMED))
if best is None or score > best[0]:
best = (score, gw, gh, top_left[0], top_left[1])
if best is None:
return None
_, gw, gh, ox, oy = best
ax, ay = bx + ox, by + oy
block = cv2.resize(at, (gw, gh), interpolation=cv2.INTER_LINEAR)
return block, (ax, ay, gw, gh)
def _apply_reverse_alpha(
self, image: NDArray[Any], amap: NDArray[Any], region: tuple[int, int, int, int]
) -> NDArray[Any]:
"""Invert the alpha blend with ``amap``: ``original = (wm - a*logo)/(1-a)``.
``amap`` is the glyph-sized alpha BLOCK for ``region`` (x, y, w, h); outside
it the blend is a no-op (``(wm - 0)/(1 - 0) == wm``). Compute the math on the
glyph crop only and copy the rest through unchanged -- byte-identical to a
full-frame pass (a uint8 round-trip through float32 is exact), but O(glyph)
instead of O(image): a full-frame pass costs ~275 ms on a 12 MP frame for a
glyph that is <0.1% of it, and it runs once per candidate placement.
"""
out = image.copy()
x1, y1, gw, gh = region
x2, y2 = x1 + gw, y1 + gh
if y1 >= y2 or x1 >= x2:
return out
a3 = np.clip(amap, 0.0, 1.0)[:, :, None]
logo = np.array(self.config.alpha_logo_bgr, np.float32)
roi = out[y1:y2, x1:x2].astype(np.float32)
out[y1:y2, x1:x2] = np.clip((roi - a3 * logo) / np.clip(1.0 - a3, 0.25, 1.0), 0, 255).astype(np.uint8)
return out
def _reverse_alpha_oversubtracts(
self, image: NDArray[Any], amap: NDArray[Any], region: tuple[int, int, int, int]
) -> bool:
"""True when reverse-alpha would darken the glyph footprint into a dark pit.
Ported from ``gemini_engine._reverse_alpha_oversubtracts`` (2026-06-20):
PREDICT the reverse-alpha output at the bright glyph core directly from the
INPUT and the captured alpha, ``(core_obs - a*logo)/(1-a)``, and trip when it
lands more than ``_OVERSUB_DARK_MARGIN`` gray levels below the local
background ring. Predicting from the input (not the produced output) keeps the
gate independent of which placement the reverse-alpha picked, so a clean
full-strength mark (whose strokes predict back to the background) never trips,
while a mark fainter than the capture (over-subtracted into a ghost) does.
"""
ax, ay, gw, gh = region
ih, iw = image.shape[:2]
if gw < 4 or gh < 4:
return False
if float(amap.max()) < 0.2: # too faint a capture to over-subtract meaningfully
return False
body_box = amap >= _OVERSUB_BODY_ALPHA_FLOOR # glyph strokes
if not bool(body_box.any()):
return False
pad = max(4, int(gh * _OVERSUB_RING_PAD_FRAC))
ry1, ry2 = max(0, ay - pad), min(ih, ay + gh + pad)
rx1, rx2 = max(0, ax - pad), min(iw, ax + gw + pad)
ring = image[ry1:ry2, rx1:rx2].astype(np.float32).mean(axis=2)
fy1, fy2, fx1, fx2 = ay - ry1, ay - ry1 + gh, ax - rx1, ax - rx1 + gw
ring_mask = np.ones(ring.shape, dtype=bool)
ring_mask[fy1:fy2, fx1:fx2] = False
if int(ring_mask.sum()) < 10:
return False
# Predict the reverse-alpha output PER PIXEL over the glyph body -- exactly
# the (obs - a*logo)/(1-a) math the remover applies -- so a cleanly captured
# mark predicts back to the true background everywhere (no trip), while a mark
# fainter than the capture predicts a body far below the local ring. The
# per-pixel alpha (not a single peak value) keeps the prediction faithful
# across the glyph's anti-aliased alpha gradient.
obs = ring[fy1:fy2, fx1:fx2]
a = np.clip(amap, 0.0, 0.99)
logo = float(np.mean(self.config.alpha_logo_bgr))
predicted = (obs - a * logo) / (1.0 - a)
predicted_core = float(np.median(predicted[body_box]))
bg = float(np.median(ring[ring_mask]))
oversub = predicted_core < bg - _OVERSUB_DARK_MARGIN
if oversub:
logger.debug(
"%s reverse-alpha over-subtracts: predicted core=%.1f bg=%.1f (margin %.0f) -> footprint inpaint",
self.config.name,
predicted_core,
bg,
_OVERSUB_DARK_MARGIN,
)
return oversub
def _inpaint_footprint(
self, image: NDArray[Any], amap: NDArray[Any], region: tuple[int, int, int, int]
) -> NDArray[Any]:
"""Reconstruct the glyph footprint from its surroundings (used when
reverse-alpha would over-subtract into a dark pit). Inpaints the ORIGINAL
image over a dilated glyph mask, so the result never contains the darkened
reverse-alpha pixels."""
ax, ay, gw, gh = region
mask = np.zeros(image.shape[:2], np.uint8)
mask[ay : ay + gh, ax : ax + gw] = (amap > self.config.residual_alpha_floor).astype(np.uint8) * 255
mask = cv2.dilate(mask, np.ones((_OVERSUB_INPAINT_DILATE, _OVERSUB_INPAINT_DILATE), np.uint8))
return cv2.inpaint(image, mask, _OVERSUB_INPAINT_RADIUS, cv2.INPAINT_NS)
def remove_watermark_reverse_alpha(self, image: NDArray[Any], *, residual_inpaint: bool = True) -> NDArray[Any]:
"""Recover the original pixels by inverting the alpha blend, then clear the
residual outline with a thin inpaint over the glyph footprint.
Placement: fixed geometry AND the NCC-aligned placement are always tried and
the one leaving the least residual mark (lowest re-``detect`` confidence) is
kept -- the mark re-rasterizes a few px per image, so fixed geometry alone is
not reliable. A single capture cannot pixel-cancel the mark on every image, so
a deliberately THIN residual inpaint (``residual_*``) follows: reverse-alpha
has already recovered the true background under the mark, so the inpaint only
finishes the residual edges instead of smearing the whole footprint. Call only
when :meth:`reverse_alpha_available` and the mark is detected.
"""
c = self.config
# Normalize to 3-channel BGR (the reverse-alpha math assumes a 3-channel logo).
image = image_io.to_bgr(image)
# An image too small to hold the mark would make the geometry boxes degenerate
# and feed cv2.resize a ~1-px-tall target; skip cv2 entirely.
h, w = image.shape[:2]
if h < 32 or w < 64:
return image.copy()
maps = [m for m in (self._fixed_alpha_map(image), self._aligned_alpha_map(image)) if m is not None]
if not maps:
return image.copy()
best_out: NDArray[Any] | None = None
best_amap: NDArray[Any] | None = None
best_region: tuple[int, int, int, int] | None = None
best_residual = float("inf")
for amap, region in maps:
out = self._apply_reverse_alpha(image, amap, region)
residual = self.detect(out).confidence
if residual < best_residual:
best_residual, best_out, best_amap, best_region = residual, out, amap, region
if best_out is None or best_amap is None or best_region is None: # pragma: no cover - maps is non-empty
return image.copy()
# Over-subtraction guard: on a dark/mid-tone background the captured alpha can
# over-estimate the mark's opacity and reverse-alpha leaves a darker-than-
# background ghost. When the recovered glyph body sits far below the local
# ring, reconstruct the footprint from its surroundings instead of shipping the
# dark pit (the thin residual inpaint cannot fix a footprint-wide darkening).
if self._reverse_alpha_oversubtracts(image, best_amap, best_region):
return self._inpaint_footprint(image, best_amap, best_region)
if residual_inpaint:
# Embed the glyph-sized alpha block into a full-frame uint8 mask only for
# the inpaint (cv2.inpaint needs a mask matching best_out). One uint8
# full-frame array, built once, vs the old two full-frame float32 maps;
# byte-identical to thresholding the old full-frame float32 map (zero
# outside the block, so the dilate/inpaint see the same mask).
ax, ay, gw, gh = best_region
rm = np.zeros(best_out.shape[:2], np.uint8)
rm[ay : ay + gh, ax : ax + gw] = (best_amap > c.residual_alpha_floor).astype(np.uint8) * 255
kernel = np.ones((c.residual_dilate, c.residual_dilate), np.uint8)
rm = cv2.dilate(rm, kernel)
best_out = cv2.inpaint(best_out, rm, c.residual_inpaint_radius, cv2.INPAINT_NS)
return best_out
# ── Inpaint footprint (for the inpaint-fallback removal path) ────────
# Minimum glyph pixels for a template-free footprint. Below this the corner has
# no real wordmark (a few top-hat specks), so without ``force`` there is nothing
# to mask. A real strip covers hundreds of pixels.
_MIN_GLYPH_PIXELS = 20
def footprint_mask(
self, image: NDArray[Any], *, force: bool = False, dilate: int | None = None
) -> NDArray[Any] | None:
"""Full-frame uint8 mask (255 = mark) of the mark footprint, for the
inpaint-fallback removal path (LaMa / cv2), or None if no placement fits.
"""Full-frame uint8 mask (255 = mark) of the mark footprint, for the shared
fill removal path (cv2 / MI-GAN / LaMa), or None if no glyph is found.
``force`` is accepted for a uniform engine signature (the caller passes it to
every engine) but ignored here -- the text-mark footprint is always the
geometry-placed captured silhouette, present with or without a detection.
Template-FREE: localize the glyph blob with the top-hat :meth:`extract_mask`,
take its bounding box in the corner, and fill that box solid (plus a small
margin + dilation). Filling the enclosing rectangle -- not the sparse glyph
strokes -- is what makes it robust: the top-hat under-segments individual
strokes (which used to leave a "三包"-style residual ghost when the strokes
themselves were the mask), but the inpaint reconstructs the whole wordmark
rectangle from its surroundings, so a stroke missed by the top-hat is still
covered. This drops the fixed alpha-template dependency, so a re-rendered or
differently-localized mark (e.g. a non-Italian Samsung string) is still masked.
Uses the NCC-ALIGNED captured silhouette, NOT the per-image
:meth:`extract_mask` signature: the signature under-segments the glyphs, so
inpainting it leaves a residual ghost (corpus-validated 2026-07 -- Doubao
left a "三包" remnant). The mask is dilated to absorb alpha-alignment slop
(a scale/position mismatch at low detect confidence otherwise leaves a thin
residual ring); ``dilate`` defaults to a mark-relative margin.
The caller gates on detection -- this returns the geometric footprint
regardless, so a clean corner would be masked too.
With ``force`` and no glyph found, falls back to the whole geometry box (the
``--no-detect`` path). The caller gates on detection.
"""
image = image_io.to_bgr(image)
h, w = image.shape[:2]
if h < 32 or w < 64:
return None
placed = self._aligned_alpha_map(image) or self._fixed_alpha_map(image)
if placed is None:
loc = self.locate(image)
bx, by, bw, bh = loc.bbox
glyph = self.extract_mask(image, loc) # box-sized, 255 = glyph
ys, xs = np.where(glyph > 0)
if xs.size >= self._MIN_GLYPH_PIXELS:
pad = max(4, int(0.10 * bh))
rx1 = max(0, bx + int(xs.min()) - pad)
rx2 = min(w, bx + int(xs.max()) + 1 + pad)
ry1 = max(0, by + int(ys.min()) - pad)
ry2 = min(h, by + int(ys.max()) + 1 + pad)
elif force:
rx1, ry1, rx2, ry2 = bx, by, min(w, bx + bw), min(h, by + bh)
else:
return None
block, (ax, ay, gw, gh) = placed
sil = (block > self.config.residual_alpha_floor).astype(np.uint8) * 255
if int((sil > 0).sum()) == 0:
if rx1 >= rx2 or ry1 >= ry2:
return None
mask = np.zeros((h, w), np.uint8)
ch, cw = min(gh, h - ay), min(gw, w - ax)
mask[ay : ay + ch, ax : ax + cw] = sil[:ch, :cw]
d = dilate if dilate is not None else max(9, int(0.05 * gw))
if d > 0:
mask = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * d + 1, 2 * d + 1)))
return mask
# Rectangular footprint + dilation is exactly region_eraser.boxes_to_mask (the
# same primitive the shared fill uses); reuse it instead of re-inlining the
# zeros/fill/MORPH_ELLIPSE-dilate here.
from remove_ai_watermarks import region_eraser
d = dilate if dilate is not None else max(3, int(0.02 * bw))
return region_eraser.boxes_to_mask((h, w), [(rx1, ry1, rx2 - rx1, ry2 - ry1)], dilate=d)
+127
View File
@@ -0,0 +1,127 @@
"""High-level convenience API: clean an image (or array) in one call.
The low-level building blocks live in ``watermark_registry`` (localize -> fill) and
``image_io`` (Unicode-safe, alpha-preserving IO). This module ties them into the two
calls a caller usually wants, so a library user does not have to decode images, wire
up metadata provenance, or preserve the alpha channel by hand:
import remove_ai_watermarks as raiw
raiw.remove_visible("in.png", "out.png") # path -> file, provenance auto
result, removed = raiw.remove_visible(bgr_array) # array -> array
raiw.remove_visible("shot.png", "out.png", sensitivity="assume_ai")
raiw.visible_provenance("in.png") # -> frozenset({"gemini"})
Imports stay lazy (inside the functions), so ``import remove_ai_watermarks`` is cheap.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from numpy.typing import NDArray
from remove_ai_watermarks.watermark_registry import Backend, Sensitivity
def visible_provenance(source: str | Path) -> frozenset[str]:
"""Vendor keys that the file's local metadata confirms, the evidence that drives
the ``auto`` sensitivity (relaxing a corroborated mark's detection trust gate).
Mapping: a Google/Gemini C2PA issuer -> ``"gemini"``; a China-AIGC (TC260) label
-> ``"doubao"``/``"jimeng"``; a ``samsung_genai`` marker -> ``"samsung"``.
Best-effort: any read error yields an empty set (no relaxation). Metadata-only, so
it never loads cv2/torch.
"""
import contextlib
keys: set[str] = set()
with contextlib.suppress(Exception):
from remove_ai_watermarks import identify, metadata
rep = identify.identify(Path(source), check_visible=False, check_invisible=False)
platform = (rep.platform or "").lower()
if "google" in platform or "gemini" in platform:
keys.add("gemini")
if metadata.aigc_label(Path(source)):
keys |= {"doubao", "jimeng"}
if metadata.samsung_genai(Path(source)):
keys.add("samsung")
return frozenset(keys)
def remove_visible(
source: str | Path | NDArray[Any],
output: str | Path | None = None,
*,
sensitivity: Sensitivity = "auto",
backend: Backend = "auto",
strip_metadata: bool = True,
write_noop: bool = True,
) -> tuple[NDArray[Any], list[str]]:
"""Remove every detected known visible AI mark (Gemini sparkle, Doubao/Jimeng/
Samsung text, the Jimeng pill) via localize -> fill, returning ``(result_bgr,
[labels removed])``.
``source`` is a file path OR a BGR ndarray. For a PATH, metadata provenance is read
automatically (so ``sensitivity="auto"`` recovers a moved/faint mark whenever the
file still carries its provenance) and the alpha channel is preserved on write; for
an ARRAY there is no metadata to read and no separate alpha plane. When ``output``
is given the cleaned image is written there (alpha rejoined for a path source); the
array is always returned as well, so an empty ``removed`` list tells a caller nothing
known was found (e.g. route to the diffusion ``all`` path or ``erase``).
``sensitivity`` (``auto``/``strict``/``assume_ai``) and ``backend``
(``auto``/``cv2``/``migan``/``lama``) are the same knobs as the CLI. Pass
``sensitivity="assume_ai"`` for a metadata-stripped screenshot the caller knows is
AI-generated (best recall, at the cost of a small near-lossless fill on a clean
corner if the guess is wrong).
``strip_metadata`` (default True, matching the CLI ``visible --strip-metadata``)
also strips AI provenance metadata (C2PA/EXIF/XMP/IPTC) from the written output via
the lossless :func:`metadata.remove_ai_metadata`, so a library call does exactly
what the CLI does. Only applies when ``output`` is given.
``write_noop`` (default True) controls whether ``output`` is written when NOTHING was
removed: True writes a clean passthrough copy (an idempotent clean); False leaves the
output path untouched, so a caller that treats "no mark" as "produce nothing" (the CLI
``visible`` no-mark contract) does not clobber a pre-existing file at that path.
"""
from remove_ai_watermarks import image_io, watermark_registry
alpha: NDArray[Any] | None = None
provenance: frozenset[str] = frozenset()
if isinstance(source, (str, Path)):
path = Path(source)
bgr, alpha = image_io.read_bgr_and_alpha(path)
if bgr is None:
raise ValueError(f"Could not read image: {source}")
provenance = visible_provenance(path)
else:
bgr = source
result, removed = watermark_registry.remove_auto_marks(
bgr, sensitivity=sensitivity, provenance=provenance, backend=backend
)
if output is not None and (removed or write_noop):
out_path = Path(output)
out_path.parent.mkdir(parents=True, exist_ok=True)
same_format = isinstance(source, (str, Path)) and Path(source).suffix.lower() == out_path.suffix.lower()
if not removed and same_format:
# Nothing was removed: copy the ORIGINAL bytes verbatim instead of a lossy
# re-encode of its decode, so the pixels stay bit-identical (the metadata
# strip below is lossless, so it does not disturb them either). Skip the copy
# for an in-place call (output == source): the bytes are already there, and
# shutil.copyfile would raise SameFileError.
if Path(source).resolve() != out_path.resolve(): # type: ignore[arg-type]
import shutil
shutil.copyfile(source, out_path) # type: ignore[arg-type]
else:
image_io.write_bgr_with_alpha(out_path, result, alpha)
if strip_metadata:
from remove_ai_watermarks import metadata
metadata.remove_ai_metadata(out_path, out_path)
return result, removed
+185 -225
View File
@@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Any, Literal, NoReturn
import click
from remove_ai_watermarks import __version__, watermark_registry
from remove_ai_watermarks import __version__, image_io, watermark_registry
from remove_ai_watermarks.noai.constants import SUPPORTED_FORMATS
from remove_ai_watermarks.noai.watermark_profiles import (
resolve_strength,
@@ -31,7 +31,7 @@ if TYPE_CHECKING:
from numpy.typing import NDArray
# --- plain-text output layer (replaces rich: no colors, no markup, no boxes) ---
# ── plain-text output layer (replaces rich: no colors, no markup, no boxes) ──
class _Table:
@@ -136,8 +136,6 @@ def _validate_image(path: Path) -> Path:
return path
_ALPHA_FORMATS = {".png", ".webp"}
# Shared option decorator for commands that run the invisible-watermark pipeline.
# Both cmd_invisible and cmd_all expose this flag; defining it once avoids
# copy-paste drift.
@@ -293,15 +291,28 @@ _force_option = click.option(
)
_visible_method_option = click.option(
"--method",
"removal_method",
type=click.Choice(["auto", "reverse-alpha", "inpaint"]),
_visible_backend_option = click.option(
"--backend",
"backend",
type=click.Choice(["auto", "cv2", "migan", "lama"]),
default="auto",
help="Visible-mark removal method. auto: reverse-alpha for capture marks (exact "
"pixels, lighter), inpaint for the capture-less pill. reverse-alpha recovers "
"pixels from a captured alpha map; inpaint erases the footprint (MI-GAN with the "
"'migan' extra, else cv2).",
help="Fill backend for visible-mark removal (localize -> fill). auto: best available, "
"LaMa > MI-GAN > cv2 (a learned backend needs the 'lama' or 'migan' extra; else cv2, "
"with a warning). cv2: classical inpaint (no deps, smears texture). migan: MI-GAN ONNX "
"(light, ~1 GB, the memory-tight pick). lama: big-LaMa ONNX (best quality, ~4.7 GB).",
)
_visible_sensitivity_option = click.option(
"--sensitivity",
"sensitivity",
type=click.Choice(["auto", "strict", "assume-ai"]),
default="auto",
help="How hard to trust a borderline mark. auto: relax a mark only when metadata "
"or a same-product sibling mark corroborates it (safe; clean images untouched). "
"strict: high-precision visual gate only, never relaxed. assume-ai: treat the "
"image as AI and relax every mark (best recall on metadata-stripped screenshots, "
"at the cost of a small fill on some clean corners).",
)
@@ -339,57 +350,59 @@ def _warn_if_esrgan_unavailable(upscaler: str) -> None:
console.print(" Note: --upscaler esrgan needs the 'esrgan' extra; falling back to Lanczos.")
def _aigc_metadata_present(path: Path) -> bool:
"""True when the file carries a China-AIGC (TC260) metadata label. Feeds the
weak-detector 'AI生成' pill gate (``remove_auto_marks`` ``_keep_pill``): metadata
confirms Jimeng-class provenance but not pill presence, so the metadata-only arm
removes the pill ONLY on a flat, safe-to-inpaint footprint. The reliable
bottom-right wordmark is the other, unrestricted confirmation arm (and it survives
a metadata-STRIPPED upload)."""
with contextlib.suppress(Exception):
from remove_ai_watermarks import metadata
def _visible_provenance(path: Path | None) -> frozenset[str]:
"""Vendor keys local metadata confirms, the EVIDENCE that drives ``auto``
sensitivity. Thin wrapper over the public :func:`api.visible_provenance` (one
implementation for the CLI and the library), with a None-path guard."""
if path is None:
return frozenset()
from remove_ai_watermarks.api import visible_provenance
return bool(metadata.aigc_label(path))
return False
return visible_provenance(path)
def _remove_visible_auto(
image: NDArray[Any],
*,
source_path: Path | None = None,
removal_method: str = "auto",
inpaint: bool = True,
inpaint_method: str = "ns",
inpaint_strength: float = 0.85,
backend: str = "auto",
sensitivity: str = "auto",
) -> tuple[NDArray[Any], str | None]:
"""Remove the strongest auto-detected visible mark via the registry.
"""Remove every auto-detected visible mark via the registry (localize -> fill).
Routes the ``all``/``batch`` visible step through the same registry path the
standalone ``visible`` command uses, so EVERY registered mark is handled (the
Gemini sparkle AND the Doubao/Jimeng/Samsung text marks), not just the sparkle.
Returns ``(result, label-or-None)``; when no ``in_auto`` mark fires the image is
returned unchanged with ``None``. ``removal_method`` selects reverse-alpha vs the
inpaint fallback (see ``KnownMark.remove``); ``inpaint*`` tune the Gemini
edge-residual cleanup only (the text engines ignore them).
"""
returned unchanged with ``None``. ``backend`` selects the shared fill; ``sensitivity``
controls how hard a borderline mark is trusted (auto reads metadata provenance)."""
from remove_ai_watermarks import watermark_registry
rmethod: watermark_registry.RemovalMethod = removal_method # type: ignore[assignment]
method: Literal["telea", "ns"] = "ns" if inpaint_method == "ns" else "telea"
pill_md = _aigc_metadata_present(source_path) if source_path is not None else False
result, removed = watermark_registry.remove_auto_marks(
image,
pill_metadata=pill_md,
method=rmethod,
inpaint_method=method,
inpaint=inpaint,
inpaint_strength=inpaint_strength,
)
bk: watermark_registry.Backend = backend # type: ignore[assignment]
sens = _parse_sensitivity(sensitivity)
provenance = _visible_provenance(source_path)
try:
result, removed = watermark_registry.remove_auto_marks(
image, sensitivity=sens, provenance=provenance, backend=bk
)
except RuntimeError as e: # e.g. a selected migan/lama backend whose extra is absent
console.print(f" Error: {e}")
raise SystemExit(1) from e
if not removed:
return image, None
return result, ", ".join(removed)
def _parse_sensitivity(value: str) -> watermark_registry.Sensitivity:
"""Map the CLI ``--sensitivity`` choice (kebab ``assume-ai``) to the registry
literal (``assume_ai``); ``auto``/``strict`` pass through unchanged."""
if value == "assume-ai":
return "assume_ai"
if value == "strict":
return "strict"
return "auto"
# Exit code for the standalone ``visible`` command when no visible mark was
# removed -- distinct from success (0) and a hard error (1) so a wrapping
# service can tell "nothing to do here" apart and surface guidance instead of
@@ -397,7 +410,7 @@ def _remove_visible_auto(
EXIT_NO_VISIBLE_MARK = 2
def _no_visible_mark_exit(source: Path) -> NoReturn:
def _no_visible_mark_exit(source: Path, *, sensitivity: str = "auto") -> NoReturn:
"""Explain why no visible watermark was removed, then exit non-zero.
The visible registry handles only known visual marks (the Gemini sparkle and
@@ -408,7 +421,10 @@ def _no_visible_mark_exit(source: Path) -> NoReturn:
watermarked image -- the recurring "it didn't work" report. Instead, run a
cheap metadata-only :func:`identify`, tell the user what the image actually
carries and which command removes it, and exit
:data:`EXIT_NO_VISIBLE_MARK`.
:data:`EXIT_NO_VISIBLE_MARK`. When the conservative detector found nothing and
the user has NOT already asked for the aggressive pass, point them at
``--sensitivity assume-ai`` (a faint or moved visible mark may be sitting just
below the default gate).
"""
from remove_ai_watermarks.identify import identify
@@ -430,6 +446,12 @@ def _no_visible_mark_exit(source: Path) -> NoReturn:
" If instead there is a logo or object to remove, target it with the region eraser:\n"
f" remove-ai-watermarks erase {source.name} --region x,y,w,h"
)
if sensitivity != "assume_ai":
console.print(
" If you know this image is AI-generated, retry the visible pass with\n"
f" remove-ai-watermarks visible {source.name} --sensitivity assume-ai\n"
" which relaxes detection to catch a faint or moved mark the default gate skips."
)
raise SystemExit(EXIT_NO_VISIBLE_MARK)
@@ -482,56 +504,7 @@ def _should_skip_invisible_scrub(force: bool, image_path: Path) -> bool:
return not has_invisible_target(image_path)
def _read_bgr_and_alpha(path: Path) -> tuple[NDArray[Any] | None, NDArray[Any] | None]:
"""Read an image preserving its alpha channel separately.
Returns ``(bgr, alpha)`` where ``alpha`` is a single-channel ndarray when the
source has transparency, else ``None``. Greyscale inputs are promoted to BGR.
Returns ``(None, None)`` if the image cannot be decoded.
"""
import cv2
from remove_ai_watermarks import image_io
image = image_io.imread(path, cv2.IMREAD_UNCHANGED)
if image is None:
return None, None
if image.ndim == 2:
return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR), None
if image.shape[2] == 4:
return image[:, :, :3].copy(), image[:, :, 3].copy()
return image, None
def _write_bgr_with_alpha(
path: Path,
bgr: NDArray[Any],
alpha: NDArray[Any] | None,
) -> None:
"""Write BGR (with optional alpha) to ``path``.
When ``alpha`` is provided and the output extension supports it, the original
alpha plane is rejoined unchanged. The watermark region is NOT made
transparent: reverse-alpha (and inpaint) recover real pixels there, so
zeroing alpha would punch a transparent hole that renders as a white box on
any non-transparent viewer (issue #30). Preserving the input alpha keeps
genuinely transparent backgrounds intact without inventing new holes.
"""
import numpy as np
from remove_ai_watermarks import image_io
if alpha is None or path.suffix.lower() not in _ALPHA_FORMATS:
image_io.imwrite(path, bgr)
return
bgra = np.dstack([bgr, alpha])
image_io.imwrite(path, bgra)
# -- Main group -------------------------------------------------------
# ── Main group ──
@click.group(invoke_without_command=True)
@click.version_option(__version__, prog_name="remove-ai-watermarks")
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose logging.")
@@ -551,50 +524,41 @@ def main(ctx: click.Context, verbose: bool) -> None:
click.echo(ctx.get_help())
# -- Visible (Gemini) watermark removal -------------------------------
# ── Visible (Gemini) watermark removal ──
@main.command("visible")
@click.argument("source", type=click.Path(exists=True, path_type=Path))
@click.option(
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
)
@click.option("--inpaint/--no-inpaint", default=True, help="Apply inpainting cleanup after removal.")
@click.option(
"--inpaint-method", type=click.Choice(["ns", "telea", "gaussian"]), default="ns", help="Inpainting method."
)
@click.option("--inpaint-strength", type=float, default=0.85, help="Inpainting blend strength (0.0-1.0).")
@click.option("--detect/--no-detect", default=True, help="Detect watermark before removal.")
@click.option(
"--mark",
type=click.Choice(["auto", *watermark_registry.mark_keys()]),
default="auto",
help="Which known visible mark to target (auto picks the strongest detected). "
"Removal method is chosen by --method (default auto).",
help="Which known visible mark to target (auto picks every detected mark). "
"The fill backend is chosen by --backend (default auto).",
)
@_visible_method_option
@_visible_backend_option
@_visible_sensitivity_option
@click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.")
@click.pass_context
def cmd_visible(
ctx: click.Context,
source: Path,
output: Path | None,
inpaint: bool,
inpaint_method: Literal["ns", "telea", "gaussian"],
inpaint_strength: float,
detect: bool,
mark: str,
removal_method: str,
backend: str,
sensitivity: str,
strip_metadata: bool,
) -> None:
"""Remove a known visible AI watermark from an image.
Finds a known mark in its usual place (Gemini sparkle / Doubao text) via the
watermark registry and removes it. Default ``--method auto`` recovers the true
pixels by exact reverse-alpha for the capture marks, and inpaints only the
capture-less "AI生成" pill (MI-GAN with the ``migan`` extra, else cv2).
``--mark auto`` picks the strongest detected mark. For arbitrary logos/objects,
use ``erase``.
Finds a known mark in its usual place (Gemini sparkle / Doubao-Jimeng-Samsung
text) via the watermark registry and removes it by LOCALIZING the mark to a mask
and filling that mask with the chosen ``--backend`` (auto: best available, LaMa >
MI-GAN > cv2). ``--mark auto`` removes every detected mark in one
pass. For arbitrary logos/objects, use ``erase``.
"""
from remove_ai_watermarks import watermark_registry as registry
@@ -604,67 +568,79 @@ def cmd_visible(
if output is None:
output = source.with_stem(source.stem + "_clean")
# Load image (preserving any alpha channel separately)
image, alpha = _read_bgr_and_alpha(source)
bk: registry.Backend = backend # type: ignore[assignment]
sens = _parse_sensitivity(sensitivity)
resolved_backend = registry.resolve_backend(bk)
if resolved_backend == "cv2" and not registry.inpaint_model_available():
console.print(" Note: using cv2 fill (install the 'migan' extra for a lightweight ONNX model).")
# ``auto`` removes EVERY detected in_auto mark in one pass (a Jimeng-basic image
# carries the top-left pill AND the bottom-right wordmark). Delegate the whole
# read -> provenance -> localize/fill -> write -> metadata-strip to the library
# entry point, so the CLI and the library go through ONE path (no drift).
if mark == "auto" and detect:
from remove_ai_watermarks import api
t0 = time.monotonic()
try:
with console.status("Detecting & removing visible marks..."):
result, removed = api.remove_visible(
str(source),
str(output),
sensitivity=sens,
backend=bk,
strip_metadata=strip_metadata,
write_noop=False,
)
except RuntimeError as e: # e.g. a selected migan/lama backend whose extra is absent
console.print(f" Error: {e}")
raise SystemExit(1) from e
elapsed = time.monotonic() - t0
h, w = result.shape[:2]
console.print(f" Input: {source.name} ({w}x{h})")
if not removed:
# write_noop=False means nothing was written, so a pre-existing file at the
# output path is left intact (the no-mark contract writes nothing).
console.print(" No known visible mark detected (gemini / doubao / jimeng / jimeng-pill / samsung).")
_no_visible_mark_exit(source, sensitivity=sens)
console.print(f" Removed: {', '.join(removed)}")
size_kb = output.stat().st_size / 1024
console.print(f" Saved: {output} ({size_kb:.0f} KB, {elapsed:.2f}s)")
return
# Explicit single mark (or --no-detect): needs the decoded array + the per-mark gate,
# so it keeps its own read/remove/write (still through the shared io + registry).
image, alpha = image_io.read_bgr_and_alpha(source)
if image is None:
console.print(f"Error: Failed to read image: {source}")
raise SystemExit(1)
h, w = image.shape[:2]
console.print(f" Input: {source.name} ({w}x{h})")
provenance = _visible_provenance(source)
target = "gemini" if mark == "auto" else mark # --no-detect auto: gemini fallback
chosen = registry.get_mark(target)
# A single explicit mark has no cross-mark pass (no sibling corroboration), so use the
# canonical arbiter policy with an empty strict-sibling set instead of re-deriving it
# inline (keeps this in lockstep with `decide`).
prov = registry.resolve_relax(chosen.key, sensitivity=sens, provenance=provenance, strict_keys=set())
det = chosen.detect(image, provenance=prov)
if detect and not det.detected:
console.print(f" {chosen.label} not detected (conf {det.confidence:.2f}). Use --no-detect to force.")
_no_visible_mark_exit(source, sensitivity=sens)
if det.detected:
console.print(f" {chosen.label} detected ({chosen.location}, conf {det.confidence:.2f})")
t0 = time.monotonic()
try:
with console.status(f"Removing {chosen.label}... ({resolved_backend})"):
result, _ = chosen.remove(image, backend=bk, provenance=prov, force=not detect)
except RuntimeError as e: # e.g. a selected migan/lama backend whose extra is absent
console.print(f" Error: {e}")
raise SystemExit(1) from e
elapsed = time.monotonic() - t0
method: Literal["telea", "ns"] = "ns" if inpaint_method == "ns" else "telea"
# ``auto`` removes EVERY detected in_auto mark in one pass (a Jimeng-basic image
# carries the top-left pill AND the bottom-right wordmark); an explicit
# ``--mark <key>`` targets that one (the user asserts its presence).
if mark == "auto" and detect:
t0 = time.monotonic()
with console.status("Detecting & removing visible marks..."):
result, removed = registry.remove_auto_marks(
image,
pill_metadata=_aigc_metadata_present(source),
method=removal_method, # type: ignore[arg-type]
inpaint_method=method,
inpaint=inpaint,
inpaint_strength=inpaint_strength,
)
elapsed = time.monotonic() - t0
if not removed:
console.print(" No known visible mark detected (gemini / doubao / jimeng / jimeng-pill / samsung).")
_no_visible_mark_exit(source)
console.print(f" Removed: {', '.join(removed)}")
else:
target = "gemini" if mark == "auto" else mark # --no-detect auto: gemini fallback
chosen = registry.get_mark(target)
det = chosen.detect(image)
if detect and not det.detected:
console.print(f" {chosen.label} not detected (conf {det.confidence:.2f}). Use --no-detect to force.")
_no_visible_mark_exit(source)
if det.detected:
console.print(f" {chosen.label} detected ({chosen.location}, conf {det.confidence:.2f})")
resolved = registry.resolve_removal_method(removal_method, chosen.has_capture) # type: ignore[arg-type]
if resolved == "inpaint" and not registry.inpaint_model_available():
console.print(
" Note: --method inpaint using cv2 (install the 'migan' extra for a lightweight ONNX model)."
)
t0 = time.monotonic()
with console.status(f"Removing {chosen.label}... ({resolved})"):
result, _ = chosen.remove(
image,
method=removal_method, # type: ignore[arg-type]
inpaint_method=method,
inpaint=inpaint,
inpaint_strength=inpaint_strength,
force=not detect,
)
elapsed = time.monotonic() - t0
# Save (rejoins the original alpha plane unchanged)
# Save (rejoins the original alpha plane unchanged) + strip metadata.
output.parent.mkdir(parents=True, exist_ok=True)
_write_bgr_with_alpha(output, result, alpha)
# Strip metadata
image_io.write_bgr_with_alpha(output, result, alpha)
if strip_metadata:
try:
from remove_ai_watermarks.metadata import remove_ai_metadata
@@ -678,9 +654,7 @@ def cmd_visible(
console.print(f" Saved: {output} ({size_kb:.0f} KB, {elapsed:.2f}s)")
# -- Universal region eraser -----------------------------------------
# ── Universal region eraser ──
def _parse_region(spec: str) -> tuple[int, int, int, int]:
"""Parse an ``x,y,w,h`` region string into a 4-int tuple."""
parts = spec.replace(" ", "").split(",")
@@ -725,7 +699,7 @@ def cmd_erase(
"""Erase arbitrary region(s) from an image via inpainting.
Universal and position-agnostic: removes any logo / watermark / object inside
the boxes you pass, regardless of colour or location. Runs on CPU. Use this
the boxes you pass, regardless of color or location. Runs on CPU. Use this
for marks the dedicated ``visible`` engines (Gemini, Doubao) do not cover.
"""
from remove_ai_watermarks.region_eraser import erase
@@ -737,7 +711,7 @@ def cmd_erase(
boxes = [_parse_region(r) for r in regions]
image, alpha = _read_bgr_and_alpha(source)
image, alpha = image_io.read_bgr_and_alpha(source)
if image is None:
console.print(f"Error: Failed to read image: {source}")
raise SystemExit(1)
@@ -755,7 +729,7 @@ def cmd_erase(
elapsed = time.monotonic() - t0
output.parent.mkdir(parents=True, exist_ok=True)
_write_bgr_with_alpha(output, result, alpha)
image_io.write_bgr_with_alpha(output, result, alpha)
if strip_metadata:
try:
@@ -770,9 +744,7 @@ def cmd_erase(
console.print(f" Erased {len(boxes)} region(s) -> {output} ({size_kb:.0f} KB, {elapsed:.2f}s)")
# -- Invisible watermark removal -------------------------------------
# ── Invisible watermark removal ──
@main.command("invisible")
@click.argument("source", type=click.Path(exists=True, path_type=Path))
@click.option(
@@ -908,9 +880,7 @@ def cmd_invisible(
console.print(f"\n Saved: {result_path} ({size_kb:.0f} KB, {elapsed:.1f}s)")
# -- Metadata operations ---------------------------------------------
# ── Metadata operations ──
@main.command("metadata")
@click.argument("source", type=click.Path(exists=True, path_type=Path))
@click.option("--check", is_flag=True, help="Check for AI metadata (don't modify).")
@@ -967,9 +937,7 @@ def cmd_metadata(
console.print(f" AI metadata stripped -> {out}")
# -- Provenance identification ---------------------------------------
# ── Provenance identification ──
@main.command("identify")
@click.argument("source", type=click.Path(exists=True, path_type=Path))
@click.option(
@@ -1038,19 +1006,14 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
console.print(f" - {c}")
# -- Combined "all" mode ----------------------------------------------
# ── Combined "all" mode ──
@main.command("all")
@click.argument("source", type=click.Path(exists=True, path_type=Path))
@click.option(
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
)
@click.option("--inpaint/--no-inpaint", default=True, help="Apply inpainting cleanup after visible removal.")
@click.option(
"--inpaint-method", type=click.Choice(["ns", "telea", "gaussian"]), default="ns", help="Inpainting method."
)
@_visible_method_option
@_visible_backend_option
@_visible_sensitivity_option
@_strength_option
@click.option("--steps", type=int, default=50, help="Number of denoising steps for invisible removal.")
@_pipeline_option
@@ -1086,9 +1049,8 @@ def cmd_all(
ctx: click.Context,
source: Path,
output: Path | None,
inpaint: bool,
inpaint_method: Literal["ns", "telea", "gaussian"],
removal_method: str,
backend: str,
sensitivity: str,
strength: float | None,
steps: int,
pipeline: str,
@@ -1113,7 +1075,7 @@ def cmd_all(
"""Remove ALL watermarks: visible + invisible + metadata.
Runs the full pipeline in order:
1. Visible watermark removal (Gemini sparkle, reverse alpha blending)
1. Visible watermark removal (Gemini sparkle / text marks, localize -> fill)
2. Invisible watermark removal (SynthID etc., diffusion regeneration)
3. AI metadata stripping (EXIF, PNG text, C2PA)
@@ -1147,9 +1109,9 @@ def cmd_all(
os.close(tmp_fd)
# -- Step 1: Visible watermark --------------------------------
# ── Step 1: Visible watermark ──
console.print("\n 1) Visible watermark removal")
image, alpha = _read_bgr_and_alpha(source)
image, alpha = image_io.read_bgr_and_alpha(source)
if image is None:
console.print(f"Error: Failed to read image: {source}")
raise SystemExit(1)
@@ -1159,7 +1121,7 @@ def cmd_all(
with console.status("Removing visible watermark..."):
result, removed_label = _remove_visible_auto(
image, source_path=source, removal_method=removal_method, inpaint=inpaint, inpaint_method=inpaint_method
image, source_path=source, backend=backend, sensitivity=sensitivity
)
if removed_label is not None:
console.print(f" Visible watermark removed ({removed_label})")
@@ -1167,9 +1129,9 @@ def cmd_all(
console.print(" Skipped (no visible watermark detected)")
# Save to temp file for invisible engine input (preserve alpha if present)
_write_bgr_with_alpha(tmp_path, result, alpha)
image_io.write_bgr_with_alpha(tmp_path, result, alpha)
# -- Step 2: Invisible watermark ------------------------------
# ── Step 2: Invisible watermark ──
console.print("\n 2) Invisible watermark removal")
from remove_ai_watermarks.invisible_engine import is_available as invisible_available
@@ -1233,7 +1195,7 @@ def cmd_all(
)
console.print(" Invisible watermark removed")
# -- Step 3: Metadata -----------------------------------------
# ── Step 3: Metadata ──
console.print("\n 3) AI metadata stripping")
try:
from remove_ai_watermarks.metadata import remove_ai_metadata
@@ -1243,23 +1205,23 @@ def cmd_all(
except Exception as e:
console.print(f" Warning: Metadata strip failed: {e}")
# -- Write final result ----------------------------------------
# ── Write final result ──
# The invisible step (and downstream cv2.IMREAD_COLOR paths) drops alpha,
# so re-attach the original alpha plane unchanged when writing the final
# output for transparent formats.
output.parent.mkdir(parents=True, exist_ok=True)
final_bgr, _ = _read_bgr_and_alpha(tmp_path)
final_bgr, _ = image_io.read_bgr_and_alpha(tmp_path)
if final_bgr is None:
console.print(f"Error: Failed to read intermediate file: {tmp_path}")
raise SystemExit(1)
_write_bgr_with_alpha(output, final_bgr, alpha)
image_io.write_bgr_with_alpha(output, final_bgr, alpha)
finally:
# Clean up temp file if it still exists
if tmp_path.exists():
tmp_path.unlink()
# -- Done -----------------------------------------------------
# ── Done ──
elapsed = time.monotonic() - t0
size_kb = output.stat().st_size / 1024
console.print(f"\n Done: {output} ({size_kb:.0f} KB, {elapsed:.1f}s total)")
@@ -1283,15 +1245,12 @@ def cmd_all(
raise SystemExit(1)
# -- Batch command ----------------------------------------------------
# ── Batch command ──
def _process_batch_image(
ctx: click.Context,
img_path: Path,
out_path: Path,
mode: str,
inpaint: bool,
strength: float | None,
steps: int,
pipeline: str,
@@ -1299,7 +1258,8 @@ def _process_batch_image(
seed: int | None,
hf_token: str | None,
humanize: float,
removal_method: str = "auto",
backend: str = "auto",
sensitivity: str = "auto",
unsharp: float = 0.0,
max_resolution: int = 0,
min_resolution: int = 1024,
@@ -1328,13 +1288,13 @@ def _process_batch_image(
# stale out_path from a previous run must not be re-processed as if it were
# the input. (The invisible step below reads out_path for `all` -- that chain
# is within a single run.)
image, alpha = _read_bgr_and_alpha(img_path)
image, alpha = image_io.read_bgr_and_alpha(img_path)
if image is None:
raise ValueError("Failed to read image")
result, _ = _remove_visible_auto(image, source_path=img_path, removal_method=removal_method, inpaint=inpaint)
result, _ = _remove_visible_auto(image, source_path=img_path, backend=backend, sensitivity=sensitivity)
_write_bgr_with_alpha(out_path, result, alpha)
image_io.write_bgr_with_alpha(out_path, result, alpha)
saved_alpha = alpha
if mode in ("invisible", "all"):
@@ -1385,9 +1345,9 @@ def _process_batch_image(
# No invisible target and the visible/all pass did not write out_path
# (invisible mode): copy the input through so the output dir is complete
# with the pixels deliberately left intact.
src_bgr, src_alpha = _read_bgr_and_alpha(img_path)
src_bgr, src_alpha = image_io.read_bgr_and_alpha(img_path)
if src_bgr is not None:
_write_bgr_with_alpha(out_path, src_bgr, src_alpha)
image_io.write_bgr_with_alpha(out_path, src_bgr, src_alpha)
if mode in ("metadata", "all"):
from remove_ai_watermarks.metadata import remove_ai_metadata
@@ -1397,9 +1357,9 @@ def _process_batch_image(
# In "all" mode, the invisible step (color-only OpenCV paths) drops alpha,
# so re-attach the cached alpha when the input had transparency.
if mode == "all" and saved_alpha is not None:
final_bgr, _ = _read_bgr_and_alpha(out_path)
final_bgr, _ = image_io.read_bgr_and_alpha(out_path)
if final_bgr is not None:
_write_bgr_with_alpha(out_path, final_bgr, saved_alpha)
image_io.write_bgr_with_alpha(out_path, final_bgr, saved_alpha)
@main.command("batch")
@@ -1416,8 +1376,8 @@ def _process_batch_image(
)
@_strength_option
@click.option("--steps", type=int, default=50, help="Number of denoising steps (invisible mode).")
@click.option("--inpaint/--no-inpaint", default=True, help="Apply inpainting (visible mode).")
@_visible_method_option
@_visible_backend_option
@_visible_sensitivity_option
@click.option(
"--humanize", type=float, default=0.0, help="Analog Humanizer film grain intensity (0 = off, typical: 2.0-6.0)."
)
@@ -1458,8 +1418,8 @@ def cmd_batch(
device: str,
seed: int | None,
hf_token: str | None,
inpaint: bool,
removal_method: str,
backend: str,
sensitivity: str,
humanize: float,
unsharp: float,
max_resolution: int,
@@ -1518,7 +1478,6 @@ def cmd_batch(
img_path=img_path,
out_path=out_path,
mode=mode,
inpaint=inpaint,
strength=strength,
steps=steps,
pipeline=pipeline,
@@ -1526,7 +1485,8 @@ def cmd_batch(
seed=seed,
hf_token=hf_token,
humanize=humanize,
removal_method=removal_method,
backend=backend,
sensitivity=sensitivity,
unsharp=unsharp,
max_resolution=max_resolution,
min_resolution=min_resolution,
+14 -27
View File
@@ -1,16 +1,17 @@
"""Doubao visible watermark removal engine.
"""Doubao visible watermark detector/localizer.
Doubao (ByteDance) stamps every generated image with a visible "豆包AI生成"
(Doubao AI generated) text strip in the bottom-right corner -- the explicit AIGC
label mandated by China's TC260 standard, a near-white semi-transparent overlay.
Removal is **reverse-alpha blending** against a captured alpha map
(``original = (wm - a*logo)/(1-a)``), always NCC-aligned to the actual mark plus a
thin residual inpaint over the glyph footprint. This is one of the three text-mark
engines that share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`;
this module supplies only Doubao's tuned :class:`TextMarkConfig` (bottom-right corner,
``assets/doubao_alpha.png`` rebuilt by ``scripts/visible_alpha_solve.py``). Arbitrary-
region inpainting still lives in ``region_eraser`` / the ``erase`` command.
Detection matches the bundled glyph silhouette against the corner candidate; removal
is the shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds
``region_eraser``), NOT reverse-alpha. This is one of the three text-mark engines that
share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module
supplies only Doubao's tuned :class:`TextMarkConfig` (bottom-right corner,
``assets/doubao_alpha.png`` -- the detection silhouette, rebuilt by
``scripts/visible_alpha_solve.py``). Arbitrary-region inpainting still lives in
``region_eraser`` / the ``erase`` command.
"""
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
@@ -48,19 +49,12 @@ TOPHAT_DELTA = 12 # glyph must exceed the local background by this many levels
DETECT_MIN_COVERAGE = 0.04
DETECT_NCC_THRESHOLD = 0.4
# Reverse-alpha geometry, emitted by scripts/visible_alpha_solve.py at the captured
# width. Removal always tries fixed AND NCC-aligned placement and keeps the lower
# residual, then a thin footprint inpaint clears the leftover edges.
# Detection-silhouette geometry, emitted by scripts/visible_alpha_solve.py at the
# captured width. Sizes the glyph silhouette for the TM_CCOEFF_NORMED detection match
# (removal is the template-free glyph-bbox footprint mask, not this template).
_ALPHA_NATIVE_WIDTH = 2048
_ALPHA_LOGO_BGR: tuple[float, float, float] = (255.0, 255.0, 255.0)
_ALPHA_WIDTH_FRAC = 0.1636 # asset width / image width -- the alignment scale seed
_ALPHA_WIDTH_FRAC = 0.1636 # asset width / image width -- sizes the detection silhouette
_ALPHA_HEIGHT_FRAC = 0.0405
_ALPHA_MARGIN_RIGHT_FRAC = 0.0132
_ALPHA_MARGIN_BOTTOM_FRAC = 0.0166
_ALPHA_ALIGN_SEARCH = (0.88, 1.12, 25)
_RESIDUAL_ALPHA_FLOOR = 0.05
_RESIDUAL_DILATE = 5
_RESIDUAL_INPAINT_RADIUS = 2
_CONFIG = TextMarkConfig(
name="Doubao",
@@ -79,14 +73,7 @@ _CONFIG = TextMarkConfig(
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
alpha_width_frac=_ALPHA_WIDTH_FRAC,
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
alpha_margin_x_frac=_ALPHA_MARGIN_RIGHT_FRAC,
alpha_margin_bottom_frac=_ALPHA_MARGIN_BOTTOM_FRAC,
alpha_align_search=_ALPHA_ALIGN_SEARCH,
min_gw=8,
alpha_logo_bgr=_ALPHA_LOGO_BGR,
residual_alpha_floor=_RESIDUAL_ALPHA_FLOOR,
residual_dilate=_RESIDUAL_DILATE,
residual_inpaint_radius=_RESIDUAL_INPAINT_RADIUS,
)
# Doubao-specific aliases for the shared detection result/engine.
@@ -109,7 +96,7 @@ def _template_match_score(box_mask: NDArray[Any], image_width: int) -> float:
class DoubaoEngine(TextMarkEngine):
"""Remove the visible Doubao "豆包AI生成" watermark (locate -> mask -> reverse-alpha)."""
"""Detect/localize the visible Doubao "豆包AI生成" watermark (locate -> mask; mask feeds the fill)."""
def __init__(self) -> None:
super().__init__(_CONFIG)
+125 -467
View File
@@ -1,16 +1,17 @@
"""Gemini visible watermark removal engine.
"""Gemini visible-sparkle detector and localizer (cv2/numpy, no GPU).
Port of the GeminiWatermarkTool reverse-alpha-blending algorithm from C++ to Python.
Original author: Allen Kuo (allenk) https://github.com/allenk/GeminiWatermarkTool
Locates the Google Gemini / Nano Banana sparkle so the shared fill (region_eraser)
can inpaint it. Detection is a multi-scale NCC search against the captured sparkle
alpha template (ported from GeminiWatermarkTool's Snap Engine; original author
Allen Kuo (allenk), https://github.com/allenk/GeminiWatermarkTool), scored by a
spatial + gradient + variance fusion with a false-positive gate. ``footprint_mask``
returns the sparkle footprint (captured alpha thresholded low to include the halo,
then dilated) as a full-frame mask for the fill.
The Gemini AI watermark is applied using alpha blending:
watermarked = a * logo + (1 - a) * original
We reverse this to recover the original:
original = (watermarked - a * logo) / (1 - a)
The alpha maps are derived from background captures of the Gemini watermark
on pure-black backgrounds (48x48 for small images, 96x96 for large images).
The captured alpha maps are background captures of the sparkle on pure-black
backgrounds (48x48 for small images, 96x96 for large). NB: they are used here only
to DETECT and to shape the removal mask -- the old reverse-alpha pixel recovery
(``original = (watermarked - a*logo)/(1-a)``) is gone; removal is localize -> fill.
"""
# cv2/numpy boundary: cv2 and numpy ship no usable type info for the array ops
@@ -23,7 +24,7 @@ import logging
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
from typing import TYPE_CHECKING, Any
import cv2
import numpy as np
@@ -126,51 +127,16 @@ def _load_embedded_asset(name: str) -> NDArray[Any]:
class GeminiEngine:
"""Engine for removing visible Gemini watermarks via reverse alpha blending.
"""Detects and localizes the visible Gemini sparkle for the shared fill removal.
This is a Python port of the GeminiWatermarkTool C++ engine.
The multi-scale NCC detection is a Python port of the GeminiWatermarkTool C++
Snap Engine; ``footprint_mask`` turns a detection into a removal mask.
"""
# Footprint pixels with alpha at/above this are the sparkle body; below it the
# mark barely affects the pixel, so those are excluded from both the
# over-subtraction test and the inpaint mask.
_FOOTPRINT_ALPHA = 0.1
# If more than this fraction of footprint pixels over-subtract (numerator < 0),
# the fixed alpha does not match this image's sparkle and reverse-alpha would
# punch a dark pit -- inpaint instead. demo_banana measures 0.0 (reverse-alpha
# kept), the issue #30 dark-grass image measures ~0.61 (inpaint), so the 0.05
# gate separates them with a wide margin.
_OVERSUB_FOOTPRINT_FRAC = 0.05
# Mid-tone over-subtraction (2026-06-18 prod "the color just changed, not removed"
# report). The numerator fraction above only trips when reverse-alpha drives a
# footprint pixel fully NEGATIVE -- the dark-background black-pit case. On a MID-TONE
# background a sparkle fainter than the captured alpha is over-subtracted into a
# visibly DARKER-than-background diamond while no pixel ever crosses zero, so the
# numerator gate misses it and ships the dark mark. Predict the reverse-alpha output
# at the bright core, (core - a*logo)/(1-a); when it lands more than this many gray
# levels BELOW the local background ring, reverse-alpha would leave a dark diamond --
# inpaint instead. Calibrated wide: clean removals predict within ~12 of background
# (demo_banana ~-1, a bright-bg sparkle ~-12), the prod regression predicts ~-40 and
# the issue #30 dark case ~-82, so 25 separates keep-vs-inpaint with margin.
_OVERSUB_DARK_MARGIN = 25.0
# Per-image alpha gain (under-subtraction fix). The captured alpha peaks ~0.51
# (a ~51%-opaque sparkle). Some real Gemini sparkles are rendered MORE opaque,
# so the fixed alpha under-subtracts and reverse-alpha leaves a bright residual
# the detector still fires on (~11% of marks on the spaces corpus). Estimate
# this image's effective sparkle opacity from the bright core vs the local
# background and scale the alpha to match, capped so alpha stays < 0.99. The
# gain is clamped to >= 1.0 so it only ever STRENGTHENS removal: ~1.0 when the
# sparkle matches the capture (working cases unchanged), >1 when more opaque.
# On the spaces corpus the gain cleanly separates -- under-removed marks ~1.47,
# cleanly-removed ~1.00. 1.94 is the cap that reaches alpha 0.99 from 0.51.
_ALPHA_GAIN_MAX = 1.94
_ALPHA_GAIN_CORE_FRAC = 0.8 # body pixels at >= this * peak alpha define the core
# Deadband: apply the gain only above this, so a sparkle that already matches the
# capture (estimated gain ~1.0-1.04 from background noise) stays byte-identical to
# the pre-fix output. Under-removed marks estimate >= 1.26, well clear of the band.
_ALPHA_GAIN_DEADBAND = 1.05
# Body pixels at >= this fraction of the peak captured alpha define the sparkle
# "core", sampled by the detection FP-gate's core-vs-ring brightness margin
# (:meth:`_core_and_bg`).
_CORE_ALPHA_FRAC = 0.8
# Sparkle false-positive gate. A real Gemini sparkle is a bright WHITE overlay,
# so its core sits above the local background; a shape-only NCC match on ornate
@@ -201,22 +167,20 @@ class GeminiEngine:
# confidence >= 0.65, above the gate).
_SPARKLE_FP_GRAD = 0.55
# Self-verify fallback. The gain estimate corrects most under-subtractions, but
# on the spaces corpus a tail of strong sparkles still survived reverse-alpha
# (a few px of position jitter or a gain estimate the [1.0, 1.94] clamp could
# not fully reach). After the reverse blend, re-detect; if a sparkle this strong
# remains, inpaint the footprint and keep that ONLY when it lowers the re-detect
# confidence. Purely additive: the common clean removal re-detects below this and
# is returned untouched. Threshold matches the registry's real fail line (0.5),
# so it triggers exactly on the cases that would otherwise read as not-removed
# (rescued 4 of 15 corpus fails, 0 regressions). An offset+scale alignment search
# was prototyped on the remaining 11 but REJECTED: it only lowered the shape-NCC by
# moving the reverse-alpha to a different placement that left the sparkle as bright
# or brighter (NCC-gaming, not removal), so a brightness sanity check rejected every
# one. The footprint inpaint physically reconstructs the slot from its surroundings,
# so its rescues are genuine; the survivors are near-white ill-conditioning or
# detector false positives that no reverse-alpha placement fixes.
_VERIFY_FALLBACK_CONF = 0.5
# White-core rescue for the gate above. A real but FAINT sparkle -- a soft white
# star on a bright/textured background -- has a high core-ring margin but low
# gradient fidelity, the SAME signature the grad gate uses to demote the smooth
# colored-corner FP, so faint real sparkles get demoted with it. The separator the
# grad gate discards is the CORE COLOR: a real Gemini sparkle core is near-WHITE
# (low saturation), while a clean bright corner that shape-matches (sky, sun, a warm
# light) is COLORED. So do NOT demote a low-grad match that already clears the trust
# confidence (_SPARKLE_KEEP_CONF -- the registry's 0.5 sparkle gate plus a small
# margin so the ~0.51 bright-background FPs the grad gate was added for stay demoted)
# AND has a bright (margin) near-neutral core (_core_saturation <= _SPARKLE_WHITE_SAT).
# Corpus-measured on metadata-stripped faint sparkles: recovers ~14/20 the low-grad
# demotion would drop, at ~0.8% clean false-fire vs the ~0.55% baseline.
_SPARKLE_KEEP_CONF = 0.52
_SPARKLE_WHITE_SAT = 0.20
# Corner promotion (issue #36): the size weight that suppresses tiny-patch
# false positives also buries a small, near-perfect sparkle when a larger,
@@ -323,8 +287,18 @@ class GeminiEngine:
self,
image: NDArray[Any],
force_size: WatermarkSize | None = None,
*,
trust_provenance: bool = False,
) -> DetectionResult:
"""Detect Gemini watermark using multi-scale Snap Engine logic (ported from C++ vendor algorithm)."""
"""Detect Gemini watermark using multi-scale Snap Engine logic (ported from C++ vendor algorithm).
``trust_provenance`` signals that external metadata already proves this is a
Google generation (C2PA issuer "Google"/"Gemini"). The false-positive gate
exists only to reject content that shape-matches the sparkle on NON-Google
images (Doubao text, ornate corners); when provenance confirms Google, that
gate would demote a genuine sparkle the vendor moved/re-rendered (bigger,
lighter, shifted), so it is skipped. The caller (registry) still applies the
relaxed provenance trust gate to the returned confidence."""
result = DetectionResult()
if image is None or image.size == 0:
@@ -421,19 +395,27 @@ class GeminiEngine:
# incl. bright). Demote when both are weak -- this catches the dark/mid no-core
# FP (low margin) AND the bright-background smooth-blob FP (high margin but low
# gradient), which the margin check alone misses. See _SPARKLE_FP_GRAD.
if confidence < self._SPARKLE_FP_CONF:
margin = self._core_ring_margin(image, self.get_interpolated_alpha(best_scale), (pos_x, pos_y))
if confidence < self._SPARKLE_FP_CONF and not trust_provenance:
alpha = self.get_interpolated_alpha(best_scale)
pos = (pos_x, pos_y)
margin = self._core_ring_margin(image, alpha, pos)
low_margin = margin is not None and margin < self._SPARKLE_FP_MARGIN
low_grad = grad_score < self._SPARKLE_FP_GRAD
if low_margin or low_grad:
logger.debug(
"Sparkle FP gate: conf=%.3f, core-ring margin=%s, grad=%.3f < %.2f; demoting.",
confidence,
f"{margin:.1f}" if margin is not None else "n/a",
grad_score,
self._SPARKLE_FP_GRAD,
)
confidence = min(confidence, 0.30)
# White-core rescue: a real faint sparkle clears the trust confidence,
# has a bright core (not low_margin), and a near-WHITE core -- unlike the
# colored-corner FP the low-grad demotion targets. See _SPARKLE_WHITE_SAT.
core_sat = self._core_saturation(image, alpha, pos)
white_core = not low_margin and core_sat is not None and core_sat <= self._SPARKLE_WHITE_SAT
if not (confidence >= self._SPARKLE_KEEP_CONF and white_core):
logger.debug(
"Sparkle FP gate: conf=%.3f, margin=%s, grad=%.3f, core_sat=%s; demoting.",
confidence,
f"{margin:.1f}" if margin is not None else "n/a",
grad_score,
f"{core_sat:.2f}" if core_sat is not None else "n/a",
)
confidence = min(confidence, 0.30)
result.confidence = float(max(0.0, min(1.0, confidence)))
result.detected = result.confidence >= 0.35
@@ -530,146 +512,66 @@ class GeminiEngine:
# ── Removal ──────────────────────────────────────────────────────
def remove_watermark(
# Footprint mask for the localize -> fill removal path. The mask must cover the
# WHOLE sparkle including its faint semi-transparent halo, not just the bright
# core, or the fill leaves a visible ring. Threshold the captured alpha low
# (>_MASK_ALPHA catches the halo the core-only 0.10 misses) then dilate by a
# sparkle-relative margin so alignment slop and the outermost halo are absorbed.
_MASK_ALPHA = 0.04
_MASK_DILATE_FRAC = 0.18 # dilation radius as a fraction of the sparkle scale
def footprint_mask(
self,
image: NDArray[Any],
force_size: WatermarkSize | None = None,
) -> NDArray[Any]:
"""Remove Gemini visible watermark from an image using reverse alpha blending.
No-op when the detector does not find a watermark: returns an unmodified
copy. Reverse alpha blending applied where no sparkle exists creates a
visible inverse artifact, so we refuse to touch pixels without a positive
detection. To bypass detection (e.g. you know the exact region), use
``remove_watermark_custom``.
Args:
image: BGR image as numpy array (will NOT be modified in-place).
force_size: Force a specific watermark size (auto-detect if None).
Returns:
Cleaned BGR image as numpy array, or an unmodified copy when no
watermark is detected.
"""
# Normalize to 3-channel BGR up front: 2D grayscale (no channel axis) and
# 4-channel BGRA both reach this public entry point and would otherwise
# crash on the channel-count checks / downstream 3-channel math.
result = image_io.to_bgr(image.copy())
size = force_size or get_watermark_size(result.shape[1], result.shape[0])
# Detect dynamic position & size (on the normalized 3-channel image so a
# grayscale/BGRA input does not crash the detector).
detection = self.detect_watermark(result, force_size=size)
if not detection.detected:
logger.debug(
"No watermark detected (conf=%.3f); returning image unchanged.",
detection.confidence,
)
return result
pos = (detection.region[0], detection.region[1])
alpha_map = self.get_interpolated_alpha(detection.region[2])
# Match the captured alpha to this image's sparkle opacity (under-subtraction
# fix): a more-opaque-than-captured sparkle would otherwise leave a bright
# residual. gain == 1.0 leaves the working cases byte-identical.
gain = self._estimate_alpha_gain(result, alpha_map, pos)
if gain > self._ALPHA_GAIN_DEADBAND:
alpha_map = np.clip(alpha_map * gain, 0.0, 0.99)
logger.debug(
"Removing watermark at (%d, %d) size %dx%d [conf=%.3f]",
pos[0],
pos[1],
detection.region[2],
detection.region[3],
detection.confidence,
)
# The captured alpha map (max ~0.51 = a ~50%-opaque white sparkle) is exact
# only when the real mark's effective opacity matches it. On a dark/textured
# background the sparkle's effective alpha is lower than the capture, so the
# fixed-alpha reverse blend OVER-subtracts and drives the footprint to black --
# the "white sparkle turns into a black pit" bug (issue #30). The signature is
# a large fraction of footprint pixels whose numerator (watermarked - a*logo)
# goes negative, which is physically impossible under a brightening overlay.
# In that case inpaint the small footprint from the surrounding pixels instead;
# on a bright background no pixel over-subtracts, so reverse-alpha is used and
# the result is byte-identical to before (verified on demo_banana: 0% vs 61%).
if self._reverse_alpha_oversubtracts(result, alpha_map, pos):
logger.debug("Reverse-alpha over-subtracts on this background; inpainting sparkle footprint.")
self._inpaint_footprint(result, alpha_map, pos)
else:
self._reverse_alpha_blend(result, alpha_map, pos)
return self._verify_and_repair(result, alpha_map, pos, size)
def footprint_mask(self, image: NDArray[Any], *, force: bool = False, dilate: int = 13) -> NDArray[Any] | None:
*,
force: bool = False,
dilate: int | None = None,
region: tuple[int, int, int, int] | None = None,
) -> NDArray[Any] | None:
"""Full-frame uint8 mask (255 = sparkle) of the sparkle footprint, for the
inpaint-fallback removal path (LaMa / cv2), or None.
shared fill removal path (cv2 / MI-GAN / LaMa), or None.
The footprint is the interpolated captured alpha at the detected scale --
the same region reverse-alpha operates on. When ``force`` and nothing is
detected, falls back to the default sparkle slot for the image size (the
``--no-detect`` path). The caller gates on the trust-confidence detection.
The footprint is the interpolated captured alpha at the detected scale,
thresholded LOW so the faint halo is included, then dilated by a
sparkle-relative margin. When ``force`` and nothing is detected, falls back to
the default sparkle slot for the image size (the ``--no-detect`` path).
``region`` is the already-resolved ``(x, y, scale)`` from the caller's detection
(the registry passes the decision's provenance-aware region). When given, the
mask is built from it directly WITHOUT a second internal detect -- otherwise a
provenance/assume-relaxed sparkle would be re-demoted by the strict re-detect and
yield no mask (reported-removed-but-unchanged). Absent ``region``, direct callers
keep the detect-then-force behavior.
"""
image = image_io.to_bgr(image)
h, w = image.shape[:2]
det = self.detect_watermark(image)
if det.detected:
x, y, scale = det.region[0], det.region[1], det.region[2]
elif force:
cfg = get_watermark_config(w, h)
x, y = cfg.get_position(w, h)
scale = cfg.logo_size
if region is not None:
x, y, scale = region[0], region[1], region[2]
else:
return None
det = self.detect_watermark(image)
if det.detected:
x, y, scale = det.region[0], det.region[1], det.region[2]
elif force:
cfg = get_watermark_config(w, h)
x, y = cfg.get_position(w, h)
scale = cfg.logo_size
else:
return None
alpha = self.get_interpolated_alpha(scale)
fp = self._footprint_indices(alpha, (x, y), image.shape)
if fp is None:
return None
aroi, (y1, y2, x1, x2) = fp
sil = (aroi > 0.10).astype(np.uint8) * 255
sil = (aroi > self._MASK_ALPHA).astype(np.uint8) * 255
if int((sil > 0).sum()) == 0:
return None
mask = np.zeros((h, w), np.uint8)
mask[y1:y2, x1:x2] = sil
if dilate > 0:
mask = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * dilate + 1, 2 * dilate + 1)))
d = dilate if dilate is not None else max(13, int(scale * self._MASK_DILATE_FRAC))
if d > 0:
mask = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * d + 1, 2 * d + 1)))
return mask
def remove_watermark_custom(
self,
image: NDArray[Any],
region: tuple[int, int, int, int],
) -> NDArray[Any]:
"""Remove watermark from a custom region with interpolated alpha map.
Args:
image: BGR image (will NOT be modified in-place).
region: (x, y, width, height) of the watermark region.
Returns:
Cleaned BGR image.
"""
# Same channel normalization as remove_watermark: the reverse-alpha blend
# assumes 3-channel BGR (a grayscale/BGRA input would mis-broadcast).
result = image_io.to_bgr(image.copy())
x, y, rw, rh = region
# Check standard sizes
if rw == 48 and rh == 48:
self._reverse_alpha_blend(result, self._alpha_small, (x, y))
return result
if rw == 96 and rh == 96:
self._reverse_alpha_blend(result, self._alpha_large, (x, y))
return result
# Interpolate alpha map for custom size
interp = cv2.INTER_LINEAR if rw > 96 else cv2.INTER_AREA
alpha = cv2.resize(self._alpha_large, (rw, rh), interpolation=interp)
self._reverse_alpha_blend(result, alpha, (x, y))
return result
def _footprint_indices(
self,
alpha_map: NDArray[Any],
@@ -712,7 +614,7 @@ class GeminiEngine:
a_cap = float(alpha_roi.max())
if a_cap < 0.2:
return None
core = alpha_roi >= a_cap * self._ALPHA_GAIN_CORE_FRAC
core = alpha_roi >= a_cap * self._CORE_ALPHA_FRAC
if not bool(core.any()):
return None
# Convert only the footprint+ring crop to gray, not the whole image: every
@@ -749,278 +651,34 @@ class GeminiEngine:
cb = self._core_and_bg(image, alpha_map, position)
return None if cb is None else cb[0] - cb[1]
def _estimate_alpha_gain(
def _core_saturation(
self,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> float:
"""Scale factor matching the captured alpha to this image's sparkle opacity.
The captured alpha (peak ~0.51) under-represents sparkles rendered more
opaque; reverse-alpha then leaves a bright residual. Estimate the effective
opacity at the sparkle core (observed brightness vs the local background
ring) and return ``a_eff / a_capture``, clamped to ``[1.0, _ALPHA_GAIN_MAX]``
so it only ever STRENGTHENS removal (1.0 = no change on a matching sparkle).
Returns 1.0 when the background cannot be estimated reliably.
"""
cb = self._core_and_bg(image, alpha_map, position)
if cb is None:
return 1.0
core_obs, bg, a_cap = cb
if 255.0 - bg < 5.0:
return 1.0
a_eff = float(np.clip((core_obs - bg) / (255.0 - bg), 0.0, 0.99))
return float(np.clip(a_eff / a_cap, 1.0, self._ALPHA_GAIN_MAX))
def _reverse_alpha_oversubtracts(
self,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> bool:
"""True when reverse-alpha would drive the footprint dark.
Two signatures of the captured alpha over-estimating this image's sparkle
opacity, either of which means reverse-alpha would leave a dark mark:
1. Dark-background black pit (issue #30): the numerator
``watermarked - alpha*logo`` over the sparkle body. A brightening overlay
can never make it negative, so a large negative fraction means the fixed
alpha over-subtracts past black.
2. Mid-tone dark diamond (see ``_OVERSUB_DARK_MARGIN``): on a mid-tone
background the over-subtraction darkens the core well below the background
without any pixel crossing zero, so case 1 misses it. Predict the
reverse-alpha core output and trip when it lands far below the local ring.
) -> float | None:
"""Median color saturation of the sparkle core (0 = white/neutral, higher =
colored). A real Gemini sparkle is a white star, so its core is near-neutral;
a clean bright corner that shape-matches (sky, sun, a warm light) is colored,
so a high core saturation flags the false positive the brightness/gradient
gates miss. Samples the same high-alpha core pixels as :meth:`_core_and_bg`.
None when the footprint cannot be placed or the core is empty.
"""
placed = self._footprint_indices(alpha_map, position, image.shape)
if placed is None:
return False
return None
alpha_roi, (y1, y2, x1, x2) = placed
body = alpha_roi >= self._FOOTPRINT_ALPHA
if not bool(body.any()):
return False
roi = image[y1:y2, x1:x2].astype(np.float32)
numerator = roi.mean(axis=2) - np.clip(alpha_roi, 0.0, 0.99) * self.logo_value
frac = float((numerator[body] < 0).sum()) / float(body.sum())
if frac > self._OVERSUB_FOOTPRINT_FRAC:
return True
# Mid-tone darkening: predict the reverse-alpha output at the bright core and
# compare to the local background ring (reuses the FP-gate / alpha-gain machinery).
cb = self._core_and_bg(image, alpha_map, position)
if cb is None:
return False
core_obs, bg, a_cap = cb
a = min(a_cap, 0.99)
predicted_core = (core_obs - a * self.logo_value) / (1.0 - a)
return predicted_core < bg - self._OVERSUB_DARK_MARGIN
def _inpaint_footprint(
self,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> None:
"""Inpaint the sparkle body from surrounding pixels, in-place.
Fallback for backgrounds where reverse-alpha over-subtracts: a small mask of
the footprint (alpha >= threshold, dilated) is reconstructed by cv2 NS inpaint
from the continuous surroundings, so the sparkle is replaced by plausible
background instead of a black pit.
"""
placed = self._footprint_indices(alpha_map, position, image.shape)
if placed is None:
return
alpha_roi, (y1, y2, x1, x2) = placed
# Inpaint only a padded crop around the footprint, not the whole image: the
# mask is zero outside a ~96x96 corner, so inpainting the full (multi-MP)
# image would be ~hundreds of times more work for an identical result. The
# padding gives cv2 enough surrounding context to reconstruct the sparkle.
ih, iw = image.shape[:2]
pad = 24
cy1, cy2 = max(0, y1 - pad), min(ih, y2 + pad)
cx1, cx2 = max(0, x1 - pad), min(iw, x2 + pad)
crop = image[cy1:cy2, cx1:cx2]
mask = np.zeros(crop.shape[:2], dtype=np.uint8)
mask[y1 - cy1 : y2 - cy1, x1 - cx1 : x2 - cx1] = (alpha_roi >= self._FOOTPRINT_ALPHA).astype(np.uint8) * 255
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask = cv2.dilate(mask, kernel, iterations=2)
image[cy1:cy2, cx1:cx2] = cv2.inpaint(crop, mask, 6, cv2.INPAINT_NS)
def _verify_and_repair(
self,
result: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
size: WatermarkSize,
) -> NDArray[Any]:
"""Inpaint-repair a sparkle that survived reverse-alpha, keeping the better.
Re-detect on the reverse-alpha output; if a sparkle this strong remains (an
alpha mismatch the gain estimate could not fully correct), inpaint the
footprint and return that ONLY when it lowers the re-detect confidence. The
footprint inpaint reconstructs from the (darker) surroundings, so it physically
removes the bright sparkle rather than gaming the shape-NCC. Returns ``result``
unchanged when the removal is already clean (the common case) or when the
inpaint does not improve it, so it can never regress.
"""
residual = self.detect_watermark(result, force_size=size).confidence
if residual < self._VERIFY_FALLBACK_CONF:
return result
candidate = result.copy()
self._inpaint_footprint(candidate, alpha_map, position)
if self.detect_watermark(candidate, force_size=size).confidence < residual:
logger.debug("Sparkle survived reverse-alpha (conf=%.3f); footprint inpaint improved it.", residual)
return candidate
return result
def _reverse_alpha_blend(
self,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> None:
"""Apply reverse alpha blending in-place.
Formula: original = (watermarked - a * logo) / (1 - a)
"""
placed = self._footprint_indices(alpha_map, position, image.shape)
if placed is None:
return
alpha_roi, (y1, y2, x1, x2) = placed
image_roi = image[y1:y2, x1:x2].astype(np.float32)
alpha_threshold = 0.002
max_alpha = 0.99
# Vectorized reverse alpha blending
alpha = alpha_roi.copy()
mask = alpha >= alpha_threshold
alpha = np.clip(alpha, 0.0, max_alpha)
one_minus_alpha = 1.0 - alpha
# Expand alpha for 3-channel broadcast
alpha_3d = alpha[:, :, np.newaxis]
one_minus_3d = one_minus_alpha[:, :, np.newaxis]
mask_3d = mask[:, :, np.newaxis]
# original = (watermarked - alpha * logo) / (1 - alpha)
restored = (image_roi - alpha_3d * self.logo_value) / one_minus_3d
restored = np.clip(restored, 0.0, 255.0)
# Apply only where alpha is significant
image_roi = np.where(mask_3d, restored, image_roi)
image[y1:y2, x1:x2] = image_roi.astype(np.uint8)
# ── Inpainting cleanup ───────────────────────────────────────────
def inpaint_residual(
self,
image: NDArray[Any],
region: tuple[int, int, int, int],
strength: float = 0.85,
method: Literal["gaussian", "telea", "ns"] = "ns",
inpaint_radius: int = 10,
padding: int = 32,
) -> NDArray[Any]:
"""Apply inpaint cleanup on residual artifacts after reverse alpha blend.
Uses a sparse mask derived from alpha map gradient to repair only
the sparkle-edge pixels where interpolation broke the math.
Args:
image: BGR image (will NOT be modified in-place).
region: (x, y, w, h) of the watermark region.
strength: Blend strength (0.0 = keep original, 1.0 = fully inpainted).
method: Inpaint method ("gaussian", "telea", or "ns").
inpaint_radius: Radius for cv2.inpaint.
padding: Context padding around region in pixels.
Returns:
Cleaned BGR image.
"""
result = image.copy()
x, y, rw, rh = region
if rw < 4 or rh < 4:
return result
strength = max(0.0, min(1.0, strength))
if strength < 0.001:
return result
# Padded region
px1 = max(0, x - padding)
py1 = max(0, y - padding)
px2 = min(image.shape[1], x + rw + padding)
py2 = min(image.shape[0], y + rh + padding)
if (px2 - px1) < 8 or (py2 - py1) < 8:
return result
# Inner rect relative to padded
ix1 = x - px1
iy1 = y - py1
# Get alpha map (interpolated if needed)
source_alpha = self._alpha_large
interp = cv2.INTER_LINEAR if rw > source_alpha.shape[1] else cv2.INTER_AREA
alpha_resized = cv2.resize(source_alpha, (rw, rh), interpolation=interp)
# Compute gradient mask from alpha
grad_x = cv2.Sobel(alpha_resized, cv2.CV_32F, 1, 0, ksize=3)
grad_y = cv2.Sobel(alpha_resized, cv2.CV_32F, 0, 1, ksize=3)
grad_mag = cv2.magnitude(grad_x, grad_y)
grad_min, grad_max = grad_mag.min(), grad_mag.max()
if grad_max <= grad_min:
return result
# Normalize and apply gamma correction
grad_norm = (grad_mag - grad_min) / (grad_max - grad_min)
grad_weight = np.sqrt(grad_norm)
# Dilate the mask
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
grad_weight = cv2.dilate(grad_weight, kernel)
if method == "gaussian":
# Soft blend with Gaussian blur
padded_roi = result[py1:py2, px1:px2].copy()
blurred = cv2.GaussianBlur(padded_roi, (0, 0), sigmaX=2.0)
# Create weight mask on padded area (only inner region has weights)
weight_full = np.zeros((py2 - py1, px2 - px1), dtype=np.float32)
weight_full[iy1 : iy1 + rh, ix1 : ix1 + rw] = grad_weight * strength
weight_3d = weight_full[:, :, np.newaxis]
blended = padded_roi.astype(np.float32) * (1 - weight_3d) + blurred.astype(np.float32) * weight_3d
result[py1:py2, px1:px2] = blended.astype(np.uint8)
else:
# OpenCV inpainting (TELEA or NS)
inpaint_flag = cv2.INPAINT_TELEA if method == "telea" else cv2.INPAINT_NS
# Create binary mask from gradient weight
binary_mask = (grad_weight * 255).astype(np.uint8)
_, binary_mask = cv2.threshold(binary_mask, 30, 255, cv2.THRESH_BINARY)
# Expand mask to padded region
mask_full = np.zeros((py2 - py1, px2 - px1), dtype=np.uint8)
mask_full[iy1 : iy1 + rh, ix1 : ix1 + rw] = binary_mask
padded_roi = result[py1:py2, px1:px2].copy()
inpainted = cv2.inpaint(padded_roi, mask_full, inpaint_radius, inpaint_flag)
# Blend with strength
weight_full = np.zeros((py2 - py1, px2 - px1), dtype=np.float32)
weight_full[iy1 : iy1 + rh, ix1 : ix1 + rw] = grad_weight * strength
weight_3d = weight_full[:, :, np.newaxis]
blended = padded_roi.astype(np.float32) * (1 - weight_3d) + inpainted.astype(np.float32) * weight_3d
result[py1:py2, px1:px2] = blended.astype(np.uint8)
return result
a_cap = float(alpha_roi.max())
if a_cap < 0.2:
return None
core = alpha_roi >= a_cap * self._CORE_ALPHA_FRAC
box = image[y1:y2, x1:x2]
if box.shape[:2] != core.shape or not bool(core.any()):
return None
px = box[core].astype(np.float32) # (N, 3) BGR core pixels
hi = px.max(axis=1)
lo = px.min(axis=1)
return float(np.median((hi - lo) / (hi + 1.0)))
def detect_sparkle_confidence(image_path: Path, *, image: NDArray[Any] | None = None) -> float | None:
+6 -6
View File
@@ -52,7 +52,7 @@ if TYPE_CHECKING:
from remove_ai_watermarks.watermark_registry import MarkDetection
log = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# How much of a non-PNG container to binary-scan for the C2PA issuer.
_SCAN_BYTES = 1024 * 1024
@@ -393,7 +393,7 @@ def _visible_sparkle(image_path: Path, *, image: NDArray[Any] | None = None) ->
try:
from remove_ai_watermarks.gemini_engine import detect_sparkle_confidence
except Exception as exc: # cv2/engine assets missing
log.debug("visible-sparkle detector unavailable: %s", exc)
logger.debug("visible-sparkle detector unavailable: %s", exc)
return None
return detect_sparkle_confidence(image_path, image=image)
@@ -424,7 +424,7 @@ def _visible_text_marks(image_path: Path, *, image: NDArray[Any] | None = None)
from remove_ai_watermarks.image_io import imread
from remove_ai_watermarks.watermark_registry import get_mark
except Exception as exc: # cv2/engine assets missing
log.debug("visible-mark detectors unavailable: %s", exc)
logger.debug("visible-mark detectors unavailable: %s", exc)
return []
if image is None:
image = imread(image_path)
@@ -435,7 +435,7 @@ def _visible_text_marks(image_path: Path, *, image: NDArray[Any] | None = None)
try:
det = get_mark(key).detect(image)
except Exception as exc: # one engine failing must not break identify
log.debug("visible-mark %s detector failed: %s", key, exc)
logger.debug("visible-mark %s detector failed: %s", key, exc)
continue
if det.detected:
detections.append(det)
@@ -719,7 +719,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
vis_image = imread(image_path)
except Exception as exc: # cv2 missing - detectors fall back / no-op
log.debug("visible-mark decode unavailable: %s", exc)
logger.debug("visible-mark decode unavailable: %s", exc)
# ── Visible Gemini sparkle (fallback for stripped-metadata case) ─
sparkle_conf = _visible_sparkle(image_path, image=vis_image) if check_visible else None
@@ -798,6 +798,6 @@ def has_invisible_target(image_path: Path) -> bool:
try:
report = identify(image_path, check_visible=False, check_invisible=True)
except Exception: # unreadable / detector error -> do not skip the removal
log.debug("has_invisible_target: identify failed, defaulting to run", exc_info=True)
logger.debug("has_invisible_target: identify failed, defaulting to run", exc_info=True)
return True
return report.ai_from_metadata
+163 -17
View File
@@ -29,11 +29,17 @@ if TYPE_CHECKING:
def imread(path: str | Path, flags: int | None = None) -> NDArray[Any] | None:
"""Unicode-safe ``cv2.imread``.
"""Unicode-safe ``cv2.imread`` with a Pillow fallback for HEIC/AVIF.
``flags`` defaults to ``cv2.IMREAD_COLOR`` (same as ``cv2.imread``). Returns
``None`` when the file is missing or cannot be decoded, matching
``cv2.imread`` semantics so existing ``if img is None`` checks keep working.
``None`` when the file is missing or cannot be decoded, matching ``cv2.imread``
semantics so existing ``if img is None`` checks keep working.
OpenCV cannot decode HEIC/AVIF (and some other containers), so when its decode
returns None we fall back to Pillow (:func:`_pil_read`): AVIF is native in modern
Pillow, HEIC works when the optional ``pillow-heif`` plugin is installed. This lets
the pixel path (visible removal) read the same formats the metadata path already
scans; normal PNG/JPEG/WebP never reach the fallback, so they are unaffected.
"""
import cv2
import numpy as np
@@ -46,16 +52,50 @@ def imread(path: str | Path, flags: int | None = None) -> NDArray[Any] | None:
return None
if data.size == 0:
return None
return cv2.imdecode(data, flags)
img = cv2.imdecode(data, flags)
# cv2.imdecode returns None on an undecodable container (HEIC/AVIF); the type stub
# omits that, hence the ignore.
if img is not None: # pyright: ignore[reportUnnecessaryComparison]
return img
return _pil_read(path, flags)
_heif_registered = False
def _pil_read(path: str | Path, flags: int) -> NDArray[Any] | None:
"""Decode via Pillow (HEIC/AVIF and any other Pillow-readable container) into the
cv2 layout ``flags`` implies: grayscale, 3-channel BGR, or BGRA when the source has
alpha and ``IMREAD_UNCHANGED`` was requested. Returns None if Pillow (with the
optional HEIF plugin) still cannot open it. No EXIF auto-rotation, matching cv2."""
import cv2
import numpy as np
try:
from PIL import Image
except Exception:
return None
_register_heif()
try:
with Image.open(path) as im:
im.load()
if flags == cv2.IMREAD_GRAYSCALE:
return np.asarray(im.convert("L"))
has_alpha = im.mode in ("RGBA", "LA", "PA") or "transparency" in im.info
if flags == cv2.IMREAD_UNCHANGED and has_alpha:
return cv2.cvtColor(np.asarray(im.convert("RGBA")), cv2.COLOR_RGBA2BGRA)
return cv2.cvtColor(np.asarray(im.convert("RGB")), cv2.COLOR_RGB2BGR)
except Exception:
return None
def to_bgr(image: NDArray[Any]) -> NDArray[Any]:
"""Return a 3-channel BGR view of ``image``, promoting grayscale and BGRA.
The cv2-based engines (sparkle + the reverse-alpha text marks) assume a
3-channel BGR array for their channel reductions (``mean(axis=2)``, the
per-pixel logo subtraction). A 2D grayscale or 4-channel BGRA input -- a real
Gemini-app export is opaque RGBA -- would otherwise crash or mis-broadcast.
The cv2-based engines (sparkle + the text-mark detectors/localizers) assume a
3-channel BGR array for their channel reductions (``mean(axis=2)``, the top-hat
glyph extraction). A 2D grayscale or 4-channel BGRA input -- a real Gemini-app
export is opaque RGBA -- would otherwise crash or mis-broadcast.
Centralizes the shape coercion that was inlined across the engines. A 3-channel
input is returned unchanged (no copy).
"""
@@ -68,18 +108,83 @@ def to_bgr(image: NDArray[Any]) -> NDArray[Any]:
return image
def imwrite(path: str | Path, img: NDArray[Any]) -> bool:
"""Unicode-safe ``cv2.imwrite``.
def _register_heif() -> None:
"""Register the HEIF+AVIF Pillow opener/saver via libheif (idempotent, best-effort)."""
global _heif_registered
if _heif_registered:
return
_heif_registered = True
import contextlib
The output format is taken from the path extension (e.g. ``.png``), exactly
like ``cv2.imwrite``. Returns ``True`` on success, ``False`` if the codec
rejects the image or the path cannot be written (matching ``cv2.imwrite``,
which returns ``False`` rather than raising on an unwritable path).
"""
with contextlib.suppress(Exception):
import pillow_heif # pyright: ignore[reportMissingImports]
pillow_heif.register_heif_opener()
# Containers cv2 cannot encode -> written via Pillow (pillow-heif).
_HEIF_WRITE_EXTS = {".heic", ".heif", ".avif"}
def _encode_params(ext: str) -> list[int]:
"""cv2 encode params that PRESERVE quality. The removal only touches the mark's
footprint, so the container re-encode must not degrade the untouched pixels:
JPEG at quality 100 with 4:4:4 chroma (no subsampling), WebP at max. Lossless
containers (PNG/BMP/TIFF) need no params. getattr-guarded so an older OpenCV
build without the chroma/subsampling flags still gets quality 100."""
import cv2
ext = Path(path).suffix or ".png"
ok, buf = cv2.imencode(ext, img)
if ext in (".jpg", ".jpeg"):
params = [cv2.IMWRITE_JPEG_QUALITY, 100]
cq = getattr(cv2, "IMWRITE_JPEG_CHROMA_QUALITY", None)
if cq is not None:
params += [cq, 100]
sf = getattr(cv2, "IMWRITE_JPEG_SAMPLING_FACTOR", None)
sf444 = getattr(cv2, "IMWRITE_JPEG_SAMPLING_FACTOR_444", None)
if sf is not None and sf444 is not None:
params += [sf, sf444]
return params
if ext == ".webp":
return [cv2.IMWRITE_WEBP_QUALITY, 100]
return []
def _pil_write(path: str | Path, img: NDArray[Any]) -> bool:
"""Encode HEIC/AVIF via Pillow (+pillow-heif) at high quality -- cv2 has no encoder
for them. BGR / BGRA in; returns False if Pillow (with the plugin) cannot save."""
import cv2
import numpy as np
from PIL import Image
_register_heif()
if img.ndim == 3 and img.shape[2] == 4:
arr, mode = cv2.cvtColor(img, cv2.COLOR_BGRA2RGBA), "RGBA"
else:
arr, mode = cv2.cvtColor(to_bgr(img), cv2.COLOR_BGR2RGB), "RGB"
try:
Image.fromarray(np.ascontiguousarray(arr), mode).save(str(path), quality=100)
return True
except Exception:
return False
def imwrite(path: str | Path, img: NDArray[Any]) -> bool:
"""Unicode-safe image write that PRESERVES the input format at max quality.
Format is taken from the path extension. HEIC/AVIF (which cv2 cannot encode) go
through Pillow; everything else through cv2 with quality-preserving params (see
:func:`_encode_params`) so a lossy re-encode of the untouched pixels stays near-
lossless. Returns ``True`` on success, ``False`` if the codec rejects the image or
the path cannot be written (matching ``cv2.imwrite``, never raising)."""
import cv2
ext = (Path(path).suffix or ".png").lower()
if ext in _HEIF_WRITE_EXTS:
return _pil_write(path, img)
try:
ok, buf = cv2.imencode(ext, img, _encode_params(ext))
except cv2.error:
return False
if not ok:
return False
try:
@@ -87,3 +192,44 @@ def imwrite(path: str | Path, img: NDArray[Any]) -> bool:
except OSError:
return False
return True
# Container extensions that carry an alpha channel (for read/write-with-alpha).
ALPHA_FORMATS = {".png", ".webp", ".heic", ".heif", ".avif"}
def read_bgr_and_alpha(path: str | Path) -> tuple[NDArray[Any] | None, NDArray[Any] | None]:
"""Read an image preserving its alpha channel separately.
Returns ``(bgr, alpha)`` where ``alpha`` is a single-channel ndarray when the
source has transparency, else ``None``. Grayscale inputs are promoted to BGR.
Returns ``(None, None)`` if the image cannot be decoded.
"""
import cv2
image = imread(path, cv2.IMREAD_UNCHANGED)
if image is None:
return None, None
if image.ndim == 2:
return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR), None
if image.shape[2] == 4:
return image[:, :, :3].copy(), image[:, :, 3].copy()
return image, None
def write_bgr_with_alpha(path: str | Path, bgr: NDArray[Any], alpha: NDArray[Any] | None) -> None:
"""Write BGR (with optional alpha) to ``path``.
When ``alpha`` is provided and the output extension supports it, the original
alpha plane is rejoined unchanged. The watermark region is NOT made transparent:
the fill reconstructs real pixels there, so zeroing alpha would punch a
transparent hole that renders as a white box on any non-transparent viewer
(issue #30). Preserving the input alpha keeps genuinely transparent backgrounds
intact without inventing new holes.
"""
import numpy as np
if alpha is None or Path(path).suffix.lower() not in ALPHA_FORMATS:
imwrite(path, bgr)
return
imwrite(path, np.dstack([bgr, alpha]))
+13 -3
View File
@@ -229,6 +229,12 @@ class InvisibleEngine:
# SDXL img2img runs near its ~1024 training size instead of distorting on a
# tiny latent (a 381x512 portrait wrecks at native -- issue #36 follow-up).
# The output is restored to orig_size below, so the floor is transparent.
# Register the HEIF/AVIF opener so a .heic/.avif input (now a SUPPORTED_FORMAT)
# decodes here too. The --force skip path bypasses image_io.imread, which is
# what would otherwise register it, so a bare Image.open would fail on HEIC.
from remove_ai_watermarks import image_io
image_io._register_heif()
image = Image.open(image_path)
image = ImageOps.exif_transpose(image)
orig_size = image.size # (width, height)
@@ -256,10 +262,14 @@ class InvisibleEngine:
# Always persist to a temp file, even without downscaling: WatermarkRemover
# reloads by path, so the EXIF-transposed pixels must be saved or rotation
# is lost. Cleaned up in the finally block via _tmp_path.
_tmp_fd, _tmp_str = tempfile.mkstemp(suffix=image_path.suffix)
# is lost. Written as PNG (lossless) regardless of the input format, so a JPEG
# input does not feed a re-compressed copy into the diffusion pass.
# Cleaned up in the finally block via _tmp_path.
_tmp_fd, _tmp_str = tempfile.mkstemp(suffix=".png")
_tmp_path = Path(_tmp_str)
image.save(_tmp_path)
# Convert to RGB before the PNG temp: the diffusion pass is RGB anyway, and a
# non-RGB source mode (e.g. a CMYK JPEG) cannot be written as PNG and would raise.
image.convert("RGB").save(_tmp_path)
os.close(_tmp_fd)
image_path = _tmp_path
@@ -31,7 +31,7 @@ from typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from pathlib import Path
log = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Known 48-bit ``bits`` watermarks (dwtDct, no key), name -> message integer.
_BITS_48: dict[str, int] = {
@@ -96,7 +96,7 @@ def detect_invisible_watermark(image_path: Path) -> str | None:
if _bits_match(value, ref) >= _MATCH_48:
return name
except Exception as exc: # decode can fail on tiny images
log.debug("48-bit watermark decode failed for %s: %s", image_path, exc)
logger.debug("48-bit watermark decode failed for %s: %s", image_path, exc)
# 136-bit default string watermark (SD 1.x / 2.x).
try:
@@ -104,6 +104,6 @@ def detect_invisible_watermark(image_path: Path) -> str | None:
if _bytes_match_frac(raw, _SD1_STRING) >= _MATCH_SD1_FRAC:
return "Stable Diffusion 1.x / 2.x"
except Exception as exc:
log.debug("string watermark decode failed for %s: %s", image_path, exc)
logger.debug("string watermark decode failed for %s: %s", image_path, exc)
return None
+14 -26
View File
@@ -1,17 +1,18 @@
"""Jimeng / Dreamina visible watermark removal engine.
"""Jimeng / Dreamina visible watermark detector/localizer.
Jimeng (即梦AI, ByteDance) stamps generated images with a visible "★ 即梦AI" wordmark
in the bottom-right corner -- a near-white semi-transparent overlay, the same overlay
class as the Doubao text strip.
Removal is **reverse-alpha blending** against a captured alpha map
(``original = (wm - a*logo)/(1-a)``), always NCC-aligned to the actual mark plus a thin
residual inpaint over the glyph footprint. This is one of the three text-mark engines
that share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module
Detection matches the bundled glyph silhouette against the corner; removal is the
shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds
``region_eraser``), NOT reverse-alpha. This is one of the three text-mark engines that
share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module
supplies only Jimeng's tuned :class:`TextMarkConfig` (bottom-right corner,
``assets/jimeng_alpha.png`` rebuilt by ``scripts/visible_alpha_solve.py`` from the gray
capture). Jimeng images are also caught by the China TC260 AIGC metadata label, so this
is the visible-mark *removal* path, not a new ``identify`` signal.
``assets/jimeng_alpha.png`` -- the detection silhouette, rebuilt by
``scripts/visible_alpha_solve.py`` from the gray capture). Jimeng images are also caught
by the China TC260 AIGC metadata label, so this is the visible-mark *removal* path, not
a new ``identify`` signal.
"""
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
@@ -44,18 +45,12 @@ TOPHAT_DELTA = 12
DETECT_MIN_COVERAGE = 0.02
DETECT_NCC_THRESHOLD = 0.45
# Reverse-alpha geometry, emitted by scripts/visible_alpha_solve.py from the gray
# capture at the captured width.
# Detection-silhouette geometry, emitted by scripts/visible_alpha_solve.py from the
# gray capture at the captured width (sizes the silhouette for the detection match;
# removal is the template-free glyph-bbox footprint mask).
_ALPHA_NATIVE_WIDTH = 2048
_ALPHA_LOGO_BGR: tuple[float, float, float] = (255.0, 255.0, 255.0)
_ALPHA_WIDTH_FRAC = 0.2021 # asset width / image width -- the alignment scale seed
_ALPHA_WIDTH_FRAC = 0.2021 # asset width / image width -- sizes the detection silhouette
_ALPHA_HEIGHT_FRAC = 0.0576
_ALPHA_MARGIN_RIGHT_FRAC = 0.0288
_ALPHA_MARGIN_BOTTOM_FRAC = 0.0288
_ALPHA_ALIGN_SEARCH = (0.90, 1.12, 23)
_RESIDUAL_ALPHA_FLOOR = 0.05
_RESIDUAL_DILATE = 5
_RESIDUAL_INPAINT_RADIUS = 2
_CONFIG = TextMarkConfig(
name="Jimeng",
@@ -74,14 +69,7 @@ _CONFIG = TextMarkConfig(
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
alpha_width_frac=_ALPHA_WIDTH_FRAC,
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
alpha_margin_x_frac=_ALPHA_MARGIN_RIGHT_FRAC,
alpha_margin_bottom_frac=_ALPHA_MARGIN_BOTTOM_FRAC,
alpha_align_search=_ALPHA_ALIGN_SEARCH,
min_gw=8,
alpha_logo_bgr=_ALPHA_LOGO_BGR,
residual_alpha_floor=_RESIDUAL_ALPHA_FLOOR,
residual_dilate=_RESIDUAL_DILATE,
residual_inpaint_radius=_RESIDUAL_INPAINT_RADIUS,
)
JimengDetection = TextMarkDetection
@@ -103,7 +91,7 @@ def _template_match_score(box_mask: NDArray[Any], image_width: int) -> float:
class JimengEngine(TextMarkEngine):
"""Remove the visible Jimeng "★ 即梦AI" watermark (locate -> mask -> reverse-alpha)."""
"""Detect/localize the visible Jimeng "★ 即梦AI" watermark (locate -> mask; mask feeds the fill)."""
def __init__(self) -> None:
super().__init__(_CONFIG)
+74
View File
@@ -864,6 +864,66 @@ def _strip_with_ffmpeg(source_path: Path, output_path: Path) -> Path:
return output_path
def _jpeg_app_carries_ai(marker: int, payload: bytes) -> bool:
"""Whether a JPEG APPn segment carries AI provenance to drop wholesale (C2PA in
APP11, an AI XMP packet in APP1, an IPTC "Made with AI" record in APP13). EXIF
(APP1 ``Exif``) is NOT dropped here -- it is scrubbed tag-by-tag via piexif so
genuine camera EXIF survives."""
if marker == 0xEB: # APP11: C2PA / JUMBF manifest
return c2pa_marker_in(payload) or b"jumb" in payload[:256].lower()
if marker == 0xE1 and payload.startswith(b"http://ns.adobe.com/xap/"): # APP1 XMP
return c2pa_marker_in(payload) or any(m in payload for m in AIGC_MARKERS)
if marker == 0xED: # APP13: Photoshop / IPTC
return any(m in payload for m in IPTC_AI_MARKERS) or any(m in payload for m in IPTC_AI_FIELD_MARKERS)
return False
def _strip_jpeg_metadata_lossless(source_path: Path, output_path: Path) -> bool:
"""Remove AI metadata from a JPEG WITHOUT re-encoding the DCT scan, so the pixels
stay bit-identical (the point of "work with originals" -- a metadata strip must not
degrade the image). Walks the marker segments up to SOS, drops the AI-bearing APP
segments (:func:`_jpeg_app_carries_ai`), copies the entropy-coded scan verbatim,
then scrubs AI EXIF tags in place via piexif (which rewrites only the APP1 EXIF,
leaving genuine camera EXIF and the scan untouched). Returns False if the bytes are
not a parseable JPEG, so the caller falls back to the near-lossless PIL re-save."""
import piexif
data = source_path.read_bytes()
if not data.startswith(b"\xff\xd8"):
return False
out = bytearray(b"\xff\xd8")
i, n = 2, len(data)
while i + 1 < n:
if data[i] != 0xFF:
return False # malformed marker boundary: defer to the PIL re-encode fallback
marker = data[i + 1]
if marker in (0xDA, 0xD9): # SOS / EOI -> the coded scan follows; copy verbatim
out += data[i:]
break
if 0xD0 <= marker <= 0xD7 or marker == 0x01: # standalone markers carry no length
out += data[i : i + 2]
i += 2
continue
if i + 4 > n:
return False # truncated segment header: defer to the PIL re-encode fallback
seg_len = int.from_bytes(data[i + 2 : i + 4], "big")
seg_end = i + 2 + seg_len
if seg_len < 2 or seg_end > n:
return False # malformed segment length: defer to the PIL re-encode fallback
if not _jpeg_app_carries_ai(marker, data[i + 4 : seg_end]):
out += data[i:seg_end]
i = seg_end
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(bytes(out))
try:
exif = piexif.load(str(output_path))
if _scrub_ai_exif(exif):
piexif.insert(piexif.dump(exif), str(output_path))
except Exception:
logger.debug("piexif EXIF scrub skipped on %s", output_path, exc_info=True)
return True
def remove_ai_metadata(
source_path: Path,
output_path: Path | None = None,
@@ -931,6 +991,20 @@ def remove_ai_metadata(
if source_path.suffix.lower() in _FFMPEG_STRIP_EXTS:
return _strip_with_ffmpeg(source_path, output_path)
# JPEG: strip AI metadata at the byte level so the DCT scan (the pixels) is NOT
# re-encoded. The PIL open+save path below is lossy for JPEG (a q95 re-encode that
# would undo the quality-preserving writes of the removal pipelines); this keeps a
# JPEG bit-identical outside its APP metadata segments. Falls through on a
# non-parseable JPEG. Only when keep_standard: the lossless walk drops AI segments
# but preserves standard ones, so a keep_standard=False caller (strip EVERYTHING)
# must use the full re-encode path below instead.
if (
keep_standard
and output_path.suffix.lower() in (".jpg", ".jpeg")
and _strip_jpeg_metadata_lossless(source_path, output_path)
):
return output_path
# Read image and filter metadata
with Image.open(source_path) as img:
img = img.copy()
+4 -2
View File
@@ -17,7 +17,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from remove_ai_watermarks.noai.cleaner import remove_ai_metadata
from remove_ai_watermarks.metadata import remove_ai_metadata
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover, remove_watermark
__all__ = ["WatermarkRemover", "remove_ai_metadata", "remove_watermark"]
@@ -26,7 +26,9 @@ __all__ = ["WatermarkRemover", "remove_ai_metadata", "remove_watermark"]
def __getattr__(name: str) -> object:
"""Resolve the public API on first access (PEP 562), not at package import."""
if name == "remove_ai_metadata":
from remove_ai_watermarks.noai.cleaner import remove_ai_metadata
# Re-export the single, robust stripper (byte-level, lossless-for-JPEG, all
# containers); the old noai.cleaner implementation is retired.
from remove_ai_watermarks.metadata import remove_ai_metadata
return remove_ai_metadata
if name in ("WatermarkRemover", "remove_watermark"):
+3 -3
View File
@@ -41,7 +41,7 @@ from remove_ai_watermarks.noai.constants import (
SYNTHID_C2PA_ISSUERS,
)
log = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Official C2PA reader (c2pa-python, a core dependency). It is the primary,
# spec-tracking manifest parser; the hand-rolled caBX/CBOR scanner below stays as
@@ -96,7 +96,7 @@ def _read_manifest_store_impl(path_str: str) -> str | None:
try:
reader = _C2paReader.try_create(path_str)
except Exception as exc: # malformed manifest, unsupported container, etc.
log.debug("c2pa Reader could not parse %s: %s", path_str, exc)
logger.debug("c2pa Reader could not parse %s: %s", path_str, exc)
return None
if reader is None:
return None
@@ -104,7 +104,7 @@ def _read_manifest_store_impl(path_str: str) -> str | None:
with reader:
return reader.json()
except Exception as exc: # pragma: no cover - reader opened but json() failed
log.debug("c2pa Reader.json() failed on %s: %s", path_str, exc)
logger.debug("c2pa Reader.json() failed on %s: %s", path_str, exc)
return None
-180
View File
@@ -1,180 +0,0 @@
"""AI metadata cleaning and removal.
Provides functions to identify and strip AI-generation metadata from
PNG and JPEG images while optionally preserving standard fields like
Author, Title, and Copyright.
The removal pipeline:
1. Opens the image and iterates over all metadata keys.
2. Classifies each key as AI-related (using ``constants.AI_METADATA_KEYS``
and ``constants.AI_KEYWORDS``) or standard.
3. Rebuilds the image with only the desired metadata retained.
"""
from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pathlib import Path
import piexif
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from remove_ai_watermarks.noai.constants import AI_KEYWORDS, AI_METADATA_KEYS, PNG_METADATA_KEYS
from remove_ai_watermarks.noai.utils import get_image_format
# Pre-compute a lowercase set for O(1) key lookups.
_AI_KEYS_LOWER: frozenset[str] = frozenset(k.lower() for k in AI_METADATA_KEYS)
def remove_ai_metadata(
source_path: Path,
output_path: Path | None = None,
keep_standard: bool = True,
) -> Path:
"""
Remove all AI-generated metadata from an image.
Removes:
- AI parameters (Stable Diffusion, ComfyUI, etc.)
- C2PA metadata (Google Imagen, OpenAI, etc.)
- Any metadata with AI-related keywords
Args:
source_path: Path to the source image file.
output_path: Optional output path. If not provided, modifies source in place.
keep_standard: If True, keeps standard metadata (Author, Title, etc.).
Returns:
Path to the output file with AI metadata removed.
"""
if output_path is None:
output_path = source_path
cleaned_metadata = _extract_non_ai_metadata(source_path, keep_standard)
with Image.open(source_path) as img:
img = img.copy()
output_format = get_image_format(output_path)
save_kwargs: dict[str, Any] = {"format": output_format}
# Handle EXIF data (keep it, just don't include AI-related fields)
if "exif" in cleaned_metadata:
save_kwargs["exif"] = cleaned_metadata["exif"]
if output_format == "PNG":
save_kwargs = _prepare_clean_png_kwargs(save_kwargs, cleaned_metadata)
elif output_format == "JPEG":
save_kwargs = _prepare_clean_jpeg_kwargs(save_kwargs, cleaned_metadata)
output_path.parent.mkdir(parents=True, exist_ok=True)
if output_format == "JPEG" and img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(output_path, **save_kwargs)
return output_path
def _extract_non_ai_metadata(source_path: Path, keep_standard: bool) -> dict[str, Any]:
"""Extract metadata excluding AI-related fields."""
cleaned_metadata: dict[str, Any] = {}
with Image.open(source_path) as img:
# Handle EXIF data
if "exif" in img.info:
with contextlib.suppress(Exception):
exif_dict = piexif.load(img.info["exif"])
cleaned_metadata["exif"] = exif_dict
# Extract non-AI metadata
for key, value in img.info.items():
if not isinstance(key, str):
continue
if _is_ai_metadata_key(key):
continue
is_standard = keep_standard and key in PNG_METADATA_KEYS
is_nonstandard = not keep_standard and key not in ["exif", "dpi", "gamma"] and key not in PNG_METADATA_KEYS
if is_standard or is_nonstandard:
cleaned_metadata[key] = value
# Keep DPI and gamma
if "dpi" in img.info:
cleaned_metadata["dpi"] = img.info["dpi"]
if "gamma" in img.info:
cleaned_metadata["gamma"] = img.info["gamma"]
return cleaned_metadata
def _is_ai_metadata_key(key: str) -> bool:
"""Return True if *key* is an AI-generation metadata field.
Detection uses two layers:
1. Exact match against the canonical ``AI_METADATA_KEYS`` list.
2. Substring match against ``AI_KEYWORDS`` (covers partial hits
like ``"stable_diffusion_model"``).
"""
key_lower = key.lower()
if key_lower in _AI_KEYS_LOWER:
return True
return any(kw in key_lower for kw in AI_KEYWORDS)
def _prepare_clean_png_kwargs(save_kwargs: dict[str, Any], metadata: dict[str, Any]) -> dict[str, Any]:
"""Prepare save kwargs for clean PNG."""
pnginfo: dict[str, Any] = {}
exclude_keys = ["exif", "exif_raw", "dpi", "gamma"]
for key, value in metadata.items():
if key not in exclude_keys:
pnginfo[key] = value
if pnginfo:
pnginfo_obj = PngInfo()
for key, value in pnginfo.items():
if isinstance(value, str):
pnginfo_obj.add_text(key, value)
elif isinstance(value, bytes):
pnginfo_obj.add_text(key, value.decode("utf-8", errors="replace"))
save_kwargs["pnginfo"] = pnginfo_obj
if "dpi" in metadata:
save_kwargs["dpi"] = metadata["dpi"]
return save_kwargs
def _prepare_clean_jpeg_kwargs(save_kwargs: dict[str, Any], metadata: dict[str, Any]) -> dict[str, Any]:
"""Prepare save kwargs for clean JPEG."""
exif_dict = metadata.get("exif", {"0th": {}, "Exif": {}, "1st": {}, "GPS": {}, "Interop": {}})
with contextlib.suppress(Exception):
exif_bytes = piexif.dump(exif_dict)
save_kwargs["exif"] = exif_bytes
if "dpi" in metadata:
save_kwargs["dpi"] = metadata["dpi"]
return save_kwargs
def has_ai_content(image_path: Path) -> bool:
"""
Check if an image has any AI-generated content or metadata.
Args:
image_path: Path to the image file.
Returns:
True if the image contains AI metadata.
"""
from remove_ai_watermarks.noai.extractor import has_ai_metadata
return has_ai_metadata(image_path)
+6 -2
View File
@@ -6,8 +6,12 @@ so adding a new AI tool or metadata key requires updating only this file.
from typing import NamedTuple
# Supported image formats
SUPPORTED_FORMATS = {".png", ".jpg", ".jpeg", ".webp"}
# Supported image formats for the pixel/removal path (CLI input validation + batch
# discovery). PNG/JPEG/WebP decode+encode via cv2; HEIC/HEIF/AVIF via the core
# pillow-heif dep (image_io.imread Pillow fallback + imwrite _pil_write), so batch
# now picks them up and the CLI no longer warns on an iPhone HEIC. JPEG-XL is left
# out on purpose -- it is metadata/strip-only (no pixel decoder without pillow-jxl).
SUPPORTED_FORMATS = {".png", ".jpg", ".jpeg", ".webp", ".heic", ".heif", ".avif"}
# AI-generated image metadata keys (Stable Diffusion, ComfyUI, Midjourney, etc.)
AI_METADATA_KEYS = [
+2 -2
View File
@@ -33,7 +33,7 @@ from remove_ai_watermarks.metadata import (
IPTC_AI_MARKERS,
)
log = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Top-level box types that may carry AI provenance. ``uuid`` boxes are checked
# against ``C2PA_UUID`` / AI-label markers before being stripped; ``jumb`` boxes
@@ -188,7 +188,7 @@ def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
# would silently truncate the file from the bad box to EOF -- worse than not
# stripping. If the walk did not consume the whole input, return it unchanged.
if consumed != len(data):
log.warning(
logger.warning(
"ISOBMFF box walk stopped at offset %d of %d (malformed box); "
"returning input unchanged to avoid truncation",
consumed,
+1 -1
View File
@@ -81,7 +81,7 @@ def feather_weights(width: int, height: int, overlap: int) -> NDArray[Any]:
Separable linear taper over ``overlap`` pixels from every edge (capped at
half the tile so short tiles still taper symmetrically). Strictly positive
everywhere, so the normalised blend is well-defined even at an image corner
everywhere, so the normalized blend is well-defined even at an image corner
that only one tile covers.
"""
import numpy as np
@@ -738,12 +738,24 @@ class WatermarkRemover:
self._set_progress(f"Encoding as JPEG → {output_path.name}...")
else:
self._set_progress(f"Encoding as PNG → {output_path.name}...")
cleaned_image.save(output_path)
# Encode through image_io so the regenerated image gets the same quality-
# preserving write as the visible path (JPEG q100 / 4:4:4, HEIC/AVIF via Pillow)
# instead of PIL's default JPEG quality 75, which would crush the diffusion
# output further for no reason.
import numpy as np
from remove_ai_watermarks import image_io
rgb = np.asarray(cleaned_image.convert("RGB"))[:, :, ::-1] # PIL RGB -> cv2 BGR
if not image_io.imwrite(str(output_path), np.ascontiguousarray(rgb)):
cleaned_image.save(output_path) # fallback for a container image_io/cv2 cannot encode
if output_path.exists():
self._set_progress("Stripping AI metadata from output...")
try:
from remove_ai_watermarks.noai.cleaner import remove_ai_metadata
# The single, robust stripper (byte-level: lossless for JPEG, handles
# every container) -- not the legacy PIL-re-encoding noai.cleaner one.
from remove_ai_watermarks.metadata import remove_ai_metadata
remove_ai_metadata(output_path, output_path, keep_standard=True)
except Exception:
+6 -6
View File
@@ -1,9 +1,9 @@
"""Jimeng-basic 'AI生成' pill: a CAPTURE-LESS visible mark (issue #54).
The Jimeng free-tier TC260 label is a rounded pill with 'AI生成' in the TOP-LEFT
corner -- distinct from the reverse-alpha ``jimeng`` "★ 即梦AI" mark (bottom-right).
No flat capture / alpha map exists for it, so it is removed by INPAINT, not
reverse-alpha:
corner -- distinct from the ``jimeng`` "★ 即梦AI" wordmark (bottom-right). It has no
captured alpha map, so unlike the other marks it is detected purely by a synthetic
silhouette; like every mark it is then removed by the shared localize -> fill:
* Detect: edge-NCC of a font-rendered SILHOUETTE (``assets/jimeng_pill.png``,
synthetic, data-safe -- see ``scripts/render_pill_silhouette.py``) against the
@@ -89,7 +89,7 @@ def _grad(gray: NDArray[Any]) -> NDArray[Any]:
class PillEngine:
"""Detect + inpaint-mask the top-left 'AI生成' pill (edge-NCC, no reverse-alpha)."""
"""Detect + build the removal mask for the top-left 'AI生成' pill (edge-NCC of a synthetic silhouette)."""
def _match(self, image: NDArray[Any]) -> tuple[float, tuple[int, int, int, int]] | None:
sil = _load_silhouette()
@@ -98,7 +98,7 @@ class PillEngine:
h, w = image.shape[:2]
if h < 64 or w < 64:
return None
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
gray = cv2.cvtColor(image_io.to_bgr(image), cv2.COLOR_BGR2GRAY)
rh, rw = int(h * _ROI_H_FRAC), int(w * _ROI_W_FRAC)
roi = gray[0:rh, 0:rw]
tw = max(24, int(_WIDTH_FRAC * w))
@@ -138,7 +138,7 @@ class PillEngine:
return 0.0
x0, y0, x1, y1 = box
crop = image[y0:y1, x0:x1]
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) if crop.ndim == 3 else crop
gray = cv2.cvtColor(image_io.to_bgr(crop), cv2.COLOR_BGR2GRAY)
tw = 220
gray = cv2.resize(gray, (tw, max(1, int(gray.shape[0] * tw / gray.shape[1])))).astype(np.float32)
gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
+2 -2
View File
@@ -2,7 +2,7 @@
Position- and content-agnostic. You supply the rectangle(s); the eraser inpaints
whatever is inside, so it removes any visible logo / watermark / object regardless
of colour, style, or location. Localisation is the user's responsibility (pass the
of color, style, or location. Localization is the user's responsibility (pass the
box); restoration runs on CPU. This is the universal fallback for marks the
deterministic per-generator engines (Gemini sparkle, Doubao) do not cover.
@@ -151,7 +151,7 @@ def erase_lama(image_bgr: NDArray[Any], mask: NDArray[Any]) -> NDArray[Any]:
crop_mask = mask[cy0:cy1, cx0:cx1]
ch, cw = crop.shape[:2]
# Resize crop + mask to the model size, normalise to [0,1] RGB CHW.
# Resize crop + mask to the model size, normalize to [0,1] RGB CHW.
crop_rs = cv2.resize(crop, (size, size), interpolation=cv2.INTER_AREA)
mask_rs = cv2.resize(crop_mask, (size, size), interpolation=cv2.INTER_NEAREST)
img_in = cv2.cvtColor(crop_rs, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
+20 -32
View File
@@ -1,19 +1,22 @@
"""Samsung Galaxy AI visible watermark removal engine.
"""Samsung Galaxy AI visible watermark detector/localizer.
Samsung's on-device Generative AI photo edits burn a visible "✦ Contenuti generati
dall'AI" wordmark into the bottom-LEFT corner (the Italian locale variant calibrated
here; the string is locale-specific). It is a faint, near-white semi-transparent
overlay, the same overlay class as the Doubao/Jimeng marks but bottom-left.
here; the string is locale-specific -- DETECTION only matches this locale's silhouette,
so other locales are not yet detected, though the fill mask itself is locale-agnostic).
It is a faint, near-white semi-transparent overlay, the same overlay class as the
Doubao/Jimeng marks but bottom-left.
Removal is **reverse-alpha blending** against a captured alpha map
(``original = (wm - a*logo)/(1-a)``), always NCC-aligned to the actual mark plus a thin
residual inpaint over the glyph footprint. This is one of the three text-mark engines
that share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module
Detection matches the bundled glyph silhouette against the corner; removal is the
shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds
``region_eraser``), NOT reverse-alpha. This is one of the three text-mark engines that
share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module
supplies only Samsung's tuned :class:`TextMarkConfig` (bottom-LEFT corner, a lower glyph
luma since the mark is faint, ``assets/samsung_alpha.png`` solved from the flat captures
by ``scripts/visible_alpha_solve.py``). Samsung Galaxy AI edits are also caught by C2PA
+ the ``genAIType`` marker, so this is the visible-mark *removal* path; it also feeds
``identify`` as the medium-confidence ``visible_samsung`` signal via the registry.
luma since the mark is faint, ``assets/samsung_alpha.png`` -- the detection silhouette,
solved from the flat captures by ``scripts/visible_alpha_solve.py``). Samsung Galaxy AI
edits are also caught by C2PA + the ``genAIType`` marker, so this is the visible-mark
*removal* path; it also feeds ``identify`` as the medium-confidence ``visible_samsung``
signal via the registry.
"""
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
@@ -48,21 +51,13 @@ TOPHAT_DELTA = 8
DETECT_MIN_COVERAGE = 0.01
DETECT_NCC_THRESHOLD = 0.40
# Reverse-alpha geometry, solved by scripts/visible_alpha_solve.py from the flat gray
# capture (native width 1086). Real photos are ~2958 wide, so the captured glyph is
# upscaled; width-scale + NCC-align removes it cleanly (a flat capture at the real
# resolution would make the alpha pixel-sharp -- an open quality upgrade).
# Detection-silhouette geometry, solved by scripts/visible_alpha_solve.py from the flat
# gray capture (native width 1086). Real photos are ~2958 wide, so the captured glyph is
# upscaled; width-scale + NCC-align sizes the silhouette for the detection match (removal
# is the template-free glyph-bbox footprint mask).
_ALPHA_NATIVE_WIDTH = 1086
_ALPHA_LOGO_BGR: tuple[float, float, float] = (255.0, 255.0, 255.0)
_ALPHA_WIDTH_FRAC = 0.3195 # asset width / image width -- the alignment scale seed
_ALPHA_WIDTH_FRAC = 0.3195 # asset width / image width -- sizes the detection silhouette
_ALPHA_HEIGHT_FRAC = 0.0378
_ALPHA_MARGIN_LEFT_FRAC = 0.0110
_ALPHA_MARGIN_BOTTOM_FRAC = 0.0064
# Wider scale search: the flat capture is far off the real-photo width.
_ALPHA_ALIGN_SEARCH = (0.85, 1.18, 23)
_RESIDUAL_ALPHA_FLOOR = 0.05
_RESIDUAL_DILATE = 5
_RESIDUAL_INPAINT_RADIUS = 2
_CONFIG = TextMarkConfig(
name="Samsung Galaxy AI",
@@ -81,14 +76,7 @@ _CONFIG = TextMarkConfig(
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
alpha_width_frac=_ALPHA_WIDTH_FRAC,
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
alpha_margin_x_frac=_ALPHA_MARGIN_LEFT_FRAC,
alpha_margin_bottom_frac=_ALPHA_MARGIN_BOTTOM_FRAC,
alpha_align_search=_ALPHA_ALIGN_SEARCH,
min_gw=16,
alpha_logo_bgr=_ALPHA_LOGO_BGR,
residual_alpha_floor=_RESIDUAL_ALPHA_FLOOR,
residual_dilate=_RESIDUAL_DILATE,
residual_inpaint_radius=_RESIDUAL_INPAINT_RADIUS,
)
SamsungDetection = TextMarkDetection
@@ -110,7 +98,7 @@ def _template_match_score(box_mask: NDArray[Any], image_width: int) -> float:
class SamsungEngine(TextMarkEngine):
"""Remove the visible Samsung Galaxy AI text mark (locate -> mask -> reverse-alpha)."""
"""Detect/localize the visible Samsung Galaxy AI text mark (locate -> mask; mask feeds the fill)."""
def __init__(self) -> None:
super().__init__(_CONFIG)
@@ -28,7 +28,7 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pathlib import Path
log = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Adobe ships Variant P in production (com.adobe.trustmark.P).
_MODEL_TYPE = "P"
@@ -94,10 +94,10 @@ def detect_trustmark(image_path: Path) -> str | None:
if not wm_present:
return None
if not _survives_reencode(decoder, cover, wm_schema):
log.debug("TrustMark decode for %s did not survive re-encode; treating as false positive", image_path)
logger.debug("TrustMark decode for %s did not survive re-encode; treating as false positive", image_path)
return None
except Exception as exc: # model download / decode failure / unreadable image
log.debug("TrustMark decode failed for %s: %s", image_path, exc)
logger.debug("TrustMark decode failed for %s: %s", image_path, exc)
return None
return f"Adobe TrustMark (variant {_MODEL_TYPE}, schema {wm_schema})"
+383 -207
View File
@@ -5,29 +5,28 @@ A single catalog that ties each known visible mark to (a) where it usually sits,
registry detects every known mark in its usual place and removes the ones
present.
**Reverse-alpha based.** A known mark is a fixed semi-transparent overlay, so it
is removed by inverting the alpha blend against a captured alpha map
(``original = (wm - a*logo)/(1-a)``) -- recovering the true pixels rather than
inpainting a guess. Gemini and Doubao recover exactly with no inpaint at native on
bright/flat backgrounds (Gemini falls back to inpainting the sparkle footprint when
reverse-alpha would over-subtract on a dark background -- issue #30, see gemini_engine);
Jimeng adds a thin residual inpaint over the glyph footprint to clear the outline
its per-image render variation leaves behind (still seeded by the reverse-alpha
recovery, not a blind inpaint). Detection is consistent with that: each mark is
recognized by matching its known shape/template (the thing we invert), not by
heuristics. A mark is therefore listed here only once a real alpha map has been
captured for it; everything else (arbitrary logos/objects) is the user-directed
``erase --region`` tool, not this catalog.
**Localize -> fill.** A known mark is removed by LOCALIZING it (a template-free,
version-robust detector that returns a binary footprint MASK) and then handing
that mask to ONE shared, swappable fill backend (``region_eraser``: cv2 Telea/NS,
MI-GAN, or big-LaMa). No mark carries a reverse-alpha step any more: the old
``original = (wm - a*logo)/(1-a)`` recovery depended on a fixed captured alpha map
at a fixed position, broke whenever a vendor re-rendered or moved its mark, and was
not color-lossless even with the right map (it amplifies quantization/JPEG-chroma
error by ``1/(1-a)`` -- the "the color just changed, not removed" reports). The
localizer stays cheap (cv2/numpy, CPU) so a memory-tight caller can run it on a
small worker; the heavy fill (MI-GAN / LaMa) is opt-in and chosen by the caller.
Entries:
- ``gemini`` -- Google Gemini / Nano Banana sparkle, bottom-right.
- ``doubao`` -- ByteDance Doubao "豆包AI生成" text strip, bottom-right.
- ``jimeng`` -- ByteDance Jimeng / Dreamina "★ 即梦AI" wordmark, bottom-right.
- ``samsung`` -- Samsung Galaxy AI "Contenuti generati dall'AI" strip, bottom-left.
- ``jimeng_pill`` -- Jimeng-basic "AI生成" pill, top-left (capture-less).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal
@@ -36,15 +35,43 @@ if TYPE_CHECKING:
from numpy.typing import NDArray
# cv2 method for the Gemini reverse-alpha edge-residual cleanup (not a standalone
# remover): "ns" / "telea".
InpaintMethod = Literal["telea", "ns"]
logger = logging.getLogger(__name__)
Region = tuple[int, int, int, int]
# Removal method selection for the visible pass. ``auto`` prefers the inpaint
# fallback (LaMa footprint) when the ``lama`` extra is installed, else reverse-alpha
# for marks with a captured alpha map (cv2 inpaint for capture-less marks).
RemovalMethod = Literal["auto", "reverse-alpha", "inpaint"]
# Fill backend for the shared removal path. ``auto`` resolves best-first to the highest
# quality installed model -- LaMa, else MI-GAN, else cv2 (see ``resolve_backend``); the
# others force a specific backend (mirrors the ``erase`` command's ``--backend``).
Backend = Literal["auto", "cv2", "migan", "lama"]
# Detection sensitivity for the removal path -- how much to trust a borderline mark.
# * ``strict``: high-precision visual gate only; never relaxed, so a clean image is
# never touched (the gate demotes a sparkle-shaped content match, so it never fills
# a clean corner). Lowest recall on faint/moved marks.
# * ``auto`` (default): relax a mark's gate ONLY when the image carries same-product
# evidence the mark is there -- metadata provenance for that vendor, or a confidently
# detected sibling mark of the same product (see ``resolve_relax``). No evidence ->
# stays strict. Safe: it only escalates where the mark is corroborated.
# * ``assume_ai``: relax every mark's gate regardless of evidence -- the caller asserts
# the image is AI and wants the mark gone (e.g. a metadata-stripped screenshot uploaded
# to a watermark remover). Recovers the faint/moved marks the strict gate demotes
# (~49% -> ~89% Gemini recall, corpus-measured), at the cost of a harmless small fill
# on some clean corners. The library CANNOT infer this from a stripped image -- only the
# caller's out-of-band context (the user uploaded to remove a mark) justifies it.
Sensitivity = Literal["auto", "strict", "assume_ai"]
# Product family per mark, for the ``auto`` cross-mark corroboration: a confidently
# detected mark relaxes only OTHER marks of the SAME product (different corners, one
# product -- the Jimeng wordmark + the Jimeng pill). Doubao and Jimeng are BOTH ByteDance
# but distinct products in the SAME bottom-right corner, so they must NOT cross-relax
# (relaxing Doubao on a Jimeng wordmark would spuriously fire Doubao on it).
_PRODUCT_OF: dict[str, str] = {
"gemini": "gemini",
"doubao": "doubao",
"jimeng": "jimeng",
"jimeng_pill": "jimeng", # same product as the Jimeng wordmark
"samsung": "samsung",
}
@dataclass(frozen=True)
@@ -59,56 +86,132 @@ class MarkDetection:
region: Region
@dataclass(frozen=True)
class Localization:
"""A located mark: its detection verdict plus the full-frame removal mask.
``mask`` is a full-frame uint8 array (255 = remove) sized to the image, or None
when nothing should be removed (no detection and not forced, or the footprint
could not be placed). ``region`` is the mark's bbox (for logging / residual
positioning)."""
detected: bool
confidence: float
region: Region
mask: NDArray[Any] | None
@dataclass(frozen=True)
class Context:
"""The evidence + policy the removal arbiter decides against (perception is
kept separate from this decision). ``sensitivity`` is the caller's intent
(see :data:`Sensitivity`); ``provenance`` is the vendor keys local metadata
confirms, the evidence that drives ``auto``. Bundling them into one object is
why the arbiter can be a pure function of ``(candidates, context)``."""
sensitivity: Sensitivity = "auto"
provenance: frozenset[str] = frozenset()
@dataclass(frozen=True)
class Candidate:
"""One mark's PERCEPTION output -- what the engine sees, with NO policy applied.
Carries the mark's verdict at BOTH trust levels (``detected_strict`` = the
conservative gate, ``detected_relaxed`` = the gate the engine relaxes to under
provenance/assume), so the arbiter can pick per mark without re-running detection.
``features`` is a generic bag of physical measurements a mark's gate may need (the
mark owns which it reports via ``KnownMark._features``); e.g. the pill supplies
``footprint_flat`` (0/1). Empty for marks whose gate needs no extra evidence."""
key: str
label: str
detected_strict: bool
detected_relaxed: bool
features: dict[str, float] # generic; both construction sites always supply it (empty when none)
@dataclass(frozen=True)
class Decision:
"""The arbiter's verdict for one fired mark: remove it, at the resolved trust
level (``relax`` feeds the mark's mask build so the fill footprint matches the
level the mark was accepted at)."""
candidate: Candidate
relax: bool
@dataclass(frozen=True)
class KnownMark:
"""A known visible watermark: where it lives, how to find and remove it."""
"""A known visible watermark: where it lives, how to find and mask it.
Removal is uniform (:meth:`remove`): localize the mark to a mask, then fill that
mask with the chosen backend. Each mark supplies two cheap cv2/numpy callables --
``_detect`` (verdict + bbox, no mask; used by the identify scan) and ``_mask``
(the full-frame footprint mask; used by removal)."""
key: str
label: str
location: str # usual place, human-readable ("bottom-right")
in_auto: bool # participate in `--mark auto` scanning
recovery: str # default removal strategy label ("reverse-alpha")
_detect: Callable[[NDArray[Any]], MarkDetection]
_remove: Callable[..., tuple[NDArray[Any], Region | None]]
has_capture: bool = True # a captured alpha map exists (reverse-alpha possible)
_detect: Callable[..., MarkDetection]
_mask: Callable[..., NDArray[Any] | None]
# Optional physical-feature probe: the mark's OWN measurements its gate needs
# (e.g. the pill's footprint flatness), so the perception pass stays uniform and
# does not special-case any mark. None = the mark's gate needs no extra evidence.
_features: Callable[..., dict[str, float]] | None = None
def detect(self, image: NDArray[Any]) -> MarkDetection:
return self._detect(image)
def features(self, image: NDArray[Any]) -> dict[str, float]:
"""Physical features the mark reports for the arbiter's gate (empty if none)."""
return self._features(image) if self._features is not None else {}
def detect(self, image: NDArray[Any], *, provenance: bool = False) -> MarkDetection:
"""Detect the mark (verdict + bbox, no mask). ``provenance`` signals that
external metadata already confirms this vendor, so the engine may relax its
trust threshold (a mark it would otherwise demote as a content false positive
is trusted when provenance says the vendor is present)."""
return self._detect(image, provenance=provenance)
def localize(self, image: NDArray[Any], *, provenance: bool = False, force: bool = False) -> Localization:
"""Detect and build the removal mask in one call. Returns a
:class:`Localization`; ``mask`` is None unless the mark is detected (or
``force`` bypasses detection for the mark's usual footprint)."""
det = self.detect(image, provenance=provenance)
if not (det.detected or force):
return Localization(det.detected, det.confidence, det.region, None)
# Pass the (provenance-aware) detection to the mask builder so it does NOT
# re-detect at a different trust level -- a relaxed sparkle must not be
# re-demoted into a None mask (reported-removed-but-unchanged).
mask = self._mask(image, force=force, detection=det)
return Localization(det.detected, det.confidence, det.region, mask)
def remove(
self,
image: NDArray[Any],
*,
method: RemovalMethod = "auto",
inpaint_method: InpaintMethod = "ns",
inpaint: bool = True,
inpaint_strength: float = 0.85,
backend: Backend = "auto",
provenance: bool = False,
force: bool = False,
) -> tuple[NDArray[Any], Region | None]:
"""Remove this mark; returns ``(result, region)`` where ``region`` is the
removed mark's bbox (for residual-inpaint positioning), or None if nothing
was removed. NB: the CLI does NOT use ``region`` to clear alpha on save --
that zeroing caused the issue-#30 white box.
"""Remove this mark by localize -> fill; returns ``(result, region)`` where
``region`` is the removed mark's bbox, or None if nothing was removed.
``method`` selects the removal path: ``reverse-alpha`` recovers the true
pixels from the captured alpha map (lighter, exact, better on structured
backgrounds); ``inpaint`` erases the footprint with LaMa (or cv2 when the
``lama`` extra is absent), needing no capture; ``auto`` (default) prefers
inpaint when LaMa is installed and falls back to reverse-alpha otherwise
(cv2 inpaint for capture-less marks). ``inpaint``/``inpaint_strength``/
``inpaint_method`` tune the Gemini reverse-alpha edge-residual cleanup only.
``force`` removes at the mark's usual location even without a positive
detection (the ``--no-detect`` path).
"""
if resolve_removal_method(method, self.has_capture) == "inpaint":
return _inpaint_remove(self, image, force)
return self._remove(image, inpaint_method, inpaint, inpaint_strength, force)
``backend`` picks the fill (``auto`` = LaMa > MI-GAN > cv2, best available; or force
``cv2``/``migan``/``lama``). ``provenance`` relaxes the detector's trust gate
when metadata already confirms the vendor. ``force`` removes at the mark's
usual footprint even without a positive detection (the ``--no-detect`` path).
NB: the CLI does NOT use ``region`` to clear alpha on save -- that zeroing
caused the issue-#30 white box."""
loc = self.localize(image, provenance=provenance, force=force)
if loc.mask is None or not loc.mask.any():
return image.copy(), None
return fill(image, loc.mask, backend=backend), (loc.region if loc.detected else None)
# Single source of truth for the Gemini-sparkle "trust this as a real mark"
# confidence, shared by BOTH the removal arbitration here (`best_auto_mark` /
# `_gemini_detect`) and the provenance detector in `identify` (which imports it
# as its sparkle threshold). Defining it once removes the detect-vs-remove
# confidence, shared by BOTH the removal arbitration here (`_gemini_detect`) and
# the provenance detector in `identify` (which imports it as its sparkle threshold).
# Defining it once removes the detect-vs-remove
# threshold drift the retained-corpus mining surfaced (2026-06-20): identify
# would report a sparkle while removal declined it, or vice versa, whenever the
# two independently-maintained 0.5 constants fell out of step. Now they cannot.
@@ -119,14 +222,24 @@ class KnownMark:
# and its core-ring brightness margin is HIGHER than a genuine faint sparkle's,
# so neither confidence nor the brightness gate separates them in the [0.35, 0.5)
# band. Lowering this gate to recover faint sparkles was evaluated against that
# band (2026-06-20) and REJECTED: it cannot be done without re-admitting the
# Doubao-text / content false positives, trading a rare miss for false-positive
# removals on clean images. The band below the gate is therefore intentionally
# left to the higher-strength / metadata paths. 0.5 gives 0 false positives on
# the corpus.
# band (2026-06-20) and REJECTED for the no-provenance case: it cannot be done
# without re-admitting the Doubao-text / content false positives. The band below
# the gate is therefore left to the metadata-confirmed path below.
GEMINI_SPARKLE_TRUST_CONF = 0.5
_GEMINI_AUTO_MIN_CONF = GEMINI_SPARKLE_TRUST_CONF
# Provenance-confirmed Gemini trust gate. When external metadata already proves the
# image is a Google generation (C2PA issuer "Google"/"Gemini"), the [0.35, 0.5)
# band that the no-provenance gate leaves out is no longer ambiguous with Doubao
# text: a Doubao image carries ByteDance provenance, not Google, so it never reaches
# this relaxed gate. The vendor moving/re-rendering the sparkle (bigger, lighter,
# shifted north-west) drops a real sparkle into this band, and the fixed-slot
# detector demotes it -- provenance is exactly the extra evidence that lets us trust
# it. Set to the engine's own internal `detected` floor (0.35); combined with the
# engine's FP-gate being skipped under provenance (see gemini_engine), this recovers
# the moved-mark misses without touching the no-provenance precision.
_GEMINI_PROVENANCE_MIN_CONF = 0.35
# ── Engine adapters (lazy singletons; engines are cv2-only, no model load) ──
_engines: dict[str, Any] = {}
@@ -166,148 +279,137 @@ def inpaint_model_available() -> bool:
return region_eraser.migan_available() or region_eraser.lama_available()
def preferred_inpaint_backend() -> str:
"""Backend used by the inpaint fallback: MI-GAN (light, droplet-friendly, the
default) when its ONNX runtime is available, else cv2. big-LaMa is NOT auto-
selected -- it is a heavier explicit opt-in via ``erase --backend lama`` (both
models run on onnxruntime, so availability alone cannot express the user's
intent; the light model is the safe default)."""
_warned_cv2_fallback = False
def preferred_inpaint_backend() -> Literal["lama", "migan", "cv2"]:
"""Backend the ``auto`` fill resolves to, best-first: LaMa > MI-GAN > cv2.
LaMa is the highest-quality inpaint (it recovers the textured/structured backgrounds
the classical fill smears), so ``auto`` prefers it whenever a learned backend can run
(onnxruntime present). MI-GAN is the lighter learned model; both currently share the
SAME onnxruntime availability check, so ``auto`` cannot tell them apart and always
prefers the better one -- a memory-tight deployment that cannot afford LaMa's ~4.7 GB
peak pins MI-GAN explicitly via ``--backend migan`` / ``backend="migan"`` (that is the
deployment's call, not the library's). cv2 is the classical no-deps floor and the last
resort: it smears textured/structured backgrounds, so a one-time quality warning fires
when ``auto`` falls back to it."""
from remove_ai_watermarks import region_eraser
return "migan" if region_eraser.migan_available() else "cv2"
if region_eraser.lama_available():
return "lama"
if region_eraser.migan_available():
return "migan"
global _warned_cv2_fallback
if not _warned_cv2_fallback:
_warned_cv2_fallback = True
logger.warning(
"No learned-inpaint backend available (onnxruntime not installed); falling back "
"to the cv2 classical inpaint, which can smear textured or structured backgrounds. "
"Install the 'lama' (best) or 'migan' (lighter) extra for higher-quality fills."
)
return "cv2"
def resolve_removal_method(method: RemovalMethod, has_capture: bool) -> Literal["reverse-alpha", "inpaint"]:
"""Resolve the requested method to a concrete one. A capture-less mark has no
alpha map, so it can only be inpainted -- even explicit ``reverse-alpha`` falls
back to inpaint there.
``auto`` uses **reverse-alpha for capture marks** and **inpaint for capture-less**.
Reverse-alpha recovers the true pixels under the mark (measured cleaner than
MI-GAN inpaint on the capture marks -- doubao/gemini/jimeng -- especially on
structured backgrounds, and it needs no model / RAM), so it stays the default
where a capture exists; inpaint is reserved for marks that have no alpha map
(the Jimeng pill). ``--method inpaint`` still forces inpaint for anyone who
wants it."""
# A capture-less mark can only be inpainted; a capture mark inpaints only when
# explicitly asked (auto + reverse-alpha both recover the true pixels).
if method == "inpaint" or not has_capture:
return "inpaint"
return "reverse-alpha"
def resolve_backend(backend: Backend) -> Literal["cv2", "migan", "lama"]:
"""Resolve ``auto`` to the preferred installed backend; pass the rest through."""
if backend == "auto":
return preferred_inpaint_backend()
return backend
def _inpaint_remove(mark: KnownMark, image: NDArray[Any], force: bool) -> tuple[NDArray[Any], Region | None]:
"""Remove ``mark`` by inpainting its footprint: the NCC-aligned captured
silhouette (:meth:`footprint_mask`), erased with MI-GAN when its extra is
installed, else cv2 (see :func:`preferred_inpaint_backend`). No-op (returns a
copy) on a clean corner unless ``force``. Gated on the mark's trust-confidence
detection, so a clean image is never touched."""
def fill(image: NDArray[Any], mask: NDArray[Any], *, backend: Backend = "auto") -> NDArray[Any]:
"""The ONE shared, mark-agnostic removal: erase ``mask`` (255 = remove) via the
chosen inpaint backend. Delegates to :func:`region_eraser.erase`; ``auto``
resolves to MI-GAN when installed else cv2 (see :func:`resolve_backend`)."""
from remove_ai_watermarks import region_eraser
det = mark.detect(image)
if not (det.detected or force):
return image.copy(), None
engine = _engine(mark.key)
fm = engine.footprint_mask(image, force=force) # uniform signature; text/pill ignore force
if fm is None or not fm.any():
return image.copy(), None
if preferred_inpaint_backend() == "migan":
result = region_eraser.erase_migan(image, fm)
else:
result = region_eraser.erase_cv2(image, fm, radius=6)
return result, (det.region if det.detected else None)
return region_eraser.erase(image, mask=mask, backend=resolve_backend(backend))
def _gemini_detect(image: NDArray[Any]) -> MarkDetection:
d = _engine("gemini").detect_watermark(image)
detected = bool(d.detected) and d.confidence >= _GEMINI_AUTO_MIN_CONF
# ── Detection adapters (verdict + bbox; no mask work on this path) ──
# The identify scan calls `detect_marks`, which must stay cheap (it runs every
# detector on the memory-tight identify host), so detection never builds a mask.
def _gemini_detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetection:
d = _engine("gemini").detect_watermark(image, trust_provenance=provenance)
gate = _GEMINI_PROVENANCE_MIN_CONF if provenance else _GEMINI_AUTO_MIN_CONF
detected = bool(d.detected) and d.confidence >= gate
return MarkDetection("gemini", "Google Gemini sparkle", "bottom-right", detected, d.confidence, d.region)
def _gemini_remove(
image: NDArray[Any], inpaint_method: InpaintMethod, inpaint: bool, strength: float, force: bool
) -> tuple[NDArray[Any], Region | None]:
engine = _engine("gemini")
det = engine.detect_watermark(image)
if not det.detected:
if not force:
return image.copy(), None
# Forced (--no-detect): remove at the default sparkle slot for the size.
from remove_ai_watermarks.gemini_engine import get_watermark_config
h, w = image.shape[:2]
cfg = get_watermark_config(w, h)
px, py = cfg.get_position(w, h)
region = (px, py, cfg.logo_size, cfg.logo_size)
result = engine.remove_watermark_custom(image, region)
if inpaint:
result = engine.inpaint_residual(result, region, strength=strength, method=inpaint_method)
return result, region
result = engine.remove_watermark(image)
# Reverse-alpha leaves a faint residual at the sparkle edge; the engine's
# own residual inpaint cleans that seam (part of its reverse-alpha pipeline).
if inpaint:
result = engine.inpaint_residual(result, det.region, strength=strength, method=inpaint_method)
return result, det.region
def _gemini_mask(
image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None
) -> NDArray[Any] | None:
# Reuse the decision's provenance-aware region (skip the strict re-detect that would
# otherwise re-demote a relaxed sparkle to None); None region -> footprint_mask
# falls back to its own detect-then-force path (direct/--no-detect callers).
region = detection.region if (detection is not None and detection.detected) else None
return _engine("gemini").footprint_mask(image, force=force, region=region)
# The three text-mark engines (Doubao/Jimeng/Samsung) share the TextMarkEngine
# interface, so one parameterized adapter pair drives all of them -- a new
# reverse-alpha text mark is one `_text_mark(...)` row below, not another copy-paste
# of these bodies. Removal is reverse-alpha only: applied when the mark is detected
# (or forced) and the alpha asset loads, otherwise skipped (no hallucination on a
# clean corner).
def _text_mark_detect(key: str, label: str, location: str) -> Callable[[NDArray[Any]], MarkDetection]:
def detect(image: NDArray[Any]) -> MarkDetection:
d = _engine(key).detect(image)
# text mark is one `_text_mark(...)` row below, not another copy-paste of these
# bodies. Detection matches the glyph silhouette; the mask is the template-free
# glyph-bbox footprint (see TextMarkEngine.footprint_mask).
def _text_mark_detect(key: str, label: str, location: str) -> Callable[..., MarkDetection]:
def detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetection:
d = _engine(key).detect(image, provenance=provenance)
return MarkDetection(key, label, location, d.detected, d.confidence, d.region)
return detect
def _text_mark_remove(key: str) -> Callable[..., tuple[NDArray[Any], Region | None]]:
def remove(
image: NDArray[Any], _inpaint_method: InpaintMethod, _inpaint: bool, _strength: float, force: bool
) -> tuple[NDArray[Any], Region | None]:
engine = _engine(key)
det = engine.detect(image)
if (det.detected or force) and engine.reverse_alpha_available(image):
return engine.remove_watermark_reverse_alpha(image), (det.region if det.detected else None)
return image.copy(), None
def _text_mark_mask(key: str) -> Callable[..., NDArray[Any] | None]:
def mask(
image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None
) -> NDArray[Any] | None:
# Text masks rebuild the glyph blob template-free (no trust gate to re-apply), so
# the detection is not needed here; accepted for the uniform _mask signature.
del detection
return _engine(key).footprint_mask(image, force=force)
return remove
return mask
def _text_mark(key: str, label: str, location: str) -> KnownMark:
"""A reverse-alpha text-mark registry row (Doubao/Jimeng/Samsung)."""
return KnownMark(
key, label, location, True, "reverse-alpha", _text_mark_detect(key, label, location), _text_mark_remove(key)
)
"""A text-mark registry row (Doubao/Jimeng/Samsung): glyph-silhouette detect +
template-free glyph-bbox mask."""
return KnownMark(key, label, location, True, _text_mark_detect(key, label, location), _text_mark_mask(key))
# ── Capture-less mark: the Jimeng-basic "AI生成" pill (top-left, inpaint-only) ──
# No alpha map exists, so removal is inpaint only (has_capture=False routes every
# method to inpaint). Detection is edge-NCC of a synthetic silhouette; see pill_engine.
def _pill_detect(image: NDArray[Any]) -> MarkDetection:
# ── Capture-less mark: the Jimeng-basic "AI生成" pill (top-left) ──
# Detection is edge-NCC of a synthetic silhouette; the mask is a fixed top-left
# geometry box (see pill_engine). Removal is the same localize -> fill as the rest.
def _pill_detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetection:
del provenance # the pill detector is provenance-independent; its relaxation lives entirely in _keep_pill
d = _engine("jimeng_pill").detect(image)
return MarkDetection("jimeng_pill", "Jimeng AI生成 pill", "top-left", d.detected, d.confidence, d.region)
def _pill_noop_remove(
image: NDArray[Any], _im: InpaintMethod, _ip: bool, _st: float, _force: bool
) -> tuple[NDArray[Any], Region | None]:
# Capture-less: reverse-alpha is impossible. resolve_removal_method routes every
# method to inpaint (_inpaint_remove), so this reverse-alpha slot is never reached;
# return an untouched copy defensively.
return image.copy(), None
def _pill_mask(
image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None
) -> NDArray[Any] | None:
# The pill mask is a fixed top-left geometry box, independent of the detection;
# accepted for the uniform _mask signature.
del detection
return _engine("jimeng_pill").footprint_mask(image, force=force)
def _pill_features(image: NDArray[Any]) -> dict[str, float]:
"""The pill's own gate feature: top-left footprint flatness (1.0 = flat enough for
an invisible fill), read by the metadata/assume arm of :func:`_keep_pill`."""
return {"footprint_flat": float(_engine("jimeng_pill").footprint_is_flat(image))}
_REGISTRY: tuple[KnownMark, ...] = (
KnownMark("gemini", "Google Gemini sparkle", "bottom-right", True, "reverse-alpha", _gemini_detect, _gemini_remove),
KnownMark("gemini", "Google Gemini sparkle", "bottom-right", True, _gemini_detect, _gemini_mask),
_text_mark("doubao", "Doubao 豆包AI生成 text", "bottom-right"),
_text_mark("jimeng", "Jimeng 即梦AI wordmark", "bottom-right"),
_text_mark("samsung", "Samsung Galaxy AI text", "bottom-left"),
KnownMark("jimeng_pill", "Jimeng AI生成 pill", "top-left", True, "inpaint", _pill_detect, _pill_noop_remove, False),
KnownMark("jimeng_pill", "Jimeng AI生成 pill", "top-left", True, _pill_detect, _pill_mask, _pill_features),
)
@@ -329,34 +431,60 @@ def get_mark(key: str) -> KnownMark:
raise KeyError(key)
def detect_marks(image: NDArray[Any], *, include_explicit: bool = True) -> list[MarkDetection]:
def detect_marks(
image: NDArray[Any],
*,
include_explicit: bool = True,
provenance: frozenset[str] = frozenset(),
) -> list[MarkDetection]:
"""Detect every known mark in its usual place.
Returns one MarkDetection per scanned mark (``detected`` flags which fired).
``include_explicit=False`` scans only the ``in_auto`` marks -- the set used
by ``--mark auto``."""
return [m.detect(image) for m in _REGISTRY if include_explicit or m.in_auto]
by ``--mark auto``. ``provenance`` names the vendor keys that external metadata
already confirms, so each named mark's detector may relax its trust gate."""
return [m.detect(image, provenance=m.key in provenance) for m in _REGISTRY if include_explicit or m.in_auto]
def best_auto_mark(image: NDArray[Any]) -> MarkDetection | None:
"""The highest-confidence detected ``in_auto`` mark, or None if none fired."""
fired = [d for d in detect_marks(image, include_explicit=False) if d.detected]
return max(fired, key=lambda d: d.confidence) if fired else None
def resolve_relax(
key: str,
*,
sensitivity: Sensitivity,
provenance: frozenset[str],
strict_keys: set[str],
) -> bool:
"""Whether mark ``key``'s detection gate is relaxed (strict -> assume level).
The single place that turns the ``sensitivity`` policy + evidence into a per-mark
boolean (which the engines consume): ``strict`` never relaxes, ``assume_ai`` always
relaxes, and ``auto`` relaxes only on same-product evidence -- the vendor confirmed
by metadata (``key in provenance``) or a confidently strict-detected sibling of the
same product (``_PRODUCT_OF``)."""
if sensitivity == "strict":
return False
if sensitivity == "assume_ai":
return True
if key in provenance:
return True
product = _PRODUCT_OF[key]
return any(_PRODUCT_OF[k] == product for k in strict_keys if k != key)
def _keep_pill(image: NDArray[Any], keys: set[str], *, pill_metadata: bool) -> bool:
def _keep_pill(keys: set[str], *, provenance: frozenset[str], sensitivity: Sensitivity, footprint_flat: bool) -> bool:
"""Whether to auto-remove the capture-less 'AI生成' pill given the fired marks.
The pill detector is weak (~7% raw false-fire) and metadata confirms the platform,
not pill presence, so a naive metadata-OR-wordmark gate over-fires: on a 32k
real-upload corpus (2026-07) the metadata-only arm was only ~27% precise and its
false fires were textured ceilings/walls that inpaint visibly SMEARS. Two arms:
Pure decision (the flatness feature is precomputed at perception time and passed
in). The pill detector is weak (~7% raw false-fire) and metadata/intent confirms
the platform, not pill presence, so a naive confirmation-OR gate over-fires: on a
32k real-upload corpus (2026-07) the metadata-only arm was only ~27% precise and
its false fires were textured ceilings/walls that the fill visibly SMEARS. Arms:
* bottom-right "★ 即梦AI" wordmark fired -> ~94% precise, and it survives
metadata-STRIPPED uploads: remove the pill unrestricted;
* metadata only (TC260 AIGC, no wordmark) -> remove ONLY when the top-left
footprint is flat enough for an invisible inpaint (``footprint_is_flat``),
so real flat-scene pills (and harmless flat false fires) are cleaned while
the damaging textured false fires are left untouched.
* TC260 metadata confirms Jimeng (``"jimeng" in provenance``, no wordmark) OR the
caller asserts AI (``sensitivity == "assume_ai"``) -> remove ONLY when the
top-left footprint is flat enough for an invisible fill (``footprint_flat``),
so real flat-scene pills (and harmless flat false fires) are cleaned while the
damaging textured false fires are left untouched even under assume_ai.
A Doubao image is TC260 too but is not Jimeng-basic, so the pill never rides on a
Doubao detection. No confirmation at all -> never remove (blocks false fires on
non-Jimeng content)."""
@@ -364,44 +492,92 @@ def _keep_pill(image: NDArray[Any], keys: set[str], *, pill_metadata: bool) -> b
return False
if "jimeng" in keys:
return True
if pill_metadata:
return bool(_engine("jimeng_pill").footprint_is_flat(image))
if "jimeng" in provenance or sensitivity == "assume_ai":
return footprint_flat
return False
def _build_candidates(image: NDArray[Any]) -> list[Candidate]:
"""PERCEPTION pass: run every ``in_auto`` mark's detector at both trust levels and
package the raw verdicts + physical features into :class:`Candidate` objects. No
policy here -- the arbiter (:func:`decide`) makes every keep/drop call.
Each mark is detected at the strict AND the relaxed (``provenance=True``) level so
:func:`decide` can pick per mark without re-running detection; a relaxed gate is
monotonically more permissive, so this reproduces the old strict-then-relax pass
exactly. The loop is uniform -- it knows nothing about any specific mark: each mark
reports its own gate features via :meth:`KnownMark.features` (computed only when the
mark is detected, so a clean image pays nothing extra)."""
cands: list[Candidate] = []
for m in _REGISTRY:
if not m.in_auto:
continue
strict = m.detect(image, provenance=False)
relaxed = m.detect(image, provenance=True)
feats = m.features(image) if (strict.detected or relaxed.detected) else {}
cands.append(Candidate(m.key, m.label, strict.detected, relaxed.detected, feats))
return cands
def decide(candidates: list[Candidate], context: Context) -> list[Decision]:
"""The removal ARBITER: a pure function turning perception + context into the
ordered list of marks to remove (and the trust level each was accepted at).
All policy lives here, in one place: per-mark relaxation (:func:`resolve_relax`,
which needs the strict-detected siblings for ``auto`` cross-mark corroboration) and
the capture-less pill gate (:func:`_keep_pill`). No image, no I/O -- so it is
unit-testable in isolation and the same decision drives every caller."""
strict_keys = {c.key for c in candidates if c.detected_strict}
fired: list[Decision] = []
for c in candidates:
relax = resolve_relax(
c.key, sensitivity=context.sensitivity, provenance=context.provenance, strict_keys=strict_keys
)
if c.detected_relaxed if relax else c.detected_strict:
fired.append(Decision(c, relax))
keys = {d.candidate.key for d in fired}
if "jimeng_pill" in keys:
pill = next(d for d in fired if d.candidate.key == "jimeng_pill")
flat = bool(pill.candidate.features.get("footprint_flat", 0.0))
if not _keep_pill(
keys,
provenance=context.provenance,
sensitivity=context.sensitivity,
footprint_flat=flat,
):
fired = [d for d in fired if d.candidate.key != "jimeng_pill"]
return fired
def remove_auto_marks(
image: NDArray[Any],
*,
pill_metadata: bool = False,
method: RemovalMethod = "auto",
inpaint_method: InpaintMethod = "ns",
inpaint: bool = True,
inpaint_strength: float = 0.85,
sensitivity: Sensitivity = "auto",
provenance: frozenset[str] = frozenset(),
backend: Backend = "auto",
) -> tuple[NDArray[Any], list[str]]:
"""Remove EVERY detected ``in_auto`` mark in one pass, chaining the result.
"""Remove EVERY decided ``in_auto`` mark in one pass, chaining the result.
Marks coexist in different corners -- a Jimeng-basic image carries BOTH the
top-left "AI生成" pill AND the bottom-right "★ 即梦AI" wordmark -- and their
confidences are on different scales, so ``best_auto_mark`` (single strongest)
would clean only one and leave the other (issue #54). Each mark re-detects at
its own corner on the progressively-cleaned image, so order does not matter.
The three stages are separated: PERCEPTION (:func:`_build_candidates` -- engines
report what they see, no policy), DECISION (:func:`decide` -- the pure arbiter over
``(candidates, Context)``), ACTION (localize -> :func:`fill` per winner). Marks
coexist in different corners -- a Jimeng-basic image carries BOTH the top-left pill
AND the bottom-right wordmark -- so every decided mark is removed, chained on the
progressively-cleaned image (order does not matter, each re-localizes its corner).
The capture-less ``jimeng_pill`` has a weak edge-NCC detector (~7% raw
false-fire), so its removal is gated by ``_keep_pill`` (Jimeng-class confirmation
+ a safe-inpaint check on the metadata-only arm; see that helper). Returns
``(result, [labels removed])``; an empty list means nothing fired."""
fired = [d for d in detect_marks(image, include_explicit=False) if d.detected]
keys = {d.key for d in fired}
if "jimeng_pill" in keys and not _keep_pill(image, keys, pill_metadata=pill_metadata):
fired = [d for d in fired if d.key != "jimeng_pill"]
Three orthogonal knobs: ``sensitivity`` (how hard to trust a borderline mark --
see :data:`Sensitivity`), ``provenance`` (vendor keys external metadata confirms,
the evidence that drives ``auto``; the TC260 label maps to ``jimeng``/``doubao``),
and ``backend`` (the shared fill). Returns ``(result, [labels removed])``; empty
means nothing fired."""
context = Context(sensitivity=sensitivity, provenance=provenance)
result = image
for det in fired:
result, _ = get_mark(det.key).remove(
result,
method=method,
inpaint_method=inpaint_method,
inpaint=inpaint,
inpaint_strength=inpaint_strength,
force=False,
)
return result, [d.label for d in fired]
labels: list[str] = []
for d in decide(_build_candidates(image), context):
result, region = get_mark(d.candidate.key).remove(result, backend=backend, provenance=d.relax, force=False)
# Only report the mark as removed when a fill actually happened: remove() returns
# a None region when the localized mask came back empty, and reporting it anyway
# would claim a removal that left the pixels unchanged.
if region is not None:
labels.append(d.candidate.label)
return result, labels
+128
View File
@@ -0,0 +1,128 @@
"""High-level convenience API (remove_visible / visible_provenance) and the lazy
top-level re-exports."""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
import remove_ai_watermarks as raiw
from remove_ai_watermarks import api
SAMPLES = Path(__file__).resolve().parents[1] / "data" / "samples"
DOUBAO = SAMPLES / "doubao-1.png"
CHATGPT = SAMPLES / "chatgpt-1.png"
class TestTopLevelExports:
def test_lazy_reexports_resolve(self):
assert raiw.remove_visible is api.remove_visible
assert raiw.visible_provenance is api.visible_provenance
def test_unknown_attribute_raises(self):
with pytest.raises(AttributeError):
_ = raiw.does_not_exist
def test_bare_import_is_light(self):
# importing the package must not pull the heavy cv2/torch stack (PEP 562 lazy).
# Checked in a FRESH interpreter -- another test in this process may already
# have imported cv2, so an in-process sys.modules check would be flaky.
import subprocess
import sys
code = "import remove_ai_watermarks, sys; print(int(any(m in sys.modules for m in ('cv2','torch'))))"
out = subprocess.run( # noqa: S603 -- fixed sys.executable + literal code, no untrusted input
[sys.executable, "-c", code], check=True, capture_output=True, text=True
)
assert out.stdout.strip() == "0", f"bare import pulled a heavy module: {out.stdout!r}"
class TestRemoveVisibleArray:
def test_array_no_mark_is_noop_copy(self):
arr = np.zeros((256, 256, 3), np.uint8)
result, removed = raiw.remove_visible(arr, backend="cv2")
assert removed == []
assert result.shape == arr.shape
assert np.array_equal(result, arr)
def test_array_accepts_knobs(self):
arr = np.zeros((256, 256, 3), np.uint8)
result, removed = raiw.remove_visible(arr, sensitivity="assume_ai", backend="cv2")
assert removed == []
assert result.shape == arr.shape
def test_bad_source_raises(self, tmp_path):
with pytest.raises(ValueError, match="Could not read image"):
raiw.remove_visible(tmp_path / "nope.png")
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
class TestRemoveVisiblePath:
def test_path_removes_and_writes(self, tmp_path):
out = tmp_path / "clean.png"
result, removed = raiw.remove_visible(DOUBAO, out, backend="cv2")
assert out.exists()
assert any("Doubao" in lbl for lbl in removed)
assert result.shape[2] == 3
def test_path_no_output_returns_without_writing(self, tmp_path):
# output=None returns the array but writes nothing
result, _ = raiw.remove_visible(DOUBAO, backend="cv2")
assert result.ndim == 3
class TestNoOpPreservesOriginal:
def test_no_mark_copies_original_bytes(self, tmp_path):
# A clean image (no mark) same-format-out must be copied VERBATIM, not
# re-encoded -- so a no-op never degrades the original ("work with originals").
import filecmp
from PIL import Image
src = tmp_path / "clean.jpg"
Image.fromarray(np.full((40, 40, 3), 120, np.uint8), "RGB").save(src, quality=90)
out = tmp_path / "clean_out.jpg"
_, removed = raiw.remove_visible(str(src), str(out), sensitivity="strict", backend="cv2")
assert removed == []
assert filecmp.cmp(str(src), str(out), shallow=False) # byte-identical
class TestVisibleProvenance:
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
def test_doubao_tc260_maps_to_bytedance(self):
prov = raiw.visible_provenance(DOUBAO)
# TC260 label -> ByteDance family (both doubao and jimeng)
assert {"doubao", "jimeng"} <= prov
@pytest.mark.skipif(not CHATGPT.exists(), reason="chatgpt sample not present")
def test_openai_image_has_no_visible_vendor(self):
# OpenAI C2PA is not one of the visible-mark vendors -> empty provenance
assert raiw.visible_provenance(CHATGPT) == frozenset()
def test_unreadable_path_is_empty(self, tmp_path):
assert raiw.visible_provenance(tmp_path / "missing.png") == frozenset()
class TestRemoveVisibleOutputPath:
"""Output-path robustness: in-place clean (#3) and a missing output dir (#4)."""
def _write_clean(self, p: Path) -> None:
from remove_ai_watermarks import image_io
image_io.imwrite(str(p), np.full((128, 128, 3), 200, np.uint8))
def test_inplace_clean_no_crash(self, tmp_path: Path):
p = tmp_path / "clean.png"
self._write_clean(p)
_, removed = raiw.remove_visible(str(p), str(p), backend="cv2")
assert removed == []
assert p.exists()
def test_creates_missing_output_dir(self, tmp_path: Path):
src = tmp_path / "in.png"
self._write_clean(src)
out = tmp_path / "sub" / "out.png"
raiw.remove_visible(str(src), str(out), backend="cv2")
assert out.exists()
+26 -2
View File
@@ -172,7 +172,9 @@ class TestVisibleCommand:
expected = sample_png.with_stem(sample_png.stem + "_clean")
assert expected.exists()
def test_visible_no_inpaint(self, runner, sample_png, tmp_path):
def test_visible_backend_cv2(self, runner, sample_png, tmp_path):
# The old --inpaint/--no-inpaint flags are gone; the fill backend is picked
# with --backend (localize -> fill). cv2 needs no ONNX model.
output = tmp_path / "clean.png"
result = runner.invoke(
main,
@@ -181,7 +183,8 @@ class TestVisibleCommand:
str(sample_png),
"-o",
str(output),
"--no-inpaint",
"--backend",
"cv2",
"--no-detect",
],
)
@@ -863,3 +866,24 @@ class TestEraseCommand:
assert result.exit_code != 0
assert "onnxruntime" in result.output.lower()
assert not output.exists()
def test_visible_backend_runtime_error_exits_cleanly(runner, tmp_path, monkeypatch):
# A backend whose extra is missing raises RuntimeError in region_eraser.erase;
# the visible --mark auto path must surface it cleanly, not as a raw traceback (#9).
from pathlib import Path
from remove_ai_watermarks import region_eraser
doubao = Path(__file__).resolve().parent.parent / "data" / "samples" / "doubao-1.png"
if not doubao.exists():
pytest.skip("doubao sample not present")
def boom(*_a, **_k):
raise RuntimeError("MI-GAN backend requires onnxruntime. Install the extra: ...")
monkeypatch.setattr(region_eraser, "erase", boom)
out = tmp_path / "out.png"
result = runner.invoke(main, ["visible", str(doubao), "-o", str(out), "--backend", "migan"])
assert result.exit_code == 1
assert not isinstance(result.exception, RuntimeError), "RuntimeError leaked as a traceback"
+63 -97
View File
@@ -1,4 +1,4 @@
"""Tests for the Doubao visible-watermark engine (reverse-alpha only)."""
"""Tests for the Doubao visible-watermark engine (localize -> fill)."""
from __future__ import annotations
@@ -8,11 +8,9 @@ import cv2
import numpy as np
import pytest
from remove_ai_watermarks import watermark_registry as registry
from remove_ai_watermarks.doubao_engine import (
_ALPHA_HEIGHT_FRAC,
_ALPHA_LOGO_BGR,
_ALPHA_MARGIN_BOTTOM_FRAC,
_ALPHA_MARGIN_RIGHT_FRAC,
_ALPHA_NATIVE_WIDTH,
_ALPHA_WIDTH_FRAC,
DETECT_NCC_THRESHOLD,
@@ -26,6 +24,22 @@ from remove_ai_watermarks.doubao_engine import (
SAMPLE = Path(__file__).resolve().parents[1] / "data" / "samples" / "doubao-1.png"
def _compose(w: int, h: int, bg: float = 100.0):
"""Composite the real alpha (scaled to width ``w``) onto a flat bg.
Returns ``(watermarked_uint8, mark_bool_mask)``."""
img = np.full((h, w, 3), bg, np.float32)
at = _alpha_template()
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
margin = int(0.015 * w)
ax = w - margin - gw
ay = h - margin - gh
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
wm = (a3 * 255.0 + (1 - a3) * img).clip(0, 255).astype(np.uint8)
return wm, amap > 0.2
class TestLocate:
def test_box_anchored_bottom_right(self):
eng = DoubaoEngine()
@@ -81,15 +95,7 @@ class TestDetect:
the CJK silhouette (2026-06-26 FP: a 48x48 app-icon chevron scored 0.41). The
size guard suppresses detection there. Bracket it: a real mark is detected at
native size, but the same content downscaled below the guard is not."""
w = h = _ALPHA_NATIVE_WIDTH
at = _alpha_template()
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
ax = w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw
ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * 100.0).clip(0, 255).astype(np.uint8)
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
eng = DoubaoEngine()
assert eng.detect(wm).detected # native: real mark detected
assert not eng.detect(cv2.resize(wm, (150, 150))).detected # below guard: suppressed
@@ -102,25 +108,25 @@ class TestRealSample:
assert det.detected
assert det.confidence >= DETECT_NCC_THRESHOLD
def test_reverse_alpha_removes_mark(self):
eng = DoubaoEngine()
def test_fill_lowers_confidence(self):
img = load_image_bgr(SAMPLE)
assert eng.reverse_alpha_available(img) # sample is at the captured width
out = eng.remove_watermark_reverse_alpha(img)
assert not eng.detect(out).detected # mark gone after recovery
before = DoubaoEngine().detect(img)
assert before.detected
out, region = registry.get_mark("doubao").remove(img, backend="cv2")
assert region is not None
assert DoubaoEngine().detect(out).confidence < before.confidence
def test_far_region_untouched(self):
eng = DoubaoEngine()
img = load_image_bgr(SAMPLE)
out = eng.remove_watermark_reverse_alpha(img)
out, _ = registry.get_mark("doubao").remove(img, backend="cv2")
h, w = img.shape[:2]
assert np.array_equal(img[: h // 2, : w // 2], out[: h // 2, : w // 2])
# ── Reverse-alpha (exact recovery) ──────────────────────────────────
# ── Alpha asset + footprint mask + localize -> fill removal ─────────
class TestReverseAlpha:
class TestAlphaAsset:
def test_alpha_asset_loads(self):
at = _alpha_template()
assert at is not None
@@ -128,104 +134,64 @@ class TestReverseAlpha:
assert float(at.min()) >= 0.0
assert float(at.max()) <= 1.0
def test_available_whenever_asset_present(self):
# NCC alignment generalizes to any resolution, so availability is just
# "asset loadable" (any non-empty image); the caller gates on detect.
eng = DoubaoEngine()
assert eng.reverse_alpha_available(np.zeros((1024, 1024, 3), np.uint8))
assert eng.reverse_alpha_available(np.zeros((1773, 1535, 3), np.uint8))
assert not eng.reverse_alpha_available(np.zeros((0, 0, 3), np.uint8))
@staticmethod
def _compose(w: int, h: int, bg: float = 100.0):
"""Composite the real alpha (scaled to width ``w``) onto a flat bg.
Returns ``(watermarked_uint8, mark_bool_mask)``."""
img = np.full((h, w, 3), bg, np.float32)
at = _alpha_template()
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
ax = w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw
ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * img).clip(0, 255).astype(np.uint8)
return wm, amap > 0.2
class TestFootprintMaskAndRemoval:
def test_footprint_mask_in_bottom_right(self):
"""A composed mark yields a footprint mask localized to the bottom-right."""
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
mask = DoubaoEngine().footprint_mask(wm)
assert mask is not None
assert mask.shape == wm.shape[:2]
ys, xs = np.where(mask > 0)
assert ys.mean() > wm.shape[0] / 2
assert xs.mean() > wm.shape[1] / 2
def test_removes_synthetic_mark(self):
"""Reverse-alpha + thin residual inpaint clears a mark composed from the
real alpha (re-detect no longer fires)."""
eng = DoubaoEngine()
wm, _mark = self._compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
assert eng.detect(wm).detected
out = eng.remove_watermark_reverse_alpha(wm)
assert not eng.detect(out).detected
"""localize -> cv2 fill clears a mark composed from the real alpha
(re-detect no longer fires)."""
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
assert DoubaoEngine().detect(wm).detected
out, region = registry.get_mark("doubao").remove(wm, backend="cv2")
assert region is not None
assert not DoubaoEngine().detect(out).detected
@pytest.mark.parametrize(
("w", "h", "max_err"),
("w", "h"),
[
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH, 5.0), # captured width
(1773, 2364, 8.0), # 3:4 portrait -> NCC alignment generalizes the single capture
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH), # captured width
(1773, 2364), # 3:4 portrait -> template-free footprint generalizes
],
)
def test_recovers_flat_background(self, w, h, max_err):
"""Recovers the flat background at the captured width AND a non-native
resolution (NCC alignment generalizes the single capture)."""
eng = DoubaoEngine()
wm, mark = self._compose(w, h)
def test_fill_removes_and_leaves_far_region(self, w, h):
"""The fill lowers re-detect confidence and leaves the far corner exact."""
wm, mark = _compose(w, h)
assert float(np.abs(wm.astype(np.float32)[mark] - 100.0).mean()) > 15 # mark visible
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
assert float(np.abs(out[mark] - 100.0).mean()) < max_err
@staticmethod
def _textured_bg(w: int, h: int):
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
base = 120 + 40 * np.sin(xx / 90.0) + 30 * np.cos(yy / 70.0)
return np.clip(np.stack([base, base * 0.95, base * 1.05], axis=-1), 0, 255)
def test_recovers_shifted_mark_on_texture(self):
"""A real mark is re-rasterized a few px off its fixed slot, so removal
must NCC-align to it. Regression guard for the issue-#13 follow-up defect:
a too-tight locate box let a corner-ward shift fall outside the alignment
search, leaving a readable outline that the detector did not flag. Composes
the real alpha SHIFTED on a known texture and asserts the texture is
recovered (a misaligned removal would leave the bright glyph outline)."""
eng = DoubaoEngine()
w = h = _ALPHA_NATIVE_WIDTH
at = _alpha_template()
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
ax = w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw + 12 # shift toward the corner
ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh + 8
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
bg = self._textured_bg(w, h)
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * bg).clip(0, 255).astype(np.uint8)
mark = amap > 0.15
assert float(np.abs(wm.astype(np.float32)[mark] - bg[mark]).mean()) > 30 # mark clearly visible
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
assert float(np.abs(out[mark] - bg[mark]).mean()) < 8.0 # texture recovered, no outline
before = DoubaoEngine().detect(wm)
out, _ = registry.get_mark("doubao").remove(wm, backend="cv2")
assert DoubaoEngine().detect(out).confidence < before.confidence
assert np.array_equal(out[: h // 2, : w // 2], wm[: h // 2, : w // 2])
class TestDegenerateAndChannelInputs:
"""Removal must not crash on degenerate sizes or non-3-channel inputs."""
"""footprint_mask must not crash on degenerate sizes or non-3-channel inputs."""
@pytest.mark.parametrize(("w", "h"), [(2048, 1), (1, 2048), (2048, 8)])
def test_wide_short_does_not_raise(self, w, h):
"""A wide/short image at native width makes the width-derived glyph box
taller than the image; the slice assignment must not ValueError."""
taller than the image; masking must not ValueError."""
eng = DoubaoEngine()
img = np.zeros((h, w, 3), np.uint8)
out = eng.remove_watermark_reverse_alpha(img)
assert out.shape == img.shape
mask = eng.footprint_mask(img, force=True)
assert mask is None or mask.shape == (h, w)
def test_grayscale_2d_does_not_raise(self):
eng = DoubaoEngine()
gray = np.zeros((2048, 2048), np.uint8)
out = eng.remove_watermark_reverse_alpha(gray)
assert out.shape == (2048, 2048, 3)
mask = eng.footprint_mask(gray, force=True)
assert mask is None or mask.shape == (2048, 2048)
def test_bgra_4channel_does_not_raise(self):
eng = DoubaoEngine()
bgra = np.zeros((2048, 2048, 4), np.uint8)
out = eng.remove_watermark_reverse_alpha(bgra)
assert out.shape == (2048, 2048, 3)
mask = eng.footprint_mask(bgra, force=True)
assert mask is None or mask.shape == (2048, 2048)
+81 -290
View File
@@ -102,28 +102,6 @@ class TestGeminiEngine:
assert small.shape == (48, 48)
assert large.shape == (96, 96)
def test_remove_watermark_returns_same_shape(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.remove_watermark(image)
assert result.shape == image.shape
assert result.dtype == np.uint8
def test_remove_watermark_does_not_modify_input(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
original = image.copy()
self.engine.remove_watermark(image)
np.testing.assert_array_equal(image, original)
def test_remove_watermark_large_image(self, tmp_large_image_path):
image = cv2.imread(str(tmp_large_image_path), cv2.IMREAD_COLOR)
result = self.engine.remove_watermark(image)
assert result.shape == image.shape
def test_remove_watermark_custom_region(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.remove_watermark_custom(image, (10, 10, 48, 48))
assert result.shape == image.shape
def test_detect_on_grayscale_does_not_crash(self):
# A 2D grayscale array reaching detect_watermark (registry adapter / library
# API) must not crash the FP-gate's axis=2 reduction; it is normalized to BGR.
@@ -131,26 +109,59 @@ class TestGeminiEngine:
result = self.engine.detect_watermark(gray)
assert result is not None
def test_remove_on_bgra_returns_3_channel(self):
# ── Removal: localize -> fill ───────────────────────────────────────
def _composite_sparkle(engine, bg_value: int = 160, size: int = 1400):
"""Composite the captured sparkle at the configured position on a textured
mid-tone image (texture so the filled footprint no longer NCC-matches the
template). Returns ``(watermarked_uint8, (x, y, w, h))``."""
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
base = bg_value + 18 * np.sin(xx / 40.0) + 14 * np.cos(yy / 55.0)
img = np.clip(np.stack([base, base * 0.97, base * 1.03], axis=-1), 0, 255)
config = get_watermark_config(size, size)
x, y = config.get_position(size, size)
alpha = engine.get_interpolated_alpha(config.logo_size)
ah, aw = alpha.shape[:2]
a = alpha[:, :, None]
roi = img[y : y + ah, x : x + aw]
img[y : y + ah, x : x + aw] = a * 255.0 + (1.0 - a) * roi
return np.clip(img, 0, 255).astype(np.uint8), (x, y, aw, ah)
class TestRemovalLocalizeFill:
"""Removal is localize (footprint_mask) -> fill (shared inpaint backend)."""
@pytest.fixture(autouse=True)
def _setup_engine(self):
self.engine = GeminiEngine()
def test_footprint_mask_covers_detected_sparkle(self):
img, (x, y, w, h) = _composite_sparkle(self.engine)
assert self.engine.detect_watermark(img).detected
mask = self.engine.footprint_mask(img)
assert mask is not None
assert mask.shape == img.shape[:2]
# The mask mass overlaps the sparkle footprint box.
assert int(mask[y : y + h, x : x + w].sum()) > 0
def test_fill_lowers_confidence(self):
import remove_ai_watermarks.watermark_registry as registry
img, _ = _composite_sparkle(self.engine)
before = self.engine.detect_watermark(img).confidence
mask = self.engine.footprint_mask(img)
assert mask is not None
out = registry.fill(img, mask, backend="cv2")
assert self.engine.detect_watermark(out).confidence < before
def test_footprint_mask_bgra_input(self):
# A 4-channel input is normalized before masking; no crash, right shape.
bgra = np.zeros((300, 300, 4), dtype=np.uint8)
bgra[..., 3] = 255
result = self.engine.remove_watermark(bgra)
assert result.shape == (300, 300, 3)
def test_remove_watermark_custom_large_region(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.remove_watermark_custom(image, (10, 10, 96, 96))
assert result.shape == image.shape
def test_remove_watermark_custom_arbitrary_region(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.remove_watermark_custom(image, (5, 5, 60, 60))
assert result.shape == image.shape
def test_force_size(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.remove_watermark(image, force_size=WatermarkSize.LARGE)
assert result.shape == image.shape
mask = self.engine.footprint_mask(bgra, force=True)
assert mask is None or mask.shape == (300, 300)
# ── Detection ───────────────────────────────────────────────────────
@@ -203,239 +214,6 @@ class TestDetectSparkleConfidence:
assert detect_sparkle_confidence(bogus) is None
# ── Inpainting ──────────────────────────────────────────────────────
class TestInpainting:
"""Tests for residual inpainting."""
@pytest.fixture(autouse=True)
def _setup_engine(self):
self.engine = GeminiEngine()
def test_inpaint_ns(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.inpaint_residual(image, (150, 150, 48, 48), method="ns")
assert result.shape == image.shape
def test_inpaint_telea(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.inpaint_residual(image, (150, 150, 48, 48), method="telea")
assert result.shape == image.shape
def test_inpaint_gaussian(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.inpaint_residual(image, (150, 150, 48, 48), method="gaussian")
assert result.shape == image.shape
def test_inpaint_zero_strength(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.inpaint_residual(image, (150, 150, 48, 48), strength=0.0)
np.testing.assert_array_equal(result, image)
def test_inpaint_tiny_region_returns_unchanged(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.inpaint_residual(image, (10, 10, 2, 2))
np.testing.assert_array_equal(result, image)
def test_inpaint_does_not_modify_input(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
original = image.copy()
self.engine.inpaint_residual(image, (150, 150, 48, 48))
np.testing.assert_array_equal(image, original)
class TestOverSubtractionGuard:
"""Issue #30: reverse-alpha must not turn the sparkle into a black pit.
On a dark background the captured alpha over-estimates the real sparkle opacity,
so the fixed-alpha reverse blend over-subtracts and drives the footprint to black.
The engine detects this and inpaints the footprint instead.
"""
# Composite the mark at ~60% of the captured opacity: the engine's alpha maxes at
# ~0.51, real dark-background sparkles sit nearer ~0.31, so 0.6x reproduces the
# capture-over-estimates-reality mismatch that triggers the bug.
_REALISTIC_ALPHA_SCALE = 0.6
@pytest.fixture(autouse=True)
def _setup_engine(self):
self.engine = GeminiEngine()
def _composite_sparkle(self, bg_value: int, size: int = 1400, alpha_scale: float = _REALISTIC_ALPHA_SCALE):
"""Build a flat BGR image of ``bg_value`` with the sparkle composited in.
The mark is composited at a LOWER effective opacity than the engine's captured
alpha map (``alpha_scale`` < 1), reproducing the real-world mismatch behind
issue #30: the captured alpha (~0.51) over-estimates a real sparkle whose
effective opacity is lower, so the fixed-alpha reverse blend over-subtracts.
Placed at the configured large-image position so the detector locates it.
"""
img = np.full((size, size, 3), bg_value, dtype=np.float32)
config = get_watermark_config(size, size)
x, y = config.get_position(size, size)
alpha = self.engine.get_alpha_map(WatermarkSize.LARGE)
ah, aw = alpha.shape[:2]
a = (alpha * alpha_scale)[:, :, None]
roi = img[y : y + ah, x : x + aw]
img[y : y + ah, x : x + aw] = a * 255.0 + (1.0 - a) * roi
return np.clip(img, 0, 255).astype(np.uint8), (x, y, aw, ah)
def test_dark_background_does_not_leave_black_pit(self):
image, (x, y, w, h) = self._composite_sparkle(bg_value=60)
out = self.engine.remove_watermark(image)
footprint = out[y : y + h, x : x + w]
# The recovered footprint must read like the dark background, not a black hole.
assert footprint.min() > 25, f"black pit: min={footprint.min()}"
assert abs(float(footprint.mean()) - 60.0) < 25.0
def test_bright_background_keeps_reverse_alpha(self):
"""A bright background does not over-subtract, so reverse-alpha is used."""
bright, pos = self._composite_sparkle(bg_value=230)
alpha = self.engine.get_interpolated_alpha(pos[2])
assert self.engine._reverse_alpha_oversubtracts(bright, alpha, (pos[0], pos[1])) is False
dark, dpos = self._composite_sparkle(bg_value=60)
dalpha = self.engine.get_interpolated_alpha(dpos[2])
assert self.engine._reverse_alpha_oversubtracts(dark, dalpha, (dpos[0], dpos[1])) is True
def test_midtone_background_does_not_leave_dark_diamond(self):
"""2026-06-18 prod report: a faint sparkle on a MID-TONE background was
over-subtracted into a darker-than-background diamond ("the color just
changed, not removed"). No footprint pixel crosses zero there, so the
numerator gate misses it -- the dark-margin gate must catch it and inpaint.
"""
image, (x, y, w, h) = self._composite_sparkle(bg_value=160)
footprint = image[y : y + h, x : x + w]
# The numerator (black-pit) gate alone does NOT fire on a mid-tone background.
alpha = self.engine.get_interpolated_alpha(w)
roi = footprint.astype(np.float32).mean(axis=2)
body = alpha[:h, :w] >= self.engine._FOOTPRINT_ALPHA
numerator = roi - np.clip(alpha[:h, :w], 0.0, 0.99) * self.engine.logo_value
assert float((numerator[body] < 0).sum()) / float(body.sum()) <= self.engine._OVERSUB_FOOTPRINT_FRAC
# ...but the over-subtraction guard still trips (via the dark-margin path) and
# removal leaves the footprint reading like the mid-tone background, not darker.
assert self.engine._reverse_alpha_oversubtracts(image, alpha, (x, y)) is True
out = self.engine.remove_watermark(image)
cleaned = out[y : y + h, x : x + w]
assert abs(float(cleaned.mean()) - 160.0) < 15.0, f"dark diamond: mean={cleaned.mean()}"
assert int(cleaned.min()) > 160 - 30, f"dark pit: min={cleaned.min()}"
class TestUnderSubtractionGain:
"""Under-subtraction fix: a sparkle MORE opaque than the captured alpha must not
survive removal. The captured alpha (~0.51) under-represents such marks, so the
fixed-alpha reverse blend leaves a bright residual; the per-image gain scales the
alpha up to match this image's opacity. Mirror of TestOverSubtractionGuard.
"""
@pytest.fixture(autouse=True)
def _setup_engine(self):
self.engine = GeminiEngine()
def _composite_sparkle(self, bg_value: int, alpha_scale: float, size: int = 1400):
"""Flat ``bg_value`` image with the sparkle composited at ``alpha_scale`` opacity.
``alpha_scale`` > 1 makes the mark MORE opaque than the engine's captured alpha,
reproducing the under-subtraction case (real under-removed marks estimate ~1.47).
"""
img = np.full((size, size, 3), bg_value, dtype=np.float32)
config = get_watermark_config(size, size)
x, y = config.get_position(size, size)
alpha = self.engine.get_alpha_map(WatermarkSize.LARGE)
ah, aw = alpha.shape[:2]
a = np.clip(alpha * alpha_scale, 0.0, 1.0)[:, :, None]
roi = img[y : y + ah, x : x + aw]
img[y : y + ah, x : x + aw] = a * 255.0 + (1.0 - a) * roi
return np.clip(img, 0, 255).astype(np.uint8), (x, y, aw, ah)
def test_more_opaque_sparkle_estimates_gain_above_deadband(self):
image, pos = self._composite_sparkle(bg_value=80, alpha_scale=1.3)
alpha = self.engine.get_interpolated_alpha(pos[2])
gain = self.engine._estimate_alpha_gain(image, alpha, (pos[0], pos[1]))
assert gain > self.engine._ALPHA_GAIN_DEADBAND, f"gain {gain} did not exceed deadband"
def test_matching_sparkle_estimates_unit_gain(self):
"""A sparkle that matches the captured opacity gets ~1.0 (no scaling)."""
image, pos = self._composite_sparkle(bg_value=80, alpha_scale=1.0)
alpha = self.engine.get_interpolated_alpha(pos[2])
gain = self.engine._estimate_alpha_gain(image, alpha, (pos[0], pos[1]))
assert gain <= self.engine._ALPHA_GAIN_DEADBAND, f"matching sparkle scaled by {gain}"
def test_more_opaque_sparkle_is_removed(self):
"""The gain-scaled removal clears a more-opaque sparkle without a black pit.
Asserted on the footprint PIXELS, not the detector: the detector's NCC is
degenerate on a perfectly flat synthetic background (zero-variance regions
spuriously match), so a re-detect conf is meaningless here -- on real textured
images the same removal drops the detector from ~0.80 to ~0.27 (spaces corpus).
"""
image, (x, y, w, h) = self._composite_sparkle(bg_value=80, alpha_scale=1.3)
assert self.engine.detect_watermark(image).detected
before_max = int(image[y : y + h, x : x + w].max()) # bright sparkle present
assert before_max > 150
out = self.engine.remove_watermark(image)
footprint = out[y : y + h, x : x + w]
# Sparkle gone: no bright residual, no black pit, footprint reads like the bg.
assert int(footprint.max()) < 80 + 30, f"bright residual: max={footprint.max()}"
assert int(footprint.min()) > 25, f"black pit: min={footprint.min()}"
assert abs(float(footprint.mean()) - 80.0) < 20.0
class TestVerifyAndRepair:
"""Self-verify fallback: a sparkle that survives reverse-alpha is inpaint-repaired,
but only when that lowers the re-detect confidence (so it can never regress).
The detector NCC is degenerate on flat synthetic backgrounds, so the keep-best
control flow is driven through a stubbed ``detect_watermark`` rather than a real
re-detect (mirroring the reasoning in TestUnderSubtractionGain).
"""
@pytest.fixture(autouse=True)
def _setup_engine(self):
self.engine = GeminiEngine()
self.alpha = self.engine.get_interpolated_alpha(96)
self.pos = (200, 200)
def _stub_detect(self, confidences):
"""detect_watermark stub yielding the given confidences in order."""
seq = iter(confidences)
def fake(image, force_size=None):
return DetectionResult(detected=True, confidence=next(seq))
return fake
def _repair(self, result):
return self.engine._verify_and_repair(result, self.alpha, self.pos, WatermarkSize.LARGE)
def test_clean_removal_returned_untouched(self, monkeypatch):
"""Below the fallback threshold, the input is returned byte-identical."""
img = np.full((600, 600, 3), 90, dtype=np.uint8)
monkeypatch.setattr(self.engine, "detect_watermark", self._stub_detect([0.2]))
out = self._repair(img)
assert out is img # no copy, no inpaint
def test_keeps_footprint_inpaint_when_it_helps(self, monkeypatch):
"""A surviving sparkle is footprint-inpainted when that re-detects lower; the
footprint pixels change."""
img = np.full((600, 600, 3), 90, dtype=np.uint8)
# Bright residual block over the footprint so the inpaint visibly changes it.
img[self.pos[1] : self.pos[1] + 96, self.pos[0] : self.pos[0] + 96] = 240
# residual 0.7 (>= 0.5 triggers), candidate re-detects 0.2 (< residual -> keep).
monkeypatch.setattr(self.engine, "detect_watermark", self._stub_detect([0.7, 0.2]))
out = self._repair(img)
assert out is not img
assert not np.array_equal(out, img) # footprint was inpainted from surroundings
def test_repair_rejected_when_inpaint_does_not_help(self, monkeypatch):
"""When the inpaint does not lower the re-detect confidence, keep the original."""
img = np.full((600, 600, 3), 90, dtype=np.uint8)
# residual 0.7, candidate re-detects 0.75 (>= residual -> reject the inpaint).
monkeypatch.setattr(self.engine, "detect_watermark", self._stub_detect([0.7, 0.75]))
out = self._repair(img)
assert out is img
class TestSparkleFalsePositiveGate:
"""False-positive gate: a low-confidence shape match whose core is NOT brighter
than its surroundings (ornate/flat content, not a white sparkle overlay) is
@@ -514,14 +292,15 @@ class TestSparkleFalsePositiveGate:
assert det.confidence < 0.5
assert not det.detected
def test_bright_background_low_gradient_match_demoted(self):
"""Bright-background content FP (2026-06-26 landing-page reports: a snow+sky
photo and a white product render scored ~0.51). A bright background gives the
match a HIGH core-ring margin, so the margin gate cannot demote it -- but the
smooth blob lacks the crisp star edges of a real sparkle, so its GRADIENT NCC
is low. The gradient gate (``_SPARKLE_FP_GRAD``) demotes it. Reproduces the
regime with a heavily-blurred bright sparkle: high margin, low gradient, and a
pre-gate fusion above the 0.5 promote bar (so it WOULD have been detected).
def test_bright_white_blurred_sparkle_rescued(self):
"""A strong, bright, WHITE blurred sparkle is now KEPT (white-core rescue). A
faint / re-compressed real sparkle has soft edges (low gradient) like a smooth
blob, but its core is bright AND near-WHITE and its fused confidence clears the
trust gate, so the rescue (``_SPARKLE_WHITE_SAT`` / ``_SPARKLE_KEEP_CONF``) keeps
it -- recovering the metadata-stripped faint sparkles the grad gate used to drop.
The weaker (~0.51) bright-background FPs the grad gate was added for still get
demoted, because they fall below ``_SPARKLE_KEEP_CONF`` (0.52); the colored-blob
case below is demoted on core saturation.
"""
size = 1400
config = get_watermark_config(size, size)
@@ -533,16 +312,28 @@ class TestSparkleFalsePositiveGate:
img[y : y + ah, x : x + aw] = a * 255.0 + (1.0 - a) * img[y : y + ah, x : x + aw]
img = cv2.GaussianBlur(np.clip(img, 0, 255).astype(np.uint8), (39, 39), 0)
det = self.engine.detect_watermark(img)
# Pre-gate fusion clears the 0.5 promote bar (would have been detected)...
pre = det.spatial_score * 0.5 + det.gradient_score * 0.3 + det.variance_score * 0.2
assert pre > 0.5
# ...the OLD margin gate cannot catch it (bright background -> high margin)...
margin = self.engine._core_ring_margin(img, self.engine.get_interpolated_alpha(det.region[2]), det.region[:2])
assert margin is not None
assert margin >= self.engine._SPARKLE_FP_MARGIN
# ...but the gradient is low (smooth blob, not a crisp star)...
assert det.gradient_score < self.engine._SPARKLE_FP_GRAD # soft edges, low gradient
assert det.confidence >= self.engine._SPARKLE_KEEP_CONF # strong white-core match, kept
assert det.detected
def test_bright_colored_blob_still_demoted(self):
"""The white-core rescue must NOT re-admit a COLORED bright-corner FP (sky, sun,
a warm light): its core saturation is high, so the rescue does not apply and the
gradient gate still demotes it. Same blurred construction as the white sparkle
above, but tinted -- proving the discriminator is core color, not just brightness.
"""
size = 1400
config = get_watermark_config(size, size)
x, y = config.get_position(size, size)
alpha = self.engine.get_alpha_map(WatermarkSize.LARGE)
ah, aw = alpha.shape[:2]
img = np.full((size, size, 3), 110, dtype=np.float32)
a = np.clip(alpha * 1.6, 0.0, 1.0)[:, :, None]
color = np.array([40.0, 140.0, 235.0]) # BGR warm/orange -> saturated (non-white) core
img[y : y + ah, x : x + aw] = a * color + (1.0 - a) * img[y : y + ah, x : x + aw]
img = cv2.GaussianBlur(np.clip(img, 0, 255).astype(np.uint8), (39, 39), 0)
det = self.engine.detect_watermark(img)
assert det.gradient_score < self.engine._SPARKLE_FP_GRAD
# ...so the gradient gate demotes it below the detection bar.
assert det.confidence < 0.5
assert not det.detected
+3 -3
View File
@@ -493,7 +493,7 @@ _DEMO_AFTER = REPO_ROOT / "demo_banana_after.png"
@pytest.mark.skipif(not (_DEMO_BEFORE.exists() and _DEMO_AFTER.exists()), reason="demo banana pair not present")
class TestSparkleDetectRemoveAlignment:
"""Detect (identify) and remove (registry.best_auto_mark) must agree on the
"""Detect (identify) and remove (registry.detect_marks) must agree on the
same image -- the retained-corpus desync where identify reported a sparkle the
removal arbitration declined (or vice versa). Both gate on the single shared
GEMINI_SPARKLE_TRUST_CONF, so a sparkle just over the line is taken by BOTH
@@ -520,8 +520,8 @@ class TestSparkleDetectRemoveAlignment:
conf = detect_sparkle_confidence(path) or 0.0
identify_fires = conf >= GEMINI_SPARKLE_TRUST_CONF
best = watermark_registry.best_auto_mark(image_io.imread(path))
remove_takes_gemini = best is not None and best.key == "gemini"
dets = watermark_registry.detect_marks(image_io.imread(path), include_explicit=False)
remove_takes_gemini = any(d.key == "gemini" and d.detected for d in dets)
return identify_fires, remove_takes_gemini, conf
def test_above_threshold_both_fire(self, tmp_path: Path):
+93
View File
@@ -2,10 +2,12 @@
from __future__ import annotations
import io
from typing import TYPE_CHECKING
import cv2
import numpy as np
import pytest
from remove_ai_watermarks import image_io
@@ -103,3 +105,94 @@ class TestFailureSemantics:
# An unwritable path must return False (cv2.imwrite contract), not raise.
path = tmp_path / "no-such-dir" / "out.png"
assert image_io.imwrite(path, _make_bgr()) is False
def _avif_writable() -> bool:
"""True when the installed Pillow can ENCODE AVIF (needed to synthesize a fixture);
read support is what we test, but we need a writer to make the sample."""
from PIL import Image, features
if not (hasattr(features, "check") and features.check("avif")):
return False
try:
Image.fromarray(np.zeros((8, 8, 3), np.uint8)).save(io.BytesIO(), format="AVIF")
return True
except Exception:
return False
_HAS_AVIF_WRITE = _avif_writable()
class TestPillowFallback:
"""OpenCV cannot decode HEIC/AVIF; :func:`imread` falls back to Pillow (with the
pillow-heif plugin) so the pixel/removal path reads them. Uses a synthetic AVIF
(no corpus files); HEIC is exercised the same way in production."""
@pytest.mark.skipif(not _HAS_AVIF_WRITE, reason="Pillow AVIF encoder unavailable")
def test_avif_decodes_via_fallback(self, tmp_path: Path) -> None:
from PIL import Image
path = tmp_path / "x.avif"
Image.fromarray(np.full((32, 48, 3), (10, 20, 200), np.uint8), "RGB").save(path)
img = image_io.imread(path) # cv2 fails on AVIF -> Pillow fallback
assert img is not None
assert img.shape == (32, 48, 3)
# Source RGB (R=10, G=20, B=200) -> BGR, so blue [...,0] is high, red [...,2] low
# (thresholds loose for AVIF's lossy compression).
assert int(img[0, 0, 0]) > 150
assert int(img[0, 0, 2]) < 70
@pytest.mark.skipif(not _HAS_AVIF_WRITE, reason="Pillow AVIF encoder unavailable")
def test_avif_alpha_survives_read_bgr_and_alpha(self, tmp_path: Path) -> None:
from PIL import Image
path = tmp_path / "x.avif"
rgba = np.dstack([np.full((20, 20, 3), 80, np.uint8), np.full((20, 20), 128, np.uint8)])
Image.fromarray(rgba, "RGBA").save(path)
bgr, alpha = image_io.read_bgr_and_alpha(path)
assert bgr is not None
assert bgr.shape == (20, 20, 3)
assert alpha is not None # the source alpha plane survives the fallback
assert alpha.shape == (20, 20)
def _heif_writable(fmt: str) -> bool:
try:
image_io._register_heif()
from PIL import Image
Image.fromarray(np.zeros((8, 8, 3), np.uint8), "RGB").save(io.BytesIO(), format=fmt, quality=100)
return True
except Exception:
return False
class TestQualityPreservingWrite:
"""The removal only touches the mark footprint, so the container re-encode must
not degrade the untouched pixels: JPEG/WebP are written at max quality, HEIC/AVIF
(which cv2 cannot encode) via Pillow instead of crashing."""
def test_jpeg_written_near_lossless(self, tmp_path: Path) -> None:
# a smooth gradient -> JPEG at quality 100 / 4:4:4 is near-lossless
g = np.tile(np.linspace(30, 210, 64, dtype=np.uint8), (48, 1))
img = np.dstack([g, g, g])
p = tmp_path / "x.jpg"
assert image_io.imwrite(p, img) is True
back = image_io.imread(p)
assert back is not None
assert float(np.abs(img.astype(int) - back.astype(int)).mean()) < 1.0
@pytest.mark.skipif(not _heif_writable("HEIF"), reason="no HEIC encoder in this env")
def test_heic_write_roundtrips(self, tmp_path: Path) -> None:
# cv2 cannot encode HEIC (used to raise); imwrite must route through Pillow.
img = np.full((32, 48, 3), (30, 140, 200), np.uint8)
p = tmp_path / "x.heic"
assert image_io.imwrite(p, img) is True
back = image_io.imread(p)
assert back is not None
assert back.shape == (32, 48, 3)
def test_unencodable_ext_returns_false_not_raises(self, tmp_path: Path) -> None:
# a bogus extension cv2 can't encode returns False (never raises the cv2.error).
assert image_io.imwrite(tmp_path / "x.zzz", _make_bgr()) is False
+53 -49
View File
@@ -1,55 +1,60 @@
"""Inpaint-fallback visible removal: method resolution, footprint masks, dispatch.
"""Visible removal via localize -> fill: backend resolution, footprint masks, dispatch.
The inpaint path erases the mark footprint (MI-GAN when the ``migan`` extra is
installed, else cv2) instead of reverse-alpha, so a mark needs no captured alpha
map for removal (only for the footprint silhouette). ``auto`` is deterministic:
reverse-alpha for capture marks, inpaint for capture-less. These tests avoid any
ONNX model download by pinning the backend to cv2 via ``preferred_inpaint_backend``;
only pure cv2/numpy paths run.
Every known mark is removed by LOCALIZING it to a full-frame footprint mask and
handing that mask to ONE shared fill backend (MI-GAN when the ``migan`` extra is
installed, else cv2). These tests avoid any ONNX model download by pinning the
backend to cv2; only pure cv2/numpy paths run.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import cv2
import numpy as np
import pytest
from remove_ai_watermarks import watermark_registry as registry
from remove_ai_watermarks._text_mark_engine import load_alpha_template
from remove_ai_watermarks.doubao_engine import DoubaoEngine
from remove_ai_watermarks.gemini_engine import GeminiEngine
if TYPE_CHECKING:
import pytest
def _compose_textmark(engine, bg: float = 120.0, w: int = 1024, h: int = 1024):
"""Composite the engine's captured mark onto a flat ``bg`` at full opacity so
the mark is detectable. Returns ``(watermarked_uint8, (ax, ay, gw, gh))``."""
c = engine.config
at = load_alpha_template(c.asset_name)
gw = max(c.min_gw, int(c.alpha_width_frac * w))
gh = max(4, int(c.alpha_height_frac * w))
margin = int(0.015 * w)
ax = (w - margin - gw) if c.corner == "br" else margin
ay = h - margin - gh
block = cv2.resize(at, (gw, gh))
img = np.full((h, w, 3), float(bg), np.float32)
block, (ax, ay, gw, gh) = engine._fixed_alpha_map(img)
a = np.clip(block, 0.0, 0.99)[:, :, None]
logo = np.array(engine.config.alpha_logo_bgr, np.float32)
img[ay : ay + gh, ax : ax + gw] = img[ay : ay + gh, ax : ax + gw] * (1 - a) + logo * a
img[ay : ay + gh, ax : ax + gw] = img[ay : ay + gh, ax : ax + gw] * (1 - a) + 255.0 * a
return np.clip(img, 0, 255).astype(np.uint8), (ax, ay, gw, gh)
class TestResolveMethod:
@pytest.mark.parametrize("explicit", ["reverse-alpha", "inpaint"])
def test_explicit_passthrough(self, explicit: str) -> None:
# capture mark: the explicit method passes through unchanged
assert registry.resolve_removal_method(explicit, True) == explicit # type: ignore[arg-type]
# capture-less: reverse-alpha is impossible, so it collapses to inpaint
expected = "inpaint" if explicit == "reverse-alpha" else explicit
assert registry.resolve_removal_method(explicit, False) == expected # type: ignore[arg-type]
class TestResolveBackend:
def test_auto_resolves_to_available_backend(self) -> None:
# auto picks the best available model (LaMa > MI-GAN) or cv2; any is fine.
assert registry.resolve_backend("auto") in {"cv2", "migan", "lama"}
def test_auto_uses_reverse_alpha_for_capture_marks(self) -> None:
# auto is deterministic and model-independent: reverse-alpha where a capture
# exists (cleaner + lighter than inpaint), inpaint only where it does not.
assert registry.resolve_removal_method("auto", True) == "reverse-alpha"
def test_cv2_passthrough(self) -> None:
assert registry.resolve_backend("cv2") == "cv2"
def test_auto_uses_inpaint_for_capture_less(self) -> None:
assert registry.resolve_removal_method("auto", False) == "inpaint"
def test_lama_passthrough(self) -> None:
assert registry.resolve_backend("lama") == "lama"
class TestFootprintMask:
def test_textmark_footprint_geometry(self) -> None:
mask = DoubaoEngine().footprint_mask(np.full((1024, 1024, 3), 120, np.uint8))
# A clean flat corner has no glyph, so force=True yields the geometry box.
mask = DoubaoEngine().footprint_mask(np.full((1024, 1024, 3), 120, np.uint8), force=True)
assert mask is not None
assert mask.shape == (1024, 1024)
assert mask.dtype == np.uint8
@@ -71,57 +76,56 @@ class TestFootprintMask:
assert forced.any()
class TestInpaintDispatch:
"""Force the cv2 backend (patch preferred_inpaint_backend) so no ONNX model
downloads; the dispatch/gating logic is backend-agnostic."""
class TestFillDispatch:
"""Force the cv2 backend so no ONNX model downloads; the dispatch/gating logic
is backend-agnostic."""
def test_clean_image_is_untouched(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(registry, "preferred_inpaint_backend", lambda: "cv2")
def test_clean_image_is_untouched(self) -> None:
img = np.full((1024, 1024, 3), 120, np.uint8)
out, region = registry.get_mark("doubao").remove(img, method="inpaint")
out, region = registry.get_mark("doubao").remove(img, backend="cv2")
assert region is None
assert np.array_equal(out, img) # not detected, not forced -> no-op
def test_forced_inpaint_edits_only_footprint(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(registry, "preferred_inpaint_backend", lambda: "cv2")
def test_forced_fill_edits_only_footprint(self) -> None:
img, (ax, ay, gw, gh) = _compose_textmark(DoubaoEngine())
out, _ = registry.get_mark("doubao").remove(img, method="inpaint", force=True)
out, _ = registry.get_mark("doubao").remove(img, backend="cv2", force=True)
assert not np.array_equal(out[ay : ay + gh, ax : ax + gw], img[ay : ay + gh, ax : ax + gw])
assert np.array_equal(out[:200, :200], img[:200, :200]) # far corner untouched
def test_detected_inpaint_lowers_confidence(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(registry, "preferred_inpaint_backend", lambda: "cv2")
def test_detected_fill_lowers_confidence(self) -> None:
mark = registry.get_mark("doubao")
img, _ = _compose_textmark(DoubaoEngine())
before = mark.detect(img)
assert before.detected # the composed mark is detectable
out, region = mark.remove(img, method="inpaint")
out, region = mark.remove(img, backend="cv2")
assert region is not None
assert mark.detect(out).confidence < before.confidence
def test_reverse_alpha_method_still_selectable(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(registry, "inpaint_model_available", lambda: True) # would be inpaint on auto
img, _ = _compose_textmark(DoubaoEngine())
# explicit reverse-alpha bypasses the inpaint fallback even with a model present
out, region = registry.get_mark("doubao").remove(img, method="reverse-alpha")
assert region is not None
assert not np.array_equal(out, img)
class TestBackendSelection:
"""MI-GAN is the preferred inpaint backend (light, droplet-friendly); big-LaMa
is NOT auto-selected. cv2 is the floor when no ONNX model is present."""
"""auto resolves to the best available inpaint backend: LaMa > MI-GAN > cv2.
cv2 is the floor when no learned ONNX model is present (and warns once)."""
def test_prefers_migan_when_available(self, monkeypatch: pytest.MonkeyPatch) -> None:
def test_prefers_lama_when_available(self, monkeypatch: pytest.MonkeyPatch) -> None:
from remove_ai_watermarks import region_eraser
monkeypatch.setattr(region_eraser, "lama_available", lambda: True)
monkeypatch.setattr(region_eraser, "migan_available", lambda: True)
assert registry.preferred_inpaint_backend() == "lama"
def test_migan_when_only_migan(self, monkeypatch: pytest.MonkeyPatch) -> None:
from remove_ai_watermarks import region_eraser
monkeypatch.setattr(region_eraser, "lama_available", lambda: False)
monkeypatch.setattr(region_eraser, "migan_available", lambda: True)
assert registry.preferred_inpaint_backend() == "migan"
def test_cv2_when_no_model(self, monkeypatch: pytest.MonkeyPatch) -> None:
from remove_ai_watermarks import region_eraser
monkeypatch.setattr(region_eraser, "lama_available", lambda: False)
monkeypatch.setattr(region_eraser, "migan_available", lambda: False)
monkeypatch.setattr(registry, "_warned_cv2_fallback", True)
assert registry.preferred_inpaint_backend() == "cv2"
def test_inpaint_model_available_reflects_either(self, monkeypatch: pytest.MonkeyPatch) -> None:
+34 -67
View File
@@ -11,11 +11,9 @@ import cv2
import numpy as np
import pytest
from remove_ai_watermarks import watermark_registry as registry
from remove_ai_watermarks.jimeng_engine import (
_ALPHA_HEIGHT_FRAC,
_ALPHA_LOGO_BGR,
_ALPHA_MARGIN_BOTTOM_FRAC,
_ALPHA_MARGIN_RIGHT_FRAC,
_ALPHA_NATIVE_WIDTH,
_ALPHA_WIDTH_FRAC,
DETECT_NCC_THRESHOLD,
@@ -32,12 +30,13 @@ def _compose(w: int, h: int, bg: float = 100.0):
img = np.full((h, w, 3), bg, np.float32)
at = _alpha_template()
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
ax = w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw
ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh
margin = int(0.015 * w)
ax = w - margin - gw
ay = h - margin - gh
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * img).clip(0, 255).astype(np.uint8)
wm = (a3 * 255.0 + (1 - a3) * img).clip(0, 255).astype(np.uint8)
return wm, amap > 0.2
@@ -105,7 +104,7 @@ class TestDetect:
assert det.confidence >= DETECT_NCC_THRESHOLD
class TestReverseAlpha:
class TestAlphaAssetAndRemoval:
def test_alpha_asset_loads(self):
at = _alpha_template()
assert at is not None
@@ -113,89 +112,57 @@ class TestReverseAlpha:
assert float(at.min()) >= 0.0
assert float(at.max()) <= 1.0
def test_logo_is_white(self):
assert _ALPHA_LOGO_BGR == (255.0, 255.0, 255.0)
def test_available_whenever_asset_present(self):
eng = JimengEngine()
assert eng.reverse_alpha_available(np.zeros((1024, 1024, 3), np.uint8))
assert eng.reverse_alpha_available(np.zeros((1440, 2560, 3), np.uint8))
assert not eng.reverse_alpha_available(np.zeros((0, 0, 3), np.uint8))
def test_footprint_mask_in_bottom_right(self):
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
mask = JimengEngine().footprint_mask(wm)
assert mask is not None
assert mask.shape == wm.shape[:2]
ys, xs = np.where(mask > 0)
assert ys.mean() > wm.shape[0] / 2
assert xs.mean() > wm.shape[1] / 2
def test_removes_synthetic_mark(self):
"""Reverse-alpha + residual inpaint clears the composed mark (re-detect
no longer fires)."""
eng = JimengEngine()
"""localize -> cv2 fill clears the composed mark (re-detect no longer fires)."""
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
assert eng.detect(wm).detected
out = eng.remove_watermark_reverse_alpha(wm)
assert not eng.detect(out).detected
assert JimengEngine().detect(wm).detected
out, region = registry.get_mark("jimeng").remove(wm, backend="cv2")
assert region is not None
assert not JimengEngine().detect(out).detected
@pytest.mark.parametrize(
("w", "h", "max_err"),
("w", "h"),
[
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH, 4.0), # captured width
(1440, 2560, 8.0), # off-native -> NCC alignment generalizes the capture
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH), # captured width
(1440, 2560), # off-native -> template-free footprint generalizes
],
)
def test_recovers_flat_background(self, w, h, max_err):
eng = JimengEngine()
def test_fill_removes_and_leaves_far_region(self, w, h):
wm, mark = _compose(w, h)
assert float(np.abs(wm.astype(np.float32)[mark] - 100.0).mean()) > 15 # mark visible
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
assert float(np.abs(out[mark] - 100.0).mean()) < max_err
def test_far_region_untouched(self):
"""The residual inpaint only touches the bottom-right footprint; the
opposite corner stays pixel-identical."""
eng = JimengEngine()
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
out = eng.remove_watermark_reverse_alpha(wm)
h, w = wm.shape[:2]
assert np.array_equal(wm[: h // 2, : w // 2], out[: h // 2, : w // 2])
def test_recovers_shifted_mark_on_texture(self):
"""A real mark is re-rasterized a few px off its fixed slot, so removal
must NCC-align to it (a too-tight locate box would let a corner-ward shift
escape the search and leave a readable outline). Composes the real alpha
SHIFTED on a known texture and asserts the texture is recovered."""
eng = JimengEngine()
w = h = _ALPHA_NATIVE_WIDTH
at = _alpha_template()
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
ax = w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw + 12 # shift toward the corner
ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh + 8
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
base = 120 + 40 * np.sin(xx / 90.0) + 30 * np.cos(yy / 70.0)
bg = np.clip(np.stack([base, base * 0.95, base * 1.05], axis=-1), 0, 255)
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * bg).clip(0, 255).astype(np.uint8)
mark = amap > 0.15
assert float(np.abs(wm.astype(np.float32)[mark] - bg[mark]).mean()) > 30 # mark clearly visible
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
assert float(np.abs(out[mark] - bg[mark]).mean()) < 8.0 # texture recovered, no outline
before = JimengEngine().detect(wm)
out, _ = registry.get_mark("jimeng").remove(wm, backend="cv2")
assert JimengEngine().detect(out).confidence < before.confidence
assert np.array_equal(out[: h // 2, : w // 2], wm[: h // 2, : w // 2])
class TestDegenerateAndChannelInputs:
"""Removal must not crash on degenerate sizes or non-3-channel inputs."""
"""footprint_mask must not crash on degenerate sizes or non-3-channel inputs."""
@pytest.mark.parametrize(("w", "h"), [(2048, 1), (1, 2048), (2048, 8)])
def test_wide_short_does_not_raise(self, w, h):
eng = JimengEngine()
img = np.zeros((h, w, 3), np.uint8)
out = eng.remove_watermark_reverse_alpha(img)
assert out.shape == img.shape
mask = eng.footprint_mask(img, force=True)
assert mask is None or mask.shape == (h, w)
def test_grayscale_2d_does_not_raise(self):
eng = JimengEngine()
gray = np.zeros((2048, 2048), np.uint8)
out = eng.remove_watermark_reverse_alpha(gray)
assert out.shape == (2048, 2048, 3)
mask = eng.footprint_mask(gray, force=True)
assert mask is None or mask.shape == (2048, 2048)
def test_bgra_4channel_does_not_raise(self):
eng = JimengEngine()
bgra = np.zeros((2048, 2048, 4), np.uint8)
out = eng.remove_watermark_reverse_alpha(bgra)
assert out.shape == (2048, 2048, 3)
mask = eng.footprint_mask(bgra, force=True)
assert mask is None or mask.shape == (2048, 2048)
+48
View File
@@ -156,6 +156,29 @@ class TestHasAiMetadata:
path.write_bytes(b"\xff\xd8\xff\xe1" + xmp + b"\xff\xd9")
assert has_ai_metadata(path)
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/samples not present")
def test_jpeg_metadata_strip_is_pixel_lossless(self, tmp_path: Path):
"""A JPEG metadata strip must NOT re-encode the DCT scan: pixels stay
bit-identical, only the AI provenance APP segments are removed. Verified on real
committed fixtures -- grok-1.jpg (EXIF signature), flux-1.jpg (C2PA APP11)."""
import numpy as np
from remove_ai_watermarks import image_io
from remove_ai_watermarks.metadata import remove_ai_metadata
for name in ("grok-1.jpg", "flux-1.jpg"):
src = SAMPLES_DIR / name
if not src.exists():
continue
before = image_io.imread(str(src))
out = tmp_path / name
remove_ai_metadata(src, out)
after = image_io.imread(str(out))
assert before is not None
assert after is not None
assert np.array_equal(before, after), f"{name}: pixels changed (DCT was re-encoded)"
assert not has_ai_metadata(out), f"{name}: AI metadata survived the strip"
class TestC2paMarkerIn:
"""The C2PA presence check requires a JUMBF wrapper or the C2PA uuid box, so
@@ -1336,3 +1359,28 @@ class TestC2paCloudManifest:
assert r.is_ai_generated is None
assert any("Durable Content Credentials" in w for w in r.watermarks)
assert any(s.name == "c2pa_cloud" for s in r.signals)
def test_remove_all_bypasses_lossless_jpeg(tmp_path, monkeypatch):
# keep_standard=False (--remove-all) must NOT take the AI-only lossless JPEG path,
# which leaves standard (non-AI) metadata in place; it must fall through to the full
# re-encode strip (#7).
import remove_ai_watermarks.metadata as md
calls: list[int] = []
real = md._strip_jpeg_metadata_lossless
def spy(s, o):
calls.append(1)
return real(s, o)
monkeypatch.setattr(md, "_strip_jpeg_metadata_lossless", spy)
src = tmp_path / "x.jpg"
Image.new("RGB", (32, 32), (128, 128, 128)).save(src, "JPEG")
out = tmp_path / "o.jpg"
md.remove_ai_metadata(src, out, keep_standard=True)
assert calls, "lossless JPEG strip should run when keeping standard metadata"
calls.clear()
md.remove_ai_metadata(src, out, keep_standard=False)
assert not calls, "keep_standard=False must bypass the AI-only lossless JPEG path"
+17 -9
View File
@@ -1,4 +1,5 @@
"""Tests for vendored noai submodules: constants, extractor, cleaner, c2pa."""
"""Tests for vendored noai submodules: constants, extractor, c2pa, plus the
consolidated metadata strip (formerly noai.cleaner)."""
from __future__ import annotations
@@ -7,6 +8,9 @@ from pathlib import Path
import pytest
from remove_ai_watermarks.metadata import (
remove_ai_metadata as noai_remove_ai_metadata,
)
from remove_ai_watermarks.noai.c2pa import (
_parse_c2pa_chunk,
cbor_text_after,
@@ -16,12 +20,6 @@ from remove_ai_watermarks.noai.c2pa import (
inject_c2pa_chunk,
synthid_verdict,
)
from remove_ai_watermarks.noai.cleaner import (
has_ai_content,
)
from remove_ai_watermarks.noai.cleaner import (
remove_ai_metadata as noai_remove_ai_metadata,
)
from remove_ai_watermarks.noai.constants import (
AI_KEYWORDS,
AI_METADATA_KEYS,
@@ -53,6 +51,15 @@ class TestConstants:
def test_supported_formats_include_jpg(self):
assert ".jpg" in SUPPORTED_FORMATS
def test_supported_formats_include_heic_avif(self):
# HEIC/AVIF are first-class on the pixel path now (read+write via pillow-heif),
# so batch discovers them and the CLI does not warn.
assert {".heic", ".heif", ".avif"} <= SUPPORTED_FORMATS
def test_supported_formats_exclude_jpeg_xl(self):
# JPEG-XL stays metadata/strip-only -- no pixel decoder without pillow-jxl.
assert ".jxl" not in SUPPORTED_FORMATS
def test_ai_metadata_keys_not_empty(self):
assert len(AI_METADATA_KEYS) > 0
@@ -107,7 +114,8 @@ class TestExtractor:
class TestCleaner:
"""Tests for noai.cleaner functions."""
"""Metadata stripping via the single, consolidated ``metadata.remove_ai_metadata``
(the legacy ``noai.cleaner`` duplicate was retired)."""
def test_remove_ai_metadata(self, tmp_png_with_ai_metadata, tmp_path):
output = tmp_path / "cleaned.png"
@@ -118,7 +126,7 @@ class TestCleaner:
assert "parameters" not in meta
def test_has_ai_content(self, tmp_png_with_ai_metadata):
assert has_ai_content(tmp_png_with_ai_metadata)
assert has_ai_metadata(tmp_png_with_ai_metadata)
# ── C2PA ────────────────────────────────────────────────────────────
+53 -27
View File
@@ -93,27 +93,28 @@ class TestFootprintFlatness:
class TestPillRegistry:
def test_pill_is_capture_less(self) -> None:
def test_pill_registered_top_left(self) -> None:
m = registry.get_mark("jimeng_pill")
assert m.has_capture is False
assert m.location == "top-left"
assert m.in_auto is True
def test_capture_less_routes_every_method_to_inpaint(self) -> None:
# a capture-less mark cannot reverse-alpha; even explicit reverse-alpha -> inpaint
assert registry.resolve_removal_method("reverse-alpha", False) == "inpaint"
assert registry.resolve_removal_method("auto", False) == "inpaint"
assert registry.resolve_removal_method("inpaint", False) == "inpaint"
def test_pill_mask_is_top_left_via_registry(self) -> None:
# The registry mask callable delegates to the pill engine's top-left footprint.
mask = registry.get_mark("jimeng_pill")._mask(np.full((1600, 1200, 3), 150, np.uint8))
assert mask is not None
assert mask.any()
class TestPillGate:
"""Pill removal is gated (``_keep_pill``): the reliable bottom-right wordmark
removes it unrestricted, the metadata-only arm removes it ONLY on a flat footprint
(safe inpaint), Doubao/no-confirmation never remove it. Fakes detect_marks so no
image content is needed; cv2 backend so nothing downloads. Frame flatness matters
now, so tests pass a flat or a textured frame explicitly."""
removes it unrestricted, the metadata (``"jimeng"`` provenance) / assume_ai arm
removes it ONLY on a flat footprint (safe fill), Doubao/no-confirmation never
remove it. Fakes each mark's detect so no image content is needed; cv2 backend so
nothing downloads. Frame flatness matters, so tests pass a flat or textured frame."""
@staticmethod
def _fakes(monkeypatch: pytest.MonkeyPatch, keys: set[str]) -> None:
from remove_ai_watermarks.watermark_registry import MarkDetection
from remove_ai_watermarks.watermark_registry import KnownMark, MarkDetection
labels = {
"doubao": "Doubao 豆包AI生成 text",
@@ -121,40 +122,65 @@ class TestPillGate:
"jimeng_pill": "Jimeng AI生成 pill",
}
monkeypatch.setattr(registry, "preferred_inpaint_backend", lambda: "cv2")
monkeypatch.setattr(
registry,
"detect_marks",
lambda image, *, include_explicit=True: [
MarkDetection(k, labels[k], "loc", True, 0.6, (10, 10, 40, 40)) for k in keys
],
)
def fake_detect(self: KnownMark, image: object, *, provenance: bool = False) -> MarkDetection:
return MarkDetection(
self.key, labels.get(self.key, self.key), "loc", self.key in keys, 0.6, (10, 10, 40, 40)
)
monkeypatch.setattr(registry.KnownMark, "detect", fake_detect)
def test_pill_kept_with_metadata_on_flat_footprint(self, monkeypatch: pytest.MonkeyPatch) -> None:
# metadata-only + flat background -> safe inpaint, remove
# jimeng provenance (TC260) + flat background -> safe fill, remove
self._fakes(monkeypatch, {"jimeng_pill"})
_, removed = registry.remove_auto_marks(np.full((400, 300, 3), 150, np.uint8), pill_metadata=True)
_, removed = registry.remove_auto_marks(np.full((400, 300, 3), 150, np.uint8), provenance=frozenset({"jimeng"}))
assert "Jimeng AI生成 pill" in removed
def test_pill_dropped_with_metadata_on_textured_footprint(self, monkeypatch: pytest.MonkeyPatch) -> None:
# metadata-only + textured background (ceiling-like) -> inpaint would smear, skip
# jimeng provenance + textured background (ceiling-like) -> fill would smear, skip
self._fakes(monkeypatch, {"jimeng_pill"})
_, removed = registry.remove_auto_marks(_textured_frame(), pill_metadata=True)
_, removed = registry.remove_auto_marks(_textured_frame(), provenance=frozenset({"jimeng"}))
assert "Jimeng AI生成 pill" not in removed
def test_pill_kept_via_wordmark_ignores_texture(self, monkeypatch: pytest.MonkeyPatch) -> None:
# wordmark confirmation (~94% precise, survives metadata stripping) is NOT
# texture-gated: a wordmark-confirmed pill is removed even on a textured frame
self._fakes(monkeypatch, {"jimeng", "jimeng_pill"})
_, removed = registry.remove_auto_marks(_textured_frame(), pill_metadata=False)
_, removed = registry.remove_auto_marks(_textured_frame())
assert "Jimeng AI生成 pill" in removed
def test_pill_kept_via_assume_ai_on_flat_footprint(self, monkeypatch: pytest.MonkeyPatch) -> None:
# assume_ai (no metadata) removes the pill on a flat footprint (safe fill)...
self._fakes(monkeypatch, {"jimeng_pill"})
_, removed = registry.remove_auto_marks(np.full((400, 300, 3), 150, np.uint8), sensitivity="assume_ai")
assert "Jimeng AI生成 pill" in removed
def test_pill_dropped_via_assume_ai_on_textured_footprint(self, monkeypatch: pytest.MonkeyPatch) -> None:
# ...but even assume_ai keeps the flatness guard (textured false fires smear).
self._fakes(monkeypatch, {"jimeng_pill"})
_, removed = registry.remove_auto_marks(_textured_frame(), sensitivity="assume_ai")
assert "Jimeng AI生成 pill" not in removed
def test_pill_dropped_without_metadata_or_wordmark(self, monkeypatch: pytest.MonkeyPatch) -> None:
self._fakes(monkeypatch, {"jimeng_pill"})
_, removed = registry.remove_auto_marks(np.full((400, 300, 3), 150, np.uint8), pill_metadata=False)
_, removed = registry.remove_auto_marks(np.full((400, 300, 3), 150, np.uint8))
assert "Jimeng AI生成 pill" not in removed
def test_pill_dropped_on_doubao_even_with_metadata(self, monkeypatch: pytest.MonkeyPatch) -> None:
# doubao is faked as detected, which drives the pill gate (pill never rides on a
# Doubao detection). The same flat + jimeng-metadata setup WITHOUT doubao keeps the
# pill (test_pill_kept_with_metadata_on_flat_footprint), so doubao is the
# differentiator. Doubao itself is not asserted in `removed` here: this synthetic
# frame is flat with no real glyph, so the text mask has nothing to fill (its real
# removal is covered by TestRealSample on the committed doubao sample).
self._fakes(monkeypatch, {"doubao", "jimeng_pill"})
_, removed = registry.remove_auto_marks(np.full((400, 300, 3), 150, np.uint8), pill_metadata=True)
assert "Doubao 豆包AI生成 text" in removed
_, removed = registry.remove_auto_marks(np.full((400, 300, 3), 150, np.uint8), provenance=frozenset({"jimeng"}))
assert "Jimeng AI生成 pill" not in removed
def test_detect_bgra_no_crash() -> None:
# A 4-channel BGRA array must be normalized, not crash cv2.cvtColor(BGR2GRAY) (#10).
bgra = np.zeros((256, 256, 4), np.uint8)
det = PillEngine().detect(bgra)
assert det.detected in (True, False)
assert PillEngine().footprint_texture(bgra) >= 0.0
+35 -67
View File
@@ -12,11 +12,9 @@ import cv2
import numpy as np
import pytest
from remove_ai_watermarks import watermark_registry as registry
from remove_ai_watermarks.samsung_engine import (
_ALPHA_HEIGHT_FRAC,
_ALPHA_LOGO_BGR,
_ALPHA_MARGIN_BOTTOM_FRAC,
_ALPHA_MARGIN_LEFT_FRAC,
_ALPHA_NATIVE_WIDTH,
_ALPHA_WIDTH_FRAC,
DETECT_NCC_THRESHOLD,
@@ -33,12 +31,13 @@ def _compose(w: int, h: int, bg: float = 100.0):
img = np.full((h, w, 3), bg, np.float32)
at = _alpha_template()
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
ax = int(_ALPHA_MARGIN_LEFT_FRAC * w)
ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh
margin = int(0.015 * w)
ax = margin
ay = h - margin - gh
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * img).clip(0, 255).astype(np.uint8)
wm = (a3 * 255.0 + (1 - a3) * img).clip(0, 255).astype(np.uint8)
return wm, amap > 0.15
@@ -106,7 +105,7 @@ class TestDetect:
assert det.confidence >= DETECT_NCC_THRESHOLD
class TestReverseAlpha:
class TestAlphaAssetAndRemoval:
def test_alpha_asset_loads(self):
at = _alpha_template()
assert at is not None
@@ -114,89 +113,58 @@ class TestReverseAlpha:
assert float(at.min()) >= 0.0
assert float(at.max()) <= 1.0
def test_logo_is_white(self):
assert _ALPHA_LOGO_BGR == (255.0, 255.0, 255.0)
def test_available_whenever_asset_present(self):
eng = SamsungEngine()
assert eng.reverse_alpha_available(np.zeros((1086, 1086, 3), np.uint8))
assert eng.reverse_alpha_available(np.zeros((4054, 2958, 3), np.uint8))
assert not eng.reverse_alpha_available(np.zeros((0, 0, 3), np.uint8))
def test_footprint_mask_in_bottom_left(self):
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, int(_ALPHA_NATIVE_WIDTH * 1.33))
mask = SamsungEngine().footprint_mask(wm)
assert mask is not None
assert mask.shape == wm.shape[:2]
ys, xs = np.where(mask > 0)
assert ys.mean() > wm.shape[0] / 2 # bottom
assert xs.mean() < wm.shape[1] / 2 # left
def test_removes_synthetic_mark(self):
"""Reverse-alpha + residual inpaint clears the composed mark (re-detect no
longer fires)."""
eng = SamsungEngine()
"""localize -> cv2 fill clears the composed mark (re-detect no longer fires)."""
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, int(_ALPHA_NATIVE_WIDTH * 1.33))
assert eng.detect(wm).detected
out = eng.remove_watermark_reverse_alpha(wm)
assert not eng.detect(out).detected
assert SamsungEngine().detect(wm).detected
out, region = registry.get_mark("samsung").remove(wm, backend="cv2")
assert region is not None
assert not SamsungEngine().detect(out).detected
@pytest.mark.parametrize(
("w", "h", "max_err"),
("w", "h"),
[
(_ALPHA_NATIVE_WIDTH, int(_ALPHA_NATIVE_WIDTH * 1.33), 5.0), # captured width
(2958, 4054, 10.0), # real-photo width (~2.7x native) -> NCC alignment generalizes
(_ALPHA_NATIVE_WIDTH, int(_ALPHA_NATIVE_WIDTH * 1.33)), # captured width
(2958, 4054), # real-photo width (~2.7x native) -> template-free footprint generalizes
],
)
def test_recovers_flat_background(self, w, h, max_err):
eng = SamsungEngine()
def test_fill_removes_and_leaves_far_region(self, w, h):
wm, mark = _compose(w, h)
assert float(np.abs(wm.astype(np.float32)[mark] - 100.0).mean()) > 15 # mark visible
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
assert float(np.abs(out[mark] - 100.0).mean()) < max_err
def test_far_region_untouched(self):
"""The residual inpaint only touches the bottom-left footprint; the
opposite (top-right) corner stays pixel-identical."""
eng = SamsungEngine()
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, int(_ALPHA_NATIVE_WIDTH * 1.33))
out = eng.remove_watermark_reverse_alpha(wm)
h, w = wm.shape[:2]
assert np.array_equal(wm[: h // 2, w // 2 :], out[: h // 2, w // 2 :])
def test_recovers_shifted_mark_on_texture(self):
"""A real mark is re-rasterized a few px off its fixed slot, so removal must
NCC-align to it (a too-tight locate box would let a corner-ward shift escape
the search and leave a readable outline). Composes the real alpha SHIFTED on
a known texture and asserts the texture is recovered."""
eng = SamsungEngine()
w, h = _ALPHA_NATIVE_WIDTH, int(_ALPHA_NATIVE_WIDTH * 1.33)
at = _alpha_template()
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
ax = max(0, int(_ALPHA_MARGIN_LEFT_FRAC * w) + 9) # shift right of the fixed slot
ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh - 7 # shift up
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
base = 120 + 40 * np.sin(xx / 90.0) + 30 * np.cos(yy / 70.0)
bg = np.clip(np.stack([base, base * 0.95, base * 1.05], axis=-1), 0, 255)
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * bg).clip(0, 255).astype(np.uint8)
mark = amap > 0.15
assert float(np.abs(wm.astype(np.float32)[mark] - bg[mark]).mean()) > 20 # mark clearly visible
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
assert float(np.abs(out[mark] - bg[mark]).mean()) < 10.0 # texture recovered, no outline
before = SamsungEngine().detect(wm)
out, _ = registry.get_mark("samsung").remove(wm, backend="cv2")
assert SamsungEngine().detect(out).confidence < before.confidence
# The mark is bottom-left; the opposite (top-right) corner stays exact.
assert np.array_equal(out[: h // 2, w // 2 :], wm[: h // 2, w // 2 :])
class TestDegenerateAndChannelInputs:
"""Removal must not crash on degenerate sizes or non-3-channel inputs."""
"""footprint_mask must not crash on degenerate sizes or non-3-channel inputs."""
@pytest.mark.parametrize(("w", "h"), [(2048, 1), (1, 2048), (2048, 8)])
def test_wide_short_does_not_raise(self, w, h):
eng = SamsungEngine()
img = np.zeros((h, w, 3), np.uint8)
out = eng.remove_watermark_reverse_alpha(img)
assert out.shape == img.shape
mask = eng.footprint_mask(img, force=True)
assert mask is None or mask.shape == (h, w)
def test_grayscale_2d_does_not_raise(self):
eng = SamsungEngine()
gray = np.zeros((1448, 1086), np.uint8)
out = eng.remove_watermark_reverse_alpha(gray)
assert out.shape == (1448, 1086, 3)
mask = eng.footprint_mask(gray, force=True)
assert mask is None or mask.shape == (1448, 1086)
def test_bgra_4channel_does_not_raise(self):
eng = SamsungEngine()
bgra = np.zeros((1448, 1086, 4), np.uint8)
out = eng.remove_watermark_reverse_alpha(bgra)
assert out.shape == (1448, 1086, 3)
mask = eng.footprint_mask(bgra, force=True)
assert mask is None or mask.shape == (1448, 1086)
+11 -87
View File
@@ -1,19 +1,9 @@
"""Byte-identity guards for the text-mark engine memory optimization.
"""Memory guard for the text-mark engine ``extract_mask``.
The reverse-alpha text-mark engine used to allocate full-frame arrays where only
the glyph footprint is ever read:
* ``extract_mask`` built a full ``(h, w)`` uint8 mask and every caller cropped
it to the located box;
* ``_fixed_alpha_map`` / ``_aligned_alpha_map`` each built a full ``(h, w)``
float32 alpha map that is non-zero only inside the glyph box, and two were
held at once during removal.
Both now return footprint-sized arrays. These tests prove the new footprint-sized
path is BYTE-IDENTICAL to the old full-frame path by reconstructing the old
behavior inline from the new building blocks (so the proof survives a cv2/asset
version bump, unlike a pinned output hash), and lock in the O(footprint) memory
characteristic so a regression back to a full-frame allocation fails loudly.
``extract_mask`` used to build a full ``(h, w)`` uint8 mask that every caller
cropped to the located box; it now returns the box-sized mask directly. This test
locks in the O(footprint) memory characteristic so a regression back to a
full-frame allocation fails loudly.
"""
from __future__ import annotations
@@ -38,19 +28,20 @@ ENGINES = [
def _watermarked(engine, module) -> np.ndarray:
"""Composite the engine's real alpha glyph onto a flat mid-gray field at the
captured native width (so both placement candidates fire)."""
"""Composite the engine's real alpha glyph (white) onto a flat mid-gray field at
the captured native width, anchored in the mark's configured corner."""
cfg = engine.config
nw = module._ALPHA_NATIVE_WIDTH
at = module._alpha_template()
gw, gh = int(cfg.alpha_width_frac * nw), int(cfg.alpha_height_frac * nw)
ax = (nw - int(cfg.alpha_margin_x_frac * nw) - gw) if cfg.corner == "br" else int(cfg.alpha_margin_x_frac * nw)
ay = nw - int(cfg.alpha_margin_bottom_frac * nw) - gh
margin = int(0.015 * nw)
ax = (nw - margin - gw) if cfg.corner == "br" else margin
ay = nw - margin - gh
amap = np.zeros((nw, nw), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
img = np.full((nw, nw, 3), 100.0, np.float32)
return (a3 * np.array(cfg.alpha_logo_bgr, np.float32) + (1 - a3) * img).clip(0, 255).astype(np.uint8)
return (a3 * 255.0 + (1 - a3) * img).clip(0, 255).astype(np.uint8)
@pytest.mark.parametrize(("factory", "module"), ENGINES)
@@ -65,70 +56,3 @@ class TestExtractMaskFootprint:
assert box.shape == (loc.h, loc.w)
# Footprint, not full frame: the box is a tiny fraction of the image.
assert box.size * 4 < img.shape[0] * img.shape[1]
@pytest.mark.parametrize(("factory", "module"), ENGINES)
class TestAlphaMapFootprint:
def test_maps_are_footprint_sized_blocks(self, factory, module):
eng = factory()
img = _watermarked(eng, module)
for placed in (eng._fixed_alpha_map(img), eng._aligned_alpha_map(img)):
assert placed is not None
block, (ax, ay, gw, gh) = placed
assert block.dtype == np.float32
assert block.shape == (gh, gw)
# The placement stays fully inside the image (no clipping needed).
assert ax >= 0
assert ax + gw <= img.shape[1]
assert ay >= 0
assert ay + gh <= img.shape[0]
# O(footprint): far smaller than the frame.
assert block.size * 4 < img.shape[0] * img.shape[1]
def test_apply_reverse_alpha_equals_old_fullframe(self, factory, module):
"""``_apply_reverse_alpha`` with the glyph block is byte-identical to the
old full-frame path: rebuild the full ``(h, w)`` map, run the old-style
full-frame reverse-alpha, and compare to the new block-based output."""
eng = factory()
img = _watermarked(eng, module)
h, w = img.shape[:2]
for placed in (eng._fixed_alpha_map(img), eng._aligned_alpha_map(img)):
assert placed is not None
block, region = placed
ax, ay, gw, gh = region
new_out = eng._apply_reverse_alpha(img, block, region)
# Old behavior: a full-frame map, indexed by region inside _apply_reverse_alpha.
full = np.zeros((h, w), np.float32)
full[ay : ay + gh, ax : ax + gw] = block
old_out = img.copy()
a3 = np.clip(full[ay : ay + gh, ax : ax + gw], 0.0, 1.0)[:, :, None]
logo = np.array(eng.config.alpha_logo_bgr, np.float32)
roi = old_out[ay : ay + gh, ax : ax + gw].astype(np.float32)
old_out[ay : ay + gh, ax : ax + gw] = np.clip(
(roi - a3 * logo) / np.clip(1.0 - a3, 0.25, 1.0), 0, 255
).astype(np.uint8)
assert np.array_equal(new_out, old_out)
def test_residual_mask_equals_old_fullframe(self, factory, module):
"""The residual inpaint mask built from the block embedded in a full-frame
canvas equals thresholding the old full-frame float32 map (zero outside the
block), so the dilate + inpaint see the same mask."""
eng = factory()
img = _watermarked(eng, module)
h, w = img.shape[:2]
cfg = eng.config
block, (ax, ay, gw, gh) = eng._fixed_alpha_map(img)
# New: embed the block into a uint8 canvas, then threshold.
new_mask = np.zeros((h, w), np.uint8)
new_mask[ay : ay + gh, ax : ax + gw] = (block > cfg.residual_alpha_floor).astype(np.uint8) * 255
# Old: a full-frame float32 map, thresholded everywhere.
old_full = np.zeros((h, w), np.float32)
old_full[ay : ay + gh, ax : ax + gw] = block
old_mask = (old_full > cfg.residual_alpha_floor).astype(np.uint8) * 255
assert np.array_equal(new_mask, old_mask)
-120
View File
@@ -1,120 +0,0 @@
"""Reverse-alpha over-subtraction guard for the visible text-mark engines.
Ported from the Gemini sparkle fix (commit 41f6797) to Doubao/Jimeng/Samsung
(retained-corpus mining 2026-06-20, roadmap P0#8): on a dark or mid-tone
background the captured alpha can over-estimate THIS image's mark opacity, and
reverse-alpha leaves a darker-than-background glyph ghost (a "dark pit") instead
of recovering the true pixels. The guard predicts the reverse-alpha output per
pixel and, when the glyph body lands far below the local ring, reconstructs the
footprint from the original surroundings instead of shipping the pit.
These assert visual residual (pixel levels vs the local background), not just a
detector re-fire -- a dark pit can clear the NCC detector while still looking wrong.
"""
from __future__ import annotations
import numpy as np
import pytest
from remove_ai_watermarks import image_io
from remove_ai_watermarks._text_mark_engine import _OVERSUB_DARK_MARGIN
from remove_ai_watermarks.doubao_engine import DoubaoEngine
from remove_ai_watermarks.jimeng_engine import JimengEngine
from remove_ai_watermarks.samsung_engine import SamsungEngine
_ENGINES = [DoubaoEngine, JimengEngine, SamsungEngine]
def _compose(engine, bg: float, opacity_gain: float, w: int = 1024, h: int = 1024):
"""Composite the engine's captured mark onto a flat ``bg`` at ``opacity_gain``.
``opacity_gain < 1`` makes the mark FAINTER than the capture, so reverse-alpha
at the full captured alpha over-subtracts into a dark pit -- the case the guard
must catch. Returns ``(watermarked_uint8, alpha_block, region)`` where the block
and region are exactly what the engine's reverse-alpha receives.
"""
img = np.full((h, w, 3), float(bg), np.float32)
block, (ax, ay, gw, gh) = engine._fixed_alpha_map(img)
a = np.clip(block * opacity_gain, 0.0, 0.99)[:, :, None]
logo = np.array(engine.config.alpha_logo_bgr, np.float32)
img[ay : ay + gh, ax : ax + gw] = img[ay : ay + gh, ax : ax + gw] * (1 - a) + logo * a
return np.clip(img, 0, 255).astype(np.uint8), block, (ax, ay, gw, gh)
def _body_vs_ring(out, region, block) -> tuple[float, float]:
"""Median luma of the glyph body vs the local background ring in ``out``."""
ax, ay, gw, gh = region
g = out.astype(np.float32).mean(axis=2)
body = block >= 0.15
pad = max(4, int(gh * 0.6))
ry1, ry2 = max(0, ay - pad), min(g.shape[0], ay + gh + pad)
rx1, rx2 = max(0, ax - pad), min(g.shape[1], ax + gw + pad)
ring = g[ry1:ry2, rx1:rx2]
fy1, fy2, fx1, fx2 = ay - ry1, ay - ry1 + gh, ax - rx1, ax - rx1 + gw
ring_mask = np.ones(ring.shape, dtype=bool)
ring_mask[fy1:fy2, fx1:fx2] = False
core = float(np.median(g[ay : ay + gh, ax : ax + gw][body]))
return core, float(np.median(ring[ring_mask]))
@pytest.mark.parametrize("Engine", _ENGINES, ids=lambda e: e.__name__)
class TestOversubtractionGuard:
@pytest.mark.parametrize(("bg", "gain"), [(120, 0.45), (150, 0.4), (90, 0.5)])
def test_guard_trips_on_faint_mark(self, Engine, bg, gain):
eng = Engine()
wm, block, region = _compose(eng, bg, gain)
assert eng._reverse_alpha_oversubtracts(image_io.to_bgr(wm), block, region)
@pytest.mark.parametrize("bg", [255, 200, 128, 60])
def test_guard_skips_clean_full_strength_mark(self, Engine, bg):
# A cleanly captured (gain 1.0) mark predicts back to the background, so the
# guard must NOT trip -- no regression of the common clean-removal path.
eng = Engine()
wm, block, region = _compose(eng, bg, 1.0)
assert not eng._reverse_alpha_oversubtracts(image_io.to_bgr(wm), block, region)
@pytest.mark.parametrize(("bg", "gain"), [(120, 0.45), (150, 0.4)])
def test_faint_removal_leaves_no_dark_pit(self, Engine, bg, gain):
# End-to-end acceptance (roadmap P0#8): after removal the glyph footprint is
# not a region more than _OVERSUB_DARK_MARGIN below the local background.
eng = Engine()
wm, block, region = _compose(eng, bg, gain)
out = eng.remove_watermark_reverse_alpha(wm)
core, ring_bg = _body_vs_ring(out, region, block)
assert core >= ring_bg - _OVERSUB_DARK_MARGIN, f"dark pit: body {core:.0f} vs ring {ring_bg:.0f}"
def test_clean_mark_removal_unchanged_by_guard(self, Engine, monkeypatch):
# On a clean mark the guard must be a no-op: forcing it off yields the same
# output (the guard only ever diverts the over-subtraction case).
eng = Engine()
wm, _block, _region = _compose(eng, 200, 1.0)
guarded = eng.remove_watermark_reverse_alpha(wm)
monkeypatch.setattr(type(eng), "_reverse_alpha_oversubtracts", lambda self, *a, **k: False)
unguarded = eng.remove_watermark_reverse_alpha(wm)
assert np.array_equal(guarded, unguarded)
@pytest.mark.parametrize("Engine", _ENGINES, ids=lambda e: e.__name__)
def test_guard_recovers_pit_on_textured_background(Engine):
"""The guard's footprint inpaint reconstructs from the ORIGINAL surroundings,
so a faint mark over-subtracted on a textured background recovers to roughly the
local content level rather than a glyph-shaped dark ghost."""
eng = Engine()
w = h = 1024
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
base = 120 + 35 * np.sin(xx / 80.0) + 25 * np.cos(yy / 60.0)
bg_img = np.clip(np.stack([base, base * 0.95, base * 1.05], axis=-1), 0, 255)
block, (ax, ay, gw, gh) = eng._fixed_alpha_map(bg_img)
a = np.clip(block * 0.45, 0.0, 0.99)[:, :, None]
logo = np.array(eng.config.alpha_logo_bgr, np.float32)
bg_img[ay : ay + gh, ax : ax + gw] = bg_img[ay : ay + gh, ax : ax + gw] * (1 - a) + logo * a
wm = np.clip(bg_img, 0, 255).astype(np.uint8)
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
# Compare the recovered glyph body to the clean texture under the mark.
clean = np.clip(np.stack([base, base * 0.95, base * 1.05], axis=-1), 0, 255)
body = block >= 0.15
region_out = out[ay : ay + gh, ax : ax + gw].mean(axis=2)
region_clean = clean[ay : ay + gh, ax : ax + gw].mean(axis=2)
err = float(np.abs(region_out[body] - region_clean[body]).mean())
assert err < 25.0, f"glyph body not recovered (mean abs err {err:.1f})"
+216 -18
View File
@@ -1,4 +1,4 @@
"""Tests for the known-visible-watermark registry (reverse-alpha only)."""
"""Tests for the known-visible-watermark registry (localize -> fill)."""
from __future__ import annotations
@@ -19,12 +19,12 @@ class TestCatalog:
def test_all_in_auto(self):
assert all(m.in_auto for m in reg.known_marks())
def test_recovery(self):
# Capture marks recover by reverse-alpha; the capture-less pill is inpaint-only.
by_key = {m.key: m for m in reg.known_marks()}
assert all(by_key[k].recovery == "reverse-alpha" for k in ("gemini", "doubao", "jimeng", "samsung"))
assert by_key["jimeng_pill"].recovery == "inpaint"
assert by_key["jimeng_pill"].has_capture is False
def test_marks_expose_detect_and_mask(self):
# Every mark drives the uniform localize -> fill contract: a detect callable
# (verdict + bbox, no mask) and a mask callable (full-frame footprint).
for m in reg.known_marks():
assert callable(m._detect)
assert callable(m._mask)
def test_locations(self):
by_key = {m.key: m for m in reg.known_marks()}
@@ -46,31 +46,229 @@ class TestScan:
assert keys == {"gemini", "doubao", "jimeng", "samsung", "jimeng_pill"}
def test_blank_image_no_auto_mark(self):
assert reg.best_auto_mark(np.zeros((256, 256, 3), np.uint8)) is None
dets = reg.detect_marks(np.zeros((256, 256, 3), np.uint8), include_explicit=False)
assert not any(d.detected for d in dets)
class TestBackendResolution:
def test_auto_resolves_to_available_backend(self):
assert reg.resolve_backend("auto") in ("cv2", "migan", "lama")
def test_explicit_backend_passes_through(self):
assert reg.resolve_backend("cv2") == "cv2"
assert reg.resolve_backend("lama") == "lama"
def test_cv2_fallback_warns_once(self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture):
import logging
from remove_ai_watermarks import region_eraser
monkeypatch.setattr(region_eraser, "lama_available", lambda: False)
monkeypatch.setattr(region_eraser, "migan_available", lambda: False)
monkeypatch.setattr(reg, "_warned_cv2_fallback", False)
with caplog.at_level(logging.WARNING):
assert reg.preferred_inpaint_backend() == "cv2"
assert reg.preferred_inpaint_backend() == "cv2"
assert sum("cv2 classical inpaint" in r.message for r in caplog.records) == 1
class TestFill:
def test_fill_erases_masked_region(self):
# A bright square on a flat field, masked, is inpainted away (cv2 backend).
img = np.full((128, 128, 3), 60, np.uint8)
img[40:70, 40:70] = 240
mask = np.zeros((128, 128), np.uint8)
mask[36:74, 36:74] = 255
out = reg.fill(img, mask, backend="cv2")
assert out.shape == img.shape
# the masked bright square is pulled toward the surrounding field
assert int(out[55, 55].mean()) < 160
def test_fill_empty_mask_is_noop(self):
img = np.full((64, 64, 3), 100, np.uint8)
out = reg.fill(img, np.zeros((64, 64), np.uint8), backend="cv2")
assert np.array_equal(out, img)
class TestProvenanceGate:
"""The Gemini trust gate relaxes from 0.5 to 0.35 when provenance confirms Google;
tested deterministically by stubbing the engine's raw detection confidence."""
def _stub(self, monkeypatch: pytest.MonkeyPatch, conf: float) -> None:
from remove_ai_watermarks.gemini_engine import DetectionResult
def fake_detect(image, force_size=None, *, trust_provenance=False):
return DetectionResult(detected=conf >= 0.35, confidence=conf, region=(10, 10, 48, 48))
monkeypatch.setattr(reg._engine("gemini"), "detect_watermark", fake_detect)
def test_midband_conf_needs_provenance(self, monkeypatch: pytest.MonkeyPatch):
# conf 0.42 sits in [0.35, 0.5): demoted without provenance, trusted with it.
self._stub(monkeypatch, 0.42)
img = np.zeros((256, 256, 3), np.uint8)
assert reg.get_mark("gemini").detect(img).detected is False
assert reg.get_mark("gemini").detect(img, provenance=True).detected is True
def test_high_conf_detected_either_way(self, monkeypatch: pytest.MonkeyPatch):
self._stub(monkeypatch, 0.72)
img = np.zeros((256, 256, 3), np.uint8)
assert reg.get_mark("gemini").detect(img).detected is True
assert reg.get_mark("gemini").detect(img, provenance=True).detected is True
@pytest.mark.skipif(not DOUBAO_SAMPLE.exists(), reason="doubao sample not present")
class TestRealSample:
def test_doubao_sample_wins_auto(self):
def test_doubao_sample_detected(self):
from remove_ai_watermarks.image_io import imread
best = reg.best_auto_mark(imread(DOUBAO_SAMPLE))
assert best is not None
assert best.key == "doubao"
fired = [d.key for d in reg.detect_marks(imread(DOUBAO_SAMPLE), include_explicit=False) if d.detected]
assert "doubao" in fired
def test_doubao_remove_returns_region(self):
from remove_ai_watermarks.image_io import imread
img = imread(DOUBAO_SAMPLE) # 2048 wide -> reverse-alpha applies
result, region = reg.get_mark("doubao").remove(img)
img = imread(DOUBAO_SAMPLE)
result, region = reg.get_mark("doubao").remove(img, backend="cv2")
assert region is not None
assert result.shape == img.shape
class TestReverseAlphaOnly:
def test_doubao_off_resolution_is_skipped(self):
# No alpha capture for this width -> no inpaint fallback, image untouched.
class TestLocalizeFill:
def test_clean_corner_is_untouched(self):
# No glyph in the corner -> no mask -> remove is a no-op copy.
img = np.zeros((512, 512, 3), np.uint8)
result, region = reg.get_mark("doubao").remove(img)
result, region = reg.get_mark("doubao").remove(img, backend="cv2")
assert region is None
assert np.array_equal(result, img)
class TestSensitivity:
"""``resolve_relax`` turns the sensitivity policy + evidence into the per-mark
relaxation boolean the engines consume."""
def test_strict_never_relaxes(self):
# even with metadata provenance, strict keeps the conservative gate
assert (
reg.resolve_relax("gemini", sensitivity="strict", provenance=frozenset({"gemini"}), strict_keys=set())
is False
)
def test_assume_ai_always_relaxes(self):
assert reg.resolve_relax("gemini", sensitivity="assume_ai", provenance=frozenset(), strict_keys=set()) is True
def test_auto_relaxes_on_own_metadata(self):
assert (
reg.resolve_relax("gemini", sensitivity="auto", provenance=frozenset({"gemini"}), strict_keys=set()) is True
)
def test_auto_strict_without_evidence(self):
assert reg.resolve_relax("gemini", sensitivity="auto", provenance=frozenset(), strict_keys=set()) is False
def test_auto_cross_mark_same_product(self):
# a detected Jimeng wordmark relaxes the Jimeng pill (same product, other corner)
assert (
reg.resolve_relax("jimeng_pill", sensitivity="auto", provenance=frozenset(), strict_keys={"jimeng"}) is True
)
def test_auto_no_cross_mark_across_products(self):
# a detected Jimeng wordmark must NOT relax Doubao (distinct products, same corner)
assert reg.resolve_relax("doubao", sensitivity="auto", provenance=frozenset(), strict_keys={"jimeng"}) is False
def test_remove_auto_marks_accepts_all_sensitivities(self):
blank = np.zeros((256, 256, 3), np.uint8)
for s in ("auto", "strict", "assume_ai"):
_, removed = reg.remove_auto_marks(blank, sensitivity=s, backend="cv2")
assert removed == []
class TestArbiter:
"""``decide`` is the PURE removal arbiter: (candidates, context) -> ordered
winners, no image / no I/O. Tested in isolation by handing it fabricated
Candidates -- this is the payoff of separating decision from perception."""
@staticmethod
def _c(key, *, strict=False, relaxed=False, flat=False):
feats = {"footprint_flat": 1.0} if flat else {}
return reg.Candidate(key, f"L:{key}", strict, relaxed, feats)
def _keys(self, cands, ctx):
return {d.candidate.key for d in reg.decide(cands, ctx)}
def test_empty(self):
assert reg.decide([], reg.Context()) == []
def test_strict_uses_strict_verdict(self):
# relaxed-only detection must NOT fire under strict
assert self._keys([self._c("gemini", relaxed=True)], reg.Context(sensitivity="strict")) == set()
def test_assume_ai_uses_relaxed(self):
fired = reg.decide([self._c("gemini", relaxed=True)], reg.Context(sensitivity="assume_ai"))
assert [d.candidate.key for d in fired] == ["gemini"]
assert fired[0].relax is True
def test_auto_relaxes_on_provenance(self):
c = [self._c("gemini", relaxed=True)]
assert self._keys(c, reg.Context(provenance=frozenset({"gemini"}))) == {"gemini"}
assert self._keys(c, reg.Context()) == set() # no evidence -> strict verdict (not fired)
def test_cross_mark_relaxes_pill_via_jimeng(self):
cands = [self._c("jimeng", strict=True, relaxed=True), self._c("jimeng_pill", relaxed=True, flat=True)]
assert self._keys(cands, reg.Context()) == {"jimeng", "jimeng_pill"}
def test_pill_dropped_on_doubao(self):
cands = [
self._c("doubao", strict=True, relaxed=True),
self._c("jimeng_pill", strict=True, relaxed=True, flat=True),
]
keys = self._keys(cands, reg.Context(provenance=frozenset({"jimeng"})))
assert "doubao" in keys
assert "jimeng_pill" not in keys
def test_pill_metadata_arm_gated_on_flatness(self):
ctx = reg.Context(provenance=frozenset({"jimeng"}))
assert self._keys([self._c("jimeng_pill", strict=True, relaxed=True, flat=True)], ctx) == {"jimeng_pill"}
assert self._keys([self._c("jimeng_pill", strict=True, relaxed=True, flat=False)], ctx) == set()
def test_pill_wordmark_arm_ignores_flatness(self):
# wordmark present -> pill removed even on a textured (non-flat) footprint
cands = [
self._c("jimeng", strict=True, relaxed=True),
self._c("jimeng_pill", strict=True, relaxed=True, flat=False),
]
assert "jimeng_pill" in self._keys(cands, reg.Context())
class TestProvenanceMaskThreading:
"""Regression for the provenance-relaxed Gemini no-op (#1) and the false 'removed'
label (#2). Before the fix, footprint_mask re-detected WITHOUT trust_provenance, the
FP gate demoted the sparkle to detected=False, the mask came back None, yet
remove_auto_marks still reported the mark as removed."""
def test_relaxed_sparkle_yields_mask(self, monkeypatch: pytest.MonkeyPatch):
# A sparkle a strict re-detect would demote (detected False) but a
# provenance-relaxed detect accepts must still produce a removal mask.
from remove_ai_watermarks.gemini_engine import DetectionResult
def fake(image, force_size=None, *, trust_provenance=False):
return DetectionResult(
detected=trust_provenance, confidence=0.42 if trust_provenance else 0.30, region=(400, 400, 60)
)
monkeypatch.setattr(reg._engine("gemini"), "detect_watermark", fake)
img = np.full((512, 512, 3), 90, np.uint8)
assert reg.get_mark("gemini").localize(img, provenance=True).mask is not None
assert reg.get_mark("gemini").localize(img, provenance=False).mask is None
def test_no_label_when_mask_none(self, monkeypatch: pytest.MonkeyPatch):
# A decided mark whose mask comes back None must NOT be reported as removed.
from remove_ai_watermarks.gemini_engine import DetectionResult
eng = reg._engine("gemini")
monkeypatch.setattr(
eng,
"detect_watermark",
lambda image, force_size=None, *, trust_provenance=False: DetectionResult(True, 0.9, (10, 10, 40)),
)
monkeypatch.setattr(eng, "footprint_mask", lambda image, *, force=False, region=None, dilate=None: None)
_, removed = reg.remove_auto_marks(np.zeros((256, 256, 3), np.uint8), sensitivity="strict", backend="cv2")
assert "Google Gemini sparkle" not in removed
Generated
+61 -1
View File
@@ -1965,6 +1965,64 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
]
[[package]]
name = "pillow-heif"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pillow" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e3/5f/4753689400e657ca5d984f5e897657dab12d91b62f1bb6a1e73487b59a97/pillow_heif-1.4.0.tar.gz", hash = "sha256:55a7c0cb5321538d1ca74037be54b48d147017735a766eb29bcca4761253a1f1", size = 17127538, upload-time = "2026-06-10T16:02:40.009Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/1f/15b97c35be25f88f9253927a9091ea1857640f5eea61274a2c57ddfa1172/pillow_heif-1.4.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:84a035e7e247d98d5766fa0b290c86663f8b83cfddf38f6863a978bd32d42bff", size = 4729930, upload-time = "2026-06-10T16:00:55.128Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/a4503b5429745d0099d3ce50c9c1d1f4fded471719d3609c1104d98cde57/pillow_heif-1.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9ca5aab5e28112c444e95a57f4de6fc6fcb0efc639c7b43e29cce61f7847aae2", size = 3955591, upload-time = "2026-06-10T16:00:57.36Z" },
{ url = "https://files.pythonhosted.org/packages/1f/b1/d083de4151ffe4c86848b826fa2803370a59887430b1777b3f34312db0ae/pillow_heif-1.4.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:847d7e2ce24d3ecd0340cf28b6fa5e6eb7557c209fae0d98b7af0e0371ead3f9", size = 6249094, upload-time = "2026-06-10T16:00:58.898Z" },
{ url = "https://files.pythonhosted.org/packages/59/5e/5a66c9f03dcdde1acbadfb502b6b7e0fce426f94f21bb98a6c0953530994/pillow_heif-1.4.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5507c4f3cd9fdbaa3f5036cc7211423603a330c9457fd70352616c246aafccc8", size = 5663311, upload-time = "2026-06-10T16:01:00.981Z" },
{ url = "https://files.pythonhosted.org/packages/f5/f6/65290b7cb79abcbb568865e072cf27cb553bcfb59061bcda7a492da4ed14/pillow_heif-1.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd8cf6bdd7ca2226ee434fbb2973bc63a5e82d8d5c10d48290b1172a5c9bd8fc", size = 7335887, upload-time = "2026-06-10T16:01:02.607Z" },
{ url = "https://files.pythonhosted.org/packages/ca/37/d8a02f7eec35d9b75b4c3f26a9fc46bcb7c21785ccb669eda1ed7e9ef2e0/pillow_heif-1.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c57c482e540dc7e5b3d304a5dcb7db3be146450b78309d63ef26c05bf3e236cc", size = 6597008, upload-time = "2026-06-10T16:01:04.827Z" },
{ url = "https://files.pythonhosted.org/packages/a1/b7/cde71d941995622aa5d6c1247c73fc10f52d43ffc81c91439b322cecef45/pillow_heif-1.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:51d86f5b8ad870dfce20963cab5b91ecc04041bb66ea4702338e8541be2b10d4", size = 6514441, upload-time = "2026-06-10T16:01:06.657Z" },
{ url = "https://files.pythonhosted.org/packages/a3/f3/9fdc040e4b6ae7706ceae366e2791842e0dbde9edb260a41548a2e4e5139/pillow_heif-1.4.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:e93b6f06b521f17c09a42b7ad21949e1e6af5382a5ac886f633a1dcea6646f6a", size = 4729933, upload-time = "2026-06-10T16:01:08.422Z" },
{ url = "https://files.pythonhosted.org/packages/98/73/f928bd4e2ff637d8cd3a7505effbbcef6e23ece336858d00fe274cfcd0be/pillow_heif-1.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3bba281b64861e15bc43eac384841e0ed4f33347eecfd2e80cd9b7ae1afc72f", size = 3955590, upload-time = "2026-06-10T16:01:09.814Z" },
{ url = "https://files.pythonhosted.org/packages/d9/00/539f145c3a413f539f72e84160e477140ea96e42887377e2eb225677c129/pillow_heif-1.4.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54faae0df573f5e4a81d6fc07a68025547246d499ac27b2cdf90a21e92d2d0d5", size = 6250797, upload-time = "2026-06-10T16:01:11.709Z" },
{ url = "https://files.pythonhosted.org/packages/60/b6/12bc3818fb2ef27f1a7821bcc2869c3a91ee9229aa542ce9ab3bf892fef0/pillow_heif-1.4.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f030e442de916cf44bdca740a3ffe61a5156cb92aaf9aba4b584992e2c33b8", size = 5664872, upload-time = "2026-06-10T16:01:13.645Z" },
{ url = "https://files.pythonhosted.org/packages/76/6c/14a319ea60291c5fe8d6c6d0d9ef3a3d2a4bed4b91ed855abc8b2a8fea5f/pillow_heif-1.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:45bfbf0124ef0be4349ba64922538292f038772cbbfd153ebf2a00683705ca82", size = 7337402, upload-time = "2026-06-10T16:01:15.598Z" },
{ url = "https://files.pythonhosted.org/packages/6b/bc/8f67f1bfb4ff8e3736a35775174ea288124d272cfc90bfbabd00f2547942/pillow_heif-1.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:25ae941e34456173c66a6b279ae38c1306e789c9342ae500ecbca864aeaa912e", size = 6598592, upload-time = "2026-06-10T16:01:17.629Z" },
{ url = "https://files.pythonhosted.org/packages/25/bb/25045177835f21f145835d54148718e8346f430738ab7166708ea359a669/pillow_heif-1.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:786ca8345fa1669c35984d03370aa3bab83d5fae12fe9e725a3857d4566251a0", size = 6514437, upload-time = "2026-06-10T16:01:19.574Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ba/1a8f5a42c14cf8ad7c7a6b459de33e2bbd693a15690802463b370ee578cd/pillow_heif-1.4.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:5c6181fb25c7d28f98702160b98967d1d7d432d71decac9351f5a47ee33554e0", size = 4730173, upload-time = "2026-06-10T16:01:23.282Z" },
{ url = "https://files.pythonhosted.org/packages/cd/64/e659dd4a9b54ad148b8e753df6c9b84e0140065bebb69665b583b53804a8/pillow_heif-1.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ade286eeadb361773604e294f98c4a1b64b577aca271faa6971e2fbd018918c7", size = 3955563, upload-time = "2026-06-10T16:01:24.75Z" },
{ url = "https://files.pythonhosted.org/packages/b8/48/73336f6b4c6532ca98025a8c1a0254882d52c93ce7cc377440234fa20317/pillow_heif-1.4.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:972c68fae8379c158aad222defa8b8f10c858a5f7f9cc99942f34b9667a516cd", size = 6249338, upload-time = "2026-06-10T16:01:27.247Z" },
{ url = "https://files.pythonhosted.org/packages/06/7a/e8053fa5e31f5f330ad1e9eec77625ba7ce97a1e765e5e05c85bf65d574a/pillow_heif-1.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfcd611a09dbb949715d5f25063bd3668e0daa06b53a3ed5bbd92d36b4f6b2a9", size = 5664216, upload-time = "2026-06-10T16:01:29.074Z" },
{ url = "https://files.pythonhosted.org/packages/23/e8/76f6ab993238c9d7da53fb64e588fd37ef7b603895b2383f1cbccc209233/pillow_heif-1.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:08026afd5f90628e029fe88aaa222280428ba7cc485a7cae553e78f7bb289b74", size = 7336120, upload-time = "2026-06-10T16:01:30.796Z" },
{ url = "https://files.pythonhosted.org/packages/d5/45/d77ad6de495dfcc55be00a48434183abd7cf8e3416916236e29d0ecd95b6/pillow_heif-1.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c8d001996e3a4687551385c2fe4d3fb84293727052fca1c0f50528c78b8327c7", size = 6597816, upload-time = "2026-06-10T16:01:32.773Z" },
{ url = "https://files.pythonhosted.org/packages/99/ef/f240ce0e84a6d0cf8dfee1834b4aec29f209c08e3d2047196cee65527fb7/pillow_heif-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:107d598a1b1694ba6f371927ffd6d72d6f9af2ca4082577d9edbfdeed465f940", size = 6514536, upload-time = "2026-06-10T16:01:34.571Z" },
{ url = "https://files.pythonhosted.org/packages/79/d4/d15e568b61f6020b1a772174b5c9c60341a1f0130951c518d33461b36bf8/pillow_heif-1.4.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:c699f8a3e845839bba590d3459ce59dba16c82c38e799955bef7042c54443eba", size = 4730166, upload-time = "2026-06-10T16:01:36.594Z" },
{ url = "https://files.pythonhosted.org/packages/55/c0/e4d9b5570ee70f12c817722df398cdcba7fb25f4ddc691a79008a31c653d/pillow_heif-1.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8f90b500ec1ae3a59d6613fd96123de35457f69fb9b3cd314d4b5a6799ba9843", size = 3955577, upload-time = "2026-06-10T16:01:38.13Z" },
{ url = "https://files.pythonhosted.org/packages/17/75/717a3ac5edf3b5c027659c59bbd09f731708e76eab03a7bcd89d68edefd7/pillow_heif-1.4.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6d907f454afa1958a688902e53f50ed7a98c8cbd6261d20f84b15e25f068020", size = 6249393, upload-time = "2026-06-10T16:01:40.398Z" },
{ url = "https://files.pythonhosted.org/packages/f9/ba/9d1336b1c5a6d2b7098cc851fcd3dbb5f25e37f942f327a404b2c8e0205d/pillow_heif-1.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be955358e501ab03cd83331a49d331c16b8ea1a4f5751d46c24e6b28d8aa6a56", size = 5664268, upload-time = "2026-06-10T16:01:42.018Z" },
{ url = "https://files.pythonhosted.org/packages/0b/63/8382df95a9b20d295742f19a54fc8f66c438362b841c416786b4f9a5c3e1/pillow_heif-1.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1207b6b0819654262811a0cb74730cca1ced1ab7786a0fcd92f55b84ca07a05c", size = 7336108, upload-time = "2026-06-10T16:01:44.682Z" },
{ url = "https://files.pythonhosted.org/packages/da/72/fbcbfac3ee43f8da79b1c6a8cbd62c2b9ee49ae069548029715c5a3fdc55/pillow_heif-1.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cdd3ebd552b50d0221bf525e033fbe3e83130b05c81bfd53d50ed4eafbb77a7c", size = 6597858, upload-time = "2026-06-10T16:01:46.501Z" },
{ url = "https://files.pythonhosted.org/packages/df/4b/3250c23b53f3ae0b39000da6bc517f2762600516f1d44549c9eb65657587/pillow_heif-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:19a2d9bd52b1f6dabed5305b8b5c4962b2bf7c21e8195c909e9425e4ca8ebf72", size = 6514532, upload-time = "2026-06-10T16:01:48.646Z" },
{ url = "https://files.pythonhosted.org/packages/44/bf/2d210965f010ed3e88c03530137ce03aeb9e1e1306e5815b49d0d3b9e2f0/pillow_heif-1.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4849dfceeec19d12c819bd12e138f91f1aa181eb2fd21231644bda91d2d0b292", size = 4730182, upload-time = "2026-06-10T16:01:50.45Z" },
{ url = "https://files.pythonhosted.org/packages/e1/d3/64818f50ff63066c2fbbbd2eecef7acd774bc96a9ac9b672df9175cb0ecb/pillow_heif-1.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d9d05477d67b3d63d601254ddca6b1e1943b0f5e5934cb5ed123c9b89e0fc60d", size = 3955616, upload-time = "2026-06-10T16:01:52.593Z" },
{ url = "https://files.pythonhosted.org/packages/b9/d2/ae19025e45149a7b974a4bfcf6a522b3359c7ae8afb48cce57204a0d7be9/pillow_heif-1.4.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93de4bdb496c5f509911b57b057c16ec1ed10f3617b87f03f7f152745758691b", size = 6249592, upload-time = "2026-06-10T16:02:07.409Z" },
{ url = "https://files.pythonhosted.org/packages/8d/9b/431f3080133849ee3d2a497b4097238ba9786ba64e30820563f07c8c9a18/pillow_heif-1.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6789fc4fe9ffa7beafcf0e287ea355de3a3c0c84e3adfabbbfdf8b46b83f223a", size = 5664277, upload-time = "2026-06-10T16:02:09.347Z" },
{ url = "https://files.pythonhosted.org/packages/30/bb/2e8578747987dd4c0d3289ca9ae8d9669c55cc5055bb0ccf39191b6b0338/pillow_heif-1.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31a152471c757eb02b71c9944a5973b818a21dea7980bfc57fe13945d051326", size = 7336225, upload-time = "2026-06-10T16:02:11.611Z" },
{ url = "https://files.pythonhosted.org/packages/b9/f4/d6ddf033215a7d4b5a2ddcac423d6243bb13aae021d856028e6205ea62f1/pillow_heif-1.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a3debca82964ba4080277620601f274a8c9d509d52fd3e1faacb314dc0678ee4", size = 6597937, upload-time = "2026-06-10T16:02:13.619Z" },
{ url = "https://files.pythonhosted.org/packages/28/6c/170e91115a5825b646fcb40cae9cd0c8977482588f667ad22981dc0ebe4c/pillow_heif-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:4bd30446e025e682ac1450446e0b9a9a06efc27b5417c81b18d26aa40b97cd70", size = 6698362, upload-time = "2026-06-10T16:02:15.48Z" },
{ url = "https://files.pythonhosted.org/packages/6c/6c/b7e21b57479a159157e944da641dc2112b432c4ef421c43c02a9c87580a8/pillow_heif-1.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9299b4e6cfa3d49c0495680011d224db69e41687c0e2de81a38ea6c39276e15b", size = 4731147, upload-time = "2026-06-10T16:02:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/43/6b/efb46f9f7c4a152ad1db194388f490b5c13f5fd11132aef99ba6c4e57e60/pillow_heif-1.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6b772360a412df897ed59d56d2ef30d52b6cce7c3f7b8b170a9578deb311953b", size = 3956423, upload-time = "2026-06-10T16:02:18.797Z" },
{ url = "https://files.pythonhosted.org/packages/56/1d/077278664d25759957ed2d16e2a777a0ecaf3629059bea4e5fc796e4e9db/pillow_heif-1.4.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffbed970ea96485f96874604d3df298c06e89475dea09c217b8b901cc7ed7d1a", size = 6254712, upload-time = "2026-06-10T16:02:20.454Z" },
{ url = "https://files.pythonhosted.org/packages/77/1f/20f28434f4ea119c34c98d3f3b3950236471a5cf5c5c8d47b400d1bfa2b2/pillow_heif-1.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f75faab30c0b1f20d266e0c8a1a746f7aa56694baf3fd5cc4d86f2f242bb58de", size = 5668435, upload-time = "2026-06-10T16:02:22.391Z" },
{ url = "https://files.pythonhosted.org/packages/aa/39/79a1e3b3d8a09fc618f5ecb984170d716b812fba7342f3246524a9c829e7/pillow_heif-1.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:500bc4ea048ed8f2e7167451737539c2763c2d157357d1706024b2a9e6910c61", size = 7341339, upload-time = "2026-06-10T16:02:24.566Z" },
{ url = "https://files.pythonhosted.org/packages/95/52/e72d34251e4712e4591b245aa3888beaee750894c6f7777d7505695ee364/pillow_heif-1.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:387b16e826ec29ab31101f257f362ed33e9b6aecec77d4f7d19caeb33fed0f1a", size = 6602011, upload-time = "2026-06-10T16:02:26.426Z" },
{ url = "https://files.pythonhosted.org/packages/68/36/03647089e0dc9ccdeb0de2653b549ff83c414fa0a5799d56b994b1894a0e/pillow_heif-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:84f138c31d34a8bab5ffa07ffbcf9b865b8ad34e6c495dcb6e01fb29574e218d", size = 6698960, upload-time = "2026-06-10T16:02:28.198Z" },
{ url = "https://files.pythonhosted.org/packages/de/ee/9799038c9e8790ff51976910b41cbf5359ad714d2c4ead688d419e86a65a/pillow_heif-1.4.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7be55e50250a79c2723e2a75aa6da90774fb42cce45d93062f91896c8b569ab3", size = 4718296, upload-time = "2026-06-10T16:02:29.945Z" },
{ url = "https://files.pythonhosted.org/packages/c6/98/b5e5aa1e2324b4d441d140b802183c33c8f6744d0f01a728be4bcc9d5bda/pillow_heif-1.4.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6eef7ac1594dafa592435336e7e6954ccfc549e767bff9ed814cdfa92e1278f7", size = 3952059, upload-time = "2026-06-10T16:02:31.465Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d9/79a82fd1802c529777977ac4cbe1210e470ed4ae29f3dbb6d2a0bf08757f/pillow_heif-1.4.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf6b287205bf47aa7423ca3caa795fd381e1e74422d10ba65c3f78e3a4a3c974", size = 6208633, upload-time = "2026-06-10T16:02:33.154Z" },
{ url = "https://files.pythonhosted.org/packages/cb/35/19d7bbe415657d74fd06e2aa661e5e379f8c6b5f8df0727cf5b0accf6e37/pillow_heif-1.4.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d6ce8db65311a7f56029bedc46034adb59f51a8f7ad6503edac1b26f50d49e4", size = 5619701, upload-time = "2026-06-10T16:02:34.987Z" },
{ url = "https://files.pythonhosted.org/packages/f3/e9/6a23b01ad080175fb495e37f32c124602ec750e79518dff09e1e51febcc8/pillow_heif-1.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4e34c549dce5f6808b6cee2ebfe81d6a80ef8ba3827007d57ca43c58540107f9", size = 6514779, upload-time = "2026-06-10T16:02:36.65Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -2692,7 +2750,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.13.0"
version = "0.14.0"
source = { editable = "." }
dependencies = [
{ name = "c2pa-python" },
@@ -2701,6 +2759,7 @@ dependencies = [
{ name = "opencv-python-headless" },
{ name = "piexif" },
{ name = "pillow" },
{ name = "pillow-heif" },
{ name = "python-dotenv" },
]
@@ -2779,6 +2838,7 @@ requires-dist = [
{ name = "opencv-python-headless", specifier = ">=4.8.0" },
{ name = "piexif", specifier = ">=1.1.3" },
{ name = "pillow", specifier = ">=10.0.0" },
{ name = "pillow-heif", specifier = ">=0.13.0" },
{ name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },