feat(visible): add Jimeng remover, fix Doubao outline defect, reproducible mask build
Visible-watermark work across all three corner-mark engines plus a committed,
reproducible alpha-build pipeline (scripts/visible_alpha_solve.py) fed by committed
solid black/gray/white captures.
- jimeng: new "即梦AI" wordmark remover (reverse-alpha + thin residual inpaint,
always NCC-aligned -- the mark re-rasterizes/jitters per image). Detect via glyph
silhouette NCC (0.45 threshold; does not cross-fire with Doubao). Registered in the
visible-mark catalog; `visible --mark jimeng` / `--mark auto`.
- doubao: fix a real production defect -- the shipped remover left a READABLE
"豆包AI生成" outline on real samples while detect() returned conf 0.0 (fooled by a
thin outline), so the test passed and the "56/56 clean" claim was detector-measured,
not visual. Root cause: under-estimated alpha + fixed-geometry-no-inpaint + tight
locate box. Rebuilt alpha (careful gray-self solve), always-align, thin inpaint,
widened locate box -> readable outline becomes faint texture-level traces.
- gemini: rebuild gemini_bg_{96,48} from our own controlled captures (validated NCC
0.9998 vs the prior third-party asset); removal re-verified clean, no behaviour change.
- tests: add textured-shift regression to both engines (guards the align-on-shift path
the Doubao defect exposed; lesson: a detector-only removal test is insufficient,
assert visual residual).
- docs: CLAUDE.md, README, capture READMEs and docstrings synced; stale
"exact/pixel-exact/56-clean" claims removed.
Also includes a SynthID label-wording clarification in identify.py/cli.py
("SynthID pixel watermark" -> "SynthID watermark, inferred from C2PA metadata").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@@ -36,9 +36,13 @@ yolov8n.pt
|
||||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
# Doubao watermark calibration (local only; ship only the derived alpha-map asset).
|
||||
# Synthetic seeds + raw Doubao captures are regenerable and not committed.
|
||||
# Non-ours reference artifacts go in any _refs/ dir (already ignored above): usable
|
||||
# locally for bootstrap/validation, never redistributed in the repo.
|
||||
# Visible-watermark alpha calibration. The solid black/gray/white CAPTURES are
|
||||
# committed (content-free: a solid colour + the watermark; the source for
|
||||
# scripts/visible_alpha_solve.py so the alpha assets are reproducible). The
|
||||
# synthetic seeds (regenerable) and any real-content validation download (a real
|
||||
# generated scene, kept local for privacy) are NOT committed.
|
||||
data/doubao_capture/seeds/
|
||||
data/doubao_capture/captures/
|
||||
data/jimeng_capture/seeds/
|
||||
data/jimeng_capture/captures/jimeng_content_*.png
|
||||
data/gemini_capture/seeds/
|
||||
data/gemini_capture/captures/gemini_content_*.png
|
||||
|
||||
@@ -17,7 +17,7 @@ If this tool saves you time, consider [sponsoring its development](https://githu
|
||||
|
||||
## Features
|
||||
|
||||
- **Visible watermark removal** — a registry of known marks in their usual places: the Gemini / Nano Banana sparkle and the Doubao "豆包AI生成" text strip. Each is removed by **exact reverse-alpha blending** against a captured alpha map (`original = (wm − α·logo)/(1−α)`), recovering the true pixels rather than inpainting a guess. Fast, offline, no GPU. `visible --mark auto` finds and removes the strongest detected mark. (For arbitrary logos/objects, see `erase`.)
|
||||
- **Visible watermark removal** — a registry of known marks in their usual places: the Gemini / Nano Banana sparkle, the Doubao "豆包AI生成" text strip, and the Jimeng "★ 即梦AI" wordmark. 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; the Doubao and Jimeng 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
|
||||
- **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
|
||||
@@ -49,13 +49,14 @@ If this tool saves you time, consider [sponsoring its development](https://githu
|
||||
| **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`) | Exact reverse-alpha (captured α map): pixel-exact at native width, NCC-aligned at other 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`) | 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, ...) | — | — | ✅ C2PA (signer "Samsung Galaxy") + `trainedAlgorithmicMedia` / proprietary `genAIType` marker | Detected (`identify`) + 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) and by Doubao / China AIGC generators (the mandated "...AI生成" corner text). Both are removed deterministically on CPU. 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) and by ByteDance's Doubao ("豆包AI生成" corner text) and Jimeng / Dreamina ("★ 即梦AI" wordmark). All are removed on CPU by reverse-alpha against a captured alpha map (Jimeng adds a residual inpaint over the glyph footprint, since its mark re-rasterizes per image). Other services rely on invisible watermarks and/or metadata; our diffusion-based regeneration works against any invisible watermark in pixel or frequency domain. For a visible mark from any other source (any position, any colour), use the universal `erase --region` command.
|
||||
|
||||
> **Detection:** `remove-ai-watermarks identify <image>` reports the origin platform and watermark inventory for all the signals above — C2PA issuer, the C2PA soft-binding forensic-watermark vendor (TrustMark / Digimarc / Imatag / ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, the China TC260 AIGC label (XMP, PNG chunk, or EXIF), the HuggingFace `hf-job-id` job marker, embedded generation params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the SynthID metadata proxy, the visible sparkle, and (with the `[detect]` / `[trustmark]` extras) the open SD/SDXL/FLUX and Adobe TrustMark invisible watermarks. SynthID and the proprietary soft-binding watermarks (Digimarc etc.) have no local decoder, so they are reported by metadata proxy / vendor name only.
|
||||
|
||||
@@ -81,9 +82,18 @@ A three-stage NCC (Normalized Cross-Correlation) detector finds the watermark po
|
||||
|
||||
### 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 — like the Gemini sparkle — it is removed by **exact reverse-alpha blending**: `original = (watermarked - α·logo) / (1 - α)`, recovering the true pixels instead of hallucinating them. The α map and logo colour were solved from controlled black + gray captures (on black, `captured = α·logo`; the black/gray pair solves α per-pixel). At the captured width the placement is exact, so the recovery is returned untouched (inpainting over exactly-recovered pixels only degrades them). The single capture generalizes to any resolution: off the captured width an NCC scale-and-position search registers the α template to the actual mark, and a light residual inpaint cleans the sub-pixel seam there. Detection is consistent with removal: it matches the same alpha glyph silhouette against the corner (normalized correlation), so it keys on the actual "豆包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. 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.
|
||||
|
||||
**Speed**: ~0.05s, no GPU needed. Reverse-alpha at the captured resolution recovers the true background pixels exactly.
|
||||
**Speed**: ~0.05s, 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).
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks visible jimeng.png -o clean.png # --mark auto picks Jimeng
|
||||
remove-ai-watermarks visible jimeng.png --mark jimeng -o clean.png
|
||||
```
|
||||
|
||||
### Universal region eraser
|
||||
|
||||
@@ -104,8 +114,12 @@ image → encode to latent space (VAE) at native resolution
|
||||
|
||||
- Native resolution avoids shrinking the input to 1024 px first; that down-then-up round-trip was the main quality loss (issue #10). Use `--max-resolution N` only to cap GPU/MPS memory on very large inputs.
|
||||
|
||||
> **If SynthID still verifies after the run, raise `--strength`.** The default `0.10` is the value that clears the watermark today, but SynthID is a moving target: both Google and OpenAI tighten it over time, and a larger image carries a stronger watermark (a 1600x1600 image needs more than a 400x400 one). There is no single permanent number, and there is no local SynthID detector, so the tool cannot self-check and auto-tune. The rule is simple: if the verifier still reads SynthID, step the strength up — try `--strength 0.12`, then `0.15`. Higher strength changes more detail and text, so use the lowest value that comes back clean on the oracle ([openai.com/research/verify](https://openai.com/research/verify/) or the Gemini app's "Verify with SynthID").
|
||||
|
||||
SDXL is the default since May 2026: empirically defeats SynthID v2 on Gemini 3 Pro outputs, where the older SD-1.5 pipeline at 768 px did not. The SD-1.5 path was removed once it was verified not to handle v2. Note the scope: this defeats the SynthID *verifier*, which is not the same as being forensically indistinguishable from a real photo. Recent work ([arXiv:2605.09203](https://arxiv.org/abs/2605.09203)) shows watermark-removal pipelines leave detectable traces, so a separate "this image was processed" classifier can still flag the output.
|
||||
|
||||
> **Oracle vs `identify` can disagree, and that is expected.** An online verifier reads the actual SynthID *pixel* watermark and detects only its own vendor's content — [openai.com/research/verify](https://openai.com/research/verify/) states "OpenAI generation signals will only be detected if the image was generated with our tools". Our `identify` cannot decode the pixel watermark (no vendor ships a local decoder), so it infers SynthID from the **C2PA metadata** instead. So after the SDXL pass the oracle can read "no SynthID" (pixel watermark gone) while `identify` still reports SynthID from a surviving C2PA manifest. They measure different signals. Run `metadata --remove` (or `all`) to also strip the manifest; note that a quiet metadata proxy is not proof the pixel watermark itself is gone.
|
||||
|
||||
**Face Protection**: before diffusion, YOLO detects people in the image and extracts them. After diffusion, the original faces are blended back with a soft elliptical mask to prevent AI distortion of facial features.
|
||||
|
||||
**Analog Humanizer**: optional film grain and chromatic aberration injection that mimics a photo of a screen, raising the bar for AI-generated image classifiers. (It frustrates generic classifiers but does not guarantee forensic invisibility — see the [arXiv:2605.09203](https://arxiv.org/abs/2605.09203) note above.)
|
||||
@@ -240,8 +254,8 @@ remove-ai-watermarks batch ./images/ --mode all
|
||||
remove-ai-watermarks identify image.png
|
||||
|
||||
# Visible watermark only — fast, offline, CPU. --mark auto (default) finds the
|
||||
# strongest known mark (Gemini sparkle / Doubao "豆包AI生成" text); force one
|
||||
# with --mark gemini / doubao. Removed by exact reverse-alpha (true-pixel recovery).
|
||||
# strongest known mark (Gemini sparkle / Doubao "豆包AI生成" / Jimeng "即梦AI"); force
|
||||
# one with --mark gemini / doubao / jimeng. Removed by reverse-alpha (true-pixel recovery).
|
||||
remove-ai-watermarks visible image.png -o clean.png
|
||||
|
||||
# Erase arbitrary region(s) — universal, any logo/watermark/object, any position.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# Doubao visible watermark capture
|
||||
|
||||
> **Status (completed 2026-05-29):** the capture described below was carried out (black + gray
|
||||
> Doubao captures) and the exact alpha map was solved. Removal is now **reverse-alpha only**: at the
|
||||
> captured native width recovery is pixel-exact and inpaint is OFF; a residual inpaint runs off-native
|
||||
> only. See the `doubao_engine.py` notes in the root `CLAUDE.md`. The text below is kept as the
|
||||
> historical capture plan.
|
||||
> **Status (captured 2026-05-29; alpha rebuilt 2026-05-31):** the black/gray/white captures were
|
||||
> taken and are now **committed** in `captures/` (solid colour + watermark, content-free). The alpha
|
||||
> map is rebuilt by `scripts/visible_alpha_solve.py doubao` (the careful gray-self solve shared with
|
||||
> Jimeng). The first build claimed "pixel-exact" but left a readable outline on the real sample (issue
|
||||
> #13 follow-up); removal now reverse-alphas, NCC-aligns, and applies a thin residual inpaint. See the
|
||||
> `doubao_engine.py` notes in the root `CLAUDE.md`. The text below is kept as the historical capture plan.
|
||||
|
||||
Goal: capture the Doubao "豆包AI生成" visible watermark over known flat backgrounds so we can
|
||||
build a per-pixel alpha map and a reverse-alpha-blend remover, the same way the Gemini sparkle
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 980 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,45 @@
|
||||
# Gemini (Nano Banana) visible sparkle capture
|
||||
|
||||
> **Status (captured 2026-05-31):** black/gray/white captures taken and **committed**
|
||||
> in `captures/` (solid colour + the sparkle, content-free). The sparkle-on-black assets
|
||||
> `gemini_bg_{96,48}.png` are rebuilt by `scripts/visible_alpha_solve.py gemini`.
|
||||
|
||||
Google Gemini (Nano Banana) stamps a four-point sparkle icon in the bottom-right corner
|
||||
via alpha compositing: `watermarked = a*logo + (1-a)*original`. Unlike the Doubao/Jimeng
|
||||
text marks, the sparkle is captured over a **pure-black** background, where
|
||||
`watermarked = a*255` (logo is near-white), so the alpha reads directly off the capture
|
||||
(`alpha = max(R,G,B)/255`) -- no background fit needed. This is the "golden" capture case.
|
||||
|
||||
## What the captures confirmed (2026-05-31)
|
||||
|
||||
- The sparkle at a 2048-wide image is **96x96 px** (width_frac ~0.047), bottom-right,
|
||||
margins ~0.031, alpha max ~0.51 -- matching the engine's existing 96px asset.
|
||||
- Our own controlled capture matches the previously third-party-sourced
|
||||
`gemini_bg_96.png` to **NCC 0.9998**, so the bundled asset is validated and now
|
||||
reproducible from our own capture.
|
||||
|
||||
## How to capture (image-edit path)
|
||||
|
||||
For each solid-colour seed (`seeds/seed_{black,gray,white}_2048.png`, gitignored):
|
||||
|
||||
1. Open Gemini image generation, image-edit / reference mode, upload the seed.
|
||||
2. Prompt: `Recreate this image exactly as it is, keep it identical, do not add or change anything`
|
||||
3. Download the ORIGINAL output (not a screenshot). Do not crop / edit / re-save.
|
||||
|
||||
Black is the key one (sparkle on black -> exact alpha). Gray/white cross-check.
|
||||
|
||||
## Naming, drop into `captures/`
|
||||
|
||||
```
|
||||
gemini_black_2048.png # the key capture (sparkle on black)
|
||||
gemini_gray_2048.png
|
||||
gemini_white_2048.png
|
||||
```
|
||||
|
||||
The solid captures are **committed** (content-free). The synthetic `seeds/` and any
|
||||
real-content `gemini_content_*.png` validation download are gitignored (local-only).
|
||||
Rebuild the assets with:
|
||||
|
||||
```
|
||||
uv run python scripts/visible_alpha_solve.py gemini # or: all
|
||||
```
|
||||
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 715 KiB |
|
After Width: | Height: | Size: 138 KiB |
@@ -0,0 +1,75 @@
|
||||
# Jimeng (即梦AI) visible watermark capture
|
||||
|
||||
> **Status (completed 2026-05-30):** solid black/gray/white Jimeng captures were
|
||||
> obtained (issue #13, from @powersee) and the alpha map was solved. Removal is
|
||||
> reverse-alpha plus a residual inpaint over the glyph footprint; see the
|
||||
> `jimeng_engine.py` notes in the root `CLAUDE.md`. The text below is kept as the
|
||||
> capture plan.
|
||||
|
||||
Goal: capture the Jimeng / Dreamina "★ 即梦AI" visible wordmark over known flat
|
||||
backgrounds so we can build a per-pixel alpha map and a reverse-alpha remover, the
|
||||
same way the Gemini sparkle and Doubao strip engines work
|
||||
(`src/remove_ai_watermarks/gemini_engine.py`, `doubao_engine.py`).
|
||||
|
||||
## What we learned (verified from the captures, 2026-05-30)
|
||||
|
||||
- Mark: a four-point sparkle icon followed by the "即梦AI" characters, near-white
|
||||
semi-transparent overlay, bottom-right corner.
|
||||
- Blend model: **alpha compositing with a pure-white logo** `watermarked =
|
||||
a*255 + (1-a)*original`, confirmed in sRGB (a linear-light solve made the
|
||||
black/gray cross-residual much worse, so the compositing is plain sRGB). An
|
||||
L-pair-solve (independent of the L assumption) lands at ~254.6, confirming white.
|
||||
- **Alpha is solved from the GRAY capture**, not black: `a = (I - B)/(255 - B)`
|
||||
with B a per-capture CUBIC background fit over the non-glyph pixels, averaged
|
||||
over channels, at FULL halo extent (down to a~0.02) and UNBLURRED. Gray (bg ~132)
|
||||
is the best proxy for real content (the mark sits on bright photo areas, not on
|
||||
black). This careful build drops the gray self-residual to ~1.3; an earlier
|
||||
max-channel / quadratic-bg / blurred / halo-truncated build (and a black-dominated
|
||||
least-squares solve) left a visible outline -- the mask quality, not the method,
|
||||
was the limit.
|
||||
- Geometry (fraction of image WIDTH, at the captured 2048): asset width ~0.211,
|
||||
height ~0.068, right margin ~0.023, bottom margin ~0.023. The mark scales with
|
||||
width; a real 1440-wide download matched width_frac ~0.21.
|
||||
- **Per-image render variation:** the alpha maps solved independently from the
|
||||
black and the gray capture correlate 0.998 but not 1.0 (mean |Δa| ~0.02). Jimeng
|
||||
re-rasterizes the mark per generation AND jitters its position a few px, so a
|
||||
single alpha map does NOT pixel-cancel the mark the way Doubao's deterministic
|
||||
overlay does. Removal therefore: NCC-aligns the alpha to the actual mark (always,
|
||||
not only off-native), reverse-alphas, then clears the residual with a THIN inpaint
|
||||
over the glyph footprint (a wide full-footprint pass smeared the texture/edges).
|
||||
|
||||
## How to capture (image-edit path, most reliable)
|
||||
|
||||
For each solid-color seed:
|
||||
|
||||
1. Open Jimeng image generation, use the image-edit / reference mode, upload the seed.
|
||||
2. Prompt (Chinese preferred):
|
||||
`请完全按照原图重新生成这张图片,保持完全一致,不要添加或修改任何内容`
|
||||
3. Download the ORIGINAL output file (not a screenshot). Do not crop / edit / re-save.
|
||||
|
||||
The black capture is the key one (white logo on black -> `captured ~= a*255`); the
|
||||
gray capture refines the alpha at mid-tones; the white capture confirms the logo is
|
||||
pure white (the mark is nearly invisible on white, as expected).
|
||||
|
||||
## Hygiene
|
||||
|
||||
- Original download, never a screenshot. PNG preferred; if Jimeng only gives JPEG, note it.
|
||||
- No crop / edit / re-save. Default settings, watermark left ON.
|
||||
|
||||
## Naming, drop into `captures/`
|
||||
|
||||
```
|
||||
jimeng_cap_A.png # black seed run through Jimeng
|
||||
jimeng_cap_B.png # white seed
|
||||
jimeng_cap_C.png # gray seed
|
||||
jimeng_content_1.png # a normal-content download, for end-to-end validation
|
||||
```
|
||||
|
||||
The solid `jimeng_cap_{A,B,C}.png` captures are **committed** (content-free: a solid
|
||||
colour + the watermark; the source for `scripts/visible_alpha_solve.py jimeng`). The
|
||||
synthetic `seeds/` and the real-content `jimeng_content_*.png` validation download are
|
||||
gitignored (local-only). Rebuild the alpha asset with:
|
||||
|
||||
```
|
||||
uv run python scripts/visible_alpha_solve.py jimeng # or: all
|
||||
```
|
||||
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 374 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,247 @@
|
||||
"""Rebuild the visible-watermark alpha assets from controlled captures.
|
||||
|
||||
The committed, reproducible build of the bundled visible-mark assets -- the inputs
|
||||
live in ``data/<engine>_capture/captures/`` (committed solid-colour captures run
|
||||
through the generator). Re-run after re-capturing.
|
||||
|
||||
**Doubao "豆包AI生成" strip and Jimeng "★ 即梦AI" wordmark** are fixed
|
||||
semi-transparent white overlays; the asset is their recovered per-pixel alpha map
|
||||
(``assets/<engine>_alpha.png``). The "careful" solve (issue #13) -- a naive build
|
||||
(max-over-channels, coarse background, blur, truncated halo, or a black-dominated
|
||||
least-squares fit) leaves a visible outline because the alpha is wrong at the glyph
|
||||
edges:
|
||||
|
||||
1. Locate the mark on the BLACK capture (bright pixels in the bottom-right).
|
||||
2. Fit a smooth CUBIC background per channel over the GRAY capture's non-glyph
|
||||
pixels (a cubic captures the gentle gradient without bleeding glyph values).
|
||||
3. Solve ``a = (I - B) / (255 - B)`` on the gray capture, AVERAGED over channels,
|
||||
at FULL halo extent (down to a~0.02) and UNBLURRED. Gray (background ~130-200)
|
||||
is the reference because the mark sits on bright photo content in real use, not
|
||||
on black; the white capture only confirms the logo is white.
|
||||
|
||||
**Gemini sparkle** is a different type: a single icon stamped on PURE BLACK, so the
|
||||
engine reads ``alpha = max(R,G,B)/255`` directly (no background fit). Its assets are
|
||||
the sparkle-on-black capture cropped to two fixed logo sizes (``gemini_bg_{96,48}.png``).
|
||||
|
||||
Usage::
|
||||
|
||||
uv run python scripts/visible_alpha_solve.py doubao
|
||||
uv run python scripts/visible_alpha_solve.py jimeng
|
||||
uv run python scripts/visible_alpha_solve.py gemini
|
||||
uv run python scripts/visible_alpha_solve.py all
|
||||
"""
|
||||
|
||||
# cv2/numpy boundary: third-party libs ship no usable element types; relax the
|
||||
# unknown-type rules for this file only (mirrors the engine modules).
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EngineSpec:
|
||||
"""Per-engine capture inputs and the alpha asset to rebuild."""
|
||||
|
||||
name: str
|
||||
capture_dir: Path
|
||||
black: str
|
||||
gray: str
|
||||
asset: Path
|
||||
native_width: int = 2048
|
||||
|
||||
|
||||
_SPECS: dict[str, EngineSpec] = {
|
||||
"doubao": EngineSpec(
|
||||
"doubao",
|
||||
_ROOT / "data" / "doubao_capture" / "captures",
|
||||
"doubao_black_1x1_1.png",
|
||||
"doubao_gray_1x1_1.png",
|
||||
_ROOT / "src" / "remove_ai_watermarks" / "assets" / "doubao_alpha.png",
|
||||
),
|
||||
"jimeng": EngineSpec(
|
||||
"jimeng",
|
||||
_ROOT / "data" / "jimeng_capture" / "captures",
|
||||
"jimeng_cap_A.png", # black seed
|
||||
"jimeng_cap_C.png", # gray seed
|
||||
_ROOT / "src" / "remove_ai_watermarks" / "assets" / "jimeng_alpha.png",
|
||||
),
|
||||
}
|
||||
|
||||
_CUBIC_BG_PAD = 30 # px of background margin around the mark for the cubic fit
|
||||
_GLYPH_BODY = 0.08 # alpha above this is the solid glyph body (for the bbox)
|
||||
_MIN_PART_AREA = 25 # drop connected glyph-mask blobs smaller than this (cubic-fit specks)
|
||||
_HALO_PAD = 7 # keep this many px of halo around the glyph body in the saved asset
|
||||
|
||||
# Gemini is a different watermark TYPE: a single sparkle icon stamped on a
|
||||
# PURE-BLACK background (so the engine reads alpha = max(R,G,B)/255 directly, no
|
||||
# background fit). Its assets are the sparkle-on-black CAPTURE at two fixed logo
|
||||
# sizes (the engine interpolates between them), not an alpha map.
|
||||
_GEMINI_CAPTURE = _ROOT / "data" / "gemini_capture" / "captures" / "gemini_black_2048.png"
|
||||
_GEMINI_ASSETS: dict[int, Path] = {
|
||||
96: _ROOT / "src" / "remove_ai_watermarks" / "assets" / "gemini_bg_96.png",
|
||||
48: _ROOT / "src" / "remove_ai_watermarks" / "assets" / "gemini_bg_48.png",
|
||||
}
|
||||
|
||||
|
||||
def _union_bbox(mask: NDArray[np.uint8], err: str) -> tuple[int, int, int, int]:
|
||||
"""Union bbox ``(x0, x1, y0, y1)`` of ``mask``'s connected components with area
|
||||
>= ``_MIN_PART_AREA``. The mark is several separate glyphs, so the union spans
|
||||
the whole word while a stray small speck/blotch is dropped by the area filter.
|
||||
Raises ``ValueError(err)`` if nothing qualifies."""
|
||||
n, _labels, stats, _c = cv2.connectedComponentsWithStats(mask, connectivity=8)
|
||||
parts = [i for i in range(1, n) if stats[i, cv2.CC_STAT_AREA] >= _MIN_PART_AREA]
|
||||
if not parts:
|
||||
raise ValueError(err)
|
||||
x0 = min(int(stats[i, cv2.CC_STAT_LEFT]) for i in parts)
|
||||
y0 = min(int(stats[i, cv2.CC_STAT_TOP]) for i in parts)
|
||||
x1 = max(int(stats[i, cv2.CC_STAT_LEFT] + stats[i, cv2.CC_STAT_WIDTH]) for i in parts)
|
||||
y1 = max(int(stats[i, cv2.CC_STAT_TOP] + stats[i, cv2.CC_STAT_HEIGHT]) for i in parts)
|
||||
return x0, x1, y0, y1
|
||||
|
||||
|
||||
def _locate_on_black(black: NDArray[np.float32]) -> tuple[int, int, int, int]:
|
||||
"""Bounding box of the white mark on the black capture (bottom-right).
|
||||
|
||||
Thresholds well above the blotchy near-black background, then unions the
|
||||
sufficiently-large bright components so the box spans the whole word.
|
||||
"""
|
||||
h, w = black.shape[:2]
|
||||
lum = black.mean(axis=2)
|
||||
br = lum > 40 # comfortably above the ~5-30 background blotches
|
||||
br[: h * 3 // 4, :] = False # bottom quarter only
|
||||
br[:, : w * 3 // 4] = False # right quarter only
|
||||
bright = cv2.morphologyEx(br.astype(np.uint8) * 255, cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8))
|
||||
return _union_bbox(bright, "no mark found on the black capture (bottom-right is empty)")
|
||||
|
||||
|
||||
def _cubic_background(crop: NDArray[np.float32], glyph: NDArray[np.bool_]) -> NDArray[np.float32]:
|
||||
"""Per-channel cubic surface fit over the non-glyph pixels of ``crop``."""
|
||||
h, w = crop.shape[:2]
|
||||
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
|
||||
yy /= h
|
||||
xx /= w
|
||||
terms = [np.ones_like(xx), xx, yy, xx * xx, xx * yy, yy * yy, xx**3, xx * xx * yy, xx * yy * yy, yy**3]
|
||||
basis = np.stack(terms, axis=-1).reshape(-1, len(terms))
|
||||
keep = (~glyph).reshape(-1)
|
||||
out = np.zeros_like(crop)
|
||||
for ch in range(3):
|
||||
values = crop[..., ch].reshape(-1)
|
||||
coef, *_ = np.linalg.lstsq(basis[keep], values[keep], rcond=None)
|
||||
out[..., ch] = (basis @ coef).reshape(h, w)
|
||||
return out
|
||||
|
||||
|
||||
def solve_alpha(spec: EngineSpec) -> NDArray[np.uint8]:
|
||||
"""Solve the careful gray-self alpha map for one engine (uint8, a*255)."""
|
||||
black = image_io.imread(str(spec.capture_dir / spec.black), cv2.IMREAD_COLOR)
|
||||
gray = image_io.imread(str(spec.capture_dir / spec.gray), cv2.IMREAD_COLOR)
|
||||
if black is None or gray is None:
|
||||
raise FileNotFoundError(f"missing captures in {spec.capture_dir} (expected {spec.black}, {spec.gray})")
|
||||
black_f = black.astype(np.float32)
|
||||
gray_f = gray.astype(np.float32)
|
||||
|
||||
img_h, img_w = black_f.shape[:2]
|
||||
mx0, mx1, my0, my1 = _locate_on_black(black_f)
|
||||
pad = _CUBIC_BG_PAD
|
||||
rx0, rx1 = max(0, mx0 - pad), min(img_w, mx1 + pad)
|
||||
ry0, ry1 = max(0, my0 - pad), min(img_h, my1 + pad)
|
||||
cg = gray_f[ry0:ry1, rx0:rx1]
|
||||
cb = black_f[ry0:ry1, rx0:rx1]
|
||||
|
||||
glyph = cv2.dilate((cb.mean(axis=2) > 8).astype(np.uint8), np.ones((9, 9), np.uint8)) > 0
|
||||
bg = _cubic_background(cg, glyph)
|
||||
alpha = np.clip((cg - bg).mean(axis=2) / np.clip(255.0 - bg.mean(axis=2), 1e-3, None), 0.0, 1.0)
|
||||
|
||||
# Crop to the UNION of the glyph parts (the mark is several disconnected
|
||||
# glyphs), padded by _HALO_PAD -- this keeps the real anti-aliased halo while
|
||||
# dropping the small cubic-fit specks at the crop edges (< _MIN_PART_AREA) that
|
||||
# a bare a>floor box would otherwise inflate the asset with.
|
||||
body = (alpha > _GLYPH_BODY).astype(np.uint8)
|
||||
bx, bex, by, bey = _union_bbox(body, "solved alpha has no glyph body -- check the gray capture background")
|
||||
cx0 = max(0, bx - _HALO_PAD)
|
||||
cy0 = max(0, by - _HALO_PAD)
|
||||
cx1 = min(alpha.shape[1], bex + _HALO_PAD)
|
||||
cy1 = min(alpha.shape[0], bey + _HALO_PAD)
|
||||
tight = alpha[cy0:cy1, cx0:cx1]
|
||||
aw, ah = tight.shape[1], tight.shape[0]
|
||||
# Absolute asset position in the capture, for the engine's geometry constants.
|
||||
abs_x0, abs_y0 = rx0 + cx0, ry0 + cy0
|
||||
log.info(
|
||||
"%s: alpha %dx%d max %.3f | WIDTH_FRAC %.4f HEIGHT_FRAC %.4f "
|
||||
"MARGIN_RIGHT_FRAC %.4f MARGIN_BOTTOM_FRAC %.4f (native_width %d)",
|
||||
spec.name,
|
||||
aw,
|
||||
ah,
|
||||
float(tight.max()),
|
||||
aw / spec.native_width,
|
||||
ah / spec.native_width,
|
||||
(img_w - (abs_x0 + aw)) / spec.native_width,
|
||||
(img_h - (abs_y0 + ah)) / spec.native_width,
|
||||
spec.native_width,
|
||||
)
|
||||
return (np.clip(tight, 0.0, 1.0) * 255.0).astype(np.uint8)
|
||||
|
||||
|
||||
def solve_gemini() -> dict[int, NDArray[np.uint8]]:
|
||||
"""Extract the Gemini sparkle-on-black region from the black capture at each
|
||||
bundled logo size (the bg-capture asset format; the engine derives the alpha).
|
||||
Returns ``{size: bgr_image}``."""
|
||||
black = image_io.imread(str(_GEMINI_CAPTURE), cv2.IMREAD_COLOR)
|
||||
if black is None:
|
||||
raise FileNotFoundError(f"missing Gemini capture {_GEMINI_CAPTURE}")
|
||||
h, w = black.shape[:2]
|
||||
bright = np.zeros((h, w), np.uint8)
|
||||
reg = black.astype(np.float32).mean(axis=2) > 60 # sparkle is ~0.5*255 on black
|
||||
reg[: h * 3 // 4, :] = False
|
||||
reg[:, : w * 3 // 4] = False
|
||||
bright[reg] = 255
|
||||
bright = cv2.morphologyEx(bright, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8))
|
||||
x0, x1, y0, y1 = _union_bbox(bright, "no sparkle found on the Gemini black capture")
|
||||
crop = black[y0:y1, x0:x1]
|
||||
log.info("gemini: sparkle %dx%d at margin_frac %.4f", x1 - x0, y1 - y0, (w - x1) / w)
|
||||
return {size: cv2.resize(crop, (size, size), interpolation=cv2.INTER_AREA) for size in _GEMINI_ASSETS}
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("engine", type=click.Choice([*_SPECS, "gemini", "all"]))
|
||||
def main(engine: str) -> None:
|
||||
"""Rebuild the alpha asset(s) for ENGINE (doubao / jimeng / gemini / all)."""
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
|
||||
def _write(path: Path, img: NDArray[np.uint8], label: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not image_io.imwrite(str(path), img):
|
||||
raise OSError(f"failed to write {path}")
|
||||
log.info("%s: wrote %s", label, path.relative_to(_ROOT))
|
||||
|
||||
if engine in ("doubao", "jimeng", "all"):
|
||||
specs = list(_SPECS.values()) if engine == "all" else [_SPECS[engine]]
|
||||
for spec in specs:
|
||||
_write(spec.asset, solve_alpha(spec), spec.name)
|
||||
if engine in ("gemini", "all"):
|
||||
for size, img in solve_gemini().items():
|
||||
_write(_GEMINI_ASSETS[size], img, f"gemini-{size}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -502,7 +502,7 @@ def cmd_metadata(
|
||||
console.print(f" [yellow]⚠[/] AI metadata detected in {source.name}:")
|
||||
meta = get_ai_metadata(source)
|
||||
if synthid := meta.get("synthid_watermark"):
|
||||
console.print(f" [bold yellow]⚠ SynthID pixel watermark {synthid}[/]")
|
||||
console.print(f" [bold yellow]⚠ SynthID watermark (inferred from C2PA metadata) {synthid}[/]")
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("Key", style="cyan")
|
||||
table.add_column("Value")
|
||||
|
||||
@@ -42,13 +42,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Geometry as a fraction of image WIDTH. The Doubao mark scales with width and
|
||||
# is anchored bottom-right. The box is intentionally generous (the glyph mask
|
||||
# tightens it); values cover measured outputs across resolutions and aspect
|
||||
# ratios (square 2048, portrait, ultra-wide). Margins are width-relative too.
|
||||
WM_WIDTH_FRAC = 0.185
|
||||
WM_HEIGHT_FRAC = 0.065
|
||||
MARGIN_RIGHT_FRAC = 0.012
|
||||
MARGIN_BOTTOM_FRAC = 0.014
|
||||
# is anchored bottom-right. The box must be GENEROUSLY wider than the mark and
|
||||
# reach close to the corner -- the mark is re-rasterized a few px off per image,
|
||||
# and the NCC alignment search only registers within this box, so a tight box
|
||||
# (the old 0.185 / margin 0.012) let a corner-ward shift fall partly outside it
|
||||
# and the alignment missed. The glyph mask tightens the actual removal.
|
||||
WM_WIDTH_FRAC = 0.22
|
||||
WM_HEIGHT_FRAC = 0.075
|
||||
MARGIN_RIGHT_FRAC = 0.004
|
||||
MARGIN_BOTTOM_FRAC = 0.004
|
||||
|
||||
# Glyph appearance: the label is a low-saturation light gray, rendered brighter
|
||||
# than the surrounding content (the common case: a generated photo/illustration).
|
||||
@@ -73,34 +75,37 @@ 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 (exact recovery, Gemini-style) ─────────────────────
|
||||
# ── Reverse-alpha (recovery + thin residual inpaint) ─────────────────
|
||||
# The Doubao mark is a fixed semi-transparent white overlay, so given its alpha
|
||||
# map the original pixels are recovered exactly: original = (wm - a*logo)/(1-a).
|
||||
# The alpha map + logo colour were solved from black+gray Doubao captures on a
|
||||
# controlled background (data/doubao_capture/): on black, captured = a*logo, and
|
||||
# the black/gray pair solves a per-pixel WITHOUT assuming the logo colour. The
|
||||
# bundled asset (assets/doubao_alpha.png) is the alpha template (a*255) at the
|
||||
# captured width. The mark scales with image WIDTH, but a pure width-scale is
|
||||
# only sub-pixel-accurate at the captured width and ghosts elsewhere, so removal
|
||||
# does NOT trust fixed geometry: `_aligned_alpha_map` registers the template to
|
||||
# the actual mark by a TM_CCOEFF_NORMED scale+position search, which makes the
|
||||
# single capture work at any resolution (verified clean on 1773x2364). Verified
|
||||
# 2026-05-29: white-capture cross-check -> mark vanishes to a flat fill; clean on
|
||||
# doubao-1.png (2048) and the 3:4 portrait corpus size.
|
||||
# map the original pixels are recovered by inverting the blend: (wm - a*logo)/(1-a).
|
||||
# The alpha map is rebuilt by scripts/visible_alpha_solve.py from the black/gray
|
||||
# Doubao captures (data/doubao_capture/): the CAREFUL solve -- a = (I - B)/(255 - B)
|
||||
# on the gray capture with B a per-channel cubic background fit, mean over channels,
|
||||
# full halo extent, unblurred. The earlier build (a coarser solve) under-estimated
|
||||
# the alpha and left a clearly READABLE "豆包AI生成" outline on real samples
|
||||
# (issue #13 follow-up: the detector was fooled by the outline -- conf 0.0 -- so the
|
||||
# test passed while the result was visibly bad; suspect the captured alpha map, not
|
||||
# the method). The mark is re-rasterized and a few px off per image, so removal
|
||||
# does NOT trust fixed geometry: it ALWAYS tries fixed AND `_aligned_alpha_map`'s
|
||||
# TM_CCOEFF_NORMED scale+position search and keeps the lower-residual placement,
|
||||
# then a deliberately THIN residual inpaint clears the leftover edges without
|
||||
# smearing the recovered texture. Geometry below is emitted by the solver -- keep in
|
||||
# sync when the asset is rebuilt.
|
||||
_ALPHA_NATIVE_WIDTH = 2048
|
||||
_ALPHA_LOGO_BGR: tuple[float, float, float] = (252.0, 255.0, 255.0)
|
||||
_ALPHA_WIDTH_FRAC = 0.1572 # glyph width / image width -- the alignment scale seed
|
||||
_ALPHA_HEIGHT_FRAC = 0.0347
|
||||
_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_HEIGHT_FRAC = 0.0405
|
||||
# Margins (of image WIDTH) of the captured mark -- the geometry record / where to
|
||||
# seed; alignment refines the actual position, so these are not load-bearing.
|
||||
_ALPHA_MARGIN_RIGHT_FRAC = 0.0166
|
||||
_ALPHA_MARGIN_BOTTOM_FRAC = 0.0195
|
||||
_ALPHA_MARGIN_RIGHT_FRAC = 0.0132
|
||||
_ALPHA_MARGIN_BOTTOM_FRAC = 0.0166
|
||||
# Alignment scale search (np.linspace args) around the width-scaled glyph size.
|
||||
_ALPHA_ALIGN_SEARCH = (0.88, 1.12, 13)
|
||||
# At (near) the captured width the fixed geometry is pixel-exact, so we use it
|
||||
# directly there -- NCC alignment is integer-pixel and would land ~1px off,
|
||||
# degrading the otherwise-exact native recovery. Off this band, alignment wins.
|
||||
_ALPHA_NATIVE_BAND = 0.03
|
||||
_ALPHA_ALIGN_SEARCH = (0.88, 1.12, 25)
|
||||
# Residual inpaint over the glyph footprint -- thin (NS, small radius) so it clears
|
||||
# the leftover edges without the smear a wide full-footprint pass caused.
|
||||
_RESIDUAL_ALPHA_FLOOR = 0.05
|
||||
_RESIDUAL_DILATE = 5
|
||||
_RESIDUAL_INPAINT_RADIUS = 2
|
||||
_alpha_template_cache: NDArray[Any] | None = None
|
||||
|
||||
|
||||
@@ -348,21 +353,17 @@ class DoubaoEngine:
|
||||
|
||||
def remove_watermark_reverse_alpha(self, image: NDArray[Any], *, residual_inpaint: bool = True) -> NDArray[Any]:
|
||||
"""Recover the original pixels by inverting the alpha blend
|
||||
``original = (wm - a*logo)/(1-a)``.
|
||||
``original = (wm - a*logo)/(1-a)``, then clear the residual edges with a
|
||||
thin inpaint over the glyph footprint.
|
||||
|
||||
Placement: at (near) the captured width the fixed geometry is pixel-exact,
|
||||
so the recovery is returned UNTOUCHED -- inpainting over exactly-recovered
|
||||
interior pixels only swaps them for a cv2 hallucination (measured worse on
|
||||
textured backgrounds: native error vs true bg 1.6 reverse-alpha-only vs
|
||||
2.6 with full-footprint inpaint). Off-native, NCC alignment registers the
|
||||
template to the real mark; the alignment is only sub-pixel-approximate, so
|
||||
the interior recovery is no longer exact and the seam can re-trip the
|
||||
detector. There we try BOTH placements and keep whichever leaves the least
|
||||
residual mark (on a faint/busy-background mark the NCC peak can wander a
|
||||
few px, where geometry wins; on a clear mark alignment wins) -- no magic
|
||||
threshold, it just picks the better removal -- then a residual inpaint over
|
||||
the glyph footprint cleans the seam (the interior is approximate anyway, so
|
||||
inpaint there costs nothing and reliably clears the mark).
|
||||
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 is re-rasterized and a few px off per image, so fixed
|
||||
geometry alone leaves a visible outline (it did on the doubao-1.png sample).
|
||||
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 leftover edges instead of smearing the whole footprint.
|
||||
Call only when :meth:`reverse_alpha_available` and the mark is detected.
|
||||
"""
|
||||
# Normalize to 3-channel BGR so a 2D grayscale or 4-channel BGRA input
|
||||
@@ -371,10 +372,6 @@ class DoubaoEngine:
|
||||
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
||||
elif image.shape[2] == 4:
|
||||
image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
|
||||
at_native = abs(image.shape[1] / _ALPHA_NATIVE_WIDTH - 1.0) <= _ALPHA_NATIVE_BAND
|
||||
if at_native:
|
||||
amap = self._fixed_alpha_map(image)
|
||||
return self._apply_reverse_alpha(image, amap[0]) if amap is not None else image.copy()
|
||||
maps = [c for c in (self._fixed_alpha_map(image), self._aligned_alpha_map(image)) if c is not None]
|
||||
if not maps:
|
||||
return image.copy()
|
||||
@@ -389,8 +386,9 @@ class DoubaoEngine:
|
||||
if best_out is None or best_amap is None: # pragma: no cover - maps is non-empty
|
||||
return image.copy()
|
||||
if residual_inpaint:
|
||||
rm = cv2.dilate((best_amap > 0.10).astype(np.uint8) * 255, np.ones((3, 3), np.uint8))
|
||||
best_out = cv2.inpaint(best_out, rm, 3, cv2.INPAINT_TELEA)
|
||||
kernel = np.ones((_RESIDUAL_DILATE, _RESIDUAL_DILATE), np.uint8)
|
||||
rm = cv2.dilate((best_amap > _RESIDUAL_ALPHA_FLOOR).astype(np.uint8) * 255, kernel)
|
||||
best_out = cv2.inpaint(best_out, rm, _RESIDUAL_INPAINT_RADIUS, cv2.INPAINT_NS)
|
||||
return best_out
|
||||
|
||||
|
||||
|
||||
@@ -429,7 +429,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
# and non-PNG (its own synthid_source fallback), so no extra scan is needed.
|
||||
synthid = meta.get("synthid_watermark")
|
||||
if synthid:
|
||||
watermarks.append(f"SynthID pixel watermark ({synthid})")
|
||||
watermarks.append(f"SynthID watermark, inferred from C2PA metadata ({synthid})")
|
||||
caveats.append(_SYNTHID_CAVEAT)
|
||||
if _vendor_of(synthid) == "OpenAI":
|
||||
caveats.append(_OPENAI_CAVEAT)
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
"""Jimeng (即梦AI) visible watermark removal engine.
|
||||
|
||||
Jimeng / Dreamina (ByteDance's image generator, distinct from Doubao) stamps a
|
||||
visible "★ 即梦AI" wordmark -- a four-point sparkle icon followed by the 即梦AI
|
||||
characters -- in the bottom-right corner: a near-white semi-transparent overlay,
|
||||
the explicit AIGC label under China's TC260 standard.
|
||||
|
||||
Like the Gemini sparkle and the Doubao strip, it is a fixed overlay, so removal
|
||||
starts from **reverse-alpha blending** against a captured alpha map
|
||||
(``remove_watermark_reverse_alpha``): ``original = (wm - a*logo)/(1-a)``. The logo
|
||||
is pure white (255,255,255); the alpha map was solved from the GRAY Jimeng capture
|
||||
(see data/jimeng_capture/), bundled as ``assets/jimeng_alpha.png`` -- a careful
|
||||
build (cubic-background fit, mean over channels, full halo extent, unblurred) that
|
||||
drops the self-residual to ~1.3. Gray is the chosen background because the mark
|
||||
sits on bright photo content in real use, not on black.
|
||||
|
||||
Unlike the Doubao mark, Jimeng re-rasterizes its mark per generation AND jitters
|
||||
its position a few px (the alpha maps solved from independent captures correlate
|
||||
0.998 but not 1.0), so a single 2048 alpha map does not pixel-cancel the mark on
|
||||
every image/resolution the way Doubao's deterministic overlay does. Removal
|
||||
therefore NCC-aligns the alpha to the actual mark (always, not only off-native),
|
||||
reverse-alphas, then clears the residual with a deliberately THIN inpaint over the
|
||||
glyph footprint. The reverse-alpha pre-step recovers the true background (including
|
||||
edges) under the semi-transparent mark, so the thin inpaint only finishes the
|
||||
residual edges rather than smearing the whole footprint -- a wide full-footprint
|
||||
pass blurred the texture/edges under the mark. Verified clean on the solid captures
|
||||
(native 2048) and on a real 1440-wide Jimeng download (off-native, table edge kept).
|
||||
|
||||
Detection (``detect``) matches the bundled "即梦AI" glyph silhouette against the
|
||||
corner candidate via normalized correlation, so it keys on the actual mark shape
|
||||
(real marks score >=0.81, the Doubao strip 0.21, other AI output 0.0) rather than
|
||||
coverage heuristics, and does not hijack ``--mark auto`` on a Doubao image.
|
||||
|
||||
``locate`` (geometry box, scales with image WIDTH) and ``extract_mask`` (the
|
||||
candidate glyph mask the detector correlates) mirror the Doubao engine. Fast,
|
||||
offline, no GPU. Arbitrary-region inpainting still lives in ``region_eraser`` /
|
||||
the ``erase`` command.
|
||||
"""
|
||||
|
||||
# cv2/numpy boundary: third-party libs ship no usable element types; relax the
|
||||
# unknown-type rules for this file only.
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Geometry as a fraction of image WIDTH. The Jimeng mark scales with width and is
|
||||
# anchored bottom-right. The box is intentionally generous (the glyph mask
|
||||
# tightens it); values cover the measured 2048 captures plus a real 1440 download.
|
||||
WM_WIDTH_FRAC = 0.27
|
||||
WM_HEIGHT_FRAC = 0.092
|
||||
MARGIN_RIGHT_FRAC = 0.008
|
||||
MARGIN_BOTTOM_FRAC = 0.010
|
||||
|
||||
# Glyph appearance: a low-saturation light gray rendered brighter than the
|
||||
# surrounding content (white top-hat: brighter than a blurred local background)
|
||||
# intersected with the grayish + minimum-brightness tests. Same polarity logic as
|
||||
# the Doubao engine: leaves white-paper documents untouched (the mark is not
|
||||
# brighter than its surroundings there, so nothing is masked).
|
||||
MAX_SATURATION = 55 # max channel spread to count a pixel as "grayish"
|
||||
LOGO_MIN_LUMA = 150 # glyphs are at least this bright in absolute terms
|
||||
TOPHAT_DELTA = 12 # glyph must exceed the local background by this many levels
|
||||
|
||||
# Detection matches the bundled alpha-template glyph silhouette
|
||||
# (assets/jimeng_alpha.png) against the candidate via zero-mean normalized
|
||||
# correlation (cv2 TM_CCOEFF_NORMED). Real Jimeng marks score >=0.83, the Doubao
|
||||
# strip 0.22, other AI output 0.0 -> threshold 0.45 separates cleanly while
|
||||
# keeping `--mark auto` from confusing Jimeng with Doubao. A small coverage floor
|
||||
# skips the template match on a near-empty candidate box.
|
||||
DETECT_MIN_COVERAGE = 0.02
|
||||
DETECT_NCC_THRESHOLD = 0.45
|
||||
|
||||
# ── Reverse-alpha (recovery, Gemini/Doubao-style) ────────────────────
|
||||
# The Jimeng mark is a fixed semi-transparent white overlay; given its alpha map
|
||||
# the original pixels are recovered by inverting the blend. The logo is pure white
|
||||
# (the white capture confirms L=255 and a pair-solve of L lands at ~254.6). The
|
||||
# alpha map was solved from the GRAY capture: a = (I - B)/(255 - B) with B a
|
||||
# per-capture CUBIC background fit over the non-glyph pixels, averaged over the
|
||||
# three channels, kept at full halo extent (down to a~0.02) and UNBLURRED. Gray
|
||||
# (background ~132, mark contrast ~120) is chosen over black because it is the
|
||||
# best proxy for real content, where the mark sits on bright photo areas, not on
|
||||
# black; the careful build drops the gray self-residual to ~1.3 (the earlier
|
||||
# max-channel / quadratic-bg / blurred / halo-truncated build was visibly worse --
|
||||
# the mask, not the method, was the limit). The bundled asset
|
||||
# (assets/jimeng_alpha.png) is the alpha template (a*255) at the captured width.
|
||||
# The mark scales with image WIDTH; a pure width-scale is only sub-pixel-accurate
|
||||
# at the captured width, so removal also registers the template to the actual mark
|
||||
# via a TM_CCOEFF_NORMED scale+position search (`_aligned_alpha_map`) off it.
|
||||
_ALPHA_NATIVE_WIDTH = 2048
|
||||
_ALPHA_LOGO_BGR: tuple[float, float, float] = (255.0, 255.0, 255.0)
|
||||
# Geometry below is emitted by scripts/visible_alpha_solve.py for the bundled
|
||||
# asset -- keep them in sync when the asset is rebuilt.
|
||||
_ALPHA_WIDTH_FRAC = 0.2021 # asset width / image width -- the alignment scale seed
|
||||
_ALPHA_HEIGHT_FRAC = 0.0576
|
||||
# Margins (of image WIDTH) of the captured mark -- the geometry record / where to
|
||||
# seed; alignment refines the actual position, so these are not load-bearing.
|
||||
_ALPHA_MARGIN_RIGHT_FRAC = 0.0288
|
||||
_ALPHA_MARGIN_BOTTOM_FRAC = 0.0288
|
||||
# Alignment scale search (np.linspace args) around the width-scaled glyph size --
|
||||
# fine enough that a per-image scale/position jitter does not leave a thick
|
||||
# edge-misalignment outline (a coarse step left ~4px slop at the mark ends).
|
||||
_ALPHA_ALIGN_SEARCH = (0.90, 1.12, 23)
|
||||
# Residual inpaint footprint: unlike Doubao, Jimeng's per-image render variation
|
||||
# leaves a faint outline even at native, so the glyph footprint (alpha above this)
|
||||
# is always inpainted after reverse-alpha (dilated by this kernel, INPAINT_NS).
|
||||
# Kept deliberately THIN -- the careful alpha map (cubic-background, mean-channel,
|
||||
# full-halo solve) knocks the mark down far enough that a tight footprint clears
|
||||
# it, so the inpaint does not smear the texture/edges under the mark the way a
|
||||
# wide full-footprint pass did.
|
||||
_RESIDUAL_ALPHA_FLOOR = 0.05
|
||||
_RESIDUAL_DILATE = 5
|
||||
_RESIDUAL_INPAINT_RADIUS = 2
|
||||
_alpha_template_cache: NDArray[Any] | None = None
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""Lazily load the bundled Jimeng alpha template (float [0,1]), or None."""
|
||||
global _alpha_template_cache
|
||||
if _alpha_template_cache is None:
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
path = Path(__file__).parent / "assets" / "jimeng_alpha.png"
|
||||
img = image_io.imread(str(path), cv2.IMREAD_GRAYSCALE)
|
||||
if img is None:
|
||||
return None
|
||||
_alpha_template_cache = img.astype(np.float32) / 255.0
|
||||
return _alpha_template_cache
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JimengLocation:
|
||||
"""Located watermark box (bottom-right), in absolute pixel coordinates."""
|
||||
|
||||
x: int
|
||||
y: int
|
||||
w: int
|
||||
h: int
|
||||
is_fallback: bool = True # geometry anchor (no template match) -> always True for now
|
||||
|
||||
@property
|
||||
def bbox(self) -> tuple[int, int, int, int]:
|
||||
return self.x, self.y, self.w, self.h
|
||||
|
||||
|
||||
@dataclass
|
||||
class JimengDetection:
|
||||
"""Result of visible Jimeng watermark detection."""
|
||||
|
||||
detected: bool = False
|
||||
confidence: float = 0.0
|
||||
region: tuple[int, int, int, int] = (0, 0, 0, 0)
|
||||
coverage: float = 0.0 # fraction of the box occupied by glyph pixels
|
||||
|
||||
|
||||
_silhouette_cache: NDArray[Any] | None = None
|
||||
|
||||
|
||||
def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
"""Binary "即梦AI" silhouette (255 = glyph) from the bundled alpha map, used
|
||||
as the detection template. None if the alpha asset is missing."""
|
||||
global _silhouette_cache
|
||||
if _silhouette_cache is None:
|
||||
at = _alpha_template()
|
||||
if at is None:
|
||||
return None
|
||||
_silhouette_cache = (at > 0.15).astype(np.uint8) * 255
|
||||
return _silhouette_cache
|
||||
|
||||
|
||||
def _template_match_score(box_mask: NDArray[Any], image_width: int) -> float:
|
||||
"""Zero-mean normalized correlation of the alpha-template glyph silhouette
|
||||
(scaled to the mark's expected size) against the candidate ``box_mask``."""
|
||||
sil = _glyph_silhouette()
|
||||
if sil is None or box_mask.size == 0:
|
||||
return 0.0
|
||||
gw = min(box_mask.shape[1] - 1, max(8, int(_ALPHA_WIDTH_FRAC * image_width)))
|
||||
gh = min(box_mask.shape[0] - 1, max(4, int(_ALPHA_HEIGHT_FRAC * image_width)))
|
||||
if gw < 8 or gh < 4:
|
||||
return 0.0
|
||||
template = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_NEAREST)
|
||||
return float(cv2.matchTemplate(box_mask, template, cv2.TM_CCOEFF_NORMED).max())
|
||||
|
||||
|
||||
class JimengEngine:
|
||||
"""Remove the visible Jimeng "即梦AI" watermark (locate -> mask -> reverse-alpha)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
width_frac: float = WM_WIDTH_FRAC,
|
||||
height_frac: float = WM_HEIGHT_FRAC,
|
||||
margin_right_frac: float = MARGIN_RIGHT_FRAC,
|
||||
margin_bottom_frac: float = MARGIN_BOTTOM_FRAC,
|
||||
) -> None:
|
||||
self.width_frac = width_frac
|
||||
self.height_frac = height_frac
|
||||
self.margin_right_frac = margin_right_frac
|
||||
self.margin_bottom_frac = margin_bottom_frac
|
||||
|
||||
# ── Locate ────────────────────────────────────────────────────────
|
||||
|
||||
def locate(self, image: NDArray[Any]) -> JimengLocation:
|
||||
"""Anchor the watermark box in the bottom-right corner by geometry."""
|
||||
h, w = image.shape[:2]
|
||||
wm_w = max(40, int(w * self.width_frac))
|
||||
wm_h = max(16, int(w * self.height_frac))
|
||||
margin_r = max(4, int(w * self.margin_right_frac))
|
||||
margin_b = max(4, int(w * self.margin_bottom_frac))
|
||||
x = max(0, w - margin_r - wm_w)
|
||||
y = max(0, h - margin_b - wm_h)
|
||||
wm_w = min(wm_w, w - x)
|
||||
wm_h = min(wm_h, h - y)
|
||||
return JimengLocation(x=x, y=y, w=wm_w, h=wm_h, is_fallback=True)
|
||||
|
||||
# ── Mask ──────────────────────────────────────────────────────────
|
||||
|
||||
def extract_mask(self, image: NDArray[Any], loc: JimengLocation) -> NDArray[Any]:
|
||||
"""Build a full-image uint8 mask (255 = watermark glyph) for the box.
|
||||
|
||||
Polarity-aware: the mark is a light, low-saturation gray rendered brighter
|
||||
than the local background (white top-hat), so a white-paper document is
|
||||
left untouched (nothing brighter than its surroundings is masked there).
|
||||
"""
|
||||
h, w = image.shape[:2]
|
||||
x, y, bw, bh = loc.bbox
|
||||
# Normalize the ROI to 3-channel BGR: a 2D grayscale or 4-channel BGRA
|
||||
# input would otherwise break the axis=2 channel reductions below.
|
||||
roi = image[y : y + bh, x : x + bw]
|
||||
if roi.ndim == 2:
|
||||
roi = cv2.cvtColor(roi, cv2.COLOR_GRAY2BGR)
|
||||
elif roi.shape[2] == 4:
|
||||
roi = cv2.cvtColor(roi, cv2.COLOR_BGRA2BGR)
|
||||
roi = roi.astype(np.float32)
|
||||
|
||||
luma = roi.mean(axis=2)
|
||||
sat = roi.max(axis=2) - roi.min(axis=2)
|
||||
grayish = sat < MAX_SATURATION
|
||||
|
||||
sigma = max(4.0, bh * 0.4)
|
||||
local_bg = cv2.GaussianBlur(luma, (0, 0), sigmaX=sigma, sigmaY=sigma)
|
||||
tophat = luma - local_bg
|
||||
|
||||
cand = grayish & (tophat > TOPHAT_DELTA) & (luma > LOGO_MIN_LUMA)
|
||||
glyph = cand.astype(np.uint8) * 255
|
||||
glyph = cv2.morphologyEx(glyph, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
|
||||
glyph = cv2.morphologyEx(glyph, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8))
|
||||
|
||||
mask = np.zeros((h, w), np.uint8)
|
||||
mask[y : y + bh, x : x + bw] = glyph
|
||||
return mask
|
||||
|
||||
# ── Detect ────────────────────────────────────────────────────────
|
||||
|
||||
def detect(self, image: NDArray[Any]) -> JimengDetection:
|
||||
"""Detect the visible Jimeng mark by matching the alpha-template glyph
|
||||
silhouette against the corner candidate (TM_CCOEFF_NORMED)."""
|
||||
det = JimengDetection()
|
||||
if image is None or image.size == 0:
|
||||
return det
|
||||
loc = self.locate(image)
|
||||
mask = self.extract_mask(image, loc)
|
||||
x, y, bw, bh = loc.bbox
|
||||
box = mask[y : y + bh, x : x + bw]
|
||||
coverage = float((box > 0).sum()) / float(max(1, bw * bh))
|
||||
det.region = loc.bbox
|
||||
det.coverage = coverage
|
||||
if coverage >= DETECT_MIN_COVERAGE:
|
||||
score = _template_match_score(box, image.shape[1])
|
||||
det.confidence = score
|
||||
det.detected = score >= DETECT_NCC_THRESHOLD
|
||||
logger.debug("Jimeng detect: coverage=%.3f ncc=%.2f detected=%s", coverage, score, det.detected)
|
||||
return det
|
||||
|
||||
# ── Reverse-alpha (recovery + 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``)."""
|
||||
return image is not None and image.size > 0 and _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."""
|
||||
at = _alpha_template()
|
||||
if at is None:
|
||||
return None
|
||||
h, w = image.shape[:2]
|
||||
gw = min(w, max(1, int(_ALPHA_WIDTH_FRAC * w)))
|
||||
gh = min(h, max(1, int(_ALPHA_HEIGHT_FRAC * w)))
|
||||
ax = max(0, w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw)
|
||||
ay = max(0, 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), interpolation=cv2.INTER_LINEAR)
|
||||
return amap, (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 -- so the single capture works
|
||||
off the captured width. Returns ``(alpha_map, glyph_bbox)`` or None."""
|
||||
at = _alpha_template()
|
||||
sil = _glyph_silhouette()
|
||||
if at is None or sil is None:
|
||||
return None
|
||||
h, w = image.shape[:2]
|
||||
loc = self.locate(image)
|
||||
bx, by, bw, bh = loc.bbox
|
||||
box_mask = self.extract_mask(image, loc)[by : by + bh, bx : bx + bw]
|
||||
expected = _ALPHA_WIDTH_FRAC * w
|
||||
best: tuple[float, int, int, int, int] | None = None
|
||||
for scale in np.linspace(*_ALPHA_ALIGN_SEARCH):
|
||||
gw, gh = int(expected * scale), int(_ALPHA_HEIGHT_FRAC * w * scale)
|
||||
if gw < 8 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
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh), interpolation=cv2.INTER_LINEAR)
|
||||
return amap, (ax, ay, gw, gh)
|
||||
|
||||
def _apply_reverse_alpha(self, image: NDArray[Any], amap: NDArray[Any]) -> NDArray[Any]:
|
||||
"""Invert the alpha blend with ``amap``: ``original = (wm - a*logo)/(1-a)``."""
|
||||
a3 = np.clip(amap, 0.0, 1.0)[:, :, None]
|
||||
logo = np.array(_ALPHA_LOGO_BGR, np.float32)
|
||||
return np.clip((image.astype(np.float32) - a3 * logo) / np.clip(1.0 - a3, 0.25, 1.0), 0, 255).astype(np.uint8)
|
||||
|
||||
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 -- Jimeng jitters the mark a few px per image even at
|
||||
the captured width, so fixed geometry alone is not reliable. A single 2048
|
||||
alpha cannot pixel-cancel the mark re-rasterized at another resolution, so a
|
||||
deliberately THIN residual inpaint (``_RESIDUAL_*``) follows: reverse-alpha
|
||||
has already recovered the true background (edges included) 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.
|
||||
"""
|
||||
# Normalize to 3-channel BGR so a 2D grayscale or 4-channel BGRA input
|
||||
# does not break the reverse-alpha math (which assumes a 3-channel logo).
|
||||
if image.ndim == 2:
|
||||
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
||||
elif image.shape[2] == 4:
|
||||
image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
|
||||
# Always try fixed geometry AND the NCC-aligned placement and keep
|
||||
# whichever leaves the least residual mark (re-detect confidence on the
|
||||
# bare reverse-alpha). Unlike Doubao's deterministic overlay, Jimeng jitters
|
||||
# the mark's position a few px PER IMAGE even at the captured width, so
|
||||
# fixed geometry alone misses there too -- the NCC search registers the
|
||||
# template to the actual mark; fixed stays as a fallback if the search has
|
||||
# no saliency to lock onto (a flat/contrastless mark).
|
||||
maps = [c for c in (self._fixed_alpha_map(image), self._aligned_alpha_map(image)) if c is not None]
|
||||
if not maps:
|
||||
return image.copy()
|
||||
best_out: NDArray[Any] | None = None
|
||||
best_amap: NDArray[Any] | None = None
|
||||
best_residual = float("inf")
|
||||
for amap, _region in maps:
|
||||
out = self._apply_reverse_alpha(image, amap)
|
||||
residual = self.detect(out).confidence
|
||||
if residual < best_residual:
|
||||
best_residual, best_out, best_amap = residual, out, amap
|
||||
if best_out is None or best_amap is None: # pragma: no cover - maps is non-empty
|
||||
return image.copy()
|
||||
if residual_inpaint:
|
||||
kernel = np.ones((_RESIDUAL_DILATE, _RESIDUAL_DILATE), np.uint8)
|
||||
rm = cv2.dilate((best_amap > _RESIDUAL_ALPHA_FLOOR).astype(np.uint8) * 255, kernel)
|
||||
best_out = cv2.inpaint(best_out, rm, _RESIDUAL_INPAINT_RADIUS, cv2.INPAINT_NS)
|
||||
return best_out
|
||||
|
||||
|
||||
def load_image_bgr(path: str | Path) -> NDArray[Any]:
|
||||
"""Read an image as BGR ndarray (helper for scripts/tests)."""
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
img = image_io.imread(path, cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
raise FileNotFoundError(f"Failed to read image: {path}")
|
||||
return img
|
||||
@@ -5,18 +5,22 @@ 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 only.** A known mark is a fixed semi-transparent overlay, so it
|
||||
**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)``) -- exact recovery of the true pixels, not an
|
||||
inpaint guess. 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
|
||||
(``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;
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -106,6 +110,10 @@ def _engine(key: str) -> Any:
|
||||
from remove_ai_watermarks.doubao_engine import DoubaoEngine
|
||||
|
||||
_engines[key] = DoubaoEngine()
|
||||
elif key == "jimeng":
|
||||
from remove_ai_watermarks.jimeng_engine import JimengEngine
|
||||
|
||||
_engines[key] = JimengEngine()
|
||||
else: # pragma: no cover - guarded by the registry keys
|
||||
raise KeyError(key)
|
||||
return _engines[key]
|
||||
@@ -162,11 +170,32 @@ def _doubao_remove(
|
||||
return image.copy(), None
|
||||
|
||||
|
||||
def _jimeng_detect(image: NDArray[Any]) -> MarkDetection:
|
||||
d = _engine("jimeng").detect(image)
|
||||
return MarkDetection("jimeng", "Jimeng 即梦AI wordmark", "bottom-right", d.detected, d.confidence, d.region)
|
||||
|
||||
|
||||
def _jimeng_remove(
|
||||
image: NDArray[Any], _inpaint_method: InpaintMethod, _inpaint: bool, _strength: float, force: bool
|
||||
) -> tuple[NDArray[Any], Region | None]:
|
||||
# Reverse-alpha (with an always-on residual inpaint over the glyph footprint,
|
||||
# see the engine): apply when the mark is present and the alpha asset loads.
|
||||
# Skipped otherwise (no hallucination on a clean corner).
|
||||
engine = _engine("jimeng")
|
||||
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
|
||||
|
||||
|
||||
_REGISTRY: tuple[KnownMark, ...] = (
|
||||
KnownMark("gemini", "Google Gemini sparkle", "bottom-right", True, "reverse-alpha", _gemini_detect, _gemini_remove),
|
||||
KnownMark(
|
||||
"doubao", "Doubao 豆包AI生成 text", "bottom-right", True, "reverse-alpha", _doubao_detect, _doubao_remove
|
||||
),
|
||||
KnownMark(
|
||||
"jimeng", "Jimeng 即梦AI wordmark", "bottom-right", True, "reverse-alpha", _jimeng_detect, _jimeng_remove
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -133,35 +133,60 @@ class TestReverseAlpha:
|
||||
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * img).clip(0, 255).astype(np.uint8)
|
||||
return wm, amap > 0.2
|
||||
|
||||
def test_native_returns_exact_reverse_alpha_no_inpaint(self):
|
||||
"""At native width the recovery is exact, so it must be returned untouched
|
||||
-- inpainting over exactly-recovered interior pixels degrades quality
|
||||
(regression: native textured error 1.6 reverse-alpha-only vs 2.6 with the
|
||||
old full-footprint inpaint). The output must equal pure reverse-alpha."""
|
||||
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)
|
||||
amap = eng._fixed_alpha_map(wm)
|
||||
assert amap is not None
|
||||
expected = eng._apply_reverse_alpha(wm, amap[0])
|
||||
assert np.array_equal(out, expected) # no inpaint touched the recovery
|
||||
assert not eng.detect(out).detected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("w", "h", "max_err"),
|
||||
[
|
||||
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH, 5.0), # native 1:1 -> fixed geometry, ~exact
|
||||
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH, 5.0), # captured width
|
||||
(1773, 2364, 8.0), # 3:4 portrait -> NCC alignment generalizes the single capture
|
||||
],
|
||||
)
|
||||
def test_recovers_flat_background(self, w, h, max_err):
|
||||
"""Recovers the flat background at native (fixed geometry, exact) AND a
|
||||
non-native resolution (NCC alignment generalizes the single capture)."""
|
||||
"""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)
|
||||
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
|
||||
|
||||
|
||||
class TestDegenerateAndChannelInputs:
|
||||
"""Removal must not crash on degenerate sizes or non-3-channel inputs."""
|
||||
|
||||
@@ -429,13 +429,13 @@ class TestOpenAiCaveatVendorScoped:
|
||||
self._png_chunk(b"tEXt", b"note\x00signed via OpenAI trust chain"),
|
||||
)
|
||||
r = identify(png, check_visible=False, check_invisible=False)
|
||||
assert any("SynthID pixel watermark (likely present (Google" in w for w in r.watermarks)
|
||||
assert any("SynthID watermark, inferred from C2PA metadata (likely present (Google" in w for w in r.watermarks)
|
||||
assert not any("before the rollout" in c for c in r.caveats)
|
||||
|
||||
def test_openai_synthid_still_gets_caveat(self, tmp_path: Path):
|
||||
png = self._png(tmp_path, "oa.png", self._png_chunk(b"caBX", b"jumbc2pa OpenAI ... trainedAlgorithmicMedia"))
|
||||
r = identify(png, check_visible=False, check_invisible=False)
|
||||
assert any("SynthID pixel watermark (likely present (OpenAI" in w for w in r.watermarks)
|
||||
assert any("SynthID watermark, inferred from C2PA metadata (likely present (OpenAI" in w for w in r.watermarks)
|
||||
assert any("before the rollout" in c for c in r.caveats)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Tests for the Jimeng (即梦AI) visible-watermark engine.
|
||||
|
||||
No real Jimeng sample is committed (the captures are gitignored, repo is public),
|
||||
so detection/removal is exercised against a watermark synthesized from the bundled
|
||||
alpha asset itself -- self-consistent and download-free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
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,
|
||||
JimengEngine,
|
||||
_alpha_template,
|
||||
_glyph_silhouette,
|
||||
_template_match_score,
|
||||
)
|
||||
|
||||
|
||||
def _compose(w: int, h: int, bg: float = 100.0):
|
||||
"""Composite the real alpha (scaled to width ``w``) onto a flat bg by the
|
||||
engine's fixed geometry. 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 TestLocate:
|
||||
def test_box_anchored_bottom_right(self):
|
||||
eng = JimengEngine()
|
||||
img = np.zeros((2048, 2048, 3), np.uint8)
|
||||
loc = eng.locate(img)
|
||||
assert 2048 - (loc.x + loc.w) < int(2048 * 0.03)
|
||||
assert 2048 - (loc.y + loc.h) < int(2048 * 0.03)
|
||||
|
||||
def test_box_scales_with_width(self):
|
||||
eng = JimengEngine()
|
||||
small = eng.locate(np.zeros((1024, 1024, 3), np.uint8))
|
||||
large = eng.locate(np.zeros((2048, 2048, 3), np.uint8))
|
||||
assert large.w == pytest.approx(small.w * 2, rel=0.1)
|
||||
|
||||
|
||||
class TestDetect:
|
||||
def test_clean_gradient_not_detected(self):
|
||||
eng = JimengEngine()
|
||||
ramp = np.tile(np.linspace(0, 255, 1024, dtype=np.uint8), (1024, 1))
|
||||
img = cv2.cvtColor(ramp, cv2.COLOR_GRAY2BGR)
|
||||
assert not eng.detect(img).detected
|
||||
|
||||
def test_solid_blob_corner_not_detected(self):
|
||||
"""A bright blob is not the glyph shape -> low correlation, not detected."""
|
||||
eng = JimengEngine()
|
||||
img = np.zeros((1024, 1024, 3), np.uint8)
|
||||
x, y, bw, bh = eng.locate(img).bbox
|
||||
img[y + bh // 4 : y + bh * 3 // 4, x : x + bw // 2] = 200
|
||||
assert not eng.detect(img).detected
|
||||
|
||||
def test_silhouette_loads(self):
|
||||
sil = _glyph_silhouette()
|
||||
assert sil is not None
|
||||
assert set(np.unique(sil)).issubset({0, 255})
|
||||
|
||||
def test_match_score_shape_sensitive(self):
|
||||
"""The glyph silhouette correlates with itself, not with a filled block."""
|
||||
sil = _glyph_silhouette()
|
||||
h, w = sil.shape
|
||||
box = np.zeros((h + 8, int(w / _ALPHA_WIDTH_FRAC * 0.2) + w), np.uint8)
|
||||
box[4 : 4 + h, 4 : 4 + w] = sil
|
||||
assert _template_match_score(box, _ALPHA_NATIVE_WIDTH) >= DETECT_NCC_THRESHOLD
|
||||
solid = np.full_like(box, 255)
|
||||
assert _template_match_score(solid, _ALPHA_NATIVE_WIDTH) < DETECT_NCC_THRESHOLD
|
||||
|
||||
def test_synthetic_mark_detected(self):
|
||||
"""A watermark composed from the real alpha is detected at its threshold."""
|
||||
eng = JimengEngine()
|
||||
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
|
||||
det = eng.detect(wm)
|
||||
assert det.detected
|
||||
assert det.confidence >= DETECT_NCC_THRESHOLD
|
||||
|
||||
|
||||
class TestReverseAlpha:
|
||||
def test_alpha_asset_loads(self):
|
||||
at = _alpha_template()
|
||||
assert at is not None
|
||||
assert at.dtype.kind == "f"
|
||||
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_removes_synthetic_mark(self):
|
||||
"""Reverse-alpha + residual inpaint clears the composed mark (re-detect
|
||||
no longer fires)."""
|
||||
eng = JimengEngine()
|
||||
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
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("w", "h", "max_err"),
|
||||
[
|
||||
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH, 4.0), # captured width
|
||||
(1440, 2560, 8.0), # off-native -> NCC alignment generalizes the capture
|
||||
],
|
||||
)
|
||||
def test_recovers_flat_background(self, w, h, max_err):
|
||||
eng = JimengEngine()
|
||||
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
|
||||
|
||||
|
||||
class TestDegenerateAndChannelInputs:
|
||||
"""Removal 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
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
@@ -14,7 +14,7 @@ DOUBAO_SAMPLE = Path(__file__).resolve().parents[1] / "data" / "samples" / "doub
|
||||
|
||||
class TestCatalog:
|
||||
def test_keys(self):
|
||||
assert reg.mark_keys() == ["gemini", "doubao"]
|
||||
assert reg.mark_keys() == ["gemini", "doubao", "jimeng"]
|
||||
|
||||
def test_all_in_auto(self):
|
||||
assert all(m.in_auto for m in reg.known_marks())
|
||||
@@ -27,6 +27,7 @@ class TestCatalog:
|
||||
by_key = {m.key: m for m in reg.known_marks()}
|
||||
assert by_key["gemini"].location == "bottom-right"
|
||||
assert by_key["doubao"].location == "bottom-right"
|
||||
assert by_key["jimeng"].location == "bottom-right"
|
||||
|
||||
def test_get_mark_unknown_raises(self):
|
||||
with pytest.raises(KeyError):
|
||||
@@ -37,7 +38,7 @@ class TestScan:
|
||||
def test_detect_marks_scans_all(self):
|
||||
img = np.zeros((256, 256, 3), np.uint8)
|
||||
keys = {d.key for d in reg.detect_marks(img)}
|
||||
assert keys == {"gemini", "doubao"}
|
||||
assert keys == {"gemini", "doubao", "jimeng"}
|
||||
|
||||
def test_blank_image_no_auto_mark(self):
|
||||
assert reg.best_auto_mark(np.zeros((256, 256, 3), np.uint8)) is None
|
||||
|
||||