mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-15 14:00:54 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f97fdc5b92 | ||
|
|
4c8a57ec7b | ||
|
|
a0a349cc66 | ||
|
|
99e57c872f | ||
|
|
9614615001 | ||
|
|
9e307d020e | ||
|
|
d4d9429328 |
No files matched your search
@@ -36,7 +36,7 @@ Consequences for contributors (do not drift back into the stock niche just becau
|
||||
- Run `uv run` from the repo root — from another cwd it falls back to a bare env without numpy/cv2/torch.
|
||||
- **Stale `trustmark` remnant in site-packages after an extras change:** the `trustmark` package downloads model weights INTO its own package dir, so when a narrower `uv sync` prunes the package, a `trustmark/models/` directory survives as an empty namespace package. Symptom: pyright `"TrustMark" is unknown import symbol` on `trustmark_detector.py` and `find_spec("trustmark")` returning a loader-less spec (so `is_available()` lies True). Fix: `rm -rf .venv/lib/python3.12/site-packages/trustmark` (regenerable weights cache).
|
||||
- To add a dev tool (pytest/ruff/pyright) into the env, use `uv sync --frozen --extra dev --extra gpu`, **never `uv pip install`** — `uv pip install` re-resolves and rewrites `uv.lock`, which silently bumped `transformers` to a build incompatible with the pinned `diffusers` (`cannot import name 'Qwen3VLForConditionalGeneration'`) and broke every `identify`/metadata import. Recovery: `git checkout uv.lock && uv sync --frozen --extra gpu --extra dev`. The `gpu` extra holds `diffusers`/`transformers`/`torch`, so a bare `uv sync` (no extras) removes them; `noai/__init__` is now **lazy** (PEP 562 `__getattr__`, so importing `identify`/`metadata` no longer pulls `watermark_remover`/torch), so a bare env breaks only when the removal pipeline is actually invoked, not on import. `maintain.sh`'s `uv sync --all-extras` also pulls the heavy `trustmark`/`lama` wheels (pytorch-lightning, onnxruntime) — fine on a good connection, but on flaky DNS sync only `--extra gpu --extra dev` and run the lint/test steps by hand.
|
||||
- Metadata/C2PA tests assert against real committed fixtures in `data/samples/` (`chatgpt-*.png` = OpenAI C2PA, `firefly-1.png` = Adobe, `mj-*` = Midjourney IPTC, `doubao-1.png` = ByteDance Doubao with the China TC260 `<TC260:AIGC>` XMP label **and** a visible "豆包AI生成" text mark bottom-right; `grok-1.jpg` = xAI Grok with its EXIF-only `Signature:` blob + UUID `Artist` and no C2PA/SynthID/IPTC); synthetic byte blobs cover the JPEG/ISOBMFF format paths. The "non-AI / clean photo" control is no longer in `data/samples/` -- the `clean_photo` conftest fixture serves a verified-negative image from the corpus `neg/` set (skips if the corpus is absent).
|
||||
- Metadata/C2PA tests assert against real committed fixtures in `data/samples/` (`chatgpt-*.png` = OpenAI C2PA, `firefly-1.png` = Adobe, `mj-*` = Midjourney IPTC, `doubao-1.png` = ByteDance Doubao with the China TC260 `<TC260:AIGC>` XMP label **and** a visible "豆包AI生成" text mark bottom-right; `grok-1.jpg` = xAI Grok with its EXIF-only `Signature:` blob + UUID `Artist` and no C2PA/SynthID/IPTC; `flux-1.png` / `flux-1.jpg` = real Black Forest Labs FLUX.2 Playground output, signed C2PA (issuer "Black Forest Labs" + `trainedAlgorithmicMedia`) -- `flux-1.jpg` is the first committed **JPEG-with-C2PA** fixture, exercising the c2pa-python non-PNG reader path end to end; whether BFL hosted output also embeds the open DWT-DCT pixel watermark is UNRESOLVED -- our detector returns None on these fox samples, but they are high-texture carriers where even a known-embedded watermark fails the round-trip, see the content-fragility caveat in `docs/watermarking-landscape.md`); synthetic byte blobs cover the remaining JPEG/ISOBMFF format paths. The "non-AI / clean photo" control is no longer in `data/samples/` -- the `clean_photo` conftest fixture serves a verified-negative image from the corpus `neg/` set (skips if the corpus is absent).
|
||||
- SynthID reference corpus: `scripts/synthid_corpus.py` ingests labeled images into `data/synthid_corpus/`. The labeled `images/` (`pos/` `neg/` `cleaned/`) are **committed** (public repo -- review every image for private content before adding; `manifest.csv` is kept in sync with the files on disk, one row per tracked image); only the synthetic `refs/` calibration fills are gitignored. See its README for the collection protocol and verification oracles. **`cleaned/` examples must be produced by a CURRENT shipped removal method** -- the default SDXL img2img pass (optionally `--max-resolution`). Do NOT archive cleaned outputs from methods that are no longer in the pipeline (ctrlregen, the old text/face-protection, IP-Adapter FaceID, CodeFormer) or from the experimental opt-in paths (controlnet, face restore) as corpus examples; a cleaned reference should represent the canonical removal, and a removed method's output is not a reproducible example. Keep those experiment outputs in a local working dir, never in the committed corpus.
|
||||
|
||||
## Configuration
|
||||
@@ -59,7 +59,7 @@ Compact map. The full per-module detail (design decisions, tuned thresholds, cal
|
||||
- `_text_mark_engine.py` — shared base for the three reverse-alpha text-mark engines (extracted 2026-06-09); the per-engine modules are config-only subclasses. New text mark = a `TextMarkConfig` + a thin subclass + one registry row. Gemini stays a separate engine (different model).
|
||||
- `doubao_engine.py` / `jimeng_engine.py` / `samsung_engine.py` — thin `TextMarkEngine` subclasses: Doubao "豆包AI生成" (bottom-right), Jimeng "★ 即梦AI" (bottom-right), Samsung Galaxy AI "✦ Contenuti generati dall'AI" (bottom-LEFT, locale-specific — Italian variant calibrated). Removal = reverse-alpha (always-align) + thin residual inpaint. A detector-only removal test is insufficient — assert visual residual (the textured-shift tests).
|
||||
- `region_eraser.py` — universal region eraser (`erase` CLI): cv2 backend default (no deps), optional big-LaMa via onnxruntime (~3.5-4 GB peak RAM, ~5-6 s/call CPU — does not fit a minimal droplet).
|
||||
- `invisible_watermark.py` — decodes the OPEN DWT-DCT watermarks (SD / SDXL / FLUX) via `imwatermark` (extra `detect`, pulls torch). Fragile: does not survive JPEG re-encode/resize, so it confirms origin only on pristine files.
|
||||
- `invisible_watermark.py` — decodes the OPEN DWT-DCT watermarks (SD / SDXL / FLUX) via `imwatermark` (extra `detect`, pulls torch). Fragile two ways: (1) does not survive JPEG re-encode/resize; (2) **carrier-fragile on a broad class of pristine images** -- a clean encode->decode round-trip recovers 48/48 on chatgpt/firefly/random but FAILS (28-39/48, below the `_MATCH_48`=44 gate) on the FLUX fox, doubao, a flat FLUX generation, AND a clean synthetic flat fill with no watermark. The failure does NOT track texture; it goes with a degenerate **all-ones decode that is a CARRIER ARTIFACT, not a watermark** (synthetic clean image reproduces it). So `detect_invisible_watermark` is **positive-only**: trust a hit; a `None` is inconclusive unless a same-carrier positive-control embed first recovers >=44. Verified 2026-06-19; full caveat in `docs/watermarking-landscape.md`.
|
||||
- `trustmark_detector.py` — Adobe TrustMark open decoder (extra `trustmark`). Do NOT remove the JPEG re-encode false-positive gate — a lone TrustMark hit without it is almost always content noise.
|
||||
- `noai/watermark_remover.py` — `WatermarkRemover` with two diffusion pipelines selected by the explicit `pipeline` ctor arg, never inferred from `model_id`: `sdxl` (plain SDXL img2img) and `controlnet` (SDXL + canny ControlNet, **the DEFAULT since 2026-06-09**). Removal comes from the img2img `strength`; ControlNet only preserves text/face STRUCTURE — SynthID CAN survive controlnet on photoreal content at low strength. No face-restore extra ships, by validated decision (every restore approach looked MORE AI-generated).
|
||||
- `auto_config.py` + the content-detection layer were REMOVED 2026-06-09; `--auto` is a deprecated no-op (controlnet is the default pipeline and the adaptive polish is ON by default and self-gates to a no-op where there is no detail deficit).
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 495 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
@@ -103,7 +103,9 @@ The 11 survivors are near-white ill-conditioning (reverse-alpha divides by `1-a`
|
||||
|
||||
`_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.
|
||||
|
||||
**`_apply_reverse_alpha` runs on the glyph crop only:** `amap` is zero outside the glyph `region` (x, y, w, h), so the blend is a no-op there (`(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. `remove_watermark_reverse_alpha` passes the `region` each `_fixed/_aligned_alpha_map` returns.
|
||||
**`_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.
|
||||
|
||||
**`_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.
|
||||
|
||||
## `doubao_engine.py`
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
> no content was changed or summarized.
|
||||
|
||||
Who embeds what, and whether it is locally detectable (so we know which gaps are fillable). See `identify.py` for what we read.
|
||||
- **Locally detectable (open decoder, no key/API):** Stable Diffusion / SDXL / FLUX via `imwatermark` DWT-DCT (now covered by `invisible_watermark.py`). FLUX uses the same library (`black-forest-labs/flux2` `src/flux2/watermark.py`, 48-bit `0b001010101111111010000111100111001111010100101110`); SDXL is the diffusers `WATERMARK_MESSAGE` (`0b101100111110110010010000011110111011000110011110`). Caveat: fragile to re-encoding.
|
||||
- **C2PA / IPTC (covered by the issuer/marker scan):** OpenAI, Google, Adobe Firefly, Microsoft (Designer + **Bing Image Creator** — collected 2026-05-24; Bing now runs Microsoft's own **MAI-Image** model, signs C2PA as "Microsoft", NOT OpenAI/DALL-E), and **Stability AI** (collected from Brand Studio / DreamStudio successor; signs C2PA as "Stability AI Ltd", no SynthID, no imwatermark on its current Stable Image model — issuer added to `C2PA_ISSUERS`). Still unsampled: Canva (its downloads are re-encoded design *exports* that strip C2PA, so a Canva "positive" is inconclusive — skipped), Getty, Shutterstock. Midjourney embeds NO C2PA and no invisible watermark (our `mj-*` sample carried only the IPTC tag).
|
||||
- **Locally detectable (open decoder, no key/API):** Stable Diffusion / SDXL / FLUX via `imwatermark` DWT-DCT (now covered by `invisible_watermark.py`). FLUX uses the same library (`black-forest-labs/flux2` `src/flux2/watermark.py`, 48-bit `0b001010101111111010000111100111001111010100101110`); SDXL is the diffusers `WATERMARK_MESSAGE` (`0b101100111110110010010000011110111011000110011110`). **Caveat: the `imwatermark` dwtDct decode is carrier-fragile on a broad class of real images, NOT just re-encode-fragile, and it is a POSITIVE-ONLY signal.** A clean encode->decode round-trip (no re-encode at all) recovers 48/48 bits on some carriers (random noise, chatgpt-1.png 48/48, firefly-1.png 45/48) but FAILS on many others — verified 2026-06-19 that a *known-embedded* watermark only round-trips 28-39/48 (below the safe `_MATCH_48` = 44 gate, random baseline ~24) on the FLUX fox sample (28), doubao-1.png (39), a 1024² minimalist-flat FLUX image (28), AND a **clean synthetic bright-flat fill with NO watermark at all (28)**. The failure does NOT track texture (firefly lapvar ~11 passes; the flat FLUX lapvar ~56 fails); it correlates with a degenerate decode where the raw bits read **all-ones (48/48 ones)** — which a clean synthetic image reproduces, so **all-ones is a CARRIER ARTIFACT, NOT a watermark signal** (a double-embed test also showed a pre-existing embed does not corrupt a second embed — no interference). Net: trust a `detect_invisible_watermark` hit, but treat a `None`/no-match as **inconclusive** whenever a positive-control embed on the same carrier does not first recover >=44/48. The 44 gate is a deliberate precision choice (lowering it would admit false positives).
|
||||
|
||||
Consequence for the FLUX hosted-output question (BFL Playground, FLUX.2 [pro] + FLUX.1 [dev], 2026-06-19): all samples carry the signed C2PA manifest (issuer "Black Forest Labs"); the open DWT-DCT decode returned `None`, but every available FLUX carrier (textured fox AND a minimalist-flat generation) failed the positive control (28/48), so the detector is blind on them and **whether BFL hosted output embeds the open pixel watermark is UNRESOLVED** (an earlier note here wrongly asserted it absent — overstated; a later note blamed "high texture" — also wrong, flat carriers fail too). What IS established: C2PA is the reliable FLUX identifier; the `_BITS_48` pattern is correct (round-trips on chatgpt/firefly/random). Resolving the hosted question needs a hosted FLUX carrier that first passes a >=44/48 positive control, which neither a textured nor a flat prompt produced — low priority (the open mark is only a stripped-metadata fallback).
|
||||
- **C2PA / IPTC (covered by the issuer/marker scan):** OpenAI, Google, Adobe Firefly, Microsoft (Designer + **Bing Image Creator** — collected 2026-05-24; Bing now runs Microsoft's own **MAI-Image** model, signs C2PA as "Microsoft", NOT OpenAI/DALL-E), **Stability AI** (collected from Brand Studio / DreamStudio successor; signs C2PA as "Stability AI Ltd", no SynthID, no imwatermark on its current Stable Image model — issuer added to `C2PA_ISSUERS`), and **Canva** (Magic Media signs C2PA as "Canva" + `trainedAlgorithmicMedia` with a generic `c2pa-rs` claim generator, no SynthID — issuer `b"Canva"` → "Canva (Magic Media)"; found on real production traffic 2026-06-19, which **disproved the earlier assumption** that Canva downloads are re-encoded exports that always strip C2PA). Still unsampled: Getty, Shutterstock. Midjourney embeds NO C2PA and no invisible watermark (our `mj-*` sample carried only the IPTC tag).
|
||||
|
||||
**Samsung Galaxy AI** (Generative Edit / Sketch to Image / Portrait Studio on Galaxy S23 FE / S24 / S25, One UI 7+) signs C2PA as "Samsung Galaxy" with the standard `trainedAlgorithmicMedia` source type AND a proprietary `genAIType` marker; verified on real signed files 2026-05-29 (the standard scan catches the source type; `genAIType` additionally catches a Galaxy S24 file that omits it). It ALSO burns a **visible** localized wordmark into the pixels — a sparkle + "generated with AI" string in the bottom-LEFT corner (issue #37; the Italian "✦ Contenuti generati dall'AI" variant is calibrated) — removed by `samsung_engine.py` / `visible --mark samsung` (reverse-alpha, see the engine bullet); detection feeds `identify` as the medium `visible_samsung` signal. The string is locale-specific, so each locale needs its own captured alpha template.
|
||||
|
||||
@@ -14,7 +16,7 @@ Who embeds what, and whether it is locally detectable (so we know which gaps are
|
||||
|
||||
**Black Forest Labs (FLUX)** API output signs C2PA: `claim_generator_info "Black Forest Labs API"` + a `c2pa.ai_generated_content` assertion + `trainedAlgorithmicMedia` (issuer `b"Black Forest Labs"` added to `C2PA_ISSUERS`, platform "Black Forest Labs (FLUX)").
|
||||
|
||||
**ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance (Doubao / Jimeng / Volcano Engine)"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. All three verified on real signed files 2026-05-29.
|
||||
**ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance (Doubao / Jimeng / Volcano Engine)"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. All three verified on real signed files 2026-05-29. ByteDance's **international brand (BytePlus / Seedream / Seededit)** signs the SAME content as **"Byteplus Pte. Ltd."** — the bare `volcengine` needle missed it, so real BytePlus output was mis-attributed to "Adobe Firefly" (an incidental "Adobe XMP" toolkit string in the file's XMP, picked up by the fallback byte-scan once the clean manifest issuer matched nothing). Added issuer `b"Byteplus"` → org "BytePlus (ByteDance)" (platform resolves to the shared "ByteDance (Doubao / Jimeng / Volcano Engine)" label via the common `ByteDance` needle) so the clean manifest issuer attributes it directly; found on real production traffic 2026-06-19.
|
||||
- **EXIF/XMP generator tag (caught by `exif_generator`):** **Ideogram** writes EXIF `Make="Ideogram AI"` (collected 2026-05-24 — no C2PA, no SynthID, no imwatermark; the Make tag is the only signal).
|
||||
- **xAI / Grok — its own EXIF signature scheme, NOT C2PA (DETECTED by `metadata.xai_signature`, built 2026-05-26).**
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.11.3"
|
||||
version = "0.11.4"
|
||||
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"
|
||||
|
||||
@@ -11,4 +11,4 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.11.3"
|
||||
__version__ = "0.11.4"
|
||||
@@ -184,20 +184,27 @@ class TextMarkEngine:
|
||||
# ── Mask ────────────────────────────────────────────────────────────
|
||||
|
||||
def extract_mask(self, image: NDArray[Any], loc: TextMarkLocation) -> NDArray[Any]:
|
||||
"""Build a full-image uint8 mask (255 = watermark glyph) for the box.
|
||||
"""Build a box-sized uint8 mask (255 = watermark glyph) for ``loc``.
|
||||
|
||||
Returns just the glyph mask of the located box (shape ``(loc.h, loc.w)``),
|
||||
not a full-frame array: every caller immediately crops to ``loc.bbox``, so
|
||||
allocating a full ``(h, w)`` mask and embedding the box was O(image) work
|
||||
and memory for an O(box) result -- a wasted full-frame uint8 allocation on
|
||||
each detect (~12 MB on a 12 MP frame, recomputed per text-mark detector on
|
||||
the memory-tight identify path). The box mask is byte-identical to the old
|
||||
full-frame mask cropped to ``loc.bbox``.
|
||||
|
||||
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).
|
||||
"""
|
||||
c = self.config
|
||||
h, w = image.shape[:2]
|
||||
x, y, bw, bh = loc.bbox
|
||||
# A degenerate ROI (a sliver from an extremely wide/short image) cannot hold
|
||||
# the mark and would feed cv2's GaussianBlur/morphology a ~1-px-tall array,
|
||||
# which can fault native code on some platforms. Skip the cv2 pipeline.
|
||||
if bh < 16 or bw < 16:
|
||||
return np.zeros((h, w), np.uint8)
|
||||
return np.zeros((bh, bw), np.uint8)
|
||||
# Normalize the ROI to 3-channel BGR (grayscale / BGRA would break axis=2).
|
||||
roi = image_io.to_bgr(image[y : y + bh, x : x + bw]).astype(np.float32)
|
||||
|
||||
@@ -216,11 +223,7 @@ class TextMarkEngine:
|
||||
glyph = cand.astype(np.uint8) * 255
|
||||
glyph = cv2.morphologyEx(glyph, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
|
||||
k = c.morph_open_size
|
||||
glyph = cv2.morphologyEx(glyph, cv2.MORPH_OPEN, np.ones((k, k), np.uint8))
|
||||
|
||||
mask = np.zeros((h, w), np.uint8)
|
||||
mask[y : y + bh, x : x + bw] = glyph
|
||||
return mask
|
||||
return cv2.morphologyEx(glyph, cv2.MORPH_OPEN, np.ones((k, k), np.uint8))
|
||||
|
||||
# ── Detect ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -232,9 +235,8 @@ class TextMarkEngine:
|
||||
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]
|
||||
box = self.extract_mask(image, loc) # box-sized mask (== old full-frame cropped to bbox)
|
||||
_x, _y, bw, bh = loc.bbox
|
||||
coverage = float((box > 0).sum()) / float(max(1, bw * bh))
|
||||
det.region = loc.bbox
|
||||
det.coverage = coverage
|
||||
@@ -254,7 +256,15 @@ class TextMarkEngine:
|
||||
|
||||
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)."""
|
||||
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:
|
||||
@@ -268,22 +278,23 @@ class TextMarkEngine:
|
||||
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)
|
||||
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)
|
||||
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 ``(alpha_map, glyph_bbox)`` or None."""
|
||||
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
|
||||
h, w = image.shape[:2]
|
||||
w = image.shape[1]
|
||||
loc = self.locate(image)
|
||||
bx, by, bw, bh = loc.bbox
|
||||
box_mask = self.extract_mask(image, loc)[by : by + bh, bx : bx + bw]
|
||||
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):
|
||||
@@ -298,18 +309,17 @@ class TextMarkEngine:
|
||||
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)
|
||||
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 zero everywhere except the glyph ``region`` (x, y, w, h), so the
|
||||
blend is a no-op (``(wm - 0)/(1 - 0) == wm``) outside it. Compute the math on
|
||||
the glyph crop only and copy the rest through unchanged -- byte-identical to 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.
|
||||
@@ -319,7 +329,7 @@ class TextMarkEngine:
|
||||
x2, y2 = x1 + gw, y1 + gh
|
||||
if y1 >= y2 or x1 >= x2:
|
||||
return out
|
||||
a3 = np.clip(amap[y1:y2, x1:x2], 0.0, 1.0)[:, :, None]
|
||||
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)
|
||||
@@ -351,16 +361,25 @@ class TextMarkEngine:
|
||||
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 = residual, out, amap
|
||||
if best_out is None or best_amap is None: # pragma: no cover - maps is non-empty
|
||||
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()
|
||||
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((best_amap > c.residual_alpha_floor).astype(np.uint8) * 255, kernel)
|
||||
rm = cv2.dilate(rm, kernel)
|
||||
best_out = cv2.inpaint(best_out, rm, c.residual_inpaint_radius, cv2.INPAINT_NS)
|
||||
return best_out
|
||||
@@ -122,6 +122,20 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
|
||||
C2paAiVendor(
|
||||
b"volcengine", "ByteDance (Volcano Engine)", "ByteDance (Doubao / Jimeng / Volcano Engine)", "ByteDance"
|
||||
),
|
||||
# ByteDance's international brand (BytePlus / Seedream / Seededit) signs its
|
||||
# cert as "Byteplus Pte. Ltd." -- the bare ``volcengine`` needle misses it, so
|
||||
# real BytePlus AI output was mis-attributed (an incidental "Adobe XMP" string
|
||||
# in the file's XMP made it read "Adobe Firefly"). Adding the issuer means the
|
||||
# clean manifest issuer matches "BytePlus (ByteDance)" directly. The platform
|
||||
# string mirrors the volcengine row: both share the "ByteDance" needle, so the
|
||||
# earlier row's label wins anyway -- they normalize together for clash
|
||||
# detection. Verified on real signed files in production traffic, 2026-06-19.
|
||||
C2paAiVendor(b"Byteplus", "BytePlus (ByteDance)", "ByteDance (Doubao / Jimeng / Volcano Engine)", "ByteDance"),
|
||||
# Canva Magic Media signs AI-generated images as "Canva" with a generic
|
||||
# c2pa-rs claim generator + trainedAlgorithmicMedia; without this entry the
|
||||
# source read AI but no platform was attributed. Verified on real signed files
|
||||
# in production traffic, 2026-06-19. Canva does not use SynthID.
|
||||
C2paAiVendor(b"Canva", "Canva", "Canva (Magic Media)", "Canva"),
|
||||
# Truepic is a C2PA signing authority, not an AI generator: no platform label,
|
||||
# never asserts is_ai (the verdict comes from the digital-source-type).
|
||||
C2paAiVendor(b"Truepic", "Truepic", None, None),
|
||||
|
||||
@@ -66,6 +66,19 @@ class TestAttributePlatform:
|
||||
assert platform
|
||||
assert "Stability AI" in platform
|
||||
|
||||
def test_canva(self):
|
||||
platform = _attribute_platform(["Canva"])
|
||||
assert platform
|
||||
assert "Canva" in platform
|
||||
|
||||
def test_byteplus_attributes_to_bytedance(self):
|
||||
# ByteDance's intl brand signs as "Byteplus Pte. Ltd."; the registry maps
|
||||
# it to the ByteDance platform (was mis-read as Adobe via an incidental
|
||||
# "Adobe XMP" file string before the entry existed).
|
||||
platform = _attribute_platform(["BytePlus (ByteDance)"])
|
||||
assert platform
|
||||
assert "ByteDance" in platform
|
||||
|
||||
def test_empty_is_none(self):
|
||||
assert _attribute_platform([]) is None
|
||||
|
||||
@@ -216,6 +229,19 @@ class TestIdentifyRealSamples:
|
||||
assert r.is_ai_generated is True
|
||||
assert any("IPTC" in w for w in r.watermarks)
|
||||
|
||||
def test_flux_bfl_c2pa_png(self):
|
||||
# flux-1.png: real Black Forest Labs FLUX.2 Playground output (signed C2PA).
|
||||
r = identify(SAMPLES_DIR / "flux-1.png", check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform == "Black Forest Labs (FLUX)"
|
||||
|
||||
def test_flux_bfl_c2pa_jpeg_via_reader(self):
|
||||
# flux-1.jpg: same source as a JPEG -- the real committed JPEG-with-C2PA
|
||||
# fixture that exercises the c2pa-python non-PNG reader path end to end.
|
||||
r = identify(SAMPLES_DIR / "flux-1.jpg", check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform == "Black Forest Labs (FLUX)"
|
||||
|
||||
def test_clean_photo_is_unknown_not_clean(self, clean_photo: Path):
|
||||
r = identify(clean_photo, check_visible=False)
|
||||
assert r.is_ai_generated is None # never asserted False
|
||||
|
||||
@@ -182,6 +182,14 @@ class TestC2PARealSamples:
|
||||
assert has_c2pa_metadata(out)
|
||||
assert "OpenAI" in extract_c2pa_info(out)["issuer"]
|
||||
|
||||
def test_extract_info_flux_jpeg_via_reader(self):
|
||||
"""Real committed JPEG-with-C2PA fixture: the non-PNG reader path works."""
|
||||
info = extract_c2pa_info(SAMPLES_DIR / "flux-1.jpg")
|
||||
assert info["has_c2pa"] is True
|
||||
assert info["c2pa_manifest"].startswith("C2PA manifest store") # reader, not chunk
|
||||
assert "Black Forest Labs" in info["issuer"]
|
||||
assert "trainedAlgorithmicMedia" in info["source_type"]
|
||||
|
||||
def test_extract_info_uses_reader_store(self):
|
||||
"""The c2pa-python reader path: structured (not heuristic) extraction."""
|
||||
from remove_ai_watermarks.noai import c2pa
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Byte-identity guards for the text-mark engine memory optimization.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import remove_ai_watermarks.doubao_engine as D
|
||||
import remove_ai_watermarks.jimeng_engine as J
|
||||
import remove_ai_watermarks.samsung_engine as S
|
||||
from remove_ai_watermarks.doubao_engine import DoubaoEngine
|
||||
from remove_ai_watermarks.jimeng_engine import JimengEngine
|
||||
from remove_ai_watermarks.samsung_engine import SamsungEngine
|
||||
|
||||
# (engine factory, engine module) for each reverse-alpha text mark.
|
||||
ENGINES = [
|
||||
pytest.param(DoubaoEngine, D, id="doubao"),
|
||||
pytest.param(JimengEngine, J, id="jimeng"),
|
||||
pytest.param(SamsungEngine, S, id="samsung"),
|
||||
]
|
||||
|
||||
|
||||
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)."""
|
||||
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
|
||||
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)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("factory", "module"), ENGINES)
|
||||
class TestExtractMaskFootprint:
|
||||
def test_returns_box_sized_mask(self, factory, module):
|
||||
eng = factory()
|
||||
img = _watermarked(eng, module)
|
||||
loc = eng.locate(img)
|
||||
box = eng.extract_mask(img, loc)
|
||||
assert box.dtype == np.uint8
|
||||
# Shape == loc.bbox, i.e. the old full-frame mask's [y:y+bh, x:x+bw] crop.
|
||||
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)
|
||||
Reference in new issue
Block a user