Compare commits

...
60 Commits
Author SHA1 Message Date
Victor Kuznetsov 72f6734b45 chore(release): bump version to 0.15.1 2026-07-13 11:54:53 +03:00
Victor KuznetsovandClaude Opus 4.8 220803c4d0 fix(metadata): remove_ai_metadata is fail-safe on a truncated/corrupt image
PIL raises OSError decoding a truncated file, which crashed remove_ai_metadata
(the PNG/WebP PIL re-save path) -- a direct library caller like a web worker
500s on a partial upload. ~0.2% of the real upload corpus is truncated. The
strip now probes decodability first and, on failure, copies the input through
unchanged and returns rather than raising (we cannot strip what we cannot parse),
mirroring strip_c2pa_boxes' fail-safe. identify already handled these.

The CLI `metadata --remove` on an unreadable file therefore now exits 0 with the
input passed through, not a clean error (exit 1) -- `visible`, which must decode
to remove a mark, still exits 1. Test updated to the per-command contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:54:33 +03:00
Victor Kuznetsov 1dc6fee91a chore(release): bump version to 0.15.0 2026-07-13 10:50:20 +03:00
Victor KuznetsovandClaude Opus 4.8 a4c901ff39 fix: metadata-strip parity, input robustness, and detection/clash coverage
Bug fixes (each with a regression test):
- metadata strip parity across every marker placement: IPTC digitalSourceType
  in XMP, the Samsung post-EOI trailer, the China TC260 AIGC block in EXIF
  UserComment, a bare AIGC block in a non-standard APP segment, and the ISOBMFF
  EXIF path (AIGC + xAI) are all now stripped -- anything a scanner flags, the
  strip reaches
- Samsung genAIType detected when its trailer sits past the 512 KB scan window
  (file-tail read on large photos)
- crashes on edge inputs: Gemini detector on images with a short side < 16px,
  footprint_mask on a zero-size ndarray, the humanizer on chromatic_shift >=
  width, and the CLI on unreadable/corrupt/empty input (clean error, not a
  traceback)
- WebP written losslessly (cv2 quality 101), not lossy at 100
- the IPTC digitalSourceType algorithmicMedia (procedural, not trained on
  sampled data) is no longer flagged as AI-generated, so clean procedural
  content is not scrubbed
- c2pa source-type: compositeWithTrainedAlgorithmicMedia is checked before the
  bare algorithmicMedia token, so an AI-enhanced composite is not misclassified

Detection:
- integrity-clash coverage now normalizes ByteDance / Canva / ElevenLabs /
  Black Forest Labs, so a transplanted manifest next to an independent
  conflicting stamp is caught; the generic China TC260 AIGC label is attributed
  to a co-present TC260 vendor, so a legit Doubao image (its own C2PA + TC260
  label) does not clash (corpus-validated: 0 new clashes on 5000 carriers)

CLI:
- batch exits non-zero (with a warning) when any image errors or a GPU-missing
  SynthID scrub is skipped, and copies the input through so the output dir stays
  complete -- it used to always exit 0 and could silently drop files

Perf:
- GeminiEngine reused as a process-wide singleton with a precomputed template
  ladder: -24% on the identify sparkle path, detection byte-identical

Internal: one shared _ai_exif_targets rule set feeds both EXIF scrubbers so
their coverage cannot drift; docs synced; maintain.sh hardened so the uv-secure
internal teardown crash no longer aborts the gate (still fails on a real finding).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 10:49:24 +03:00
Victor KuznetsovandClaude Opus 4.8 190dc89d23 perf(visible): crop MI-GAN around the mask so peak RAM is bounded by mark size
erase_migan fed the whole frame to the ONNX model, so peak RSS scaled with the
upload (~0.6 GB at 4 MP up to ~2.4 GB at 25 MP). Mirror erase_lama: crop a padded
region around the mask (pad = max(256, 2*bbox)), feed only that crop (at native
resolution -- MI-GAN accepts arbitrary dims, unlike LaMa's fixed 512 square), and
paste only masked pixels back. Peak RSS is now bounded by the mark size
(~0.6-0.9 GB), so a memory-tight host (a 1-2 GB web worker) can run MI-GAN on a
25 MP upload.

Fill quality is unchanged: verified by eye on real Gemini/Doubao marks plus a
ground-truth reconstruction sweep -- a tighter view if anything reduces the GAN's
hallucination of large background structure.

Extract the shared padded-crop-box math into _padded_crop_box (used by both
erase_lama and erase_migan).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 08:22:59 +03:00
Victor KuznetsovandClaude Opus 4.8 f69fedcf2d chore(release): bump version to 0.14.1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:13:02 +03:00
Victor KuznetsovandClaude Opus 4.8 731af94639 docs(release): note HOMEBREW_TAP_TOKEN rotation on distribute.yml Bad credentials
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:13:02 +03:00
Victor KuznetsovandClaude Opus 4.8 49869ab02b feat(identify): detect Dreamina C2PA + Tencent Cloud AIGC schema
Two AI-provenance metadata types mined from the retained corpus that
identify previously read as no-signal:

- Dreamina (ByteDance's international Jimeng brand) signs C2PA as
  "Bytedance Pte. Ltd." with a "Dreamina/x.y" claim generator and NO
  digitalSourceType, so the generator name is the only AI signal. Add a
  C2paAiVendor row with a new asserts_ai flag (identity-AI: presence
  asserts AI without trainedAlgorithmicMedia) plus the derived
  C2PA_IDENTITY_AI_ORGS view, folded into identify's c2pa_is_ai. Keyed on
  the Dreamina generator token, not the "Bytedance Pte" issuer, so non-AI
  CapCut edits signed by the same entity stay unattributed. 7/7 corpus
  files now attribute to ByteDance.

- Tencent Cloud's TC260 AIGC variant uses a ServiceProvider/ServiceUser
  schema (vs the producer-side ContentProducer schema), embedded in EXIF
  ImageDescription; add those field names to _TC260_FIELDS so the generic
  {"AIGC":{...}} gate accepts it. 11/11 corpus files now flagged.

Test-first: reproducing tests in test_identify.py / test_metadata.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 10:54:56 +03:00
Victor Kuznetsov 41e20a0606 Merge pull request #60 from wiltodelta/claude/mystifying-cori-a4c1e7
v0.14.0: visible-mark localize→fill rewrite, backend priority, fixes
2026-07-11 09:53:07 +03:00
Victor KuznetsovandClaude Opus 4.8 e3e332c4b1 Merge origin/main into claude/mystifying-cori-a4c1e7
main landed #58 (pill-gate fix, superseded by this branch's localize->fill
rewrite) and #57 (deps bump). Resolved the 6 code/test/doc conflicts by keeping
this branch's post-rewrite versions; the deps bump auto-merged into
uv.lock/pyproject. Full gate green after resolution: ruff, pyright 0, 730 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 09:50:21 +03:00
Victor KuznetsovandClaude Opus 4.8 b579385c6f feat(visible): white-core rescue for the Gemini false-positive gate
The FP gate demotes a low-gradient match, but a real FAINT sparkle also has soft
edges, so metadata-stripped faint sparkles were dropped. Keep a low-grad match
that is a strong (conf >= 0.52), bright, near-WHITE-core sparkle: a real sparkle
core is white, a clean bright corner that shape-matches (sky/sun) is colored
(_core_saturation). Recovers ~14/20 stripped faint sparkles under the DEFAULT
strict/auto (no metadata, no flag) at ~1.25% clean false-fire (baseline 0.55%);
the ~0.51-scoring bright-background FPs stay demoted (below 0.52).

A learned classifier on the same features measured WORSE than the tuned gate
(tier-1: MLP 86.7% recall vs the gate's 90.8% at equal false-fire), so the
heuristic stays; a patch-CNN with richer features is roadmapped P2 with low
expected value -- the precision/recall wall is fundamental (deep-research +
tier-1 both confirm it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 09:41:58 +03:00
Victor KuznetsovandClaude Opus 4.8 bd60be10f9 docs: fold visible-mark + detection-method research into watermarking-landscape
Deep-research 2026-07-10 (adversarially verified): the Gemini sparkle is
tier-gated (visible on Free/Pro, absent on Ultra/AI-Studio/API; no official
visible-mark detector or published glyph spec); the faint-visible-mark
precision/recall wall is fundamental (learned CNN front-end does not cleanly
separate true/false, arXiv:1705.08593 refuted); learned detectors need large
synthetic-composite datasets + carry off-distribution risk; landscape adds
Meta bottom-left + Samsung star-icon variants; China GB 45438-2025 is the
strongest visible-mark mandate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 10:00:12 +03:00
Victor KuznetsovandClaude Opus 4.8 27a921b54f docs(visible): record the 0.12.1-vs-0.14 head-to-head + backend quality
Full-dataset validation of reverse-alpha (v0.12.1) vs the current localize->fill:
doubao/jimeng identical (100% coverage + clearance across all backends); gemini
strict coverage a few points below reverse-alpha (the FP tightening), every
missed mark recovered under assume_ai, clearance ~98% both, no outside-box
damage. Clearance is fill-independent (cv2/MI-GAN/LaMa all strip the mark shape);
the difference is visual fill quality on textured/structured backgrounds -- LaMa
best, MI-GAN can ghost/hallucinate, cv2 smears -- which motivates auto = LaMa >
MI-GAN > cv2. Added to module-internals, known-limitations, and the CLAUDE.md
compact list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:18:25 +03:00
Victor KuznetsovandClaude Opus 4.8 c858006e93 feat(visible): auto fill prefers LaMa > MI-GAN > cv2, warn on cv2 fallback
The auto backend now resolves best-first: LaMa (highest quality, recovers the
textured/structured backgrounds the classical fill smears) > MI-GAN > cv2. Both
learned backends share the same onnxruntime availability check, so auto cannot
tell them apart and always prefers the better one; a memory-tight deployment
that cannot afford LaMa's ~4.7 GB peak pins MI-GAN explicitly via
`--backend migan` / `backend="migan"` (the deployment's call, not the library's).
cv2 stays the no-deps floor and now emits a one-time quality warning when auto
falls back to it, since it smears texture/structure.

Motivated by a v0.12.1 reverse-alpha vs 0.14 localize->fill head-to-head:
reverse-alpha recovered structured backgrounds more cleanly than any inpaint;
LaMa closes most of that gap, MI-GAN can ghost/hallucinate, cv2 is weakest.
doubao/jimeng removal is identical between versions; gemini strict coverage is
4pp lower (all recovered via assume_ai) with cleaner clearance and no
outside-box damage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 18:24:20 +03:00
Victor KuznetsovandClaude Opus 4.8 9756189eaf style: drop dead Candidate fields, simplify resolve_backend, US spelling
- Candidate carries only the fields the arbiter reads (key, label,
  detected_strict, detected_relaxed, features); location/region/confidence were
  vestigial from the removed best_auto_mark max-by-confidence path.
- resolve_backend returns preferred_inpaint_backend() directly (typed Literal)
  instead of an identity ternary.
- colour/normalise/behaviour -> US spelling across code comments and docs.

No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:18:13 +03:00
Victor KuznetsovandClaude Opus 4.8 178fed69a7 fix(visible): thread detection into mask + guard removal/IO edge cases
Resolve 10 code-review findings on the v0.14.0 localize->fill path, several
release-blocking:

- gemini: build the removal mask from the decision's provenance-aware region
  instead of a strict internal re-detect. A relaxed/assume_ai sparkle was
  re-demoted by the FP gate into a None mask and reported removed while left in
  the image; this also drops the redundant double-detect.
- registry: report a mark removed only when a fill actually happened (remove()
  returns a None region for an empty mask), so a no-op is never claimed.
- api/cli: add write_noop so the CLI `visible` no-mark path writes nothing and
  cannot clobber a pre-existing -o file (was write-then-unlink -> data loss);
  create output.parent; skip the same-file copy (SameFileError on in-place).
- cli: catch the missing migan/lama backend RuntimeError on the visible/all
  paths (matches `erase`); route the single-mark relaxation through the shared
  resolve_relax instead of an inline copy.
- metadata: keep_standard=False no longer takes the AI-only lossless JPEG
  short-circuit (it left standard metadata); defer a malformed-marker JPEG to
  the PIL fallback instead of reporting a partial strip as complete.
- invisible: register the HEIF opener before Image.open (HEIC --force) and
  RGB-convert before the PNG temp (CMYK JPEG).
- pill: normalize via to_bgr so a 4-channel BGRA array cannot crash cvtColor.

Regression tests for each; docs synced (resolve_relax, write_noop,
best_auto_mark -> detect_marks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:14:46 +03:00
Victor KuznetsovandClaude Opus 4.8 1f5d64e6c5 style: unify section dividers and logger variable naming
- cli.py section dividers -> the codebase-majority `# ── Title ──` form
- logger variable unified to `logger` across identify, isobmff, c2pa,
  trustmark_detector, invisible_watermark (matching the 9-file majority)
- cosmetic only, no behavior change (719 tests / pyright / ruff green)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:31:40 +03:00
Victor KuznetsovandClaude Opus 4.8 1a955b096a feat(visible): localize->fill rewrite, sensitivity/backend + api, HEIC + lossless IO
- Replace reverse-alpha removal with localize -> fill (template-free mask + one
  shared cv2/MI-GAN/big-LaMa fill) for every mark; drops the colour-shift / dark-pit
  failure modes, version-robust to a moved or re-rendered mark
- Separate perception/decision/action: engines report Candidates, a pure
  decide(candidates, Context) arbiter owns all policy (sensitivity + provenance +
  pill gate), remove_auto_marks orchestrates -- behavior-preserving (corpus 46/46/92)
- Three orthogonal knobs replace --method: --backend cv2|migan|lama,
  --sensitivity auto|strict|assume-ai, provenance (auto from metadata)
- Add high-level api.remove_visible / visible_provenance (lazy top-level re-export);
  visible --mark auto delegates to it so CLI and library share ONE path
- Read+write HEIC/AVIF on the pixel path via pillow-heif; imwrite preserves the input
  format at max quality (JPEG q100/4:4:4); a no-op copies the original bytes verbatim
- Lossless byte-level JPEG metadata strip (no DCT re-encode); consolidate the two
  remove_ai_metadata into one, delete legacy noai/cleaner + best_auto_mark
- Bump 0.13.0 -> 0.14.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:20:52 +03:00
dependabot[bot] 3c6cfd3a9b chore(deps): bump the minor-and-patch group across 1 directory with 6 updates (#57)
Bumps the minor-and-patch group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [pillow](https://github.com/python-pillow/Pillow) | `12.2.0` | `12.3.0` |
| [diffusers](https://github.com/huggingface/diffusers) | `0.38.0` | `0.39.0` |
| [transformers](https://github.com/huggingface/transformers) | `5.12.1` | `5.13.0` |
| [huggingface-hub](https://github.com/huggingface/huggingface_hub) | `1.20.1` | `1.22.0` |
| [ruff](https://github.com/astral-sh/ruff) | `0.15.19` | `0.15.20` |
| [pyright](https://github.com/RobertCraigie/pyright-python) | `1.1.410` | `1.1.411` |



Updates `pillow` from 12.2.0 to 12.3.0
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/12.2.0...12.3.0)

Updates `diffusers` from 0.38.0 to 0.39.0
- [Release notes](https://github.com/huggingface/diffusers/releases)
- [Commits](https://github.com/huggingface/diffusers/compare/v0.38.0...v0.39.0)

Updates `transformers` from 5.12.1 to 5.13.0
- [Release notes](https://github.com/huggingface/transformers/releases)
- [Commits](https://github.com/huggingface/transformers/compare/v5.12.1...v5.13.0)

Updates `huggingface-hub` from 1.20.1 to 1.22.0
- [Release notes](https://github.com/huggingface/huggingface_hub/releases)
- [Commits](https://github.com/huggingface/huggingface_hub/compare/v1.20.1...v1.22.0)

Updates `ruff` from 0.15.19 to 0.15.20
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.19...0.15.20)

Updates `pyright` from 1.1.410 to 1.1.411
- [Release notes](https://github.com/RobertCraigie/pyright-python/releases)
- [Commits](https://github.com/RobertCraigie/pyright-python/compare/v1.1.410...v1.1.411)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 12.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: diffusers
  dependency-version: 0.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: transformers
  dependency-version: 5.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: huggingface-hub
  dependency-version: 1.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: ruff
  dependency-version: 0.15.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: pyright
  dependency-version: 1.1.411
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 12:37:13 +03:00
Victor KuznetsovandClaude Opus 4.8 3524e8442e fix(visible): safe-inpaint pill gate, cut metadata-only false fires (#58)
Verified 0.13.0 pill removal on a 32k real-upload corpus. The metadata-OR-wordmark
gate was only ~1/3 precise: TC260 metadata confirms Jimeng-class provenance, not pill
presence, so the weak edge-NCC detector's false fires (textured ceilings/walls, where
inpaint visibly smears) were admitted whenever metadata was present.

Split into two arms (_keep_pill): the reliable bottom-right wordmark (~94% precise,
survives metadata stripping) removes the pill unrestricted; the metadata-only arm
removes it ONLY when the top-left footprint is flat enough for an invisible inpaint
(PillEngine.footprint_is_flat, median-Sobel <= _FLAT_TEXTURE_MAX). Keeps real
flat-scene pills and harmless flat false fires; leaves the damaging textured false
fires untouched. Corpus: 270 -> 118 removals, ~90 true preserved, damaging FP -> ~0.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:29:17 +03:00
Victor KuznetsovandClaude Opus 4.8 a8fd02a8f7 fix(visible): safe-inpaint pill gate, cut metadata-only false fires
Verified 0.13.0 pill removal on a 32k real-upload corpus. The metadata-OR-wordmark
gate was only ~1/3 precise: TC260 metadata confirms Jimeng-class provenance, not pill
presence, so the weak edge-NCC detector's false fires (textured ceilings/walls, where
inpaint visibly smears) were admitted whenever metadata was present.

Split into two arms (_keep_pill): the reliable bottom-right wordmark (~94% precise,
survives metadata stripping) removes the pill unrestricted; the metadata-only arm
removes it ONLY when the top-left footprint is flat enough for an invisible inpaint
(PillEngine.footprint_is_flat, median-Sobel <= _FLAT_TEXTURE_MAX). Keeps real
flat-scene pills and harmless flat false fires; leaves the damaging textured false
fires untouched. Corpus: 270 -> 118 removals, ~90 true preserved, damaging FP -> ~0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:26:28 +03:00
Victor KuznetsovandClaude Opus 4.8 0e5a4cbc54 feat(visible): capture-less AI生成 pill (#54), inpaint fallback, MI-GAN backend (#56)
- Add the Jimeng-basic top-left "AI生成" pill as a CAPTURE-LESS mark
  (pill_engine.py): synthetic-silhouette edge-NCC detect + inpaint-only removal.
  Gated in remove_auto_marks: kept only when Jimeng is confirmed (TC260 metadata
  OR the bottom-right "★ 即梦AI" wordmark fired -- the wordmark keeps recall on
  metadata-STRIPPED uploads) AND Doubao did not fire.
- Add an inpaint-fallback removal path + MI-GAN ONNX backend (migan extra, MIT,
  ~28 MB / ~1 GB peak -- droplet-friendly) alongside big-LaMa. New
  --method auto|reverse-alpha|inpaint (shared across visible/all/batch) and
  erase --backend migan; footprint_mask on each engine.
- auto is deterministic: reverse-alpha for capture marks (recovers exact pixels,
  lighter -- measured cleaner than MI-GAN on structured backgrounds) and inpaint
  only for the capture-less pill.
- --mark auto now removes EVERY detected mark in one pass (remove_auto_marks),
  so a Jimeng-basic image's top-left pill AND bottom-right wordmark both clear.
- Bump 0.12.1 -> 0.13.0.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:38:23 +03:00
Victor KuznetsovandClaude Opus 4.8 0f54c6b54d fix(identify): kill three visible-detector false positives
Bright-background photos/renders and a tiny app icon were flagged as
AI-generated by the visible detectors. Two failure modes:

- Gemini sparkle on a bright background (snow+sky photo, white product
  render) scored ~0.51. The FP gate only demoted on a low core-ring
  brightness margin, which a bright background makes high. Add a gradient
  floor (_SPARKLE_FP_GRAD 0.55): a real sparkle is a crisp star (grad
  ~0.97-1.0), a smooth luminance blob that NCC-matches the diamond is not
  (the two FPs measured grad 0.105 / 0.463). The OR is a strict superset
  of the old margin-only demotion, so it cannot regress dark/mid (kept by
  margin) or white-bg (kept by confidence) real sparkles.

- A 48x48 geometric icon matched the Doubao/Jimeng CJK silhouette at
  0.41/0.47 NCC. Purely a small-size artifact (the same icon at >=256px
  collapses to ~0.06-0.10). Guard text-mark detection below a 200px short
  side (_MIN_DETECT_SHORT_SIDE); real marks ship on full-resolution
  renders (smallest captured sample 1086px).

Corpus re-sweep flips only OpenAI content and already-cleaned outputs,
all sub-0.5, so no provenance verdict changes. Add synthetic regression
fixtures for both modes; docs/module-internals.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:44:24 -07:00
Victor KuznetsovandClaude Opus 4.6 f269b75ded chore: add ruff scripts per-file-ignores, Claude Code settings, bump deps
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 12:32:44 -07:00
Victor KuznetsovandClaude Opus 4.8 0d9d7dcf6a docs: compact CLAUDE.md, relocate incident/CVE detail to docs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:28:40 -07:00
dependabot[bot] 3bbcc0ee94 chore(deps): bump actions/checkout from 6 to 7 in the actions group (#52)
Bumps the actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 09:51:00 -07:00
dependabot[bot] d96e3f6334 chore(deps): bump the minor-and-patch group with 5 updates (#53)
Bumps the minor-and-patch group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [c2pa-python](https://github.com/contentauth/c2pa-python) | `0.35.1` | `0.36.0` |
| [torch](https://github.com/pytorch/pytorch) | `2.12.0` | `2.12.1` |
| [huggingface-hub](https://github.com/huggingface/huggingface_hub) | `1.19.0` | `1.20.1` |
| [pytest](https://github.com/pytest-dev/pytest) | `9.1.0` | `9.1.1` |
| [ruff](https://github.com/astral-sh/ruff) | `0.15.17` | `0.15.19` |


Updates `c2pa-python` from 0.35.1 to 0.36.0
- [Release notes](https://github.com/contentauth/c2pa-python/releases)
- [Changelog](https://github.com/contentauth/c2pa-python/blob/main/docs/release-notes.md)
- [Commits](https://github.com/contentauth/c2pa-python/compare/v0.35.1...v0.36.0)

Updates `torch` from 2.12.0 to 2.12.1
- [Release notes](https://github.com/pytorch/pytorch/releases)
- [Changelog](https://github.com/pytorch/pytorch/blob/main/RELEASE.md)
- [Commits](https://github.com/pytorch/pytorch/compare/v2.12.0...v2.12.1)

Updates `huggingface-hub` from 1.19.0 to 1.20.1
- [Release notes](https://github.com/huggingface/huggingface_hub/releases)
- [Commits](https://github.com/huggingface/huggingface_hub/compare/v1.19.0...v1.20.1)

Updates `pytest` from 9.1.0 to 9.1.1
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/9.1.0...9.1.1)

Updates `ruff` from 0.15.17 to 0.15.19
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.17...0.15.19)

---
updated-dependencies:
- dependency-name: c2pa-python
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: torch
  dependency-version: 2.12.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: huggingface-hub
  dependency-version: 1.20.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: pytest
  dependency-version: 9.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: ruff
  dependency-version: 0.15.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 09:50:56 -07:00
Victor Kuznetsov ee4af800ce chore(release): v0.12.1 2026-06-23 15:15:04 -07:00
Victor KuznetsovandClaude Opus 4.8 675590e8b2 perf(invisible): read the fp16 weight variant to halve the cold-start weight load
InvisibleEngine loads SDXL/ControlNet in fp16 on CUDA/XPU but called from_pretrained
without variant="fp16", so it read the full fp32 weight files (~7 GB) and downcast in
memory. _load_from_pretrained now passes variant="fp16" when torch_dtype is float16,
reading the half-precision files (~3.5 GB) instead - roughly halving the cold-start
weight read + host->device transfer (a phase-timed Modal run measured weight load as
~half of the ~25s cold start). Falls back to the default weights when a checkpoint ships
no fp16 variant (a custom --model), so the worst case is the prior behavior. fp32
(cpu/mps) and bf16 (qwen) never request the variant.

Tests: TestFp16WeightVariant (variant requested on fp16, fallback on missing, never on
fp32).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:42:47 -07:00
Victor KuznetsovandClaude Opus 4.8 c8dbd0c3f9 docs(claude-md): record that new visible marks are blocked on real flat captures
Per user decision 2026-06-22: synthetic font-rendered alpha reconstruction is
rejected as below the quality bar; the reverse-alpha alpha map must be solved
from real controlled flat captures (visible_alpha_solve.py). Meta AI, more
Samsung locales, and any Grok visible mark are parked until captures exist.
Future sessions must not propose synthetic or derive assets from the corpus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:28:28 -07:00
Victor KuznetsovandClaude Opus 4.8 abb7be7e9b feat(identify): detect + strip NovelAI / Reve / Aphrodite generator stamps
Mined from the retained corpus 2026-06-22 (open-world EXIF/PNG-text/XMP scan,
minus the registry): three AI image generators that stamp a plain generator
name and no C2PA, so identify read them as no-signal -- and under the P0#5
no-signal skip would have skipped the scrub.

- NovelAI (anime SD): PNG tEXt Software/Source/Title. exif_generator now reads
  PNG text chunks (via img.info), not only EXIF/XMP.
- Reve (reve.com): EXIF Software / XMP CreatorTool. Token is the full
  "reve.com", not bare "reve" (would false-fire on "forever"/"reverie").
- Aphrodite AI: EXIF Make / Software.

Detection/removal parity: NovelAI stamps an AI-shaped VALUE under a non-AI KEY
(Title/Source), which _is_ai_key alone keeps. New _is_ai_value drops a text
chunk by value-token match on removal, mirroring exif_generator -- else the
cleaned file still read as NovelAI (verified on a real corpus file).

Tests: TestExifGenerator gains NovelAI PNG-text, Reve, Reve-not-overmatched,
Aphrodite, and a NovelAI detect/remove parity regression. Docs synced
(module-internals, watermarking-landscape, CLAUDE.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:23:33 -07:00
Victor KuznetsovandClaude Opus 4.8 19f9ab0947 feat(invisible): skip the diffusion scrub when no invisible watermark is detectable (P0#5)
Regenerating pixels removes SynthID / open watermarks but degrades a real
photo, so running it on a clean image is the dominant paid score-0 cause on
no-watermark uploads. Gate invisible/all/batch on identify.has_invisible_target:
when no invisible AI signal is locally detectable and --force is unset, skip the
regeneration. Per-command semantics:
  - invisible: write no output, exit EXIT_NO_INVISIBLE_SIGNAL (2)
  - all: skip step 2 but keep visible-removed pixels + strip metadata, exit 0
  - batch: skip the scrub; copy the input through in invisible mode
A skip never claims the image is clean (a pixel SynthID is undetectable once its
metadata proxy is gone); the message says so and routes to --force. The gate
fails safe (a detector error runs the removal).

has_invisible_target wraps identify(check_visible=False, check_invisible=True)
and returns the new ProvenanceReport.ai_from_metadata field (the confidence==high
union), so the raiw.cc worker can reuse the same gate. Gate placed before engine
construction so the skip path is cheap; shared via cli._should_skip_invisible_scrub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:37:01 -07:00
Victor KuznetsovandClaude Opus 4.8 5a612adfef chore(release): v0.12.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:14:55 -07:00
Victor KuznetsovandClaude Opus 4.8 9695a7cbd2 docs(qwen): drop false 'pending seed-repeat cert' from pipeline docstring
The qwen oracle floors are certified, not pending. Near-threshold scrub is
seed-non-deterministic, but the prod path pins one fixed seed, so a certified
floor reproduces run-to-run -- pinning the seed is the release gate, not a seed
sweep. Reword the docstring so it stops implying an open seed-repeat gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:30:42 -07:00
Victor KuznetsovandClaude Opus 4.8 78e2ae65ad docs: neutralize local-pull path reference in doubao research note
Replace the `data/spaces/originals/` path with a generic "local corpus of
pristine originals" so the committed public doc carries no reference to the
local working-data pull (the data itself is gitignored). The analysis scripts'
default paths are left untouched (operational tooling, no content/provenance).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:41:23 -07:00
Victor KuznetsovandClaude Opus 4.8 719c7e9313 chore(gitignore): ignore local-only data/spaces working dir
21k user-pull images under data/spaces/ were untracked but not ignored, so a
stray `git add -A` could have committed them. Add the ignore entry alongside the
other local-data paths; the dir stays local analysis only, never a committed corpus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:32:22 -07:00
Victor KuznetsovandClaude Opus 4.8 d5dd24140c fix(qwen): native-geometry img2img + pipeline-aware strength; record dropped auto/mixed/Z-Image leads
- watermark_remover: _build_qwen_kwargs now passes explicit height/width (via
  _qwen_target_size, floored to /16). Without it QwenImageImg2ImgPipeline defaults to
  1024x1024 and silently squishes non-square inputs, distorting the scene and garbling text.
- watermark_profiles: resolve_strength gains a `pipeline` arg + a Qwen strength ladder
  (_QWEN_VENDOR_STRENGTH, Gemini 0.25), so `--pipeline qwen` gets its certified floor
  automatically; retires the manual "pass --strength 0.25 for Gemini on qwen" workaround.
- fidelity_metrics: replace per-face nearest matching (collided on multi-face images when a
  variant dropped a face, corrupting the identity metric) with a collision-free one-to-one
  assignment (assign_faces_one_to_one). lapvar/LPIPS were always bbox-anchored and immune.
  Regression-guarded by tests/test_fidelity_matching.py.
- docs: record the measured outcomes of the qwen-improvement arc. The Qwen ControlNet
  face-fix is CLOSED (no permissive Qwen detail/tile ControlNet exists; canny carries edges,
  not skin grain). The `--pipeline auto` router + faces+text mixed dual-pass were prototyped
  and DROPPED (controlnet wins faces AND display text: abba CER 0.114 vs qwen 0.379).
  Z-Image-Turbo was tried and dropped (same regeneration limits). qwen stays a manual opt-in;
  controlnet is the default for everything.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:52:56 -07:00
Victor KuznetsovandClaude Opus 4.8 8f64869bfc docs: capture the Qwen-improvement research (ship vs improve)
Cited deep-research report (22 sources, 3-vote adversarial verification, 5 refuted)
behind the "ship qwen as-is or improve first?" decision. Verdict: shippable now as
an opt-in text lane; strongest improvement lead is adding a Qwen-Image ControlNet
(InstantX / DiffSynth, Apache-2.0, diffusers QwenImageControlNetPipeline) for face/
skin structure; Z-Image-Turbo (6B, Apache-2.0) is the best cheaper text-preserving
substitute. No improvement has measured face-fidelity at our scrub floors yet --
validate with scripts/fidelity_metrics.py first. Linked from known-limitations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:58:46 -07:00
Victor Kuznetsov 0d9033d63a Merge branch 'claude/modest-carson-d72243': corpus-mining provenance + removal fixes
Retained-corpus mining (2026-06-20) fixes, all gate-green:
- C2PA vendor coverage (Volcano Engine CJK legal name, ElevenLabs; TikTok/PixelBin vetted out)
- identify AI-generated vs AI-enhanced (ai_source_kind) + shared GEMINI_SPARKLE_TRUST_CONF (detect/remove threshold unify)
- text-mark over-subtraction guard (Doubao/Jimeng/Samsung)
- region-targeted regeneration for AI-enhanced composites (feather_region_composite + remove_watermark(region=))

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

# Conflicts:
#	CLAUDE.md
2026-06-20 15:39:29 -07:00
Victor KuznetsovandClaude Opus 4.8 737305858d docs: sync module map for the corpus-mining provenance + removal fixes
Update CLAUDE.md and docs/module-internals.md for: ProvenanceReport.ai_source_kind
(generated vs enhanced) and the shared GEMINI_SPARKLE_TRUST_CONF; the text-mark
over-subtraction guard; noai/tiling.feather_region_composite + the region-targeted
WatermarkRemover.remove_watermark(region=) path; the new C2PA vendor rows (Volcano
Engine Chinese legal name, ElevenLabs) and the documented TikTok/PixelBin
exclusion. Record the rejected gemini-gate-lowering experiment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:34:39 -07:00
Victor KuznetsovandClaude Opus 4.8 c1971a3e8d feat(invisible): region-targeted regeneration for AI-enhanced composites
For AI-enhanced composites (digitalSourceType compositeWithTrainedAlgorithmicMedia,
identify ai_source_kind == "enhanced"; roadmap P1#8): regenerate ONLY the AI
region and preserve the real photo elsewhere, instead of regenerating the whole
frame.

- noai.tiling.feather_region_composite(base, regenerated, box, *, feather): pure,
  model-free compositor that blends the regenerated AI box back over the original
  with a feathered seam, leaving pixels OUTSIDE the box exactly equal to base.
  Fully unit-tested (outside-box exactness, interior == regenerated, hard paste at
  feather 0, monotonic seam ramp, dtype/grayscale/clamp/empty-box/shape-mismatch).
- WatermarkRemover.remove_watermark(region=, region_feather=) and the module-level
  convenience function thread it through: the remover regenerates (or tiles) the
  frame, then composites only the AI box back over the original input. The box is
  caller-supplied -- a C2PA composite manifest carries no reliable machine-readable
  region, so none is fabricated. The no-model lossless region path stays
  region_eraser.erase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:34:39 -07:00
Victor KuznetsovandClaude Opus 4.8 33fddbc6fa fix(visible): over-subtraction guard for Doubao/Jimeng/Samsung text marks
Port the Gemini sparkle dark-pit guard (commit 41f6797) to the shared
TextMarkEngine reverse-alpha base (roadmap P0#8): on a dark or mid-tone
background the captured alpha can over-estimate this image's mark opacity, and
reverse-alpha leaves a darker-than-background glyph ghost instead of recovering
the true pixels. The sparkle-only fix left the text marks unhandled.

_reverse_alpha_oversubtracts predicts the reverse-alpha output PER PIXEL over the
glyph body from the INPUT ((obs - a*logo)/(1-a), the remover's own math); when
the predicted body lands more than _OVERSUB_DARK_MARGIN (25) gray levels below
the local background ring it abandons the reverse-alpha output for the footprint
and inpaints it from the original surroundings (_inpaint_footprint, wider dilate/
radius than the thin residual pass). Predicting per-pixel from the input (not the
produced output, which depends on which placement the remover picked) keeps a
cleanly captured full-strength mark byte-identical -- it predicts back to the
background everywhere, so the guard never trips on it (verified across all three
engines on white/mid/dark/midgray backgrounds).

Regression-guarded by tests/test_text_mark_oversubtraction.py: predicate True on
faint / False on clean, end-to-end no-dark-pit acceptance, clean-mark byte
identity, and textured-background footprint recovery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:34:39 -07:00
Victor KuznetsovandClaude Opus 4.8 0c215b5b2f feat(identify): C2PA vendor coverage, AI-enhanced split, detect/remove threshold unify
Retained-corpus mining (2026-06-20) surfaced three provenance gaps; all are
oracle-free and regression-guarded.

- C2PA vendor coverage (roadmap): register Volcano Engine under its Chinese
  legal entity 北京火山引擎科技有限公司 (the latin "volcengine" needle misses
  those certs) -> normalizes to the same ByteDance platform; register ElevenLabs
  ("Eleven Labs Inc.", pure generative-AI) as a generator. Document the
  deliberate exclusion of TikTok Inc. and PixelBin.io/"Fynd" (provenance/transform
  signers, not generators) so they are not re-added.

- AI-generated vs AI-enhanced (roadmap): ProvenanceReport.ai_source_kind splits
  the C2PA digital-source-type into "generated" (trainedAlgorithmicMedia) vs
  "enhanced" (compositeWithTrainedAlgorithmicMedia) so a caller branches a
  full-frame scrub from a region-targeted clean. Parsed once in
  noai.c2pa._populate_registry_fields (PNG + any c2pa-python-readable container),
  with a raw head-scan fallback in identify for the non-PNG raw-blob path. CLI
  verdict reads "AI-generated (fully synthetic)" vs "AI-enhanced (real content
  with an AI-composited region)"; surfaced in --json.

- Detect-vs-remove threshold desync (P0#7): identify's sparkle threshold and the
  removal arbitration gate were two independent 0.5 constants. Unify them into the
  single GEMINI_SPARKLE_TRUST_CONF (identify imports it) so they can never drift.
  Lowering the gate to recover faint sub-0.5 sparkles was evaluated and REJECTED:
  a real Doubao text mark scores ~0.40-0.42 as a gemini match with a higher
  core-ring brightness margin than a genuine faint sparkle, so neither confidence
  nor the brightness gate separates them in [0.35, 0.5) -- lowering would trade a
  rare miss for false-positive removals on clean images. Regression-guarded by
  TestSparkleDetectRemoveAlignment (real demo sparkle at borderline opacities;
  identify and best_auto_mark must agree on either side of the line).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:34:20 -07:00
Victor KuznetsovandClaude Opus 4.8 e29d6624b9 docs(known-limitations): seed is a weak text-only quality lever; mark qwen floors certified
Measured (openai_1, 0.10, seeds 0-4): seed barely moves whole-image fidelity
(img LPIPS 0.062-0.065, SSIM/PSNR flat) but shifts text legibility (OCR CER
0.241-0.290, ~17% spread) -- it changes which details regenerate, not the level.
So per-image best-of-N-seed is a weak text-only lever (pin a seed in prod; reserve
best-of-N for text-heavy premium). Also retitle the qwen section "certified floors"
and drop the now-stale "uncertified / run seed-repeat / floor 0.30" tails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:26:27 -07:00
Victor KuznetsovandClaude Opus 4.8 7dddfef14e docs: certify qwen scrub floors (OpenAI 0.10 seed-robust, Gemini 0.25)
Oracle seed-repeat + floor refinement (2026-06-20, data/qwen_in):
- OpenAI floor 0.10 is SEED-ROBUST: 0.05 and 0.075 still detected; 0.10 clean on
  seeds 0-4 (5/5) -> a random seed is safe.
- Gemini floor lowered 0.30 -> 0.25 (0.20 still detected, 0.25 clean on both
  images). Single-seed (seed 0): the Gemini oracle rate-limits volume seed-repeat,
  so pin a seed in prod rather than relying on seed-robustness there.

Re-measured fidelity at the certified floors (controlnet 0.15 vs Qwen 0.25 for
Gemini): faces still favor controlnet (ArcFace 0.546 vs 0.382, lapvar 0.62 vs
0.40); the short-CJK text case is now a TIE (gemini_1 0.037 vs 0.037 -- the earlier
Qwen 0.000 was at 0.30, not the floor). Qwen's text win holds on substantial
Latin/mixed text (OpenAI 0.385 vs 0.241 / 0.341 vs 0.290). Update watermark_profiles
comment, CLAUDE.md, module-internals, known-limitations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:16:51 -07:00
Victor KuznetsovandClaude Opus 4.8 373b910a60 docs: fix the qwen-vs-controlnet face comparison to oracle-confirmed scrub floors
The face fidelity numbers cited an equal-strength compare (both 0.15), but Qwen at
0.15 does NOT clear Gemini SynthID -- so that output is un-scrubbed and the compare
is invalid. Per the methodology rule (compare fidelity only between outputs where
SynthID is removed in BOTH), restate faces at each pipeline's scrub floor
(controlnet 0.15 / Qwen 0.30): ArcFace identity 0.546 vs 0.331, lapvar 0.62 vs 0.40,
face LPIPS 0.09 vs 0.19 -- controlnet still wins faces, conclusion unchanged. Drop
the "equal strength" framing in CLAUDE.md / module-internals / known-limitations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:33:11 -07:00
Victor KuznetsovandClaude Opus 4.8 2d5b26ed18 test(eval): vision-transcribed ground truth for qwen_in + clean text-CER numbers
data/qwen_in/ground_truth.json is transcribed by vision (PaddleOCR mangled the
stylized Cyrillic), so the text metric scores variants against an accurate
reference instead of noisy OCR-vs-OCR. Re-measured text CER (controlnet vs qwen)
with this ground truth confirms qwen wins text across EN/RU/ZH: openai_1 0.385 vs
0.241, openai_2 0.341 vs 0.290, gemini_1 (ZH) 0.037 vs 0.000 (perfect Chinese even
at the higher 0.30 strength). Faces still favor controlnet. Refresh the numbers in
docs/known-limitations.md to this cleaner methodology.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:26:23 -07:00
Victor KuznetsovandClaude Opus 4.8 e29c156279 test(eval): fix the qwen_in pipeline-fidelity eval set + PaddleOCR ground-truth flow
- data/qwen_in/: a stable, committed set of 4 AI-generated images (OpenAI +
  Google, carrying SynthID/C2PA -- same class as data/samples fixtures) used to
  compare the controlnet/sdxl/qwen pipelines for fidelity. Two text-multi-script
  (incl. RU/CJK), one EN poster, one face grid. README documents the set + the
  ground-truth workflow. data/ is sdist-excluded so the wheel is unaffected.
- scripts/fidelity_metrics.py: switch text OCR from EasyOCR to PaddleOCR
  (PP-OCRv6, higher accuracy esp. CJK, single multilingual stack); split into
  `ocr` (seed a {basename: text} ground truth) and `compare` (--ground-truth for
  a clean CER vs the hand-verified reference instead of noisy OCR-vs-OCR). Spatial
  IoU-NMS keeps the best-scoring read per line so wrong-script models don't inject
  garbage over Cyrillic/CJK.
- Oracle methodology: validate the OpenAI arm FIRST (openai.com/verify is more
  accessible and the strongest Playwright/Chrome-MCP automation candidate; the
  Gemini app is more manual). Recorded in CLAUDE.md + docs/synthid.md.

Ground-truth JSON (data/qwen_in/ground_truth.json) lands in a follow-up once
hand-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:17:04 -07:00
Victor KuznetsovandClaude Opus 4.8 a2c33af284 feat(scripts): fidelity_metrics.py + correct the qwen-vs-controlnet claim
Add scripts/fidelity_metrics.py: an objective eval harness comparing
watermark-removal outputs against the original (reference) across four groups
-- OCR character error rate (EasyOCR), ArcFace identity cosine (insightface),
face texture (LPIPS + Laplacian-variance ratio), and whole-image LPIPS/SSIM/
PSNR. PEP 723 inline deps so it stays out of the package / uv.lock; metrics
self-gate (faces only where faces, text only where text).

The metrics overturned an eyeball conclusion: at EQUAL strength Qwen beats
controlnet on TEXT (OpenAI typography 0.10: OCR CER 0.25 vs 0.37) but controlnet
beats Qwen on FACES (gemini_3, 18 faces, 0.15 each: Laplacian-variance retention
0.62 vs 0.41, face LPIPS 0.09 vs 0.13 -- Qwen smooths faces MORE; ArcFace
identity ~tied). So Qwen is the better TEXT-preserving remover, not a universal
fidelity win. Correct the earlier "qwen keeps faces faithful where controlnet
plasticizes" claim in CLAUDE.md, module-internals.md, known-limitations.md, README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 09:58:22 -07:00
Victor KuznetsovandClaude Opus 4.8 76e3d4154c feat(invisible): add Qwen-Image img2img pipeline (--pipeline qwen)
A third diffusion pipeline alongside sdxl/controlnet: Qwen-Image (20B MMDiT,
Apache-2.0 code AND weights) img2img. The scrub still comes from the img2img
strength; Qwen preserves text (incl. CJK) and structure markedly better than
SDXL at the scrub floor, so it over-regenerates real photos far less (directly
targets the controlnet over-regeneration that degrades real uploads).

- watermark_profiles: QWEN_MODEL_ID, normalize_profile accepts "qwen".
- WatermarkRemover: _load_qwen_pipeline (bf16, loads Qwen base unless --model
  overridden, clear ImportError if diffusers lacks the class), _run_qwen (no
  MPS fallback -- 20B is CUDA/cloud-class), dispatch in _generate_one/preload,
  pure _build_qwen_kwargs (true_cfg_scale, not guidance_scale).
- Shared _base_load_kwargs() across all three loaders (dtype + token).
- CLI --pipeline gains "qwen"; invisible_engine threads it through.
- scripts/qwen_scrub_prototype.py: standalone PEP 723 GPU experiment.

Prototype oracle floors (Modal A100-80GB, single seed, controls SynthID-positive,
PENDING seed-repeat cert): OpenAI clears at strength ~0.10, Gemini at ~0.30 (0.20
still detected), with CJK text + faces faithful where controlnet plasticizes. The
Gemini floor is higher than the shared default ladder, so pass an explicit
--strength for Gemini on this pipeline until a Qwen-specific ladder is certified.

The model-running path is CUDA-only (untestable locally); unit tests cover the
pure call-shape (_build_qwen_kwargs) and profile normalization without torch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:44:36 -07:00
Victor KuznetsovandClaude Opus 4.8 0c0c6c6b03 feat(invisible): sliding-window tiled diffusion for large inputs (--tile)
Add a lossless alternative to the --max-resolution downscale for large
images that OOM on MPS/GPU: regenerate in overlapping, feather-blended
tiles at native resolution.

- noai/tiling.py: pure plan_tiles (uniform tiles, last flush to edge) +
  feather_weights (strictly-positive separable taper -> partition-of-unity
  blend) + run_tiled (per-tile generate callable, decoupled from the
  pipeline). Unit-tested without the model.
- WatermarkRemover.remove_watermark: refactor _generate into _generate_one
  + a tiled branch that engages only when --tile is set and the long side
  exceeds tile_size (ControlNet canny is rebuilt per tile).
- Thread tile/tile_size/tile_overlap through InvisibleEngine and the
  invisible/all/batch CLI commands via a shared _tile_options decorator.

Verified end-to-end on the real SDXL pipeline (forced 2x2 tiling on a
1024px sample, MPS): non-degenerate output, no gross seam at tile borders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:54:58 -07:00
Victor KuznetsovandClaude Opus 4.8 d5845a72f3 feat(metadata): blank AI-generator tokens in AVIF/HEIF Exif meta-box items
Closes a documented coverage gap (P2#9): an AI Software/Make/Artist/ImageDescription
token in an EXIF item (its TIFF bytes live in mdat/idat) survived remove_ai_metadata
because the top-level box stripper and (absent pillow-heif) the PIL EXIF reader can't
reach it. New isobmff.blank_ai_exif_tokens finds EXIF TIFF blocks by their II/MM
byte-order header, validates each with piexif (a coincidental II/MM run in pixels
won't parse as a TIFF IFD, so it's ignored), and overwrites any AI_GENERATOR_TOKENS-
bearing value with same-length spaces -- so box sizes and iloc offsets stay valid and
the coded image is untouched (mirrors blank_ai_xmp_packets; no iinf/iloc surgery, no
exiftool dep). Camera/editor EXIF without an AI token is preserved. Wired into
remove_ai_metadata's ISOBMFF path. Covers the realistic AI-generator-token case; xAI-
signature-in-meta-box-EXIF (Grok is JPEG-only) stays out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:43:35 -07:00
Victor KuznetsovandClaude Opus 4.8 3f5d6a0af1 docs(landscape): back the DWT-DCT positive-only limitation with researched root cause + citations
Deep-research (2026-06-19, adversarially verified) confirms the open imwatermark
dwtDct mark is fragile by scheme, not by our usage: maintainers admit no 100%
clean-decode guarantee; measured ~0.79 bit accuracy clean (~38/48, below our 44
gate). Root causes (code-verified + locally reproduced): per-block max-coefficient
bit read (content flips bits) and YUV chroma 8-bit clamping on bright pixels (the
bright-flat / all-ones failure). No maintained fork or detector does this scheme
reliably (WAVES relegates it to an appendix; learned schemes are a different class;
dwtDctSvd cannot decode SDXL's dwtDct). Conclusion: keep it positive-only, rely on
C2PA. Sources: imwatermark READMEs, arXiv:2406.08337 (WMAdapter), arXiv:2401.08573
(WAVES), diffusers SDXL watermark.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:27:08 -07:00
Victor Kuznetsov f97fdc5b92 chore(release): v0.11.4 2026-06-19 10:06:04 -07:00
Victor KuznetsovandClaude Opus 4.8 4c8a57ec7b docs: dwtDct detector is carrier-fragile (all-ones = artifact), FLUX open-mark unresolvable
Final characterization after a positive-control sweep. The imwatermark dwtDct
round-trip fails (28-39/48, below the 44 gate) not on "high texture" as a prior
note claimed, but on a broad carrier class: the FLUX fox, doubao, a minimalist-FLAT
FLUX generation, AND a clean synthetic bright-flat fill with NO watermark all fail
identically. The degenerate all-ones decode is therefore a CARRIER ARTIFACT, not a
watermark (the no-watermark synthetic image reproduces it; a double-embed test shows
no interference). detect_invisible_watermark is positive-only: trust a hit, treat a
None as inconclusive unless a same-carrier positive control first recovers >=44.

Consequence: whether BFL hosted FLUX embeds the open DWT-DCT is unresolvable with
this detector on the available carriers (textured AND flat FLUX both fail the
control). C2PA stays the reliable FLUX signal. Low priority to chase further.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:03:34 -07:00
Victor KuznetsovandClaude Opus 4.8 a0a349cc66 docs: correct overstated FLUX open-watermark claim; record detector content-fragility
Earlier notes asserted BFL hosted output has no open DWT-DCT watermark. That was
overstated: the test carriers were high-texture fox images where a clean
encode->decode round-trip of a KNOWN-embedded watermark recovers only 28-35/48
bits (below the safe 44 gate), so the detector would miss a present mark there --
the None is inconclusive, not proof of absence.

Verified positive-control (2026-06-19): imwatermark dwtDct round-trips 48/48 on
synthetic carriers and on chatgpt-1.png (48/48) / firefly-1.png (45/48), but
FAILS on flux-1.png (28/48) and doubao-1.png (39/48). So invisible_watermark
detection is a positive-only signal: trust a hit, treat a miss on busy content as
inconclusive. Affects all open SD/SDXL/FLUX DWT-DCT detection. C2PA stays the
reliable FLUX identifier; whether BFL hosted embeds the open mark is unresolved
(needs a low-texture hosted sample).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:03:34 -07:00
Victor KuznetsovandClaude Opus 4.8 99e57c872f perf(text-mark): footprint-sized arrays in reverse-alpha CPU path
The reverse-alpha text-mark engine (Doubao/Jimeng/Samsung) allocated
full-frame arrays where only the glyph footprint is ever read:

  - _fixed_alpha_map / _aligned_alpha_map each built a full (h, w) float32
    alpha map non-zero only inside the glyph box, and two were held at once
    during removal (~96 MB of mostly-zeros on a 12 MP frame);
  - extract_mask built a full (h, w) uint8 mask that every caller cropped to
    the located box (~12 MB, rebuilt per text-mark detector on the
    memory-tight identify path).

Both now return footprint-sized arrays: the alpha helpers return the
glyph-sized block plus its placement (ax, ay, gw, gh), and extract_mask
returns the box-sized mask. _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). remove_watermark_
reverse_alpha tracks the winning region alongside best_amap to place it.

Peak allocation drops from O(image*4)x2 + O(image) to O(footprint)x2 +
one gated O(image*1) uint8 mask -- a win every consumer gets, motivated by
the 512 MB raiw.cc worker that OOMs on large decodes. GPU path untouched.

Byte-identical to the old full-frame path (verified: 17 output hashes
across the three engines, inpaint/no-inpaint, detect, and the real
doubao-1.png fixture, unchanged before/after). tests/test_text_mark_memory.py
guards it by reconstructing the old full-frame path inline and asserting
equality, so the proof survives a cv2/asset bump, and pins the O(footprint)
shape so a regression to full-frame fails loudly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:01:07 -07:00
Victor KuznetsovandClaude Opus 4.8 9614615001 docs(landscape): confirm BFL hosted = C2PA-only on FLUX.1 [dev] too
Lossless-PNG check across both BFL Playground model lines (FLUX.2 [pro] and
FLUX.1 [dev]) confirms the open DWT-DCT pixel watermark is absent on hosted
output regardless of model or container; only the signed C2PA manifest is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 09:42:03 -07:00
Victor KuznetsovandClaude Opus 4.8 9e307d020e test(c2pa): add real FLUX.2 BFL C2PA fixtures (PNG + JPEG)
flux-1.png / flux-1.jpg are real Black Forest Labs FLUX.2 [pro] Playground
outputs (signed C2PA, issuer "Black Forest Labs" + trainedAlgorithmicMedia,
manifests verified to contain no personal data). flux-1.jpg is the first
committed JPEG-with-C2PA fixture, exercising the c2pa-python non-PNG reader path
end to end. Regression tests assert both attribute to "Black Forest Labs (FLUX)".

Also documents the verified finding (n=2, 2026-06-19): BFL's hosted output carries
the signed C2PA manifest but NOT the open invisible-watermark DWT-DCT (decodes to
degenerate all-ones, chance-level vs the FLUX reference) -- the open pixel mark is
dev-inference-code-optional only. So a hosted FLUX.2 image is identified by C2PA
alone, with no open-pixel fallback once C2PA is stripped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 09:37:40 -07:00
Victor KuznetsovandClaude Opus 4.8 d4d9429328 feat(identify): attribute Canva and BytePlus C2PA; fix BytePlus->Adobe mislabel
Mining the local production corpus (25,725 imgs) surfaced two AI vendors signing
C2PA that the registry missed:
- Canva (Magic Media) signed "Canva" + trainedAlgorithmicMedia -> detected AI but
  no platform attributed (disproves the old "Canva exports strip C2PA" assumption).
- BytePlus (ByteDance international: Seedream/Seededit) signs "Byteplus Pte. Ltd.";
  the bare volcengine needle missed it, so its output was mis-attributed to "Adobe
  Firefly" via an incidental "Adobe XMP" string the fallback byte-scan picked up.

Adding both to C2PA_AI_VENDORS lets the clean manifest issuer attribute them
directly. Corpus re-run: 16 platform changes, all improvements (3 Adobe->ByteDance
fixes, 4 None/TC260->ByteDance, 9 None->Canva), 0 regressions. An attempted
signer-based attribution fallback was measured and dropped: it regressed 18 images
(friendly ByteDance label -> raw Chinese cert org; IPTC tool name pre-empted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:57:21 -07:00
76 changed files with 7273 additions and 2345 deletions

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"WebSearch",
"WebFetch"
]
},
"enabledPlugins": {
"pyright-lsp@claude-plugins-official": true,
"context7@claude-plugins-official": true,
"code-simplifier@claude-plugins-official": true,
"claude-md-management@claude-plugins-official": true
}
}
+1 -1
View File
@@ -67,7 +67,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the tap
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: wiltodelta/homebrew-tap
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
environment: pypi
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
+2 -2
View File
@@ -13,7 +13,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
- name: Sync dev environment
run: uv sync --frozen --extra dev
@@ -36,7 +36,7 @@ jobs:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.10", "3.12"]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
with:
python-version: ${{ matrix.python-version }}
+6
View File
@@ -50,3 +50,9 @@ data/samsung_capture/captures/samsung_content_*
# (GFPGAN wrote RetinaFace/parsing weights to a CWD ./gfpgan/weights/ working
# dir on first use). Runtime artifact, never committed.
gfpgan/
# Qwen ControlNet experiment outputs (throwaway eval; never the committed corpus)
scripts/_qwen_exp_out/
# Local-only working data for analysis (not a committed corpus; never tracked)
data/spaces/
+28 -22
View File
@@ -17,26 +17,28 @@ Consequences for contributors (do not drift back into the stock niche just becau
## How to run
- `uv run remove-ai-watermarks all <image.png> -o <output.png>` — full pipeline (visible + invisible + metadata). Same diffusion knobs as `invisible` below, plus the visible-pass `--inpaint/--no-inpaint`/`--inpaint-method`. **When the `[gpu]` extra is absent, step 2 (invisible/SynthID) is skipped** — `all` still writes an output (visible mark + metadata stripped) but prints a prominent end-of-run banner ("the invisible (SynthID) watermark was NOT removed") AND exits **non-zero** (1), so a skipped SynthID pass is not mistaken for a clean result (the recurring #14/#47 trap, where the old quiet inline warning was missed). `invisible` already hard-errors without the extra; only `all` continued, hence the loud end-banner. Regression-guarded by `tests/test_cli.py::TestAllCommand::test_all_loud_warning_and_nonzero_exit_when_gpu_missing`. **Test trap:** any `all` test that exercises the full pipeline MUST `patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True)` — CI installs core+dev only (no `[gpu]`), so an unpatched `all` test takes the skip branch and now hits the non-zero exit. This passed locally (gpu present → `is_available()` True) but red-failed every matrix cell on the v0.11.0 commit (`test_all_basic`/`test_all_visible_step_uses_registry` asserted exit 0); both now patch `is_available` True.
- `uv run remove-ai-watermarks invisible <image.png> -o <out.png>` — diffusion SynthID removal. **Full knob set** (kept identical across `invisible`/`all`/`batch`): `--strength` (vendor-adaptive default), `--steps`, `--guidance-scale` (CFG, default 7.5), `--pipeline sdxl|controlnet` (default `controlnet`), `--controlnet-scale`, `--model` (HF model id, default SDXL base), `--device`, `--seed`, `--hf-token`, `--max-resolution`/`--min-resolution`, `--upscaler lanczos|esrgan`, `--humanize` (Analog Humanizer grain), `--unsharp` (final sharpen), and `--adaptive-polish/--no-adaptive-polish` (**ON by default**; detail-targeted polish that self-gates to a no-op where there is no deficit). `--auto` is deprecated and now a no-op that only warns (the polish it used to enable is ON by default).
- `uv run remove-ai-watermarks visible <image.png> -o <out.png>` — known-visible-mark removal, CPU, no GPU. Reverse-alpha based: each mark is removed by inverting its captured alpha map. `--mark auto` (default) picks the strongest detected of the Gemini sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, and the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-LEFT, locale-specific — Italian variant calibrated); `--mark gemini` / `--mark doubao` / `--mark jimeng` / `--mark samsung` force one (choices come from the registry). Gemini/Doubao recover pixels exactly with no inpaint at native; **Jimeng and Samsung add an always-on thin residual inpaint over the glyph footprint** (their marks re-rasterize per image, so reverse-alpha alone leaves a faint outline). For arbitrary logos/objects use `erase`. **When `--mark auto` finds no known mark (the common case — ~74% of real uploads carry no registered visible mark), the command does NOT silently re-serve the input as a finished result.** It runs a cheap metadata-only `identify`, prints actionable guidance (if the image carries an invisible/metadata mark, e.g. an OpenAI/Gemini C2PA image, it points to `all`; otherwise it does NOT imply the image is clean -- it warns that an invisible pixel watermark like SynthID cannot be detected once the metadata proxy is gone and routes to both `all` and `erase --region`), writes NO output file, and exits **`EXIT_NO_VISIBLE_MARK` (2)** — distinct from success (0) and a hard error (1) so a wrapping service (raiw.cc) can surface the message instead of treating the unchanged image as done (the production "it didn't work" / score-0 trap). Same handling for an explicit `--mark <name>` that is not detected. Helper `cli._no_visible_mark_exit`; regression-guarded by `tests/test_cli.py::TestVisibleCommand::test_visible_auto_no_mark_exits_two_with_eraser_hint` and `test_visible_auto_no_mark_routes_to_all_when_metadata`. `--no-detect` still forces the gemini fallback and proceeds (exit 0).
- `uv run remove-ai-watermarks erase <image.png> --region x,y,w,h -o <out.png>` — universal region eraser (any logo/object, any position). `--backend cv2` (default, no deps) or `--backend lama` (big-LaMa via onnxruntime, extra `lama`); `--region` is repeatable.
Per-command exit-code semantics (the no-signal / GPU-missing skip branches), test traps, and regression-guard paths live in `docs/module-internals.md` (section "CLI commands (`cli.py`)") — read it before changing any command's skip/exit behavior.
- `uv run remove-ai-watermarks all <image.png> -o <output.png>` — full pipeline (visible + invisible + metadata). Same diffusion knobs as `invisible`, plus the visible-pass `--backend auto|cv2|migan|lama` (default `auto`) and `--sensitivity auto|strict|assume-ai` (default `auto`) for the localize -> fill visible removal (see the `visible` bullet). Skips step 2 (invisible/SynthID) when the `[gpu]` extra is absent or no invisible signal is detectable; see the module doc for the distinct exit codes.
- `uv run remove-ai-watermarks invisible <image.png> -o <out.png>` — diffusion SynthID removal. **Full knob set** (kept identical across `invisible`/`all`/`batch`): `--strength` (vendor-adaptive default), `--steps`, `--guidance-scale` (CFG, default 7.5), `--pipeline sdxl|controlnet|qwen` (default `controlnet`; `qwen` is a manual opt-in only — see the qwen note in the module map), `--controlnet-scale`, `--model` (HF model id, default SDXL base), `--device`, `--seed`, `--hf-token`, `--max-resolution`/`--min-resolution`, `--upscaler lanczos|esrgan`, `--humanize` (Analog Humanizer grain), `--unsharp` (final sharpen), `--adaptive-polish/--no-adaptive-polish` (**ON by default**), `--tile/--no-tile` + `--tile-size`/`--tile-overlap` (**OFF by default**), `--force/--no-force` (default skip = ON, runs the scrub even with no detected signal). `--auto` is deprecated and a no-op that only warns. Skips the diffusion when no invisible signal is detectable (the no-signal gate); see the module doc.
- `uv run remove-ai-watermarks visible <image.png> -o <out.png>` — known-visible-mark removal by **localize -> fill**: each detected mark is localized to a binary full-frame footprint mask, then one shared, swappable fill inpaints that mask. `--backend auto|cv2|migan|lama` (default `auto`) picks the fill: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. `--mark auto` (default) removes EVERY detected mark in one pass (a Jimeng-basic image carries the top-left "AI生成" pill AND the bottom-right "★ 即梦AI" wordmark) from: Gemini sparkle, Doubao "豆包AI生成", Jimeng "★ 即梦AI", Samsung Galaxy AI "✦ Contenuti generati dall'AI", and the capture-less Jimeng "AI生成" pill (top-left, metadata-gated); `--mark gemini|doubao|jimeng|samsung|jimeng_pill` forces one. `--sensitivity auto|strict|assume-ai` (default `auto`) sets how hard a borderline mark is trusted: `auto` relaxes a mark's gate only on same-product evidence (metadata provenance for that vendor, or a confidently detected sibling mark of the same product — clean images stay untouched); `strict` never relaxes; `assume-ai` relaxes every mark (the caller asserts the image is AI, e.g. a metadata-stripped screenshot uploaded to a remover — corpus-measured Gemini recall ~46% -> ~92%, at the cost of a small fill on some clean corners). Metadata provenance is read automatically and feeds `auto`; the library cannot infer AI from a stripped image, so only `assume-ai` reaches the high recall there. For arbitrary logos/objects use `erase`. When no known mark is detected the command writes no output and exits with the no-visible-mark code instead of re-serving the input; `--no-detect` forces the gemini fallback and proceeds. See the module doc for the routing/exit detail. `--backend` and `--sensitivity` are shared across `visible`/`all`/`batch`.
- `uv run remove-ai-watermarks erase <image.png> --region x,y,w,h -o <out.png>` — universal region eraser (any logo/object, any position). `--backend cv2` (default, no deps), `--backend migan` (MI-GAN via onnxruntime, extra `migan`; ~28 MB, ~1 GB RAM, near-LaMa), or `--backend lama` (big-LaMa, extra `lama`; best quality but ~4.7 GB RAM); `--region` is repeatable.
- `uv run remove-ai-watermarks identify <image>` — provenance verdict (platform + watermark inventory + confidence); `--json` for machine output, `--no-visible` to skip the cv2 sparkle detector
- `uv run remove-ai-watermarks metadata <image.png> --check` — inspect AI metadata (C2PA, EXIF, PNG chunks)
- `uv run remove-ai-watermarks metadata <image.png> --remove -o <out.png>` — strip all AI metadata
- `uv run remove-ai-watermarks batch <directory>` — process every supported image in a directory (output defaults to `<directory>_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the **full `invisible` knob set above** (`--strength`/`--steps`/`--guidance-scale`/`--pipeline`/`--controlnet-scale`/`--model`/`--device`/`--max-resolution`/`--min-resolution`/`--upscaler`/`--seed`/`--hf-token`/`--humanize`/`--unsharp`/`--adaptive-polish`), plus `--inpaint/--no-inpaint` for the visible pass. `--adaptive-polish` is ON by default; `--auto` is deprecated and a no-op that only warns. One engine cached per pipeline; the polish is resolved once before the loop.
- `uv run remove-ai-watermarks batch <directory>` — process every supported image in a directory (output defaults to `<directory>_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the full `invisible` knob set above, plus `--backend` and `--sensitivity` for the visible localize -> fill pass. Applies the same no-signal skip per image; see the module doc. **Exit code:** non-zero when any image errored OR (mirroring single `all`) a `--mode invisible`/`all` image carried an invisible signal but the GPU extra was absent, so its SynthID scrub was skipped — it emits a loud warning and copies the input through (invisible mode) so the output dir stays complete; a wrapping service can then detect the incomplete run instead of trusting a silent exit 0.
## Test and lint
- **CI** (`.github/workflows/test.yml`): runs on push to `main` + every PR. A `lint` job (ubuntu: `ruff check` + `ruff format --check`) plus a `test` matrix (ubuntu/macos/windows x py3.10/3.12) that does `uv sync --frozen --extra dev` then `pytest`. The matrix installs only core + dev (no `gpu` extra), so the GPU/model-running tests skip there and it exercises the metadata/identify/visible/cv2-eraser surface on all three OSes. Keep `uv.lock` valid (don't break `--frozen`) when editing `pyproject.toml`.
- **Release flow + distribution channels** (PyPI publish via `publish.yml`/`uv publish`, the automated Homebrew-tap + HF-Space bumps in `distribute.yml`, conda-forge, ComfyUI Registry, the sdist `data/` exclusion, hatchling pin history): see `docs/release-and-distribution.md` before cutting a release.
- `bash maintain.sh` — uv-outdated, uv-secure, ruff check/fix, ruff format, pyright (scoped `src/`, see the OOM note below), pytest -n auto. The helper tools live in the `dev` extra (`pytest-xdist`, plus `uv-outdated`/`uv-secure` marker-gated to py3.12+ so the py3.10 resolution stays solvable) — a bare env without `--extra dev` does not have them.
- **Strict pyright is clean across `src/` (0 errors).** The cv2/torch/diffusers boundary files (`gemini_engine`, `region_eraser`, `doubao_engine`, `humanizer`, `invisible_engine`, `noai/watermark_remover`) carry a documented per-file `# pyright:` relax pragma that turns off only the unknown-type / untyped-third-party rules — those libs ship no usable types, so strict typing there fights the ecosystem. Pure-logic files stay fully strict; `typings/piexif/__init__.pyi` is a local stub so `metadata.py`/`extractor.py` resolve piexif. Public ndarray-returning signatures on the relaxed engines are still annotated `NDArray[Any]` so strict consumers (`cli.py`) stay clean. When touching a relaxed file, prefer fixing real issues over widening the pragma; keep the pragma scoped to genuinely-untyped boundaries. (`uv-secure` is clean since idna was bumped 3.11 -> 3.16, fixing GHSA-65pc-fj4g-8rjx, and aiohttp 3.13.5 -> 3.14.0 via `uv lock --upgrade-package aiohttp`, fixing GHSA-hg6j-4rv6-33pg + GHSA-jg22-mg44-37j8. (The old basicsr Dependabot alert (GHSA-86w8-vhw6-q9qq) is resolved by removal: the experimental `restore` extra was retired and basicsr is no longer anywhere in the dependency tree.) The torch Dependabot alert **GHSA-rrmf-rvhw-rf47** (`torch.jit.script` memory corruption, vulnerable `<= 2.12.0`) is **dismissed as `not_used`** (2026-06-10): torch is a transitive dep of the optional `gpu` extra only, the codebase never calls `torch.jit` (grep-verified), and **no patched torch version exists** (`first_patched_version` is null), so it cannot be closed by an upgrade — do not re-triage it.
- **Strict pyright is clean across `src/` (0 errors).** The cv2/torch/diffusers boundary files (`gemini_engine`, `region_eraser`, `doubao_engine`, `humanizer`, `invisible_engine`, `noai/watermark_remover`) carry a documented per-file `# pyright:` relax pragma that turns off only the unknown-type / untyped-third-party rules — those libs ship no usable types, so strict typing there fights the ecosystem. Pure-logic files stay fully strict; `typings/piexif/__init__.pyi` is a local stub so `metadata.py`/`extractor.py` resolve piexif. Public ndarray-returning signatures on the relaxed engines are still annotated `NDArray[Any]` so strict consumers (`cli.py`) stay clean. When touching a relaxed file, prefer fixing real issues over widening the pragma; keep the pragma scoped to genuinely-untyped boundaries. The `uv-secure` CVE-resolution history (idna/aiohttp bumps, retired basicsr, the dismissed torch `GHSA-rrmf-rvhw-rf47`) lives in `docs/release-and-distribution.md` — read it before re-triaging a dependency alert.
- **Full-project `uv run pyright` (no path) OOMs/crashes node on this ML-heavy repo** (emits a `libnode` stack frame, no summary) — a known environment limit, not a code error. Gate with `uv run --extra dev --extra gpu pyright src/` (completes, authoritative) or scope to changed files; also run `uv run ruff check` and `uv run pytest` directly.
- 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
@@ -51,20 +53,23 @@ Consequences for contributors (do not drift back into the stock niche just becau
Compact map. The full per-module detail (design decisions, tuned thresholds, calibration history, incident records, and the regression-guard map) lives in `docs/module-internals.md` — **read the relevant section there before changing any module below.**
- `noai/c2pa.py` — C2PA reading. `extract_c2pa_info(path)` uses the official **c2pa-python `Reader`** first (core dep, any container; `read_manifest_store_json` returns the WHOLE store JSON — active + ingredient manifests — so an AI marker on a parent manifest is seen), and falls back to the hand-rolled caBX/CBOR parser (`has_c2pa_metadata` / `extract_c2pa_chunk` / `_extract_c2pa_info_png`) for synthetic/partial blobs the validator rejects or a broken/absent wheel. The registry scan (issuer / source-type / SynthID / soft-binding) is shared by both paths via `_populate_registry_fields`, so the return-dict shape is identical. Do not reimplement chunk parsing; chunk reads are clamped to the remaining file size by design. `extract_c2pa_chunk`/`inject_c2pa_chunk` stay PNG-only (raw caBX bytes, test/extractor use).
- `noai/constants.py` — the single `C2PA_AI_VENDORS` registry (+ `C2PA_SOFT_BINDINGS`) from which `C2PA_ISSUERS` / `SYNTHID_C2PA_ISSUERS` / `identify._ISSUER_PLATFORM` are all derived. Add a new vendor as one registry entry; never edit the derived dicts and never add inline.
- `metadata.py` — `scan_head(path)` is the shared (memoized) input for every C2PA/AIGC/IPTC byte scan; use it instead of `open().read(1MB)` for any new marker scan. Also home to `synthid_source`, `xai_signature`, `iptc_ai_system`, `aigc_label`, `huggingface_job`, `samsung_genai`, and `remove_ai_metadata` (fail-safe `strip_c2pa_boxes`).
- `identify.py` — aggregates every locally-readable signal into one `ProvenanceReport`; `is_ai_generated` is True or None, never asserted False. `import identify` is deliberately light (lazy `noai/__init__`, fits a 512 MB host) — keep heavy imports out. Add capture-camera tokens to `_DEVICE_C2PA_PLATFORM` only when verified against a real C2PA file; editing-app/AI-device signer tokens go to `_SIGNER_C2PA_PLATFORM`; generator/issuer platforms to `C2PA_AI_VENDORS` in `constants.py`. Integrity-clash detection is high-precision by design (only hard generator stamps feed it, source-grouped independence).
- `watermark_registry.py` — the single catalog of known visible watermarks (gemini / doubao / jimeng / samsung), reverse-alpha based by policy. Add a new visible text mark = one `_text_mark(...)` row + a `TextMarkConfig` with a captured alpha map; do not re-add per-mark `if` branches. `cli._write_bgr_with_alpha` must NOT zero alpha in the watermark bbox (issue #30 white-box regression).
- `gemini_engine.py` — visible Gemini-sparkle remover/detector (cv2/numpy, no GPU): top-K size-weighted fusion candidate selection (`_SELECT_TOPK`), corner-promote, over/under-subtraction guards, false-positive gate, self-verify repair. Detection scores the top-K size-weighted matches by full fusion (spatial+gradient+variance) and keeps the highest — NOT the raw-NCC argmax, which re-admits the tiny-patch FPs the size weight suppresses (the osachub 2026-06-12 sub-0.85 corner-sparkle regression; see `docs/module-internals.md`). Keep the 0.85 corner-promote NCC gate; a margin/chroma-gated lower promote was measured and REJECTED 2026-06-11 (~33% FP on non-Google content). Gate any removal candidate on a physical brightness check, not the detector alone.
- `_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.
- `noai/constants.py` — the single `C2PA_AI_VENDORS` registry (+ `C2PA_SOFT_BINDINGS`) from which `C2PA_ISSUERS` / `SYNTHID_C2PA_ISSUERS` / `C2PA_IDENTITY_AI_ORGS` / `identify._ISSUER_PLATFORM` are all derived. Add a new vendor as one registry entry; never edit the derived dicts and never add inline. A vendor's `asserts_ai=True` flag means its mere presence asserts AI generation even without a `trainedAlgorithmicMedia` digital-source-type (a pure-generator brand with a distinctive issuer/generator string, e.g. **Dreamina** — ByteDance's international Jimeng brand, signed as "Bytedance Pte. Ltd." with a "Dreamina/x.y" claim generator and no source-type); NEVER set it for common-word issuers (Adobe/Google/OpenAI/Microsoft) that appear incidentally in unrelated bytes — those stay source-type-gated in `identify._attribute_platform`.
- `metadata.py` — `scan_head(path)` is the shared (memoized) input for every C2PA/AIGC/IPTC byte scan; use it instead of `open().read(1MB)` for any new marker scan. Also home to `synthid_source`, `xai_signature`, `iptc_ai_system`, `aigc_label`, `huggingface_job`, `samsung_genai`, and `remove_ai_metadata` (fail-safe `strip_c2pa_boxes`). **`remove_ai_metadata` is the SINGLE metadata stripper** (the legacy PIL-re-encoding `noai/cleaner` was deleted; the diffusion core and the public `noai.remove_ai_metadata` re-export now point here). It strips **losslessly** per container: ISOBMFF (HEIC/AVIF/MP4) blanks tokens / strips boxes in place; **JPEG uses `_strip_jpeg_metadata_lossless`** — a marker-segment walk that drops the AI-bearing APP segments (C2PA APP11; XMP APP1 carrying C2PA, a China-AIGC token, OR an IPTC `digitalSourceType` / 2025.1 AI-disclosure marker; IPTC-IIM APP13) and scrubs AI EXIF tags via piexif, copying the entropy-coded scan verbatim so **the pixels are bit-identical** (no DCT re-encode). **Detection<->removal parity across every marker placement is load-bearing** — anything a scanner flags, the strip must reach, or a re-served file still reads as AI: (a) the APP1-XMP branch of `_jpeg_app_carries_ai` checks the IPTC marker sets too, not only C2PA/AIGC (the Instagram/MidJourney/Meta "Made with AI" `digitalSourceType` lives in XMP, not the APP13 IIM record); (b) a bare `AIGC{...}` / `{"AIGC":{...}}` block in a NON-standard APP segment (near JFIF on some China gens) is dropped via `_is_aigc_exif_value`, not only the APP11/APP1/APP13 cases; (c) the China TC260 `{"AIGC":{...}}` block in EXIF `UserComment`/`ImageDescription` is scrubbed by `_scrub_ai_exif` (Doubao producer + Tencent service-provider schemas); (d) the Samsung Galaxy AI `PhotoEditor_Re_Edit_Data` trailer past the JPEG EOI is truncated by `_strip_samsung_trailer` (and `samsung_genai` reads the file tail so a multi-MB photo's trailer past the 512 KB quick-scan window is still DETECTED). Pixels stay bit-identical throughout, so a `--strip-metadata` on a q100 removal output does NOT crush it back to q75; PNG/WebP re-saves are pixel-lossless (WebP written at cv2 lossless mode, quality 101 — quality 1-100 is lossy). **`remove_ai_metadata` is fail-safe on an undecodable image:** a truncated/corrupt file (PIL raises `OSError` decoding it; ~0.2% of real uploads) is copied through UNCHANGED rather than crashing a direct library caller (a web worker would 500 on a partial upload), mirroring `strip_c2pa_boxes` — we cannot strip what we cannot parse, but we never raise. Regression: `tests/test_metadata.py::TestHasAiMetadata::test_remove_ai_metadata_failsafe_on_truncated_png`. Regression: `tests/test_metadata.py::TestHasAiMetadata::{test_jpeg_metadata_strip_is_pixel_lossless, test_jpeg_strip_removes_iptc_marker_in_xmp}`, `TestSamsungGenai::{test_remove_strips_post_eoi_trailer, test_detects_trailer_past_scan_window}`, the AIGC-EXIF/bare-APP removal tests, and `tests/test_noai.py::TestISOBMFF::{test_blank_aigc_block_in_exif, test_blank_xai_signature_pair_in_exif}`. `exif_generator` matches a VALUE against `AI_GENERATOR_TOKENS` across EXIF `Software`/`Make`/`Artist`/`ImageDescription`, XMP `CreatorTool`, AND PNG `tEXt` chunks (`Software`/`Source`/`Title`/`Description` — NovelAI stamps there, not EXIF). **Detection and removal must stay in parity:** a generator that stamps an AI-shaped VALUE under a non-AI KEY (NovelAI's `Title`/`Source`) is dropped on removal by `_is_ai_value` (value-token match, mirrors `exif_generator`), NOT by `_is_ai_key` alone — else the cleaned file still reads as that generator. Add a new no-C2PA generator = one `AI_GENERATOR_TOKENS` entry (use a distinctive token, e.g. `reve.com` not bare `reve`); detection and removal then both follow. Regression: `tests/test_metadata.py::TestExifGenerator::{test_novelai_png_text_chunk_detected,test_novelai_removal_parity}`.
- `identify.py` — aggregates every locally-readable signal into one `ProvenanceReport`; `is_ai_generated` is True or None, never asserted False. `ProvenanceReport.ai_source_kind` exposes the C2PA digital-source-type split — `"generated"` (trainedAlgorithmicMedia, fully AI) vs `"enhanced"` (compositeWithTrainedAlgorithmicMedia, a real photo with an AI-composited region), else None — so a caller branches full-frame scrub vs region-targeted clean (see `noai/tiling.feather_region_composite` + `WatermarkRemover.remove_watermark(region=...)`). The sparkle provenance threshold is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (imported, not a private copy) so the provenance "is there a sparkle" verdict and the removal "take the sparkle" decision can never drift. `import identify` is deliberately light (lazy `noai/__init__`, fits a 512 MB host) — keep heavy imports out (the `watermark_registry` constant import stays light: engines are lazy there). Add capture-camera tokens to `_DEVICE_C2PA_PLATFORM` only when verified against a real C2PA file; editing-app/AI-device signer tokens go to `_SIGNER_C2PA_PLATFORM`; generator/issuer platforms to `C2PA_AI_VENDORS` in `constants.py`. The IPTC `digitalSourceType` **`algorithmicMedia`** (bare) is PROCEDURAL (an algorithm not trained on sampled data), NOT AI/ML generation, so it is deliberately absent from `IPTC_AI_MARKERS` — flagging it made `identify` assert AI + `has_invisible_target` True, scrubbing clean procedural content (it is a distinct token from `trainedAlgorithmicMedia`, so real "Made with AI" labels are unaffected; regression `test_metadata.py::...test_bare_algorithmic_media_not_flagged_ai`). Integrity-clash detection is high-precision by design (only hard generator stamps feed it, source-grouped independence). `_vendor_of` normalizes ByteDance/Canva/ElevenLabs/Black Forest Labs (as well as OpenAI/Google/... ) so their C2PA claims participate in the clash check; the generic **China TC260 AIGC label names no specific vendor**, so when a TC260-applying vendor (ByteDance, `_TC260_VENDORS`) is co-attributed the label is attributed to it (a legit Doubao image carrying its own TC260 label must NOT clash), while a NON-TC260 vendor next to a TC260 label still clashes as a laundering tell. Corpus-validated: adding the vendors introduced 0 new clashes on 5000 carriers.
- `watermark_registry.py` — the single catalog of known visible watermarks (gemini / doubao / jimeng / samsung / jimeng_pill). **Removal is LOCALIZE -> FILL for every mark:** each mark is localized to a binary full-frame footprint mask (a `Localization`), then ONE shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). Reverse-alpha (the old `original = (wm - a*logo)/(1-a)` inversion of a captured alpha map + thin residual inpaint) is GONE for ALL marks; why it was dropped is recorded in `docs/module-internals.md`. Backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. The captured alpha maps (`scripts/visible_alpha_solve.py`) are still used to DETECT the marks and to shape the mask, but NOT for pixel recovery. **`--mark auto` removes EVERY detected mark in one pass** via `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` (marks coexist -- a Jimeng-basic image has the top-left pill AND the bottom-right wordmark; a single-strongest pick would leave one). **Three orthogonal axes:** `backend` (the fill), `sensitivity` (how hard to trust a borderline mark: `auto`/`strict`/`assume_ai`, see the `Sensitivity` literal), and `provenance` (vendor keys metadata confirms -- the evidence that drives `auto`). **Perception / decision / action are separated:** `_build_candidates(image)` runs every detector at BOTH trust levels (strict + relaxed) and packages raw verdicts + features into `Candidate`s (no policy); the pure arbiter `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` makes every keep/drop call (per-mark `resolve_relax` + the pill gate) with no image/IO, so it is unit-testable in isolation; then each winner is localized -> filled. Do NOT put policy back into the engines (the one exception, the Gemini FP gate, stays in `gemini_engine` because `identify` shares that confidence). `detect_marks(..., provenance=frozenset())` stays strict (identify verdict, precision over recall); `KnownMark.remove/detect/localize(..., provenance: bool)` take the already-resolved boolean. **How auto/assume decide (this is metadata-INDEPENDENT for recall):** the visual detectors are pixel-based and need no metadata; the recall gain comes from RELAXING the false-positive gate, not from metadata. `strict` never relaxes (clean images untouched); `auto` relaxes a mark only on same-product evidence -- metadata provenance for that vendor OR a confidently detected sibling mark of the SAME product (`_PRODUCT_OF`; Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax); `assume_ai` relaxes every mark (the caller asserts AI -- a metadata-stripped screenshot uploaded to a remover). Corpus finding: Gemini sparkle removal on Google-C2PA images is ~46% under `strict`/metadata-free `auto` and ~92% under `assume_ai` (recovering marks the vendor moved or re-rendered); the library CANNOT infer AI from a stripped image, so only the caller's `assume_ai` reaches the high recall there. A wrong relaxation just fills a small corner near-losslessly (the localize -> fill benign failure mode), which is what makes `assume_ai` acceptable. Metadata provenance mapping (feeds `auto`, read by `cli._visible_provenance`): Google/Gemini C2PA issuer -> gemini; China-AIGC (TC260) label -> doubao/jimeng; `samsung_genai` -> samsung. **The `jimeng_pill` is CAPTURE-LESS** (`pill_engine.py`): the top-left "AI生成" label has no captured alpha map, so it is detect-by-synthetic-silhouette; its footprint is a fixed top-left geometry box. Its weak edge-NCC detector (~7% raw false-fire) is gated in `remove_auto_marks` via **`_keep_pill`** (32k real-upload corpus validation 2026-07): the pill never rides on a **Doubao** detection, and has confirmation arms because metadata/intent confirms the platform, not pill presence. **(1) Bottom-right "★ 即梦AI" wordmark fired** — ~94% precise and survives **metadata-STRIPPED uploads** (screenshots / re-saves, ~61% of pills carry a detectable wordmark): remove **unrestricted**. **(2) TC260 metadata confirms Jimeng** (`"jimeng" in provenance`, no wordmark) **OR the caller asserts AI** (`sensitivity == "assume_ai"`) — the metadata-only arm is only ~27% precise and its false fires are **textured ceilings/walls that the fill visibly SMEARS**, so remove **only when the top-left footprint is flat enough for an invisible fill** (`pill_engine.footprint_is_flat`, median-Sobel texture ≤ `_FLAT_TEXTURE_MAX`) — the flatness guard holds even under `assume_ai`. This keeps real flat-scene pills (incl. metadata-only ones the wordmark misses) plus harmless flat false fires, and leaves the damaging textured false fires untouched. Do NOT drop the wordmark arm or loosenLine truncated
- `gemini_engine.py` — visible Gemini-sparkle detector + localizer (cv2/numpy, no GPU): top-K size-weighted fusion candidate selection (`_SELECT_TOPK`), corner-promote, false-positive gate (the provenance prior relaxes the gate + lowers the trust threshold when a Google/Gemini C2PA issuer confirms the vendor). **White-core rescue:** the FP gate demotes a low-gradient match (soft edges), but a real FAINT sparkle also has soft edges -- so the gate keeps a low-grad match that is a strong (conf ≥ `_SPARKLE_KEEP_CONF` 0.52), bright (margin), near-WHITE-core sparkle (`_core_saturation` ≤ `_SPARKLE_WHITE_SAT` 0.20): a real sparkle core is white, a clean bright corner that shape-matches (sky/sun) is colored. This recovers ~14/20 metadata-stripped faint sparkles under the DEFAULT strict/auto (no flag, no metadata) at ~1.25% clean false-fire (baseline 0.55%); the ~0.51-scoring bright-bg FPs stay demoted (below 0.52). A learned classifier on the SAME features was measured WORSE than the tuned gate (2026-07 tier-1: MLP 86.7% recall vs 90.8% at equal FP), so the heuristic stays; a patch-CNN with richer features is the only lever left (roadmapped P2, low expected value -- the wall is fundamental). Detection scores the top-K size-weighted matches by full fusion (spatial+gradient+variance) and keeps the highest — NOT the raw-NCC argmax, which re-admits the tiny-patch FPs the size weight suppresses (the osachub 2026-06-12 sub-0.85 corner-sparkle regression; see `docs/module-internals.md`). Keep the 0.85 corner-promote NCC gate; a margin/chroma-gated lower promote was measured and REJECTED 2026-06-11 (~33% FP on non-Google content). Removal is localize -> fill: `footprint_mask` returns the sparkle footprint (the captured alpha thresholded LOW so the faint halo is included, then dilated by a sparkle-relative margin), and the shared `watermark_registry.fill` inpaints it. The captured alpha maps are used only to detect and to shape the mask, not for pixel recovery. `detect_sparkle_confidence` reuses a process-wide `_shared_engine()` singleton (lru_cache) — the engine holds only constant assets (captures, alpha maps, a precomputed 16..118 template ladder) and takes the image as an arg, so do NOT reconstruct `GeminiEngine()` per call: that reloaded assets + recomputed alpha maps + rebuilt the template cache on every one of ~34k `identify` calls (−24% on the sparkle path once made a singleton, output byte-identical). `detect_watermark`/`footprint_mask` guard `image.size == 0` before `to_bgr`, and return an empty (detected=False) result when no template scale fits (short side < 16 px), rather than dereferencing an empty candidate list.
- `_text_mark_engine.py` — shared base for the three text-mark engines (extracted 2026-06-09); the per-engine modules are config-only subclasses. Detection still matches the glyph silhouette (NCC, keys on glyph shape). The removal mask is TEMPLATE-FREE: it is the bounding box of the top-hat glyph blob (`extract_mask`), filled solid + dilated, so the shared fill inpaints the whole wordmark rectangle. This drops the fixed alpha-template placement, so a re-rendered or differently-placed mark is still masked; the captured alpha maps are now used only for the detection silhouette, not for removal. New text mark = a `TextMarkConfig` + a thin subclass + one registry row. Gemini stays a separate engine (different model).
- `pill_engine.py` — the CAPTURE-LESS Jimeng-basic "AI生成" pill (top-left, issue #54). No alpha map: `detect` is edge-NCC of a synthetic font-rendered silhouette (`assets/jimeng_pill.png`, regenerate via `scripts/render_pill_silhouette.py`; committed, data-safe -- corpus stays out of the repo) in the top-left ROI, calibrated on 61 local real positives to threshold 0.22; `footprint_mask` is a generous FIXED top-left geometry box (NOT the NCC match position -- the synthetic silhouette localizes only approximately, the corner is negative space, so a geometry box fills cleanly while a match box leaves outline residue). `footprint_texture`/`footprint_is_flat` (median-Sobel over that box, `_FLAT_TEXTURE_MAX`) back the metadata-only safe-fill gate. Removal is the shared localize -> fill (MI-GAN/cv2). Detector precision is weak (~7% raw false-fire), so it is registry-gated in `remove_auto_marks` via `_keep_pill`: never on Doubao; the bottom-right wordmark removes it unrestricted (~94% precise, survives metadata-STRIPPED uploads); TC260-metadata-only removes it ONLY on a flat footprint (its textured false fires -- ceilings/walls -- are what the fill smears). Do NOT loosen those gates.
- `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). Detection matches the glyph silhouette (NCC); removal localizes the glyph blob to a solid dilated box (`extract_mask`) and hands it to the shared fill. Corpus validation: doubao and jimeng localize + remove at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit); clean images with no vendor signature had 0% false removal. **Samsung detection is calibrated only for the Italian "Contenuti generati dall'AI" string** (a pre-existing limit, unchanged by the localize -> fill refactor but now surfaced because detection gates removal): non-Italian Samsung locales are not detected, and thus not removed, even though the fill mask itself is locale-independent; other locales need their own detection silhouette (the locale string font-rendered + calibrated on real positives), NOT an app capture.
- `region_eraser.py` — universal region eraser (`erase` CLI) and the shared fill backend behind `watermark_registry.fill` for the visible localize -> fill removal. Three backends: `cv2` (default, no deps, the floor), `migan` (MI-GAN ONNX, extra `migan`, MIT, ~28 MB / ~0.19 s — the droplet-friendly tier, **the preferred default fill** when the extra is installed), `lama` (big-LaMa ONNX, extra `lama`, ~200 MB / ~4.7 GB peak — best quality, does not fit a minimal droplet, explicit opt-in only). Both `migan` and `lama` **crop a padded region around the mask** before inference and paste only masked pixels back, so peak RAM is bounded by the MARK size, not the image (`migan` ~0.6-0.9 GB regardless of upload size — feeding the whole frame scaled it to ~2.4 GB at 25 MP; `migan` feeds the crop at native resolution, `lama` resizes to its fixed 512²). **MI-GAN mask polarity is INVERTED** (0=hole/255=known) vs this package's 255-erase convention; `erase_migan` inverts before feeding the model (feeding 255=hole regenerates the whole frame into stripes — corpus-validated). Both ONNX models download on first use, never bundled. The `erase` command keeps its own `--backend`/`--inpaint-method` (unchanged).
- `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).
- `noai/watermark_remover.py` — `WatermarkRemover` with three diffusion pipelines selected by the explicit `pipeline` ctor arg, never inferred from `model_id`: `sdxl` (plain SDXL img2img), `controlnet` (SDXL + canny ControlNet, **the DEFAULT since 2026-06-09**), and `qwen` (Qwen-Image 20B MMDiT img2img, Apache-2.0, CUDA/cloud-class — best **text** preservation (incl. CJK); `_load_qwen_pipeline`/`_run_qwen`, bf16, no MPS fallback; call shape in the pure `_build_qwen_kwargs` using `true_cfg_scale`). Removal comes from the img2img `strength`; ControlNet only preserves text/face STRUCTURE — SynthID CAN survive controlnet on photoreal content at low strength. Qwen CERTIFIED oracle floors (2026-06-20): OpenAI **0.10** (seed-robust, clean on seeds 0-4), Gemini **0.25** (seed 0 verified, pin a seed — Gemini oracle rate-limits volume; higher than the controlnet Gemini floor 0.15). `resolve_strength(..., pipeline="qwen")` carries the Qwen ladder (`_QWEN_VENDOR_STRENGTH`), so `--pipeline qwen` gets the 0.25 Gemini floor automatically (the old manual `--strength 0.25` workaround is retired). `_build_qwen_kwargs` passes an explicit `height`/`width` from the input (floored to /16 via `_qwen_target_size`) — without it the pipeline defaults to a 1024x1024 SQUARE and silently squishes non-square inputs (fixed 2026-06-20). **`qwen` is a MANUAL opt-in only — there is NO auto-router.** Measured (`scripts/fidelity_metrics.py`, OCR-CER / ArcFace / LPIPS / Laplacian-var, NOT eyeball): qwen beats controlnet on ONE niche only — **clean body text on a plain background, no faces** (openai_1/2 CER 0.241 vs 0.385). controlnet wins FACES (it always has) AND **display/decorative text in a scene** (abba poster: controlnet CER 0.114 vs qwen 0.379 — canny holds letter shapes, qwen re-renders and garbles them). So a content `--pipeline auto` router and a faces+text **mixed dual-pass** were prototyped and **DROPPED** (2026-06-20): on the canonical faces+text case controlnet wins every metric incl. text, so mixed loses; and "text→qwen" can't be auto-decided (it is body-vs-display text that matters, undetectable cheaply). qwen stays for callers who KNOW their content is clean-text-heavy and face-free. No face-restore extra ships, by validated decision (every restore approach looked MORE AI-generated). `remove_watermark(region=(x,y,w,h), region_feather=...)` runs the regeneration but feather-composites only the AI box back over the original (via `noai/tiling.feather_region_composite`), preserving the real photo elsewhere — the **AI-enhanced composite** path (`identify` `ai_source_kind == "enhanced"`); the box is supplied by the caller (a C2PA composite manifest carries no reliable machine-readable region, so we do not fabricate one).
- `noai/tiling.py` — sliding-window tiled diffusion for large inputs (CLI `--tile`). `WatermarkRemover.remove_watermark` branches to `run_tiled` when `tile` is set AND the long side exceeds `tile_size`, refactoring the single-pass `_generate` into a per-tile `_generate_one` (the ControlNet edge map is rebuilt per tile inside it). Pure helpers `plan_tiles` (uniform-size tiles, last one flush to the edge) and `feather_weights` (strictly-positive separable taper -> partition-of-unity blend) are unit-tested without the model. Also home to `feather_region_composite(base, regenerated, box, *, feather)` — the pure region-targeted compositor for **AI-enhanced composites** (`ai_source_kind == "enhanced"`): blends the regenerated AI box back over the original with a feathered seam, leaving the real photo OUTSIDE the box pixel-exact. It backs `WatermarkRemover.remove_watermark(region=...)` (regenerate ONLY the AI region, not the whole frame); the no-model lossless region path stays `region_eraser.erase`. New tile/region-blend tuning goes in these pure helpers; do not inline blend math into the runner.
- `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).
- `upscaler.py` — optional Real-ESRGAN pre-diffusion super-resolution for small inputs (extra `esrgan`, spandrel only). Manual opt-in; the default `--upscaler` stays `lanczos` and the engine always falls back to Lanczos on absence/error. ESRGAN can degrade faces and thin text.
- `image_io.py` — Unicode-safe cv2 IO (issue #17). Every cv2 file read/write in the package routes through `imread`/`imwrite`; do not call `cv2.imread`/`cv2.imwrite` directly. `to_bgr(image)` is the shared channel normalizer — use it instead of inlining `cvtColor` branches.
- `image_io.py` — Unicode-safe cv2 IO (issue #17). Every cv2 file read/write in the package routes through `imread`/`imwrite`; do not call `cv2.imread`/`cv2.imwrite` directly. `to_bgr(image)` is the shared channel normalizer — use it instead of inlining `cvtColor` branches. `read_bgr_and_alpha`/`write_bgr_with_alpha` (+ `ALPHA_FORMATS`) are the alpha-preserving IO helpers shared by the CLI and the library `api` (moved here from cli so both use ONE implementation; the write MUST NOT zero alpha in the mark bbox — issue #30 white box). cv2/numpy import lazily, so importing `image_io` is cheap. **`imread` has a Pillow fallback (`_pil_read`) for HEIC/AVIF**: cv2 can't decode those containers, so when its decode returns None it opens via Pillow (AVIF native; HEIC via the core `pillow-heif` dep, whose libheif also covers AVIF) and converts to the same BGR/BGRA layout the flags imply — so the pixel/removal path reads iPhone HEIC and AVIF, not just the metadata path. Normal PNG/JPEG/WebP never reach the fallback. Corpus-verified: 54/55 HEIC+AVIF now decode (the 1 miss is a truncated upload). **`imwrite` PRESERVES the input format at max quality** ("work with originals"): the removal only touches the mark footprint (cv2 AND MI-GAN fills composite over the original — untouched pixels are bit-exact), so the container re-encode must not degrade the rest. JPEG is written at quality 100 / 4:4:4 (no chroma subsampling) — PSNR ~55 dB vs the old default-95's ~48; HEIC/AVIF write via Pillow (`_pil_write`) since cv2 has NO encoder for them (writing `.heic` via cv2 RAISES — a HEIC input used to crash on save). `imwrite` never raises (catches `cv2.error`). **`api.remove_visible` copies the original bytes verbatim on a no-op** (nothing removed + same output format) rather than a lossy re-encode, so a clean image round-trips byte-identical. `noai/constants.SUPPORTED_FORMATS` now includes `.heic`/`.heif`/`.avif` alongside png/jpg/jpeg/webp (pillow-heif is core, so read+write both work), so `batch` discovers them and the CLI no longer warns on an iPhone HEIC; JPEG-XL stays OUT (metadata/strip-only, no pixel decoder without pillow-jxl). **The invisible/SynthID path is inherently a full-frame diffusion regeneration (every pixel changes by design — you cannot "work with originals" there), but it no longer piles gratuitous re-encodes on top:** `watermark_remover` saves the regenerated output through `image_io.imwrite` (not raw `PIL.save`, which defaults to JPEG q75), `invisible_engine` writes its pre-diffusion temp as lossless PNG (not a re-compressed copy of a JPEG input), and the output metadata strip goes through the byte-level `metadata.remove_ai_metadata` (see its bullet) which for JPEG does NOT re-encode the DCT at all — pixels stay bit-identical.
- `api.py` — the high-level convenience API, re-exported lazily at the package top level via `__init__.__getattr__` (PEP 562, so `import remove_ai_watermarks` stays cheap): `remove_visible(source, output=None, *, sensitivity="auto", backend="auto", strip_metadata=True, write_noop=True) -> (result_bgr, [labels])` (source = path OR BGR ndarray; a PATH auto-reads metadata provenance and preserves alpha, an ARRAY does neither; `write_noop=True` writes a clean passthrough copy when nothing is removed, `False` leaves `output` untouched so a "no mark = produce nothing" caller like the CLI `visible` command does not clobber a pre-existing file there) and `visible_provenance(path) -> frozenset[str]` (the single metadata→vendor-keys mapper; `cli._visible_provenance` is a thin None-guarded wrapper over it). **`remove_visible` is the ONE path the CLI and library share** — `cli.cmd_visible`'s `--mark auto` branch delegates entirely to it (read → provenance → `remove_auto_marks` → write → `strip_metadata`), so there is no CLI-vs-library drift; `strip_metadata` defaults True to match `visible --strip-metadata`. This is where a library caller should start — NOT the engines directly (`GeminiEngine`/`TextMarkEngine` have no `remove_watermark` any more; removal is registry `remove_auto_marks`/`KnownMark.remove`; the old single-strongest `best_auto_mark` is gone — removal takes EVERY mark). `identify` is NOT top-level re-exported (it collides with the `identify` submodule); use `from remove_ai_watermarks.identify import identify`.
For the Doubao alpha-distillation history (why content-image reverse-alpha distillation fails by physics and controlled captures were required), see `docs/research-doubao-distillation.md`.
@@ -76,14 +81,15 @@ Who embeds what (C2PA / IPTC / EXIF / TC260 AIGC / xAI signature / open and prop
Compact list. Full measurements, incident history, and oracle-validation runs live in `docs/known-limitations.md` — **read the relevant section there before changing the diffusion pipelines, strength defaults, resolution handling, or metadata coverage.**
- `invisible` processes at native resolution for inputs >= 1024px long side and auto-upscales smaller inputs to a 1024px floor (`--min-resolution 0` disables; `--max-resolution N` is an opt-in cap to bound GPU/MPS memory). MPS OOM is memory-tier dependent, not a hard limit: ~24 GB unified memory falls back to CPU (slow but weight-identical output), 32 GB runs native on MPS. The native-vs-cap-vs-floor decision lives in the pure helper `invisible_engine._target_size` — keep the logic there, unit-tested without the model.
- **Visible-mark fill quality is background/backend-dependent.** The fill only touches the mark footprint (no outside-box damage) and whether the mark is removed is fill-independent — cv2/MI-GAN/LaMa all strip the shape; only the recovered region's *quality* differs. Flat backgrounds: all clean (cv2 often crispest). Textured/regular-structured (fabric, grid): cv2 smears, MI-GAN can ghost/hallucinate, LaMa best. The old reverse-alpha recovered true pixels so it was sometimes cleaner on structure, but localize -> fill trades that for robustness (moved/re-rendered marks, no per-mark capture); `auto` = LaMa > MI-GAN > cv2 with a one-time cv2-fallback warning. Head-to-head vs v0.12.1 on the full visible set: doubao/jimeng identical (100%/100%), gemini strict coverage a few points lower (the metadata-stripped faint ones now mostly recovered by the default white-core rescue in the gemini FP gate; the residual via `assume-ai`), clearance ~98% both. Detail in `docs/known-limitations.md`.
- `invisible` processes at native resolution for inputs >= 1024px long side and auto-upscales smaller inputs to a 1024px floor (`--min-resolution 0` disables; `--max-resolution N` is an opt-in cap to bound GPU/MPS memory). MPS OOM is memory-tier dependent, not a hard limit: ~24 GB unified memory falls back to CPU (slow but weight-identical output), 32 GB runs native on MPS. The native-vs-cap-vs-floor decision lives in the pure helper `invisible_engine._target_size` — keep the logic there, unit-tested without the model. For large inputs that OOM, `--tile` is the **lossless** alternative to `--max-resolution`: sliding-window diffusion at native resolution, each tile near SDXL's 1024 training size, feather-blended over the overlap (`noai/tiling.py`). It only engages when the long side exceeds `--tile-size`; the geometry (`plan_tiles`) and the blend window (`feather_weights`) are pure and unit-tested (`tests/test_tiling.py`). Caveat: each tile is an independent low-strength regeneration, so at the certified removal strengths (0.20-0.30) tile drift is minimal but not zero; tiling is a memory workaround, not a quality upgrade over a single native pass.
- fp16 VAE black-output (issues #29/#41): the fp16-fixed SDXL VAE (`madebyollin/sdxl-vae-fp16-fix`) is swapped in for the default SDXL checkpoint on cuda/xpu fp16, plus a model-agnostic backstop that detects a degenerate (all-black) fp16 output and re-runs once in fp32. cpu/mps run fp32 and never reproduce the bug.
- Pyright first run is slow (2-3 min) due to ML deps (torch/diffusers/transformers stubs); full-project `uv run pyright` can stall for many minutes — scope it to changed files.
- A third-party PIL plugin autoload (e.g. an HEIF/AVIF plugin) can raise a non-OSError (`ModuleNotFoundError`), not `UnidentifiedImageError`, when opening a file. Code that opens user-supplied or unknown-format files should `except Exception`, not just `OSError`/`UnidentifiedImageError`.
- rich was dropped: the CLI + analysis scripts print plain text (`click.echo` / the `scripts/_plain_console.py` shim). `rich` is NOT a dependency — importing it breaks the core+dev CI sync; new scripts must use the shim. No Unicode glyphs / colors / progress bars in CLI output by design.
- AVIF/HEIF/JPEG-XL metadata detection is a binary scan; C2PA removal in those containers (and MP4/MOV/M4V) is `noai/isobmff.py`; non-ISOBMFF audio/video (WebM/MP3/WAV/FLAC/OGG) strips losslessly via ffmpeg on PATH. Still NOT built: an `Exif` meta-box *item* (needs `iinf`/`iloc` surgery) and Resemble PerTh audio detection (no presence/confidence flag exists).
- HEIC/AVIF are decodable on BOTH paths now: the pixel/removal path via the `image_io.imread` Pillow fallback (+ core `pillow-heif`), and metadata detection via a plugin-free binary scan. C2PA removal in those containers (and MP4/MOV/M4V) is `noai/isobmff.py`; JPEG-XL stays metadata/strip-only (Pillow can't decode it without `pillow-jxl`, not a dep). Non-ISOBMFF audio/video (WebM/MP3/WAV/FLAC/OGG) strips losslessly via ffmpeg on PATH. On the ISOBMFF path `remove_ai_metadata` routes to the container branch and never runs the JPEG `_scrub_ai_exif`, so `isobmff.blank_ai_exif_tokens` is the ONLY EXIF scrubber there and must stay in PARITY with it: it blanks **in place** (same-length space overwrite, piexif-validated so a coincidental II/MM run in pixels is ignored — no `iinf`/`iloc` surgery, mirrors `blank_ai_xmp_packets`) an AI-generator token in `Software`/`Make`/`Artist`/`ImageDescription`, the China TC260 `{"AIGC":{...}}` block in `ImageDescription`/`UserComment` (via `_is_aigc_exif_value`), AND the xAI/Grok `Signature:` + UUID-`Artist` pair — leaving camera/editor EXIF intact. Still NOT built: Resemble PerTh audio detection (no presence/confidence flag exists).
- **SynthID technical reference: `docs/synthid.md`** — primary-source-cited doc covering mechanism (post-hoc encoder/decoder pair, 136-bit payload at 512x512, pixel-space, model weights NOT modified), robustness numbers (arXiv:2510.09263: ~99.98% TPR@0.1%FPR across 30 transforms including JPEG/crop/resize/color/noise), removal attacks and forensic detectability (arXiv:2605.09203: all 6 attacks detectable at >98% TPR@1%FPR), detectability limits (no public decoder, metadata-proxy only), oracle scope, and adoption landscape. Read that doc first before adding notes here.
- **SynthID detection is metadata-only.** No local pixel detector is possible by design (Google's decoder is proprietary, trusted-testers only); we read the C2PA companion proxy, which goes quiet once metadata is stripped — a quiet proxy is not proof the pixel watermark is gone. The Gemini app "Verify with SynthID" is the ONLY valid SynthID oracle; `openai.com/verify` is scoped to OpenAI provenance and each vendor's oracle detects only its own content. SynthID survives JPEG re-encode, so GitHub issue attachments remain valid pixel-watermark test subjects. Every spectral/phase detection approach evaluated (reverse-SynthID, our own probes) works only on controlled solid fills, never on real content.
- **SynthID detection is metadata-only.** No local pixel detector is possible by design (Google's decoder is proprietary, trusted-testers only); we read the C2PA companion proxy, which goes quiet once metadata is stripped — a quiet proxy is not proof the pixel watermark is gone. Each vendor has its OWN oracle and it detects only that vendor's content: the Gemini app "Verify with SynthID" for Google, `openai.com/verify` for OpenAI. **Validate the OpenAI arm FIRST** — `openai.com/verify` is more accessible (fewer per-check restrictions) and the strongest automation candidate (Playwright / Chrome MCP); the Gemini flow is more manual. Ordering/throughput choice, not a substitution (see `docs/synthid.md`). SynthID survives JPEG re-encode, so GitHub issue attachments remain valid pixel-watermark test subjects. Every spectral/phase detection approach evaluated (reverse-SynthID, our own probes) works only on controlled solid fills, never on real content.
- **External AI-vs-real classifier models are out of scope** (decided 2026-05-24): per-generator, degrade off-distribution, and our own light SDXL pass would likely defeat them. Detection stays local + signal-based.
- **Default strength is VENDOR-ADAPTIVE, one ladder for BOTH pipelines** (since 2026-06-09): `resolve_strength(strength, vendor)` picks OpenAI **0.20** / Gemini **0.30** / unknown **0.30** when `--strength` is unset; explicit `--strength` always wins. Removal at low strength is content x pipeline dependent, and near-threshold removal is SEED-NON-DETERMINISTIC — pick a strength with margin and oracle-revalidate per content type. Certified controlnet floors (Modal cert 2026-06-04): OpenAI 0.20 (resolution-independent), Gemini 0.30 (only <= 1536px; native large Gemini needs ~0.35+ or a cap).
- **Default strength is VENDOR-ADAPTIVE, one ladder for BOTH pipelines** (since 2026-06-09): `resolve_strength(strength, vendor)` picks OpenAI **0.10** / Gemini **0.15** / unknown **0.15** when `--strength` is unset (the 2026-06-14 lowering from the 2026-06-04 cert floors of 0.20/0.30 — the single source of truth is `watermark_profiles.py`, and the full cert/lowering history is in `docs/known-limitations.md`); explicit `--strength` always wins. Removal at low strength is content x pipeline dependent, and near-threshold removal is SEED-NON-DETERMINISTIC — pick a strength with margin and oracle-revalidate per content type.
- **`controlnet` is the default pipeline**; `--pipeline sdxl` is the lighter opt-down. Neither pipeline clears all content at low strength (photoreal survives controlnet, flat graphics survive sdxl — the lever is higher strength). A removal-priority caller MUST oracle-validate strength across content types; prod recipe: controlnet + per-vendor floor + FIXED seed. Forensic-stealth caveat (arXiv:2605.09203): defeating the SynthID verifier is NOT forensic invisibility — removal-processed images are flaggable at >98% TPR@1%FPR.
+57 -37
View File
@@ -27,13 +27,13 @@ It does **not** target watermarks that protect someone else's paid or copyrighte
## Features
- **Visible watermark removal** — a registry of known marks in their usual places: the Gemini / Nano Banana sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, and the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-left, locale-specific). Each is removed by **reverse-alpha blending** against a captured alpha map (`original = (wm − α·logo)/(1−α)`), recovering the true pixels rather than inpainting a guess. The Gemini sparkle recovers cleanly on its own on bright backgrounds; it adapts the alpha to each image's sparkle opacity, so a more-opaque-than-captured sparkle is still fully removed (and on a dark background, where the fixed alpha would over-subtract and leave a dark spot, it automatically inpaints the small sparkle footprint instead); the Doubao, Jimeng, and Samsung text marks re-rasterize slightly per image, so a thin residual inpaint over the glyph footprint clears the leftover edges (the alpha maps are reproducibly rebuilt from controlled captures by `scripts/visible_alpha_solve.py`). Fast, offline, no GPU. `visible --mark auto` finds and removes the strongest detected mark. (For arbitrary logos/objects, see `erase`.)
- **Universal region eraser (`erase`)** — remove any logo / watermark / object inside boxes you specify, regardless of position or colour. Default cv2 inpainting (CPU, instant); optional big-LaMa via onnxruntime (`lama` extra) for higher quality
- **Visible watermark removal** — a registry of known marks in their usual places: the Gemini / Nano Banana sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, and the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-left, locale-specific). Each mark is **localized to a footprint mask, then filled**: the engine finds the mark, builds a binary mask over its footprint, and one shared, swappable fill inpaints that region. Choose the fill with `--backend`: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN, light, the memory-tight pick where LaMa will not fit), or `lama` (big-LaMa, best quality, heavier, auto-preferred when a learned backend is available); the default `auto` uses LaMa > MI-GAN > cv2, best available. The localizer is cheap CPU (cv2/numpy), so it runs anywhere; the heavier MI-GAN/LaMa fill is opt-in. Detection keys on each mark's own shape (NCC against a captured silhouette; the alpha captures rebuilt by `scripts/visible_alpha_solve.py` are used to detect and to shape the mask, not for pixel recovery). The visual detector needs no metadata, but a borderline (faint or moved) mark is only trusted with corroboration: `--sensitivity` (default `auto`) relaxes a mark's gate when local metadata confirms the vendor or a same-product sibling mark is found; `--sensitivity assume-ai` relaxes every mark on the assertion that the image is AI, recovering the moved or re-rendered marks the conservative gate skips on a metadata-stripped screenshot (`strict` never relaxes). `visible --mark auto` finds and removes every detected mark in one pass. Fast, offline, no GPU. (For arbitrary logos/objects, see `erase`.)
- **Universal region eraser (`erase`)** — remove any logo / watermark / object inside boxes you specify, regardless of position or color. Default cv2 inpainting (CPU, instant); optional big-LaMa via onnxruntime (`lama` extra) for higher quality
- **Invisible watermark removal** — SynthID, StableSignature, TreeRing via diffusion-based regeneration (needs a local GPU, or run it with no setup on [raiw.cc](https://raiw.cc))
- **AI metadata stripping** — EXIF, PNG text chunks, C2PA provenance manifests (PNG / JPEG / AVIF / HEIF / JPEG-XL, **MP4 / MOV / M4V / M4A** at the container level, and **WebM / MP3 / WAV / FLAC / OGG** losslessly via ffmpeg), XMP DigitalSourceType
- **"Made with AI" label removal** — removes the AI-disclosure metadata that platforms read to apply automatic labels (useful for clearing a false-positive label from a human-edited photograph)
- **Analog Humanizer** — optional film grain and chromatic aberration post-processing
- **Text and face preservation (default)** — the default pipeline is a canny ControlNet that keeps text and face structure sharp through the removal pass (without copying original pixels, so SynthID is still removed). Use `--pipeline sdxl` for plain SDXL img2img (lighter, no extra model download) on inputs without text or faces. Canny preserves face *structure*, not *identity* (the regenerated face drifts in likeness). The library does not ship a face-restore extra: every approach evaluated (GFPGAN-on-cleaned, PhotoMaker-V2, InstantID txt2img, InstantID img2img-on-cleaned) regenerated the face via SDXL and made the output look more AI-generated than the cleaned image. The cleaned controlnet output is the least-AI face state achievable without re-introducing SynthID.
- **Text and face preservation (default)** — the default pipeline is a canny ControlNet that keeps text and face structure sharp through the removal pass (without copying original pixels, so SynthID is still removed). Use `--pipeline sdxl` for plain SDXL img2img (lighter, no extra model download) on inputs without text or faces. An experimental `--pipeline qwen` runs Qwen-Image (20B, Apache-2.0) img2img, which preserves **text** (including CJK and small text) better than SDXL at equal strength; it is CUDA/cloud-class (does not fit MPS), and its strength floors are not yet certified (pass an explicit `--strength`, especially for Gemini content). Note: measured fidelity (`scripts/fidelity_metrics.py`) shows Qwen wins on text but controlnet preserves **faces** better (Qwen smooths skin more), so Qwen is not a universal upgrade. Canny preserves face *structure*, not *identity* (the regenerated face drifts in likeness). The library does not ship a face-restore extra: every approach evaluated (GFPGAN-on-cleaned, PhotoMaker-V2, InstantID txt2img, InstantID img2img-on-cleaned) regenerated the face via SDXL and made the output look more AI-generated than the cleaned image. The cleaned controlnet output is the least-AI face state achievable without re-introducing SynthID.
- **Batch processing** — process entire directories
- **Detection** — three-stage NCC watermark detection with confidence scoring
- **Provenance detection (`identify`)** — aggregate C2PA issuer, the C2PA soft-binding forensic-watermark vendor (Adobe TrustMark, Digimarc, Imatag, ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, embedded SD/ComfyUI params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the China TC260 AIGC label (XMP, PNG chunk, EXIF, or JPEG segment), the HuggingFace `hf-job-id` job marker, the SynthID metadata proxy, the C2PA cloud-manifest reference (Adobe Durable Content Credentials, when the embedded manifest is stripped), the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" / Samsung Galaxy AI "Contenuti generati dall'AI" text marks), the open SD/SDXL/FLUX invisible watermark, and (with the `trustmark` extra) the open Adobe TrustMark watermark into one origin-platform + watermark-inventory verdict (`--json` for machine output)
@@ -48,7 +48,7 @@ It does **not** target watermarks that protect someone else's paid or copyrighte
| AI model | Visible watermark | Invisible watermark | Metadata | Our approach |
| --- | --- | --- | --- | --- |
| **Google Gemini / Nano Banana / Gemini 3 Pro** | ✅ Sparkle logo | ✅ SynthID v1 + v2 (default SDXL pipeline, native resolution) | ✅ C2PA + EXIF | Alpha reversal + diffusion + metadata strip |
| **Google Gemini / Nano Banana / Gemini 3 Pro** | ✅ Sparkle logo | ✅ SynthID v1 + v2 (default SDXL pipeline, native resolution) | ✅ C2PA + EXIF | Localize + fill + diffusion + metadata strip |
| **OpenAI DALL-E 3 / ChatGPT** | — | — | ✅ C2PA manifest | Metadata strip |
| **OpenAI ChatGPT Images 2.0** (gpt-image-2) | — | ✅ SynthID + content-specific pixel watermark (since May 2026; no local decoder, openai.com/verify oracle) | ✅ C2PA manifest (verified) | Diffusion regeneration + metadata strip |
| **Stable Diffusion / SDXL (AUTOMATIC1111, ComfyUI)** | — | ✅ DWT-DCT (imwatermark — locally detectable) | ✅ PNG text chunks | Diffusion regeneration + metadata strip |
@@ -59,14 +59,14 @@ It does **not** target watermarks that protect someone else's paid or copyrighte
| **xAI Grok (Aurora)** | — | — | ✅ EXIF signature scheme (no C2PA): `Signature:` blob + UUID `Artist` | Detected (`identify`); metadata strip |
| **Midjourney** | — | — | ✅ EXIF + XMP (prompt, model, seed) | Metadata strip |
| **Meta AI** | — | — | ✅ IPTC "Made with AI" (digitalSourceType) | Metadata strip (removes the label) |
| **Doubao** (ByteDance) / China AIGC generators | ✅ "豆包AI生成" text strip (bottom-right) | — | ✅ TC260 AIGC label (`<TC260:AIGC>` XMP, `AIGC` PNG chunk, or EXIF JSON) **+ C2PA** signed by ByteDance Volcano Engine (`volcengine`) | Reverse-alpha (captured α map) + thin residual inpaint, NCC-aligned across resolutions, + metadata strip |
| **Jimeng / Dreamina** (即梦AI, ByteDance) | ✅ "★ 即梦AI" wordmark (bottom-right) | — | ✅ TC260 AIGC label + C2PA (Volcano Engine) | Reverse-alpha (captured α map) + residual inpaint over the glyph footprint, NCC-aligned across resolutions, + metadata strip |
| **Samsung Galaxy AI** (Generative Edit, Sketch to Image, ...) | ✅ "✦ Contenuti generati dall'AI" strip (bottom-left, locale-specific) | — | ✅ C2PA (signer "Samsung Galaxy") + `trainedAlgorithmicMedia` / proprietary `genAIType` marker | Reverse-alpha (captured α map) + thin residual inpaint, NCC-aligned across resolutions, + metadata strip |
| **Doubao** (ByteDance) / China AIGC generators | ✅ "豆包AI生成" text strip (bottom-right) | — | ✅ TC260 AIGC label (`<TC260:AIGC>` XMP, `AIGC` PNG chunk, or EXIF JSON) **+ C2PA** signed by ByteDance Volcano Engine (`volcengine`) | Localize glyph footprint + fill + metadata strip |
| **Jimeng / Dreamina** (即梦AI, ByteDance) | ✅ "★ 即梦AI" wordmark (bottom-right) | — | ✅ TC260 AIGC label + C2PA (Volcano Engine) | Localize glyph footprint + fill + metadata strip |
| **Samsung Galaxy AI** (Generative Edit, Sketch to Image, ...) | ✅ "✦ Contenuti generati dall'AI" strip (bottom-left, Italian-locale detection) | — | ✅ C2PA (signer "Samsung Galaxy") + `trainedAlgorithmicMedia` / proprietary `genAIType` marker | Localize glyph footprint + fill + metadata strip |
| **Black Forest Labs** (FLUX API) | — | — | ✅ C2PA (`Black Forest Labs API` + `c2pa.ai_generated_content` + `trainedAlgorithmicMedia`) | Metadata strip |
| **StableSignature** (Meta) | — | ✅ In-model watermark | — | Diffusion regeneration |
| **TreeRing** | — | ✅ Latent space watermark | — | Diffusion regeneration |
> Visible overlays are used by Google Gemini / Nano Banana (sparkle logo), by ByteDance's Doubao ("豆包AI生成" corner text) and Jimeng / Dreamina ("★ 即梦AI" wordmark), and by Samsung Galaxy AI ("✦ Contenuti generati dall'AI" strip, bottom-left, locale-specific). All are removed on CPU by reverse-alpha against a captured alpha map (Jimeng and Samsung add a thin residual inpaint over the glyph footprint, since their marks re-rasterize per image). Other services rely on invisible watermarks and/or metadata; our diffusion-based regeneration works against any invisible watermark in pixel or frequency domain. For a visible mark from any other source (any position, any colour), use the universal `erase --region` command.
> Visible overlays are used by Google Gemini / Nano Banana (sparkle logo), by ByteDance's Doubao ("豆包AI生成" corner text) and Jimeng / Dreamina ("★ 即梦AI" wordmark), and by Samsung Galaxy AI ("✦ Contenuti generati dall'AI" strip, bottom-left, locale-specific). All are removed by localizing the mark to a footprint mask and inpainting it with one shared fill (cv2 by default, MI-GAN or big-LaMa via `--backend`); the localizer is CPU-cheap and the heavier fills are opt-in. Other services rely on invisible watermarks and/or metadata; our diffusion-based regeneration works against any invisible watermark in pixel or frequency domain. For a visible mark from any other source (any position, any color), use the universal `erase --region` command.
> **Detection:** `remove-ai-watermarks identify <image>` reports the origin platform and watermark inventory for all the signals above — C2PA issuer, the C2PA soft-binding forensic-watermark vendor (TrustMark / Digimarc / Imatag / ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, the China TC260 AIGC label (XMP, PNG chunk, EXIF, or JPEG segment), the HuggingFace `hf-job-id` job marker, embedded generation params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the SynthID metadata proxy, the C2PA cloud-manifest reference (Adobe Durable Content Credentials, when the embedded manifest is stripped), the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" / Samsung Galaxy AI "Contenuti generati dall'AI" text marks), and (with the `[detect]` / `[trustmark]` extras) the open SD/SDXL/FLUX and Adobe TrustMark invisible watermarks. SynthID and the proprietary soft-binding watermarks (Digimarc etc.) have no local decoder, so they are reported by metadata proxy / vendor name only.
@@ -80,25 +80,19 @@ Google Gemini (internally codenamed **Nano Banana**) adds a visible sparkle logo
watermarked = α × logo + (1 − α) × original
```
We reverse this with a known alpha map (extracted from Gemini / Nano Banana output on a pure-black background):
A multi-scale NCC (Normalized Cross-Correlation) detector finds the sparkle position and scale dynamically in the bottom-right region (with a false-positive gate), so it works even if the image was resized or cropped. Removal is **localize then fill**: the sparkle footprint is built from the captured alpha thresholded low (so the faint halo is included) and dilated by a sparkle-relative margin, and that mask is inpainted by the shared fill. The captured alpha maps are used only to detect the sparkle and to shape the mask, not to recover pixels. If local metadata already confirms Google (a Google / Gemini C2PA issuer), the detection trust gate is relaxed so a moved or re-rendered sparkle is still caught. Pick the fill with `--backend` (cv2 default, MI-GAN or big-LaMa opt-in).
```text
original = (watermarked − α × logo) / (1 − α)
```
A three-stage NCC (Normalized Cross-Correlation) detector finds the watermark position and scale dynamically, so it works even if the image was resized or cropped. After removal, residual sparkle-edge artifacts are cleaned via gradient-masked inpainting.
**Speed**: ~0.05s per image. No GPU needed.
**Speed**: fast on CPU with the default cv2 fill. No GPU needed.
### Removing the Doubao "豆包AI生成" text watermark
Doubao (ByteDance) stamps every output with a light, semi-transparent "豆包AI生成" text strip in the bottom-right corner — the visible AIGC label mandated by China's TC260 standard. It is a fixed semi-transparent white overlay, so it is removed by **reverse-alpha blending**: `original = (watermarked - α·logo) / (1 - α)`, recovering the true pixels instead of hallucinating them. The α map is solved from controlled black/gray captures (rebuildable with `scripts/visible_alpha_solve.py`). Like the Jimeng mark, Doubao re-rasterizes its text slightly per image, so reverse-alpha is followed by a thin residual inpaint over the glyph footprint to clear the leftover edges, and the α template is NCC-aligned to the actual mark (handling per-image scale/position jitter). Detection matches the same glyph silhouette against the corner (normalized correlation), so it keys on the "豆包AI生成" shape, not on textured corners.
Doubao (ByteDance) stamps every output with a light, semi-transparent "豆包AI生成" text strip in the bottom-right corner — the visible AIGC label mandated by China's TC260 standard. Detection matches the glyph silhouette against the corner (normalized correlation), so it keys on the "豆包AI生成" shape, not on textured corners. Removal is **localize then fill**: the light glyph blob is localized to a solid, dilated footprint mask (template-free, so a re-rendered or differently-placed mark is still masked) and the shared fill inpaints it. On corpus images the filled region blends into its surroundings within a few LAB levels, with no color shift and no dark pit. Pick the fill with `--backend` (cv2 default, MI-GAN or big-LaMa opt-in).
**Speed**: ~0.05s, no GPU needed.
**Speed**: fast on CPU with the default cv2 fill, no GPU needed.
### Removing the Jimeng "★ 即梦AI" wordmark
Jimeng / Dreamina (即梦AI, also ByteDance, distinct from Doubao) stamps a "★ 即梦AI" wordmark — a four-point sparkle followed by the 即梦AI characters — in the bottom-right corner. It is a fixed semi-transparent **pure-white** overlay, solved from controlled black / gray / white captures the same way as Doubao. `visible --mark auto` detects and removes it (or force it with `--mark jimeng`). One difference from Doubao: Jimeng re-rasterizes its mark slightly differently per image, so a single alpha map does not cancel it pixel-for-pixel — reverse-alpha knocks the mark down and a residual inpaint over the glyph footprint clears the remaining outline. The two ByteDance marks do not confuse `auto`: detection keys on each mark's own glyph shape (the Jimeng detector scores far below its threshold on a Doubao strip, and vice versa).
Jimeng / Dreamina (即梦AI, also ByteDance, distinct from Doubao) stamps a "★ 即梦AI" wordmark — a four-point sparkle followed by the 即梦AI characters — in the bottom-right corner. Detection keys on the wordmark's glyph shape (NCC against a captured silhouette). `visible --mark auto` detects and removes it (or force it with `--mark jimeng`). Removal is **localize then fill**: the glyph blob is localized to a solid, dilated footprint mask and the shared fill inpaints it, so a re-rendered or differently-placed mark is still cleared. On corpus images the filled region blends into its surroundings within a few LAB levels, no color shift. The two ByteDance marks do not confuse `auto`: detection keys on each mark's own glyph shape (the Jimeng detector scores far below its threshold on a Doubao strip, and vice versa).
```bash
remove-ai-watermarks visible jimeng.png -o clean.png # --mark auto picks Jimeng
@@ -107,7 +101,7 @@ remove-ai-watermarks visible jimeng.png --mark jimeng -o clean.png
### Removing the Samsung Galaxy AI "✦ Contenuti generati dall'AI" mark
Samsung's on-device Generative AI edits (Generative Edit, Sketch to Image, Portrait Studio) burn a visible sparkle + "generated with AI" string into the **bottom-left** corner — a faint, low-opacity semi-transparent white overlay. It is solved from controlled black / gray / white captures the same way as Jimeng and removed by reverse-alpha plus a thin residual inpaint over the glyph footprint (the mark re-rasterizes per image, and the flat captures are smaller than real photos, so the alpha template is NCC-aligned and width-scaled to the actual mark). `visible --mark auto` detects and removes it (or force it with `--mark samsung`); being bottom-left it never confuses the bottom-right Gemini/Doubao/Jimeng marks. The string is **locale-specific** — this build is calibrated for the Italian "Contenuti generati dall'AI" variant; other locales need their own captured template (open a sample on issue #37).
Samsung's on-device Generative AI edits (Generative Edit, Sketch to Image, Portrait Studio) burn a visible sparkle + "generated with AI" string into the **bottom-left** corner — a faint, low-opacity semi-transparent white overlay. Detection matches the glyph silhouette (NCC), and removal is **localize then fill**: the glyph blob is localized to a solid, dilated footprint mask and the shared fill inpaints it. `visible --mark auto` detects and removes it (or force it with `--mark samsung`); being bottom-left it never confuses the bottom-right Gemini/Doubao/Jimeng marks. **Detection is locale-specific** — this build detects only the Italian "Contenuti generati dall'AI" variant, so other Samsung locales are not detected (and thus not removed) and need their own captured detection template (open a sample on issue #37). The fill mask itself is locale-independent.
```bash
remove-ai-watermarks visible samsung.jpg -o clean.jpg # --mark auto picks Samsung
@@ -116,7 +110,7 @@ remove-ai-watermarks visible samsung.jpg --mark samsung -o clean.jpg
### Universal region eraser
For any visible mark the dedicated engines do not cover — a logo anywhere, any colour — `erase --region x,y,w,h` inpaints the box you specify. The default `cv2` backend is instant and dependency-free; the optional `lama` backend (big-LaMa via onnxruntime, `lama` extra, ~200 MB model downloaded on first use) gives much cleaner fills on textured regions at the cost of ~3-4 GB RAM per call.
For any visible mark the dedicated engines do not cover — a logo anywhere, any color — `erase --region x,y,w,h` inpaints the box you specify. The default `cv2` backend is instant and dependency-free; the optional `lama` backend (big-LaMa via onnxruntime, `lama` extra, ~200 MB model downloaded on first use) gives much cleaner fills on textured regions at the cost of ~3-4 GB RAM per call.
### Removing SynthID and other invisible watermarks
@@ -133,7 +127,7 @@ image → encode to latent space (VAE) at native resolution
→ decode back to pixels (VAE)
```
- Large inputs run at native resolution (no down-then-up round-trip, which was the main quality loss in issue #10); use `--max-resolution N` only to cap GPU/MPS memory on very large inputs. Small inputs (long side under 1024 px) are auto-upscaled to a 1024 px floor before diffusion, because SDXL distorts on a tiny latent, and the result is restored to the original size (a transparent quality boost). Disable the floor with `--min-resolution 0`. The floor upscale uses Lanczos by default; `--upscaler esrgan` (the `esrgan` extra) runs Real-ESRGAN first for sharper detail and falls back to Lanczos if the extra is absent. ESRGAN is a generic photo/texture GAN with no face/glyph prior, so it is best for photo/texture content -- it can degrade faces (the diffusion pass regenerates them, so the final recovers) and thin text; keep Lanczos for text-heavy inputs.
- Large inputs run at native resolution (no down-then-up round-trip, which was the main quality loss in issue #10); use `--max-resolution N` only to cap GPU/MPS memory on very large inputs. For inputs that run out of GPU/MPS memory at native resolution, `--tile` is the lossless alternative to `--max-resolution`: it regenerates the image in overlapping, feather-blended tiles (each near SDXL's 1024 px size) so there is no downscale and no visible seam. It engages only when the long side exceeds `--tile-size` (default 1024; overlap `--tile-overlap`, default 128); pair it with `--max-resolution 0`. Small inputs (long side under 1024 px) are auto-upscaled to a 1024 px floor before diffusion, because SDXL distorts on a tiny latent, and the result is restored to the original size (a transparent quality boost). Disable the floor with `--min-resolution 0`. The floor upscale uses Lanczos by default; `--upscaler esrgan` (the `esrgan` extra) runs Real-ESRGAN first for sharper detail and falls back to Lanczos if the extra is absent. ESRGAN is a generic photo/texture GAN with no face/glyph prior, so it is best for photo/texture content -- it can degrade faces (the diffusion pass regenerates them, so the final recovers) and thin text; keep Lanczos for text-heavy inputs.
> **Default strength is vendor-adaptive (no flag needed).** The tool reads the C2PA issuer to detect which vendor's SynthID is present and picks the strength accordingly: **OpenAI gpt-image → `0.20`**, **Google Gemini → `0.30`**, **unknown source → `0.30`**. The **same ladder applies to both pipelines** — these are the oracle-certified `controlnet` floors (June 2026 Modal cert, multi-seed). They also cover plain `sdxl`: the two pipelines have opposite hard cases (controlnet leaves SynthID on photoreal, sdxl on flat graphics), but on its own hard case sdxl is the weaker remover, so it needs at least controlnet's strength — using one certified ladder is the safe choice (margin-based for sdxl, not separately certified). The dominant factor is the vendor (Google's SynthID is ~3x more robust). There is no local SynthID detector, so if the oracle still reads SynthID, raise `--strength`; if you care more about preserving fine detail, lower it. (Caveat: Google's `0.30` was validated only at `--max-resolution 1536`; a very large native Gemini image may need ~`0.35`+.)
>
@@ -335,15 +329,24 @@ remove-ai-watermarks batch ./images/ --mode all
# of a clean origin. Add --json for machine-readable output.
remove-ai-watermarks identify image.png
# Visible watermark only — fast, offline, CPU. --mark auto (default) finds the
# strongest known mark (Gemini sparkle / Doubao "豆包AI生成" / Jimeng "即梦AI" /
# Visible watermark only — fast, offline, CPU. --mark auto (default) removes every
# detected known mark (Gemini sparkle / Doubao "豆包AI生成" / Jimeng "即梦AI" /
# Samsung Galaxy AI "Contenuti generati dall'AI"); force one with
# --mark gemini / doubao / jimeng / samsung. Removed by reverse-alpha (true-pixel recovery).
# --mark gemini / doubao / jimeng / samsung. Removal localizes each mark to a
# footprint mask and inpaints it with a shared fill; --backend auto|cv2|migan|lama
# (default auto) picks the fill (auto = LaMa > MI-GAN > cv2, best available).
# --sensitivity auto|strict|assume-ai (default auto) sets how hard a borderline
# mark is trusted; use assume-ai on a metadata-stripped screenshot to recover a
# moved or faint mark the conservative gate would skip.
# If no known visible mark is found, it writes no output and exits 2 (not 0),
# pointing you to `all` (for an invisible/metadata mark) or `erase` (for an
# arbitrary logo) instead of handing back the unchanged image.
remove-ai-watermarks visible image.png -o clean.png
# Metadata-stripped screenshot: assert it is AI to relax detection and recover
# a moved/faint mark (a wrong guess just fills a small corner near-losslessly).
remove-ai-watermarks visible screenshot.png --sensitivity assume-ai -o clean.png
# Erase arbitrary region(s) — universal, any logo/watermark/object, any position.
# Default cv2 inpainting (CPU). --backend lama uses big-LaMa (extra 'lama').
remove-ai-watermarks erase image.png --region 1640,1930,400,100 -o clean.png
@@ -354,7 +357,8 @@ remove-ai-watermarks invisible image.png -o clean.png --humanize 4.0 --unsharp 0
# Large images run at native resolution; small ones are upscaled to a 1024 floor
# first (disable with --min-resolution 0); --upscaler esrgan uses Real-ESRGAN for
# that floor upscale (needs the 'esrgan' extra). On a very large image that OOMs the
# GPU/MPS, cap the long side: --max-resolution 2048
# GPU/MPS, either cap the long side (--max-resolution 2048, lossy) or pass --tile
# to regenerate in overlapping feather-blended tiles at native resolution (lossless).
# Strength is vendor-adaptive by default (OpenAI 0.20 / Google 0.30, same
# for both pipelines); override with --strength. controlnet (text/face
# structure preservation) is the default pipeline; --pipeline sdxl opts down
@@ -364,6 +368,12 @@ remove-ai-watermarks invisible image.png -o clean.png --humanize 4.0 --unsharp 0
# --adaptive-polish (ON by default) restores the input's detail level (sparing
# text) to counter the over-smoothed look; it self-limits to a no-op where
# there is no detail deficit. Disable with --no-adaptive-polish.
# By default, if no invisible AI watermark is locally detectable, the diffusion
# scrub is SKIPPED (regenerating pixels would only degrade a clean image): for
# `invisible` that writes no output and exits 2, for `all` it skips step 2 but
# still strips metadata and exits 0. A skip never claims the image is clean
# (a pixel SynthID is undetectable once its metadata is gone). Pass --force to
# regenerate regardless when you know the image is AI-generated.
# Check / strip AI metadata (C2PA, EXIF, "Made with AI" labels)
# --check also flags SynthID-bearing sources: a C2PA manifest signed by
@@ -383,20 +393,30 @@ remove-ai-watermarks batch ./images/ --mode all
### Python API
One high-level call removes every detected visible mark (Gemini sparkle, Doubao / Jimeng / Samsung text, the Jimeng pill) by localize then fill. For a file it reads metadata provenance automatically and preserves the alpha channel; `import remove_ai_watermarks` stays cheap (the heavy deps load lazily on first use).
```python
from remove_ai_watermarks.gemini_engine import GeminiEngine
import remove_ai_watermarks as raiw
# Clean a file -> writes clean.png, returns the array and what was removed.
result, removed = raiw.remove_visible("watermarked.png", "clean.png")
print("removed:", removed) # e.g. ['Google Gemini sparkle']
# An empty list means nothing known was found (route to `all` or `erase` instead).
# Array in, array out (BGR numpy). Pick the fill backend: cv2 (default), migan, lama.
import cv2
clean, removed = raiw.remove_visible(cv2.imread("in.png"), backend="cv2")
engine = GeminiEngine()
image = cv2.imread("watermarked.png")
# Metadata-stripped screenshot you know is AI-generated: relax detection to recover a
# moved or faint mark (a wrong guess just fills a small corner near-losslessly).
raiw.remove_visible("screenshot.png", "clean.png", sensitivity="assume_ai")
# Detect
result = engine.detect_watermark(image)
print(f"Detected: {result.detected} (confidence: {result.confidence:.1%})")
# What the file's metadata confirms (drives the default `auto` sensitivity):
raiw.visible_provenance("in.png") # e.g. frozenset({'gemini'})
# Remove
clean = engine.remove_watermark(image)
cv2.imwrite("clean.png", clean)
# Full provenance verdict (platform + watermark inventory + confidence):
from remove_ai_watermarks.identify import identify
report = identify("in.png")
```
#### Invisible removal (diffusion)
@@ -480,9 +500,9 @@ Won't fix:
## Limitations
- **Visible-mark and metadata removal is lossless.** Reverse-alpha recovers the original pixels under the mark; metadata stripping never touches image data.
- **Visible-mark removal is localized inpainting; metadata removal is lossless.** Each visible mark is localized to a footprint mask and filled by inpainting (cv2, MI-GAN, or big-LaMa), so only the small masked region is reconstructed and it blends into its surroundings within a few LAB levels; a slightly-off localization just fills a small region near-losslessly rather than leaving a color-shifted smear. Metadata stripping never touches image data.
- **The invisible (SynthID) path is lossy and not guaranteed.** It runs a low-strength SDXL img2img regeneration, so it softens fine detail and is content-dependent. There is no public SynthID decoder, so the tool cannot verify removal locally; confirm with the Gemini app's "Verify with SynthID" oracle and raise `--strength` if it still detects. A vendor can change the scheme at any time, so treat this as an arms race, not a permanent fix.
- **Large images are not tiled yet.** The SynthID path runs at the diffusion model's native resolution and caps or upscales larger inputs instead of tiling them, so very large images lose detail. (Nano Banana 2 is natively 1024px; GPT Image 2 supports 4K experimentally.) Tiled diffusion is the planned fix.
- **Large images: native by default, opt-in tiling for OOM.** The SynthID path runs at the diffusion model's native resolution; on a memory-constrained GPU/MPS you can either cap the long side with `--max-resolution` (lossy downscale) or pass `--tile` to regenerate in overlapping, feather-blended tiles at native resolution (lossless, no seam). Tiling is a memory workaround, not a quality upgrade over a single native pass: each tile is an independent low-strength regeneration. (Nano Banana 2 is natively 1024px; GPT Image 2 supports 4K experimentally.)
- **Out of scope:** defeating trained AI-vs-real classifiers like Hive (see [Threat model](#threat-model)), visible-logo removal from video, and any guarantee that a stripped copy is untraceable server-side.
## Legal
+35
View File
@@ -0,0 +1,35 @@
# qwen_in — pipeline-fidelity eval set
A small, **stable** set of AI-generated images used to compare the diffusion
removal pipelines (`controlnet` / `sdxl` / `qwen`) for fidelity with
`scripts/fidelity_metrics.py`. Fixing the set in the repo keeps comparisons
reproducible across runs and pipelines.
All four are AI-generated test content (they carry SynthID + C2PA from their
generator — verify with `remove-ai-watermarks identify`), same class as the
`data/samples/` fixtures. No real-person photos.
| file | vendor (SynthID) | content | exercises |
|---|---|---|---|
| `openai_1_original.png` | OpenAI | typography sheet (EN + RU + ZH) | text (multi-script) |
| `openai_2_original.png` | OpenAI | Raiw.cc poster | text (EN, small) |
| `gemini_1_original.png` | Google | landscape + Chinese sign | text (CJK) |
| `gemini_3_original.png` | Google | 3x3 portrait grid | faces (identity / skin texture) |
## Text ground truth
`ground_truth.json` (`{basename: text}`) is the **hand-verified** OCR of the
text-bearing originals, seeded by `fidelity_metrics.py ocr` and corrected by
hand (PaddleOCR mis-reads stylized Cyrillic in particular). It is the reference
for the text CER metric — much cleaner than OCR-vs-OCR. Regenerate the seed with:
uv run scripts/fidelity_metrics.py ocr data/qwen_in/openai_1_original.png \
data/qwen_in/openai_2_original.png data/qwen_in/gemini_1_original.png \
--langs en,ru,ch --out data/qwen_in/ground_truth.json
# then re-verify by hand before trusting it.
## Compare
uv run scripts/fidelity_metrics.py compare \
--original data/qwen_in/gemini_3_original.png \
--variant controlnet=<out>.png --variant qwen=<out>.png --ocr-langs ""
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 MiB

+5
View File
@@ -0,0 +1,5 @@
{
"openai_1_original.png": "This is a longer sample text in English.\nTypography can flow smoothly from large to medium size.\nSmaller lines help demonstrate hierarchy, rhythm, and clarity.\nEven the finest text should remain clean and readable.\n这是一段较长的中文示例文本。\n排版可以从大字号逐渐过渡到中字号。\n更小的文字能够展示层次、节奏与清晰度。\n即使是最小的一行,也应该保持清楚易读。\nЭто более длинный пример текста на русском языке.\nТипографика может плавно переходить от крупного размера к среднему.\nБолее мелкие строки показывают иерархию, ритм и ясность.\nДаже самый маленький текст должен оставаться чистым и читаемым.",
"openai_2_original.png": "raiw.cc\nRaiw.cc – The Platform for the Future\nRaiw.cc is a modern project built for those who value quality, innovation, and reliability.\nSecurity & Reliability\nWe use advanced technologies to protect your data and ensure stable service performance.\nInnovation\nWe continuously explore and implement better solutions to help you stay one step ahead.\nCommunity\nRaiw.cc is an active community of like-minded people who share knowledge, experience, and ideas.\nProgress Together\nWhether you're a developer, creator, or dreamer — raiw.cc is your partner in achieving your goals.\nOur mission is to create opportunities and inspire people to reach new heights.\nRaiw.cc\nVisit raiw.cc today\nExplore more possibilities and start your journey to the future!\nGet Started\nSecure\nInnovative\nCommunity\nForward-Thinking\nINNOVATE · CONNECT · CREATE · GROW",
"gemini_1_original.png": "每天都是一个新的机会。\n用微笑开始它。\n世界也会向你微笑。"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+55 -2
View File
@@ -9,11 +9,44 @@ measurements, incident history, oracle runs, and the reasoning behind each
decision. Read the relevant section here before changing the diffusion
pipelines, strength defaults, or metadata coverage.
## Visible-mark fill quality is background- and backend-dependent
Visible-mark removal is localize -> fill (the reverse-alpha pixel recovery was
dropped; see `docs/module-internals.md`). The fill only touches the mark's
footprint, so there is never collateral damage outside it, and whether the mark
is *removed* is fill-independent -- cv2, MI-GAN and LaMa all strip the mark's
shape. What varies is the *quality of the recovered region*, and it depends on
the background:
- **Flat backgrounds:** every backend is clean; cv2 is often the crispest.
- **Textured / regular-structured backgrounds** (fabric, foliage, a lattice or
grid): an inpaint can only guess the hidden pixels. `cv2` (the classical
no-deps floor) visibly smears; `migan` (light, learned) can leave a ghost or
hallucinate structure; `lama` (heavy, learned) is the most reliable and
recovers structure best.
The old reverse-alpha recovered the *true* pixels under a well-captured, static
mark, so on structured backgrounds it was sometimes cleaner than any inpaint.
The trade for localize -> fill is robustness (it also handles moved / re-rendered
marks and needs no per-mark alpha capture) and a simpler, swappable pipeline.
`auto` resolves best-first (`LaMa > MI-GAN > cv2`) and warns once when it falls
back to cv2 because no learned backend is installed; a memory-tight deployment
that cannot afford LaMa's ~4.7 GB peak pins `--backend migan` explicitly.
## Invisible-pipeline resolution handling (native / 1024 floor / `--max-resolution`; MPS memory tiers)
`invisible` pipeline processes at **native resolution for inputs whose long side is >= 1024px**, and **auto-upscales smaller inputs UP to a 1024px floor** (`min_resolution=1024`, the default; `--min-resolution 0` disables) before diffusion -- SDXL img2img distorts badly on a tiny latent (a 381x512 portrait wrecks at native, the #36 follow-up), and the output is restored to the original input size so the floor is a transparent quality boost (it adds time/memory on small inputs). The floor upscale uses Lanczos by default; **`--upscaler esrgan`** (opt-in, the `esrgan` extra) runs Real-ESRGAN first for better detail before the Lanczos resize to the exact target (`upscaler.py` / `InvisibleEngine._esrgan_upscale`, falls back to Lanczos if the extra is absent). `max_resolution=0` (default) means no downscale cap, matching the hosted raiw.cc backend (fal fast-sdxl, no pre-downscale). The old forced downscale-to-1024 -> upscale-back round-trip for LARGE images was the main quality loss (issue #10) and is gone; at strength ~0.05 SDXL img2img does not need a downscale.
**Final `--unsharp` post-filter (`humanizer.unsharp_mask`, opt-in, default 0):** applied LAST (after the face-restore pass, else it would be smoothed over) to counter the soft/over-smoothed look diffusion + restoration leave (an AI tell); ~0.5-0.8 safe, higher risks halos. Pairs with `--humanize` (grain adds sensor-noise texture, unsharp adds crispness). `--max-resolution N` re-introduces an opt-in long-side cap purely to bound GPU/MPS memory on very large inputs (it reintroduces the lossy round-trip). For huge images that OOM at native, tile-based diffusion is still the proper long-term fix.
**Final `--unsharp` post-filter (`humanizer.unsharp_mask`, opt-in, default 0):** applied LAST (after the face-restore pass, else it would be smoothed over) to counter the soft/over-smoothed look diffusion + restoration leave (an AI tell); ~0.5-0.8 safe, higher risks halos. Pairs with `--humanize` (grain adds sensor-noise texture, unsharp adds crispness). `--max-resolution N` re-introduces an opt-in long-side cap purely to bound GPU/MPS memory on very large inputs (it reintroduces the lossy round-trip). For huge images that OOM at native, **`--tile` is the lossless alternative** -- see the tiled-diffusion subsection below.
### Tiled diffusion for large inputs (`--tile`, issue #10)
`--tile` (OFF by default; `--tile-size` default 1024, `--tile-overlap` default 128) processes the diffusion pass in overlapping sliding-window tiles instead of one forward pass, so a large image is regenerated at **native resolution** without the OOM and without the lossy `--max-resolution` downscale round-trip. It engages only when the long side exceeds `--tile-size`; a sub-tile image runs a single pass unchanged. `WatermarkRemover.remove_watermark` refactors the single-image `_generate` into a per-tile `_generate_one` (the ControlNet canny edge map is rebuilt per tile, so structure preservation works tile-local) and routes it through `noai.tiling.run_tiled` when tiling is active. The geometry and blend math are pure helpers, unit-tested without the model (`tests/test_tiling.py`):
- `plan_tiles(w, h, tile_size, overlap)` lays out a row-major grid where every tile is exactly `tile_size` (the last tile on each axis is pulled back flush to the far edge, simply overlapping its predecessor more). Uniform tile size keeps each diffusion pass at SDXL's preferred dimension.
- `feather_weights(w, h, overlap)` is a separable linear taper, ~1 in the interior and ramping toward each edge, kept **strictly positive** so the normalized accumulate-and-divide blend (`accum / weight_sum`) is a partition of unity: a region covered by one feathered edge (an image corner) still divides cleanly. Identical (unchanged) tiles therefore reconstruct the input exactly -- the seam-free guarantee, asserted in `test_identity_generate_reconstructs_image`.
CAVEAT: each tile is an **independent** low-strength regeneration. At the certified removal strengths (0.20-0.30) the per-tile drift is small and the feather blend hides the seams, but tiling is a memory workaround, not a quality upgrade over a single native pass -- a 32 GB MPS box that clears the native UNet peak should prefer no tiling. The MPS->CPU fallback still applies per tile; if the first tile falls back to CPU, the device stays CPU for the rest of the image.
**Concrete MPS data points (the OOM is memory-tier-dependent, NOT a hard MPS limit):** on a ~24 GB unified-memory machine (verified 2026-05-25, 1254x1254 gpt-image SDXL, fp32) native res OOMs at the *UNet* step (peak ~17 GiB), not only the VAE decode, and the auto-fallback in `img2img_runner` reloads on CPU and finishes (slow, ~13 min) -- the output is still weight-identical and defeats SynthID, so "looks hung/crashed" on Mac is usually this CPU fallback, not a pipeline error. On a **32 GB** unified-memory machine the same default SDXL pass runs entirely on MPS with **no CPU fallback** (verified 2026-05-31, 1122x1402 gpt-image, `all`/default, ~155 s end-to-end), so 32 GB clears the native-res UNet peak that 24 GB could not. Adding `enable_vae_tiling()` alone does NOT prevent the 24 GB OOM (the peak is the UNet, not the VAE). The fast Mac workarounds for memory-constrained machines are fp16 on MPS (roughly halves memory) or `--max-resolution` to cap the long side; neither is wired as the default. The `controlnet` pipeline adds the canny ControlNet weights on top of SDXL, so its peak is a bit higher than the plain `default` pass; the same MPS->CPU fallback covers an OOM. The native-vs-cap-vs-floor decision lives in the pure helper `invisible_engine._target_size(w, h, max_resolution, min_resolution)` (returns `None` for native, a target tuple for a downscale cap OR an upscale floor; cap takes precedence, the floor is skipped on a min>max misconfig) so it is unit-tested (`tests/test_invisible_engine.py::TestTargetSize`, the #10/#15/#36 regression guard) without loading the model -- keep that logic in the helper, don't re-inline it.
@@ -41,7 +74,7 @@ Metadata detection for AVIF/HEIF/JPEG-XL relies on a binary scan for `C2PA_UUID`
**Meta-box XMP now handled (`isobmff.blank_ai_xmp_packets`, v0.6.9):** an AI-label XMP packet stored as a meta-box `mime` item (AVIF/HEIF) is blanked in place (overwritten with spaces of the same length, so `iloc` offsets and the coded image stay valid).
**Still NOT built:** an `Exif` *item* inside the `meta` box (rare -- AI labels are XMP) needs full `iinf`/`iloc` surgery (offset rewrite) with corruption risk -- exiftool (R/W/C for HEIC/AVIF EXIF+XMP, verified on exiftool.org 2026-05-27) would do it but is a non-installed binary dep, so it stays a documented gap.
**`Exif` item inside the `meta` box (AVIF/HEIF), now handled in place (2026-06-19):** an AI-generator token in an EXIF item (its TIFF bytes live in `mdat`/`idat`) is blanked by `isobmff.blank_ai_exif_tokens` — it finds EXIF TIFF blocks by their II/MM byte-order header, validates each with **piexif** (a coincidental II/MM run in pixel data won't parse as a TIFF IFD, so it is ignored), and overwrites any `Software`/`Make`/`Artist`/`ImageDescription` value carrying an `AI_GENERATOR_TOKENS` token with spaces of the **same length**. Same-length means every box size and `iloc` offset stays valid and the coded image is untouched — so it avoids the full `iinf`/`iloc` surgery (offset rewrite) that exiftool would need (exiftool is a non-installed binary dep, deliberately not used). It scrubs only the AI value; camera/editor EXIF is preserved. Wired into `remove_ai_metadata`'s ISOBMFF path after `blank_ai_xmp_packets`. Because the ISOBMFF branch never runs the JPEG `_scrub_ai_exif`, this is the ONLY EXIF scrubber on that path and must stay in PARITY with it: it now also blanks the China TC260 `{"AIGC":{...}}` block in `ImageDescription`/`UserComment` (via `_is_aigc_exif_value` — Doubao producer + Tencent service-provider schemas) and the xAI/Grok `Signature:` + UUID-`Artist` pair, not just `AI_GENERATOR_TOKENS` in `Software`/`Make`/`Artist`/`ImageDescription` (regression `test_noai.py::TestISOBMFF::{test_blank_aigc_block_in_exif, test_blank_xai_signature_pair_in_exif}`). **Still NOT built:** Resemble PerTh audio detection (no presence/confidence flag exists).
**Audio watermark DETECTION (Resemble PerTh) was evaluated and NOT built (2026-05-26):** `resemble-perth`'s `PerthImplicitWatermarker.get_watermark()` returns a raw bit-array with **no presence/confidence flag** (clean audio decodes to arbitrary bits too), so reliably distinguishing watermarked-from-clean needs either Resemble's fixed payload or a confidence API -- neither is public, and there's no real Resemble sample to calibrate against. Same wall-class as the SynthID pixel detector: the decode exists, reliable presence-detection does not. (perth's top-level `PerthImplicitWatermarker` is also gated to None unless `librosa` is importable.)
@@ -57,6 +90,8 @@ There is no reliable *local* detector of the SynthID *pixel* watermark — Googl
**This explains the recurring "oracle says clean but `identify` still flags SynthID" report (#14):** the oracle reads the *pixel* watermark (gone after our SDXL pass), while `identify` reads the *C2PA-metadata proxy* (still present if the manifest survived). Different signals, not a contradiction -- strip the metadata too (`metadata --remove` / `all`) and the proxy goes quiet, but a quiet proxy is not proof the pixel watermark is gone.
**Consequence for the P0#5 no-signal skip (`has_invisible_target`, 2026-06-22):** `invisible`/`all`/`batch` skip the diffusion scrub by default when no invisible AI signal is *locally* detectable, to avoid degrading a clean image (`--force` overrides). Because SynthID detection is metadata-only, a real AI image whose C2PA was **already stripped** (e.g. a re-encoded download, or the API/playground surfaces above that never emit C2PA) reads as no-signal and is therefore **skipped** — leaving its pixel SynthID in place. This is the deliberate trade: the skip's message never claims the image is clean, and the user re-runs with `--force` when they know it is AI. The blind spot is the same metadata-only ceiling, not a new bug; the visible-sparkle path (`check_visible`) still catches the no-C2PA Gemini-playground case for the *visible* mark, but not the invisible one.
**SynthID is durable to JPEG re-encode by design, so a GitHub-recompressed issue attachment is still a valid SynthID test subject** (verified 2026-06-01 on issue #14's pic3: the GitHub-served JPEG survived re-encoding and openai.com/verify still detected SynthID). Do NOT dismiss issue-attachment JPEGs as "not faithful originals" when reproducing a SynthID-survival report: the recompression strips the **C2PA metadata** (so `identify` reads Unknown on the attachment) but NOT the **pixel watermark** that openai.com/verify reads. A true byte-original only matters for the metadata/C2PA path, not for the pixel-SynthID-removal test. (Contrast the open imwatermark above, which IS fragile to JPEG.) The spectral phase-coherence approach from `github.com/aloshdenny/reverse-SynthID` was evaluated (May 2026) and **does not work for real-content detection**: on its own shipped codebook + validation set, watermarked and cleaned images were indistinguishable (conf within noise, cleaned often higher); it only fires on pure-black 1024x1024 reference images at exact resolution (the controlled case it was calibrated on). The README's "90% / conf=0.91" reproduces only in that lab condition. Do not build a production detector on it; if revisited, it is experimental/diagnostic only and needs a per-resolution, per-model reference corpus. A from-scratch gpt-image pilot (2026-05-24) confirmed this independently: 5 independent solid-black gpt-image outputs share a near-identical fixed signature (pairwise residual correlation **0.92**, avg-template retains 97% energy), so the watermark/carrier IS strongly present and consistent on flat content — but the carrier frequencies extracted from it do NOT discriminate real content (carrier-to-random ratio: cleaned 1.86 > watermarked 1.53; a non-gpt-image image scored highest at 3.67). The signature drowns in content texture. Net: a perfectly consistent solid-color signature still yields no real-content pixel detector with magnitude/carrier methods. A corpus discrimination test (2026-05-24, `scripts/synthid_pixel_probe.py`, raw zero-mean residual NCC) independently re-confirms this: at matched resolution, SynthID positives do NOT cluster apart from negatives (within-Gemini 0.07; at 1024 px pos-vs-neg >= pos-vs-pos). The only high correlations were near-duplicate *content* (5 ChatGPT renders of one prompt at ~0.92, while a distinct ChatGPT image scored ~0 against them) — content, not a carrier. The probe is solid-fills-only and EXPERIMENTAL/DIAGNOSTIC; do not use it on real content.
**Correction (deeper re-examination 2026-05-25):** the carrier IS real on solid fills — the earlier "no carrier" was a *method* artifact of using spatial / FFT-magnitude NCC, which can't see it. The carrier is a fixed *phase* at specific low frequencies, so the right metric is **per-bin phase coherence**. On 8 white `gemini-2.5-flash-image` fills (generated via the reverse-SynthID trick: identity-edit prompt "Recreate this image exactly as it is" on a synthetic pure-white PNG — this bypasses the recitation block that rejects text prompts for pure colors), phase coherence at the white carriers `(0,±7..±12,±20..±23)` = **0.86** vs **0.31** random; single-image leave-one-out phase-match **+0.83** vs real photos **-0.24**. (Black `2.5-flash` fills clip to std≈0 — SynthID can't push values below 0, so no carrier in black; the repo's dark carriers come from nano-banana-pro.)
@@ -122,3 +157,21 @@ See `docs/synthid.md` §5.5 + `docs/controlnet-removal-pipeline-research.md` (ce
`controlnet_conditioning_scale` (CLI `--controlnet-scale`, default 1.0) is the structure-preservation knob (higher = closer to the original structure); fp32 on cpu/mps, fp16-fixed VAE on cuda/xpu. The `controlnet` profile is threaded explicitly (`WatermarkRemover(pipeline=...)` / `InvisibleEngine(pipeline=...)`), NOT inferred from `model_id`. This productionizes the `scripts/controlnet_sweep.py` prototype; see `docs/controlnet-removal-pipeline-research.md`.
**Forensic-stealth caveat still applies** (arXiv:2605.09203): defeating the SynthID verifier is not forensic invisibility -- a "this image went through a removal pipeline" classifier can still flag the output.
## `qwen` pipeline (experimental, Qwen-Image 20B, certified floors)
`--pipeline qwen` runs `QwenImageImg2ImgPipeline` on `Qwen/Qwen-Image` (20B MMDiT, Apache-2.0 code AND weights), as an img2img alternative to the SDXL pipelines. Motivation: the controlnet over-regeneration problem above (it plasticizes real photos / loses fine text at the scrub floor). Qwen-Image renders text natively (incl. CJK) and preserves structure markedly better, so at the strength that removes SynthID it damages real content far less.
The scrub still comes from the img2img `strength` (same lever as SDXL); the call shape lives in the pure `_build_qwen_kwargs` (uses Qwen's `true_cfg_scale`, not SDXL's `guidance_scale` — the CLI `--guidance-scale` maps onto it, and ~4.0 is typical vs the SDXL default 7.5). bf16 on CUDA. It is **CUDA/cloud-class — the 20B does not fit MPS — so `_run_qwen` has NO MPS→CPU fallback** (unlike the SDXL paths). Cost on Modal A100-80GB is ~$0.05-0.10/image vs SDXL.
**Certified oracle floors (Modal A100-80GB, 2026-06-20):** on native-resolution OpenAI and Gemini cert inputs (`data/qwen_in/`, both controls SynthID-POSITIVE): **OpenAI 0.10** (0.05 and 0.075 still detected; 0.10 clean and SEED-ROBUST — clean on seeds 0-4, so a random seed is safe) and **Gemini 0.25** (0.20 still detected, 0.25 clean on both images; lowered from the 0.30 first measured). Gemini seed-repeat is single-seed (seed 0): the Gemini oracle rate-limits volume, so PIN a seed in production rather than relying on seed-robustness there.
**Fidelity vs controlnet was MEASURED, not eyeballed (`scripts/fidelity_metrics.py`, text scored against a vision-transcribed ground truth in `data/qwen_in/ground_truth.json` + PaddleOCR on the variants; an initial eyeball read was wrong and overturned by the metrics).** Methodology rule: only compare fidelity at each pipeline's OWN oracle-confirmed scrub floor -- i.e. between outputs where SynthID is actually removed in BOTH (controlnet OpenAI 0.10 / Gemini 0.15; Qwen OpenAI 0.10 / Gemini 0.25). An equal-strength comparison is invalid where it leaves one pipeline un-scrubbed (Qwen at 0.15 does NOT clear Gemini SynthID, so that run was dropped). At those scrub floors:
- **Text:** Qwen wins on substantial Latin/mixed-script text -- OCR CER, controlnet vs Qwen: openai_1 (EN+RU+ZH, both 0.10) 0.385 vs **0.241**, openai_2 (EN, both 0.10) 0.341 vs **0.290**. On a SHORT CJK sign (gemini_1, cnet 0.15 / Qwen 0.25) it is a TIE (0.037 vs 0.037 -- both near-perfect; the earlier Qwen 0.000 was at the higher 0.30, not the certified floor).
- **Faces:** controlnet wins -- gemini_3, 18 faces (cnet 0.15 / Qwen 0.25): ArcFace identity 0.546 vs 0.382, Laplacian-variance retention 0.62 vs 0.40, face LPIPS 0.09 vs 0.17 (Qwen smooths faces MORE; the gap narrows vs Qwen 0.30 but controlnet still wins clearly).
**Conclusion: Qwen wins TEXT only for clean body text on a plain background with NO faces; controlnet wins faces AND display/decorative text in a scene. So `qwen` is a MANUAL `--pipeline qwen` opt-in, not a routed lane.** A content `--pipeline auto` router + a faces+text mixed dual-pass were prototyped and DROPPED (2026-06-20): on the canonical faces+text case (the abba poster, faces + display text) controlnet won EVERY metric incl. text (CER 0.114 vs qwen 0.379), so grafting qwen text only hurts; and "text→qwen" is undecidable cheaply (body-vs-display text is what matters). Caveat: `resolve_strength(..., pipeline="qwen")` carries the Qwen ladder (`_QWEN_VENDOR_STRENGTH`, Gemini 0.25), so `--pipeline qwen` gets the 0.25 Gemini floor automatically — the old manual `--strength 0.25` workaround is retired. `_build_qwen_kwargs` now passes an explicit height/width (qwen squished non-square inputs to 1024² without it). Flat-graphic content was not in the sample.
**Improving Qwen (ship vs improve):** the cited research lives in `docs/qwen-improvement-research.md` -- read it before extending the `qwen` pipeline. Verdict: shippable as an opt-in text lane. **The "add a Qwen-Image ControlNet to fix face smoothing" lead was built, measured, and CLOSED (2026-06-20):** a DiffSynth-Studio Qwen + Apache-2.0 blockwise-canny ControlNet at the Gemini floor 0.25 did NOT restore face skin texture (face Laplacian-variance retention flat 0.40 -> 0.40, 13/16 faces within +-0.02; the SDXL+canny target 0.62 was not approached), because canny carries edges not skin grain and Qwen's higher Gemini floor (0.25 vs SDXL+canny 0.15) forces more smoothing -- and a deep-research sweep confirmed NO permissively-licensed Qwen tile/detail/realism/skin ControlNet exists anywhere (every Qwen conditioning is geometry). So **faces stay on SDXL+controlnet; Qwen is the text lane, not a face fix.** The strongest remaining lead is **Z-Image-Turbo** (6B, Apache-2.0, `ZImageImg2ImgPipeline`, scrub mechanism preserved) -- its own SynthID floor and face/text fidelity are UNMEASURED; that is the next experiment. Non-regenerative high-frequency detail re-injection is NOT safe by assumption (the "clean-output high frequencies do not carry the watermark" claim was refuted) -- it must be oracle-gated. Always validate any improvement at the certified floors with `scripts/fidelity_metrics.py` first.
**Seed as a quality lever (measured, openai_1 at 0.10, seeds 0-4):** the seed barely moves whole-image fidelity (img LPIPS 0.062-0.065, SSIM 0.855-0.857, PSNR 28.5-28.7 — flat) but does shift TEXT legibility (OCR CER 0.241-0.290, ~17% spread) -- the seed changes WHICH details get regenerated, not the overall level. So a per-image best-of-N-seed selection is a WEAK, text-only lever (pick the lowest-CER seed that still scrubs; fidelity selection needs no oracle). Not worth the N× cost for general use -- pin one decent seed in prod; reserve best-of-N for text-heavy premium cases.
+83 -60
View File
@@ -15,7 +15,7 @@ module.
## `noai/constants.py`
`noai/constants.py` — PNG_SIGNATURE, C2PA_CHUNK_TYPE, C2PA_SIGNATURES, and `C2PA_AI_VENDORS` — the single `C2paAiVendor` registry of C2PA-signing vendors (issuer byte, resolved org name, the `identify` platform label, and a `synthid` flag), from which `C2PA_ISSUERS`, `SYNTHID_C2PA_ISSUERS` (issuers that pair SynthID with C2PA: Google, OpenAI), and `identify._ISSUER_PLATFORM` are all **derived** — plus `C2PA_SOFT_BINDINGS` (soft-binding `alg` prefix → forensic-watermark vendor: Adobe TrustMark, Digimarc, Imatag, Steg.AI, Microsoft, ...). Add a new C2PA vendor as one `C2PA_AI_VENDORS` entry (never edit the derived dicts), a new soft-binding to `C2PA_SOFT_BINDINGS`; not inline.
`noai/constants.py` — PNG_SIGNATURE, C2PA_CHUNK_TYPE, C2PA_SIGNATURES, and `C2PA_AI_VENDORS` — the single `C2paAiVendor` registry of C2PA-signing vendors (issuer byte, resolved org name, the `identify` platform label, and a `synthid` flag), from which `C2PA_ISSUERS`, `SYNTHID_C2PA_ISSUERS` (issuers that pair SynthID with C2PA: Google, OpenAI), and `identify._ISSUER_PLATFORM` are all **derived** — plus `C2PA_SOFT_BINDINGS` (soft-binding `alg` prefix → forensic-watermark vendor: Adobe TrustMark, Digimarc, Imatag, Steg.AI, Microsoft, ...). Add a new C2PA vendor as one `C2PA_AI_VENDORS` entry (never edit the derived dicts), a new soft-binding to `C2PA_SOFT_BINDINGS`; not inline. A vendor that signs under multiple legal names needs one entry PER distinctive issuer byte string: e.g. ByteDance's Volcano Engine is registered both as latin `volcengine` AND the Chinese legal entity `北京火山引擎科技有限公司` (UTF-8; the latin needle misses the Chinese-named certs entirely) — both normalize to the same "ByteDance" needle/platform. ElevenLabs ("Eleven Labs Inc.", pure generative-AI) is registered as a generator. A vendor may also set **`asserts_ai=True`** — its presence asserts AI generation even without a `trainedAlgorithmicMedia` digital-source-type; the derived `C2PA_IDENTITY_AI_ORGS` frozenset feeds `identify`, which lifts the AI verdict for such an issuer. Set it ONLY for a pure-generator brand with a distinctive issuer/generator string: **Dreamina** (ByteDance's international Jimeng brand, signed as "Bytedance Pte. Ltd." with a `Dreamina/x.y` claim generator and NO source-type — the caBX / store-JSON byte-scan sees the `Dreamina` token across active + ingredient manifests, where the active one is often a plain `c2pa-tool` transcode; verified on the retained corpus 2026-07; normalizes to the shared "ByteDance" needle/platform). Do NOT set `asserts_ai` on common-word issuers (Adobe/Google/OpenAI/Microsoft) — they appear incidentally in unrelated XMP/trust-chain bytes, so they must stay source-type-gated. Deliberately EXCLUDED (mined-corpus candidates 2026-06-20, documented in the file): TikTok Inc. (a content-provenance / AI-labeling signer on uploads, not a generator) and PixelBin.io / "Fynd" (an image transform / CDN signer) — registering either as a generator would mis-label human uploads as AI; the `is_ai` verdict keys off the digitalSourceType, which is already honored.
## `metadata.py`
@@ -27,7 +27,9 @@ module.
`identify.py` — the OpenAI rollout caveat is keyed on `_vendor_of(synthid) == "OpenAI"` (not a raw substring over the issuer + verdict blob). `identify(path)` aggregates every locally-readable signal (C2PA issuer→platform, C2PA soft-binding forensic-watermark vendor, **C2PA cloud-manifest reference** via `metadata.c2pa_cloud_manifest` — signal `c2pa_cloud`, **medium**, provenance-only (does NOT set `is_ai`, excluded from `ai_from_metadata` + clash vendors): a C2PA 2.4 Durable-Content-Credentials case where the embedded manifest is stripped but an XMP `dcterms:provenance` pointer to the vendor's cloud manifest store (`_C2PA_MANIFEST_REPOSITORIES`, today `cai-manifests.adobe.com` → "Adobe Content Authenticity") survives, so the credentials stay recoverable server-side; only emitted when no embedded manifest already attributed the file — surfaced on 2 corpus PNGs 2026-06-10 that read fully `unknown` before, IPTC "Made with AI" + IPTC 2025.1 `AISystemUsed`, embedded SD/ComfyUI params, SynthID proxy, xAI/Grok EXIF signature via `metadata.xai_signature`, the China TC260 AIGC label via `metadata.aigc_label`, the HuggingFace `hf-job-id` job marker via `metadata.huggingface_job`, the Samsung Galaxy AI editing marker via `metadata.samsung_genai`, the visible marks — Gemini sparkle plus the ByteDance Doubao 豆包AI生成 / Jimeng 即梦AI / Samsung Galaxy AI "Contenuti generati dall'AI" text marks via the `watermark_registry` — open invisible watermark, Adobe TrustMark via `trustmark_detector`) into one `ProvenanceReport`. `is_ai_generated` is True or None (never asserted False — stripped metadata is not proof of clean origin). The `hf_job`, visible-mark, and Samsung `samsung_genai` signals are **medium** confidence: each lifts an otherwise-Unknown verdict to a tentative AI (`hf_only` / `visible_only` / `samsung_only`, parallel branches; `visible_only` fires on any `visible_*` signal) but is excluded from the high-confidence `ai_from_metadata` set, so none overrides a hard metadata signal.
**Visible-mark detection** (`check_visible`, signals `visible_sparkle` / `visible_doubao` / `visible_jimeng` / `visible_samsung`): the Gemini sparkle keeps its own file-level path (`_visible_sparkle` → `gemini_engine.detect_sparkle_confidence`, promoted only at confidence ≥ `_SPARKLE_THRESHOLD` 0.5; corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49), while Doubao/Jimeng/Samsung reuse the registry detectors (`_visible_text_marks` → `watermark_registry`, iterating `_VISIBLE_MARK_PLATFORM`), each gated by its own engine NCC threshold via `MarkDetection.detected` (Doubao 0.4, Jimeng 0.45, Samsung 0.4). Doubao/Jimeng are normally also caught by the TC260 AIGC metadata label and Samsung by its C2PA + `genAIType` marker, so the visible path is their stripped-metadata fallback. Visible marks set `platform` only when no harder signal already did, and (like the sparkle) are excluded from integrity-clash vendor claims. The cv2 dependency lives in the engines, not here.
**AI-generated vs AI-enhanced** (`ProvenanceReport.ai_source_kind`, roadmap item): the C2PA digital-source-type is split into `"generated"` (trainedAlgorithmicMedia, fully synthetic) vs `"enhanced"` (compositeWithTrainedAlgorithmicMedia, a real photo with an AI-composited region) — the two byte strings are unambiguous (`compositeWithTrainedAlgorithmicMedia` capitalizes the inner "Trained", so a lowercase `trainedAlgorithmicMedia` match is standalone full generation; full generation wins when both appear). `ai_source_kind` is set only when the AI verdict actually came from the C2PA source type (a non-C2PA AI signal — IPTC/AIGC/local gen/xAI — leaves it None). It lets a caller branch a full-frame scrub (`generated`) from a region-targeted clean that preserves the real photo (`enhanced`; see `noai/tiling.feather_region_composite`). The CLI verdict line reads "AI-generated (fully synthetic)" vs "AI-enhanced (real content with an AI-composited region)".
**Visible-mark detection** (`check_visible`, signals `visible_sparkle` / `visible_doubao` / `visible_jimeng` / `visible_samsung`): the Gemini sparkle keeps its own file-level path (`_visible_sparkle` → `gemini_engine.detect_sparkle_confidence`, promoted only at confidence ≥ `_SPARKLE_THRESHOLD`, which is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (0.5) — imported, not a private copy, so the provenance detect threshold and the removal `detect_marks` / `_gemini_detect` arbitration gate can never drift (the detect-vs-remove desync from roadmap P0#7; regression-guarded by `tests/test_identify.py::TestSparkleDetectRemoveAlignment`, which composites the real demo sparkle at borderline opacities and asserts identify and `detect_marks` AGREE on either side of the line). Lowering the gate to recover faint sub-0.5 sparkles was evaluated 2026-06-20 and REJECTED: a real Doubao text mark scores ~0.40-0.42 as a gemini match with a HIGHER core-ring brightness margin than a genuine faint sparkle, so neither confidence nor the brightness gate separates them in the [0.35, 0.5) band — lowering trades a rare miss for false-positive removals on clean images. Corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49), while Doubao/Jimeng/Samsung reuse the registry detectors (`_visible_text_marks` → `watermark_registry`, iterating `_VISIBLE_MARK_PLATFORM`), each gated by its own engine NCC threshold via `MarkDetection.detected` (Doubao 0.4, Jimeng 0.45, Samsung 0.4). Doubao/Jimeng are normally also caught by the TC260 AIGC metadata label and Samsung by its C2PA + `genAIType` marker, so the visible path is their stripped-metadata fallback. Visible marks set `platform` only when no harder signal already did, and (like the sparkle) are excluded from integrity-clash vendor claims. The cv2 dependency lives in the engines, not here.
**`import identify` is deliberately light** (~26 MB; ~36 MB with cv2 loaded by a visible-mark run, ~106 MB for a full `check_visible` run): it imports the `noai.c2pa`/`noai.constants` submodules, and `noai/__init__` is lazy (see "Test and lint"), so torch/diffusers are NOT pulled at import even in a full `gpu`/`detect` install — fits a 512 MB host. `noai.c2pa` does eagerly import the **c2pa-python** binary (Rust + cryptography, ~+5 MB RSS, no torch) for the primary `Reader` path — light enough to stay on the dependency-light host; a broken/absent wheel degrades to the byte-scan parser (`reader_available()` False). The heavy paths are opt-in: `check_invisible=True` needs the `detect`/`trustmark` extras (each pulls **torch**; TrustMark also **downloads weights**), so on a core-only deploy leave `check_invisible` off (it is a no-op there anyway). Before the lazy `__init__`, the mere presence of torch in the env inflated `import identify` to ~420 MB.
@@ -39,9 +41,9 @@ module.
**Samsung Galaxy + ASUS Gallery live in a separate `_SIGNER_C2PA_PLATFORM` (scanned after `_device_platform`, before the issuer fallback), NOT in `_DEVICE_C2PA_PLATFORM`** — verified on real signed files 2026-05-29. Reason: a Galaxy phone stamps BOTH its device cert AND a `trainedAlgorithmicMedia`/genAIType AI marker on a Generative-Edit image, so treating it as a "genuine camera capture" would false-fire integrity-clash rule 2 on every Galaxy AI edit. The signer tokens (`b"Samsung Galaxy"` cert org — distinct from the EXIF `SM-xxxx` model string on ordinary Samsung photos; `b"com.asus.gallery"` claim generator) only resolve the platform label; the AI verdict still comes from the source-type / genAIType. ASUS Gallery is a C2PA-signed edit with no AI marker, so it attributes the platform without asserting `is_ai`.
**Samsung's `genAIType` (in the proprietary `PhotoEditor_Re_Edit_Data` JSON) is an undocumented Galaxy-AI editing marker** (`metadata.samsung_genai`, gated on the `PhotoEditor_Re_Edit_Data` container; non-zero value = AI tool used, values {1,5} observed): medium-confidence because the field has no public spec (verified 2026-05-29: absent from C2PA spec + Samsung docs), but it co-occurred with `trainedAlgorithmicMedia` in 3/3 verified files that record a source-type and was the SOLE AI marker on a Galaxy S24 file that omits the source type. Camera C2PA marks capture authenticity, not AI (Pixel carries `computationalCapture`, not `trainedAlgorithmicMedia`), so these never set `is_ai` -- that stays driven by digital-source-type. `c2pa.cbor_text_after` (now public) is best-effort for the `generator` detail string only and can be None when the manifest keys it `claim_generator_info` (Pixel).
**Samsung's `genAIType` (in the proprietary `PhotoEditor_Re_Edit_Data` JSON) is an undocumented Galaxy-AI editing marker** (`metadata.samsung_genai`, gated on the `PhotoEditor_Re_Edit_Data` container; non-zero value = AI tool used, values {1,5} observed; Galaxy AI appends it as a trailer AFTER the JPEG EOI, so `samsung_genai` reads the file TAIL when the 512 KB quick-scan head misses it — else a multi-MB photo's trailer past the window went undetected while removal, which reads the whole file, would still strip it; removal truncates the post-EOI Samsung trailer via `metadata._strip_samsung_trailer`, pixels bit-identical): medium-confidence because the field has no public spec (verified 2026-05-29: absent from C2PA spec + Samsung docs), but it co-occurred with `trainedAlgorithmicMedia` in 3/3 verified files that record a source-type and was the SOLE AI marker on a Galaxy S24 file that omits the source type. Camera C2PA marks capture authenticity, not AI (Pixel carries `computationalCapture`, not `trainedAlgorithmicMedia`), so these never set `is_ai` -- that stays driven by digital-source-type. `c2pa.cbor_text_after` (now public) is best-effort for the `generator` detail string only and can be None when the manifest keys it `claim_generator_info` (Pixel).
**Issuer→generator mapping is `is_ai`-gated** (`_attribute_platform(issuers, is_ai=c2pa_is_ai)`): a specific AI-generator platform is named only when the digital-source-type is `trainedAlgorithmicMedia`; on a non-AI source an issuer substring is treated as incidental (an "Adobe XMP" toolkit string in an *unmapped* Canon/Sony capture would otherwise mislabel it "Adobe Firefly"), so it degrades to the neutral "C2PA signer: X" label. Real Firefly/OpenAI/Google output carries the AI source-type, so it is unaffected (verified: chatgpt-1.png→OpenAI, firefly-1.png→Adobe Firefly still attribute). `_attribute_platform` defaults `is_ai=True` so the mapping stays unit-testable in isolation. Add capture-camera tokens to `_DEVICE_C2PA_PLATFORM`, editing-app/AI-device signer tokens to `_SIGNER_C2PA_PLATFORM`, generator/issuer platforms to the `C2PA_AI_VENDORS` registry in `constants.py` (which derives `_ISSUER_PLATFORM`), not inline. For non-PNG containers (JPEG/WebP/AVIF/HEIF/JXL) the caBX parser returns nothing, so issuer (`_issuers_in`) and generator (`_ai_tools_in`, reusing `C2PA_AI_TOOLS`) are recovered by binary-scanning the first MB. EXIF `Software` / `Make` / `Artist` / `ImageDescription` and XMP `CreatorTool` generator tags are read by `metadata.exif_generator` (PIL+piexif for any format PIL opens incl. AVIF, plus a container-agnostic XMP raw-byte scan that also covers HEIF/JXL), matched against `AI_GENERATOR_TOKENS` so ordinary editors (plain "Adobe Photoshop") and real-camera `Make` ("Apple"/"Canon") are not flagged.
**Issuer→generator mapping is `is_ai`-gated** (`_attribute_platform(issuers, is_ai=c2pa_is_ai)`): a specific AI-generator platform is named only when the digital-source-type is `trainedAlgorithmicMedia`; on a non-AI source an issuer substring is treated as incidental (an "Adobe XMP" toolkit string in an *unmapped* Canon/Sony capture would otherwise mislabel it "Adobe Firefly"), so it degrades to the neutral "C2PA signer: X" label. **The one exception is an identity-AI issuer** (`c2pa_is_ai = c2pa_source_kind is not None or c2pa_identity_ai`, where `c2pa_identity_ai` is any resolved issuer org in `C2PA_IDENTITY_AI_ORGS`): a vendor flagged `asserts_ai` (today only Dreamina) sets `c2pa_is_ai` True on its own, so its platform resolves even though the manifest carries no `trainedAlgorithmicMedia`. This is safe precisely because the flag is restricted to distinctive brand strings, not the incidental-mention-prone common words. Real Firefly/OpenAI/Google output carries the AI source-type, so it is unaffected (verified: chatgpt-1.png→OpenAI, firefly-1.png→Adobe Firefly still attribute). `_attribute_platform` defaults `is_ai=True` so the mapping stays unit-testable in isolation. Add capture-camera tokens to `_DEVICE_C2PA_PLATFORM`, editing-app/AI-device signer tokens to `_SIGNER_C2PA_PLATFORM`, generator/issuer platforms to the `C2PA_AI_VENDORS` registry in `constants.py` (which derives `_ISSUER_PLATFORM`), not inline. For non-PNG containers (JPEG/WebP/AVIF/HEIF/JXL) the caBX parser returns nothing, so issuer (`_issuers_in`) and generator (`_ai_tools_in`, reusing `C2PA_AI_TOOLS`) are recovered by binary-scanning the first MB. EXIF `Software` / `Make` / `Artist` / `ImageDescription`, XMP `CreatorTool`, and PNG `tEXt` chunks (`Software`/`Source`/`Title`/`Description` — NovelAI stamps its generator there, not EXIF) are read by `metadata.exif_generator` (PIL+piexif for any format PIL opens incl. AVIF, plus a container-agnostic XMP raw-byte scan that also covers HEIF/JXL), matched against `AI_GENERATOR_TOKENS` so ordinary editors (plain "Adobe Photoshop") and real-camera `Make` ("Apple"/"Canon") are not flagged. Tokens mined from the retained corpus 2026-06-22: `novelai`, `reve.com` (full token, not bare `reve`), `aphrodite ai` — all no-C2PA generator stamps that previously read as no-signal (and under the P0#5 no-signal skip would have skipped the scrub).
**Ideogram tags its output with EXIF `Make="Ideogram AI"`** (verified on a real download 2026-05-24) — that's why `Make` is read.
@@ -49,21 +51,27 @@ module.
**Rule 2's independence gate (added 2026-06-11):** a device that both captures and runs on-device generative AI (Google Pixel Magic Editor / Pixel Studio) records the capture AND the AI edit in ONE C2PA manifest — so the AI vendor is named only from that same manifest (`c2pa` issuer + `synthid` proxy, both `c2pa_manifest` source) — a legitimate edit chain, NOT a clash. Rule 2 therefore fires only when some `ai_vendor_claims` family has a source `!= "c2pa_manifest"` (EXIF/XMP generator, IPTC, TC260 AIGC, a second manifest naming AI on a camera capture — the real laundering tell). This killed a false-positive class on the corpus: 2 real Pixel generative-edit PNGs (`computationalCapture` + `trainedAlgorithmicMedia` + "Applied imperceptible SynthID watermark" in one Google manifest) read as camera-vs-AI clashes before the gate. Pure cameras (Leica/Sony/Nikon/Truepic) that do NOT generate AI still clash on any within-manifest AI marker only if it is independent — they never legitimately carry one, so the gate is behavior-neutral for them while fixing Pixel (regression-guarded by `test_identify.py::TestIntegrityClashesHelper::{test_pixel_generative_edit_same_manifest_no_clash,test_camera_plus_independent_ai_marker_still_clashes}` + `TestIntegrityClashEndToEnd::test_pixel_generative_edit_no_clash`).
**Independence is source-grouped (`_CLASH_SOURCE`, added 2026-06-02):** the C2PA issuer attribution (`c2pa`) and the SynthID proxy (`synthid`) are NOT independent — the proxy is inferred from the *same* manifest — so they share one source and two vendors named within a single manifest do not clash. This killed a false-positive class found on the spaces corpus: legitimate multi-actor manifests where a product wraps another vendor's engine (Microsoft Designer on OpenAI → `OpenAI, Microsoft`; Microsoft on Google → `Microsoft, Google LLC, Google C2PA Core Generator Library`) or an edit chain re-signs (Adobe over a Gemini original → Adobe c2pa + Google synthid) — 19 such files across the 2026-06-01/02 batches read as clashes before the fix. Rule 1 still fires when a manifest vendor disagrees with a genuinely independent stamp (EXIF/XMP generator, IPTC `AISystemUsed`, AIGC, xAI); each non-`c2pa`/`synthid` family is its own source (`test_identify.py::TestIntegrityClashes::{test_multi_actor_manifest_no_clash,test_manifest_vendor_vs_independent_signal_clashes}`). Vendor normalization is `_vendor_of` over `_AI_VENDOR_TOKENS` (so a C2PA "Google (Gemini)" issuer and a SynthID-Google proxy agree, while different vendors clash).
**Independence is source-grouped (`_CLASH_SOURCE`, added 2026-06-02):** the C2PA issuer attribution (`c2pa`) and the SynthID proxy (`synthid`) are NOT independent — the proxy is inferred from the *same* manifest — so they share one source and two vendors named within a single manifest do not clash. This killed a false-positive class found on the spaces corpus: legitimate multi-actor manifests where a product wraps another vendor's engine (Microsoft Designer on OpenAI → `OpenAI, Microsoft`; Microsoft on Google → `Microsoft, Google LLC, Google C2PA Core Generator Library`) or an edit chain re-signs (Adobe over a Gemini original → Adobe c2pa + Google synthid) — 19 such files across the 2026-06-01/02 batches read as clashes before the fix. Rule 1 still fires when a manifest vendor disagrees with a genuinely independent stamp (EXIF/XMP generator, IPTC `AISystemUsed`, AIGC, xAI); each non-`c2pa`/`synthid` family is its own source (`test_identify.py::TestIntegrityClashes::{test_multi_actor_manifest_no_clash,test_manifest_vendor_vs_independent_signal_clashes}`). Vendor normalization is `_vendor_of` over `_AI_VENDOR_TOKENS` (so a C2PA "Google (Gemini)" issuer and a SynthID-Google proxy agree, while different vendors clash). `_AI_VENDOR_TOKENS` covers ByteDance (all brands: bytedance/doubao/jimeng/dreamina/volcengine), Canva, ElevenLabs, and Black Forest Labs in addition to the OpenAI/Google/Adobe/... set — without them a transplanted ByteDance/Canva/BFL C2PA manifest next to an independent conflicting stamp was silently missed. **The generic `China AIGC (TC260)` label names no SPECIFIC vendor** (any Chinese generator applies it), so it cannot vendor-conflict in the spoofing sense: when a Chinese TC260-applying vendor (`_TC260_VENDORS`, today `{ByteDance}`) is co-attributed, Rule 1 attributes the label to that vendor (a legit Doubao image carries BOTH a ByteDance C2PA manifest and its own TC260 label and must not clash); against a NON-TC260 vendor (OpenAI etc.) the label stays generic and still clashes as a laundering tell (`test_bytedance_c2pa_plus_own_aigc_no_clash`, `test_foreign_vendor_plus_aigc_still_clashes`, `test_bytedance_c2pa_plus_foreign_generator_clashes`). Corpus-validated: 0 new clashes on 5000 ByteDance/AIGC/Canva/FLUX carriers.
**High-precision by design:** only hard generator stamps feed it (C2PA-issuer when source is AI, SynthID, EXIF/XMP generator, IPTC `AISystemUsed`, xAI, AIGC); the fuzzy visible sparkle and the open invisible watermark are **excluded** (the latter can be a by-product of our own SDXL removal pass). The c2pa vendor is classified from the issuer attribution / generator, NOT the resolved `platform` (a camera label like "Google Pixel" would mis-normalize to "Google"). All real single-origin fixtures (chatgpt/firefly/doubao/grok/mj) verified to produce **zero** clashes (false-positive guard in `test_identify.py::TestRealSamplesHaveNoClash`).
**`ai_from_metadata` field + `has_invisible_target` helper (P0#5, 2026-06-22):** the high-confidence union (everything that sets `confidence == "high"`: C2PA AI-issuer / SynthID proxy, IPTC, AIGC, local gen params, EXIF/xAI, open DWT-DCT / TrustMark — the medium-confidence `hf_only`/`visible_only`/`samsung_only` are excluded) is now surfaced as the public `ProvenanceReport.ai_from_metadata` boolean, so callers gate on intent rather than on the `confidence` string. `has_invisible_target(path)` wraps `identify(path, check_visible=False, check_invisible=True)` and returns that field — it is the decision gate for the diffusion scrub (the CLI `invisible`/`all`/`batch` no-signal skip, `cli._no_invisible_signal_exit`): a visible-only or no-signal image has it False, so regeneration (which would only degrade a clean image) does not run. It fails SAFE — any detector exception returns True so the removal still runs (leaving a watermark on a paid removal is worse than over-regenerating). It does NOT prove a pixel SynthID is absent (SynthID is detectable only via its metadata proxy, gone once stripped), so a False means "no locally-detectable target", never "clean". Guarded by `test_identify.py::{TestIdentifyRealSamples::test_has_invisible_target_*,TestHasInvisibleTargetFailSafe}`.
## `watermark_registry.py`
`watermark_registry.py` — **single catalog of known visible watermarks**, the unified "find known marks in their usual places, recognize, remove" entry.
**Reverse-alpha based by policy**: a mark is listed only once a real alpha map has been captured for it, and removal inverts that map (`original = (wm - a*logo)/(1-a)`) — Gemini recovers cleanly with no inpaint (its sparkle alpha comes from a pure-black capture, so it is near-exact), while **Doubao, Jimeng, and Samsung all add an always-on THIN residual inpaint** over the glyph footprint (their text marks re-rasterize + jitter a few px per image, so a single capture cannot pixel-cancel them; the inpaint blends into the reverse-alpha-recovered pixels). Arbitrary-region inpainting still lives in `region_eraser`/`erase`. Each `KnownMark` ties a key to {usual `location`, `in_auto` flag, `recovery` (="reverse-alpha"), a `detect` adapter → uniform `MarkDetection`, a `remove` adapter}. Entries today: `gemini` (bottom-right sparkle), `doubao` (bottom-right "豆包AI生成"), `jimeng` (bottom-right "★ 即梦AI"), and `samsung` (bottom-**LEFT** "✦ Contenuti generati dall'AI", Samsung Galaxy AI, Italian locale). `detect_marks` scans all; `best_auto_mark` picks the highest-confidence detection.
**Localize -> fill by policy (replaced reverse-alpha):** each mark is localized to a binary full-frame footprint mask (a `Localization`), and one shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). This replaced the old reverse-alpha removal (invert a captured alpha map, `original = (wm - a*logo)/(1-a)`, plus a thin residual inpaint) for ALL marks — gemini, doubao, jimeng, samsung, and jimeng_pill. **Why it changed:** reverse-alpha depended on a fixed captured alpha map at a fixed position, so it broke whenever a vendor moved or re-rendered its mark; and it was not color-lossless even with the right map (it amplifies 8-bit quantization and JPEG-chroma error by `1/(1-a)`), which showed up as "the color just changed, not removed" reports. Localize -> fill has a benign failure mode: a slightly-off localization just inpaints a small region near-losslessly instead of leaving a color-shifted smear. The captured alpha maps are still used to DETECT the marks and to shape the mask (gemini's footprint), but NOT for pixel recovery. Fill backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. Each `KnownMark` ties a key to {usual `location`, `in_auto` flag, a `_detect` callable → uniform `MarkDetection`, a `_mask` callable → full-frame footprint mask}; `KnownMark.remove(image, *, backend="auto", provenance=False, force=False)`. Entries today: `gemini` (bottom-right sparkle), `doubao` (bottom-right "豆包AI生成"), `jimeng` (bottom-right "★ 即梦AI"), `samsung` (bottom-**LEFT** "✦ Contenuti generati dall'AI", Samsung Galaxy AI, Italian locale), and the capture-less `jimeng_pill` (top-left "AI生成"). `detect_marks(image, *, provenance=frozenset())` scans all (strict, for the identify verdict); `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` removes every detected mark in one pass. **Sensitivity (`auto`/`strict`/`assume_ai`)** decides how hard a borderline mark is trusted: the visual detectors are pixel-based (no metadata needed) and the recall gain comes from relaxing the false-positive gate, not from metadata. `resolve_relax` turns the policy + evidence into the per-mark relaxation boolean — `strict` never relaxes, `assume_ai` always relaxes (the caller asserts AI, e.g. a metadata-stripped screenshot; ~46% -> ~92% Gemini recall, at the cost of a small near-lossless fill on some clean corners), `auto` relaxes only on same-product evidence (metadata provenance for that vendor, or a confidently strict-detected sibling of the same `_PRODUCT_OF` — Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax). **Perception / decision / action are separated three ways** (the removal path only; `identify` keeps calling `KnownMark.detect` directly, so its verdict is untouched): `_build_candidates(image)` is PERCEPTION — it runs each detector at both trust levels and packages raw verdicts + the pill's flatness feature into `Candidate`s, no policy; `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` is the pure DECISION arbiter — all keep/drop policy (`resolve_relax` cross-mark corroboration + the pill gate) in one image-free, unit-testable function (`tests/test_watermark_registry.py::TestArbiter`); then `remove_auto_marks` does the ACTION, localizing -> filling each winner. The Gemini FP gate deliberately stays inside `gemini_engine` (not the arbiter) because `identify` reads that same gated confidence — pulling it out would drift the provenance verdict. Behavior is byte-identical to the pre-arbiter two-pass (corpus-verified: strict/auto 46%, assume_ai 92% Gemini recall unchanged).
**Cross-engine confidences aren't directly comparable**, so the gemini adapter applies the corpus-validated 0.5 sparkle threshold (`_GEMINI_AUTO_MIN_CONF`) for its `detected` flag — otherwise the gemini engine's loose internal threshold weakly fires (~0.36) on the Doubao text and hijacks `auto`. The shape-keyed Doubao/Jimeng/Samsung NCC detectors don't cross-fire (jimeng scores ~0.22 on the Doubao strip, well under its 0.45 threshold; Samsung is bottom-left so it shares no corner with the others, and scored 0.0 on Doubao/Jimeng captures and they 0.0 on a real Samsung photo), so `auto` picks the right one. `cli.cmd_visible` is registry-driven: `--mark auto` → `best_auto_mark`, `--mark <key>` → that mark; `--mark` choices come from `mark_keys()`.
**Head-to-head validation (v0.12.1 reverse-alpha vs the current localize -> fill):** run over the full labelled visible-mark set, with the cv2 / MI-GAN / LaMa fills each compared against the old reverse-alpha. **doubao and jimeng are identical** across every backend -- 100% coverage and 100% clearance either way. **gemini** strict coverage is a few points below reverse-alpha's (the deliberate false-positive tightening), but the metadata-stripped faint ones are now mostly recovered by the DEFAULT white-core rescue in the FP gate (`gemini_engine`: a bright near-WHITE core distinguishes a real faint sparkle from a colored bright corner -- ~14/20 recovered at ~1.25% clean false-fire; a learned classifier on the same features measured worse, 2026-07 tier-1), the residual under `assume_ai`; clearance is equal (~98% both), and neither version touches pixels outside the mark box (outside-box PSNR ~99). **Clearance is fill-independent** -- cv2, MI-GAN and LaMa all strip the mark's shape equally, so the re-detect metric does not separate them; the difference is purely the *visual fill quality* on the recovered region, and it is background-dependent. reverse-alpha recovered textured and especially regular/structured backgrounds (a lattice, a grid) more cleanly than any inpaint; **LaMa closes most of that gap** (the best learned backend), **MI-GAN can ghost or hallucinate structure**, and **cv2 smears** (the last-resort floor). This is why `auto` resolves `LaMa > MI-GAN > cv2` (`preferred_inpaint_backend`) and warns once on the cv2 fallback; on flat backgrounds every backend is clean.
**`cli._remove_visible_auto` is the shared visible-removal helper used by `cmd_all`/`cmd_batch` too** (they no longer hardcode `GeminiEngine`), so `all`/`batch` remove Doubao/Jimeng/Samsung text marks, not just the Gemini sparkle (regression-guarded by `test_all_visible_step_uses_registry`). The three text-mark adapters were consolidated 2026-06-09: a single `_text_mark(key, label, location)` builds the registry row from one parameterized `_text_mark_detect`/`_text_mark_remove` pair (reverse-alpha only when detected/forced AND `reverse_alpha_available`, else skipped — no inpaint); the gemini adapters stay bespoke. Add a new visible mark = one `_text_mark(...)` row + its `TextMarkConfig` (with a captured alpha map); do not re-add per-mark `if` branches or copy-paste adapters.
**Provenance prior:** when local metadata already confirms the vendor, the mark's detection trust gate is relaxed (a confirmed vendor means the mark is present with high prior, so a mark the conservative detector would demote as a content false positive is trusted). `detect_marks` / `remove_auto_marks` take a `provenance` frozenset and `KnownMark.remove` a `provenance` flag. Mapping: a Google/Gemini C2PA issuer relaxes gemini (skips its false-positive gate and lowers the trust threshold from 0.5 to 0.35); a China-AIGC (TC260) label relaxes doubao/jimeng; `samsung_genai` relaxes samsung. Corpus finding: on Google-C2PA images, Gemini sparkle recall rose from ~46% (plain detector) to ~90% with the provenance prior (recovering marks the vendor moved or re-rendered). The localizer is cheap CPU (cv2/numpy), so a memory-tight caller runs it anywhere; the heavy MI-GAN/LaMa fill is opt-in and chosen by the caller.
**Alpha-on-save policy (issue #30):** `cli._write_bgr_with_alpha` rejoins the input's alpha plane **unchanged** — it must NOT zero alpha in the watermark bbox. Reverse-alpha (and `erase` inpaint) recover real pixels there, so zeroing alpha punched a transparent hole that renders as a solid **white box** on any non-transparent viewer (Gemini app exports are opaque RGBA, so every user hit it; regression-guarded by `test_visible_keeps_alpha_opaque_in_watermark_region`). The registry `remove()` still returns its region (used for `inpaint_residual` positioning), but the CLI no longer uses it to clear alpha.
**Cross-engine confidences aren't directly comparable**, so the gemini adapter applies the corpus-validated 0.5 sparkle threshold (`_GEMINI_AUTO_MIN_CONF`) for its `detected` flag (lowered to 0.35 under the Google/Gemini provenance prior) — otherwise the gemini engine's loose internal threshold weakly fires (~0.36) on the Doubao text and hijacks `auto`. The shape-keyed Doubao/Jimeng/Samsung NCC detectors don't cross-fire (jimeng scores ~0.22 on the Doubao strip, well under its 0.45 threshold; Samsung is bottom-left so it shares no corner with the others, and scored 0.0 on Doubao/Jimeng captures and they 0.0 on a real Samsung photo), so `auto` picks the right one. `cli.cmd_visible` is registry-driven: `--mark auto` → `remove_auto_marks` (removes every detected mark), `--mark <key>` → that mark; `--mark` choices come from `mark_keys()`.
**`cli._remove_visible_auto` is the shared visible-removal helper used by `cmd_all`/`cmd_batch` too** (they no longer hardcode `GeminiEngine`), so `all`/`batch` remove Doubao/Jimeng/Samsung text marks, not just the Gemini sparkle (regression-guarded by `test_all_visible_step_uses_registry`). The three text-mark adapters were consolidated 2026-06-09: a single `_text_mark(key, label, location)` builds the registry row from one parameterized `_text_mark_detect`/`_text_mark_remove` pair (the remove adapter localizes the glyph footprint and hands it to the shared `fill` only when detected/forced, else skipped); the gemini adapters stay bespoke. Add a new visible mark = one `_text_mark(...)` row + its `TextMarkConfig` (with a captured alpha map for the detection silhouette); do not re-add per-mark `if` branches or copy-paste adapters.
**Alpha-on-save policy (issue #30):** `cli._write_bgr_with_alpha` rejoins the input's alpha plane **unchanged** — it must NOT zero alpha in the watermark bbox. The fill reconstructs real pixels there, so zeroing alpha punched a transparent hole that renders as a solid **white box** on any non-transparent viewer (Gemini app exports are opaque RGBA, so every user hit it; regression-guarded by `test_visible_keeps_alpha_opaque_in_watermark_region`). The registry `remove()` still returns its region, but the CLI no longer uses it to clear alpha.
## `gemini_engine.py`
@@ -79,85 +87,61 @@ module.
The cost (mislabel ~8-33% of non-Gemini content as Gemini) outweighs the benefit -- the visible sparkle is a medium-confidence stripped-metadata fallback, and intact Gemini is caught by C2PA in `identify` regardless. Remaining square misses are an accepted known limitation; a real fix would need a sparkle-specific discriminator (template match on a background-subtracted image, or a hard fixed-margin position prior), which is open research, not a threshold tweak.
**Removal is reverse-alpha with an over-subtraction guard** (`remove_watermark` → `_reverse_alpha_blend`, else `_inpaint_footprint`): the sparkle alpha is computed (`alpha = max(R,G,B)/255`) from the bundled sparkle-on-black captures `assets/gemini_bg_{96,48}.png` (the capture max is ~130, NOT 255 — the sparkle is a ~51%-opaque white overlay, so `alpha` maxes at ~0.51, which is CORRECT for the capture, not under-exposed). The alpha is near-exact only when the real mark's effective opacity matches the capture, which holds on bright/flat backgrounds — re-verified clean on `demo_banana_before.png` 2026-05-31.
**Removal is localize -> fill** (`footprint_mask` → `watermark_registry.fill`): `footprint_mask` returns the sparkle footprint = the captured alpha (computed `alpha = max(R,G,B)/255` from the bundled sparkle-on-black captures `assets/gemini_bg_{96,48}.png`, capture max ~130 for the ~51%-opaque overlay) thresholded LOW so the faint halo is included, then dilated by a sparkle-relative margin. That binary mask is inpainted by the shared fill (cv2 / MI-GAN / big-LaMa). The captured alpha maps are used only to detect and to shape the mask, not for pixel recovery. This replaced the old reverse-alpha removal path; because the fill only reconstructs the masked footprint from its surroundings (rather than dividing by `1-a`), the whole reverse-alpha removal tail — the over-subtraction guard (`_reverse_alpha_oversubtracts`, the dark-background black-pit fix), the under-subtraction alpha-gain estimate (`_estimate_alpha_gain`), and the self-verify repair — is GONE, along with the near-white `1/(1-a)` ill-conditioning and the "color changed, not removed" failure mode those guards patched around. A slightly-off localization now just fills a small region near-losslessly instead of leaving a color-shifted smear.
**Issue #30 (dark-background black pit):** on a dark/textured background (e.g. grass, ~73) the real sparkle's effective opacity is LOWER than the captured 0.51, so the fixed-alpha reverse blend OVER-subtracts (`watermarked - a*logo` goes negative) and drives the footprint to black — the white sparkle becomes a black diamond. `remove_watermark` now detects this via `_reverse_alpha_oversubtracts` (fraction of footprint pixels with `alpha >= _FOOTPRINT_ALPHA` 0.1 whose numerator < 0 exceeds `_OVERSUB_FOOTPRINT_FRAC` 0.05) and **inpaints the footprint** (`_inpaint_footprint`, cv2 NS over the dilated alpha mask) from the surrounding pixels instead.
**False-positive gate (added 2026-06-03):** `detect_watermark`'s shape-only NCC (`spatial*0.5 + gradient*0.3 + var*0.2`) fires on ornate/flat content (text strips, banners, hatching) that coincidentally matches the diamond shape — a real Gemini sparkle is a bright WHITE overlay, so its core sits above the local background, but the NCC is contrast-invariant and cannot see that. The fusion now **demotes** (caps confidence to 0.30) any low-confidence (`< _SPARKLE_FP_CONF` 0.65) match that shows NEITHER real-sparkle signature: a bright core (`_core_ring_margin >= _SPARKLE_FP_MARGIN` 5) OR a crisp star silhouette (`gradient_score >= _SPARKLE_FP_GRAD` 0.55). I.e. demote when `low_margin OR low_grad`. Real sparkles escape via high confidence (white-bg sparkles score ≥0.79 despite a low margin — the NCC shape match is strong), high margin (dark/mid backgrounds, incl. the #36 faint-corner case, lift well clear), OR high gradient (a real sparkle is grad ~0.97–1.0). **The gradient condition (added 2026-06-26) closes the bright-background FP class** the margin check alone missed: a snow+sky photo and a white-background product render both scored ~0.51 at `identify`, because a bright background gives the match a HIGH core-ring margin (it genuinely IS brighter than its surroundings), so the brightness gate read it as a real overlay — but a smooth luminance blob that shape-NCC-matches the rough diamond has low gradient fidelity (the two FPs measured grad 0.105 and 0.463 vs ≥0.8 for real sparkles), so the gradient floor demotes them. The OR is **strictly a superset** of the old margin-only demotion (it only ADDS demotions on bright backgrounds, where a real sparkle keeps grad ~0.97), so it cannot regress a dark/mid sparkle (kept by margin) or a white-bg one (kept by confidence ≥ 0.65). The gate is **monotonic** (only ever removes detections, never adds), so it cannot regress the verified-negative corpus (already 0 FPs); the 2026-06-26 corpus re-sweep flipped only OpenAI/ChatGPT content (no Gemini sparkle exists there) and already-`cleaned/` outputs, all sub-0.5 (below the `identify` threshold), so no provenance verdict changed. The original gate demoted 16/495 flagged sparkles on the validation corpus (13 carried no AI metadata = content FPs; the 3 AI-meta were visually FPs / a near-invisible white-on-white sparkle whose AI verdict is held by metadata anyway). `_core_ring_margin` uses the `_core_and_bg` helper (core 75th-pct brightness vs background-ring median). This gate is detection-side and unchanged by the localize -> fill refactor; the provenance prior skips it when a Google/Gemini C2PA issuer confirms the vendor. Regression-guarded by `test_gemini_engine.py::TestSparkleFalsePositiveGate` (incl. `test_bright_background_low_gradient_match_demoted`).
**Behavior-neutral on the working case:** a bright background over-subtracts at ~0% so reverse-alpha is used and the output is byte-identical to before (verified: demo_banana 0.0 frac vs issue-#30 grass 0.61 frac; regression-guarded by `test_gemini_engine.py::TestOverSubtractionGuard`, which composites the sparkle at a reduced effective alpha to reproduce the mismatch).
**The reverse-alpha removal tail is retired.** The self-verify repair (`_verify_and_repair`), the offset+scale alignment search, and the near-white `1/(1-a)` ill-conditioning survivors were all artifacts of solving the sparkle by inverting the alpha map. Under localize -> fill the footprint is reconstructed from its surroundings by the shared fill, so those failure classes and their guards no longer exist. The lesson from that era still holds and generalizes: a re-detect-confidence audit metric is gameable by reshaping the residual, so judge a visible removal by physical inspection of the footprint, not the detector alone.
**Under-subtraction (the symmetric case, fixed 2026-06-03):** some real Gemini sparkles are rendered MORE opaque than the captured ~0.51, so the fixed-alpha reverse blend UNDER-subtracts and leaves a bright sparkle residual the detector still fires on (measured on the spaces corpus: a visible-removal audit through the registry path left a detectable sparkle on a meaningful fraction of marks, all under-removals, NOT a background-brightness class — failures and successes had the same input confidence and the same background-luma distribution; the discriminator was the removal delta itself). `remove_watermark` now estimates a per-image alpha gain (`_estimate_alpha_gain`: effective sparkle opacity at the bright core vs the local background ring, `a_eff/a_cap`, clamped `[1.0, _ALPHA_GAIN_MAX` 1.94`]`) and scales the alpha to match before the over-sub/blend branch. The gain cleanly separates on the corpus (under-removed marks ~1.47, cleanly-removed ~1.00), and a deadband (`_ALPHA_GAIN_DEADBAND` 1.05) keeps a matching sparkle **byte-identical** to the pre-fix output, so the fix is purely additive (0 regressions on the audit set; the over-sub guard still runs on the scaled alpha as the safety net for an over-shooting estimate). Regression-guarded by `test_gemini_engine.py::TestUnderSubtractionGain` (composites a more-opaque-than-capture sparkle; **asserts on footprint pixels, NOT the detector** — the detector's NCC is degenerate on a flat synthetic background, so a re-detect conf is meaningless there; the real corpus removal drops the detector from ~0.80 to ~0.27).
**False-positive gate (added 2026-06-03):** `detect_watermark`'s shape-only NCC (`spatial*0.5 + gradient*0.3 + var*0.2`) fires on ornate/flat content (text strips, banners, hatching) that coincidentally matches the diamond shape — a real Gemini sparkle is a bright WHITE overlay, so its core sits above the local background, but the NCC is contrast-invariant and cannot see that. The fusion now **demotes** (caps confidence to 0.30) any match that is BOTH low-confidence (`< _SPARKLE_FP_CONF` 0.65) AND has a low core-ring brightness margin (`_core_ring_margin < _SPARKLE_FP_MARGIN` 5). Real sparkles escape via EITHER high confidence (white-bg sparkles score ≥0.79 despite a low margin — the NCC shape match is strong) OR high margin (dark/mid backgrounds, incl. the #36 faint-corner case, lift well clear), so BOTH must fail to demote. The gate is **monotonic** (only ever removes detections, never adds), so it cannot regress the verified-negative corpus (already 0 FPs). On the spaces corpus it demoted 16/495 flagged sparkles (13 carried no AI metadata = content FPs; the 3 AI-meta were visually FPs / a near-invisible white-on-white sparkle whose AI verdict is held by metadata anyway), and dropped the removal-audit failures 20→15 (post-removal flat footprints the NCC re-fired on). `_core_ring_margin` and `_estimate_alpha_gain` share the `_core_and_bg` helper (core 75th-pct brightness vs background-ring median). Regression-guarded by `test_gemini_engine.py::TestSparkleFalsePositiveGate`.
**Self-verify repair (added 2026-06-04):** the gain estimate corrects most under-subtractions, but a tail of strong sparkles still survived reverse-alpha (position jitter, or a gain the `[1.0, 1.94]` clamp could not fully reach). After the reverse blend, `remove_watermark` re-detects via `_verify_and_repair`; when a sparkle at or above `_VERIFY_FALLBACK_CONF` 0.5 (the registry's real fail line) remains, it inpaints the footprint and **keeps that only when it lowers the re-detect confidence** — purely additive (the common clean removal re-detects below 0.5 and is returned untouched, so it can never regress). On the spaces corpus this rescued **4 of the 15 remaining gemini removal-audit failures** (15→11, doubao/jimeng still 0), verified through the registry/CLI path. Costs one extra `detect_watermark` per removal (two when the fallback fires). Regression-guarded by `test_gemini_engine.py::TestVerifyAndRepair` (stubs `detect_watermark` to drive the keep-best control flow, since the NCC is degenerate on flat synthetics).
**An offset+scale alignment search was prototyped on the remaining 11 fails and REJECTED (2026-06-04):** an audit "ceiling" test suggested it could rescue 4 more (e.g. a5a9 0.577→0.417), but direct inspection showed those were NCC-gaming, not removal — the lower-scoring placement left the sparkle as bright or BRIGHTER (a5a9: first-pass slot 99.5th-pct ~76 at background level, the "aligned win" slot ~164), it just reshaped the residual so the contrast-invariant shape-NCC scored lower. A slot-brightness sanity gate rejected every one, so alignment contributed 0 genuine rescues and was removed (the footprint inpaint stays because it physically reconstructs the slot from its darker surroundings, so its rescues are real).
**Lesson: the visible-audit pass/fail metric (re-detect conf < 0.5) is gameable by reshaping the residual — optimizing it directly finds NCC-gaming placements, not clean removals; gate any removal candidate on a physical brightness check, not the detector alone.**
The 11 survivors are near-white ill-conditioning (reverse-alpha divides by `1-a`≈0.02) or detector false positives (before≈after≈0.51) that no reverse-alpha placement fixes. The registry's optional `inpaint_residual` (edge cleanup) is a no-op on a clean reverse-alpha removal (and on the same corpus it lowered the re-detect conf on 3 marks, raised it on 10, no-op on 466 — net-neutral on pass/fail, so the self-verify repair, not it, drives the removal tail); an earlier "Gemini smears" read was a misjudged soft-fur original, not an artifact.
**The bg assets are now rebuilt from OUR OWN controlled captures** (`data/gemini_capture/captures/`, committed) by `scripts/visible_alpha_solve.py gemini`, which locates the 96px sparkle on the black capture and crops it to the two logo sizes; our capture matched the previously third-party-sourced `gemini_bg_96.png` to **NCC 0.9998**, validating the asset and making it reproducible. Gemini's multi-size fixed-slot model is genuinely different from the Doubao/Jimeng text-strip engines (so it stays a separate engine, not part of the shared-base refactor).
**The bg assets are rebuilt from OUR OWN controlled captures** (`data/gemini_capture/captures/`, committed) by `scripts/visible_alpha_solve.py gemini`, which locates the 96px sparkle on the black capture and crops it to the two logo sizes; our capture matched the previously third-party-sourced `gemini_bg_96.png` to **NCC 0.9998**, validating the asset and making it reproducible. Gemini's multi-size fixed-slot model is genuinely different from the Doubao/Jimeng text-strip engines (so it stays a separate engine, not part of the shared-base refactor).
## `_text_mark_engine.py`
`_text_mark_engine.py` — **shared base for the three reverse-alpha text-mark engines (Doubao/Jimeng/Samsung), extracted 2026-06-09** (they were ~90% byte-identical clones). `TextMarkEngine(config: TextMarkConfig)` owns the whole `locate → extract_mask → detect → _fixed/_aligned_alpha_map → _apply_reverse_alpha → remove_watermark_reverse_alpha` pipeline (+ the asset-keyed `load_alpha_template`/`glyph_silhouette`/`template_match_score` caches). Each engine module is now a thin subclass: it supplies only its `TextMarkConfig` (the tuned constants, the bundled asset, and the bounded structural deltas — `corner` br/bl, `margin_floor` 4/2, `morph_open_size` 5/3, `min_gw` 8/16) plus the test-facing module shims (`_alpha_template`/`_glyph_silhouette`/`_template_match_score` + the constants). Behavior is byte-exact vs the old per-engine code (the three engine test suites pass unchanged). Gemini stays a SEPARATE engine (its multi-size fixed-slot sparkle model is genuinely different). Add a new text mark = a new `TextMarkConfig` + a thin subclass + one registry `_text_mark(...)` row. The engine bullets below describe each mark's calibration history; the LOGIC lives here.
`_text_mark_engine.py` — **shared base for the three text-mark engines (Doubao/Jimeng/Samsung), extracted 2026-06-09** (they were ~90% byte-identical clones). `TextMarkEngine(config: TextMarkConfig)` owns the `locate → extract_mask → detect` detection pipeline plus the removal that localizes the glyph blob to a footprint mask and hands it to the shared `watermark_registry.fill` (+ the asset-keyed `load_alpha_template`/`glyph_silhouette`/`template_match_score` caches). Detection still matches the glyph silhouette (NCC against the captured template); the removal MASK is TEMPLATE-FREE — it is the bounding box of the top-hat glyph blob from `extract_mask`, filled solid + dilated, so a re-rendered or differently-placed mark is still masked. This dropped the fixed alpha-template placement; the captured alpha maps are now used only for the detection silhouette, not for removal. Each engine module is a thin subclass supplying only its `TextMarkConfig` (the tuned constants, the bundled asset, and the bounded structural deltas — `corner` br/bl, `margin_floor` 4/2, `morph_open_size` 5/3, `min_gw` 8/16) plus the test-facing module shims (`_alpha_template`/`_glyph_silhouette`/`_template_match_score` + the constants). Gemini stays a SEPARATE engine (its multi-size fixed-slot sparkle model is genuinely different). Add a new text mark = a new `TextMarkConfig` + a thin subclass + one registry `_text_mark(...)` row. The engine bullets below describe each mark's calibration history; the LOGIC lives here. **Small-image detection guard (`_MIN_DETECT_SHORT_SIDE` 200, added 2026-06-26):** `detect` returns not-detected when the image short side is below 200px. Below that the glyph template degrades to the `min_gw` floor (~8px) and `TM_CCOEFF_NORMED` on a few pixels is noise, so an unrelated small geometric shape can spuriously correlate with the CJK silhouette — a 48×48 app-icon chevron scored Doubao 0.41 / Jimeng 0.47 (both above their thresholds), a pure small-size artifact (the same icon upscaled collapses to ~0.06–0.10 NCC at ≥256px). A real AI-generation label is stamped on a full-resolution render (the captured samples are 1086–2048px wide, the smallest positive test image is 1086px), so the floor sits far below any genuine mark while killing the icon/thumbnail band (≤96px); `identify` falls back to "unknown" (the safe default) and removal, gated on detection, is suppressed too. Regression-guarded by `test_{doubao,jimeng,samsung}_engine.py::TestDetect::test_small_image_guarded_from_false_positive`.
**`_apply_reverse_alpha` runs on the glyph crop only:** `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.
**Removal is localize -> fill.** The engine localizes the glyph blob (`extract_mask` over the located box) into a solid, dilated footprint mask and hands it to the shared `watermark_registry.fill` (cv2 / MI-GAN / big-LaMa). The template-free mask (bounding box of the glyph blob, not the fixed alpha template) means a re-rendered or moved mark is still covered, and the fill reconstructs the box from its surroundings. On corpus images doubao and jimeng localize + remove at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit); clean images with no vendor signature had 0% false removal.
**The reverse-alpha removal machinery is retired.** The old per-glyph reverse-alpha blend (`_apply_reverse_alpha`), the fixed/aligned alpha-map helpers, the over-subtraction guard (`_reverse_alpha_oversubtracts` → `_inpaint_footprint`, the dark-pit fix on dark/mid-tone backgrounds), and the always-align placement search are all gone — the fill reconstructs the footprint from its surroundings rather than inverting the captured alpha, so the dark-pit and color-shift failure modes those guards patched around no longer arise. `extract_mask` still returns a box-sized (`(loc.h, loc.w)`) mask rather than a full frame, which keeps the memory-tight `identify` detect path cheap.
## `doubao_engine.py`
`doubao_engine.py` — **a thin `_text_mark_engine.TextMarkEngine` subclass (config only) since 2026-06-09.** visible Doubao "豆包AI生成" remover/detector (cv2/numpy, no GPU). `DoubaoEngine.locate` anchors a bottom-right box by **geometry** (mark scales with image WIDTH), `extract_mask` pulls the light, low-chroma glyphs (the detection candidate) using a per-pixel channel-spread proxy `sat = roi.max(axis=2) - roi.min(axis=2)` (no HSV conversion). `detect` is **shape-consistent**: it matches the bundled alpha glyph silhouette (`assets/doubao_alpha.png`) against the candidate via zero-mean normalized correlation (`_template_match_score`, cv2 `TM_CCOEFF_NORMED`), gated at `DETECT_NCC_THRESHOLD` 0.4 over a small `DETECT_MIN_COVERAGE` floor. Keying on glyph SHAPE (not coverage heuristics) fixed #23 (corpus FP 7/1243).
`doubao_engine.py` — **a thin `_text_mark_engine.TextMarkEngine` subclass (config only) since 2026-06-09.** visible Doubao "豆包AI生成" detector + localizer (cv2/numpy, no GPU). `DoubaoEngine.locate` anchors a bottom-right box by **geometry** (mark scales with image WIDTH), `extract_mask` pulls the light, low-chroma glyphs (the detection candidate) using a per-pixel channel-spread proxy `sat = roi.max(axis=2) - roi.min(axis=2)` (no HSV conversion). `detect` is **shape-consistent**: it matches the bundled glyph silhouette (`assets/doubao_alpha.png`) against the candidate via zero-mean normalized correlation (`_template_match_score`, cv2 `TM_CCOEFF_NORMED`), gated at `DETECT_NCC_THRESHOLD` 0.4 over a small `DETECT_MIN_COVERAGE` floor. Keying on glyph SHAPE (not coverage heuristics) fixed #23 (corpus FP 7/1243).
**Removal = reverse-alpha + thin residual inpaint** (`remove_watermark_reverse_alpha`): `original = (wm - a*logo)/(1-a)` from the bundled alpha map + `_ALPHA_LOGO_BGR` (pure white) + `_ALPHA_*_FRAC` geometry, then a deliberately THIN inpaint (`_RESIDUAL_*`, `INPAINT_NS`) over the glyph footprint clears leftover edges without smearing.
**Removal is localize -> fill:** the glyph blob is localized to a solid, dilated footprint mask (`extract_mask` over the located box) and the shared `watermark_registry.fill` inpaints it. On corpus images this removes at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit).
**Alpha is rebuilt by `scripts/visible_alpha_solve.py` (the careful gray-self solve: cubic background fit, mean over channels, full halo, unblurred), same recipe as Jimeng** — the captures are committed in `data/doubao_capture/captures/`.
**The detection template (`assets/doubao_alpha.png`) is rebuilt by `scripts/visible_alpha_solve.py`** (the careful gray-self solve: cubic background fit, mean over channels, full halo, unblurred), same recipe as Jimeng — the captures are committed in `data/doubao_capture/captures/`. It is used only as the detection silhouette, not for pixel recovery.
**Removal aligns ALWAYS** (no `_ALPHA_NATIVE_BAND` fast-path): it tries fixed geometry AND `_aligned_alpha_map`'s `TM_CCOEFF_NORMED` scale+position search and keeps the lower-residual one — the mark is re-rasterized and a few px off per image, so fixed geometry alone leaves a visible outline even at 2048.
**The locate box (`WM_*`) is generous (0.22 wide, margins 0.004) and reaches close to the corner** — a tight box (the old 0.185 / margin 0.012) let a corner-ward shift fall OUTSIDE the alignment search, so the align missed and a readable outline survived; regression-guarded by `test_recovers_shifted_mark_on_texture` (composes the alpha shifted on a known texture; old box ~29 vs new ~1 mean residual).
**Issue #13 follow-up defect (found 2026-05-31): the SHIPPED Doubao removal left a clearly READABLE "豆包AI生成" outline on the real `doubao-1.png` sample, while `detect` returned conf 0.0 (it is fooled by a thin outline) so `test_reverse_alpha_removes_mark` passed and the old "56/56 clean" claim was detector-measured, not visual.**
Root cause: bad alpha (under-estimated, max ~0.65) + fixed-no-inpaint + tight box; the careful rebuild + always-align + thin inpaint + wide box takes it from a readable outline to faint texture-level traces (parity with Jimeng — a single capture cannot pixel-cancel a per-image re-rasterized mark).
**Lesson: a detector-only removal test is insufficient; assert visual residual (the textured-shift test).** **`extract_mask` guards a degenerate ROI (`bh < 16 or bw < 16` -> empty mask, skips cv2):** the always-align removal scores each placement with a residual `detect(out)`, and on an extremely wide/short image (e.g. 2048x1, `test_wide_short_does_not_raise`) that fed cv2's GaussianBlur a ~1-px-tall ROI and **faulted natively on Windows py3.12 (access violation, non-deterministic — one CI cell went red while a re-run passed)**; the old at-native path never ran `detect` on degenerate sizes. Real images always clear the guard (the `WM_*` box floors are `max(16, …)` height / `max(40, …)` width), so it only short-circuits slivers. `reverse_alpha_available` is just "asset present"; the registry gates removal on `detect`. The shipped third-party `_refs/zhengsuanfa_doubao_alpha_120x20.png` is NOT a usable alpha (verified 2026-05-29). Arbitrary-region inpainting is `region_eraser`/`erase`.
**The locate box (`WM_*`) is generous (0.22 wide, margins 0.004) and reaches close to the corner** so a re-rasterized, corner-ward-shifted mark still falls inside the localized box; regression-guarded by `test_recovers_shifted_mark_on_texture` (composes the mark shifted on a known texture). **`extract_mask` guards a degenerate ROI (`bh < 16 or bw < 16` -> empty mask, skips cv2)** — an extremely wide/short image (e.g. 2048x1, `test_wide_short_does_not_raise`) once fed cv2's GaussianBlur a ~1-px-tall ROI and **faulted natively on Windows py3.12**; real images always clear the guard (the `WM_*` box floors are `max(16, …)` height / `max(40, …)` width), so it only short-circuits slivers. The registry gates removal on `detect`. The shipped third-party `_refs/zhengsuanfa_doubao_alpha_120x20.png` is NOT a usable template (verified 2026-05-29). Arbitrary-region inpainting is `region_eraser`/`erase`. **Lesson from the reverse-alpha era (still holds): a detector-only removal test is insufficient; assert visual residual (the textured-shift test).**
## `jimeng_engine.py`
`jimeng_engine.py` — **a thin `TextMarkEngine` subclass (config only) since 2026-06-09.** visible Jimeng / Dreamina "★ 即梦AI" remover/detector (cv2/numpy, no GPU), built 2026-05-30 from issue #13's solid captures (@powersee). Shares the base with `doubao_engine`: `locate` anchors a bottom-right box by **geometry** (scales with WIDTH), `extract_mask` pulls the light low-chroma glyphs (white top-hat + grayish + min-luma), `detect` matches the bundled "即梦AI" glyph silhouette (`assets/jimeng_alpha.png`) via `TM_CCOEFF_NORMED` over a coverage floor. Threshold `DETECT_NCC_THRESHOLD` **0.45** cleanly separates real Jimeng marks (>=0.81) from the Doubao strip (0.21) and other AI output (0.0), so the two ByteDance marks don't cross-fire in `--mark auto`.
`jimeng_engine.py` — **a thin `TextMarkEngine` subclass (config only) since 2026-06-09.** visible Jimeng / Dreamina "★ 即梦AI" detector + localizer (cv2/numpy, no GPU), built 2026-05-30 from issue #13's solid captures (@powersee). Shares the base with `doubao_engine`: `locate` anchors a bottom-right box by **geometry** (scales with WIDTH), `extract_mask` pulls the light low-chroma glyphs (white top-hat + grayish + min-luma), `detect` matches the bundled "即梦AI" glyph silhouette (`assets/jimeng_alpha.png`) via `TM_CCOEFF_NORMED` over a coverage floor. Threshold `DETECT_NCC_THRESHOLD` **0.45** cleanly separates real Jimeng marks (>=0.81) from the Doubao strip (0.21) and other AI output (0.0), so the two ByteDance marks don't cross-fire in `--mark auto`.
**Logo is pure white (255,255,255)** (`_ALPHA_LOGO_BGR`; the white capture + an L-pair-solve confirm ~254.6); compositing is **sRGB, not linear** (a linear-light solve tripled the cross-residual).
**The detection template (`assets/jimeng_alpha.png`) is rebuilt by `scripts/visible_alpha_solve.py` from the GRAY capture** (`data/jimeng_capture/captures/`, the solid captures committed): `a = (I - B)/(255 - B)`, B a per-capture **cubic** background fit over the non-glyph pixels, **averaged over channels, full halo extent (down to a~0.02), unblurred**. Gray (bg ~132) is the deliberate choice over black: it is the best proxy for real content (the mark sits on bright photo areas, not on black). The captured template is used only as the detection silhouette, not for pixel recovery. Solver geometry at `_ALPHA_NATIVE_WIDTH` 2048: `_ALPHA_WIDTH_FRAC` 0.202, `_ALPHA_HEIGHT_FRAC` 0.058, margins ~0.029.
**Alpha rebuilt by `scripts/visible_alpha_solve.py` from the GRAY capture** (`data/jimeng_capture/captures/`, the solid captures now committed): `a = (I - B)/(255 - B)`, B a per-capture **cubic** background fit over the non-glyph pixels, **averaged over channels, full halo extent (down to a~0.02), unblurred**. Gray (bg ~132) is the deliberate choice over black: it is the best proxy for real content (the mark sits on bright photo areas, not on black), and the careful build drops the gray self-residual to ~1.3.
**Removal is localize -> fill:** the glyph blob is localized to a solid, dilated footprint mask and the shared `watermark_registry.fill` inpaints it; the `WM_*` locate box is generous so a re-rasterized, corner-ward-shifted mark stays inside the localized box (the same widen that fixed Doubao). On corpus images this removes at ~100% with clean footprints (blends within a few LAB levels, no color shift). The registry gates removal on `detect`.
**The mask quality, not the method, was the earlier limit** — a max-channel / quadratic-bg / blurred / halo-truncated build (and a black-dominated LS) left a visible outline (lesson from issue #13: when reverse-alpha leaves a ghost, suspect the captured alpha map before adding heuristics or switching method). Geometry emitted by the solver at `_ALPHA_NATIVE_WIDTH` 2048: `_ALPHA_WIDTH_FRAC` 0.202, `_ALPHA_HEIGHT_FRAC` 0.058, margins ~0.029.
**Removal = reverse-alpha + a deliberately THIN residual inpaint** (`remove_watermark_reverse_alpha`, `_RESIDUAL_DILATE` 5 over the `_RESIDUAL_ALPHA_FLOOR` 0.05 footprint, `_RESIDUAL_INPAINT_RADIUS` 2, `INPAINT_NS`): a single 2048 alpha cannot pixel-cancel the mark re-rasterized at another resolution (alpha maps from independent captures correlate 0.998, not 1.0; off-native reverse-alpha alone only halves the mark), so a tight inpaint clears the residual edges WITHOUT the texture/edge smear a wide full-footprint pass caused.
**Placement ALWAYS tries fixed geometry AND `_aligned_alpha_map`'s NCC scale+position search, keeping the lower-residual** — the mark re-rasterizes + jitters a few px per image even at the captured width, so fixed geometry alone misses (there is no `_ALPHA_NATIVE_BAND` fast-path; the scale search `_ALPHA_ALIGN_SEARCH` is fine-stepped, and the `WM_*` locate box is generous so a corner-ward shift stays inside the search — the same widen that fixed Doubao). Verified clean on the solid captures (native 2048; faint self-residual ~1.3 visible only on a dead-flat field, hidden by real texture) and a real 1440-wide Jimeng download (off-native, table edge preserved). `reverse_alpha_available` is just "asset present"; the registry gates on `detect`.
**No committed real sample** (the real content download stays gitignored; only the solid calibration captures are committed) — `tests/test_jimeng_engine.py` synthesizes a mark from the bundled alpha asset, and `test_recovers_shifted_mark_on_texture` guards the align-on-shift path that the Doubao defect exposed. Jimeng images are independently caught by the China TC260 AIGC label in `metadata`/`identify`, so this engine is the visible-mark *removal* path, not a new `identify` signal.
**No committed real sample** (only the solid calibration captures are committed) — `tests/test_jimeng_engine.py` synthesizes a mark from the bundled template, and `test_recovers_shifted_mark_on_texture` guards the localize-on-shift path that the Doubao defect exposed. Jimeng images are independently caught by the China TC260 AIGC label in `metadata`/`identify`, so this engine is the visible-mark *removal* path, not a new `identify` signal.
## `samsung_engine.py`
`samsung_engine.py` — **a thin `TextMarkEngine` subclass (config only) since 2026-06-09.** visible Samsung Galaxy AI "✦ Contenuti generati dall'AI" remover/detector (cv2/numpy, no GPU), built 2026-06-05 from issue #37's flat captures (@f-liva). Shares the base but anchored **bottom-LEFT** (Doubao/Jimeng are bottom-right): `locate` anchors a bottom-left box by **geometry** (scales with WIDTH), `extract_mask` pulls the light low-chroma glyphs (white top-hat + grayish + min-luma — `LOGO_MIN_LUMA` is lowered to **110** because the mark is faint, peak alpha ~0.38, so on a mid/dark background its glyph luma is lower than Jimeng's), `detect` matches the bundled glyph silhouette (`assets/samsung_alpha.png`) via `TM_CCOEFF_NORMED` over a coverage floor. Threshold `DETECT_NCC_THRESHOLD` **0.40** (real marks ~0.79 on a real photo, ~0.57/0.71 on the black/gray captures; 0.0 on Doubao/Jimeng captures, and Doubao/Jimeng score 0.0 on a real Samsung photo — no cross-fire, also because the corner differs).
`samsung_engine.py` — **a thin `TextMarkEngine` subclass (config only) since 2026-06-09.** visible Samsung Galaxy AI "✦ Contenuti generati dall'AI" detector + localizer (cv2/numpy, no GPU), built 2026-06-05 from issue #37's flat captures (@f-liva). Shares the base but anchored **bottom-LEFT** (Doubao/Jimeng are bottom-right): `locate` anchors a bottom-left box by **geometry** (scales with WIDTH), `extract_mask` pulls the light low-chroma glyphs (white top-hat + grayish + min-luma — `LOGO_MIN_LUMA` is lowered to **110** because the mark is faint, peak alpha ~0.38, so on a mid/dark background its glyph luma is lower than Jimeng's), `detect` matches the bundled glyph silhouette (`assets/samsung_alpha.png`) via `TM_CCOEFF_NORMED` over a coverage floor. Threshold `DETECT_NCC_THRESHOLD` **0.40** (real marks ~0.79 on a real photo, ~0.57/0.71 on the black/gray captures; 0.0 on Doubao/Jimeng captures, and Doubao/Jimeng score 0.0 on a real Samsung photo — no cross-fire, also because the corner differs).
**Logo is pure white (255,255,255)** (`_ALPHA_LOGO_BGR`; white capture confirms).
**The detection template (`assets/samsung_alpha.png`) is solved by `scripts/visible_alpha_solve.py samsung` from the GRAY capture** (`data/samsung_capture/captures/`, the flat black/gray/white captures committed; the solver gained a `corner="bl"` mode + left-margin logging for this), same careful recipe as Jimeng (cubic background, mean-channel, full halo, unblurred). Geometry emitted at `_ALPHA_NATIVE_WIDTH` **1086** (the flat-edit capture width): `_ALPHA_WIDTH_FRAC` 0.3195, `_ALPHA_HEIGHT_FRAC` 0.0378, `_ALPHA_MARGIN_LEFT_FRAC` 0.0110, `_ALPHA_MARGIN_BOTTOM_FRAC` 0.0064. Used only as the detection silhouette, not for pixel recovery.
**Alpha solved by `scripts/visible_alpha_solve.py samsung` from the GRAY capture** (`data/samsung_capture/captures/`, the flat black/gray/white captures committed; the solver gained a `corner="bl"` mode + left-margin logging for this), same careful recipe as Jimeng (cubic background, mean-channel, full halo, unblurred). Geometry emitted at `_ALPHA_NATIVE_WIDTH` **1086** (the flat-edit capture width): `_ALPHA_WIDTH_FRAC` 0.3195, `_ALPHA_HEIGHT_FRAC` 0.0378, `_ALPHA_MARGIN_LEFT_FRAC` 0.0110, `_ALPHA_MARGIN_BOTTOM_FRAC` 0.0064.
**Removal is localize -> fill:** the glyph blob is localized to a solid, dilated footprint mask and the shared `watermark_registry.fill` inpaints it. Verified on a real 2958-wide @f-liva photo: re-detect 0.79→0.00, no readable text or outline on the recovered wooden table — checked **visually**, not just by the detector. The registry gates removal on `detect`.
**Removal = reverse-alpha + a deliberately THIN residual inpaint** (`remove_watermark_reverse_alpha`, same `_RESIDUAL_*` recipe as Jimeng) with **always-try fixed AND `_aligned_alpha_map` NCC scale+position search, keep the lower-residual** (`_ALPHA_ALIGN_SEARCH` widened to (0.85, 1.18, 23) because the flat captures are far off the real-photo width).
**Detection is locale-specific** (the string differs per language); this build detects only the Italian "Contenuti generati dall'AI" variant, so non-Italian Samsung locales are not detected — and, because detection gates removal, not removed — even though the fill mask itself is locale-independent. Other locales need their own detection silhouette — the locale string font-rendered and calibrated on real positives (the pill's `scripts/render_pill_silhouette.py` pattern), NOT an app capture (the solid/gray/white capture workflow retired with reverse-alpha). This is a pre-existing limit, unchanged by the localize -> fill refactor.
**Resolution caveat:** the flat captures arrived at 1086 wide while real photos are ~2958 wide (the mark scales with width, so the captured glyph ~334px is ~2.7x smaller than the ~903px real-photo glyph); width-scale + NCC-align still removes it cleanly (verified on a real 2958-wide @f-liva photo: re-detect 0.79→0.00, no readable text or outline on the recovered wooden table — checked **visually**, not just by the detector, per the Gemini self-verify lesson), but a flat capture at the real photo resolution would make the alpha pixel-sharp instead of upscaled (open quality upgrade, noted in `data/samsung_capture/README.md`).
**The mark is locale-specific** (text differs per language); this build is the Italian "Contenuti generati dall'AI" variant — other locales need their own captured template. `reverse_alpha_available` is just "asset present"; the registry gates on `detect`.
**No committed real sample** (the real photo stays gitignored; only the flat calibration captures are committed) — `tests/test_samsung_engine.py` synthesizes a mark from the bundled alpha asset (bottom-left geometry), with `test_recovers_shifted_mark_on_texture` guarding the align-on-shift path. Samsung Galaxy AI edits are independently caught by C2PA + the `genAIType` marker in `metadata`/`identify`, so this engine is the visible-mark *removal* path; it also feeds `identify` as the medium-confidence `visible_samsung` signal via the registry (the stripped-metadata fallback).
**No committed real sample** (only the flat calibration captures are committed) — `tests/test_samsung_engine.py` synthesizes a mark from the bundled template (bottom-left geometry), with `test_recovers_shifted_mark_on_texture` guarding the localize-on-shift path. Samsung Galaxy AI edits are independently caught by C2PA + the `genAIType` marker in `metadata`/`identify`, so this engine is the visible-mark *removal* path; it also feeds `identify` as the medium-confidence `visible_samsung` signal via the registry (the stripped-metadata fallback).
## `region_eraser.py`
`region_eraser.py` — universal region eraser (`erase` CLI). `erase(image, boxes=|mask=, backend=)` accepts grayscale (2D) and RGBA (4-channel) inputs on **both** backends (`erase_cv2` and `erase_lama` each split off any alpha plane and re-attach it unchanged, and promote grayscale to BGR for processing — LaMa would otherwise crash on grayscale and drop alpha on BGRA): `boxes_to_mask` → `cv2.inpaint` (`cv2` backend, default, no deps) or big-LaMa via onnxruntime (`lama` backend, extra `lama`, `Carve/LaMa-ONNX` Apache-2.0 model downloaded on first use, never bundled). `erase_lama` crops a padded region around the mask, runs LaMa at its fixed 512² input, pastes only masked pixels back (untouched areas stay pixel-exact). Lazy `_get_lama_session` singleton; `lama_available()` guards the optional import.
`region_eraser.py` — universal region eraser (`erase` CLI) AND the shared fill backend behind `watermark_registry.fill` for the visible localize -> fill removal. `erase(image, boxes=|mask=, backend=)` accepts grayscale (2D) and RGBA (4-channel) inputs on **all** backends (each splits off any alpha plane and re-attaches it unchanged, and promotes grayscale to BGR): `boxes_to_mask` → one of three backends.
- `cv2` (default, no deps): `cv2.inpaint`.
- `migan` (extra `migan`, `andraniksargsyan/migan` ONNX, MIT, ~28 MB): `erase_migan`. Like `erase_lama`, it crops a padded region around the mask (`pad = max(256, 2*bbox)`), feeds only that crop to the ONNX model, and pastes only masked pixels back — but since MI-GAN accepts arbitrary dims (unlike LaMa's fixed 512² square) the crop is fed at NATIVE resolution (no resize). This **bounds the ONNX working set by the mark size, not the image**: feeding the whole frame made peak RAM scale with the upload (~0.6 GB at 4 MP up to ~2.4 GB at 25 MP, measured 2026-07); cropping holds it roughly constant (~0.6-0.9 GB), so a memory-tight host (a 1-2 GB web worker) can run MI-GAN on a 25 MP upload. The crop does not degrade the fill — a small mark only needs local context, and on real marks the cropped fill is on par with / sometimes cleaner than the full-frame fill (a tighter view gives the GAN less room to hallucinate large background structure; verified by eye on real Gemini/Doubao marks + a ground-truth reconstruction sweep). **Mask polarity is INVERTED** vs this package's 255-erase convention — the shipped ONNX wants 0=hole / 255=known, so `erase_migan` feeds `(crop_mask<=127)*255`; feeding 255=hole regenerates the whole frame into stripes (corpus-validated 2026-07, cost hours to find). ~0.19 s. This is the **preferred default fill** for the visible localize -> fill path.
- `lama` (extra `lama`, `Carve/LaMa-ONNX` Apache-2.0, ~200 MB): `erase_lama` crops a padded region around the mask, runs at LaMa's fixed 512² input, pastes only masked pixels back. Best quality but ~4.7 GB peak — explicit opt-in only, NOT auto-selected.
Lazy `_get_{lama,migan}_session` singletons; `{lama,migan}_available()` guard the optional imports (both == onnxruntime present). Note both extras install the same onnxruntime, so the two `*_available()` checks are identical — the fill's `auto` backend therefore resolves to MI-GAN whenever onnxruntime is present, else cv2, and big-LaMa is reachable only by an explicit `lama` backend (`--backend lama` on `erase`, or the shared fill's `backend="lama"`).
**LaMa-ONNX costs ~3.5-4 GB peak RAM and ~5-6 s/call on CPU** (FFC working set, not arena — `enable_cpu_mem_arena=False` does not help), so it does NOT fit a minimal droplet; the cv2 backend (tens of MB, ~30 ms) does. LaMa quality at low RAM = serverless/GPU, mirroring how raiw.cc offloads SDXL to fal.
@@ -175,10 +159,12 @@ Root cause: bad alpha (under-estimated, max ~0.65) + fixed-no-inpaint + tight bo
## `noai/watermark_remover.py`
`noai/watermark_remover.py` — the `WatermarkRemover` class has two diffusion pipelines, selected by the explicit `pipeline` ctor arg (NOT inferred from `model_id` -- both use the same SDXL base, `DEFAULT_MODEL_ID`).
`noai/watermark_remover.py` — the `WatermarkRemover` class has three diffusion pipelines, selected by the explicit `pipeline` ctor arg (NOT inferred from `model_id`). `sdxl`/`controlnet` share the SDXL base (`DEFAULT_MODEL_ID`); `qwen` is its own base (`QWEN_MODEL_ID`).
**`sdxl`** (renamed from `default` 2026-06-09; `default` kept as a back-compat alias via `normalize_profile`) runs plain SDXL img2img (`_run_img2img`); it is the lighter opt-down alternative (no ControlNet weights).
**`qwen`** (`_run_qwen`, `_load_qwen_pipeline`) runs `QwenImageImg2ImgPipeline` on `Qwen/Qwen-Image` (20B MMDiT, Apache-2.0 code AND weights). The scrub still comes from the img2img `strength`; Qwen's value is **text preservation** (incl. CJK and small text). **Metric-measured nuance (2026-06-19, `scripts/fidelity_metrics.py`, do NOT trust the eyeball here — it misled). Compare ONLY at each pipeline's oracle-confirmed scrub floor (outputs where SynthID is removed in BOTH — an equal-strength compare is invalid where it leaves one un-scrubbed; Qwen at 0.15 does not clear Gemini): Qwen wins TEXT (lower OCR CER across EN/RU/ZH, perfect Chinese) but controlnet wins FACES (higher Laplacian-variance retention and lower LPIPS — Qwen smooths faces MORE; ArcFace identity favors controlnet 0.546 vs 0.331 at the Gemini floors).** So Qwen is the better text-preserving remover, NOT a universal fidelity win — controlnet's canny edge map holds face skin detail better. Specifics: bf16 on CUDA (fp16 risks overflow on the 20B MMDiT — see the dtype branch in `__init__`); loads `QWEN_MODEL_ID` unless `--model` is overridden; the call shape lives in the pure module helper `_build_qwen_kwargs` (unit-tested without torch in `tests/test_platform.py::TestQwenKwargs`), which uses Qwen's `true_cfg_scale` (NOT SDXL's `guidance_scale` — the CLI `--guidance-scale` maps onto it; ~4.0 is typical, the SDXL default 7.5 is high for Qwen) and an explicit `negative_prompt` (`_QWEN_PROMPT`/`_QWEN_NEGATIVE`). It is CUDA/cloud-class (the 20B does not fit MPS), so `_run_qwen` has NO MPS->CPU fallback — an error propagates. `_load_qwen_pipeline` raises a clear ImportError if the installed diffusers lacks `QwenImageImg2ImgPipeline`. **CERTIFIED oracle floors (Modal A100-80GB, 2026-06-20): OpenAI 0.10 (seed-robust — clean on seeds 0-4), Gemini 0.25 (seed 0 verified on 2 images; the Gemini oracle rate-limits volume seed-repeat, so PIN a seed in prod). The Gemini floor (0.25) is HIGHER than the certified controlnet Gemini floor (0.15); `resolve_strength(..., pipeline="qwen")` carries the Qwen ladder (`_QWEN_VENDOR_STRENGTH`), so `--pipeline qwen` gets the 0.25 Gemini floor automatically -- the old manual `--strength 0.25` workaround is retired. `_build_qwen_kwargs` passes an explicit `height`/`width` from the input (floored to /16 via the pure `_qwen_target_size`); WITHOUT it the img2img pipeline defaults to a 1024x1024 SQUARE and silently squishes non-square inputs (the abba 2816x1536 case came back 1024x1024, distorting the scene and garbling text — fixed 2026-06-20, tested in `TestQwenKwargs`).** Fidelity vs controlnet was measured at the certified floors (`scripts/fidelity_metrics.py`), NOT eyeballed. **`qwen` is a MANUAL opt-in only — there is NO auto-router (one was prototyped and DROPPED, see below).** It wins ONE niche: clean body text on a plain background, NO faces (openai_1/2 CER 0.241 vs 0.385). controlnet wins FACES and **display/decorative text in a scene** (abba poster: controlnet CER 0.114 vs qwen 0.379 — canny holds letter shapes; qwen re-renders and garbles them). **`--pipeline auto` + a faces+text mixed dual-pass were built and DROPPED (2026-06-20):** on the canonical faces+text case (abba) controlnet wins EVERY metric incl. text, so grafting qwen text would only hurt; and "text→qwen" is undecidable cheaply (it is body-vs-display text that matters). The router/detector/mixed modules were removed; the geometry fix + the Qwen strength ladder were kept (they make the manual `--pipeline qwen` correct). **Do NOT retry "add a Qwen ControlNet to close the face gap" — it was built, measured, and CLOSED 2026-06-20:** a DiffSynth blockwise-canny Qwen ControlNet did not restore face skin texture (lapvar flat 0.40, canny carries edges not skin grain) and no permissively-licensed Qwen tile/detail/skin ControlNet exists anywhere (all conditioning is geometry). Faces stay on controlnet; the next improvement lead is Z-Image-Turbo (Apache-2.0, unmeasured floor). Full record + the deep-research sweep in `docs/qwen-improvement-research.md`.
**`controlnet`** (**the DEFAULT pipeline since 2026-06-09** for `invisible`/`all`/`batch` and both engine ctors; `_run_controlnet`, `_load_controlnet_pipeline`) runs `StableDiffusionXLControlNetImg2ImgPipeline` with the SDXL-native canny ControlNet `xinsir/controlnet-canny-sdxl-1.0` (`watermark_profiles.CONTROLNET_CANNY_MODEL`): the control image is `cv2.Canny(gray, 100, 200)` stacked to 3 channels (`_CANNY_LOW`/`_CANNY_HIGH`, prompt `_CONTROLNET_PROMPT` / `_CONTROLNET_NEGATIVE`).
**Removal comes from the img2img regeneration (`strength`); the ControlNet only PRESERVES text and face STRUCTURE via the edge map.**
@@ -189,6 +175,14 @@ At the shared low removal strength the canny edge-conditioning keeps the regener
**But the reverse also holds: a flat-graphic logo/poster SURVIVED `default` while clearing controlnet** -- removal at the low strength is content×pipeline dependent and neither pipeline is universally safe; the real lever is a higher strength. See the controlnet Known-limitations bullet for the full table + root cause. Canny holds face STRUCTURE but NOT identity (the regenerated face drifts in likeness -- canny carries edges, not identity). The drifted cleaned face is the LEAST-AI state we can reach without re-introducing SynthID; the library does NOT ship a face-restore extra. Every restore approach we evaluated (GFPGAN-on-cleaned, PhotoMaker-V2 txt2img, InstantID txt2img, InstantID img2img-on-cleaned at three parameter sweeps, 2026-06-04 - 2026-06-08 Modal cert sweeps) regenerated the face from an ArcFace embedding via SDXL diffusion -- which makes the output face look MORE AI-generated, not less. Empirical conclusion in `docs/synthid-robust-identity-research-2026-06-08.md` "Empirical follow-up". For production face preservation, ship the cleaned image as-is. `controlnet_conditioning_scale` (ctor arg, default 1.0) is the structure-preservation knob. Same dtype rule as `default` (fp32 on cpu/mps, fp16 only on cuda/xpu; the fp16-fixed SDXL VAE `_SDXL_FP16_VAE_ID` is swapped in on fp16 GPUs -- issue #29) and the same MPS->CPU fallback (reload on cpu/fp32, drop a non-cpu generator, retry once).
**Tiled diffusion (`tile`/`tile_size`/`tile_overlap` ctor-path args, CLI `--tile`, issue #10):** for large inputs that OOM at native resolution, `remove_watermark` can process the diffusion pass in overlapping sliding-window tiles instead of one forward pass — the lossless alternative to a `--max-resolution` downscale. The single-image generation closure was refactored into `_generate_one(img)` (dispatches controlnet/img2img, generator shared so the seed advances deterministically across tiles), and `_generate()` routes it through `noai.tiling.run_tiled` when `tile` is set AND `max(init_image.size) > tile_size` (a sub-tile image runs one pass unchanged). The ControlNet canny edge map is rebuilt per tile inside `_generate_one`, so structure preservation is tile-local. See `noai/tiling.py` below and the tiled-diffusion subsection in `docs/known-limitations.md` for the geometry, the partition-of-unity blend, and the quality caveat.
## `noai/tiling.py`
Pure sliding-window tiling for the diffusion path (no torch import; numpy/PIL only). `plan_tiles(w, h, tile_size, overlap)` returns a row-major grid of uniform-size `Tile` boxes — every tile is exactly `tile_size` (the SDXL training size), with the last tile on each axis pulled back flush to the far edge (`_axis_positions` clamps a pathological `overlap >= tile` to `tile - 1` so the step stays >= 1). `feather_weights(w, h, overlap)` is a separable linear taper (1 in the interior, ramping toward each edge) floored at `_WEIGHT_EPS` so it is **strictly positive everywhere** — that makes the normalized `accum / weight_sum` blend a partition of unity, so identical/unchanged tiles reconstruct the input exactly (the seam-free guarantee). `run_tiled(generate_tile, image, tile_size, overlap, set_progress)` is the orchestration loop: crop each planned tile, call `generate_tile` (one diffusion pass on a single PIL tile — injected, so this stays decoupled from the pipeline), resize a latent-grid-rounded result back to the exact tile size, and feather-accumulate. All three are unit-tested without the model (`tests/test_tiling.py`: axis math, grid coverage, taper shape/symmetry/positivity, identity reconstruction, per-tile call count, and the resize-back path). New blend tuning belongs in these pure helpers, not inlined into the runner.
`feather_region_composite(base, regenerated, box, *, feather)` is the pure region-targeted compositor for **AI-enhanced composites** (roadmap P1#8; `identify` `ai_source_kind == "enhanced"`, digitalSourceType `compositeWithTrainedAlgorithmicMedia`). It blends `regenerated` over `base` inside `box = (x, y, w, h)` with a separable linear taper of `feather` px at the box edges (the taper anchors to ~0 at the boundary, so unlike `feather_weights` it is NOT floored — the result equals `base` EXACTLY outside the box), preserving dtype and supporting HxW or HxWxC. It backs `WatermarkRemover.remove_watermark(region=..., region_feather=...)`: the remover regenerates the frame (or tiles), then composites only the AI box back over the original input, so the real photo outside the box stays pixel-exact and only the AI region is scrubbed. The box is caller-supplied (a C2PA composite manifest carries no reliable machine-readable region); the no-model lossless region path remains `region_eraser.erase`. Unit-tested in `tests/test_tiling.py::TestFeatherRegionComposite` (outside-box exactness, interior == regenerated, hard-paste at feather 0, monotonic seam ramp, dtype/grayscale/clamp/empty-box/shape-mismatch).
## `auto_config.py` (REMOVED 2026-06-09)
**`auto_config.py` + the content-detection layer were REMOVED 2026-06-09.**
@@ -197,6 +191,15 @@ History: `auto_config.plan()` was a content-adaptive planner that detected faces
**`--auto` is now a DEPRECATED no-op** (`cli._resolve_auto_polish`): controlnet is already the default pipeline AND the adaptive polish is ON by default, so `--auto` has nothing left to do — it only prints a deprecation warning and passes `adaptive_polish` through unchanged (an explicit `--no-adaptive-polish` still wins). (Originally it re-enabled the polish; once the polish default flipped to ON the same day, the parameter-source branch became dead and was dropped.) The **adaptive polish itself lives on** in `humanizer.adaptive_polish` (CLI `--adaptive-polish/--no-adaptive-polish`, **ON by default since 2026-06-09** — it self-gates to a no-op where there is no detail deficit, so default-on is safe; uses the full-res original as the detail reference) — see the `humanizer` test note. `batch` resolves the polish once before the loop (one warning) and caches the invisible engine per pipeline (`ctx.obj["_inv_engines"]`).
## Content `--pipeline auto` router + faces+text mixed dual-pass — PROTOTYPED and DROPPED (2026-06-20)
A `--pipeline auto` content router (`pipeline_router.py` + `content_detect.py`: Haar faces + MSER text → route text→qwen / faces→controlnet / both→mixed) and a faces+text **mixed dual-pass** (`mixed_pipeline.py`: scrub the whole frame on BOTH pipelines, then graft the qwen text regions onto the controlnet base via `tiling.feather_region_composite`) were built, run on Modal (the abba poster: faces + display text), measured, and **removed**. Why it failed:
- On the canonical faces+text image **controlnet wins EVERY metric, including text** (CER 0.114 vs qwen 0.379; ID 0.64 vs 0.36; lapvar 0.71 vs 0.59) — canny holds the existing letter shapes, qwen re-renders display/decorative text and garbles it. So grafting qwen text onto the controlnet base only HURTS.
- qwen beats controlnet on text ONLY for clean body text on a plain background with no faces (openai_1/2) — a niche where there are no faces to route around anyway, so `--pipeline qwen` alone covers it. The faces+clean-body-text intersection is near-empty.
- "text→qwen" is not cheaply decidable: it is body-vs-display text that matters, which face/text detectors can't tell apart. MSER also over-fired (47% of the busy poster, incl. faces).
KEPT from that work (independently valid for the manual `--pipeline qwen`): the qwen **geometry fix** (`_qwen_target_size` + `_build_qwen_kwargs` height/width — qwen squished non-square inputs to 1024² without it) and the **pipeline-aware `resolve_strength`** Qwen ladder (Gemini 0.25). Also kept: the `fidelity_metrics.py` one-to-one face matcher. The throwaway Modal eval scripts were removed after the run (findings recorded here and in `docs/qwen-improvement-research.md`).
## `upscaler.py`
`upscaler.py` — optional Real-ESRGAN pre-diffusion super-resolution for small inputs (spandrel boundary, top-of-file pyright pragma). `is_available()` gates on spandrel+torch (via `importlib.util.find_spec`); `upscale(bgr, device=None)` loads a lazily-built spandrel `ImageModelDescriptor` singleton (double-checked lock) and upscales by the model's native factor (x2), with a non-CPU→CPU device fallback mirroring the diffusion engine's MPS→CPU retry. Weights (`RealESRGAN_x2plus.pth`, BSD-3-Clause) download on first use to the `torch.hub` checkpoints cache; never bundled. Used only when UPscaling to the `min_resolution` floor (a `max_resolution` downscale always uses Lanczos). The wiring is `InvisibleEngine._esrgan_upscale(pil, target)` — Real-ESRGAN at native factor, then a Lanczos resize to the exact target, falling back to a plain Lanczos resize if the extra is absent or the model errors (so an optional upscaler can never break removal). The default `--upscaler` is `lanczos` (cv2, no deps).
@@ -212,3 +215,23 @@ History: `auto_config.plan()` was a content-adaptive planner that detected faces
`imwrite` returns `False` on an unwritable path (`OSError` caught) instead of raising, matching `cv2.imwrite` semantics. macOS/Linux already accept UTF-8 paths, so it is behavior-neutral there (the bug only reproduces on Windows).
**`to_bgr(image)` (added 2026-06-09)** is the shared channel normalizer: promotes 2D grayscale / (h,w,1) / 4-channel BGRA to 3-channel BGR (a 3-channel input is returned unchanged, no copy). Use it instead of inlining the `cvtColor(GRAY2BGR/BGRA2BGR)` branch — the gemini engine and the `TextMarkEngine` base both route through it so a grayscale/BGRA input (a real Gemini-app export is opaque RGBA) does not crash the `axis=2` channel reductions. cv2/numpy are imported lazily inside the functions, so the module is cheap to import in a bare env.
## CLI commands (`cli.py`)
Full per-command behavior for the skip/exit branches summarized in `CLAUDE.md`'s "How to run". The CLI distinguishes three exit codes: success (0), hard error (1), and a "nothing to do" code (2, `EXIT_NO_VISIBLE_MARK` / `EXIT_NO_INVISIBLE_SIGNAL`) so a wrapping service (raiw.cc) can surface guidance instead of treating an unchanged image as done (the production "it didn't work" / score-0 trap).
### `all`
Full pipeline (visible + invisible + metadata). Same diffusion knobs as `invisible`, plus the visible-pass `--backend auto|cv2|migan|lama` (default `auto`) that picks the fill for the localize -> fill visible removal. **When the `[gpu]` extra is absent, step 2 (invisible/SynthID) is skipped** — `all` still writes an output (visible mark + metadata stripped) but prints a prominent end-of-run banner ("the invisible (SynthID) watermark was NOT removed") AND exits **non-zero** (1), so a skipped SynthID pass is not mistaken for a clean result (the recurring #14/#47 trap, where the old quiet inline warning was missed). `invisible` already hard-errors without the extra; only `all` continued, hence the loud end-banner. Regression-guarded by `tests/test_cli.py::TestAllCommand::test_all_loud_warning_and_nonzero_exit_when_gpu_missing`. **No-signal skip (P0#5):** step 2 also runs the same `has_invisible_target` gate (see `invisible` below) — when no invisible watermark is detectable and `--force` is not set, step 2 is skipped and the pixels are left intact, but unlike the GPU-missing skip this is a **SUCCESS (exit 0)**: the visible pass + metadata strip still ran and a file is written (the message says so without claiming the image is clean). Distinct exit semantics by design: GPU-missing = couldn't do the work (non-zero); no-signal = nothing to do (zero). Regression-guarded by `test_all_skips_invisible_on_no_signal_but_succeeds`. **Test trap:** any `all` test that exercises the full pipeline MUST `patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True)` — CI installs core+dev only (no `[gpu]`), so an unpatched `all` test takes the skip branch and now hits the non-zero exit. This passed locally (gpu present → `is_available()` True) but red-failed every matrix cell on the v0.11.0 commit (`test_all_basic`/`test_all_visible_step_uses_registry` asserted exit 0); both now patch `is_available` True.
### `invisible`
Diffusion SynthID removal. The `--tile/--no-tile` knob is the *lossless* alternative to a `--max-resolution` downscale for large inputs that OOM on MPS/GPU: it engages only when the long side exceeds `--tile-size` (default 1024); tiles are feather-blended over `--tile-overlap` px (default 128); pair with `--max-resolution 0`. `--adaptive-polish` is a detail-targeted polish that self-gates to a no-op where there is no deficit. `--auto` is deprecated and now a no-op that only warns (the polish it used to enable is ON by default). **No-signal skip (P0#5, roadmap):** before the diffusion runs, the command checks `identify.has_invisible_target(source)` (the `ProvenanceReport.ai_from_metadata` union: C2PA AI-issuer / SynthID proxy, IPTC, AIGC, local gen params, EXIF/xAI, open DWT-DCT / TrustMark — visible marks do NOT count, they are a separate pass). When nothing is locally detectable it does NOT regenerate (that would only degrade a clean image — the dominant paid score-0 cause on no-watermark uploads): it writes NO output, prints guidance that does NOT claim the image is clean (a pixel SynthID is undetectable once its metadata proxy is gone), and exits **`EXIT_NO_INVISIBLE_SIGNAL` (2)** — same value/role as the visible `EXIT_NO_VISIBLE_MARK`. `--force/--no-force` (**default skip = ON**) runs the scrub regardless. The check fails SAFE (a detector exception → run, since leaving a watermark on a paid removal is worse than over-regenerating). Helpers `cli._no_invisible_signal_exit` + `identify.has_invisible_target`; regression-guarded by `tests/test_cli.py::TestInvisibleCommand::{test_invisible_no_signal_skips_and_exits_two,test_invisible_force_runs_scrub_on_no_signal,test_invisible_runs_without_force_when_signal_present}` and `tests/test_identify.py::TestHasInvisibleTargetFailSafe`. **Test trap:** any `invisible`/`all`/`batch` test that exercises the diffusion path on a signal-LESS fixture (e.g. the synthetic `sample_png`) MUST pass `--force`, or the new gate skips step 2 (so `mock_engine.remove_watermark` is never called / `invisible` exits 2).
### `visible`
Known-visible-mark removal by **localize -> fill**: each detected mark is localized to a binary full-frame footprint mask, then one shared, swappable fill inpaints that mask. `--backend auto|cv2|migan|lama` (default `auto`) picks the fill: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available (LaMa is auto-preferred when a learned backend is present; a memory-tight deploy pins migan). `--sensitivity auto|strict|assume-ai` (default `auto`) controls how hard a borderline mark is trusted (see the registry section: the visual detectors are metadata-independent; `auto` relaxes a mark only on same-product evidence, `assume-ai` relaxes every mark on the caller's AI assertion — the only path to high recall on a metadata-stripped screenshot). `--backend` and `--sensitivity` are shared across `visible`/`all`/`batch`. Detection keys on each mark's own shape, and under `auto` the trust gate is relaxed when local metadata confirms the vendor (a Google/Gemini C2PA issuer relaxes gemini, a China-AIGC label relaxes doubao/jimeng, `samsung_genai` relaxes samsung), so a moved or re-rendered mark is still caught. `--mark auto` (default) removes EVERY detected mark in one pass (`registry.remove_auto_marks`, not the single strongest -- a Jimeng-basic image carries both the top-left pill and the bottom-right wordmark) from: the Gemini sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-LEFT, Italian-locale detection), and the capture-less Jimeng "AI生成" pill (top-left, `pill_engine`). The pill's weak edge-NCC detector is gated in `remove_auto_marks` via `_keep_pill` (32k real-upload corpus validation 2026-07): never on Doubao, and two confirmation arms since metadata confirms the platform, not pill presence. (1) The bottom-right wordmark fired — ~94% precise and survives metadata-STRIPPED uploads (screenshots / re-saves) — removes the pill unrestricted. (2) TC260 metadata confirms Jimeng (`"jimeng" in provenance`, from `cli._visible_provenance`) OR the caller asserts AI (`sensitivity == "assume_ai"`), no wordmark — ~27% precise, its false fires are textured ceilings/walls that the fill visibly SMEARS — removes the pill ONLY when the top-left footprint is flat enough for an invisible fill (`pill_engine.footprint_is_flat`, median-Sobel ≤ `_FLAT_TEXTURE_MAX`; the flatness guard holds even under `assume_ai`). No confirmation → never removed. `--mark gemini|doubao|jimeng|samsung|jimeng_pill` forces one (choices come from the registry). Corpus validation: doubao and jimeng localize + remove at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit); clean images with no vendor signature had 0% false removal. For arbitrary logos/objects use `erase`. **When `--mark auto` finds no known mark (the common case — ~74% of real uploads carry no registered visible mark), the command does NOT silently re-serve the input as a finished result.** It runs a cheap metadata-only `identify`, prints actionable guidance (if the image carries an invisible/metadata mark, e.g. an OpenAI/Gemini C2PA image, it points to `all`; otherwise it does NOT imply the image is clean -- it warns that an invisible pixel watermark like SynthID cannot be detected once the metadata proxy is gone and routes to both `all` and `erase --region`), writes NO output file, and exits **`EXIT_NO_VISIBLE_MARK` (2)** — distinct from success (0) and a hard error (1) so a wrapping service (raiw.cc) can surface the message instead of treating the unchanged image as done (the production "it didn't work" / score-0 trap). Same handling for an explicit `--mark <name>` that is not detected. Helper `cli._no_visible_mark_exit`; regression-guarded by `tests/test_cli.py::TestVisibleCommand::test_visible_auto_no_mark_exits_two_with_eraser_hint` and `test_visible_auto_no_mark_routes_to_all_when_metadata`. `--no-detect` still forces the gemini fallback and proceeds (exit 0).
### `batch`
Process every supported image in a directory (output defaults to `<directory>_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the **full `invisible` knob set** (`--strength`/`--steps`/`--guidance-scale`/`--pipeline`/`--controlnet-scale`/`--model`/`--device`/`--max-resolution`/`--min-resolution`/`--upscaler`/`--seed`/`--hf-token`/`--humanize`/`--unsharp`/`--adaptive-polish`/`--tile`/`--tile-size`/`--tile-overlap`/`--force`), plus `--backend` for the visible localize -> fill pass. `--adaptive-polish` is ON by default; `--auto` is deprecated and a no-op that only warns. **No-signal skip (P0#5):** in invisible/all mode each image runs the same `has_invisible_target` gate — a signal-less image is skipped (no diffusion); in `invisible` mode the input is copied through to the output dir so it stays complete, in `all` mode the visible-removed result is kept and metadata is still stripped. `--force` scrubs every image regardless. One engine cached per pipeline; the polish is resolved once before the loop. **Exit code (`batch` used to always exit 0, hiding failures):** `cmd_batch` raises `SystemExit(1)` when any image errored, OR when a `--mode invisible`/`all` image carried an invisible signal but the GPU extra was absent so its SynthID scrub was skipped — mirroring single `all`, it emits a loud "the invisible watermark was NOT removed on N image(s)" warning and (invisible mode) copies the input through so the output dir stays complete, rather than silently dropping the signal-bearing files that most needed processing. `_process_batch_image` returns that skipped-scrub flag; the loop tallies it. Regression-guarded by `tests/test_cli.py::TestBatchCommand::{test_batch_errors_exit_nonzero, test_batch_invisible_gpu_missing_writes_output_and_exits_nonzero}`.
+220
View File
@@ -0,0 +1,220 @@
# Qwen-Image improvement research (2026-06-20)
Cited research behind the decision **"ship the `qwen` pipeline as-is, or improve it
first?"** Produced by the multi-source deep-research harness (5 search angles, 22
sources fetched, 85 claims extracted, 25 verified by a 3-vote adversarial check, 20
confirmed / 5 killed, 104 agent calls). Findings carry their confidence and vote.
## Context
The `qwen` pipeline runs base Qwen-Image (20B MMDiT, Apache-2.0) as a low-strength
img2img scrub (removal comes from the denoising `strength`). Certified oracle scrub
floors: OpenAI 0.10 (seed-robust), Gemini 0.25 (pinned seed). Measured against the
SDXL + canny-ControlNet pipeline (`scripts/fidelity_metrics.py`): Qwen preserves
**text** markedly better (incl. CJK and Cyrillic, lower OCR CER) but preserves
**faces** worse, smoothing skin (Laplacian-variance retention 0.40 vs 0.62, face
LPIPS 0.17 vs 0.09, ArcFace identity 0.38 vs 0.55 at the scrub floors). The goal of
the research: keep Qwen's text advantage while fixing the face-smoothing, and judge
production-readiness.
## Verdict
Base Qwen-Image is **shippable now as an opt-in text-content lane** (Apache-2.0 on
code and weights, scrub lever confirmed), but it is not a universal upgrade (it loses
faces). The strongest verified improvement path is to **add structure conditioning**
(a Qwen-Image ControlNet) to the existing base pass, the direct analog of the SDXL +
canny conditioning that wins on faces. Separately, **Z-Image / Z-Image-Turbo** (6B,
Apache-2.0) is the best-verified lighter alternative to evaluate before committing to
the 20B cost. None of the improvements has measured face-fidelity numbers at our
scrub floors yet, so each must be validated with `scripts/fidelity_metrics.py` plus
the oracle before shipping.
## Follow-up: ControlNet experiment + deeper research (2026-06-20)
The verdict's strongest lead -- adding a Qwen-Image ControlNet -- was **built, measured, and
CLOSED**.
**Experiment** (Modal A100-80GB; DiffSynth-Studio `QwenImagePipeline` + the Apache-2.0
`DiffSynth-Studio/Qwen-Image-Blockwise-ControlNet-Canny` -- the only framework exposing
Qwen-Image + canny ControlNet + img2img `denoising_strength` in ONE call; diffusers ships no
`QwenImageControlNetImg2ImgPipeline`, its three Qwen ControlNet pipelines are txt2img only).
Measured on `gemini_3` (18 faces) at the Gemini scrub floor 0.25 vs base-Qwen 0.25 with
`scripts/fidelity_metrics.py`:
- **The actual failure mode (face skin texture) was NOT restored:** Laplacian-variance
retention stayed flat (base 0.40 -> qwen+canny 0.40; per-face 13/16 within +-0.02 after a
one-to-one face match, sd 0.016 -- not an averaging artifact). The SDXL+canny target 0.62
was not approached.
- Identity rose modestly and broadly (ArcFace 0.346 -> 0.415, 12/16 faces improved) but the
absolute stays ~0.42 ("a different person, slightly closer").
- Mechanism (verified, not inferred): canny conditioning was applied fully (scale 1.0, full
denoise schedule); the canny edge map is clean facial geometry with BLANK skin (4.83% edge
density) -- canny carries edges, not skin grain. Root cause: Qwen's Gemini floor (0.25) is
higher than SDXL+canny's (0.15), forcing more denoising -> more smoothing; structure
conditioning cannot compensate for that.
**Deeper research** (deep-research harness, 103 agents, 3-vote adversarial):
- **[high, unanimous] No permissively-licensed Qwen-Image tile / detail / realism / skin
ControlNet exists anywhere** -- DiffSynth first-party is Canny/Depth/Inpaint only, InstantX
Union is canny/soft-edge/depth/pose, the official QwenLM repo ships none. Every Qwen
conditioning is GEOMETRY, the same class as the tested canny. **The "add a Qwen ControlNet to
fix faces" lead is closed for good.**
- **[high, unanimous] Z-Image / Z-Image-Turbo (6B, Apache-2.0 on code AND weights, ~1/3 of
Qwen 20B)** ships a documented `ZImageImg2ImgPipeline` with standard strength denoising, so
it preserves the scrub mechanism. Its own SynthID scrub floor and face/text fidelity are
UNMEASURED -- this is the strongest concrete NEXT experiment.
- **[medium] Lowering Qwen's scrub floor has no off-the-shelf SynthID answer:** the "partial
img2img ~0.3 breaks robust watermarks" literature tests open schemes
(StegaStamp/TrustMark/VINE), NEVER SynthID (proprietary decoder) -- analogy, not proof. No
minimal-strength SynthID attack under a named permissive license was found.
- **REFUTED [0-3]:** "re-injecting high-frequency detail from a clean diffusion output would
not carry the watermark back." So non-regenerative detail transfer is NOT safe by
assumption -- the transferred high-frequency band must be gated against the SynthID oracle.
**Net for the pipeline:** **faces stay on SDXL+controlnet**; there is no Qwen face-fix.
The live frontier is Z-Image-Turbo (next experiment) and oracle-gated non-regenerative detail
re-injection.
**Follow-up (2026-06-20) — the content-routed lane / mixed dual-pass was tested and DROPPED.**
A `--pipeline auto` router (Haar+MSER → text→qwen / faces→controlnet / both→mixed) and a
faces+text mixed dual-pass (scrub the whole frame on both, graft qwen text regions onto the
controlnet base) were built and run on Modal (the abba poster: faces + display text). On that
canonical faces+text case **controlnet won EVERY metric, including text** (CER 0.114 vs qwen
0.379; ID 0.64 vs 0.36) — canny holds existing letter shapes, qwen re-renders display text and
garbles it, so grafting qwen text only hurts. Qwen beats controlnet on text ONLY for clean body
text on a plain background with no faces (openai_1/2), a niche `--pipeline qwen` alone covers;
the faces+clean-body-text intersection is near-empty, and "text→qwen" is undecidable cheaply
(body-vs-display text is what matters). So the router + mixed modules were removed and **`qwen`
is a manual `--pipeline qwen` opt-in only.** KEPT (independently valid): the qwen geometry fix
(it squished non-square inputs to 1024²), the pipeline-aware `resolve_strength` Qwen ladder, and
the `fidelity_metrics.py` one-to-one face matcher below.
**Tooling fix surfaced by this run:** `scripts/fidelity_metrics.py` face matching was changed
from per-face nearest-center to a collision-free one-to-one assignment
(`assign_faces_one_to_one`, gated by face size), after the 18-face `gemini_3` exposed
collisions (the regenerated variants detected 17 faces, so two originals mapped to the same
variant face, corrupting the identity metric). lapvar/LPIPS were always anchored to the
original bbox and stayed collision-immune. Regression-guarded by
`tests/test_fidelity_matching.py`.
## Findings
1. **[high, 3-0] A permissively-licensed Qwen-Image ControlNet exists today and is
CUDA/diffusers-runnable.** InstantX Qwen-Image-ControlNet-Union supports
canny/soft-edge/depth/pose; DiffSynth-Studio maintains blockwise Canny/Depth/Inpaint
plus an In-Context-Control-Union; diffusers exposes `QwenImageControlNetPipeline`
and `QwenImageMultiControlNetModel` with `controlnet_conditioning_scale` (default
1.0) and `control_guidance_start`/`end`. This is the direct analog of the certified
SDXL+canny structure conditioning that wins on faces. Caveat: canny/depth preserve
geometric structure, not face identity per se, and none is a **tile**-ControlNet
(the variant most tied to fine-detail/skin retention in the SDXL world).
Sources: InstantX/Qwen-Image-ControlNet-Union, InstantX/Qwen-Image-ControlNet-Inpainting,
DiffSynth-Studio Qwen-Image docs, diffusers qwenimage pipeline docs.
2. **[high, 3-0] The scrub mechanism is preserved, and the license is clean.**
`QwenImageImg2ImgPipeline.strength` (default 0.6, range 0-1; DiffSynth names it
`denoising_strength`) keeps the partial-regeneration scrub the project relies on,
lower values staying closer to the input. Qwen-Image and Qwen-Image-Edit-2509 are
Apache-2.0 on both code and weights.
3. **[medium, mixed 2-1 / 3-0] Qwen-Image-Edit improves identity consistency, but that
is not proof it fixes our metric.** The instruction-edit pipeline (2511 better than
2509) improves identity/character consistency, but only for identity *through edits*
of an input portrait, which is not the same as measured face-skin Laplacian/LPIPS
fidelity at a low scrub strength. Architecture: 20B base + Qwen2.5-VL (semantic
control) + VAE Encoder (appearance control). Several stronger edit-model face claims
were refuted (see below).
4. **[high, 3-0] Z-Image / Z-Image-Turbo is the best-verified lighter alternative.** A
6B model (~1/3 of Qwen-Image's 20B), Apache-2.0 on code and weights, strong bilingual
(Chinese + English) native text rendering, with an official diffusers
`ZImageImg2ImgPipeline` exposing the same 0-1 denoising-strength scrub lever; Turbo
runs at ~8 steps (guidance_scale=0.0) vs ~40. A material cost/footprint reduction vs
20B/A100-80GB (but see caveat 4 on the refuted consumer-GPU claim).
5. **[high, 3-0] EliGen-V2 is NOT relevant** to the face-smoothing problem. It is an
entity-level/regional control model (LoRA + regional attention placing entities via
text + mask maps, plus entity-level inpainting); it provides no
ControlNet/canny/depth/tile structure conditioning or face-skin-detail retention.
6. **[medium, 2-1] flymy-ai/qwen-image-realism-lora** is Apache-2.0 (code+weights) on
base Qwen-Image, so it is permissively usable with the existing base img2img pass,
but it is NOT verified to specifically fix the face/skin-smoothing failure mode.
## Caveats
1. The research did NOT surface verified evidence for two things specifically asked:
(a) a Qwen-Image **tile**-ControlNet (the variant most tied to fine-detail/skin
retention; only canny/soft-edge/depth/pose/inpaint were confirmed), and (b) any
**non-regenerative detail-restoration** technique (high-frequency residual transfer,
guided filtering) that recovers smoothed faces without re-introducing the watermark.
Research angle 4 produced zero surviving claims, so it is unanswered.
2. No claim provides measured face-fidelity numbers (ArcFace/LPIPS/Laplacian) for ANY
recommended intervention at the project's scrub floors. All fidelity evidence is the
project's own internal measurement. The improvements are mechanistically sound but
unproven for this exact metric, so validate with `scripts/fidelity_metrics.py`
before shipping.
3. Several vendor model cards are marketing-register primary sources (Qwen blog,
Z-Image card). Load-bearing facts (license, params, API levers) are independently
corroborated, but comparative quality framings are author glosses.
4. Z-Image's "sub-second" figure is H800-specific and author-benchmarked; consumer-GPU
third-party benchmarks are still limited (seconds, not sub-second, though within the
<16GB envelope).
5. Time-sensitivity: Qwen-Image-Edit-2511 and Z-Image are late-2025/2026 releases; the
diffusers pipelines cited are on the main/dev branch, so confirm released-version
availability before pinning.
6. Five claims were refuted (below), clustering on over-strong edit-model face-fidelity
and one over-strong Z-Image cost claim.
## Open questions
- Does a Qwen-Image **tile-ControlNet** (or equivalent high-resolution detail
conditioning) exist under a permissive license?
- What **non-regenerative detail-restoration** method recovers smoothed faces WITHOUT
re-introducing SynthID? Note: residual transfer from the ORIGINAL risks copying back
watermark-carrying high frequencies, so it must be verified against the SynthID oracle.
- Does adding Qwen-Image-ControlNet (canny/depth) at the certified floors (OpenAI 0.10,
Gemini 0.25) actually raise face Laplacian/LPIPS toward the SDXL+ControlNet numbers
(0.62 / 0.09) WITHOUT re-introducing SynthID, or does the structure constraint
preserve the watermark the way ControlNet can on photoreal content (the existing
"SynthID CAN survive controlnet at low strength" caveat)?
- Head-to-head: does Z-Image-Turbo at its scrub floor match Qwen's text advantage
(CJK+Cyrillic CER) while not worsening faces, and what are Z-Image's own SynthID
scrub floors and seed-robustness (none exist yet)?
## Refuted claims (do NOT rely on these)
- [0-3] "Qwen-Image-Edit-2511 specifically targets/mitigates image drift, the same
failure mode as face-detail loss in a low-strength scrub." (qwen.ai/blog, 2511)
- [0-3] "Qwen-Image-Edit-2509 explicitly improves facial identity preservation and
supports portrait styles and pose transformations." (HF Qwen-Image-Edit-2509)
- [0-3] "Qwen-Image-Edit-2509 has native built-in ControlNet support (depth/edge/
keypoint)." (HF Qwen-Image-Edit-2509)
- [1-2] "flymy realism LoRA specifically targets facial and skin detail, the exact
failure mode." (HF flymy-ai/qwen-image-realism-lora)
- [0-3] "Z-Image-Turbo runs on consumer 16GB-VRAM hardware, far below the A100-80GB of
Qwen-Image 20B, materially lowering per-image cost." (HF Tongyi-MAI/Z-Image-Turbo)
## Sources
1. https://qwen.ai/blog?id=qwen-image-edit-2511
2. https://qwenlm.github.io/blog/qwen-image-edit/
3. https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit
4. https://github.com/FurkanGozukara/Stable-Diffusion/wiki/Qwen-Image-Edit-2511-Free-and-Open-Source-Crushes-Qwen-Image-Edit-2509-and-Challenges-Nano-Banana-Pro
5. https://myaiforce.com/qie-2511/
6. https://huggingface.co/Qwen/Qwen-Image-Edit-2509
7. https://huggingface.co/InstantX/Qwen-Image-ControlNet-Union
8. https://huggingface.co/InstantX/Qwen-Image-ControlNet-Inpainting
9. https://huggingface.co/DiffSynth-Studio/Qwen-Image-EliGen-V2
10. https://github.com/modelscope/DiffSynth-Studio/blob/main/docs/en/Model_Details/Qwen-Image.md
11. https://blog.comfy.org/p/day-1-support-of-qwen-image-instantx
12. https://learn.thinkdiffusion.com/how-to-use-qwen-image-with-instantx-union-controlnet-in-comfyui-guide-workflow/
13. https://huggingface.co/flymy-ai/qwen-image-realism-lora
14. https://huggingface.co/lightx2v/Qwen-Image-Lightning/discussions/4
15. https://huggingface.co/docs/diffusers/main/en/api/pipelines/qwenimage
16. https://www.diyphotography.net/skin-retouching-technique-frequency-separation/
17. https://link.springer.com/content/pdf/10.1007/978-3-642-15549-9_1.pdf
18. https://github.com/ShieldMnt/invisible-watermark/wiki/Frequency-Methods
19. https://huggingface.co/Tongyi-MAI/Z-Image-Turbo
20. https://github.com/huggingface/diffusers/blob/main/src/diffusers/pipelines/z_image/pipeline_z_image_img2img.py
21. https://arxiv.org/pdf/2511.22699
22. https://github.com/ModelTC/LightX2V-Qwen-Image-Lightning
+11
View File
@@ -16,6 +16,8 @@ stays in `CLAUDE.md`; read this before cutting a release.
**This is now AUTOMATED:** the main repo's `.github/workflows/distribute.yml` fires on the GitHub Release `published` event, waits for the sdist to appear on PyPI (poll loop, the Release event races publish.yml's upload), rewrites the formula's `url`+`sha256`, and pushes to the tap using the `HOMEBREW_TAP_TOKEN` repo secret (a fine-grained PAT with Contents:write on `homebrew-tap`). The SAME workflow also factory-rebuilds the HF Space (`HfApi.restart_space(..., factory_reboot=True)`, `HF_TOKEN` secret) so the Space reinstalls the new sdist (it pins `remove-ai-watermarks>=...` and only re-resolves on a rebuild). The manual Homebrew steps above are the fallback / what the workflow automates — a normal release needs no Homebrew or HF action.
**If the distribute.yml Homebrew job fails with "Bad credentials" (or the tap push 403s),** the `HOMEBREW_TAP_TOKEN` secret has expired or been revoked — fine-grained PATs expire on a fixed date, so this recurs. Fix: rotate the PAT (a fine-grained token with Contents:write on `wiltodelta/homebrew-tap`), update the `HOMEBREW_TAP_TOKEN` repo secret, then re-run the failed job (`gh run rerun <run-id> --failed`). While the token is being rotated, the manual formula bump above unblocks the release. The same rotate-secret-and-rerun applies to any distribute.yml credential failure (`HF_TOKEN` for the Space rebuild).
**Other distribution channels:** (1) **conda-forge** — recipe source of truth committed at `packaging/conda/recipe.yaml` (v1 `recipe.yaml`, noarch core-only: pillow/piexif/numpy/py-opencv/click/python-dotenv); the initial submission is `conda-forge/staged-recipes` PR #33674 (went green only after **`pip_check: false`** in the python test — rattler-build's `pip check` defaults to ON and fails on the ancient conda-forge `piexif py_2` build's stale metadata with "piexif 1.1.3 is not supported on this platform", though the package installs/imports/works; keep it disabled). Once that merges and the `remove-ai-watermarks-feedstock` exists, the `regro-cf-autotick-bot` auto-opens a version-bump PR on the feedstock when each new PyPI sdist is detected — just review + merge it (hand-edit only if run-deps changed; keep `packaging/conda/recipe.yaml` in sync as the reference copy). (2) **ComfyUI Registry** — the node package is a SEPARATE repo `wiltodelta/ComfyUI-remove-ai-watermarks` with its OWN `pyproject.toml` `version` (independent of the library version). Publish a new node version by bumping that `version` in the node repo's `pyproject.toml` and pushing to `main` — the node repo's `.github/workflows/publish.yml` (`Comfy-Org/publish-node-action@main`, triggered on a push that touches `pyproject.toml`, secret `COMFY_REGISTRY_TOKEN`) **auto-publishes** it; `comfy node publish --token <registry-PAT>` is the manual/local fallback. It is NOT auto-published on a library release (the node has its own version), so only bump it when the node code or its `remove-ai-watermarks>=` dependency floor changes.
**Sdist must exclude `data/`** (`[tool.hatch.build.targets.sdist] exclude = ["/data"]`): hatchling's default sdist bundles all VCS-tracked files, so the committed `data/` test corpora (the multi-hundred-MB synthid_corpus images + the visible-mark captures) pushed the **0.8.0** sdist past PyPI's per-project file-size limit (400 "File too large") — the wheel uploaded but the sdist was rejected, so 0.8.0 shipped wheel-only and 0.8.1 carried the fix. The wheel only ships `src/` (via `[tool.hatch.build.targets.wheel] packages`), so it was never affected.
@@ -23,3 +25,12 @@ stays in `CLAUDE.md`; read this before cutting a release.
**A failed PyPI upload of one artifact still leaves the other live and you cannot re-upload the same version** — fix the build and cut the next patch.
**Build backend is unpinned `hatchling`** (`[build-system] requires`) since 2026-06-09. History: it was pinned `<1.31` because hatchling 1.30.0 made Metadata-Version 2.5 (PEP 794) the default and the twine bundled in `pypa/gh-action-pypi-publish@release/v1` rejected it (`"'2.5' is not a valid Metadata-Version"`), which **failed the v0.8.3 PyPI upload on 2026-06-01**; hatchling 1.30.1 reverted the default to 2.4. After the workflow moved to `uv publish` (whose uploader accepts 2.5) the pin was belt-and-suspenders only, and once v0.9.0 + v0.10.0 both published wheel+sdist through that path (verified on PyPI) it was dropped. If a future hatchling flips the default to 2.5 again and some consumer chokes, re-pin with a dated comment.
## Dependency CVE-resolution history (`uv-secure`)
The standing `uv-secure` gate in `maintain.sh` is clean; this is the changelog of how each alert was resolved, so a future alert is not re-triaged from scratch.
- **idna** bumped 3.11 -> 3.16, fixing GHSA-65pc-fj4g-8rjx.
- **aiohttp** bumped 3.13.5 -> 3.14.0 via `uv lock --upgrade-package aiohttp`, fixing GHSA-hg6j-4rv6-33pg + GHSA-jg22-mg44-37j8.
- **basicsr** Dependabot alert GHSA-86w8-vhw6-q9qq is resolved by removal: the experimental `restore` extra was retired and basicsr is no longer anywhere in the dependency tree.
- **torch** Dependabot alert **GHSA-rrmf-rvhw-rf47** (`torch.jit.script` memory corruption, vulnerable `<= 2.12.0`) is **dismissed as `not_used`** (2026-06-10): torch is a transitive dep of the optional `gpu` extra only, the codebase never calls `torch.jit` (grep-verified), and **no patched torch version exists** (`first_patched_version` is null), so it cannot be closed by an upgrade — do not re-triage it.
+2 -2
View File
@@ -10,9 +10,9 @@
**Conclusion (historical): pure reverse-alpha distilled from content images does NOT work, and the blocker is the WRONG kind of data, not too little of it.**
The earlier framing ("need ~5-8 PRISTINE same-resolution originals") is obsolete -- `data/spaces/originals/` holds plenty. Curate them with `DoubaoEngine.detect` + an NCC filter against a clean glyph template, keeping only marks at offset ≈ (0,0): that yields e.g. **15 pixel-aligned 2048² marks** (sub-pixel drift, not the ±50 px the old lossy/mixed-res scrapes had), plus 1086x1448 / 1792x2400 clusters. With those, LaMa-clean `O` + weighted-LS (and per-pixel I-on-O regression) for `α` (+ logo colour) was tried end-to-end and **still leaves a persistent ghost outline.**
The earlier framing ("need ~5-8 PRISTINE same-resolution originals") is obsolete -- a local corpus of pristine originals holds plenty. Curate them with `DoubaoEngine.detect` + an NCC filter against a clean glyph template, keeping only marks at offset ≈ (0,0): that yields e.g. **15 pixel-aligned 2048² marks** (sub-pixel drift, not the ±50 px the old lossy/mixed-res scrapes had), plus 1086x1448 / 1792x2400 clusters. With those, LaMa-clean `O` + weighted-LS (and per-pixel I-on-O regression) for `α` (+ logo color) was tried end-to-end and **still leaves a persistent ghost outline.**
Diagnosed why, empirically (cached stacks, `/tmp/doubao_distill`): (1) the mark is a clean white overlay with **no dark halo** -- over glyph pixels ~54% are brighter than the clean bg, only ~4% darker -- so the white-logo model `I=(1-α)O+α·255` is correct; (2) but content backgrounds are almost never dark *under* the mark (median darkest available bg over glyph pixels = **58/255**; only ~13% of mark pixels are ever observed on a bg < 40), so on bright backgrounds the equation is ill-conditioned and `α` is unidentifiable; (3) LaMa's `O` is a plausible **hallucination**, not the true pre-mark background, which compounds the error, and per-pixel regression on ~15 obs overfits into colour noise.
Diagnosed why, empirically (cached stacks, `/tmp/doubao_distill`): (1) the mark is a clean white overlay with **no dark halo** -- over glyph pixels ~54% are brighter than the clean bg, only ~4% darker -- so the white-logo model `I=(1-α)O+α·255` is correct; (2) but content backgrounds are almost never dark *under* the mark (median darkest available bg over glyph pixels = **58/255**; only ~13% of mark pixels are ever observed on a bg < 40), so on bright backgrounds the equation is ill-conditioned and `α` is unidentifiable; (3) LaMa's `O` is a plausible **hallucination**, not the true pre-mark background, which compounds the error, and per-pixel regression on ~15 obs overfits into color noise.
**Why Gemini's engine is clean (verified in GeminiWatermarkTool `src/core/watermark_engine.cpp`): its alpha map is the watermark stamped on a PURE-BLACK background**, where `watermarked = α·255 + (1-α)·0 = α·255`, so `alpha = capture/255` exactly -- no estimation. (`gemini_bg_*.png` is literally the sparkle in grey on black.) So the real Doubao unlock is the same controlled capture, **not more content images**. Black/white/gray seeds exist (`data/doubao_capture/seeds/seed_*_1x1_2048x2048.png`); a capture run (feed a black seed through doubao.com edit mode, download the *original*) was requested from the #13 reporter 2026-05-29. With ~2-3 black captures we get `α = capture/255` for free, Gemini-quality.
+9
View File
@@ -181,6 +181,15 @@ pos original plus its minimum-clearing cleaned output (manifest `verified_via` =
was oracle-verified but is not committed (third-party content stays out of the
public corpus).
**Oracle validation order: start with OpenAI.** When validating removal across
vendors, run the OpenAI arm first. `openai.com/verify` is more accessible than the
Gemini app -- fewer per-check restrictions, so it gives the fastest signal and is
the strongest candidate for automation (Playwright / Chrome MCP driving
`openai.com/verify`); the Gemini "Verify with SynthID" flow is more manual. This is
an ordering/throughput choice, not a substitution: each oracle only reads its own
vendor's SynthID (`openai.com/verify` is OpenAI-scoped), so Google content still
needs the Gemini app.
| Vendor | Images | Resolution(s) | Pipeline | Removed at |
|--------|--------|---------------|----------|------------|
| OpenAI (gpt-image) | n=4 (3 archived + 1 external-only) | 1024x1536 .. 1600x1600 | native | **0.05** |
+19 -5
View File
@@ -5,8 +5,12 @@
> 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).
**Root cause and external confirmation (deep-research 2026-06-19, adversarially verified).** This is the SCHEME's ceiling, not our usage — there is no better decoder to adopt. The imwatermark maintainers state verbatim (both the ShieldMnt and Stability-AI READMEs) that the algorithm "cannot guarantee to decode the original watermarks 100% accurately even though we don't apply any attack." Independent measurement (WMAdapter, arXiv:2406.08337 Table 2) puts dwtDct at only **~0.79 bit accuracy on CLEAN images (~38/48 bits — already below our 44 gate)**, collapsing to ~0.50 (chance) under crop/JPEG. Two code-verified + locally-reproduced mechanisms drive the content-dependent failures: (1) the decoder reads each bit as the **highest-magnitude DCT coefficient per block**, so any content coefficient exceeding the encoded target flips the bit; (2) the default embed is in the **YUV chroma channel, which 8-bit-clamps on white/bright pixels** (a +36 chroma delta survives a white-fill round-trip as only +4, ~89% loss) — this is the mechanism behind the bright-flat / minimalist failures and the all-ones degenerate decode. No maintained fork or detector decodes this scheme reliably: the WAVES benchmark (arXiv:2401.08573) relegates DWT-DCT to supplementary appendix G.5 and targets Stable Signature / Tree-Ring / StegaStamp instead; learned encoder/decoder schemes reach ~0.98-0.99 clean but are a DIFFERENT watermark class (not what SDXL/FLUX stamp). `dwtDctSvd` does not help (SDXL embeds `dwtDct`; dwtDctSvd cannot decode it, and its clean accuracy ~0.72 is lower). **Authoritative conclusion: the open DWT-DCT mark cannot be turned from positive-only into a reliable real-world detector; keep it positive-only and rely on C2PA.** (Refuted along the way: that the library is unmaintained, and that it is robust to JPEG but only fails on geometric attacks — both did not survive verification.)
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,8 +18,8 @@ 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.
- **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).
**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. ByteDance's consumer app **Dreamina** (the international Jimeng brand) signs as **"Bytedance Pte. Ltd."** with a `Dreamina/x.y` claim generator but, unlike the Volcano Engine surface, ships **NO `trainedAlgorithmicMedia`** — the generator name is the only AI signal, and the active manifest is frequently a plain `c2pa-tool` transcode with the real `Dreamina` token on an ingredient manifest. Added issuer `b"Dreamina"` → org "ByteDance (Dreamina)" with **`asserts_ai=True`** (see `constants.py`): the caBX / store-JSON byte-scan sees the token across all manifests, and the identity-AI flag lifts the AI verdict without a source-type. Mined from the retained corpus 2026-07 (7 files read `unknown` before, all now ByteDance). Registering the **issuer** `b"Bytedance Pte"` was deliberately AVOIDED — that same Singapore entity also signs non-AI CapCut edits (`CapCut/x.y` generator, `c2pa.created`, no AI marker), which must stay unattributed per the editor-vs-generator line; keying on the `Dreamina` generator token is precise.
- **EXIF/XMP/PNG-text 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). Three more mined from the retained corpus 2026-06-22, all no-C2PA generator stamps that previously read as no-signal: **NovelAI** (anime SD) writes its stamp in PNG `tEXt` chunks `Software="NovelAI"` / `Source="NovelAI Diffusion V4.5 <hash>"` / `Title="NovelAI generated image"` — so `exif_generator` now reads PNG text chunks (`Software`/`Source`/`Title`/`Description`), not just EXIF/XMP; **Reve** (reve.com) writes EXIF `Software` / XMP `CreatorTool` = `reve.com` (token is the full `reve.com`, not bare `reve`, to avoid false-firing on "forever"/"reverie"); **Aphrodite AI** writes EXIF `Make`/`Software` = `Aphrodite AI`.
- **xAI / Grok — its own EXIF signature scheme, NOT C2PA (DETECTED by `metadata.xai_signature`, built 2026-05-26).**
Grok JPEG downloads (Aurora model) carry **no C2PA, no XMP, no SynthID, no IPTC** — only EXIF `Artist` = a UUID and EXIF `ImageDescription` = `Signature: <base64>` (a crypto signature, unverifiable locally without xAI's public key). This empirically kills the earlier unverified "xAI signs C2PA as xAI" lead — xAI is not even a C2PA member. `exif_generator` misses it (neither field holds an `AI_GENERATOR_TOKENS` token), so a dedicated detector `xai_signature(path)` matches the pair (`ImageDescription ~ ^Signature: [A-Za-z0-9+/=]{64,}` AND UUID `Artist`); wired into `has_ai_metadata`, `get_ai_metadata` (key `xai_signature`), and `identify` (signal `xai_signature`, platform "xAI (Grok / Aurora)").
@@ -25,7 +29,7 @@ Grok JPEG downloads (Aurora model) carry **no C2PA, no XMP, no SynthID, no IPTC*
**Stripped on removal too:** `remove_ai_metadata` now calls `_scrub_ai_exif` on the JPEG EXIF, which deletes the xAI Signature+UUID-Artist pair **and** any `Software`/`Make`/`Artist`/`ImageDescription` tag holding an `AI_GENERATOR_TOKENS` token (so Ideogram's `Make="Ideogram AI"` is scrubbed too), while keeping genuine camera/editor EXIF. The shared `_is_xai_signature_pair` helper (module-level compiled regexes) is the single source of truth for the pattern, used by both `xai_signature` and `_scrub_ai_exif`. (AVIF/HEIF/JXL still strip only C2PA boxes via `isobmff`, not EXIF — unchanged.)
- **China TC260 AIGC label (caught by `AIGC_MARKERS` / `metadata.aigc_label`, surfaced by `identify` as the `aigc` signal):** China-served generators embed an XMP `<TC260:AIGC>{"Label":"1","ContentProducer":...}` block — China's mandatory AI-content labeling (TC260 namespace `tc260.org.cn/ns/AIGC`).
**Doubao** (ByteDance) uses it (verified on the real #13 sample 2026-05-25; `ContentProducer` `001191110102MACQD9K64010000`, no C2PA/SynthID/imwatermark — the XMP block is the only signal; GitHub attachment upload did NOT strip it). The same standard is mandatory for Jimeng/Kling/Qwen/Ernie etc., so the one marker covers the whole China-AIGC-labeled ecosystem. `aigc_label` reads **four serializations** through a shared `_parse` helper: the HTML-entity-encoded XMP `TC260:AIGC` block in **either RDF form** — the nested element `<TC260:AIGC>{...}</TC260:AIGC>` (Doubao) or the attribute `TC260:AIGC="{...}"` (**PicWish**, `ContentProducer="picwish"`, verified on the corpus 2026-05-30) — via a container-agnostic raw-byte scan (any JSON object accepted), a raw-JSON PNG `AIGC` tEXt chunk (Doubao also writes the label this way, no namespaced marker at all — confirmed on the corpus 2026-05-28, `ContentProducer="doubao"`), a bare raw-JSON `{"AIGC":{...}}` object embedded in **JPEG EXIF (UserComment)** by some China-served generators, brace-matched from the scan head with `json.JSONDecoder().raw_decode` (no namespaced marker, no PNG chunk — confirmed on the corpus 2026-05-30, `ContentProducer="001191440300708461136T1308L"`), **and** a bare `AIGC{...}` blob (the label glued straight to its JSON, no `"AIGC":` key wrapper) embedded in a **JPEG APP segment near the JFIF header** — confirmed on the corpus 2026-06-10 (`ContentProducer="00119144030008867405X210002"`; 3 files read `unknown` before this form was added). The two raw-JSON forms are scanned in one loop (`'"AIGC"'` then `AIGC{`) that **falls through on a non-TC260 / undecodable hit instead of returning** — a quoted `"AIGC"` can appear later in an XMP packet while the real label is a bare `AIGC{...}` earlier in the file, so an unconditional early return on the quoted form would shadow the bare form (the exact bug behind the 06-10 misses). All three generic forms (the PNG chunk, the bare `{"AIGC":...}` object, and the bare `AIGC{...}` blob) are gated on at least one TC260 field (`_TC260_FIELDS`) so a generic `AIGC` key cannot false-positive; the namespaced XMP element is unambiguous and needs no gate. In `identify`, `aigc` fires on the parsed label **or** the `AIGC_MARKERS` byte scan (the latter preserves the laundering-tell case where the JSON payload is truncated).
**Doubao** (ByteDance) uses it (verified on the real #13 sample 2026-05-25; `ContentProducer` `001191110102MACQD9K64010000`, no C2PA/SynthID/imwatermark — the XMP block is the only signal; GitHub attachment upload did NOT strip it). The same standard is mandatory for Jimeng/Kling/Qwen/Ernie etc., so the one marker covers the whole China-AIGC-labeled ecosystem. `aigc_label` reads **four serializations** through a shared `_parse` helper: the HTML-entity-encoded XMP `TC260:AIGC` block in **either RDF form** — the nested element `<TC260:AIGC>{...}</TC260:AIGC>` (Doubao) or the attribute `TC260:AIGC="{...}"` (**PicWish**, `ContentProducer="picwish"`, verified on the corpus 2026-05-30) — via a container-agnostic raw-byte scan (any JSON object accepted), a raw-JSON PNG `AIGC` tEXt chunk (Doubao also writes the label this way, no namespaced marker at all — confirmed on the corpus 2026-05-28, `ContentProducer="doubao"`), a bare raw-JSON `{"AIGC":{...}}` object embedded in **JPEG EXIF (UserComment)** by some China-served generators, brace-matched from the scan head with `json.JSONDecoder().raw_decode` (no namespaced marker, no PNG chunk — confirmed on the corpus 2026-05-30, `ContentProducer="001191440300708461136T1308L"`), **and** a bare `AIGC{...}` blob (the label glued straight to its JSON, no `"AIGC":` key wrapper) embedded in a **JPEG APP segment near the JFIF header** — confirmed on the corpus 2026-06-10 (`ContentProducer="00119144030008867405X210002"`; 3 files read `unknown` before this form was added). The two raw-JSON forms are scanned in one loop (`'"AIGC"'` then `AIGC{`) that **falls through on a non-TC260 / undecodable hit instead of returning** — a quoted `"AIGC"` can appear later in an XMP packet while the real label is a bare `AIGC{...}` earlier in the file, so an unconditional early return on the quoted form would shadow the bare form (the exact bug behind the 06-10 misses). All three generic forms (the PNG chunk, the bare `{"AIGC":...}` object, and the bare `AIGC{...}` blob) are gated on at least one TC260 field (`_TC260_FIELDS`) so a generic `AIGC` key cannot false-positive; the namespaced XMP element is unambiguous and needs no gate. `_TC260_FIELDS` covers **two schemas**: the producer-side one (`Label` / `ContentProducer` / `ProduceID` / `ContentPropagator` / `PropagateID`, Doubao and most China gens) and the **service-provider** one (`ServiceProvider` / `ServiceUser`, plus generic `Time` / `ContentId` which are NOT gated on) — **Tencent Cloud's** AIGC variant (`ServiceProvider` = `腾讯云`), embedded in **EXIF `ImageDescription`**, mined from the retained corpus 2026-07 (11 files read `unknown` before — the block was found by the raw-JSON scan but rejected because none of its fields were in the producer-only gate; removal already stripped it since it lives in EXIF). In `identify`, `aigc` fires on the parsed label **or** the `AIGC_MARKERS` byte scan (the latter preserves the laundering-tell case where the JSON payload is truncated).
- **HuggingFace-hosted job (caught by `metadata.huggingface_job`, surfaced by `identify` as the `hf_job` signal, MEDIUM confidence):** HuggingFace Jobs / Spaces stamp generated PNGs with an `hf-job-id` tEXt chunk holding the job UUID (3 on the corpus 2026-05-28, no other signal). It marks the *hosting job*, not a model — most commonly diffusion output — so it lifts an Unknown verdict to a tentative AI via `hf_only` (parallel to the visible sparkle) but never overrides a hard metadata signal; `_HF_JOB_CAVEAT` states the limit (job, not model; not proof of AI pixels). Stripped on removal (the PNG save whitelist keeps only `STANDARD_METADATA_KEYS`, so `hf-job-id` and the `AIGC` chunk are both dropped). The exact writer is not authoritatively documented (HF Jobs are generic GPU jobs), hence medium not high.
- **No detectable signal on download (correctly reported `unknown`):** **Recraft** (PNG export is a re-encoded design export — strips everything), **Krea hosting FLUX 2** (no imwatermark despite FLUX — the host omits the encoder, same as Stability's hosted SDXL), and Midjourney (embeds nothing). Lesson: the imwatermark detector only fires on *pristine* output from a pipeline that runs the encoder (diffusers default, official BFL), not from re-hosts (Krea/Stability) or re-encoded exports (Recraft/Canva).
- **Invisible but NOT locally detectable (proprietary, API/oracle only — same wall as SynthID):** Amazon Titan Image Generator + Nova Canvas (Bedrock `DetectGeneratedContent` API), Kakao (new SynthID image adopter, May 2026), NVIDIA Cosmos (SynthID video). No local detector possible; treat like SynthID.
@@ -38,3 +42,13 @@ Grok JPEG downloads (Aurora model) carry **no C2PA, no XMP, no SynthID, no IPTC*
- **Regulatory driver (context, not a code change):** AI-content labeling mandates are expanding, which pushes more generators toward exactly the C2PA + watermark signals we read. The full per-jurisdiction table lives in README "## Legal" -- keep it there, not duplicated here. Newly added + primary-source verified 2026-05-26: **EU AI Act Article 50** machine-readable marking applicable **2026-08-02** (verified against the article text); **South Korea AI Framework Act Art. 31(3)** in force since **22 January 2026** (verified via Kim & Chang + FPF/Korea Times; Enforcement Decree accepts an invisible-watermark label); **California AB 853** (amends the CA AI Transparency Act) latent-disclosure duty operative **2026-08-02**, requiring a disclosure "permanent or extraordinarily difficult to remove" (verified against the leginfo bill text -- this is the exact disclosure our tool strips); **India IT Amendment Rules 2026** in force **2026-02-20** (verified via Chambers), which prominently-label + permanent-provenance-id all synthetic media AND **expressly prohibit removing/suppressing the label or metadata** -- the first major all-content removal ban outside China.
**Removal liability (README "## Legal" disclaimer):** the tool is lawful general-purpose software; liability sits with the remover and is intent-gated -- downstream acts (fraud/deception/IP), plus US DMCA 17 USC 1202 (removing copyright-management info to conceal infringement), plus the removal-as-such bans in China + India. When extending the README table, verify each date/article against the statute/bill text before committing, not against search summaries.
## Visible AI-generation marks + detection methods (deep-research 2026-07-10, adversarially verified)
**Google Gemini visible "sparkle" -- tier-dependent, and spec-undocumented by Google.** Google primary sources (the Nano Banana Pro blog and the gemini.google image-generation page, both WebFetch-verified) confirm Gemini images carry BOTH the invisible SynthID (on ALL Google-AI media) AND a visible sparkle, but the visible mark is **tier-gated**: applied for FREE and Google AI **Pro** users, and **REMOVED** for Google AI **Ultra** subscribers, inside **Google AI Studio**, and on **API / dev** output. So a Google-C2PA image with NO visible sparkle is expected (Ultra / API), not evidence it is clean -- this reinforces the `identify` "no visible mark != clean" rule. The ONLY official verifier is the SynthID flow (upload to the Gemini app, ask if it is AI-generated), which reads the INVISIBLE mark; there is **no official visible-sparkle detector**, and Google publishes **no** glyph geometry / size / opacity / color / locale / placement spec. So our capture-based sparkle template is the only source of truth and cannot be validated against a vendor spec -- keep reverse-engineering from real captures (do not expect a published spec).
**The faint-visible-mark precision/recall wall is fundamental, not a heuristic artifact.** The visible-watermark-detection literature has moved to LEARNED segmentation / object-detection (WDNet WACV'21 arXiv:2012.07616; SLBR ACM MM'21, open code+weights; the PRCV'18 large-scale detector; Su et al. survey 2025), but three verified findings bound what a learned detector actually buys: (1) a claim that a confidence threshold "cleanly separates" true from false matches even with a learned CNN front-end was **REFUTED** in verification (arXiv:1705.08593) -- the precision/recall wall persists even with learned features. (2) Learned detectors need a LARGE, pattern-diverse labeled dataset trained on synthetic composites (PRCV'18: 60k images / 80 watermark classes; CLWD: 60k / 160 marks), and off-distribution degradation is a documented real axis (models trained on limited-pattern LVW transfer worse; diversity of training patterns drives generalization). (3) Inference is cheap (WDNet ~8 ms at 256x256) -- the cost is the data pipeline, not runtime. Net: a learned detector shifts the frontier but does NOT remove the wall; for a SINGLE mark the cheapest next step is a small patch classifier (real-sparkle vs false-positive) on top of the existing NCC localizer, not a full segmentation model. SLBR is a ready baseline. The current NCC + false-positive gate (core-ring brightness margin + gradient-NCC crispness + white-core saturation) is a sound operating point, and the residual miss is the information-theoretic wall the literature confirms.
**Visible-mark landscape beyond the registry.** Meta stamps a visible "Imagined with AI" mark (bottom-LEFT, a small symbol) on its OWN Meta AI / "Imagine" output; for third-party images it relies on C2PA / IPTC, not a visible mark. Samsung Galaxy AI additionally uses a **four-star icon** variant in a corner alongside the localized text wordmark `samsung_engine` calibrates (only the Italian text variant is covered) -- the icon is a distinct, uncovered variant. Every source agrees visible + metadata marks are trivially removable (crop / screenshot, ~2 s), which is the tool's premise.
**Regulatory driver -- China GB 45438-2025 is the strongest VISIBLE-mark mandate.** The CAC / TC260 "Measures for Labeling AI-Generated Synthesized Content" (issued March 2025, **effective 2025-09-01**, technical standard **GB 45438-2025**, building on the TC260 Aug-2023 practice guide) MANDATE a **visible** label for AI images -- a visible textual mark whose height must be **>= 5% of the image's shortest side** -- plus the metadata (implicit) label. So every major Chinese platform now ships visible "AI生成"-style text marks (we cover Doubao / Jimeng; expect more CJK-text marks under this driver). By contrast EU AI Act Article 50 mandates only the MACHINE-READABLE mark (enforceable 2026-08-02, grace to 2026-12-02); a visible label is proposed and modality-specific (visible for images) but is NOT a hard "fixed icon" mandate -- a claim that Art 50 requires a clearly-visible fixed icon for images was refuted in verification. Primary-source dates verified against the article/standard text, not search summaries.
+18 -2
View File
@@ -3,8 +3,24 @@
set -euo pipefail
uv sync --all-extras
uv run uv-outdated
uv run uv-secure --ignore-unfixed
# uv-outdated / uv-secure run via uvx (isolated env), NOT `uv run`: resolving them
# inside the project env crashes and, with set -e, aborts the whole gate before
# ruff/pyright/tests (see CLAUDE.md "Test and lint").
uvx uv-outdated
# uv-secure prints its verdict but can then crash in an internal teardown with a
# NON-ZERO exit -- observed as "annotated-doc raised exception" and later "anyio raised
# exception"; both are bugs in uv-secure's OWN uvx env, not a project vulnerability. With
# set -e that teardown crash aborts the whole gate before ruff/pyright/tests. So gate on
# the VERDICT, not the exit code: capture the output, accept the run when uv-secure
# reported all-safe (even if it then crashed), but still FAIL on a real finding (no
# all-safe line) so a genuine CVE is never masked, and fail loud if it never got a
# verdict at all (so a broken run is never silently skipped).
secure_out="$(uvx uv-secure --ignore-unfixed 2>&1)" || true
printf '%s\n' "$secure_out"
if ! grep -qE "No vulnerabilities or maintenance issues detected|All dependencies appear safe" <<<"$secure_out"; then
echo "maintain.sh: uv-secure reported a finding or failed before its verdict -- triage before committing." >&2
exit 1
fi
uv run ruff check --fix
uv run ruff format
# Scoped to src/: a full-project pyright run OOM-crashes node on this ML-heavy
+18 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "remove-ai-watermarks"
version = "0.11.3"
version = "0.15.1"
description = "AI watermark remover: strip visible and invisible AI watermarks (Gemini / Nano Banana sparkle, SynthID) and provenance metadata (C2PA, EXIF) from images"
readme = "README.md"
requires-python = ">=3.10"
@@ -46,6 +46,12 @@ classifiers = [
]
dependencies = [
"pillow>=10.0.0",
# HEIC/AVIF pixel decode for the removal path (iPhone photos, modern exports):
# OpenCV cannot decode these containers, so image_io.imread falls back to Pillow
# and pillow-heif (bundled libheif, prebuilt wheels) registers the HEIF+AVIF
# openers. The metadata path already handles them via a plugin-free binary scan;
# this closes the same gap for the pixel path so `visible`/`all` work on them.
"pillow-heif>=0.13.0",
"piexif>=1.1.3",
"numpy>=1.24.0",
"opencv-python-headless>=4.8.0",
@@ -112,6 +118,15 @@ lama = [
"onnxruntime>=1.16.0",
"huggingface-hub>=0.20.0",
]
# Lightweight inpaint backend -- MI-GAN via onnxruntime (andraniksargsyan/migan,
# MIT). CPU, no torch. Model (~28 MB) downloaded on first use and cached by
# huggingface_hub; never bundled. ~700-950 MB peak RAM / ~0.19 s/call -- the
# droplet-friendly tier (vs big-LaMa's ~4.7 GB) and the preferred inpaint backend
# for the visible-mark fallback when installed. Same runtime as `lama`.
migan = [
"onnxruntime>=1.16.0",
"huggingface-hub>=0.20.0",
]
# Optional pre-diffusion super-resolution for small inputs (Real-ESRGAN). Loaded via
# spandrel (MIT) -- a pure model-loader with NO basicsr dependency (it pulls only
# torch / torchvision / safetensors / numpy / einops).
@@ -136,7 +151,7 @@ dev = [
"uv-outdated>=0.1.0; python_version >= '3.12'",
"uv-secure>=0.12.0; python_version >= '3.12'",
]
all = ["remove-ai-watermarks[gpu,detect,trustmark,lama,dev]"]
all = ["remove-ai-watermarks[gpu,detect,trustmark,lama,migan,dev]"]
# PyTorch Intel-GPU (XPU) wheel index. ``explicit = true`` keeps it inert for
# the default CPU/CUDA install: uv consults it only when a torch install
@@ -185,6 +200,7 @@ ignore = [
]
[tool.ruff.lint.per-file-ignores]
"scripts/*.py" = ["G004", "S108", "S310", "T20"]
"tests/*.py" = ["ANN", "S101", "S105", "S106", "S108"]
"src/remove_ai_watermarks/noai/watermark_remover.py" = ["S603", "S606", "S607", "T201"] # subprocess calls for auto-install/CUDA fix
"src/remove_ai_watermarks/noai/c2pa.py" = ["S110"] # try-except-pass for corrupt file handling
+418
View File
@@ -0,0 +1,418 @@
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "click",
# "numpy",
# "opencv-python-headless",
# "pillow",
# "scikit-image",
# "rapidfuzz",
# "torch",
# "lpips",
# "paddleocr",
# "paddlepaddle",
# "insightface",
# "onnxruntime",
# ]
# ///
"""Objective fidelity metrics for comparing watermark-removal outputs.
Given an ORIGINAL (the reference) and one or more cleaned VARIANTS that have all
ALREADY passed the scrub oracle, this scores how much real detail each variant
preserved -- so "closer to the original" is the right axis here (between two
equally-scrubbed outputs, the one that deviates less from the original wins).
It is a standalone eval tool, NOT part of the package: PEP 723 inline deps let
``uv run`` build a throwaway env so the heavy models (PaddleOCR, insightface,
LPIPS) never touch uv.lock or the shipped library. Metrics self-gate: face
metrics run only where faces are detected, text metrics only where text is.
Two subcommands:
ocr -- OCR images (PaddleOCR PP-OCRv6) into a JSON {basename: text} file.
Run this on the ORIGINALS, hand-verify/correct the file, and it
becomes the ground truth for ``compare --ground-truth`` -- the clean
way to score text, since OCR-vs-OCR is doubly noisy (errors on both
images + reading-order differences inflate CER even on identical text).
compare -- Score each VARIANT against the ORIGINAL across four groups:
1. Text -- character error rate (CER) of the variant's OCR vs the
verified ground truth (or the original's OCR if no --ground-truth).
2. Face identity -- insightface (buffalo_l) ArcFace cosine similarity.
3. Face texture -- LPIPS + Laplacian-variance ratio on face crops
(catches "plastication": ratio < 1 = smoother than the original).
4. Whole image -- LPIPS / SSIM / PSNR vs the original.
Usage:
uv run scripts/fidelity_metrics.py ocr O1.png O2.png --langs en,ru,ch --out gt.json
# (edit gt.json by hand to fix any OCR slips, then:)
uv run scripts/fidelity_metrics.py compare --original O1.png \
--variant controlnet=C.png --variant qwen=Q.png \
--ocr-langs en,ru,ch --ground-truth gt.json
"""
from __future__ import annotations
import json
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import click
import cv2
import numpy as np
from _plain_console import Console, Table
console = Console()
# ── helpers ──────────────────────────────────────────────────────────
def _load_bgr(path: str) -> np.ndarray:
img = cv2.imread(path, cv2.IMREAD_COLOR)
if img is None:
raise click.ClickException(f"cannot read image: {path}")
return img
def _match_size(variant: np.ndarray, ref: np.ndarray) -> np.ndarray:
"""Resize a variant to the reference size (outputs differ by a grid-round)."""
if variant.shape[:2] != ref.shape[:2]:
variant = cv2.resize(variant, (ref.shape[1], ref.shape[0]), interpolation=cv2.INTER_LANCZOS4)
return variant
def _norm(text: str) -> str:
"""Normalize for CER: NFC + drop ALL whitespace (segmentation-order agnostic)."""
return "".join(unicodedata.normalize("NFC", text).split())
# ── text: PaddleOCR (PP-OCRv6) ───────────────────────────────────────
# Our lang codes -> PaddleOCR lang. The 'ch' model also reads Latin; 'ru' reads
# Cyrillic + Latin. Multiple langs in one image -> run each model, union detections.
_PADDLE_LANG = {"en": "en", "ru": "ru", "ch": "ch", "ch_sim": "ch", "latin": "latin"}
_paddle_cache: dict[str, Any] = {}
def _paddle(lang: str) -> Any:
if lang not in _paddle_cache:
from paddleocr import PaddleOCR
_paddle_cache[lang] = PaddleOCR(
lang=lang,
use_doc_orientation_classify=False,
use_doc_unwarping=False,
use_textline_orientation=False,
)
return _paddle_cache[lang]
def _box_xyxy(box: Any) -> tuple[float, float, float, float]:
"""Axis-aligned (x1, y1, x2, y2) of a PaddleOCR rec box ([x1,y1,x2,y2]) or poly (4x2)."""
arr = np.asarray(box, dtype=np.float32).reshape(-1)
if arr.size == 4:
return float(arr[0]), float(arr[1]), float(arr[2]), float(arr[3])
pts = arr.reshape(-1, 2)
return float(pts[:, 0].min()), float(pts[:, 1].min()), float(pts[:, 0].max()), float(pts[:, 1].max())
def _iou(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> float:
ix1, iy1 = max(a[0], b[0]), max(a[1], b[1])
ix2, iy2 = min(a[2], b[2]), min(a[3], b[3])
iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1)
inter = iw * ih
if inter <= 0:
return 0.0
area_a = (a[2] - a[0]) * (a[3] - a[1])
area_b = (b[2] - b[0]) * (b[3] - b[1])
return inter / (area_a + area_b - inter + 1e-9)
def _ocr_lines(bgr: np.ndarray, langs: list[str], min_score: float = 0.5) -> list[str]:
"""Detected text lines in reading order, unioned across lang models with spatial NMS.
Several language models over one image re-detect the same lines -- and crucially the
WRONG-script models read e.g. Cyrillic as confident Latin gibberish. So instead of a
naive union, keep the HIGHEST-score detection per physical location (greedy IoU NMS):
the model that actually fits a line wins it (the 'ru' model takes the Cyrillic, 'ch'
the CJK, 'en' the Latin), and the cross-script garbage is dropped.
"""
raw: list[tuple[float, tuple[float, float, float, float], str]] = []
for lang in langs:
plang = _PADDLE_LANG.get(lang, lang)
for page in _paddle(plang).predict(bgr):
texts = page.get("rec_texts", [])
scores = page.get("rec_scores", [])
boxes = page.get("rec_boxes", None)
if boxes is None or len(boxes) == 0:
boxes = page.get("rec_polys", [])
for text, score, box in zip(texts, scores, boxes, strict=False):
if score < min_score or not text.strip():
continue
raw.append((float(score), _box_xyxy(box), text.strip()))
raw.sort(key=lambda d: d[0], reverse=True)
kept: list[tuple[tuple[float, float, float, float], str]] = []
for _score, box, text in raw:
if any(_iou(box, kbox) > 0.3 for kbox, _ in kept):
continue
kept.append((box, text))
kept.sort(key=lambda d: (round(d[0][1] / 20.0), d[0][0])) # reading order: y then x
return [t for _, t in kept]
def _cer(ref: str, hyp: str) -> float:
from rapidfuzz.distance import Levenshtein
return Levenshtein.normalized_distance(_norm(ref), _norm(hyp))
# ── face: detection + ArcFace + texture ──────────────────────────────
@dataclass
class FaceStats:
n_faces: int = 0
identity: list[float] = field(default_factory=list)
lpips: list[float] = field(default_factory=list)
lapvar_ratio: list[float] = field(default_factory=list)
def _lap_var(bgr: np.ndarray) -> float:
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
return float(cv2.Laplacian(gray, cv2.CV_64F).var())
def _bbox_center(bbox: Any) -> tuple[float, float]:
return (bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2
def _bbox_diag(bbox: Any) -> float:
return float(((bbox[2] - bbox[0]) ** 2 + (bbox[3] - bbox[1]) ** 2) ** 0.5)
def assign_faces_one_to_one(
ref_centers: list[tuple[float, float]],
var_centers: list[tuple[float, float]],
ref_diags: list[float],
max_frac: float = 0.6,
) -> dict[int, int]:
"""One-to-one nearest-center face assignment (pure; unit-tested without insightface).
Per-face nearest matching collides on multi-face images -- two original faces can both
pick the SAME variant face (e.g. when regeneration drops a face, so the variant has fewer
detections), corrupting the identity metric (the lapvar/LPIPS metrics are immune: they are
anchored to the ORIGINAL bbox on both images). This greedy-by-distance assignment is
collision-free: it walks candidate pairs nearest-first and never reuses a ref or a variant
face. Faces are spatially well-separated, so greedy equals the optimal (Hungarian) result
here without the scipy dependency. A pair is dropped when the center distance exceeds
``max_frac`` of the original face diagonal (no plausible match -- the face was lost).
Returns a dict mapping ref-face index -> variant-face index for matched faces only.
"""
pairs: list[tuple[float, int, int]] = []
for i, (rx, ry) in enumerate(ref_centers):
for j, (vx, vy) in enumerate(var_centers):
pairs.append((((rx - vx) ** 2 + (ry - vy) ** 2) ** 0.5, i, j))
pairs.sort()
used_ref: set[int] = set()
used_var: set[int] = set()
matched: dict[int, int] = {}
for dist, i, j in pairs:
if i in used_ref or j in used_var:
continue
if dist > max_frac * ref_diags[i]:
continue
matched[i] = j
used_ref.add(i)
used_var.add(j)
return matched
def _cosine(a: np.ndarray, b: np.ndarray) -> float:
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))
def _crop(bgr: np.ndarray, bbox: Any) -> np.ndarray:
h, w = bgr.shape[:2]
x1, y1, x2, y2 = (int(max(0, bbox[0])), int(max(0, bbox[1])), int(min(w, bbox[2])), int(min(h, bbox[3])))
return bgr[y1:y2, x1:x2]
# ── whole image: LPIPS / SSIM / PSNR ─────────────────────────────────
def _lpips_model() -> tuple[Any, Any]:
import lpips
import torch
model = lpips.LPIPS(net="alex", verbose=False)
model.eval()
return model, torch
def _lpips_distance(model_torch: tuple[Any, Any], a_bgr: np.ndarray, b_bgr: np.ndarray) -> float:
model, torch = model_torch
def _t(img: np.ndarray) -> Any:
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB).astype(np.float32) / 127.5 - 1.0
return torch.from_numpy(rgb).permute(2, 0, 1).unsqueeze(0)
with torch.no_grad():
return float(model(_t(a_bgr), _t(b_bgr)).item())
def _ssim_psnr(a_bgr: np.ndarray, b_bgr: np.ndarray) -> tuple[float, float]:
from skimage.metrics import peak_signal_noise_ratio, structural_similarity
a = cv2.cvtColor(a_bgr, cv2.COLOR_BGR2GRAY)
b = cv2.cvtColor(b_bgr, cv2.COLOR_BGR2GRAY)
return float(structural_similarity(a, b)), float(peak_signal_noise_ratio(a, b))
# ── reporting ────────────────────────────────────────────────────────
def _mean(xs: list[float]) -> float | None:
return sum(xs) / len(xs) if xs else None
def _fmt(v: float | None, nd: int = 3) -> str:
return "-" if v is None else f"{v:.{nd}f}"
# ── CLI ──────────────────────────────────────────────────────────────
@click.group()
def cli() -> None:
"""Objective fidelity metrics for watermark-removal outputs."""
@cli.command("ocr")
@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True))
@click.option("--langs", default="en", help="Comma list of OCR langs (en,ru,ch).")
@click.option("--out", type=click.Path(), default=None, help="Write {basename: text} JSON here (for ground truth).")
def ocr_cmd(images: tuple[str, ...], langs: str, out: str | None) -> None:
"""OCR images into a ground-truth seed -- hand-verify the result before using it."""
lang_list = [x.strip() for x in langs.split(",") if x.strip()]
result: dict[str, str] = {}
for path in images:
lines = _ocr_lines(_load_bgr(path), lang_list)
text = "\n".join(lines)
result[Path(path).name] = text
console.print(f"\n=== {Path(path).name} ===")
console.print(text or "(no text detected)")
if out:
Path(out).write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
console.print(f"\n Wrote {out} -- verify/correct it by hand, then pass it to `compare --ground-truth`.")
@cli.command("compare")
@click.option("--original", required=True, type=click.Path(exists=True), help="Reference (unprocessed) image.")
@click.option(
"--variant", "variants", multiple=True, required=True, help="LABEL=PATH of a cleaned output (repeatable)."
)
@click.option("--ocr-langs", default="en", help="Comma list of OCR langs (en,ru,ch). Empty = skip text.")
@click.option("--ground-truth", type=click.Path(exists=True), default=None, help="Verified {basename: text} JSON.")
@click.option("--no-faces", is_flag=True, help="Skip face metrics.")
def compare(original: str, variants: tuple[str, ...], ocr_langs: str, ground_truth: str | None, no_faces: bool) -> None:
"""Score each VARIANT against ORIGINAL across the four fidelity groups."""
ref = _load_bgr(original)
parsed: list[tuple[str, np.ndarray]] = []
for spec in variants:
if "=" not in spec:
raise click.ClickException(f"--variant must be LABEL=PATH, got {spec!r}")
label, path = spec.split("=", 1)
parsed.append((label, _match_size(_load_bgr(path), ref)))
langs = [x.strip() for x in ocr_langs.split(",") if x.strip()]
lp = _lpips_model() # AlexNet LPIPS, loaded once and reused for face crops + whole image
# ── text ──
ocr_cer: dict[str, float | None] = {label: None for label, _ in parsed}
if langs:
ref_text: str | None = None
if ground_truth:
gt = json.loads(Path(ground_truth).read_text(encoding="utf-8"))
ref_text = gt.get(Path(original).name)
if ref_text is None:
console.print(f" (no ground-truth entry for {Path(original).name}; skipping text)")
else:
console.print(f" OCR original ({','.join(langs)})...")
ref_text = "\n".join(_ocr_lines(ref, langs))
if ref_text:
console.print(f" OCR variants ({','.join(langs)})...")
for label, img in parsed:
ocr_cer[label] = _cer(ref_text, "\n".join(_ocr_lines(img, langs)))
# ── faces ──
face_stats: dict[str, FaceStats] = {label: FaceStats() for label, _ in parsed}
if not no_faces:
console.print(" Faces (insightface buffalo_l)...")
from insightface.app import FaceAnalysis
app = FaceAnalysis(name="buffalo_l", providers=["CPUExecutionProvider"])
app.prepare(ctx_id=-1, det_size=(640, 640))
ref_faces = app.get(ref)
if ref_faces:
ref_centers = [_bbox_center(of.bbox) for of in ref_faces]
ref_diags = [_bbox_diag(of.bbox) for of in ref_faces]
for label, img in parsed:
vfaces = app.get(img)
st = face_stats[label]
# One-to-one assignment for identity (collision-free); lapvar/LPIPS stay
# anchored to the original bbox below, so they need no match.
matched = assign_faces_one_to_one(ref_centers, [_bbox_center(vf.bbox) for vf in vfaces], ref_diags)
for oi, of in enumerate(ref_faces):
st.n_faces += 1
vf = vfaces[matched[oi]] if oi in matched else None
if vf is not None:
st.identity.append(_cosine(of.normed_embedding, vf.normed_embedding))
oc, vc = _crop(ref, of.bbox), _crop(img, of.bbox)
if oc.size == 0 or vc.size == 0:
continue
vc_r = cv2.resize(vc, (oc.shape[1], oc.shape[0]), interpolation=cv2.INTER_LANCZOS4)
st.lpips.append(_lpips_distance(lp, oc, vc_r))
ov = _lap_var(oc)
st.lapvar_ratio.append(_lap_var(vc_r) / ov if ov > 1e-6 else 0.0)
else:
console.print(" (no faces detected in the original; skipping face metrics)")
# ── whole image ──
console.print(" Whole-image LPIPS/SSIM/PSNR...")
whole: dict[str, tuple[float, float, float]] = {}
for label, img in parsed:
ssim, psnr = _ssim_psnr(ref, img)
whole[label] = (_lpips_distance(lp, ref, img), ssim, psnr)
# ── report ──
table = Table(title=f"Fidelity vs {Path(original).name} (reference)")
for col in ("variant", "text CER↓", "faces", "ID cos↑", "face LPIPS↓", "lapvar↑", "img LPIPS↓", "SSIM↑", "PSNR↑"):
table.add_column(col)
for label, _ in parsed:
st = face_stats[label]
wl, ws, wp = whole[label]
table.add_row(
label,
_fmt(ocr_cer[label]),
str(st.n_faces),
_fmt(_mean(st.identity)),
_fmt(_mean(st.lpips)),
_fmt(_mean(st.lapvar_ratio)),
_fmt(wl),
_fmt(ws),
_fmt(wp, 1),
)
console.print(table)
console.print(
" Legend: CER lower=better; ID cos higher=better; face LPIPS lower=better; "
"lapvar ratio ~1=detail kept, <1=smoothed/plastic; img LPIPS lower=better; SSIM/PSNR higher=closer."
)
if __name__ == "__main__":
cli()
+128
View File
@@ -0,0 +1,128 @@
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "diffusers>=0.35.0",
# "transformers>=4.51.0",
# "torch",
# "accelerate",
# "pillow",
# "click",
# ]
# ///
"""Isolated GPU prototype: does a low-strength Qwen-Image img2img pass scrub the
invisible watermark while keeping text/structure legible?
This is the oracle-gated experiment behind Library roadmap P1#5 (migrate the
invisible pipeline onto Qwen-Image-Edit). It is DELIBERATELY standalone:
* It is NOT imported by the package and NOT in ``uv.lock``. Qwen-Image needs a
newer ``diffusers``/``transformers`` (Qwen2.5-VL text encoder) than the SDXL
pipeline is pinned to, so wiring it into the locked env would risk the
certified SDXL/ControlNet pipeline (the ``cannot import Qwen3VL...`` trap).
PEP 723 inline metadata lets ``uv run`` build a throwaway env for it instead.
* Qwen-Image is ~20B, so it needs a real GPU (CUDA) -- it will not fit on MPS.
Run (on a GPU box / Modal), then eyeball the outputs AND submit them to the
matching oracle (openai.com/verify for OpenAI, the Gemini app for Google):
uv run scripts/qwen_scrub_prototype.py INPUT.png -o out/ --strengths 0.1,0.2,0.3,0.4
What to look for:
* SCRUB: the oracle no longer reports the watermark at some strength.
* FIDELITY: text stays legible and faces/structure stay faithful at that same
strength -- the whole point of trying Qwen over SDXL (which garbles text).
The smallest strength that clears the oracle while keeping fidelity is the result
to compare against the SDXL/ControlNet floors (OpenAI 0.10 / Google 0.15).
"""
from __future__ import annotations
import logging
from pathlib import Path
import click
log = logging.getLogger("qwen_proto")
# A neutral, faithful-regeneration prompt (we want to scrub, not restyle); mirrors
# the intent of the SDXL controlnet prompt. Qwen renders text natively, so a light
# pass should keep captions legible where SDXL would garble them.
_PROMPT = "high quality, sharp, detailed, faithful to the original"
_NEGATIVE = "blurry, lowres, distorted text, garbled text, artifacts"
def _pick_device(requested: str) -> tuple[str, object]:
import torch
if requested != "auto":
device = requested
elif torch.cuda.is_available():
device = "cuda"
elif getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available():
device = "mps"
else:
device = "cpu"
# bf16 on CUDA (Qwen's reference dtype); fp32 elsewhere for numerical safety.
dtype = torch.bfloat16 if device == "cuda" else torch.float32
return device, dtype
@click.command()
@click.argument("source", type=click.Path(exists=True, path_type=Path))
@click.option("-o", "--output-dir", type=click.Path(path_type=Path), default=Path("qwen_out"))
@click.option("--strengths", default="0.1,0.2,0.3,0.4", help="Comma-separated img2img strengths to sweep.")
@click.option("--steps", type=int, default=40, help="Inference steps.")
@click.option("--cfg", type=float, default=4.0, help="true_cfg_scale (Qwen's CFG; reference default 4.0).")
@click.option("--model", default="Qwen/Qwen-Image", help="HF model id (Qwen-Image img2img base).")
@click.option("--device", default="auto", type=click.Choice(["auto", "cuda", "mps", "cpu"]))
@click.option("--seed", type=int, default=0, help="Reproducible seed.")
def main(
source: Path,
output_dir: Path,
strengths: str,
steps: int,
cfg: float,
model: str,
device: str,
seed: int,
) -> None:
"""Sweep Qwen-Image img2img strength over SOURCE and save one output per strength."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
import torch
from diffusers import QwenImageImg2ImgPipeline
from PIL import Image
dev, dtype = _pick_device(device)
log.info("Loading %s on %s (%s)...", model, dev, dtype)
pipe = QwenImageImg2ImgPipeline.from_pretrained(model, torch_dtype=dtype)
pipe = pipe.to(dev)
init_image = Image.open(source).convert("RGB")
output_dir.mkdir(parents=True, exist_ok=True)
values = [float(s) for s in strengths.split(",") if s.strip()]
for strength in values:
generator = torch.Generator(device="cpu").manual_seed(seed)
log.info("Generating strength=%.2f ...", strength)
result = pipe(
prompt=_PROMPT,
negative_prompt=_NEGATIVE,
image=init_image,
strength=strength,
num_inference_steps=steps,
true_cfg_scale=cfg,
generator=generator,
)
out_path = output_dir / f"{source.stem}_qwen_s{strength:.2f}.png"
result.images[0].save(out_path)
log.info(" saved %s", out_path)
log.info(
"\nDone. Eyeball text/face fidelity, then submit each output to the matching oracle "
"(openai.com/verify / Gemini app). The smallest strength that clears the oracle while "
"keeping fidelity is the number to compare against the SDXL floors (OpenAI 0.10 / Google 0.15)."
)
if __name__ == "__main__":
main()
+60
View File
@@ -0,0 +1,60 @@
"""Render the SYNTHETIC 'AI生成' pill silhouette asset (data-safe, font-rendered).
The Jimeng-basic TC260 visible label is a rounded pill with 'AI生成' in the TOP-LEFT
corner (issue #54). Unlike the reverse-alpha marks, this is a CAPTURE-LESS mark:
the committed asset is a font-rendered binary SILHOUETTE (mark shape only, zero photo
content), used ONLY to (a) detect the pill by edge-NCC in the top-left corner and
(b) build the inpaint mask. It is NOT an alpha map -- removal quality comes from the
inpaint backend (MI-GAN/cv2), so the silhouette need not be pixel-accurate, and the
synthetic render keeps corpus/user content out of the tracked repo (data-safety).
Detection was calibrated on the retained local corpus (61 real positives + jimeng
negatives): edge-NCC threshold ~0.22 in the top-left ROI. Re-run to regenerate the
asset: uv run python scripts/render_pill_silhouette.py
Requires a CJK font (macOS STHeiti by default); the asset itself is committed, so this
script only runs when regenerating it (never in CI).
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
_ASSET = Path(__file__).resolve().parents[1] / "src" / "remove_ai_watermarks" / "assets" / "jimeng_pill.png"
_FONT = "/System/Library/Fonts/STHeiti Medium.ttc"
def render_silhouette(w: int = 320) -> np.ndarray:
"""Rounded-pill outline + 'AI生成' text as a binary silhouette (255 = mark)."""
h = int(w * 0.5)
im = Image.new("L", (w, h), 0)
d = ImageDraw.Draw(im)
pad = int(w * 0.03)
r = (h - 2 * pad) // 3
d.rounded_rectangle([pad, pad, w - pad, h - pad], radius=r, outline=255, width=max(2, w // 90))
fsz = int(h * 0.42)
font = ImageFont.truetype(_FONT, fsz)
txt = "AI生成"
tb = d.textbbox((0, 0), txt, font=font)
tw, th = tb[2] - tb[0], tb[3] - tb[1]
d.text(((w - tw) // 2 - tb[0], (h - th) // 2 - tb[1]), txt, font=font, fill=255)
return np.array(im)
def main() -> None:
try:
sil = render_silhouette()
except OSError as e:
print(f"Font not found ({e}); install a CJK font or edit _FONT.", file=sys.stderr)
raise SystemExit(1) from e
_ASSET.parent.mkdir(parents=True, exist_ok=True)
Image.fromarray(sil).save(_ASSET)
print(f"wrote {_ASSET} ({sil.shape[1]}x{sil.shape[0]})")
if __name__ == "__main__":
main()
+31 -2
View File
@@ -1,7 +1,21 @@
"""Remove-AI-Watermarks: Unified tool for removing visible and invisible AI watermarks."""
"""Remove-AI-Watermarks: Unified tool for removing visible and invisible AI watermarks.
High-level API (lazy, so ``import remove_ai_watermarks`` stays cheap)::
import remove_ai_watermarks as raiw
raiw.remove_visible("in.png", "out.png") # clean a file (provenance auto)
result, removed = raiw.remove_visible(bgr_array) # array -> array
raiw.visible_provenance("in.png") # -> frozenset of confirmed vendors
For a provenance verdict use the ``identify`` submodule::
from remove_ai_watermarks.identify import identify
report = identify("in.png")
"""
import os as _os
import warnings as _warnings
from typing import TYPE_CHECKING
# transformers prints a noisy deprecation for the Siglip2ImageProcessorFast
# alias when it is imported (by the optional GPU/ML path). Silence it before
@@ -11,4 +25,19 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
__version__ = "0.11.3"
__version__ = "0.15.1"
__all__ = ["__version__", "remove_visible", "visible_provenance"]
if TYPE_CHECKING:
from remove_ai_watermarks.api import remove_visible, visible_provenance
def __getattr__(name: str) -> object:
"""Lazily resolve the high-level API (PEP 562), so the heavy imports (cv2, the
metadata/identify stack) load only when a caller actually reaches for them."""
if name in ("remove_visible", "visible_provenance"):
from remove_ai_watermarks import api
return getattr(api, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+129 -149
View File
@@ -1,20 +1,22 @@
"""Shared base for the reverse-alpha visible text-mark engines.
"""Shared base for the visible text-mark detectors/localizers (localize -> fill).
The Doubao "豆包AI生成", Jimeng "★ 即梦AI", and Samsung "✦ Contenuti generati
dall'AI" marks are the SAME algorithm: anchor a bottom-corner box by width-relative
geometry, extract the light low-saturation glyph candidate, detect by matching the
bundled alpha-glyph silhouette via ``TM_CCOEFF_NORMED``, and remove by inverting the
alpha blend ``original = (wm - a*logo)/(1-a)`` (always trying fixed AND NCC-aligned
placement, keeping the lower-residual one) plus a thin footprint inpaint.
geometry, extract the light low-saturation glyph candidate (white top-hat), detect
by matching the bundled alpha-glyph silhouette via ``TM_CCOEFF_NORMED``, and build a
removal MASK from the glyph blob's bounding box (:meth:`footprint_mask`) for the
shared fill (region_eraser). The mask is template-FREE -- the top-hat glyph bbox, not
a fixed alpha-template placement -- so a re-rendered or differently-placed mark (e.g.
a non-Italian Samsung string) is still masked. The old reverse-alpha pixel recovery
(``original = (wm - a*logo)/(1-a)``) is gone.
They differ ONLY in a bounded set of tuned values captured by :class:`TextMarkConfig`:
the constants, the bundled asset, the corner (Doubao/Jimeng bottom-right, Samsung
bottom-left), and a few structural knobs (the morphology-open kernel size and the
minimum glyph width used by the alignment / template-match). Each engine module is a
thin :class:`TextMarkEngine` subclass plus the test-facing module constants/helpers.
the constants, the bundled silhouette asset, the corner (Doubao/Jimeng bottom-right,
Samsung bottom-left), and a few structural knobs. Each engine module is a thin
:class:`TextMarkEngine` subclass plus the test-facing module constants/helpers.
Gemini stays a SEPARATE engine (``gemini_engine``): its multi-size fixed-slot sparkle
model is genuinely different, not a tuned variant of this one.
Gemini stays a SEPARATE engine (``gemini_engine``): its multi-size sparkle model is
genuinely different, not a tuned variant of this one.
"""
# cv2/numpy boundary: third-party libs ship no usable element types; relax the
@@ -37,10 +39,30 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Minimum image short side (px) for text-mark DETECTION. Below this the glyph
# template degrades to the ``min_gw`` floor (~8 px) and TM_CCOEFF_NORMED on a few
# pixels is noise, so an unrelated small geometric shape can spuriously correlate
# with the CJK silhouette (2026-06-26 FP: a 48x48 app icon -- a blue chevron --
# scored Doubao 0.41 / Jimeng 0.47, both above their thresholds). The FP is purely
# a small-size artifact: the same icon upscaled collapses to ~0.06-0.10 NCC at 256
# px and above. A real AI-generation text label is stamped on a full-resolution
# render (the captured samples are 1086-2048 px wide), so 200 px sits far below any
# genuine mark while killing the icon/thumbnail noise band (<=96 px). Detection is
# skipped (verdict stays "unknown", the safe default) rather than risk a false
# positive; removal is gated on detection, so it is suppressed too.
_MIN_DETECT_SHORT_SIDE = 200
# Provenance-confirmed NCC relaxation. When external metadata already confirms the
# vendor (so the mark is present with high prior), a faint or slightly re-rendered
# glyph that scores just below the standard NCC gate is still trusted. 0.7 recovers
# the near-threshold marks without dropping so low that an unrelated corner texture
# on a (provenance-confirmed) image would match -- the coverage gate still applies.
_PROVENANCE_NCC_FACTOR = 0.7
@dataclass(frozen=True)
class TextMarkConfig:
"""All per-mark tuning for a reverse-alpha text-mark engine."""
"""All per-mark tuning for a text-mark detector/localizer."""
name: str # short label for log lines (e.g. "Doubao")
asset_name: str # bundled alpha PNG under assets/ (e.g. "doubao_alpha.png")
@@ -59,18 +81,12 @@ class TextMarkConfig:
# detection
detect_min_coverage: float
detect_ncc_threshold: float
# alpha-map geometry (fraction of WIDTH) emitted by scripts/visible_alpha_solve.py
# alpha-map glyph geometry (fraction of WIDTH) emitted by
# scripts/visible_alpha_solve.py, sizing the detection silhouette for
# template_match_score
alpha_width_frac: float
alpha_height_frac: float
alpha_margin_x_frac: float
alpha_margin_bottom_frac: float
alpha_align_search: tuple[float, float, int] # np.linspace(start, stop, num) scale search
min_gw: int # minimum glyph width for the template match / align search (8 br, 16 Samsung)
alpha_logo_bgr: tuple[float, float, float] = (255.0, 255.0, 255.0)
# residual inpaint over the glyph footprint (thin)
residual_alpha_floor: float = 0.05
residual_dilate: int = 5
residual_inpaint_radius: int = 2
min_gw: int # minimum glyph width for the template match (8 br, 16 Samsung)
@dataclass
@@ -149,7 +165,7 @@ def template_match_score(box_mask: NDArray[Any], image_width: int, config: TextM
class TextMarkEngine:
"""Reverse-alpha visible text-mark remover (locate -> mask -> detect -> reverse-alpha)."""
"""Visible text-mark detector/localizer (locate -> mask -> detect; mask feeds the fill)."""
def __init__(self, config: TextMarkConfig) -> None:
self.config = config
@@ -184,20 +200,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,151 +239,108 @@ 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 ──────────────────────────────────────────────────────────
def detect(self, image: NDArray[Any]) -> TextMarkDetection:
def detect(self, image: NDArray[Any], *, provenance: bool = False) -> TextMarkDetection:
"""Detect the mark by matching the alpha-template glyph silhouette against
the corner candidate (``TM_CCOEFF_NORMED``); keys on glyph SHAPE, not coverage."""
the corner candidate (``TM_CCOEFF_NORMED``); keys on glyph SHAPE, not coverage.
``provenance`` signals that external metadata already confirms this vendor
(China-AIGC / byteimg for Doubao/Jimeng, ``samsung_genai`` for Samsung); the
NCC gate exists to keep a corner texture on an UNRELATED image from matching
the glyph silhouette, so when provenance confirms the vendor it is relaxed by
``_PROVENANCE_NCC_FACTOR`` to recover a faint or slightly re-rendered mark.
"""
c = self.config
det = TextMarkDetection()
if image is None or image.size == 0:
return det
# Guard against the small-image NCC-noise false positive (see
# _MIN_DETECT_SHORT_SIDE): an icon/thumbnail is too small to carry a real
# text label, and the degraded few-pixel template spuriously correlates.
if min(image.shape[:2]) < _MIN_DETECT_SHORT_SIDE:
logger.debug(
"%s detect: image short side %d < %d; too small to carry the mark, skipping.",
c.name,
min(image.shape[:2]),
_MIN_DETECT_SHORT_SIDE,
)
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
if coverage >= c.detect_min_coverage:
score = self._template_match_score(box, image.shape[1])
threshold = c.detect_ncc_threshold * (_PROVENANCE_NCC_FACTOR if provenance else 1.0)
det.confidence = score
det.detected = score >= c.detect_ncc_threshold
logger.debug("%s detect: coverage=%.3f ncc=%.2f detected=%s", c.name, coverage, score, det.detected)
det.detected = score >= threshold
logger.debug(
"%s detect: coverage=%.3f ncc=%.2f thr=%.2f detected=%s",
c.name,
coverage,
score,
threshold,
det.detected,
)
return det
# ── Reverse-alpha (recovery + thin residual inpaint) ────────────────
# ── Inpaint footprint (for the inpaint-fallback removal path) ────────
def reverse_alpha_available(self, image: NDArray[Any]) -> bool:
"""True if the bundled alpha map is loadable (NCC alignment places it at any
resolution; the caller still gates on ``detect`` so a clean corner is untouched)."""
return image is not None and image.size > 0 and self._alpha_template() is not None
# Minimum glyph pixels for a template-free footprint. Below this the corner has
# no real wordmark (a few top-hat specks), so without ``force`` there is nothing
# to mask. A real strip covers hundreds of pixels.
_MIN_GLYPH_PIXELS = 20
def _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)."""
c = self.config
at = self._alpha_template()
if at is None:
return None
h, w = image.shape[:2]
# Clamp both dims so a wide/short image cannot overflow the slice assignment.
gw = min(w, max(1, int(c.alpha_width_frac * w)))
gh = min(h, max(1, int(c.alpha_height_frac * w)))
if c.corner == "br":
ax = max(0, w - int(c.alpha_margin_x_frac * w) - gw)
else: # bottom-left
ax = min(max(0, int(c.alpha_margin_x_frac * w)), max(0, w - gw))
ay = max(0, h - int(c.alpha_margin_bottom_frac * w) - gh)
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 footprint_mask(
self, image: NDArray[Any], *, force: bool = False, dilate: int | None = None
) -> NDArray[Any] | None:
"""Full-frame uint8 mask (255 = mark) of the mark footprint, for the shared
fill removal path (cv2 / MI-GAN / LaMa), or None if no glyph is found.
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."""
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]
loc = self.locate(image)
bx, by, bw, bh = loc.bbox
box_mask = self.extract_mask(image, loc)[by : by + bh, bx : bx + bw]
expected = c.alpha_width_frac * w
best: tuple[float, int, int, int, int] | None = None
for scale in np.linspace(*c.alpha_align_search):
gw, gh = int(expected * scale), int(c.alpha_height_frac * w * scale)
if gw < c.min_gw or gh < 4 or gw >= bw or gh >= bh:
continue
t = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_NEAREST)
_, score, _, top_left = cv2.minMaxLoc(cv2.matchTemplate(box_mask, t, cv2.TM_CCOEFF_NORMED))
if best is None or score > best[0]:
best = (score, gw, gh, top_left[0], top_left[1])
if best is None:
return None
_, gw, gh, ox, oy = best
ax, ay = bx + ox, by + oy
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)
Template-FREE: localize the glyph blob with the top-hat :meth:`extract_mask`,
take its bounding box in the corner, and fill that box solid (plus a small
margin + dilation). Filling the enclosing rectangle -- not the sparse glyph
strokes -- is what makes it robust: the top-hat under-segments individual
strokes (which used to leave a "三包"-style residual ghost when the strokes
themselves were the mask), but the inpaint reconstructs the whole wordmark
rectangle from its surroundings, so a stroke missed by the top-hat is still
covered. This drops the fixed alpha-template dependency, so a re-rendered or
differently-localized mark (e.g. a non-Italian Samsung string) is still masked.
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
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.
With ``force`` and no glyph found, falls back to the whole geometry box (the
``--no-detect`` path). The caller gates on detection.
"""
out = image.copy()
x1, y1, gw, gh = region
x2, y2 = x1 + gw, y1 + gh
if y1 >= y2 or x1 >= x2:
return out
a3 = np.clip(amap[y1:y2, x1:x2], 0.0, 1.0)[:, :, None]
logo = np.array(self.config.alpha_logo_bgr, np.float32)
roi = out[y1:y2, x1:x2].astype(np.float32)
out[y1:y2, x1:x2] = np.clip((roi - a3 * logo) / np.clip(1.0 - a3, 0.25, 1.0), 0, 255).astype(np.uint8)
return out
def remove_watermark_reverse_alpha(self, image: NDArray[Any], *, residual_inpaint: bool = True) -> NDArray[Any]:
"""Recover the original pixels by inverting the alpha blend, then clear the
residual outline with a thin inpaint over the glyph footprint.
Placement: fixed geometry AND the NCC-aligned placement are always tried and
the one leaving the least residual mark (lowest re-``detect`` confidence) is
kept -- the mark re-rasterizes a few px per image, so fixed geometry alone is
not reliable. A single capture cannot pixel-cancel the mark on every image, so
a deliberately THIN residual inpaint (``residual_*``) follows: reverse-alpha
has already recovered the true background under the mark, so the inpaint only
finishes the residual edges instead of smearing the whole footprint. Call only
when :meth:`reverse_alpha_available` and the mark is detected.
"""
c = self.config
# Normalize to 3-channel BGR (the reverse-alpha math assumes a 3-channel logo).
if image is None or image.size == 0:
return None # guard before to_bgr (cvtColor raises on an empty Mat); mirror detect()
image = image_io.to_bgr(image)
# An image too small to hold the mark would make the geometry boxes degenerate
# and feed cv2.resize a ~1-px-tall target; skip cv2 entirely.
h, w = image.shape[:2]
if h < 32 or w < 64:
return image.copy()
maps = [m for m in (self._fixed_alpha_map(image), self._aligned_alpha_map(image)) if m is not None]
if not maps:
return image.copy()
best_out: NDArray[Any] | None = None
best_amap: NDArray[Any] | None = None
best_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
return image.copy()
if residual_inpaint:
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)
best_out = cv2.inpaint(best_out, rm, c.residual_inpaint_radius, cv2.INPAINT_NS)
return best_out
return None
loc = self.locate(image)
bx, by, bw, bh = loc.bbox
glyph = self.extract_mask(image, loc) # box-sized, 255 = glyph
ys, xs = np.where(glyph > 0)
if xs.size >= self._MIN_GLYPH_PIXELS:
pad = max(4, int(0.10 * bh))
rx1 = max(0, bx + int(xs.min()) - pad)
rx2 = min(w, bx + int(xs.max()) + 1 + pad)
ry1 = max(0, by + int(ys.min()) - pad)
ry2 = min(h, by + int(ys.max()) + 1 + pad)
elif force:
rx1, ry1, rx2, ry2 = bx, by, min(w, bx + bw), min(h, by + bh)
else:
return None
if rx1 >= rx2 or ry1 >= ry2:
return None
# Rectangular footprint + dilation is exactly region_eraser.boxes_to_mask (the
# same primitive the shared fill uses); reuse it instead of re-inlining the
# zeros/fill/MORPH_ELLIPSE-dilate here.
from remove_ai_watermarks import region_eraser
d = dilate if dilate is not None else max(3, int(0.02 * bw))
return region_eraser.boxes_to_mask((h, w), [(rx1, ry1, rx2 - rx1, ry2 - ry1)], dilate=d)
+127
View File
@@ -0,0 +1,127 @@
"""High-level convenience API: clean an image (or array) in one call.
The low-level building blocks live in ``watermark_registry`` (localize -> fill) and
``image_io`` (Unicode-safe, alpha-preserving IO). This module ties them into the two
calls a caller usually wants, so a library user does not have to decode images, wire
up metadata provenance, or preserve the alpha channel by hand:
import remove_ai_watermarks as raiw
raiw.remove_visible("in.png", "out.png") # path -> file, provenance auto
result, removed = raiw.remove_visible(bgr_array) # array -> array
raiw.remove_visible("shot.png", "out.png", sensitivity="assume_ai")
raiw.visible_provenance("in.png") # -> frozenset({"gemini"})
Imports stay lazy (inside the functions), so ``import remove_ai_watermarks`` is cheap.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from numpy.typing import NDArray
from remove_ai_watermarks.watermark_registry import Backend, Sensitivity
def visible_provenance(source: str | Path) -> frozenset[str]:
"""Vendor keys that the file's local metadata confirms, the evidence that drives
the ``auto`` sensitivity (relaxing a corroborated mark's detection trust gate).
Mapping: a Google/Gemini C2PA issuer -> ``"gemini"``; a China-AIGC (TC260) label
-> ``"doubao"``/``"jimeng"``; a ``samsung_genai`` marker -> ``"samsung"``.
Best-effort: any read error yields an empty set (no relaxation). Metadata-only, so
it never loads cv2/torch.
"""
import contextlib
keys: set[str] = set()
with contextlib.suppress(Exception):
from remove_ai_watermarks import identify, metadata
rep = identify.identify(Path(source), check_visible=False, check_invisible=False)
platform = (rep.platform or "").lower()
if "google" in platform or "gemini" in platform:
keys.add("gemini")
if metadata.aigc_label(Path(source)):
keys |= {"doubao", "jimeng"}
if metadata.samsung_genai(Path(source)):
keys.add("samsung")
return frozenset(keys)
def remove_visible(
source: str | Path | NDArray[Any],
output: str | Path | None = None,
*,
sensitivity: Sensitivity = "auto",
backend: Backend = "auto",
strip_metadata: bool = True,
write_noop: bool = True,
) -> tuple[NDArray[Any], list[str]]:
"""Remove every detected known visible AI mark (Gemini sparkle, Doubao/Jimeng/
Samsung text, the Jimeng pill) via localize -> fill, returning ``(result_bgr,
[labels removed])``.
``source`` is a file path OR a BGR ndarray. For a PATH, metadata provenance is read
automatically (so ``sensitivity="auto"`` recovers a moved/faint mark whenever the
file still carries its provenance) and the alpha channel is preserved on write; for
an ARRAY there is no metadata to read and no separate alpha plane. When ``output``
is given the cleaned image is written there (alpha rejoined for a path source); the
array is always returned as well, so an empty ``removed`` list tells a caller nothing
known was found (e.g. route to the diffusion ``all`` path or ``erase``).
``sensitivity`` (``auto``/``strict``/``assume_ai``) and ``backend``
(``auto``/``cv2``/``migan``/``lama``) are the same knobs as the CLI. Pass
``sensitivity="assume_ai"`` for a metadata-stripped screenshot the caller knows is
AI-generated (best recall, at the cost of a small near-lossless fill on a clean
corner if the guess is wrong).
``strip_metadata`` (default True, matching the CLI ``visible --strip-metadata``)
also strips AI provenance metadata (C2PA/EXIF/XMP/IPTC) from the written output via
the lossless :func:`metadata.remove_ai_metadata`, so a library call does exactly
what the CLI does. Only applies when ``output`` is given.
``write_noop`` (default True) controls whether ``output`` is written when NOTHING was
removed: True writes a clean passthrough copy (an idempotent clean); False leaves the
output path untouched, so a caller that treats "no mark" as "produce nothing" (the CLI
``visible`` no-mark contract) does not clobber a pre-existing file at that path.
"""
from remove_ai_watermarks import image_io, watermark_registry
alpha: NDArray[Any] | None = None
provenance: frozenset[str] = frozenset()
if isinstance(source, (str, Path)):
path = Path(source)
bgr, alpha = image_io.read_bgr_and_alpha(path)
if bgr is None:
raise ValueError(f"Could not read image: {source}")
provenance = visible_provenance(path)
else:
bgr = source
result, removed = watermark_registry.remove_auto_marks(
bgr, sensitivity=sensitivity, provenance=provenance, backend=backend
)
if output is not None and (removed or write_noop):
out_path = Path(output)
out_path.parent.mkdir(parents=True, exist_ok=True)
same_format = isinstance(source, (str, Path)) and Path(source).suffix.lower() == out_path.suffix.lower()
if not removed and same_format:
# Nothing was removed: copy the ORIGINAL bytes verbatim instead of a lossy
# re-encode of its decode, so the pixels stay bit-identical (the metadata
# strip below is lossless, so it does not disturb them either). Skip the copy
# for an in-place call (output == source): the bytes are already there, and
# shutil.copyfile would raise SameFileError.
if Path(source).resolve() != out_path.resolve(): # type: ignore[arg-type]
import shutil
shutil.copyfile(source, out_path) # type: ignore[arg-type]
else:
image_io.write_bgr_with_alpha(out_path, result, alpha)
if strip_metadata:
from remove_ai_watermarks import metadata
metadata.remove_ai_metadata(out_path, out_path)
return result, removed
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

File diff suppressed because it is too large. Load diff
+14 -27
View File
@@ -1,16 +1,17 @@
"""Doubao visible watermark removal engine.
"""Doubao visible watermark detector/localizer.
Doubao (ByteDance) stamps every generated image with a visible "豆包AI生成"
(Doubao AI generated) text strip in the bottom-right corner -- the explicit AIGC
label mandated by China's TC260 standard, a near-white semi-transparent overlay.
Removal is **reverse-alpha blending** against a captured alpha map
(``original = (wm - a*logo)/(1-a)``), always NCC-aligned to the actual mark plus a
thin residual inpaint over the glyph footprint. This is one of the three text-mark
engines that share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`;
this module supplies only Doubao's tuned :class:`TextMarkConfig` (bottom-right corner,
``assets/doubao_alpha.png`` rebuilt by ``scripts/visible_alpha_solve.py``). Arbitrary-
region inpainting still lives in ``region_eraser`` / the ``erase`` command.
Detection matches the bundled glyph silhouette against the corner candidate; removal
is the shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds
``region_eraser``), NOT reverse-alpha. This is one of the three text-mark engines that
share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module
supplies only Doubao's tuned :class:`TextMarkConfig` (bottom-right corner,
``assets/doubao_alpha.png`` -- the detection silhouette, rebuilt by
``scripts/visible_alpha_solve.py``). Arbitrary-region inpainting still lives in
``region_eraser`` / the ``erase`` command.
"""
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
@@ -48,19 +49,12 @@ TOPHAT_DELTA = 12 # glyph must exceed the local background by this many levels
DETECT_MIN_COVERAGE = 0.04
DETECT_NCC_THRESHOLD = 0.4
# Reverse-alpha geometry, emitted by scripts/visible_alpha_solve.py at the captured
# width. Removal always tries fixed AND NCC-aligned placement and keeps the lower
# residual, then a thin footprint inpaint clears the leftover edges.
# Detection-silhouette geometry, emitted by scripts/visible_alpha_solve.py at the
# captured width. Sizes the glyph silhouette for the TM_CCOEFF_NORMED detection match
# (removal is the template-free glyph-bbox footprint mask, not this template).
_ALPHA_NATIVE_WIDTH = 2048
_ALPHA_LOGO_BGR: tuple[float, float, float] = (255.0, 255.0, 255.0)
_ALPHA_WIDTH_FRAC = 0.1636 # asset width / image width -- the alignment scale seed
_ALPHA_WIDTH_FRAC = 0.1636 # asset width / image width -- sizes the detection silhouette
_ALPHA_HEIGHT_FRAC = 0.0405
_ALPHA_MARGIN_RIGHT_FRAC = 0.0132
_ALPHA_MARGIN_BOTTOM_FRAC = 0.0166
_ALPHA_ALIGN_SEARCH = (0.88, 1.12, 25)
_RESIDUAL_ALPHA_FLOOR = 0.05
_RESIDUAL_DILATE = 5
_RESIDUAL_INPAINT_RADIUS = 2
_CONFIG = TextMarkConfig(
name="Doubao",
@@ -79,14 +73,7 @@ _CONFIG = TextMarkConfig(
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
alpha_width_frac=_ALPHA_WIDTH_FRAC,
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
alpha_margin_x_frac=_ALPHA_MARGIN_RIGHT_FRAC,
alpha_margin_bottom_frac=_ALPHA_MARGIN_BOTTOM_FRAC,
alpha_align_search=_ALPHA_ALIGN_SEARCH,
min_gw=8,
alpha_logo_bgr=_ALPHA_LOGO_BGR,
residual_alpha_floor=_RESIDUAL_ALPHA_FLOOR,
residual_dilate=_RESIDUAL_DILATE,
residual_inpaint_radius=_RESIDUAL_INPAINT_RADIUS,
)
# Doubao-specific aliases for the shared detection result/engine.
@@ -109,7 +96,7 @@ def _template_match_score(box_mask: NDArray[Any], image_width: int) -> float:
class DoubaoEngine(TextMarkEngine):
"""Remove the visible Doubao "豆包AI生成" watermark (locate -> mask -> reverse-alpha)."""
"""Detect/localize the visible Doubao "豆包AI生成" watermark (locate -> mask; mask feeds the fill)."""
def __init__(self) -> None:
super().__init__(_CONFIG)
+200 -449
View File
@@ -1,16 +1,17 @@
"""Gemini visible watermark removal engine.
"""Gemini visible-sparkle detector and localizer (cv2/numpy, no GPU).
Port of the GeminiWatermarkTool reverse-alpha-blending algorithm from C++ to Python.
Original author: Allen Kuo (allenk) — https://github.com/allenk/GeminiWatermarkTool
Locates the Google Gemini / Nano Banana sparkle so the shared fill (region_eraser)
can inpaint it. Detection is a multi-scale NCC search against the captured sparkle
alpha template (ported from GeminiWatermarkTool's Snap Engine; original author
Allen Kuo (allenk), https://github.com/allenk/GeminiWatermarkTool), scored by a
spatial + gradient + variance fusion with a false-positive gate. ``footprint_mask``
returns the sparkle footprint (captured alpha thresholded low to include the halo,
then dilated) as a full-frame mask for the fill.
The Gemini AI watermark is applied using alpha blending:
watermarked = a * logo + (1 - a) * original
We reverse this to recover the original:
original = (watermarked - a * logo) / (1 - a)
The alpha maps are derived from background captures of the Gemini watermark
on pure-black backgrounds (48x48 for small images, 96x96 for large images).
The captured alpha maps are background captures of the sparkle on pure-black
backgrounds (48x48 for small images, 96x96 for large). NB: they are used here only
to DETECT and to shape the removal mask -- the old reverse-alpha pixel recovery
(``original = (watermarked - a*logo)/(1-a)``) is gone; removal is localize -> fill.
"""
# cv2/numpy boundary: cv2 and numpy ship no usable type info for the array ops
@@ -19,11 +20,12 @@ on pure-black backgrounds (48x48 for small images, 96x96 for large images).
# 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 functools
import logging
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
from typing import TYPE_CHECKING, Any
import cv2
import numpy as np
@@ -125,52 +127,23 @@ def _load_embedded_asset(name: str) -> NDArray[Any]:
return img
class GeminiEngine:
"""Engine for removing visible Gemini watermarks via reverse alpha blending.
# Single source of truth for the multi-scale template ladder (aggressively downscaled to
# slightly upscaled): the precomputed `_tmpl_cache` and the `_scan_scales` loop must use
# the SAME scales or a scan scale would miss the cache and KeyError.
_TEMPLATE_SCALES: tuple[int, ...] = tuple(range(16, 120, 2))
This is a Python port of the GeminiWatermarkTool C++ engine.
class GeminiEngine:
"""Detects and localizes the visible Gemini sparkle for the shared fill removal.
The multi-scale NCC detection is a Python port of the GeminiWatermarkTool C++
Snap Engine; ``footprint_mask`` turns a detection into a removal mask.
"""
# Footprint pixels with alpha at/above this are the sparkle body; below it the
# mark barely affects the pixel, so those are excluded from both the
# over-subtraction test and the inpaint mask.
_FOOTPRINT_ALPHA = 0.1
# If more than this fraction of footprint pixels over-subtract (numerator < 0),
# the fixed alpha does not match this image's sparkle and reverse-alpha would
# punch a dark pit -- inpaint instead. demo_banana measures 0.0 (reverse-alpha
# kept), the issue #30 dark-grass image measures ~0.61 (inpaint), so the 0.05
# gate separates them with a wide margin.
_OVERSUB_FOOTPRINT_FRAC = 0.05
# Mid-tone over-subtraction (2026-06-18 prod "the color just changed, not removed"
# report). The numerator fraction above only trips when reverse-alpha drives a
# footprint pixel fully NEGATIVE -- the dark-background black-pit case. On a MID-TONE
# background a sparkle fainter than the captured alpha is over-subtracted into a
# visibly DARKER-than-background diamond while no pixel ever crosses zero, so the
# numerator gate misses it and ships the dark mark. Predict the reverse-alpha output
# at the bright core, (core - a*logo)/(1-a); when it lands more than this many gray
# levels BELOW the local background ring, reverse-alpha would leave a dark diamond --
# inpaint instead. Calibrated wide: clean removals predict within ~12 of background
# (demo_banana ~-1, a bright-bg sparkle ~-12), the prod regression predicts ~-40 and
# the issue #30 dark case ~-82, so 25 separates keep-vs-inpaint with margin.
_OVERSUB_DARK_MARGIN = 25.0
# Per-image alpha gain (under-subtraction fix). The captured alpha peaks ~0.51
# (a ~51%-opaque sparkle). Some real Gemini sparkles are rendered MORE opaque,
# so the fixed alpha under-subtracts and reverse-alpha leaves a bright residual
# the detector still fires on (~11% of marks on the spaces corpus). Estimate
# this image's effective sparkle opacity from the bright core vs the local
# background and scale the alpha to match, capped so alpha stays < 0.99. The
# gain is clamped to >= 1.0 so it only ever STRENGTHENS removal: ~1.0 when the
# sparkle matches the capture (working cases unchanged), >1 when more opaque.
# On the spaces corpus the gain cleanly separates -- under-removed marks ~1.47,
# cleanly-removed ~1.00. 1.94 is the cap that reaches alpha 0.99 from 0.51.
_ALPHA_GAIN_MAX = 1.94
_ALPHA_GAIN_CORE_FRAC = 0.8 # body pixels at >= this * peak alpha define the core
# Deadband: apply the gain only above this, so a sparkle that already matches the
# capture (estimated gain ~1.0-1.04 from background noise) stays byte-identical to
# the pre-fix output. Under-removed marks estimate >= 1.26, well clear of the band.
_ALPHA_GAIN_DEADBAND = 1.05
# Body pixels at >= this fraction of the peak captured alpha define the sparkle
# "core", sampled by the detection FP-gate's core-vs-ring brightness margin
# (:meth:`_core_and_bg`).
_CORE_ALPHA_FRAC = 0.8
# Sparkle false-positive gate. A real Gemini sparkle is a bright WHITE overlay,
# so its core sits above the local background; a shape-only NCC match on ornate
@@ -185,23 +158,36 @@ class GeminiEngine:
# fail to demote.
_SPARKLE_FP_CONF = 0.65
_SPARKLE_FP_MARGIN = 5.0
# Bright-background content false positives (2026-06-26 landing-page FPs: a snow+sky
# photo and a white-background product render both scored ~0.51). The margin gate
# above cannot catch them -- a bright background gives the "core" a HIGH core-ring
# margin (it is genuinely brighter than its surroundings), so the brightness check
# reads it as a real overlay. The discriminating signature is the GRADIENT NCC: a
# real white sparkle is a crisp star silhouette (grad ~0.97-1.0 on the synthetic
# composites, ~0.96 on the real #36 corner sparkle), while a smooth luminance blob
# that shape-NCC-matches the rough outline has low gradient fidelity (the two FPs
# measured 0.105 and 0.463). So ALSO demote a low-confidence match whose gradient
# NCC is below this floor, regardless of margin -- 0.55 sits well above the worst FP
# (0.463) and far below every real sparkle (>=0.8). This only ADDS demotions on
# bright backgrounds (a real bright-bg sparkle keeps grad ~0.97), so it cannot
# regress a dark/mid sparkle (already kept by margin) or a white-bg one (kept by
# confidence >= 0.65, above the gate).
_SPARKLE_FP_GRAD = 0.55
# Self-verify fallback. The gain estimate corrects most under-subtractions, but
# on the spaces corpus a tail of strong sparkles still survived reverse-alpha
# (a few px of position jitter or a gain estimate the [1.0, 1.94] clamp could
# not fully reach). After the reverse blend, re-detect; if a sparkle this strong
# remains, inpaint the footprint and keep that ONLY when it lowers the re-detect
# confidence. Purely additive: the common clean removal re-detects below this and
# is returned untouched. Threshold matches the registry's real fail line (0.5),
# so it triggers exactly on the cases that would otherwise read as not-removed
# (rescued 4 of 15 corpus fails, 0 regressions). An offset+scale alignment search
# was prototyped on the remaining 11 but REJECTED: it only lowered the shape-NCC by
# moving the reverse-alpha to a different placement that left the sparkle as bright
# or brighter (NCC-gaming, not removal), so a brightness sanity check rejected every
# one. The footprint inpaint physically reconstructs the slot from its surroundings,
# so its rescues are genuine; the survivors are near-white ill-conditioning or
# detector false positives that no reverse-alpha placement fixes.
_VERIFY_FALLBACK_CONF = 0.5
# White-core rescue for the gate above. A real but FAINT sparkle -- a soft white
# star on a bright/textured background -- has a high core-ring margin but low
# gradient fidelity, the SAME signature the grad gate uses to demote the smooth
# colored-corner FP, so faint real sparkles get demoted with it. The separator the
# grad gate discards is the CORE COLOR: a real Gemini sparkle core is near-WHITE
# (low saturation), while a clean bright corner that shape-matches (sky, sun, a warm
# light) is COLORED. So do NOT demote a low-grad match that already clears the trust
# confidence (_SPARKLE_KEEP_CONF -- the registry's 0.5 sparkle gate plus a small
# margin so the ~0.51 bright-background FPs the grad gate was added for stay demoted)
# AND has a bright (margin) near-neutral core (_core_saturation <= _SPARKLE_WHITE_SAT).
# Corpus-measured on metadata-stripped faint sparkles: recovers ~14/20 the low-grad
# demotion would drop, at ~0.8% clean false-fire vs the ~0.55% baseline.
_SPARKLE_KEEP_CONF = 0.52
_SPARKLE_WHITE_SAT = 0.20
# Corner promotion (issue #36): the size weight that suppresses tiny-patch
# false positives also buries a small, near-perfect sparkle when a larger,
@@ -263,6 +249,16 @@ class GeminiEngine:
self._alpha_small = _calculate_alpha_map(bg_small)
self._alpha_large = _calculate_alpha_map(bg_large)
# Per-scale resized templates are constant (``_alpha_large`` never changes),
# so precompute the whole fixed 16..118 ladder once: ``_scan_scales`` runs it on
# every image (twice -- global + corner), and re-``resize``-ing the 96x96 source
# each time is pure repeated work. Prebuilt (not lazy) so the dict is read-only
# after construction and safe to share across threads via the module singleton.
self._tmpl_cache: dict[int, NDArray[Any]] = {
scale: cv2.resize(self._alpha_large, (scale, scale), interpolation=cv2.INTER_AREA)
for scale in _TEMPLATE_SCALES
}
logger.debug(
"Alpha maps loaded: small=%s, large=%s",
self._alpha_small.shape,
@@ -296,11 +292,10 @@ class GeminiEngine:
``_alpha_large`` is the high-quality source downscaled per scale; the range
covers aggressively downscaled to slightly upscaled logos.
"""
for scale in range(16, 120, 2):
for scale in _TEMPLATE_SCALES:
if scale > gray.shape[0] or scale > gray.shape[1]:
continue
tmpl = cv2.resize(self._alpha_large, (scale, scale), interpolation=cv2.INTER_AREA)
match_res = cv2.matchTemplate(gray, tmpl, cv2.TM_CCOEFF_NORMED)
match_res = cv2.matchTemplate(gray, self._tmpl_cache[scale], cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(match_res)
yield scale, float(max_val), max_loc
@@ -308,8 +303,18 @@ class GeminiEngine:
self,
image: NDArray[Any],
force_size: WatermarkSize | None = None,
*,
trust_provenance: bool = False,
) -> DetectionResult:
"""Detect Gemini watermark using multi-scale Snap Engine logic (ported from C++ vendor algorithm)."""
"""Detect Gemini watermark using multi-scale Snap Engine logic (ported from C++ vendor algorithm).
``trust_provenance`` signals that external metadata already proves this is a
Google generation (C2PA issuer "Google"/"Gemini"). The false-positive gate
exists only to reject content that shape-matches the sparkle on NON-Google
images (Doubao text, ornate corners); when provenance confirms Google, that
gate would demote a genuine sparkle the vendor moved/re-rendered (bigger,
lighter, shifted), so it is skipped. The caller (registry) still applies the
relaxed provenance trust gate to the returned confidence."""
result = DetectionResult()
if image is None or image.size == 0:
@@ -372,6 +377,12 @@ class GeminiEngine:
if promoted is not None:
candidates.append(promoted)
# No candidate at any scale: the search region is smaller than the 16px template
# floor (an image whose short side is < 16px), so nothing is detectable. Return
# the empty (detected=False) result rather than dereferencing candidates[0].
if not candidates:
return result
# Select the candidate with the highest full-fusion confidence (pre-FP-gate).
best_scale, pos_x, pos_y, best_raw_ncc = candidates[0]
grad_score, var_score, best_fused = 0.0, 0.0, -1.0
@@ -399,18 +410,34 @@ class GeminiEngine:
# best_fused is the selected candidate's spatial*0.5 + grad*0.3 + var*0.2.
confidence = best_fused
# False-positive gate: a low-confidence shape match whose core is NOT brighter
# than its surroundings is a content false positive, not a white sparkle overlay.
if confidence < self._SPARKLE_FP_CONF:
margin = self._core_ring_margin(image, self.get_interpolated_alpha(best_scale), (pos_x, pos_y))
if margin is not None and margin < self._SPARKLE_FP_MARGIN:
logger.debug(
"Sparkle FP gate: conf=%.3f, core-ring margin=%.1f < %.1f; demoting.",
confidence,
margin,
self._SPARKLE_FP_MARGIN,
)
confidence = min(confidence, 0.30)
# False-positive gate: a low-confidence match that shows NEITHER real-sparkle
# signature is a content false positive, not a white sparkle overlay. A real
# sparkle proves itself by a bright core (high core-ring margin, on dark/mid
# backgrounds) OR a crisp star silhouette (high gradient NCC, on any background
# incl. bright). Demote when both are weak -- this catches the dark/mid no-core
# FP (low margin) AND the bright-background smooth-blob FP (high margin but low
# gradient), which the margin check alone misses. See _SPARKLE_FP_GRAD.
if confidence < self._SPARKLE_FP_CONF and not trust_provenance:
alpha = self.get_interpolated_alpha(best_scale)
pos = (pos_x, pos_y)
margin = self._core_ring_margin(image, alpha, pos)
low_margin = margin is not None and margin < self._SPARKLE_FP_MARGIN
low_grad = grad_score < self._SPARKLE_FP_GRAD
if low_margin or low_grad:
# White-core rescue: a real faint sparkle clears the trust confidence,
# has a bright core (not low_margin), and a near-WHITE core -- unlike the
# colored-corner FP the low-grad demotion targets. See _SPARKLE_WHITE_SAT.
core_sat = self._core_saturation(image, alpha, pos)
white_core = not low_margin and core_sat is not None and core_sat <= self._SPARKLE_WHITE_SAT
if not (confidence >= self._SPARKLE_KEEP_CONF and white_core):
logger.debug(
"Sparkle FP gate: conf=%.3f, margin=%s, grad=%.3f, core_sat=%s; demoting.",
confidence,
f"{margin:.1f}" if margin is not None else "n/a",
grad_score,
f"{core_sat:.2f}" if core_sat is not None else "n/a",
)
confidence = min(confidence, 0.30)
result.confidence = float(max(0.0, min(1.0, confidence)))
result.detected = result.confidence >= 0.35
@@ -507,111 +534,67 @@ class GeminiEngine:
# ── Removal ──────────────────────────────────────────────────────
def remove_watermark(
# Footprint mask for the localize -> fill removal path. The mask must cover the
# WHOLE sparkle including its faint semi-transparent halo, not just the bright
# core, or the fill leaves a visible ring. Threshold the captured alpha low
# (>_MASK_ALPHA catches the halo the core-only 0.10 misses) then dilate by a
# sparkle-relative margin so alignment slop and the outermost halo are absorbed.
_MASK_ALPHA = 0.04
_MASK_DILATE_FRAC = 0.18 # dilation radius as a fraction of the sparkle scale
def footprint_mask(
self,
image: NDArray[Any],
force_size: WatermarkSize | None = None,
) -> NDArray[Any]:
"""Remove Gemini visible watermark from an image using reverse alpha blending.
*,
force: bool = False,
dilate: int | None = None,
region: tuple[int, int, int, int] | None = None,
) -> NDArray[Any] | None:
"""Full-frame uint8 mask (255 = sparkle) of the sparkle footprint, for the
shared fill removal path (cv2 / MI-GAN / LaMa), or None.
No-op when the detector does not find a watermark: returns an unmodified
copy. Reverse alpha blending applied where no sparkle exists creates a
visible inverse artifact, so we refuse to touch pixels without a positive
detection. To bypass detection (e.g. you know the exact region), use
``remove_watermark_custom``.
The footprint is the interpolated captured alpha at the detected scale,
thresholded LOW so the faint halo is included, then dilated by a
sparkle-relative margin. When ``force`` and nothing is detected, falls back to
the default sparkle slot for the image size (the ``--no-detect`` path).
Args:
image: BGR image as numpy array (will NOT be modified in-place).
force_size: Force a specific watermark size (auto-detect if None).
Returns:
Cleaned BGR image as numpy array, or an unmodified copy when no
watermark is detected.
``region`` is the already-resolved ``(x, y, scale)`` from the caller's detection
(the registry passes the decision's provenance-aware region). When given, the
mask is built from it directly WITHOUT a second internal detect -- otherwise a
provenance/assume-relaxed sparkle would be re-demoted by the strict re-detect and
yield no mask (reported-removed-but-unchanged). Absent ``region``, direct callers
keep the detect-then-force behavior.
"""
# Normalize to 3-channel BGR up front: 2D grayscale (no channel axis) and
# 4-channel BGRA both reach this public entry point and would otherwise
# crash on the channel-count checks / downstream 3-channel math.
result = image_io.to_bgr(image.copy())
size = force_size or get_watermark_size(result.shape[1], result.shape[0])
# Detect dynamic position & size (on the normalized 3-channel image so a
# grayscale/BGRA input does not crash the detector).
detection = self.detect_watermark(result, force_size=size)
if not detection.detected:
logger.debug(
"No watermark detected (conf=%.3f); returning image unchanged.",
detection.confidence,
)
return result
pos = (detection.region[0], detection.region[1])
alpha_map = self.get_interpolated_alpha(detection.region[2])
# Match the captured alpha to this image's sparkle opacity (under-subtraction
# fix): a more-opaque-than-captured sparkle would otherwise leave a bright
# residual. gain == 1.0 leaves the working cases byte-identical.
gain = self._estimate_alpha_gain(result, alpha_map, pos)
if gain > self._ALPHA_GAIN_DEADBAND:
alpha_map = np.clip(alpha_map * gain, 0.0, 0.99)
logger.debug(
"Removing watermark at (%d, %d) size %dx%d [conf=%.3f]",
pos[0],
pos[1],
detection.region[2],
detection.region[3],
detection.confidence,
)
# The captured alpha map (max ~0.51 = a ~50%-opaque white sparkle) is exact
# only when the real mark's effective opacity matches it. On a dark/textured
# background the sparkle's effective alpha is lower than the capture, so the
# fixed-alpha reverse blend OVER-subtracts and drives the footprint to black --
# the "white sparkle turns into a black pit" bug (issue #30). The signature is
# a large fraction of footprint pixels whose numerator (watermarked - a*logo)
# goes negative, which is physically impossible under a brightening overlay.
# In that case inpaint the small footprint from the surrounding pixels instead;
# on a bright background no pixel over-subtracts, so reverse-alpha is used and
# the result is byte-identical to before (verified on demo_banana: 0% vs 61%).
if self._reverse_alpha_oversubtracts(result, alpha_map, pos):
logger.debug("Reverse-alpha over-subtracts on this background; inpainting sparkle footprint.")
self._inpaint_footprint(result, alpha_map, pos)
if image is None or image.size == 0:
return None # guard before to_bgr (cvtColor raises on an empty Mat); mirror detect_watermark
image = image_io.to_bgr(image)
h, w = image.shape[:2]
if region is not None:
x, y, scale = region[0], region[1], region[2]
else:
self._reverse_alpha_blend(result, alpha_map, pos)
return self._verify_and_repair(result, alpha_map, pos, size)
def remove_watermark_custom(
self,
image: NDArray[Any],
region: tuple[int, int, int, int],
) -> NDArray[Any]:
"""Remove watermark from a custom region with interpolated alpha map.
Args:
image: BGR image (will NOT be modified in-place).
region: (x, y, width, height) of the watermark region.
Returns:
Cleaned BGR image.
"""
# Same channel normalization as remove_watermark: the reverse-alpha blend
# assumes 3-channel BGR (a grayscale/BGRA input would mis-broadcast).
result = image_io.to_bgr(image.copy())
x, y, rw, rh = region
# Check standard sizes
if rw == 48 and rh == 48:
self._reverse_alpha_blend(result, self._alpha_small, (x, y))
return result
if rw == 96 and rh == 96:
self._reverse_alpha_blend(result, self._alpha_large, (x, y))
return result
# Interpolate alpha map for custom size
interp = cv2.INTER_LINEAR if rw > 96 else cv2.INTER_AREA
alpha = cv2.resize(self._alpha_large, (rw, rh), interpolation=interp)
self._reverse_alpha_blend(result, alpha, (x, y))
return result
det = self.detect_watermark(image)
if det.detected:
x, y, scale = det.region[0], det.region[1], det.region[2]
elif force:
cfg = get_watermark_config(w, h)
x, y = cfg.get_position(w, h)
scale = cfg.logo_size
else:
return None
alpha = self.get_interpolated_alpha(scale)
fp = self._footprint_indices(alpha, (x, y), image.shape)
if fp is None:
return None
aroi, (y1, y2, x1, x2) = fp
sil = (aroi > self._MASK_ALPHA).astype(np.uint8) * 255
if int((sil > 0).sum()) == 0:
return None
mask = np.zeros((h, w), np.uint8)
mask[y1:y2, x1:x2] = sil
d = dilate if dilate is not None else max(13, int(scale * self._MASK_DILATE_FRAC))
if d > 0:
mask = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * d + 1, 2 * d + 1)))
return mask
def _footprint_indices(
self,
@@ -655,7 +638,7 @@ class GeminiEngine:
a_cap = float(alpha_roi.max())
if a_cap < 0.2:
return None
core = alpha_roi >= a_cap * self._ALPHA_GAIN_CORE_FRAC
core = alpha_roi >= a_cap * self._CORE_ALPHA_FRAC
if not bool(core.any()):
return None
# Convert only the footprint+ring crop to gray, not the whole image: every
@@ -692,278 +675,46 @@ class GeminiEngine:
cb = self._core_and_bg(image, alpha_map, position)
return None if cb is None else cb[0] - cb[1]
def _estimate_alpha_gain(
def _core_saturation(
self,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> float:
"""Scale factor matching the captured alpha to this image's sparkle opacity.
The captured alpha (peak ~0.51) under-represents sparkles rendered more
opaque; reverse-alpha then leaves a bright residual. Estimate the effective
opacity at the sparkle core (observed brightness vs the local background
ring) and return ``a_eff / a_capture``, clamped to ``[1.0, _ALPHA_GAIN_MAX]``
so it only ever STRENGTHENS removal (1.0 = no change on a matching sparkle).
Returns 1.0 when the background cannot be estimated reliably.
"""
cb = self._core_and_bg(image, alpha_map, position)
if cb is None:
return 1.0
core_obs, bg, a_cap = cb
if 255.0 - bg < 5.0:
return 1.0
a_eff = float(np.clip((core_obs - bg) / (255.0 - bg), 0.0, 0.99))
return float(np.clip(a_eff / a_cap, 1.0, self._ALPHA_GAIN_MAX))
def _reverse_alpha_oversubtracts(
self,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> bool:
"""True when reverse-alpha would drive the footprint dark.
Two signatures of the captured alpha over-estimating this image's sparkle
opacity, either of which means reverse-alpha would leave a dark mark:
1. Dark-background black pit (issue #30): the numerator
``watermarked - alpha*logo`` over the sparkle body. A brightening overlay
can never make it negative, so a large negative fraction means the fixed
alpha over-subtracts past black.
2. Mid-tone dark diamond (see ``_OVERSUB_DARK_MARGIN``): on a mid-tone
background the over-subtraction darkens the core well below the background
without any pixel crossing zero, so case 1 misses it. Predict the
reverse-alpha core output and trip when it lands far below the local ring.
) -> float | None:
"""Median color saturation of the sparkle core (0 = white/neutral, higher =
colored). A real Gemini sparkle is a white star, so its core is near-neutral;
a clean bright corner that shape-matches (sky, sun, a warm light) is colored,
so a high core saturation flags the false positive the brightness/gradient
gates miss. Samples the same high-alpha core pixels as :meth:`_core_and_bg`.
None when the footprint cannot be placed or the core is empty.
"""
placed = self._footprint_indices(alpha_map, position, image.shape)
if placed is None:
return False
return None
alpha_roi, (y1, y2, x1, x2) = placed
body = alpha_roi >= self._FOOTPRINT_ALPHA
if not bool(body.any()):
return False
roi = image[y1:y2, x1:x2].astype(np.float32)
numerator = roi.mean(axis=2) - np.clip(alpha_roi, 0.0, 0.99) * self.logo_value
frac = float((numerator[body] < 0).sum()) / float(body.sum())
if frac > self._OVERSUB_FOOTPRINT_FRAC:
return True
a_cap = float(alpha_roi.max())
if a_cap < 0.2:
return None
core = alpha_roi >= a_cap * self._CORE_ALPHA_FRAC
box = image[y1:y2, x1:x2]
if box.shape[:2] != core.shape or not bool(core.any()):
return None
px = box[core].astype(np.float32) # (N, 3) BGR core pixels
hi = px.max(axis=1)
lo = px.min(axis=1)
return float(np.median((hi - lo) / (hi + 1.0)))
# Mid-tone darkening: predict the reverse-alpha output at the bright core and
# compare to the local background ring (reuses the FP-gate / alpha-gain machinery).
cb = self._core_and_bg(image, alpha_map, position)
if cb is None:
return False
core_obs, bg, a_cap = cb
a = min(a_cap, 0.99)
predicted_core = (core_obs - a * self.logo_value) / (1.0 - a)
return predicted_core < bg - self._OVERSUB_DARK_MARGIN
def _inpaint_footprint(
self,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> None:
"""Inpaint the sparkle body from surrounding pixels, in-place.
@functools.lru_cache(maxsize=1)
def _shared_engine() -> GeminiEngine:
"""Process-wide default ``GeminiEngine`` singleton.
Fallback for backgrounds where reverse-alpha over-subtracts: a small mask of
the footprint (alpha >= threshold, dilated) is reconstructed by cv2 NS inpaint
from the continuous surroundings, so the sparkle is replaced by plausible
background instead of a black pit.
"""
placed = self._footprint_indices(alpha_map, position, image.shape)
if placed is None:
return
alpha_roi, (y1, y2, x1, x2) = placed
# Inpaint only a padded crop around the footprint, not the whole image: the
# mask is zero outside a ~96x96 corner, so inpainting the full (multi-MP)
# image would be ~hundreds of times more work for an identical result. The
# padding gives cv2 enough surrounding context to reconstruct the sparkle.
ih, iw = image.shape[:2]
pad = 24
cy1, cy2 = max(0, y1 - pad), min(ih, y2 + pad)
cx1, cx2 = max(0, x1 - pad), min(iw, x2 + pad)
crop = image[cy1:cy2, cx1:cx2]
mask = np.zeros(crop.shape[:2], dtype=np.uint8)
mask[y1 - cy1 : y2 - cy1, x1 - cx1 : x2 - cx1] = (alpha_roi >= self._FOOTPRINT_ALPHA).astype(np.uint8) * 255
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask = cv2.dilate(mask, kernel, iterations=2)
image[cy1:cy2, cx1:cx2] = cv2.inpaint(crop, mask, 6, cv2.INPAINT_NS)
def _verify_and_repair(
self,
result: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
size: WatermarkSize,
) -> NDArray[Any]:
"""Inpaint-repair a sparkle that survived reverse-alpha, keeping the better.
Re-detect on the reverse-alpha output; if a sparkle this strong remains (an
alpha mismatch the gain estimate could not fully correct), inpaint the
footprint and return that ONLY when it lowers the re-detect confidence. The
footprint inpaint reconstructs from the (darker) surroundings, so it physically
removes the bright sparkle rather than gaming the shape-NCC. Returns ``result``
unchanged when the removal is already clean (the common case) or when the
inpaint does not improve it, so it can never regress.
"""
residual = self.detect_watermark(result, force_size=size).confidence
if residual < self._VERIFY_FALLBACK_CONF:
return result
candidate = result.copy()
self._inpaint_footprint(candidate, alpha_map, position)
if self.detect_watermark(candidate, force_size=size).confidence < residual:
logger.debug("Sparkle survived reverse-alpha (conf=%.3f); footprint inpaint improved it.", residual)
return candidate
return result
def _reverse_alpha_blend(
self,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> None:
"""Apply reverse alpha blending in-place.
Formula: original = (watermarked - a * logo) / (1 - a)
"""
placed = self._footprint_indices(alpha_map, position, image.shape)
if placed is None:
return
alpha_roi, (y1, y2, x1, x2) = placed
image_roi = image[y1:y2, x1:x2].astype(np.float32)
alpha_threshold = 0.002
max_alpha = 0.99
# Vectorized reverse alpha blending
alpha = alpha_roi.copy()
mask = alpha >= alpha_threshold
alpha = np.clip(alpha, 0.0, max_alpha)
one_minus_alpha = 1.0 - alpha
# Expand alpha for 3-channel broadcast
alpha_3d = alpha[:, :, np.newaxis]
one_minus_3d = one_minus_alpha[:, :, np.newaxis]
mask_3d = mask[:, :, np.newaxis]
# original = (watermarked - alpha * logo) / (1 - alpha)
restored = (image_roi - alpha_3d * self.logo_value) / one_minus_3d
restored = np.clip(restored, 0.0, 255.0)
# Apply only where alpha is significant
image_roi = np.where(mask_3d, restored, image_roi)
image[y1:y2, x1:x2] = image_roi.astype(np.uint8)
# ── Inpainting cleanup ───────────────────────────────────────────
def inpaint_residual(
self,
image: NDArray[Any],
region: tuple[int, int, int, int],
strength: float = 0.85,
method: Literal["gaussian", "telea", "ns"] = "ns",
inpaint_radius: int = 10,
padding: int = 32,
) -> NDArray[Any]:
"""Apply inpaint cleanup on residual artifacts after reverse alpha blend.
Uses a sparse mask derived from alpha map gradient to repair only
the sparkle-edge pixels where interpolation broke the math.
Args:
image: BGR image (will NOT be modified in-place).
region: (x, y, w, h) of the watermark region.
strength: Blend strength (0.0 = keep original, 1.0 = fully inpainted).
method: Inpaint method ("gaussian", "telea", or "ns").
inpaint_radius: Radius for cv2.inpaint.
padding: Context padding around region in pixels.
Returns:
Cleaned BGR image.
"""
result = image.copy()
x, y, rw, rh = region
if rw < 4 or rh < 4:
return result
strength = max(0.0, min(1.0, strength))
if strength < 0.001:
return result
# Padded region
px1 = max(0, x - padding)
py1 = max(0, y - padding)
px2 = min(image.shape[1], x + rw + padding)
py2 = min(image.shape[0], y + rh + padding)
if (px2 - px1) < 8 or (py2 - py1) < 8:
return result
# Inner rect relative to padded
ix1 = x - px1
iy1 = y - py1
# Get alpha map (interpolated if needed)
source_alpha = self._alpha_large
interp = cv2.INTER_LINEAR if rw > source_alpha.shape[1] else cv2.INTER_AREA
alpha_resized = cv2.resize(source_alpha, (rw, rh), interpolation=interp)
# Compute gradient mask from alpha
grad_x = cv2.Sobel(alpha_resized, cv2.CV_32F, 1, 0, ksize=3)
grad_y = cv2.Sobel(alpha_resized, cv2.CV_32F, 0, 1, ksize=3)
grad_mag = cv2.magnitude(grad_x, grad_y)
grad_min, grad_max = grad_mag.min(), grad_mag.max()
if grad_max <= grad_min:
return result
# Normalize and apply gamma correction
grad_norm = (grad_mag - grad_min) / (grad_max - grad_min)
grad_weight = np.sqrt(grad_norm)
# Dilate the mask
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
grad_weight = cv2.dilate(grad_weight, kernel)
if method == "gaussian":
# Soft blend with Gaussian blur
padded_roi = result[py1:py2, px1:px2].copy()
blurred = cv2.GaussianBlur(padded_roi, (0, 0), sigmaX=2.0)
# Create weight mask on padded area (only inner region has weights)
weight_full = np.zeros((py2 - py1, px2 - px1), dtype=np.float32)
weight_full[iy1 : iy1 + rh, ix1 : ix1 + rw] = grad_weight * strength
weight_3d = weight_full[:, :, np.newaxis]
blended = padded_roi.astype(np.float32) * (1 - weight_3d) + blurred.astype(np.float32) * weight_3d
result[py1:py2, px1:px2] = blended.astype(np.uint8)
else:
# OpenCV inpainting (TELEA or NS)
inpaint_flag = cv2.INPAINT_TELEA if method == "telea" else cv2.INPAINT_NS
# Create binary mask from gradient weight
binary_mask = (grad_weight * 255).astype(np.uint8)
_, binary_mask = cv2.threshold(binary_mask, 30, 255, cv2.THRESH_BINARY)
# Expand mask to padded region
mask_full = np.zeros((py2 - py1, px2 - px1), dtype=np.uint8)
mask_full[iy1 : iy1 + rh, ix1 : ix1 + rw] = binary_mask
padded_roi = result[py1:py2, px1:px2].copy()
inpainted = cv2.inpaint(padded_roi, mask_full, inpaint_radius, inpaint_flag)
# Blend with strength
weight_full = np.zeros((py2 - py1, px2 - px1), dtype=np.float32)
weight_full[iy1 : iy1 + rh, ix1 : ix1 + rw] = grad_weight * strength
weight_3d = weight_full[:, :, np.newaxis]
blended = padded_roi.astype(np.float32) * (1 - weight_3d) + inpainted.astype(np.float32) * weight_3d
result[py1:py2, px1:px2] = blended.astype(np.uint8)
return result
The engine holds only constant assets (embedded captures, alpha maps, the
precomputed template ladder) and takes the image as a method argument, so one
instance is reused across every ``detect_sparkle_confidence`` call instead of
reloading assets + recomputing alpha maps + rebuilding the template cache on
each of the ~34k images an ``identify`` batch scans. Output is identical."""
return GeminiEngine()
def detect_sparkle_confidence(image_path: Path, *, image: NDArray[Any] | None = None) -> float | None:
@@ -983,4 +734,4 @@ def detect_sparkle_confidence(image_path: Path, *, image: NDArray[Any] | None =
img = image if image is not None else image_io.imread(image_path)
if img is None:
return None
return float(GeminiEngine().detect_watermark(img).confidence)
return float(_shared_engine().detect_watermark(img).confidence)
+8 -5
View File
@@ -41,11 +41,14 @@ def apply_analog_humanizer(image: NDArray, grain_intensity: float = 4.0, chromat
# Shift R channel left, B channel right. np.roll is circular, so it wraps
# the opposite edge into a thin colored fringe at the L/R borders; replicate
# the original edge columns there to keep the intended offset interior-only.
if chromatic_shift > 0:
r = np.roll(r, -chromatic_shift, axis=1)
r[:, -chromatic_shift:] = r[:, -chromatic_shift - 1 : -chromatic_shift]
b = np.roll(b, chromatic_shift, axis=1)
b[:, :chromatic_shift] = b[:, chromatic_shift : chromatic_shift + 1]
# Clamp so the edge-replication slices below always have a source column: a shift
# >= width would leave them empty and crash the broadcast (r[:, -shift:] = (H, 0)).
shift = min(chromatic_shift, image.shape[1] - 1)
if shift > 0:
r = np.roll(r, -shift, axis=1)
r[:, -shift:] = r[:, -shift - 1 : -shift]
b = np.roll(b, shift, axis=1)
b[:, :shift] = b[:, shift : shift + 1]
merged = cv2.merge((b, g, r))
+129 -19
View File
@@ -41,7 +41,13 @@ from remove_ai_watermarks.metadata import (
xai_signature,
)
from remove_ai_watermarks.noai.c2pa import cbor_text_after, extract_c2pa_info, soft_binding_vendors_in
from remove_ai_watermarks.noai.constants import C2PA_AI_TOOLS, C2PA_AI_VENDORS, C2PA_ISSUERS
from remove_ai_watermarks.noai.constants import (
C2PA_AI_TOOLS,
C2PA_AI_VENDORS,
C2PA_IDENTITY_AI_ORGS,
C2PA_ISSUERS,
)
from remove_ai_watermarks.watermark_registry import GEMINI_SPARKLE_TRUST_CONF
if TYPE_CHECKING:
from pathlib import Path
@@ -51,17 +57,20 @@ if TYPE_CHECKING:
from remove_ai_watermarks.watermark_registry import MarkDetection
log = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# How much of a non-PNG container to binary-scan for the C2PA issuer.
_SCAN_BYTES = 1024 * 1024
# Visible-sparkle confidence above which the signal is trusted as provenance.
# Stricter than the removal default (0.25): on the corpus, Gemini-family
# sparkles score >= 0.56 while non-sparkle images top out at 0.49, so 0.5
# cleanly separates them and avoids false positives when sparkle is the only
# signal (e.g. an OpenAI image scored 0.37 -- below threshold, correctly dropped).
_SPARKLE_THRESHOLD = 0.5
# Shared with the removal arbitration (watermark_registry.GEMINI_SPARKLE_TRUST_CONF)
# so the provenance "is there a sparkle" verdict and the removal "take the sparkle"
# decision can never drift apart -- the detect-vs-remove desync the retained-corpus
# mining surfaced (2026-06-20). On the corpus Gemini-family sparkles score >= 0.56
# while non-sparkle images top out at 0.49, so 0.5 cleanly separates them and avoids
# false positives when the sparkle is the only signal (e.g. an OpenAI image scored
# 0.37 -- below threshold, correctly dropped).
_SPARKLE_THRESHOLD = GEMINI_SPARKLE_TRUST_CONF
# Issuer (C2PA signer) -> human-readable generating platform, derived from the
# single C2PA_AI_VENDORS registry. Ordered: when a manifest names several issuers
@@ -132,6 +141,22 @@ class ProvenanceReport:
is_ai_generated: bool | None # True / False is never asserted; None = unknown
platform: str | None
confidence: str # "high" | "medium" | "none"
# Coarse AI-origin kind from the C2PA digital-source-type, so a caller can
# branch on full generation vs an AI-touched real photo:
# "generated" -- digitalSourceType trainedAlgorithmicMedia (fully AI).
# "enhanced" -- compositeWithTrainedAlgorithmicMedia (real content with an
# AI-composited region; scrub the AI region, keep the photo).
# None -- no C2PA AI source-type (verdict, if AI, came from another
# signal: IPTC, AIGC, local gen params, xAI, ...).
ai_source_kind: str | None = None
# True when the AI verdict rests on a metadata or embedded-invisible signal
# (C2PA AI issuer / SynthID proxy, IPTC, AIGC, local gen params, EXIF/xAI, or
# an open DWT-DCT / TrustMark decode) -- as opposed to a visible mark or a
# weak medium-confidence hint (hf-job, Samsung genAIType). It is exactly the
# set of signals an invisible/diffusion scrub targets: a visible-only or
# no-signal image has it False. Equivalent to ``confidence == "high"``;
# surfaced as a field so callers gate on intent, not on the string.
ai_from_metadata: bool = False
watermarks: list[str] = field(default_factory=list[str])
signals: list[Signal] = field(default_factory=list["Signal"])
caveats: list[str] = field(default_factory=list[str])
@@ -274,6 +299,19 @@ _AI_VENDOR_TOKENS: tuple[tuple[str, str], ...] = (
("grok", "xAI"),
("aurora", "xAI"),
("xai", "xAI"),
# ByteDance family (all its brands normalize to one origin, mirroring constants.py):
# without these a transplanted ByteDance C2PA manifest next to an independent
# conflicting stamp went undetected by the clash check.
("bytedance", "ByteDance"),
("doubao", "ByteDance"),
("jimeng", "ByteDance"),
("dreamina", "ByteDance"),
("volcengine", "ByteDance"),
("volcano engine", "ByteDance"),
("canva", "Canva"),
("elevenlabs", "ElevenLabs"),
("eleven labs", "ElevenLabs"),
("black forest", "Black Forest Labs"),
)
@@ -303,6 +341,17 @@ def _vendor_of(text: str | None) -> str | None:
_C2PA_MANIFEST_SOURCE = "c2pa_manifest"
_CLASH_SOURCE: dict[str, str] = {"c2pa": _C2PA_MANIFEST_SOURCE, "synthid": _C2PA_MANIFEST_SOURCE}
# The generic China TC260 AIGC vendor label -- a COUNTRY-LEVEL regulatory "this is AI"
# stamp any Chinese generator applies to its own output, naming no specific vendor.
_GENERIC_AIGC_VENDOR = "China AIGC (TC260)"
# Vendors that apply the TC260 label to their OWN output. When one is co-attributed with
# the generic AIGC label, the label is that vendor's own stamp (not an independent
# competing origin), so the clash check attributes the AIGC label to it -- else a legit
# ByteDance/Doubao image (C2PA "ByteDance" + its own TC260 label) would false-clash once
# ByteDance normalizes via _vendor_of. Chinese generators only (Canva/BFL/ElevenLabs,
# also added to _vendor_of, are NOT TC260 appliers).
_TC260_VENDORS: frozenset[str] = frozenset({"ByteDance"})
def _integrity_clashes(
ai_vendors: dict[str, str], camera_label: str | None, *, camera_has_ai_marker: bool
@@ -327,6 +376,18 @@ def _integrity_clashes(
# families clash only when they belong to different provenance sources (see
# _CLASH_SOURCE) AND name different vendors -- so multiple vendors named within
# one C2PA manifest (c2pa issuer + synthid proxy) do not flag.
# The generic TC260 AIGC label is a Chinese regulatory "this is AI" stamp. When a
# Chinese TC260-applying vendor (ByteDance) is ALSO attributed, the label is that
# vendor's own stamp on its own output, so attribute it to that vendor -- a legit
# Doubao image carries BOTH a ByteDance C2PA manifest and its own TC260 label and
# must not clash. Against a NON-TC260 vendor (OpenAI, Google, ...) the label stays
# generic and still clashes as a laundering tell (a foreign-vendor image carrying a
# Chinese TC260 label names two different origins).
if ai_vendors.get("aigc") == _GENERIC_AIGC_VENDOR:
own = next((v for f, v in ai_vendors.items() if f != "aigc" and v in _TC260_VENDORS), None)
if own:
ai_vendors = {**ai_vendors, "aigc": own} # copy co-located with the relabel
source = {fam: _CLASH_SOURCE.get(fam, fam) for fam in ai_vendors}
independent_conflict = any(
source[a] != source[b] and ai_vendors[a] != ai_vendors[b] for a, b in itertools.combinations(ai_vendors, 2)
@@ -373,7 +434,7 @@ def _visible_sparkle(image_path: Path, *, image: NDArray[Any] | None = None) ->
try:
from remove_ai_watermarks.gemini_engine import detect_sparkle_confidence
except Exception as exc: # cv2/engine assets missing
log.debug("visible-sparkle detector unavailable: %s", exc)
logger.debug("visible-sparkle detector unavailable: %s", exc)
return None
return detect_sparkle_confidence(image_path, image=image)
@@ -404,7 +465,7 @@ def _visible_text_marks(image_path: Path, *, image: NDArray[Any] | None = None)
from remove_ai_watermarks.image_io import imread
from remove_ai_watermarks.watermark_registry import get_mark
except Exception as exc: # cv2/engine assets missing
log.debug("visible-mark detectors unavailable: %s", exc)
logger.debug("visible-mark detectors unavailable: %s", exc)
return []
if image is None:
image = imread(image_path)
@@ -415,7 +476,7 @@ def _visible_text_marks(image_path: Path, *, image: NDArray[Any] | None = None)
try:
det = get_mark(key).detect(image)
except Exception as exc: # one engine failing must not break identify
log.debug("visible-mark %s detector failed: %s", key, exc)
logger.debug("visible-mark %s detector failed: %s", key, exc)
continue
if det.detected:
detections.append(det)
@@ -484,9 +545,25 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
# ── C2PA Content Credentials ────────────────────────────────────
has_c2pa = bool(info) or c2pa_marker_in(head)
issuers = [info["issuer"]] if info.get("issuer") else _issuers_in(head)
c2pa_is_ai = "trainedAlgorithmicMedia" in info.get("source_type", "") or any(
m in head for m in (b"trainedAlgorithmicMedia", b"compositeWithTrainedAlgorithmicMedia")
)
# Full AI generation (trainedAlgorithmicMedia) vs an AI-enhanced real photo
# (compositeWithTrainedAlgorithmicMedia). The structured kind is parsed once in
# noai.c2pa._populate_registry_fields (covers PNG + any container the c2pa-python
# reader handles); fall back to a raw head scan for the non-PNG raw-blob path
# where extract_c2pa_info returns {}. Full generation wins when both appear.
c2pa_source_kind = info.get("ai_source_kind")
if c2pa_source_kind is None:
if b"trainedAlgorithmicMedia" in head:
c2pa_source_kind = "generated"
elif b"compositeWithTrainedAlgorithmicMedia" in head:
c2pa_source_kind = "enhanced"
# An identity-AI issuer (a pure-generator brand like Dreamina) asserts AI even
# without a digitalSourceType -- some ByteDance/Dreamina manifests ship no
# trainedAlgorithmicMedia, so the registered generator name is the only signal.
# Restricted to the ``asserts_ai`` vendors (distinctive brand strings), so it
# does not reopen the incidental-mention problem the common-word issuers have.
issuer_blob = " ".join(issuers)
c2pa_identity_ai = has_c2pa and any(org in issuer_blob for org in C2PA_IDENTITY_AI_ORGS)
c2pa_is_ai = c2pa_source_kind is not None or c2pa_identity_ai
# Generator string (for the signal detail): structured for PNG, CBOR-scanned
# for other containers. Best-effort -- some manifests key it as
# `claim_generator_info` (Pixel), so this can be None even when a device is
@@ -584,7 +661,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
watermarks.append("China AIGC label (TC260 standard)")
if platform is None:
platform = "China AIGC-labeled generator (TC260; e.g. Doubao)"
ai_vendor_claims["aigc"] = "China AIGC (TC260)"
ai_vendor_claims["aigc"] = _GENERIC_AIGC_VENDOR
# ── Local diffusion parameters (Stable Diffusion / ComfyUI) ──────
local_keys = sorted(k for k in meta if k.lower() in _LOCAL_GEN_KEYS)
@@ -594,13 +671,14 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
if platform is None:
platform = "Stable Diffusion / local pipeline (Automatic1111, ComfyUI, InvokeAI)"
# ── EXIF Software / XMP CreatorTool generator (cross-format) ─────
# Catches a generator tag (incl. inside AVIF/HEIF/JXL) when there is no C2PA.
# ── EXIF Software / XMP CreatorTool / PNG-text generator (cross-format) ─
# Catches a generator tag (incl. inside AVIF/HEIF/JXL and PNG text chunks)
# when there is no C2PA.
if generator_tag := exif_generator(image_path):
signals.append(Signal("exif_generator", f"EXIF/XMP generator: {generator_tag}", "high"))
signals.append(Signal("exif_generator", f"Embedded generator tag: {generator_tag}", "high"))
watermarks.append(f"Embedded generator tag: {generator_tag}")
if platform is None:
platform = f"{generator_tag} (EXIF/XMP generator tag)"
platform = f"{generator_tag} (embedded generator tag)"
if v := _vendor_of(generator_tag):
ai_vendor_claims["exif_generator"] = v
@@ -689,7 +767,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
vis_image = imread(image_path)
except Exception as exc: # cv2 missing - detectors fall back / no-op
log.debug("visible-mark decode unavailable: %s", exc)
logger.debug("visible-mark decode unavailable: %s", exc)
# ── Visible Gemini sparkle (fallback for stripped-metadata case) ─
sparkle_conf = _visible_sparkle(image_path, image=vis_image) if check_visible else None
@@ -734,8 +812,40 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
is_ai_generated=is_ai,
platform=platform,
confidence=confidence,
# Only meaningful when the AI verdict actually came from the C2PA source
# type; a non-C2PA AI signal (IPTC/AIGC/local gen) leaves it None.
ai_source_kind=c2pa_source_kind if (is_ai and has_c2pa) else None,
ai_from_metadata=ai_from_metadata,
watermarks=watermarks,
signals=signals,
caveats=caveats,
integrity_clashes=clashes,
)
def has_invisible_target(image_path: Path) -> bool:
"""True when a locally-detectable invisible/metadata AI signal is present.
The decision gate for the diffusion scrub (``invisible`` / ``all`` / ``batch``):
regenerating pixels removes an invisible watermark (SynthID, open DWT-DCT,
TrustMark) but degrades a real photo, so it must not run when there is nothing
to remove. Runs :func:`identify` with ``check_visible=False`` -- a visible mark
is handled by the separate visible pass and is NOT a diffusion target -- and
``check_invisible=True`` so an open watermark counts. Returns
``report.ai_from_metadata`` (C2PA AI issuer / SynthID proxy, IPTC, AIGC, local
gen params, EXIF/xAI, open DWT-DCT / TrustMark).
IMPORTANT -- this cannot prove a pixel SynthID is absent: SynthID is detectable
only through its C2PA proxy, so a metadata-stripped AI image reads as no signal
here. A False therefore means "no locally-detectable invisible target", not
"clean". Callers must NOT present a skip as a finished clean result.
Fail-safe: any error resolves to True so the removal still runs -- leaving a
watermark on a paid removal is worse than over-regenerating a clean image.
"""
try:
report = identify(image_path, check_visible=False, check_invisible=True)
except Exception: # unreadable / detector error -> do not skip the removal
logger.debug("has_invisible_target: identify failed, defaulting to run", exc_info=True)
return True
return report.ai_from_metadata
+167 -17
View File
@@ -29,11 +29,17 @@ if TYPE_CHECKING:
def imread(path: str | Path, flags: int | None = None) -> NDArray[Any] | None:
"""Unicode-safe ``cv2.imread``.
"""Unicode-safe ``cv2.imread`` with a Pillow fallback for HEIC/AVIF.
``flags`` defaults to ``cv2.IMREAD_COLOR`` (same as ``cv2.imread``). Returns
``None`` when the file is missing or cannot be decoded, matching
``cv2.imread`` semantics so existing ``if img is None`` checks keep working.
``None`` when the file is missing or cannot be decoded, matching ``cv2.imread``
semantics so existing ``if img is None`` checks keep working.
OpenCV cannot decode HEIC/AVIF (and some other containers), so when its decode
returns None we fall back to Pillow (:func:`_pil_read`): AVIF is native in modern
Pillow, HEIC works when the optional ``pillow-heif`` plugin is installed. This lets
the pixel path (visible removal) read the same formats the metadata path already
scans; normal PNG/JPEG/WebP never reach the fallback, so they are unaffected.
"""
import cv2
import numpy as np
@@ -46,16 +52,50 @@ def imread(path: str | Path, flags: int | None = None) -> NDArray[Any] | None:
return None
if data.size == 0:
return None
return cv2.imdecode(data, flags)
img = cv2.imdecode(data, flags)
# cv2.imdecode returns None on an undecodable container (HEIC/AVIF); the type stub
# omits that, hence the ignore.
if img is not None: # pyright: ignore[reportUnnecessaryComparison]
return img
return _pil_read(path, flags)
_heif_registered = False
def _pil_read(path: str | Path, flags: int) -> NDArray[Any] | None:
"""Decode via Pillow (HEIC/AVIF and any other Pillow-readable container) into the
cv2 layout ``flags`` implies: grayscale, 3-channel BGR, or BGRA when the source has
alpha and ``IMREAD_UNCHANGED`` was requested. Returns None if Pillow (with the
optional HEIF plugin) still cannot open it. No EXIF auto-rotation, matching cv2."""
import cv2
import numpy as np
try:
from PIL import Image
except Exception:
return None
_register_heif()
try:
with Image.open(path) as im:
im.load()
if flags == cv2.IMREAD_GRAYSCALE:
return np.asarray(im.convert("L"))
has_alpha = im.mode in ("RGBA", "LA", "PA") or "transparency" in im.info
if flags == cv2.IMREAD_UNCHANGED and has_alpha:
return cv2.cvtColor(np.asarray(im.convert("RGBA")), cv2.COLOR_RGBA2BGRA)
return cv2.cvtColor(np.asarray(im.convert("RGB")), cv2.COLOR_RGB2BGR)
except Exception:
return None
def to_bgr(image: NDArray[Any]) -> NDArray[Any]:
"""Return a 3-channel BGR view of ``image``, promoting grayscale and BGRA.
The cv2-based engines (sparkle + the reverse-alpha text marks) assume a
3-channel BGR array for their channel reductions (``mean(axis=2)``, the
per-pixel logo subtraction). A 2D grayscale or 4-channel BGRA input -- a real
Gemini-app export is opaque RGBA -- would otherwise crash or mis-broadcast.
The cv2-based engines (sparkle + the text-mark detectors/localizers) assume a
3-channel BGR array for their channel reductions (``mean(axis=2)``, the top-hat
glyph extraction). A 2D grayscale or 4-channel BGRA input -- a real Gemini-app
export is opaque RGBA -- would otherwise crash or mis-broadcast.
Centralizes the shape coercion that was inlined across the engines. A 3-channel
input is returned unchanged (no copy).
"""
@@ -68,18 +108,87 @@ def to_bgr(image: NDArray[Any]) -> NDArray[Any]:
return image
def imwrite(path: str | Path, img: NDArray[Any]) -> bool:
"""Unicode-safe ``cv2.imwrite``.
def _register_heif() -> None:
"""Register the HEIF+AVIF Pillow opener/saver via libheif (idempotent, best-effort)."""
global _heif_registered
if _heif_registered:
return
_heif_registered = True
import contextlib
The output format is taken from the path extension (e.g. ``.png``), exactly
like ``cv2.imwrite``. Returns ``True`` on success, ``False`` if the codec
rejects the image or the path cannot be written (matching ``cv2.imwrite``,
which returns ``False`` rather than raising on an unwritable path).
"""
with contextlib.suppress(Exception):
import pillow_heif # pyright: ignore[reportMissingImports]
pillow_heif.register_heif_opener()
# Containers cv2 cannot encode -> written via Pillow (pillow-heif).
_HEIF_WRITE_EXTS = {".heic", ".heif", ".avif"}
def _encode_params(ext: str) -> list[int]:
"""cv2 encode params that PRESERVE quality. The removal only touches the mark's
footprint, so the container re-encode must not degrade the untouched pixels:
JPEG at quality 100 with 4:4:4 chroma (no subsampling), WebP at max. Lossless
containers (PNG/BMP/TIFF) need no params. getattr-guarded so an older OpenCV
build without the chroma/subsampling flags still gets quality 100."""
import cv2
ext = Path(path).suffix or ".png"
ok, buf = cv2.imencode(ext, img)
if ext in (".jpg", ".jpeg"):
params = [cv2.IMWRITE_JPEG_QUALITY, 100]
cq = getattr(cv2, "IMWRITE_JPEG_CHROMA_QUALITY", None)
if cq is not None:
params += [cq, 100]
sf = getattr(cv2, "IMWRITE_JPEG_SAMPLING_FACTOR", None)
sf444 = getattr(cv2, "IMWRITE_JPEG_SAMPLING_FACTOR_444", None)
if sf is not None and sf444 is not None:
params += [sf, sf444]
return params
if ext == ".webp":
# cv2 WebP: quality 1-100 is LOSSY; a value > 100 selects LOSSLESS mode.
# "work with originals" requires lossless so a mark-removal re-encode does not
# degrade the untouched pixels the fill composites over (regression: q100
# round-tripped a random image at maxdiff ~230, q101 at 0).
return [cv2.IMWRITE_WEBP_QUALITY, 101]
return []
def _pil_write(path: str | Path, img: NDArray[Any]) -> bool:
"""Encode HEIC/AVIF via Pillow (+pillow-heif) at high quality -- cv2 has no encoder
for them. BGR / BGRA in; returns False if Pillow (with the plugin) cannot save."""
import cv2
import numpy as np
from PIL import Image
_register_heif()
if img.ndim == 3 and img.shape[2] == 4:
arr, mode = cv2.cvtColor(img, cv2.COLOR_BGRA2RGBA), "RGBA"
else:
arr, mode = cv2.cvtColor(to_bgr(img), cv2.COLOR_BGR2RGB), "RGB"
try:
Image.fromarray(np.ascontiguousarray(arr), mode).save(str(path), quality=100)
return True
except Exception:
return False
def imwrite(path: str | Path, img: NDArray[Any]) -> bool:
"""Unicode-safe image write that PRESERVES the input format at max quality.
Format is taken from the path extension. HEIC/AVIF (which cv2 cannot encode) go
through Pillow; everything else through cv2 with quality-preserving params (see
:func:`_encode_params`) so a lossy re-encode of the untouched pixels stays near-
lossless. Returns ``True`` on success, ``False`` if the codec rejects the image or
the path cannot be written (matching ``cv2.imwrite``, never raising)."""
import cv2
ext = (Path(path).suffix or ".png").lower()
if ext in _HEIF_WRITE_EXTS:
return _pil_write(path, img)
try:
ok, buf = cv2.imencode(ext, img, _encode_params(ext))
except cv2.error:
return False
if not ok:
return False
try:
@@ -87,3 +196,44 @@ def imwrite(path: str | Path, img: NDArray[Any]) -> bool:
except OSError:
return False
return True
# Container extensions that carry an alpha channel (for read/write-with-alpha).
ALPHA_FORMATS = {".png", ".webp", ".heic", ".heif", ".avif"}
def read_bgr_and_alpha(path: str | Path) -> tuple[NDArray[Any] | None, NDArray[Any] | None]:
"""Read an image preserving its alpha channel separately.
Returns ``(bgr, alpha)`` where ``alpha`` is a single-channel ndarray when the
source has transparency, else ``None``. Grayscale inputs are promoted to BGR.
Returns ``(None, None)`` if the image cannot be decoded.
"""
import cv2
image = imread(path, cv2.IMREAD_UNCHANGED)
if image is None:
return None, None
if image.ndim == 2:
return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR), None
if image.shape[2] == 4:
return image[:, :, :3].copy(), image[:, :, 3].copy()
return image, None
def write_bgr_with_alpha(path: str | Path, bgr: NDArray[Any], alpha: NDArray[Any] | None) -> None:
"""Write BGR (with optional alpha) to ``path``.
When ``alpha`` is provided and the output extension supports it, the original
alpha plane is rejoined unchanged. The watermark region is NOT made transparent:
the fill reconstructs real pixels there, so zeroing alpha would punch a
transparent hole that renders as a white box on any non-transparent viewer
(issue #30). Preserving the input alpha keeps genuinely transparent backgrounds
intact without inventing new holes.
"""
import numpy as np
if alpha is None or Path(path).suffix.lower() not in ALPHA_FORMATS:
imwrite(path, bgr)
return
imwrite(path, np.dstack([bgr, alpha]))
+29 -5
View File
@@ -103,8 +103,9 @@ class InvisibleEngine:
device: Device for inference (auto/cpu/mps/cuda/xpu). None = auto.
pipeline: Pipeline profile. "controlnet" (DEFAULT; SDXL + canny ControlNet
that preserves text/face structure via edge conditioning while removing
SynthID) or "sdxl" (plain SDXL img2img, lighter but leaves SynthID on
flat-graphic content). "default" is a back-compat alias for "sdxl".
SynthID), "sdxl" (plain SDXL img2img, lighter but leaves SynthID on
flat-graphic content), or "qwen" (Qwen-Image 20B img2img, best text/
structure preservation but CUDA/cloud-class). "default" aliases "sdxl".
hf_token: HuggingFace API token.
progress_callback: Optional callback for progress messages.
controlnet_conditioning_scale: ControlNet structure-preservation
@@ -170,6 +171,9 @@ class InvisibleEngine:
unsharp: float = 0.0,
adaptive_polish: bool = False,
upscaler: str = "lanczos",
tile: bool = False,
tile_size: int = 1024,
tile_overlap: int = 128,
) -> Path:
"""Remove invisible watermark from an image.
@@ -205,6 +209,13 @@ class InvisibleEngine:
via the ``esrgan`` extra). Only applies when UPscaling (the floor
case); a ``max_resolution`` downscale always uses Lanczos. Falls back
to Lanczos if the extra is absent.
tile: Process the diffusion pass in overlapping tiles instead of one
forward pass -- the lossless alternative to ``max_resolution`` for
large inputs that OOM on MPS/GPU. Engages only when the long side
exceeds ``tile_size``. Pair with ``max_resolution=0`` (the default)
so the input keeps its native resolution.
tile_size: Tile dimension in px (default 1024).
tile_overlap: Overlap between adjacent tiles in px (default 128).
Returns:
Path to the cleaned image.
@@ -218,6 +229,12 @@ class InvisibleEngine:
# SDXL img2img runs near its ~1024 training size instead of distorting on a
# tiny latent (a 381x512 portrait wrecks at native -- issue #36 follow-up).
# The output is restored to orig_size below, so the floor is transparent.
# Register the HEIF/AVIF opener so a .heic/.avif input (now a SUPPORTED_FORMAT)
# decodes here too. The --force skip path bypasses image_io.imread, which is
# what would otherwise register it, so a bare Image.open would fail on HEIC.
from remove_ai_watermarks import image_io
image_io._register_heif()
image = Image.open(image_path)
image = ImageOps.exif_transpose(image)
orig_size = image.size # (width, height)
@@ -245,10 +262,14 @@ class InvisibleEngine:
# Always persist to a temp file, even without downscaling: WatermarkRemover
# reloads by path, so the EXIF-transposed pixels must be saved or rotation
# is lost. Cleaned up in the finally block via _tmp_path.
_tmp_fd, _tmp_str = tempfile.mkstemp(suffix=image_path.suffix)
# is lost. Written as PNG (lossless) regardless of the input format, so a JPEG
# input does not feed a re-compressed copy into the diffusion pass.
# Cleaned up in the finally block via _tmp_path.
_tmp_fd, _tmp_str = tempfile.mkstemp(suffix=".png")
_tmp_path = Path(_tmp_str)
image.save(_tmp_path)
# Convert to RGB before the PNG temp: the diffusion pass is RGB anyway, and a
# non-RGB source mode (e.g. a CMYK JPEG) cannot be written as PNG and would raise.
image.convert("RGB").save(_tmp_path)
os.close(_tmp_fd)
image_path = _tmp_path
@@ -261,6 +282,9 @@ class InvisibleEngine:
guidance_scale=guidance_scale,
seed=seed,
vendor=vendor,
tile=tile,
tile_size=tile_size,
tile_overlap=tile_overlap,
)
# Post-processing chain: decode the diffusion output ONCE, apply the
@@ -31,7 +31,7 @@ from typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from pathlib import Path
log = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Known 48-bit ``bits`` watermarks (dwtDct, no key), name -> message integer.
_BITS_48: dict[str, int] = {
@@ -96,7 +96,7 @@ def detect_invisible_watermark(image_path: Path) -> str | None:
if _bits_match(value, ref) >= _MATCH_48:
return name
except Exception as exc: # decode can fail on tiny images
log.debug("48-bit watermark decode failed for %s: %s", image_path, exc)
logger.debug("48-bit watermark decode failed for %s: %s", image_path, exc)
# 136-bit default string watermark (SD 1.x / 2.x).
try:
@@ -104,6 +104,6 @@ def detect_invisible_watermark(image_path: Path) -> str | None:
if _bytes_match_frac(raw, _SD1_STRING) >= _MATCH_SD1_FRAC:
return "Stable Diffusion 1.x / 2.x"
except Exception as exc:
log.debug("string watermark decode failed for %s: %s", image_path, exc)
logger.debug("string watermark decode failed for %s: %s", image_path, exc)
return None
+14 -26
View File
@@ -1,17 +1,18 @@
"""Jimeng / Dreamina visible watermark removal engine.
"""Jimeng / Dreamina visible watermark detector/localizer.
Jimeng (即梦AI, ByteDance) stamps generated images with a visible "★ 即梦AI" wordmark
in the bottom-right corner -- a near-white semi-transparent overlay, the same overlay
class as the Doubao text strip.
Removal is **reverse-alpha blending** against a captured alpha map
(``original = (wm - a*logo)/(1-a)``), always NCC-aligned to the actual mark plus a thin
residual inpaint over the glyph footprint. This is one of the three text-mark engines
that share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module
Detection matches the bundled glyph silhouette against the corner; removal is the
shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds
``region_eraser``), NOT reverse-alpha. This is one of the three text-mark engines that
share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module
supplies only Jimeng's tuned :class:`TextMarkConfig` (bottom-right corner,
``assets/jimeng_alpha.png`` rebuilt by ``scripts/visible_alpha_solve.py`` from the gray
capture). Jimeng images are also caught by the China TC260 AIGC metadata label, so this
is the visible-mark *removal* path, not a new ``identify`` signal.
``assets/jimeng_alpha.png`` -- the detection silhouette, rebuilt by
``scripts/visible_alpha_solve.py`` from the gray capture). Jimeng images are also caught
by the China TC260 AIGC metadata label, so this is the visible-mark *removal* path, not
a new ``identify`` signal.
"""
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
@@ -44,18 +45,12 @@ TOPHAT_DELTA = 12
DETECT_MIN_COVERAGE = 0.02
DETECT_NCC_THRESHOLD = 0.45
# Reverse-alpha geometry, emitted by scripts/visible_alpha_solve.py from the gray
# capture at the captured width.
# Detection-silhouette geometry, emitted by scripts/visible_alpha_solve.py from the
# gray capture at the captured width (sizes the silhouette for the detection match;
# removal is the template-free glyph-bbox footprint mask).
_ALPHA_NATIVE_WIDTH = 2048
_ALPHA_LOGO_BGR: tuple[float, float, float] = (255.0, 255.0, 255.0)
_ALPHA_WIDTH_FRAC = 0.2021 # asset width / image width -- the alignment scale seed
_ALPHA_WIDTH_FRAC = 0.2021 # asset width / image width -- sizes the detection silhouette
_ALPHA_HEIGHT_FRAC = 0.0576
_ALPHA_MARGIN_RIGHT_FRAC = 0.0288
_ALPHA_MARGIN_BOTTOM_FRAC = 0.0288
_ALPHA_ALIGN_SEARCH = (0.90, 1.12, 23)
_RESIDUAL_ALPHA_FLOOR = 0.05
_RESIDUAL_DILATE = 5
_RESIDUAL_INPAINT_RADIUS = 2
_CONFIG = TextMarkConfig(
name="Jimeng",
@@ -74,14 +69,7 @@ _CONFIG = TextMarkConfig(
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
alpha_width_frac=_ALPHA_WIDTH_FRAC,
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
alpha_margin_x_frac=_ALPHA_MARGIN_RIGHT_FRAC,
alpha_margin_bottom_frac=_ALPHA_MARGIN_BOTTOM_FRAC,
alpha_align_search=_ALPHA_ALIGN_SEARCH,
min_gw=8,
alpha_logo_bgr=_ALPHA_LOGO_BGR,
residual_alpha_floor=_RESIDUAL_ALPHA_FLOOR,
residual_dilate=_RESIDUAL_DILATE,
residual_inpaint_radius=_RESIDUAL_INPAINT_RADIUS,
)
JimengDetection = TextMarkDetection
@@ -103,7 +91,7 @@ def _template_match_score(box_mask: NDArray[Any], image_width: int) -> float:
class JimengEngine(TextMarkEngine):
"""Remove the visible Jimeng "★ 即梦AI" watermark (locate -> mask -> reverse-alpha)."""
"""Detect/localize the visible Jimeng "★ 即梦AI" watermark (locate -> mask; mask feeds the fill)."""
def __init__(self) -> None:
super().__init__(_CONFIG)
+282 -38
View File
@@ -93,9 +93,17 @@ def c2pa_marker_in(data: bytes) -> bool:
IPTC_AI_MARKERS: tuple[bytes, ...] = (
b"trainedAlgorithmicMedia",
b"compositeSynthetic",
b"algorithmicMedia",
b"compositeWithTrainedAlgorithmicMedia",
)
# NOTE: bare ``algorithmicMedia`` is deliberately NOT here. That IPTC digitalSourceType
# means "created purely by an algorithm, NOT from sampled training data" (procedural /
# generative-code art) -- it is NOT AI/ML generation. Real "Made with AI" labels
# (Meta / Instagram / MidJourney) use ``trainedAlgorithmicMedia``. Including the bare
# token flagged clean procedural images as AI (is_ai=high + has_invisible_target=True ->
# a diffusion scrub of clean content), contradicting the c2pa layer, which sets
# source_type without ai_source for it (tests/test_noai.py::test_plain_algorithmic_media_not_flagged_ai).
# It is not a substring of the trained/composite tokens, so its removal does not affect
# their detection.
# IPTC Photo Metadata 2025.1 (published 2025-11-27) added explicit AI-disclosure
# XMP properties in the Iptc4xmpExt namespace. Their mere presence is an AI
@@ -137,6 +145,7 @@ AIGC_MARKERS: tuple[bytes, ...] = (
# label even when the namespaced XMP element is absent.
_TC260_FIELDS: frozenset[str] = frozenset(
{
# Producer-side schema (Doubao and most China-served generators).
"Label",
"ContentProducer",
"ProduceID",
@@ -144,6 +153,12 @@ _TC260_FIELDS: frozenset[str] = frozenset(
"PropagateID",
"ReservedCode1",
"ReservedCode2",
# Service-provider schema (Tencent Cloud's AIGC variant, mined from the
# retained corpus 2026-07): the same ``{"AIGC":{...}}`` wrapper but keyed
# ``ServiceProvider`` / ``ServiceUser`` (+ generic ``Time`` / ``ContentId``,
# not gated on), embedded in EXIF ``ImageDescription``.
"ServiceProvider",
"ServiceUser",
}
)
@@ -176,6 +191,19 @@ def _is_ai_key(key: str) -> bool:
return any(kw in key_lower for kw in AI_KEYWORDS)
def _is_ai_value(value: str) -> bool:
"""True if a metadata VALUE carries a known AI-generator token.
Mirrors :func:`exif_generator`'s value match so removal stays in parity with
detection: NovelAI stamps a generic ``Title``/``Source`` text chunk (an
AI-shaped value under a non-AI key) that ``_is_ai_key`` alone would keep.
"""
from remove_ai_watermarks.noai.constants import AI_GENERATOR_TOKENS
value_lower = value.lower()
return any(token in value_lower for token in AI_GENERATOR_TOKENS)
# PNG ancillary chunks that can carry provenance metadata (XMP, EXIF, text).
# Never IDAT -- that is the compressed pixel stream.
_PNG_META_CHUNKS: frozenset[bytes] = frozenset({b"tEXt", b"iTXt", b"zTXt", b"eXIf", b"iCCP"})
@@ -501,18 +529,45 @@ _SAMSUNG_GENAI_RE = re.compile(rb'genAIType"\s*:\s*(-?\d+)')
_SAMSUNG_EDITOR_MARKER = b"PhotoEditor_Re_Edit_Data"
def _read_file_tail(image_path: Path, size: int) -> bytes:
"""Return the last ``size`` bytes of the file (or the whole file if smaller)."""
try:
file_size = image_path.stat().st_size
with open(image_path, "rb") as f:
if file_size > size:
f.seek(file_size - size)
return f.read()
except OSError:
return b""
def samsung_genai(image_path: Path) -> int | None:
"""Return Samsung's non-zero ``genAIType`` value if the image carries the
Galaxy AI editing marker, else None.
See the module note above ``_SAMSUNG_GENAI_RE``: detection is empirical and
gated on the ``PhotoEditor_Re_Edit_Data`` container so an incidental
``genAIType`` token cannot false-positive.
``genAIType`` token cannot false-positive. Galaxy AI appends the marker as a
trailer AFTER the JPEG EOI, so on a multi-MB phone photo it sits past the quick-
scan window; when the head misses it, also read the file tail (else detection
and removal disagree -- the strip reads the whole file and would drop a marker
detection never reported).
"""
head = scan_head(image_path, _QUICK_SCAN_BYTES)
if _SAMSUNG_EDITOR_MARKER not in head:
data = scan_head(image_path, _QUICK_SCAN_BYTES)
if _SAMSUNG_EDITOR_MARKER not in data:
# The marker is a post-EOI trailer, so only a file LARGER than the quick-scan
# window can hide it past the head (`scan_head` already read a smaller file
# whole). Gate the extra tail read on that — `samsung_genai` is on the identify
# hot path, so a redundant 512 KB re-read per small image is not free.
try:
oversize = image_path.stat().st_size > _QUICK_SCAN_BYTES
except OSError:
oversize = False
if oversize:
data = _read_file_tail(image_path, _QUICK_SCAN_BYTES)
if _SAMSUNG_EDITOR_MARKER not in data:
return None
m = _SAMSUNG_GENAI_RE.search(head)
m = _SAMSUNG_GENAI_RE.search(data)
if m is None:
return None
return int(m.group(1)) or None
@@ -579,12 +634,15 @@ def synthid_source(image_path: Path) -> str | None:
def exif_generator(image_path: Path) -> str | None:
"""Return an AI-generator name from the EXIF ``Software`` / XMP ``CreatorTool``
field, if it matches a known generator (see ``AI_GENERATOR_TOKENS``), else None.
field (or a PNG text chunk), if it matches a known generator (see
``AI_GENERATOR_TOKENS``), else None.
Cross-format: EXIF is read via PIL + piexif for any container PIL can open
(JPEG/WebP/AVIF/PNG); an XMP ``CreatorTool`` raw-byte scan additionally covers
HEIF/JPEG-XL that PIL can't open without plugins. Only AI tokens match, so
ordinary editors (plain "Adobe Photoshop", "GIMP") are not flagged.
HEIF/JPEG-XL that PIL can't open without plugins. PNG ``tEXt`` chunks are read
too -- NovelAI stamps its generator in ``Software``/``Source``/``Title`` text
chunks rather than EXIF. Only AI tokens match, so ordinary editors (plain
"Adobe Photoshop", "GIMP") are not flagged.
"""
import re
@@ -592,13 +650,21 @@ def exif_generator(image_path: Path) -> str | None:
candidates: list[str] = []
# EXIF Software / Artist / ImageDescription (0th IFD) via PIL exif bytes.
# EXIF Software / Artist / ImageDescription (0th IFD) via PIL exif bytes,
# plus PNG text chunks (NovelAI writes Software/Source/Title there, not EXIF).
try:
import piexif
from PIL import Image
with Image.open(image_path) as img:
exif_bytes = img.info.get("exif")
info = img.info
exif_bytes = info.get("exif")
# PNG tEXt/iTXt chunks land in img.info too (same idiom as the other
# PNG-text readers in this module); NovelAI stamps Software/Source/Title.
for key in ("Software", "Source", "Title", "Description"):
value = info.get(key)
if isinstance(value, str) and value:
candidates.append(value)
if exif_bytes:
tags = piexif.load(exif_bytes).get("0th", {})
# Make catches camera-style tags AI tools reuse (Ideogram writes
@@ -678,47 +744,87 @@ def xai_signature(image_path: Path) -> bool:
)
def _scrub_ai_exif(exif_dict: dict[str, Any]) -> list[str]:
"""Delete AI-provenance tags from a piexif dict's ``0th`` IFD, in place.
def _is_aigc_exif_value(raw: object) -> bool:
"""Whether an EXIF tag value carries a China TC260 AIGC producer/service block.
Removes (a) the xAI/Grok signature pair (``ImageDescription`` "Signature: ..."
+ UUID ``Artist``) and (b) any ``Software`` / ``Make`` / ``Artist`` /
``ImageDescription`` tag whose value carries an ``AI_GENERATOR_TOKENS`` token
(Ideogram's ``Make``, Firefly's ``Software``, etc.). Mirrors the detection in
``xai_signature`` / ``exif_generator`` so removal scrubs exactly what
``identify`` flags, while leaving genuine camera/editor EXIF intact. Returns
the names of the removed tags (for logging).
Mirrors ``aigc_label``'s EXIF path: the ``{"AIGC":{...}}`` wrapper embedded in
``UserComment`` / ``ImageDescription`` by China-served generators (Doubao's
producer schema AND Tencent Cloud's service-provider schema, both keyed under
``_TC260_FIELDS``). Gated on both the ``AIGC`` marker and a TC260 field so a
coincidental token cannot false-drop a genuine caption/comment.
"""
if not isinstance(raw, (bytes, bytearray)):
return False
if b"AIGC" not in raw:
return False
text = bytes(raw).decode("latin-1", "ignore")
return any(field in text for field in _TC260_FIELDS)
def _ai_exif_targets(loaded: dict[str, Any]) -> list[tuple[str, int, bytes, str]]:
"""The SINGLE AI-EXIF rule set, as ``(ifd_key, tag, value_bytes, name)`` entries.
Shared by both EXIF scrubbers so their coverage cannot drift: the JPEG-path
:func:`_scrub_ai_exif` pops each tag, and the ISOBMFF-path
``isobmff.blank_ai_exif_tokens`` blanks each value's bytes in place. Covers
(a) the xAI/Grok ``Signature:`` + UUID-``Artist`` pair, (b) any ``Software`` /
``Make`` / ``Artist`` / ``ImageDescription`` tag carrying an ``AI_GENERATOR_TOKENS``
token, and (c) the China TC260 ``{"AIGC":{...}}`` block in ``ImageDescription``
(0th) or ``UserComment`` (Exif). De-duplicated by ``(ifd_key, tag)`` so a value
flagged by two rules is removed and named once. Mirrors the detection in
``xai_signature`` / ``exif_generator`` / ``aigc_label``; adding a new AI EXIF
placement here reaches BOTH containers.
"""
import piexif
from remove_ai_watermarks.noai.constants import AI_GENERATOR_TOKENS
ifd = exif_dict.get("0th")
if not ifd:
return []
ifd0: dict[int, Any] = loaded.get("0th") or {}
ifde: dict[int, Any] = loaded.get("Exif") or {}
seen: set[tuple[str, int]] = set()
targets: list[tuple[str, int, bytes, str]] = []
drop: dict[int, str] = {}
def add(ifd_key: str, ifd: dict[int, Any], tag: int, name: str) -> None:
value = ifd.get(tag)
if isinstance(value, bytes) and (ifd_key, tag) not in seen:
seen.add((ifd_key, tag))
targets.append((ifd_key, tag, value, name))
# (a) xAI / Grok: the Signature blob and the UUID Artist go together.
if _is_xai_signature_pair(
_exif_text(ifd, piexif.ImageIFD.ImageDescription), _exif_text(ifd, piexif.ImageIFD.Artist)
_exif_text(ifd0, piexif.ImageIFD.ImageDescription), _exif_text(ifd0, piexif.ImageIFD.Artist)
):
drop[piexif.ImageIFD.ImageDescription] = "ImageDescription"
drop[piexif.ImageIFD.Artist] = "Artist"
# (b) Known AI generator token in any of the text tags.
add("0th", ifd0, piexif.ImageIFD.ImageDescription, "ImageDescription")
add("0th", ifd0, piexif.ImageIFD.Artist, "Artist")
# (b) known AI generator token in a 0th text tag.
for tag, name in (
(piexif.ImageIFD.Software, "Software"),
(piexif.ImageIFD.Make, "Make"),
(piexif.ImageIFD.Artist, "Artist"),
(piexif.ImageIFD.ImageDescription, "ImageDescription"),
):
if any(token in _exif_text(ifd, tag).lower() for token in AI_GENERATOR_TOKENS):
drop[tag] = name
if any(token in _exif_text(ifd0, tag).lower() for token in AI_GENERATOR_TOKENS):
add("0th", ifd0, tag, name)
# (c) TC260 AIGC block in ImageDescription (0th) or UserComment (Exif sub-IFD).
if _is_aigc_exif_value(ifd0.get(piexif.ImageIFD.ImageDescription)):
add("0th", ifd0, piexif.ImageIFD.ImageDescription, "ImageDescription")
if _is_aigc_exif_value(ifde.get(piexif.ExifIFD.UserComment)):
add("Exif", ifde, piexif.ExifIFD.UserComment, "UserComment")
for tag in drop:
ifd.pop(tag, None)
return list(drop.values())
return targets
def _scrub_ai_exif(exif_dict: dict[str, Any]) -> list[str]:
"""Delete the AI-provenance EXIF tags (`_ai_exif_targets`) from a piexif dict's
``0th`` / ``Exif`` IFDs in place; return the removed tag names (for logging).
Genuine camera/editor EXIF is left intact."""
removed: list[str] = []
for ifd_key, tag, _value, name in _ai_exif_targets(exif_dict):
ifd = exif_dict.get(ifd_key)
if ifd is not None:
ifd.pop(tag, None)
removed.append(name)
return removed
def get_ai_metadata(image_path: Path) -> dict[str, str]:
@@ -840,6 +946,99 @@ def _strip_with_ffmpeg(source_path: Path, output_path: Path) -> Path:
return output_path
def _jpeg_app_carries_ai(marker: int, payload: bytes) -> bool:
"""Whether a JPEG APPn segment carries AI provenance to drop wholesale (C2PA in
APP11, an AI XMP packet in APP1, an IPTC "Made with AI" record in APP13). EXIF
(APP1 ``Exif``) is NOT dropped here -- it is scrubbed tag-by-tag via piexif so
genuine camera EXIF survives."""
if marker == 0xEB: # APP11: C2PA / JUMBF manifest
return c2pa_marker_in(payload) or b"jumb" in payload[:256].lower()
if marker == 0xE1 and payload.startswith(b"http://ns.adobe.com/xap/"): # APP1 XMP
return (
c2pa_marker_in(payload)
or any(m in payload for m in AIGC_MARKERS)
or any(m in payload for m in IPTC_AI_MARKERS) # digitalSourceType in XMP, not only APP13
or any(m in payload for m in IPTC_AI_FIELD_MARKERS) # IPTC 2025.1 AI-disclosure fields
)
if marker == 0xED: # APP13: Photoshop / IPTC
return any(m in payload for m in IPTC_AI_MARKERS) or any(m in payload for m in IPTC_AI_FIELD_MARKERS)
# A bare / wrapped China TC260 AIGC block (``AIGC{...}`` or ``{"AIGC":{...}}``) that
# some China-served generators glue into a non-standard APP segment near the JFIF
# header. ``aigc_label`` detects it anywhere in the scan head, so removal must drop
# the carrying segment too (detection<->removal parity). Skip APP1-EXIF (0xE1
# ``Exif``): its camera tags are scrubbed tag-by-tag via piexif, and the AIGC-in-
# UserComment/ImageDescription placement is handled there, so it must not be dropped
# wholesale here.
if 0xE0 <= marker <= 0xEF and not (marker == 0xE1 and payload.startswith(b"Exif")):
return _is_aigc_exif_value(payload)
return False
def _strip_samsung_trailer(scan_and_tail: bytes) -> bytes:
"""Drop a Samsung Galaxy AI editing trailer appended AFTER the JPEG EOI.
Galaxy AI records its ``PhotoEditor_Re_Edit_Data`` (``genAIType``) blob as a
proprietary trailer past the final ``FFD9`` end-of-image, so the verbatim
scan copy in :func:`_strip_jpeg_metadata_lossless` would carry it through. If
the marker is present in the post-EOI trailer, truncate at EOI (the coded scan
is untouched, pixels stay bit-identical). A JPEG with no such trailer -- or a
non-Samsung trailer (e.g. an MPF multi-picture block) -- is returned unchanged.
"""
if _SAMSUNG_EDITOR_MARKER not in scan_and_tail:
return scan_and_tail
eoi = scan_and_tail.rfind(b"\xff\xd9")
if eoi == -1 or _SAMSUNG_EDITOR_MARKER not in scan_and_tail[eoi:]:
return scan_and_tail # marker not in the post-EOI trailer; leave the scan alone
return scan_and_tail[: eoi + 2]
def _strip_jpeg_metadata_lossless(source_path: Path, output_path: Path) -> bool:
"""Remove AI metadata from a JPEG WITHOUT re-encoding the DCT scan, so the pixels
stay bit-identical (the point of "work with originals" -- a metadata strip must not
degrade the image). Walks the marker segments up to SOS, drops the AI-bearing APP
segments (:func:`_jpeg_app_carries_ai`), copies the entropy-coded scan verbatim
(minus a Samsung Galaxy AI trailer past EOI, via :func:`_strip_samsung_trailer`),
then scrubs AI EXIF tags in place via piexif (which rewrites only the APP1 EXIF,
leaving genuine camera EXIF and the scan untouched). Returns False if the bytes are
not a parseable JPEG, so the caller falls back to the near-lossless PIL re-save."""
import piexif
data = source_path.read_bytes()
if not data.startswith(b"\xff\xd8"):
return False
out = bytearray(b"\xff\xd8")
i, n = 2, len(data)
while i + 1 < n:
if data[i] != 0xFF:
return False # malformed marker boundary: defer to the PIL re-encode fallback
marker = data[i + 1]
if marker in (0xDA, 0xD9): # SOS / EOI -> the coded scan follows; copy verbatim
out += _strip_samsung_trailer(data[i:])
break
if 0xD0 <= marker <= 0xD7 or marker == 0x01: # standalone markers carry no length
out += data[i : i + 2]
i += 2
continue
if i + 4 > n:
return False # truncated segment header: defer to the PIL re-encode fallback
seg_len = int.from_bytes(data[i + 2 : i + 4], "big")
seg_end = i + 2 + seg_len
if seg_len < 2 or seg_end > n:
return False # malformed segment length: defer to the PIL re-encode fallback
if not _jpeg_app_carries_ai(marker, data[i + 4 : seg_end]):
out += data[i:seg_end]
i = seg_end
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(bytes(out))
try:
exif = piexif.load(str(output_path))
if _scrub_ai_exif(exif):
piexif.insert(piexif.dump(exif), str(output_path))
except Exception:
logger.debug("piexif EXIF scrub skipped on %s", output_path, exc_info=True)
return True
def remove_ai_metadata(
source_path: Path,
output_path: Path | None = None,
@@ -871,23 +1070,32 @@ def remove_ai_metadata(
# codestream bit-for-bit. MP4/MOV/M4A are ISOBMFF too, so the same top-level
# uuid/jumb box walker applies. Route by suffix OR by an ``ftyp`` content
# sniff, so a correctly-shaped container is handled whatever its extension.
from remove_ai_watermarks.noai.isobmff import blank_ai_xmp_packets, is_isobmff, strip_c2pa_boxes
from remove_ai_watermarks.noai.isobmff import (
blank_ai_exif_tokens,
blank_ai_xmp_packets,
is_isobmff,
strip_c2pa_boxes,
)
with open(source_path, "rb") as f:
head = f.read(12)
if source_path.suffix.lower() in _ISOBMFF_EXTS or is_isobmff(head):
data = source_path.read_bytes()
# Top-level uuid/jumb boxes (C2PA + AI-label XMP), then AI-label XMP that
# lives inside a meta-box ``mime`` item (HEIF/AVIF) -- blanked in place so
# box sizes and iloc offsets stay valid and the coded image is untouched.
# Top-level uuid/jumb boxes (C2PA + AI-label XMP), then the meta-box items
# the top-level stripper can't reach (HEIF/AVIF store them in mdat/idat):
# AI-label XMP packets and AI-generator tokens in an Exif item -- both
# blanked in place (same length) so box sizes and iloc offsets stay valid
# and the coded image is untouched.
cleaned, stripped = strip_c2pa_boxes(data)
cleaned, blanked = blank_ai_xmp_packets(cleaned)
cleaned, exif_blanked = blank_ai_exif_tokens(cleaned)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(cleaned)
logger.info(
"Stripped %d AI-provenance box(es), blanked %d meta-box XMP packet(s) → %s",
"Stripped %d AI-provenance box(es), blanked %d meta-box XMP packet(s) + %d EXIF token(s) → %s",
stripped,
blanked,
exif_blanked,
output_path,
)
return output_path
@@ -898,6 +1106,37 @@ def remove_ai_metadata(
if source_path.suffix.lower() in _FFMPEG_STRIP_EXTS:
return _strip_with_ffmpeg(source_path, output_path)
# JPEG: strip AI metadata at the byte level so the DCT scan (the pixels) is NOT
# re-encoded. The PIL open+save path below is lossy for JPEG (a q95 re-encode that
# would undo the quality-preserving writes of the removal pipelines); this keeps a
# JPEG bit-identical outside its APP metadata segments. Falls through on a
# non-parseable JPEG. Only when keep_standard: the lossless walk drops AI segments
# but preserves standard ones, so a keep_standard=False caller (strip EVERYTHING)
# must use the full re-encode path below instead.
if (
keep_standard
and output_path.suffix.lower() in (".jpg", ".jpeg")
and _strip_jpeg_metadata_lossless(source_path, output_path)
):
return output_path
# Fail-safe for a truncated / corrupt image: PIL raises OSError when it decodes a
# partial file (`img.copy()` / `img.save()` below), which would crash a direct
# library caller (a web worker 500s on a partial upload). Probe decodability first;
# if it fails, copy the input through unchanged and return -- we cannot strip what we
# cannot parse, but we never raise (mirrors strip_c2pa_boxes' fail-safe).
try:
with Image.open(source_path) as _probe:
_probe.load()
except Exception:
logger.warning("Could not decode %s to strip metadata (truncated/corrupt); copied through", source_path)
if output_path != source_path:
import shutil
output_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source_path, output_path)
return output_path
# Read image and filter metadata
with Image.open(source_path) as img:
img = img.copy()
@@ -932,6 +1171,11 @@ def remove_ai_metadata(
continue
if _is_ai_key(key):
continue
# Drop a generic text chunk whose VALUE names an AI generator (NovelAI
# writes its stamp into Title/Source under non-AI keys) -- keeps removal
# in parity with exif_generator's value-based detection.
if isinstance(value, str) and _is_ai_value(value):
continue
if key == "exif":
with contextlib.suppress(Exception):
exif_data = piexif.load(value)
+4 -2
View File
@@ -17,7 +17,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from remove_ai_watermarks.noai.cleaner import remove_ai_metadata
from remove_ai_watermarks.metadata import remove_ai_metadata
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover, remove_watermark
__all__ = ["WatermarkRemover", "remove_ai_metadata", "remove_watermark"]
@@ -26,7 +26,9 @@ __all__ = ["WatermarkRemover", "remove_ai_metadata", "remove_watermark"]
def __getattr__(name: str) -> object:
"""Resolve the public API on first access (PEP 562), not at package import."""
if name == "remove_ai_metadata":
from remove_ai_watermarks.noai.cleaner import remove_ai_metadata
# Re-export the single, robust stripper (byte-level, lossless-for-JPEG, all
# containers); the old noai.cleaner implementation is retired.
from remove_ai_watermarks.metadata import remove_ai_metadata
return remove_ai_metadata
if name in ("WatermarkRemover", "remove_watermark"):
+17 -6
View File
@@ -41,7 +41,7 @@ from remove_ai_watermarks.noai.constants import (
SYNTHID_C2PA_ISSUERS,
)
log = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Official C2PA reader (c2pa-python, a core dependency). It is the primary,
# spec-tracking manifest parser; the hand-rolled caBX/CBOR scanner below stays as
@@ -96,7 +96,7 @@ def _read_manifest_store_impl(path_str: str) -> str | None:
try:
reader = _C2paReader.try_create(path_str)
except Exception as exc: # malformed manifest, unsupported container, etc.
log.debug("c2pa Reader could not parse %s: %s", path_str, exc)
logger.debug("c2pa Reader could not parse %s: %s", path_str, exc)
return None
if reader is None:
return None
@@ -104,7 +104,7 @@ def _read_manifest_store_impl(path_str: str) -> str | None:
with reader:
return reader.json()
except Exception as exc: # pragma: no cover - reader opened but json() failed
log.debug("c2pa Reader.json() failed on %s: %s", path_str, exc)
logger.debug("c2pa Reader.json() failed on %s: %s", path_str, exc)
return None
@@ -363,15 +363,26 @@ def _populate_registry_fields(buf: bytes, c2pa_info: dict[str, Any]) -> bool:
# Digital source type (matched anywhere in the store, including ingredient
# manifests -- a ChatGPT edit of a Sora generation carries the AI marker on
# the parent, not the active manifest).
# ``ai_source_kind`` is the structured generated-vs-enhanced split the caller
# branches on (full-frame scrub vs region-targeted clean); ``source_type`` is the
# human-readable form. The two byte strings are unambiguous:
# "compositeWithTrainedAlgorithmicMedia" capitalizes the inner "Trained", so a
# lowercase "trainedAlgorithmicMedia" match is standalone full generation, which
# wins when both appear (an edit chain).
ai_source = False
if b"trainedAlgorithmicMedia" in buf:
c2pa_info["source_type"] = "trainedAlgorithmicMedia (AI-generated)"
c2pa_info["ai_source_kind"] = "generated"
ai_source = True
elif b"compositeWithTrainedAlgorithmicMedia" in buf:
# Checked BEFORE bare ``algorithmicMedia``: a manifest can carry both tokens
# (an AI-enhanced composite with a procedural ingredient), and the bare-token
# branch would otherwise fire first and misclassify the AI composite as non-AI.
c2pa_info["source_type"] = "compositeWithTrainedAlgorithmicMedia (AI-enhanced)"
c2pa_info["ai_source_kind"] = "enhanced"
ai_source = True
elif b"algorithmicMedia" in buf:
c2pa_info["source_type"] = "algorithmicMedia"
elif b"compositeWithTrainedAlgorithmicMedia" in buf:
c2pa_info["source_type"] = "compositeWithTrainedAlgorithmicMedia (AI-enhanced)"
ai_source = True
# SynthID pixel-watermark proxy: a C2PA manifest from a SynthID-using
# vendor (Google/OpenAI) on AI-generated content implies an invisible
-180
View File
@@ -1,180 +0,0 @@
"""AI metadata cleaning and removal.
Provides functions to identify and strip AI-generation metadata from
PNG and JPEG images while optionally preserving standard fields like
Author, Title, and Copyright.
The removal pipeline:
1. Opens the image and iterates over all metadata keys.
2. Classifies each key as AI-related (using ``constants.AI_METADATA_KEYS``
and ``constants.AI_KEYWORDS``) or standard.
3. Rebuilds the image with only the desired metadata retained.
"""
from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pathlib import Path
import piexif
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from remove_ai_watermarks.noai.constants import AI_KEYWORDS, AI_METADATA_KEYS, PNG_METADATA_KEYS
from remove_ai_watermarks.noai.utils import get_image_format
# Pre-compute a lowercase set for O(1) key lookups.
_AI_KEYS_LOWER: frozenset[str] = frozenset(k.lower() for k in AI_METADATA_KEYS)
def remove_ai_metadata(
source_path: Path,
output_path: Path | None = None,
keep_standard: bool = True,
) -> Path:
"""
Remove all AI-generated metadata from an image.
Removes:
- AI parameters (Stable Diffusion, ComfyUI, etc.)
- C2PA metadata (Google Imagen, OpenAI, etc.)
- Any metadata with AI-related keywords
Args:
source_path: Path to the source image file.
output_path: Optional output path. If not provided, modifies source in place.
keep_standard: If True, keeps standard metadata (Author, Title, etc.).
Returns:
Path to the output file with AI metadata removed.
"""
if output_path is None:
output_path = source_path
cleaned_metadata = _extract_non_ai_metadata(source_path, keep_standard)
with Image.open(source_path) as img:
img = img.copy()
output_format = get_image_format(output_path)
save_kwargs: dict[str, Any] = {"format": output_format}
# Handle EXIF data (keep it, just don't include AI-related fields)
if "exif" in cleaned_metadata:
save_kwargs["exif"] = cleaned_metadata["exif"]
if output_format == "PNG":
save_kwargs = _prepare_clean_png_kwargs(save_kwargs, cleaned_metadata)
elif output_format == "JPEG":
save_kwargs = _prepare_clean_jpeg_kwargs(save_kwargs, cleaned_metadata)
output_path.parent.mkdir(parents=True, exist_ok=True)
if output_format == "JPEG" and img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(output_path, **save_kwargs)
return output_path
def _extract_non_ai_metadata(source_path: Path, keep_standard: bool) -> dict[str, Any]:
"""Extract metadata excluding AI-related fields."""
cleaned_metadata: dict[str, Any] = {}
with Image.open(source_path) as img:
# Handle EXIF data
if "exif" in img.info:
with contextlib.suppress(Exception):
exif_dict = piexif.load(img.info["exif"])
cleaned_metadata["exif"] = exif_dict
# Extract non-AI metadata
for key, value in img.info.items():
if not isinstance(key, str):
continue
if _is_ai_metadata_key(key):
continue
is_standard = keep_standard and key in PNG_METADATA_KEYS
is_nonstandard = not keep_standard and key not in ["exif", "dpi", "gamma"] and key not in PNG_METADATA_KEYS
if is_standard or is_nonstandard:
cleaned_metadata[key] = value
# Keep DPI and gamma
if "dpi" in img.info:
cleaned_metadata["dpi"] = img.info["dpi"]
if "gamma" in img.info:
cleaned_metadata["gamma"] = img.info["gamma"]
return cleaned_metadata
def _is_ai_metadata_key(key: str) -> bool:
"""Return True if *key* is an AI-generation metadata field.
Detection uses two layers:
1. Exact match against the canonical ``AI_METADATA_KEYS`` list.
2. Substring match against ``AI_KEYWORDS`` (covers partial hits
like ``"stable_diffusion_model"``).
"""
key_lower = key.lower()
if key_lower in _AI_KEYS_LOWER:
return True
return any(kw in key_lower for kw in AI_KEYWORDS)
def _prepare_clean_png_kwargs(save_kwargs: dict[str, Any], metadata: dict[str, Any]) -> dict[str, Any]:
"""Prepare save kwargs for clean PNG."""
pnginfo: dict[str, Any] = {}
exclude_keys = ["exif", "exif_raw", "dpi", "gamma"]
for key, value in metadata.items():
if key not in exclude_keys:
pnginfo[key] = value
if pnginfo:
pnginfo_obj = PngInfo()
for key, value in pnginfo.items():
if isinstance(value, str):
pnginfo_obj.add_text(key, value)
elif isinstance(value, bytes):
pnginfo_obj.add_text(key, value.decode("utf-8", errors="replace"))
save_kwargs["pnginfo"] = pnginfo_obj
if "dpi" in metadata:
save_kwargs["dpi"] = metadata["dpi"]
return save_kwargs
def _prepare_clean_jpeg_kwargs(save_kwargs: dict[str, Any], metadata: dict[str, Any]) -> dict[str, Any]:
"""Prepare save kwargs for clean JPEG."""
exif_dict = metadata.get("exif", {"0th": {}, "Exif": {}, "1st": {}, "GPS": {}, "Interop": {}})
with contextlib.suppress(Exception):
exif_bytes = piexif.dump(exif_dict)
save_kwargs["exif"] = exif_bytes
if "dpi" in metadata:
save_kwargs["dpi"] = metadata["dpi"]
return save_kwargs
def has_ai_content(image_path: Path) -> bool:
"""
Check if an image has any AI-generated content or metadata.
Args:
image_path: Path to the image file.
Returns:
True if the image contains AI metadata.
"""
from remove_ai_watermarks.noai.extractor import has_ai_metadata
return has_ai_metadata(image_path)
+93 -2
View File
@@ -6,8 +6,12 @@ so adding a new AI tool or metadata key requires updating only this file.
from typing import NamedTuple
# Supported image formats
SUPPORTED_FORMATS = {".png", ".jpg", ".jpeg", ".webp"}
# Supported image formats for the pixel/removal path (CLI input validation + batch
# discovery). PNG/JPEG/WebP decode+encode via cv2; HEIC/HEIF/AVIF via the core
# pillow-heif dep (image_io.imread Pillow fallback + imwrite _pil_write), so batch
# now picks them up and the CLI no longer warns on an iPhone HEIC. JPEG-XL is left
# out on purpose -- it is metadata/strip-only (no pixel decoder without pillow-jxl).
SUPPORTED_FORMATS = {".png", ".jpg", ".jpeg", ".webp", ".heic", ".heif", ".avif"}
# AI-generated image metadata keys (Stable Diffusion, ComfyUI, Midjourney, etc.)
AI_METADATA_KEYS = [
@@ -96,6 +100,13 @@ class C2paAiVendor(NamedTuple):
# (usually a shorter form of org, e.g. "Google" for "Google LLC"); None when platform is.
needle: str | None
synthid: bool = False # vendor pairs an invisible SynthID pixel watermark with its C2PA manifest
# The vendor's mere presence in the manifest asserts AI generation even without
# a digitalSourceType (``trainedAlgorithmicMedia``) assertion. Set ONLY for a
# pure-generator brand whose issuer/generator byte string is unambiguous (e.g.
# "Dreamina"). Do NOT set for common-word issuers (Adobe/Google/OpenAI/Microsoft):
# those appear incidentally in unrelated XMP/trust-chain bytes, so they stay
# source-type-gated in identify._attribute_platform.
asserts_ai: bool = False
# C2PA known vendors, ORDERED for first-match-wins platform attribution: when a
@@ -122,15 +133,85 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
C2paAiVendor(
b"volcengine", "ByteDance (Volcano Engine)", "ByteDance (Doubao / Jimeng / Volcano Engine)", "ByteDance"
),
# Some Volcano Engine certs name the signer with the Chinese legal entity
# "北京火山引擎科技有限公司" (Beijing Volcano Engine Technology Co., Ltd.) rather
# than the latin "volcengine" -- the latin needle misses it entirely, so real
# ByteDance output was un-attributed in production traffic. The issuer is the
# UTF-8 of the Chinese name (it appears UTF-8-encoded in the manifest-store
# JSON and the raw caBX bytes alike); it normalizes to the same "ByteDance"
# needle and platform as the volcengine row, so the two collapse together for
# clash detection. Verified against the mined retained corpus, 2026-06-20.
C2paAiVendor(
"北京火山引擎科技有限公司".encode(),
"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"),
# Dreamina (ByteDance's international Jimeng brand) signs C2PA as "Bytedance
# Pte. Ltd." with a "Dreamina/x.y" claim generator and, unlike the Volcano
# Engine output, NO digitalSourceType assertion -- so the generator name is the
# only AI signal. It is registered by that generator token (which the caBX /
# store-JSON byte scan sees across active + ingredient manifests, where the
# active manifest is often a plain c2pa-tool transcode). ``asserts_ai`` lets the
# issuer alone flag AI without trainedAlgorithmicMedia; "Dreamina" is a
# distinctive brand string, so it does not risk the incidental-mention problem
# the common-word issuers have. Verified on real signed files in the retained
# corpus, 2026-07. Normalizes to the same "ByteDance" needle/platform as the
# volcengine row (they collapse together for clash detection).
C2paAiVendor(
b"Dreamina",
"ByteDance (Dreamina)",
"ByteDance (Doubao / Jimeng / Volcano Engine)",
"ByteDance",
asserts_ai=True,
),
# 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"),
# ElevenLabs is a pure generative-AI company (AI voice / audio, and image /
# video via its API); it signs output as "Eleven Labs Inc.", so the C2PA
# manifest alone marks AI generation. Verified against the mined retained
# corpus, 2026-06-20. ElevenLabs does not use SynthID.
C2paAiVendor(b"Eleven Labs", "ElevenLabs", "ElevenLabs", "ElevenLabs"),
# 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),
)
# Deliberately NOT registered as AI-generation vendors (mined-corpus candidates
# evaluated 2026-06-20):
# - TikTok Inc.: signs C2PA as a content-provenance / AI-labeling authority on
# uploads, not as an image generator. The is_ai verdict keys off the
# digitalSourceType (trainedAlgorithmicMedia), which is already honored; a
# bare TikTok signer marks distribution provenance, not generation, so adding
# it as a generator needle would mis-label human uploads as AI.
# - PixelBin.io (issuer "Fynd"): an image transformation / optimization / CDN
# service. Its C2PA stamps a transform/upload step, not a generation event.
# Both are excluded to avoid false-positive AI attribution; re-evaluate only
# against a real signed file whose manifest carries a trainedAlgorithmicMedia
# digital-source type produced by the vendor itself.
# Derived view -- add a vendor to C2PA_AI_VENDORS above, not here.
# C2PA issuer signature -> resolved org name, for the manifest byte-scan.
C2PA_ISSUERS: dict[bytes, str] = {v.issuer: v.org for v in C2PA_AI_VENDORS}
# Resolved org names of the vendors whose presence asserts AI generation on its
# own (no digitalSourceType needed) -- see the ``asserts_ai`` field. identify uses
# this to lift the AI verdict for an identity-AI issuer (e.g. Dreamina) that ships
# no trainedAlgorithmicMedia. Derived from the flag -- set it on the vendor, not here.
C2PA_IDENTITY_AI_ORGS: frozenset[str] = frozenset(v.org for v in C2PA_AI_VENDORS if v.asserts_ai)
# C2PA issuers whose signed outputs also carry an invisible SynthID pixel
# watermark -- a metadata proxy for "SynthID is in the pixels":
# - Google (Imagen/Gemini): embeds SynthID, long-standing (DeepMind docs).
@@ -216,6 +297,16 @@ AI_GENERATOR_TOKENS: frozenset[str] = frozenset(
"leonardo",
"flux",
"dreamstudio",
# Mined from the retained corpus 2026-06-22 (no C2PA -- a plain EXIF/PNG
# generator stamp was the only signal and we read none of them):
# - NovelAI (anime SD): PNG tEXt Software="NovelAI", Source="NovelAI
# Diffusion V4.5 <hash>", Title="NovelAI generated image".
# - Reve Image (reve.com): EXIF Software / XMP CreatorTool = "reve.com"
# (the bare token "reve" would false-positive on "forever"/"reverie").
# - Aphrodite AI: EXIF Make / Software = "Aphrodite AI[ v1.0]".
"novelai",
"reve.com",
"aphrodite ai",
}
)
+66 -7
View File
@@ -20,7 +20,7 @@ from __future__ import annotations
import logging
import re
import struct
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterator
@@ -33,7 +33,7 @@ from remove_ai_watermarks.metadata import (
IPTC_AI_MARKERS,
)
log = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Top-level box types that may carry AI provenance. ``uuid`` boxes are checked
# against ``C2PA_UUID`` / AI-label markers before being stripped; ``jumb`` boxes
@@ -157,10 +157,11 @@ def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
All other boxes (incl. ``mdat`` / codestream) are emitted verbatim, so pixel
and audio data is preserved bit-for-bit. Non-ISOBMFF input is returned
unchanged. Despite the name this also covers MP4/MOV/M4A video and audio
(all ISOBMFF). NOTE: this drops only top-level boxes. An AI-label XMP packet
stored as an *item inside the ``meta`` box* (typical for AVIF/HEIF) is handled
separately by :func:`blank_ai_xmp_packets`; an ``Exif`` meta-box item is still
not removed (would need meta-box surgery) and remains a documented limitation.
(all ISOBMFF). NOTE: this drops only top-level boxes. AI metadata stored as an
*item inside the ``meta`` box* (typical for AVIF/HEIF) is handled separately and
in place (same length, no offset rewrite): AI-label XMP by
:func:`blank_ai_xmp_packets`, and AI-generator tokens in an ``Exif`` item by
:func:`blank_ai_exif_tokens`.
"""
if not is_isobmff(data):
return data, 0
@@ -187,7 +188,7 @@ def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
# would silently truncate the file from the bad box to EOF -- worse than not
# stripping. If the walk did not consume the whole input, return it unchanged.
if consumed != len(data):
log.warning(
logger.warning(
"ISOBMFF box walk stopped at offset %d of %d (malformed box); "
"returning input unchanged to avoid truncation",
consumed,
@@ -223,3 +224,61 @@ def blank_ai_xmp_packets(data: bytes) -> tuple[bytes, int]:
return packet
return _XMP_PACKET_RE.sub(_scrub, data), blanked
# EXIF TIFF byte-order headers: little-endian (II 0x2a 0x00) and big-endian
# (MM 0x00 0x2a). A HEIF/AVIF ``Exif`` meta-box item stores its TIFF block in
# ``mdat`` / ``idat``, so the block (and these headers) appear in the raw bytes.
_TIFF_HEADERS: tuple[bytes, ...] = (b"II\x2a\x00", b"MM\x00\x2a")
# How far past a TIFF header an EXIF block plausibly extends; bounds the slice we
# hand to piexif and search within (EXIF blocks are small kilobyte-scale).
_EXIF_WINDOW = 256 * 1024
def blank_ai_exif_tokens(data: bytes) -> tuple[bytes, int]:
"""Overwrite (with spaces, in place) any AI-generator token in an EXIF block
stored as an ISOBMFF ``meta``-box ``Exif`` item; return ``(data, blanked_count)``.
HEIF/AVIF can carry EXIF as a ``meta``-box ``Exif`` item whose TIFF bytes live
in ``mdat`` / ``idat`` -- out of reach of the top-level box stripper, and (when
no pillow-heif plugin is installed) of the PIL EXIF reader too, so an AI
``Software`` / ``Make`` / ``Artist`` / ``ImageDescription`` tag there survived
``remove_ai_metadata`` (a documented gap). This locates EXIF TIFF blocks by
their byte-order header, **validates each with piexif** (so a coincidental
II/MM run in pixel data is ignored -- it will not parse as a TIFF IFD), and
overwrites any AI value with spaces of the SAME length. Because the replacement
is same-length, every box size and ``iloc`` offset stays valid and the coded image
is untouched -- only the AI tag content is destroyed; camera/editor EXIF without an
AI token is left intact. This mirrors ``metadata._scrub_ai_exif`` in what it removes
-- generator tokens (``Software``/``Make``/``Artist``/``ImageDescription``), the
China TC260 ``{"AIGC":{...}}`` block (``ImageDescription``/``UserComment``), and the
xAI/Grok ``Signature:`` + UUID-``Artist`` pair -- since on the ISOBMFF path this is
the ONLY EXIF scrubber (``_scrub_ai_exif`` never runs there), so without parity a
HEIC/AVIF AIGC/xAI tag is detected but not removed.
"""
import piexif
# The AI-EXIF rule set is defined ONCE in metadata._ai_exif_targets and shared by both
# EXIF scrubbers (the JPEG _scrub_ai_exif pops the tag; here we blank the value bytes),
# so their coverage cannot drift. Imported lazily to avoid import-order coupling with
# metadata (which imports this module); a deliberate cross-module use, not an API leak.
from remove_ai_watermarks.metadata import _ai_exif_targets # pyright: ignore[reportPrivateUsage]
out = bytearray(data)
blanked = 0
for header in _TIFF_HEADERS:
pos = data.find(header)
while pos != -1:
window = bytes(out[pos : pos + _EXIF_WINDOW])
try:
loaded: dict[str, Any] = piexif.load(window)
except Exception:
loaded = {}
for _ifd_key, _tag, value, _name in _ai_exif_targets(loaded):
# Blank the value bytes in place, within this EXIF block only.
vpos = out.find(value, pos, pos + _EXIF_WINDOW)
if vpos != -1:
out[vpos : vpos + len(value)] = b" " * len(value)
blanked += 1
pos = data.find(header, pos + len(header))
return bytes(out), blanked
+195
View File
@@ -0,0 +1,195 @@
"""Sliding-window tiled diffusion for large images.
The img2img / ControlNet pipeline denoises the WHOLE image in one forward pass,
so it OOMs on MPS/GPU above ~2K (issue #10). Tiling splits the image into
overlapping tiles -- each kept near SDXL's ~1024 training size -- regenerates
each tile independently, and feather-blends the overlaps. The result is processed
at NATIVE resolution with no seam: the lossless alternative to the
``--max-resolution`` downscale (which trades quality for a smaller forward pass).
The geometry (``plan_tiles``) and the blend weighting (``feather_weights``) are
pure functions, unit-tested without the diffusion model. ``run_tiled`` is the
orchestration loop; it takes a ``generate_tile`` callable (one img2img/ControlNet
pass on a single PIL tile) so it stays decoupled from the pipeline internals.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, NamedTuple
if TYPE_CHECKING:
from collections.abc import Callable
from numpy.typing import NDArray
from PIL import Image as PILImage
logger = logging.getLogger(__name__)
# Strictly-positive floor for the accumulated blend weights so a region covered
# by a single feathered tile edge (an image corner, no neighbour to blend with)
# never divides by zero.
_WEIGHT_EPS = 1e-3
class Tile(NamedTuple):
"""A tile crop box in the source image: top-left ``(x, y)`` + ``width``/``height``."""
x: int
y: int
width: int
height: int
def _axis_positions(length: int, tile: int, overlap: int) -> list[int]:
"""Tile start offsets along one axis, last tile flush to the far edge.
Every interior tile is exactly ``tile`` long; the final tile is pulled back
to ``length - tile`` so it ends exactly at the edge (it simply overlaps its
predecessor a little more). Keeping all tiles the same size is what lets the
diffusion pass run at SDXL's preferred dimension on every tile.
"""
if tile <= 0:
raise ValueError(f"tile must be positive, got {tile}")
if length <= tile:
return [0]
# Guarantee forward progress even on a pathological overlap >= tile.
overlap = min(max(overlap, 0), tile - 1)
step = tile - overlap
positions = list(range(0, length - tile + 1, step))
last = length - tile
if positions[-1] != last:
positions.append(last)
return positions
def plan_tiles(width: int, height: int, tile_size: int, overlap: int) -> list[Tile]:
"""Lay out a grid of overlapping tiles covering ``width`` x ``height``.
All tiles are ``min(tile_size, width)`` x ``min(tile_size, height)`` (uniform
size; the image itself when it fits in one tile). Returned in row-major order.
"""
xs = _axis_positions(width, tile_size, overlap)
ys = _axis_positions(height, tile_size, overlap)
tile_w = min(tile_size, width)
tile_h = min(tile_size, height)
return [Tile(x, y, tile_w, tile_h) for y in ys for x in xs]
def feather_weights(width: int, height: int, overlap: int) -> NDArray[Any]:
"""A 2D blend window: ~1 in the interior, ramping down toward each edge.
Separable linear taper over ``overlap`` pixels from every edge (capped at
half the tile so short tiles still taper symmetrically). Strictly positive
everywhere, so the normalized blend is well-defined even at an image corner
that only one tile covers.
"""
import numpy as np
def ramp(n: int) -> NDArray[Any]:
w = np.ones(n, dtype=np.float32)
if overlap > 0 and n > 1:
ramp_len = min(overlap, max(1, n // 2))
taper = (np.arange(ramp_len, dtype=np.float32) + 1.0) / (ramp_len + 1.0)
w[:ramp_len] = taper
w[n - ramp_len :] = taper[::-1]
return w
weights = np.outer(ramp(height), ramp(width))
np.maximum(weights, _WEIGHT_EPS, out=weights)
return weights
def feather_region_composite(
base: NDArray[Any],
regenerated: NDArray[Any],
box: tuple[int, int, int, int],
*,
feather: int = 64,
) -> NDArray[Any]:
"""Composite ``regenerated`` over ``base`` inside ``box`` only, feathering the seam.
For AI-ENHANCED composites (digitalSourceType ``compositeWithTrainedAlgorithmicMedia``):
the diffusion remover regenerates the whole frame, but only the AI-composited
REGION should change -- the rest is a real photo that must be preserved. This
blends the regenerated pixels in over ``box = (x, y, w, h)`` with a separable
linear taper of ``feather`` px at the box edges, so the result equals ``base``
EXACTLY outside the box and ramps smoothly (no hard seam) at the boundary.
Pure and model-free (unit-tested): ``base`` and ``regenerated`` must be the same
shape (H x W, or H x W x C). The output preserves ``base``'s dtype. ``feather`` is
clamped to half the box on each axis, so a small region still tapers symmetrically;
``feather=0`` is a hard-edged paste.
"""
import numpy as np
if base.shape != regenerated.shape:
raise ValueError(f"shape mismatch: base {base.shape} vs regenerated {regenerated.shape}")
h, w = base.shape[:2]
x, y, bw, bh = box
x0, y0 = max(0, x), max(0, y)
x1, y1 = min(w, x + bw), min(h, y + bh)
out = base.copy()
if x1 <= x0 or y1 <= y0:
return out # empty / off-image box -> nothing regenerated
def taper(n: int) -> NDArray[Any]:
win = np.ones(n, dtype=np.float32)
f = min(max(feather, 0), n // 2)
if f > 0:
ramp = (np.arange(f, dtype=np.float32) + 1.0) / (f + 1.0) # in (0, 1), 0 at the edge
win[:f] = ramp
win[n - f :] = ramp[::-1]
return win
rh, rw = y1 - y0, x1 - x0
wmap = np.outer(taper(rh), taper(rw)) # ~0 at the box edge, 1 in the interior
if base.ndim == 3:
wmap = wmap[:, :, None]
roi_base = base[y0:y1, x0:x1].astype(np.float32)
roi_gen = regenerated[y0:y1, x0:x1].astype(np.float32)
blended = roi_base * (1.0 - wmap) + roi_gen * wmap
out[y0:y1, x0:x1] = np.clip(blended, 0, 255).astype(base.dtype)
return out
def run_tiled(
generate_tile: Callable[[PILImage.Image], PILImage.Image],
image: PILImage.Image,
tile_size: int,
overlap: int,
set_progress: Callable[[str], None] | None = None,
) -> PILImage.Image:
"""Tile ``image``, run ``generate_tile`` per tile, and feather-blend the result.
``generate_tile`` runs one diffusion pass on a single RGB PIL tile and returns
the regenerated tile (the ControlNet control image is built per tile inside it,
so each tile gets its own edge map). A pass that rounds dimensions to the latent
grid is resized back to the exact tile size before blending.
"""
import numpy as np
from PIL import Image
width, height = image.size
tiles = plan_tiles(width, height, tile_size, overlap)
accum = np.zeros((height, width, 3), dtype=np.float32)
weight_sum = np.zeros((height, width, 1), dtype=np.float32)
# All tiles share one size (plan_tiles is uniform), so the feather window is
# loop-invariant -- compute it once.
weights = feather_weights(tiles[0].width, tiles[0].height, overlap)[:, :, None]
total = len(tiles)
for index, tile in enumerate(tiles, start=1):
if set_progress is not None:
set_progress(f"Tiled diffusion: tile {index}/{total} at ({tile.x},{tile.y}) {tile.width}x{tile.height}...")
crop = image.crop((tile.x, tile.y, tile.x + tile.width, tile.y + tile.height))
result = generate_tile(crop)
if result.size != (tile.width, tile.height):
result = result.resize((tile.width, tile.height), Image.Resampling.LANCZOS)
arr = np.asarray(result.convert("RGB"), dtype=np.float32)
accum[tile.y : tile.y + tile.height, tile.x : tile.x + tile.width] += arr * weights
weight_sum[tile.y : tile.y + tile.height, tile.x : tile.x + tile.width] += weights
blended = accum / np.maximum(weight_sum, _WEIGHT_EPS)
return Image.fromarray(np.clip(blended, 0, 255).astype(np.uint8))
@@ -12,6 +12,20 @@ if TYPE_CHECKING:
DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
# Qwen-Image (20B MMDiT, Apache-2.0 code AND weights) base for the ``qwen`` pipeline:
# an img2img alternative to SDXL with native text rendering (incl. CJK). Loaded only
# when ``--pipeline qwen`` is selected; CUDA/cloud-class (does not fit MPS). CERTIFIED
# oracle floors (2026-06-20): OpenAI **0.10** (seed-robust -- clean on seeds 0-4) and
# Google/Gemini **0.25** (seed 0 verified on 2 images; pin a seed in prod, the Gemini
# oracle rate-limits volume seed-repeat). The Gemini floor (0.25) is HIGHER than the
# certified controlnet Gemini floor (0.15); ``resolve_strength(..., pipeline="qwen")``
# now carries this via ``_QWEN_VENDOR_STRENGTH`` (below), so ``--pipeline qwen`` gets the
# right floor automatically -- the old manual "pass --strength 0.25 for Gemini on qwen"
# workaround is retired.
# (Dispatch uses the bare "qwen" literal, matching the sdxl/controlnet sites, so there
# is no QWEN_PROFILE constant -- only the model id is referenced from code.)
QWEN_MODEL_ID = "Qwen/Qwen-Image"
# Canonical pipeline-profile names + the back-compat alias. The plain SDXL img2img
# profile is ``sdxl``; ``default`` is kept as an accepted alias (it was the profile's
# name before ``controlnet`` became the default-selected pipeline, 2026-06-09).
@@ -77,6 +91,18 @@ DEFAULT_STRENGTH = UNKNOWN_STRENGTH
# Detected-vendor -> default strength. Vendor strings come from `vendor_for_strength`.
_VENDOR_STRENGTH = {"openai": OPENAI_STRENGTH, "google": GEMINI_STRENGTH}
# Qwen has its OWN certified floors (Modal A100-80GB, 2026-06-20), DIFFERENT from the
# SDXL ladder above: OpenAI 0.10 (seed-robust), Gemini 0.25 (HIGHER than controlnet's
# 0.15 -- the 20B MMDiT perturbs less per denoising step, so it needs more strength to
# clear Gemini SynthID). Unknown vendor tracks the higher (Gemini) value, safe-by-default.
# `resolve_strength(..., pipeline="qwen")` uses this table so `--pipeline qwen` carries the
# right floor automatically -- retiring the old manual "pass --strength 0.25 for Gemini on
# qwen" workaround.
QWEN_OPENAI_STRENGTH = 0.10
QWEN_GEMINI_STRENGTH = 0.25
QWEN_UNKNOWN_STRENGTH = 0.25
_QWEN_VENDOR_STRENGTH = {"openai": QWEN_OPENAI_STRENGTH, "google": QWEN_GEMINI_STRENGTH}
def strength_default_help() -> str:
"""One-line description of the vendor-adaptive default, derived from the constants.
@@ -90,20 +116,24 @@ def strength_default_help() -> str:
)
def resolve_strength(strength: float | None, vendor: str | None = None) -> float:
def resolve_strength(strength: float | None, vendor: str | None = None, pipeline: str | None = None) -> float:
"""Resolve the denoising strength, applying the vendor default when unset.
``None`` means "the user did not pass ``--strength``", which resolves
**vendor-adaptively**: ``vendor`` (``"openai"`` / ``"google"`` / None, from
``vendor_for_strength``) selects ``OPENAI_STRENGTH`` / ``GEMINI_STRENGTH`` /
``UNKNOWN_STRENGTH``. The same ladder applies to both pipelines (see the module
comment for why one ladder is correct). An explicit value always wins (including
``vendor_for_strength``) selects the per-vendor floor. The ``sdxl`` and ``controlnet``
pipelines share ONE ladder (``OPENAI_STRENGTH`` / ``GEMINI_STRENGTH`` /
``UNKNOWN_STRENGTH`` -- see the module comment for why); ``qwen`` has its OWN higher
ladder (``_QWEN_VENDOR_STRENGTH``, Gemini 0.25 vs controlnet 0.15), selected when
``pipeline`` normalizes to ``"qwen"``. An explicit value always wins (including
``0.0`` -- the check is ``is None``, not falsiness). Shared by the CLI (for display)
and the engine (for execution) so the two never disagree -- both must pass the SAME
``vendor``.
``vendor`` and ``pipeline``.
"""
if strength is not None:
return strength
if pipeline is not None and normalize_profile(pipeline) == "qwen":
return _QWEN_VENDOR_STRENGTH.get(vendor or "", QWEN_UNKNOWN_STRENGTH)
return _VENDOR_STRENGTH.get(vendor or "", UNKNOWN_STRENGTH)
@@ -1,6 +1,16 @@
"""Watermark removal using diffusion model regeneration attack.
Two pipelines:
Three pipelines (selected by the explicit ``pipeline`` ctor arg):
0. ``qwen`` -- Qwen-Image (20B MMDiT, Apache-2.0) img2img. The scrub still comes from
the img2img ``strength``; Qwen preserves text (incl. CJK) and structure markedly
better than SDXL at the scrub floor, so it over-regenerates real photos far less.
CUDA/cloud-class (does not fit MPS). See ``watermark_profiles`` for the certified
oracle floors. (Near-threshold scrub is seed-non-deterministic, but the prod path
pins ONE fixed seed, so a certified floor reproduces run-to-run -- pinning the seed
is the release gate, not sweeping seeds.)
Two SDXL pipelines:
1. ``controlnet`` (DEFAULT) -- SDXL img2img with a canny ControlNet. The watermark
REMOVAL still comes from the img2img regeneration (``strength``); the ControlNet
only PRESERVES structure (text/faces) by conditioning on the edge map. No original
@@ -36,6 +46,7 @@ from remove_ai_watermarks.noai.watermark_profiles import (
CONTROLNET_CANNY_MODEL,
DEFAULT_MODEL_ID,
DEFAULT_STRENGTH,
QWEN_MODEL_ID,
normalize_profile,
resolve_strength,
)
@@ -308,6 +319,45 @@ _CANNY_HIGH = 200
_CONTROLNET_PROMPT = "best quality, high quality, sharp, detailed, photographic"
_CONTROLNET_NEGATIVE = "blurry, lowres, deformed, distorted text, garbled text, watermark, jpeg artifacts"
# Neutral prompts for the Qwen-Image img2img pass (faithful regeneration, not an edit).
_QWEN_PROMPT = "high quality, sharp, detailed, faithful to the original"
_QWEN_NEGATIVE = "blurry, lowres, distorted text, garbled text, artifacts"
def _qwen_target_size(width: int, height: int) -> tuple[int, int]:
"""Floor (width, height) to a multiple of 16 for Qwen's VAE/patchifier (>= 16).
Pure; unit-tested. Without explicit dims the img2img pipeline defaults to a 1024x1024
SQUARE and silently distorts any non-square input.
"""
return max(16, (width // 16) * 16), max(16, (height // 16) * 16)
def _build_qwen_kwargs(
image: Image.Image, strength: float, num_inference_steps: int, true_cfg_scale: float, generator: Any
) -> dict[str, Any]:
"""Build the QwenImageImg2ImgPipeline call kwargs (pure; unit-tested without torch).
Qwen-Image uses ``true_cfg_scale`` (not SDXL's ``guidance_scale``) and takes an
explicit ``negative_prompt``; the scrub still comes from the img2img ``strength``.
Passes an explicit ``height``/``width`` derived from the input (floored to /16): the
pipeline otherwise defaults to a 1024x1024 SQUARE, squishing any non-square input
(the abba mixed-seam test: a 2816x1536 poster came back 1024x1024, distorting the
scene and garbling text). So qwen regenerates at the input's own geometry.
"""
qw, qh = _qwen_target_size(image.width, image.height)
return {
"prompt": _QWEN_PROMPT,
"negative_prompt": _QWEN_NEGATIVE,
"image": image,
"strength": strength,
"num_inference_steps": num_inference_steps,
"true_cfg_scale": true_cfg_scale,
"generator": generator,
"height": qh,
"width": qw,
}
class WatermarkRemover:
"""Remove watermarks from images using diffusion model regeneration.
@@ -348,6 +398,11 @@ class WatermarkRemover:
if torch_dtype is None:
if self.device == "cpu" or self.device == "mps":
self.torch_dtype = torch.float32 # type: ignore
elif self.model_profile == "qwen":
# Qwen-Image is published in bf16; fp16 risks overflow on the 20B MMDiT.
# cuda/xpu-only by construction: the cpu/mps guard above already forced
# fp32, and the 20B model does not fit MPS anyway.
self.torch_dtype = torch.bfloat16 # type: ignore
else:
self.torch_dtype = torch.float16 # type: ignore
else:
@@ -355,6 +410,7 @@ class WatermarkRemover:
self._pipeline: AutoImg2ImgPipeline | None = None
self._controlnet_pipeline: Any = None
self._qwen_pipeline: Any = None
self._progress_callback = progress_callback
self.hf_token: str | None = hf_token or os.environ.get("HF_TOKEN")
@@ -369,7 +425,9 @@ class WatermarkRemover:
def preload(self) -> None:
"""Eagerly load the pipeline so download progress bars are visible."""
if self.model_profile == "controlnet":
if self.model_profile == "qwen":
self._load_qwen_pipeline()
elif self.model_profile == "controlnet":
self._load_controlnet_pipeline()
else:
self._load_pipeline()
@@ -420,22 +478,51 @@ class WatermarkRemover:
return pipeline
def _base_load_kwargs(self) -> dict[str, Any]:
"""The ``from_pretrained`` kwargs shared by all three loaders (dtype + token).
Each loader adds its own extras (SDXL safety_checker + fp16 VAE, the ControlNet
model, etc.). Centralizing the dtype/token pair avoids the drift trap of three
copies (a token forgotten on one loader silently breaks gated downloads there).
"""
load_kwargs: dict[str, Any] = {"torch_dtype": self.torch_dtype}
if self.hf_token:
load_kwargs["token"] = self.hf_token
return load_kwargs
def _load_from_pretrained(self, cls: Any, model_id: str, **load_kwargs: Any) -> Any:
"""Call ``cls.from_pretrained`` reading the fp16 weight VARIANT when on fp16.
When ``torch_dtype`` is float16 (the CUDA/XPU SDXL default), pass
``variant="fp16"`` so diffusers fetches/reads the half-precision weight files
(~half the bytes of the fp32 defaults) instead of reading the full fp32 files
and downcasting in memory. On a warm weights cache this roughly halves the
cold-start weight read + host->device transfer, which a phase-timed Modal run
measured as ~half of the ~25s cold start. Not every checkpoint publishes an
fp16 variant (a custom ``--model``, or the canny ControlNet if it ships only
default files), so fall back to the default weights if the variant is missing -
worst case is the prior behavior (read fp32, downcast). fp32 (cpu/mps) and bf16
(qwen) never request the variant.
"""
if self.torch_dtype == torch.float16:
try:
return cls.from_pretrained(model_id, variant="fp16", **load_kwargs)
except Exception as exc:
logger.info("No fp16 weight variant for %s (%s); loading default weights", model_id, exc)
return cls.from_pretrained(model_id, **load_kwargs)
def _load_pipeline(self) -> AutoImg2ImgPipeline:
"""Load the plain SDXL img2img pipeline lazily."""
if self._pipeline is None:
logger.info("Loading model %s on %s...", self.model_id, self.device)
self._set_progress(f"Loading model weights: {self.model_id}")
load_kwargs: dict[str, Any] = {
"torch_dtype": self.torch_dtype,
"safety_checker": None,
"requires_safety_checker": False,
}
if self.hf_token:
load_kwargs["token"] = self.hf_token
load_kwargs = self._base_load_kwargs()
load_kwargs["safety_checker"] = None
load_kwargs["requires_safety_checker"] = False
self._maybe_add_fp16_vae(load_kwargs)
pipeline = AutoImg2ImgPipeline.from_pretrained(self.model_id, **load_kwargs) # type: ignore
pipeline = self._load_from_pretrained(AutoImg2ImgPipeline, self.model_id, **load_kwargs) # type: ignore
self._pipeline = self._move_to_device_and_optimize(pipeline)
logger.info("Model loaded successfully")
@@ -456,15 +543,18 @@ class WatermarkRemover:
logger.info("Loading SDXL + ControlNet (%s) on %s...", CONTROLNET_CANNY_MODEL, self.device)
self._set_progress(f"Loading ControlNet: {CONTROLNET_CANNY_MODEL}")
controlnet = ControlNetModel.from_pretrained(CONTROLNET_CANNY_MODEL, torch_dtype=self.torch_dtype)
controlnet = self._load_from_pretrained(
ControlNetModel, CONTROLNET_CANNY_MODEL, torch_dtype=self.torch_dtype
)
load_kwargs: dict[str, Any] = {"controlnet": controlnet, "torch_dtype": self.torch_dtype}
if self.hf_token:
load_kwargs["token"] = self.hf_token
load_kwargs = self._base_load_kwargs()
load_kwargs["controlnet"] = controlnet
self._maybe_add_fp16_vae(load_kwargs)
self._set_progress(f"Loading model weights: {self.model_id}")
pipeline = StableDiffusionXLControlNetImg2ImgPipeline.from_pretrained(self.model_id, **load_kwargs)
pipeline = self._load_from_pretrained(
StableDiffusionXLControlNetImg2ImgPipeline, self.model_id, **load_kwargs
)
pipeline = self._move_to_device_and_optimize(pipeline)
with contextlib.suppress(Exception):
pipeline.set_progress_bar_config(disable=True)
@@ -474,6 +564,37 @@ class WatermarkRemover:
return self._controlnet_pipeline
def _load_qwen_pipeline(self) -> Any:
"""Load the Qwen-Image img2img pipeline lazily.
Qwen-Image is its OWN base model (not an SDXL add-on), so it loads
``QWEN_MODEL_ID`` unless the caller passed a custom ``--model``. Needs a
diffusers build that ships ``QwenImageImg2ImgPipeline``; raises a clear error
otherwise. CUDA/cloud-class (the 20B MMDiT does not fit MPS).
"""
if self._qwen_pipeline is None:
try:
from diffusers import QwenImageImg2ImgPipeline
except ImportError as exc:
raise ImportError(
"The 'qwen' pipeline needs a diffusers version that ships "
"QwenImageImg2ImgPipeline. Upgrade: pip install -U diffusers"
) from exc
# Use the Qwen base unless the user explicitly overrode --model.
model = self.model_id if self.model_id != self.DEFAULT_MODEL_ID else QWEN_MODEL_ID
logger.info("Loading Qwen-Image (%s) on %s...", model, self.device)
self._set_progress(f"Loading model weights: {model}")
pipeline = QwenImageImg2ImgPipeline.from_pretrained(model, **self._base_load_kwargs())
pipeline = self._move_to_device_and_optimize(pipeline)
with contextlib.suppress(Exception):
pipeline.set_progress_bar_config(disable=True)
logger.info("Qwen-Image model loaded successfully")
self._qwen_pipeline = pipeline
return self._qwen_pipeline
# ── Core removal ─────────────────────────────────────────────────
def remove_watermark(
@@ -485,6 +606,11 @@ class WatermarkRemover:
guidance_scale: float | None = None,
seed: int | None = None,
vendor: str | None = None,
tile: bool = False,
tile_size: int = 1024,
tile_overlap: int = 128,
region: tuple[int, int, int, int] | None = None,
region_feather: int = 64,
) -> Path:
"""Remove watermark from an image using regeneration attack.
@@ -501,6 +627,22 @@ class WatermarkRemover:
input with ``watermark_profiles.vendor_for_strength`` before processing
strips the metadata; the caller passes it down so display and execution
agree.
tile: Process the image in overlapping tiles instead of one forward pass.
The lossless alternative to a ``--max-resolution`` downscale for large
inputs that OOM on MPS/GPU (issue #10). Only engages when the long side
exceeds ``tile_size``; smaller images run a single pass unchanged.
tile_size: Tile dimension in px (default 1024, SDXL's training size).
tile_overlap: Overlap between adjacent tiles in px (default 128), feather-
blended so there is no visible seam.
region: Restrict the regeneration to the AI-composited box ``(x, y, w, h)``
and feather-composite it back over the ORIGINAL pixels everywhere else.
For AI-ENHANCED composites (digitalSourceType
``compositeWithTrainedAlgorithmicMedia``, surfaced as
``identify.ProvenanceReport.ai_source_kind == "enhanced"``): the real
photo outside the box is preserved exactly, only the AI region is
scrubbed. The box is supplied by the caller (a C2PA composite manifest
does not carry a reliable machine-readable region). None -> whole frame.
region_feather: Seam taper in px for ``region`` compositing (default 64).
Returns:
Path to the cleaned image.
@@ -515,7 +657,7 @@ class WatermarkRemover:
if output_path is None:
output_path = image_path
strength = resolve_strength(strength, vendor)
strength = resolve_strength(strength, vendor, self.model_profile)
if not 0.0 <= strength <= 1.0:
raise ValueError(f"Strength must be between 0.0 and 1.0, got {strength}")
@@ -541,10 +683,21 @@ class WatermarkRemover:
_total_start = time.monotonic()
def _generate() -> Image.Image:
def _generate_one(img: Image.Image) -> Image.Image:
if self.model_profile == "qwen":
return self._run_qwen(img, strength, num_inference_steps, guidance_scale, generator)
if self.model_profile == "controlnet":
return self._run_controlnet(init_image, strength, num_inference_steps, guidance_scale, generator)
return self._run_img2img(init_image, strength, num_inference_steps, guidance_scale, generator)
return self._run_controlnet(img, strength, num_inference_steps, guidance_scale, generator)
return self._run_img2img(img, strength, num_inference_steps, guidance_scale, generator)
def _generate() -> Image.Image:
# Tile only when asked AND the image is larger than one tile; otherwise a
# single full-image pass (tiling a sub-tile image is pure overhead).
if tile and max(init_image.size) > tile_size:
from remove_ai_watermarks.noai.tiling import run_tiled
return run_tiled(_generate_one, init_image, tile_size, tile_overlap, self._set_progress)
return _generate_one(init_image)
cleaned_image = _generate()
@@ -561,6 +714,22 @@ class WatermarkRemover:
self._controlnet_pipeline = None
cleaned_image = _generate()
# Region-targeted regeneration for AI-enhanced composites: keep the real photo
# outside the AI box pixel-exact, blend only the regenerated AI region back in.
if region is not None:
import numpy as np
from remove_ai_watermarks.noai.tiling import feather_region_composite
gen = cleaned_image.convert("RGB")
if gen.size != init_image.size: # a downscaled/tiled pass can resize
gen = gen.resize(init_image.size)
cleaned_image = gen
base_rgb = np.asarray(init_image) # original RGB, untouched outside the box
merged = feather_region_composite(base_rgb, np.asarray(gen), region, feather=region_feather)
cleaned_image = Image.fromarray(merged)
self._set_progress(f"Region-targeted regeneration: AI box {region}, real photo preserved")
self._set_progress(f"Regeneration complete · Output: {w}x{h}px {cleaned_image.mode}")
output_path.parent.mkdir(parents=True, exist_ok=True)
@@ -569,12 +738,24 @@ class WatermarkRemover:
self._set_progress(f"Encoding as JPEG → {output_path.name}...")
else:
self._set_progress(f"Encoding as PNG → {output_path.name}...")
cleaned_image.save(output_path)
# Encode through image_io so the regenerated image gets the same quality-
# preserving write as the visible path (JPEG q100 / 4:4:4, HEIC/AVIF via Pillow)
# instead of PIL's default JPEG quality 75, which would crush the diffusion
# output further for no reason.
import numpy as np
from remove_ai_watermarks import image_io
rgb = np.asarray(cleaned_image.convert("RGB"))[:, :, ::-1] # PIL RGB -> cv2 BGR
if not image_io.imwrite(str(output_path), np.ascontiguousarray(rgb)):
cleaned_image.save(output_path) # fallback for a container image_io/cv2 cannot encode
if output_path.exists():
self._set_progress("Stripping AI metadata from output...")
try:
from remove_ai_watermarks.noai.cleaner import remove_ai_metadata
# The single, robust stripper (byte-level: lossless for JPEG, handles
# every container) -- not the legacy PIL-re-encoding noai.cleaner one.
from remove_ai_watermarks.metadata import remove_ai_metadata
remove_ai_metadata(output_path, output_path, keep_standard=True)
except Exception:
@@ -706,6 +887,30 @@ class WatermarkRemover:
self._controlnet_pipeline = None
return self._load_controlnet_pipeline()
# ── Qwen runner ──────────────────────────────────────────────────
def _run_qwen(
self,
init_image: Image.Image,
strength: float,
num_inference_steps: int,
guidance_scale: float,
generator: Any,
) -> Image.Image:
"""Run the Qwen-Image img2img pass.
Removal comes from the img2img ``strength`` (same lever as the SDXL paths);
Qwen-Image preserves text/structure markedly better at the scrub floor. The
CLI ``guidance_scale`` maps to Qwen's ``true_cfg_scale`` (~4.0 is typical;
the SDXL default of 7.5 is high for Qwen). No MPS->CPU fallback: the 20B MMDiT
is CUDA/cloud-class and does not run on MPS, so an error here propagates.
"""
pipeline = self._load_qwen_pipeline()
self._set_progress(f"Running Qwen-Image img2img (strength={strength}, true_cfg={guidance_scale})...")
kwargs = _build_qwen_kwargs(init_image, strength, num_inference_steps, guidance_scale, generator)
result = pipeline(**kwargs)
return result.images[0]
# ── Batch ────────────────────────────────────────────────────────
def remove_watermark_batch(
@@ -754,12 +959,18 @@ def remove_watermark(
model_id: str | None = None,
device: str | None = None,
hf_token: str | None = None,
region: tuple[int, int, int, int] | None = None,
) -> Path:
"""Convenience function to remove watermark from an image.
``strength=None`` lets the profile pick its vendor-adaptive default
(0.20 OpenAI / 0.30 Google / 0.30 unknown, from the C2PA SynthID proxy on the
input; same ladder for the controlnet and sdxl pipelines). Pass a value to override.
(0.10 OpenAI / 0.15 Google / 0.15 unknown, from the C2PA SynthID proxy on the
input; same ladder for the controlnet and sdxl pipelines -- the single source of
truth is ``watermark_profiles.py``). Pass a value to override.
``region=(x, y, w, h)`` restricts the regeneration to that box and preserves the
real photo elsewhere -- for AI-enhanced composites (see
``WatermarkRemover.remove_watermark``).
"""
from remove_ai_watermarks.noai.watermark_profiles import vendor_for_strength
@@ -769,4 +980,5 @@ def remove_watermark(
output_path=output_path,
strength=strength,
vendor=vendor_for_strength(image_path),
region=region,
)
+171
View File
@@ -0,0 +1,171 @@
"""Jimeng-basic 'AI生成' pill: a CAPTURE-LESS visible mark (issue #54).
The Jimeng free-tier TC260 label is a rounded pill with 'AI生成' in the TOP-LEFT
corner -- distinct from the ``jimeng`` "★ 即梦AI" wordmark (bottom-right). It has no
captured alpha map, so unlike the other marks it is detected purely by a synthetic
silhouette; like every mark it is then removed by the shared localize -> fill:
* Detect: edge-NCC of a font-rendered SILHOUETTE (``assets/jimeng_pill.png``,
synthetic, data-safe -- see ``scripts/render_pill_silhouette.py``) against the
top-left ROI, at the pill's known width fraction. Corpus-calibrated threshold
(61 real positives + jimeng negatives): ``_DETECT_THRESHOLD`` 0.22.
* Remove: place the pill footprint at the matched location and inpaint it
(MI-GAN / cv2 via the registry). Quality comes from the inpaint backend, so the
silhouette need not be pixel-accurate -- which is why a synthetic render is
sufficient and no corpus-derived asset is committed.
Geometry measured on 51 real examples (8 resolutions, all 3:4): width ~0.161*W,
height ~0.091*W, top-left, margins ~0.02-0.05.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any, NamedTuple
import cv2
import numpy as np
from remove_ai_watermarks import image_io
if TYPE_CHECKING:
from numpy.typing import NDArray
# cv2/numpy boundary: cv2 ships no usable type info, so strict pyright cannot know
# its array element types. Relax the unknown-type rules for this file only; the
# public signatures are still annotated with NDArray[Any].
# 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, reportOptionalSubscript=false, reportAttributeAccessIssue=false, reportUnnecessaryComparison=false
_ASSET = Path(__file__).parent / "assets" / "jimeng_pill.png"
# Geometry (fractions of image WIDTH unless noted); top-left corner.
_WIDTH_FRAC = 0.161
_ROI_W_FRAC = 0.34 # search window width (of W)
_ROI_H_FRAC = 0.14 # search window height (of H)
_DETECT_THRESHOLD = 0.22 # edge-NCC gate, corpus-calibrated
# Inpaint mask GEOMETRY (fractions of W unless noted): a generous fixed top-left box
# covering the pill (measured ~0.167*W wide, ~0.09*W tall, margin ~0.02-0.05) plus
# margin. The mask uses stable geometry, NOT the NCC match position -- the synthetic
# silhouette localizes only approximately, and the corner is negative space, so
# over-covering is harmless while a match-positioned box leaves outline residue.
_MASK_X0, _MASK_Y0 = 0.012, 0.006 # x0 of W, y0 of H
_MASK_W, _MASK_H = 0.205, 0.115 # width of W, height of W
# Background-flatness gate for the metadata-only pill arm (see remove_auto_marks).
# The pill detector is weak (~7% raw false-fire); metadata confirms the platform,
# not pill presence, so its false fires are real Jimeng-class content WITHOUT a pill.
# Those false fires cluster on TEXTURED top-left corners (ceiling fixtures, structure)
# where inpaint visibly SMEARS, while real pills and harmless false fires sit on FLAT
# corners (sky / wall / solid) where inpaint is invisible. So the metadata-only arm
# removes the pill only when the footprint background is flat enough for a safe,
# invisible inpaint. Threshold = median Sobel magnitude over the footprint box at a
# normalized width; corpus-validated on 32k real uploads 2026-07 (real pills median
# ~3.2, textured-ceiling false fires median ~8+). The reliable bottom-right wordmark
# arm is NOT texture-gated -- a wordmark-confirmed pill is removed regardless.
_FLAT_TEXTURE_MAX = 6.0
_silhouette: NDArray[Any] | None = None
class PillDetection(NamedTuple):
detected: bool
confidence: float
region: tuple[int, int, int, int] # x, y, w, h of the matched pill
def _load_silhouette() -> NDArray[Any] | None:
global _silhouette
if _silhouette is None:
if not _ASSET.exists():
return None
_silhouette = image_io.imread(str(_ASSET), cv2.IMREAD_GRAYSCALE)
return _silhouette
def _grad(gray: NDArray[Any]) -> NDArray[Any]:
gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
return cv2.normalize(cv2.magnitude(gx, gy), None, 0, 255, cv2.NORM_MINMAX)
class PillEngine:
"""Detect + build the removal mask for the top-left 'AI生成' pill (edge-NCC of a synthetic silhouette)."""
def _match(self, image: NDArray[Any]) -> tuple[float, tuple[int, int, int, int]] | None:
sil = _load_silhouette()
if sil is None or image is None or image.size == 0:
return None
h, w = image.shape[:2]
if h < 64 or w < 64:
return None
gray = cv2.cvtColor(image_io.to_bgr(image), cv2.COLOR_BGR2GRAY)
rh, rw = int(h * _ROI_H_FRAC), int(w * _ROI_W_FRAC)
roi = gray[0:rh, 0:rw]
tw = max(24, int(_WIDTH_FRAC * w))
th = max(12, int(tw * sil.shape[0] / sil.shape[1]))
if th >= rh or tw >= rw:
return None
tmpl = cv2.resize(sil, (tw, th))
res = cv2.matchTemplate(_grad(roi.astype(np.float32)), _grad(tmpl.astype(np.float32)), cv2.TM_CCOEFF_NORMED)
_, score, _, loc = cv2.minMaxLoc(res)
return float(score), (int(loc[0]), int(loc[1]), tw, th)
def detect(self, image: NDArray[Any]) -> PillDetection:
m = self._match(image)
if m is None:
return PillDetection(False, 0.0, (0, 0, 0, 0))
score, box = m
return PillDetection(score >= _DETECT_THRESHOLD, score, box)
def _footprint_box(self, image: NDArray[Any]) -> tuple[int, int, int, int] | None:
h, w = image.shape[:2]
x0, y0 = int(_MASK_X0 * w), int(_MASK_Y0 * h)
x1, y1 = min(w, x0 + int(_MASK_W * w)), min(h, y0 + int(_MASK_H * w))
if x1 <= x0 or y1 <= y0:
return None
return x0, y0, x1, y1
def footprint_texture(self, image: NDArray[Any]) -> float:
"""Median gradient magnitude over the fixed top-left footprint box at a
normalized width. A robust flatness proxy: low = flat (sky / wall / solid,
inpaint invisible), high = textured (ceiling fixtures / structure, inpaint
smears). Median (not mean) so the pill's own edges -- a minority of the box --
do not inflate it. Backs the metadata-only arm's safe-inpaint gate."""
if image is None or image.size == 0:
return 0.0
box = self._footprint_box(image)
if box is None:
return 0.0
x0, y0, x1, y1 = box
crop = image[y0:y1, x0:x1]
gray = cv2.cvtColor(image_io.to_bgr(crop), cv2.COLOR_BGR2GRAY)
tw = 220
gray = cv2.resize(gray, (tw, max(1, int(gray.shape[0] * tw / gray.shape[1])))).astype(np.float32)
gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
return float(np.median(cv2.magnitude(gx, gy)))
def footprint_is_flat(self, image: NDArray[Any], *, thresh: float = _FLAT_TEXTURE_MAX) -> bool:
"""True when the top-left footprint is flat enough for an invisible inpaint."""
return self.footprint_texture(image) <= thresh
def footprint_mask(self, image: NDArray[Any], *, force: bool = False) -> NDArray[Any] | None:
"""Full-frame uint8 mask (255 = pill) over the pill's known top-left region.
Uses stable GEOMETRY (a generous fixed box), not the NCC match position: the
synthetic silhouette localizes only approximately, so a match-positioned mask
leaves outline residue, while the top-left corner is negative space, so a
generous geometric box removes the pill cleanly and harmlessly. The caller
gates on :meth:`detect`, so a clean corner is never masked. ``force`` is
accepted for a uniform engine signature but ignored (the geometry box is
fixed regardless)."""
if image is None or image.size == 0:
return None
box = self._footprint_box(image)
if box is None:
return None
x0, y0, x1, y1 = box
h, w = image.shape[:2]
mask = np.zeros((h, w), np.uint8)
mask[y0:y1, x0:x1] = 255
return mask
+137 -14
View File
@@ -2,17 +2,25 @@
Position- and content-agnostic. You supply the rectangle(s); the eraser inpaints
whatever is inside, so it removes any visible logo / watermark / object regardless
of colour, style, or location. Localisation is the user's responsibility (pass the
of color, style, or location. Localization is the user's responsibility (pass the
box); restoration runs on CPU. This is the universal fallback for marks the
deterministic per-generator engines (Gemini sparkle, Doubao) do not cover.
Backends:
- ``cv2`` (default): ``cv2.inpaint`` (Telea / Navier-Stokes). Instant, no extra
dependencies, lower quality on large or textured regions.
- ``migan`` (optional, extra ``migan``): MI-GAN via onnxruntime
(``andraniksargsyan/migan``, MIT). CPU, ~28 MB model, ~0.19 s/call -- the
droplet-friendly tier: near-big-LaMa quality on small marks. Model downloaded
on first use. Like ``lama`` it crops a padded region around the mask before
inference (at native resolution -- MI-GAN takes arbitrary dims), so peak RAM is
bounded by the mark size (~0.6-0.9 GB) rather than scaling with the image, which
is what lets a memory-tight host run it on a large upload.
- ``lama`` (optional, extra ``lama``): big-LaMa via onnxruntime
(``Carve/LaMa-ONNX``, Apache-2.0). CPU, resolution-robust, much better on
texture. The model (~200 MB) is downloaded on first use and cached by
huggingface_hub; it is never bundled in this repo.
(``Carve/LaMa-ONNX``, Apache-2.0). CPU, resolution-robust, best quality on
texture but ~200 MB model and ~4.7 GB peak RAM (too heavy for a small host).
The model is downloaded on first use and cached by huggingface_hub; it is
never bundled in this repo.
"""
# cv2/numpy boundary: cv2 ships no usable type info, so strict pyright cannot know
@@ -32,13 +40,17 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
Backend = Literal["cv2", "lama"]
Backend = Literal["cv2", "lama", "migan"]
_LAMA_REPO = "Carve/LaMa-ONNX"
_LAMA_FILE = "lama_fp32.onnx"
# Cached onnxruntime session (loading is expensive; reuse across calls).
_MIGAN_REPO = "andraniksargsyan/migan"
_MIGAN_FILE = "migan.onnx"
# Cached onnxruntime sessions (loading is expensive; reuse across calls).
_lama_session: object | None = None
_migan_session: object | None = None
def boxes_to_mask(
@@ -60,6 +72,26 @@ def boxes_to_mask(
return mask
def _padded_crop_box(
mask: NDArray[Any], h: int, w: int, *, pad_frac: float, pad_min: int
) -> tuple[int, int, int, int] | None:
"""Bounding box of the set mask pixels, padded and clamped to the image.
Returns ``(x0, y0, x1, y1)`` or ``None`` when the mask is empty. Both learned
backends crop to this box so the ONNX working set is bounded by the mark size
rather than the whole image; ``pad_frac``/``pad_min`` tune how much surrounding
context the inpainter sees (LaMa then resizes the crop to its fixed square,
MI-GAN feeds it at native resolution).
"""
ys, xs = np.where(mask > 0)
if len(xs) == 0:
return None
pad = max(pad_min, int(pad_frac * max(xs.max() - xs.min() + 1, ys.max() - ys.min() + 1)))
x0, y0 = max(0, int(xs.min()) - pad), max(0, int(ys.min()) - pad)
x1, y1 = min(w, int(xs.max()) + 1 + pad), min(h, int(ys.max()) + 1 + pad)
return x0, y0, x1, y1
def erase_cv2(
image_bgr: NDArray[Any],
mask: NDArray[Any],
@@ -130,19 +162,15 @@ def erase_lama(image_bgr: NDArray[Any], mask: NDArray[Any]) -> NDArray[Any]:
size = next((d for d in reversed(dims) if isinstance(d, int) and d > 1), 512)
h, w = image_bgr.shape[:2]
ys, xs = np.where(mask > 0)
if len(xs) == 0:
box = _padded_crop_box(mask, h, w, pad_frac=0.4, pad_min=16)
if box is None:
return image_bgr.copy()
# Padded crop around the mask (context for the inpainter).
pad = max(16, int(0.4 * max(xs.max() - xs.min() + 1, ys.max() - ys.min() + 1)))
cx0, cy0 = max(0, int(xs.min()) - pad), max(0, int(ys.min()) - pad)
cx1, cy1 = min(w, int(xs.max()) + 1 + pad), min(h, int(ys.max()) + 1 + pad)
cx0, cy0, cx1, cy1 = box
crop = image_bgr[cy0:cy1, cx0:cx1]
crop_mask = mask[cy0:cy1, cx0:cx1]
ch, cw = crop.shape[:2]
# Resize crop + mask to the model size, normalise to [0,1] RGB CHW.
# Resize crop + mask to the model size, normalize to [0,1] RGB CHW.
crop_rs = cv2.resize(crop, (size, size), interpolation=cv2.INTER_AREA)
mask_rs = cv2.resize(crop_mask, (size, size), interpolation=cv2.INTER_NEAREST)
img_in = cv2.cvtColor(crop_rs, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
@@ -167,6 +195,95 @@ def erase_lama(image_bgr: NDArray[Any], mask: NDArray[Any]) -> NDArray[Any]:
return result
def migan_available() -> bool:
"""True when the optional MI-GAN backend can run (onnxruntime installed)."""
from .optional_deps import module_available
return module_available("onnxruntime")
def _get_migan_session() -> object:
"""Load (once) the MI-GAN ONNX session, downloading the model on first use."""
global _migan_session
if _migan_session is not None:
return _migan_session
import onnxruntime as ort
from huggingface_hub import hf_hub_download
model_path = hf_hub_download(repo_id=_MIGAN_REPO, filename=_MIGAN_FILE)
logger.info("Loading MI-GAN ONNX model: %s", model_path)
_migan_session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
return _migan_session
def erase_migan(image_bgr: NDArray[Any], mask: NDArray[Any]) -> NDArray[Any]:
"""Inpaint ``mask`` (255 = erase) with MI-GAN via onnxruntime (CPU).
Like ``erase_lama``, we crop a padded region around the mask, feed only that crop
to the ONNX model, and paste only the masked pixels back -- so untouched areas stay
pixel-exact and the ONNX working set is bounded by the MARK size, not the whole
image. MI-GAN accepts arbitrary spatial dims, so (unlike LaMa's fixed 512 square)
the crop is fed at NATIVE resolution -- no resize, so the mark is seen at full
scale. Feeding the whole frame instead made peak RAM scale with the image
(~0.6 GB at 4 MP up to ~2.4 GB at 25 MP, measured 2026-07); cropping holds the
inpaint working set roughly constant, so a memory-tight host (e.g. a 1-2 GB web
worker) can run MI-GAN on a 25 MP upload. Cropping does not degrade the fill:
a small mark only needs local context, and on real marks the cropped fill is on
par with -- sometimes cleaner than -- the full-frame fill (a tighter view gives
the GAN less room to hallucinate large background structure).
Mask polarity: the shipped ``andraniksargsyan/migan`` ONNX expects 0 = hole
(inpaint) / 255 = known (keep) -- the INVERSE of this package's 255-erase
convention -- so the mask is inverted before feeding the model (corpus-validated
2026-07; feeding 255=hole regenerates the whole frame into stripes).
Accepts 1-channel (grayscale) and 4-channel (BGRA) input.
"""
if image_bgr.ndim == 2:
bgr = erase_migan(cv2.cvtColor(image_bgr, cv2.COLOR_GRAY2BGR), mask)
return cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
if image_bgr.ndim == 3 and image_bgr.shape[2] == 4:
bgr = erase_migan(np.ascontiguousarray(image_bgr[:, :, :3]), mask)
return np.dstack([bgr, image_bgr[:, :, 3]])
h, w = image_bgr.shape[:2]
# 2x the mark size (min 256 px) gives ample local context while a small corner
# mask keeps the crop small regardless of the full image resolution; the crop is
# clamped to the image, so a mark already spanning the frame degrades to the
# whole image (the old behavior) rather than erroring.
box = _padded_crop_box(mask, h, w, pad_frac=2.0, pad_min=256)
if box is None:
return image_bgr.copy()
cx0, cy0, cx1, cy1 = box
crop = np.ascontiguousarray(image_bgr[cy0:cy1, cx0:cx1])
crop_mask = mask[cy0:cy1, cx0:cx1]
ch, cw = crop.shape[:2]
session = _get_migan_session()
inp = session.get_inputs() # type: ignore[attr-defined]
img_name, mask_name = inp[0].name, inp[1].name
rgb = cv2.cvtColor(crop, cv2.COLOR_BGR2RGB)
img_in = np.transpose(rgb, (2, 0, 1))[None].astype(np.uint8) # (1,3,ch,cw)
# invert to MI-GAN polarity: 255 where KNOWN (keep), 0 where hole (erase)
known = (crop_mask <= 127).astype(np.uint8) * 255
mask_in = known[None, None] # (1,1,ch,cw)
out = session.run(None, {img_name: img_in, mask_name: mask_in})[0] # type: ignore[attr-defined]
res = np.transpose(np.asarray(out)[0], (1, 2, 0)).astype(np.uint8) # (ch',cw',3) RGB
if res.shape[:2] != (ch, cw):
res = cv2.resize(res, (cw, ch), interpolation=cv2.INTER_LINEAR)
out_bgr = cv2.cvtColor(res, cv2.COLOR_RGB2BGR)
result = image_bgr.copy()
region = result[cy0:cy1, cx0:cx1]
hole = crop_mask > 127
region[hole] = out_bgr[hole]
result[cy0:cy1, cx0:cx1] = region
return result
def erase(
image_bgr: NDArray[Any],
*,
@@ -191,6 +308,12 @@ def erase(
if not mask.any():
return image_bgr.copy()
if backend == "migan":
if not migan_available():
raise RuntimeError(
"MI-GAN backend requires onnxruntime. Install the extra: pip install 'remove-ai-watermarks[migan]'"
)
return erase_migan(image_bgr, mask)
if backend == "lama":
if not lama_available():
raise RuntimeError(
+20 -32
View File
@@ -1,19 +1,22 @@
"""Samsung Galaxy AI visible watermark removal engine.
"""Samsung Galaxy AI visible watermark detector/localizer.
Samsung's on-device Generative AI photo edits burn a visible "✦ Contenuti generati
dall'AI" wordmark into the bottom-LEFT corner (the Italian locale variant calibrated
here; the string is locale-specific). It is a faint, near-white semi-transparent
overlay, the same overlay class as the Doubao/Jimeng marks but bottom-left.
here; the string is locale-specific -- DETECTION only matches this locale's silhouette,
so other locales are not yet detected, though the fill mask itself is locale-agnostic).
It is a faint, near-white semi-transparent overlay, the same overlay class as the
Doubao/Jimeng marks but bottom-left.
Removal is **reverse-alpha blending** against a captured alpha map
(``original = (wm - a*logo)/(1-a)``), always NCC-aligned to the actual mark plus a thin
residual inpaint over the glyph footprint. This is one of the three text-mark engines
that share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module
Detection matches the bundled glyph silhouette against the corner; removal is the
shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds
``region_eraser``), NOT reverse-alpha. This is one of the three text-mark engines that
share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module
supplies only Samsung's tuned :class:`TextMarkConfig` (bottom-LEFT corner, a lower glyph
luma since the mark is faint, ``assets/samsung_alpha.png`` solved from the flat captures
by ``scripts/visible_alpha_solve.py``). Samsung Galaxy AI edits are also caught by C2PA
+ the ``genAIType`` marker, so this is the visible-mark *removal* path; it also feeds
``identify`` as the medium-confidence ``visible_samsung`` signal via the registry.
luma since the mark is faint, ``assets/samsung_alpha.png`` -- the detection silhouette,
solved from the flat captures by ``scripts/visible_alpha_solve.py``). Samsung Galaxy AI
edits are also caught by C2PA + the ``genAIType`` marker, so this is the visible-mark
*removal* path; it also feeds ``identify`` as the medium-confidence ``visible_samsung``
signal via the registry.
"""
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
@@ -48,21 +51,13 @@ TOPHAT_DELTA = 8
DETECT_MIN_COVERAGE = 0.01
DETECT_NCC_THRESHOLD = 0.40
# Reverse-alpha geometry, solved by scripts/visible_alpha_solve.py from the flat gray
# capture (native width 1086). Real photos are ~2958 wide, so the captured glyph is
# upscaled; width-scale + NCC-align removes it cleanly (a flat capture at the real
# resolution would make the alpha pixel-sharp -- an open quality upgrade).
# Detection-silhouette geometry, solved by scripts/visible_alpha_solve.py from the flat
# gray capture (native width 1086). Real photos are ~2958 wide, so the captured glyph is
# upscaled; width-scale + NCC-align sizes the silhouette for the detection match (removal
# is the template-free glyph-bbox footprint mask).
_ALPHA_NATIVE_WIDTH = 1086
_ALPHA_LOGO_BGR: tuple[float, float, float] = (255.0, 255.0, 255.0)
_ALPHA_WIDTH_FRAC = 0.3195 # asset width / image width -- the alignment scale seed
_ALPHA_WIDTH_FRAC = 0.3195 # asset width / image width -- sizes the detection silhouette
_ALPHA_HEIGHT_FRAC = 0.0378
_ALPHA_MARGIN_LEFT_FRAC = 0.0110
_ALPHA_MARGIN_BOTTOM_FRAC = 0.0064
# Wider scale search: the flat capture is far off the real-photo width.
_ALPHA_ALIGN_SEARCH = (0.85, 1.18, 23)
_RESIDUAL_ALPHA_FLOOR = 0.05
_RESIDUAL_DILATE = 5
_RESIDUAL_INPAINT_RADIUS = 2
_CONFIG = TextMarkConfig(
name="Samsung Galaxy AI",
@@ -81,14 +76,7 @@ _CONFIG = TextMarkConfig(
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
alpha_width_frac=_ALPHA_WIDTH_FRAC,
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
alpha_margin_x_frac=_ALPHA_MARGIN_LEFT_FRAC,
alpha_margin_bottom_frac=_ALPHA_MARGIN_BOTTOM_FRAC,
alpha_align_search=_ALPHA_ALIGN_SEARCH,
min_gw=16,
alpha_logo_bgr=_ALPHA_LOGO_BGR,
residual_alpha_floor=_RESIDUAL_ALPHA_FLOOR,
residual_dilate=_RESIDUAL_DILATE,
residual_inpaint_radius=_RESIDUAL_INPAINT_RADIUS,
)
SamsungDetection = TextMarkDetection
@@ -110,7 +98,7 @@ def _template_match_score(box_mask: NDArray[Any], image_width: int) -> float:
class SamsungEngine(TextMarkEngine):
"""Remove the visible Samsung Galaxy AI text mark (locate -> mask -> reverse-alpha)."""
"""Detect/localize the visible Samsung Galaxy AI text mark (locate -> mask; mask feeds the fill)."""
def __init__(self) -> None:
super().__init__(_CONFIG)
@@ -28,7 +28,7 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pathlib import Path
log = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Adobe ships Variant P in production (com.adobe.trustmark.P).
_MODEL_TYPE = "P"
@@ -94,10 +94,10 @@ def detect_trustmark(image_path: Path) -> str | None:
if not wm_present:
return None
if not _survives_reencode(decoder, cover, wm_schema):
log.debug("TrustMark decode for %s did not survive re-encode; treating as false positive", image_path)
logger.debug("TrustMark decode for %s did not survive re-encode; treating as false positive", image_path)
return None
except Exception as exc: # model download / decode failure / unreadable image
log.debug("TrustMark decode failed for %s: %s", image_path, exc)
logger.debug("TrustMark decode failed for %s: %s", image_path, exc)
return None
return f"Adobe TrustMark (variant {_MODEL_TYPE}, schema {wm_schema})"
+447 -99
View File
@@ -5,29 +5,28 @@ A single catalog that ties each known visible mark to (a) where it usually sits,
registry detects every known mark in its usual place and removes the ones
present.
**Reverse-alpha based.** A known mark is a fixed semi-transparent overlay, so it
is removed by inverting the alpha blend against a captured alpha map
(``original = (wm - a*logo)/(1-a)``) -- recovering the true pixels rather than
inpainting a guess. Gemini and Doubao recover exactly with no inpaint at native on
bright/flat backgrounds (Gemini falls back to inpainting the sparkle footprint when
reverse-alpha would over-subtract on a dark background -- issue #30, see gemini_engine);
Jimeng adds a thin residual inpaint over the glyph footprint to clear the outline
its per-image render variation leaves behind (still seeded by the reverse-alpha
recovery, not a blind inpaint). Detection is consistent with that: each mark is
recognized by matching its known shape/template (the thing we invert), not by
heuristics. A mark is therefore listed here only once a real alpha map has been
captured for it; everything else (arbitrary logos/objects) is the user-directed
``erase --region`` tool, not this catalog.
**Localize -> fill.** A known mark is removed by LOCALIZING it (a template-free,
version-robust detector that returns a binary footprint MASK) and then handing
that mask to ONE shared, swappable fill backend (``region_eraser``: cv2 Telea/NS,
MI-GAN, or big-LaMa). No mark carries a reverse-alpha step any more: the old
``original = (wm - a*logo)/(1-a)`` recovery depended on a fixed captured alpha map
at a fixed position, broke whenever a vendor re-rendered or moved its mark, and was
not color-lossless even with the right map (it amplifies quantization/JPEG-chroma
error by ``1/(1-a)`` -- the "the color just changed, not removed" reports). The
localizer stays cheap (cv2/numpy, CPU) so a memory-tight caller can run it on a
small worker; the heavy fill (MI-GAN / LaMa) is opt-in and chosen by the caller.
Entries:
- ``gemini`` -- Google Gemini / Nano Banana sparkle, bottom-right.
- ``doubao`` -- ByteDance Doubao "豆包AI生成" text strip, bottom-right.
- ``jimeng`` -- ByteDance Jimeng / Dreamina "★ 即梦AI" wordmark, bottom-right.
- ``samsung`` -- Samsung Galaxy AI "Contenuti generati dall'AI" strip, bottom-left.
- ``jimeng_pill`` -- Jimeng-basic "AI生成" pill, top-left (capture-less).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal
@@ -36,11 +35,44 @@ if TYPE_CHECKING:
from numpy.typing import NDArray
# cv2 method for the Gemini reverse-alpha edge-residual cleanup (not a standalone
# remover): "ns" / "telea".
InpaintMethod = Literal["telea", "ns"]
logger = logging.getLogger(__name__)
Region = tuple[int, int, int, int]
# Fill backend for the shared removal path. ``auto`` resolves best-first to the highest
# quality installed model -- LaMa, else MI-GAN, else cv2 (see ``resolve_backend``); the
# others force a specific backend (mirrors the ``erase`` command's ``--backend``).
Backend = Literal["auto", "cv2", "migan", "lama"]
# Detection sensitivity for the removal path -- how much to trust a borderline mark.
# * ``strict``: high-precision visual gate only; never relaxed, so a clean image is
# never touched (the gate demotes a sparkle-shaped content match, so it never fills
# a clean corner). Lowest recall on faint/moved marks.
# * ``auto`` (default): relax a mark's gate ONLY when the image carries same-product
# evidence the mark is there -- metadata provenance for that vendor, or a confidently
# detected sibling mark of the same product (see ``resolve_relax``). No evidence ->
# stays strict. Safe: it only escalates where the mark is corroborated.
# * ``assume_ai``: relax every mark's gate regardless of evidence -- the caller asserts
# the image is AI and wants the mark gone (e.g. a metadata-stripped screenshot uploaded
# to a watermark remover). Recovers the faint/moved marks the strict gate demotes
# (~49% -> ~89% Gemini recall, corpus-measured), at the cost of a harmless small fill
# on some clean corners. The library CANNOT infer this from a stripped image -- only the
# caller's out-of-band context (the user uploaded to remove a mark) justifies it.
Sensitivity = Literal["auto", "strict", "assume_ai"]
# Product family per mark, for the ``auto`` cross-mark corroboration: a confidently
# detected mark relaxes only OTHER marks of the SAME product (different corners, one
# product -- the Jimeng wordmark + the Jimeng pill). Doubao and Jimeng are BOTH ByteDance
# but distinct products in the SAME bottom-right corner, so they must NOT cross-relax
# (relaxing Doubao on a Jimeng wordmark would spuriously fire Doubao on it).
_PRODUCT_OF: dict[str, str] = {
"gemini": "gemini",
"doubao": "doubao",
"jimeng": "jimeng",
"jimeng_pill": "jimeng", # same product as the Jimeng wordmark
"samsung": "samsung",
}
@dataclass(frozen=True)
class MarkDetection:
@@ -54,49 +86,159 @@ class MarkDetection:
region: Region
@dataclass(frozen=True)
class Localization:
"""A located mark: its detection verdict plus the full-frame removal mask.
``mask`` is a full-frame uint8 array (255 = remove) sized to the image, or None
when nothing should be removed (no detection and not forced, or the footprint
could not be placed). ``region`` is the mark's bbox (for logging / residual
positioning)."""
detected: bool
confidence: float
region: Region
mask: NDArray[Any] | None
@dataclass(frozen=True)
class Context:
"""The evidence + policy the removal arbiter decides against (perception is
kept separate from this decision). ``sensitivity`` is the caller's intent
(see :data:`Sensitivity`); ``provenance`` is the vendor keys local metadata
confirms, the evidence that drives ``auto``. Bundling them into one object is
why the arbiter can be a pure function of ``(candidates, context)``."""
sensitivity: Sensitivity = "auto"
provenance: frozenset[str] = frozenset()
@dataclass(frozen=True)
class Candidate:
"""One mark's PERCEPTION output -- what the engine sees, with NO policy applied.
Carries the mark's verdict at BOTH trust levels (``detected_strict`` = the
conservative gate, ``detected_relaxed`` = the gate the engine relaxes to under
provenance/assume), so the arbiter can pick per mark without re-running detection.
``features`` is a generic bag of physical measurements a mark's gate may need (the
mark owns which it reports via ``KnownMark._features``); e.g. the pill supplies
``footprint_flat`` (0/1). Empty for marks whose gate needs no extra evidence."""
key: str
label: str
detected_strict: bool
detected_relaxed: bool
features: dict[str, float] # generic; both construction sites always supply it (empty when none)
@dataclass(frozen=True)
class Decision:
"""The arbiter's verdict for one fired mark: remove it, at the resolved trust
level (``relax`` feeds the mark's mask build so the fill footprint matches the
level the mark was accepted at)."""
candidate: Candidate
relax: bool
@dataclass(frozen=True)
class KnownMark:
"""A known visible watermark: where it lives, how to find and remove it."""
"""A known visible watermark: where it lives, how to find and mask it.
Removal is uniform (:meth:`remove`): localize the mark to a mask, then fill that
mask with the chosen backend. Each mark supplies two cheap cv2/numpy callables --
``_detect`` (verdict + bbox, no mask; used by the identify scan) and ``_mask``
(the full-frame footprint mask; used by removal)."""
key: str
label: str
location: str # usual place, human-readable ("bottom-right")
in_auto: bool # participate in `--mark auto` scanning
recovery: str # removal strategy (all reverse-alpha today)
_detect: Callable[[NDArray[Any]], MarkDetection]
_remove: Callable[..., tuple[NDArray[Any], Region | None]]
_detect: Callable[..., MarkDetection]
_mask: Callable[..., NDArray[Any] | None]
# Optional physical-feature probe: the mark's OWN measurements its gate needs
# (e.g. the pill's footprint flatness), so the perception pass stays uniform and
# does not special-case any mark. None = the mark's gate needs no extra evidence.
_features: Callable[..., dict[str, float]] | None = None
def detect(self, image: NDArray[Any]) -> MarkDetection:
return self._detect(image)
def features(self, image: NDArray[Any]) -> dict[str, float]:
"""Physical features the mark reports for the arbiter's gate (empty if none)."""
return self._features(image) if self._features is not None else {}
def detect(self, image: NDArray[Any], *, provenance: bool = False) -> MarkDetection:
"""Detect the mark (verdict + bbox, no mask). ``provenance`` signals that
external metadata already confirms this vendor, so the engine may relax its
trust threshold (a mark it would otherwise demote as a content false positive
is trusted when provenance says the vendor is present)."""
return self._detect(image, provenance=provenance)
def localize(self, image: NDArray[Any], *, provenance: bool = False, force: bool = False) -> Localization:
"""Detect and build the removal mask in one call. Returns a
:class:`Localization`; ``mask`` is None unless the mark is detected (or
``force`` bypasses detection for the mark's usual footprint)."""
det = self.detect(image, provenance=provenance)
if not (det.detected or force):
return Localization(det.detected, det.confidence, det.region, None)
# Pass the (provenance-aware) detection to the mask builder so it does NOT
# re-detect at a different trust level -- a relaxed sparkle must not be
# re-demoted into a None mask (reported-removed-but-unchanged).
mask = self._mask(image, force=force, detection=det)
return Localization(det.detected, det.confidence, det.region, mask)
def remove(
self,
image: NDArray[Any],
*,
inpaint_method: InpaintMethod = "ns",
inpaint: bool = True,
inpaint_strength: float = 0.85,
backend: Backend = "auto",
provenance: bool = False,
force: bool = False,
) -> tuple[NDArray[Any], Region | None]:
"""Remove this mark by reverse-alpha; returns ``(result, region)`` where
``region`` is the removed mark's bbox (for residual-inpaint positioning),
or None if nothing was removed. NB: the CLI does NOT use ``region`` to
clear alpha on save -- that zeroing caused the issue-#30 white box.
"""Remove this mark by localize -> fill; returns ``(result, region)`` where
``region`` is the removed mark's bbox, or None if nothing was removed.
``inpaint`` / ``inpaint_strength`` / ``inpaint_method`` tune the Gemini
reverse-alpha edge-residual cleanup only. ``force`` removes at the mark's
usual location even without a positive detection (the ``--no-detect`` path).
"""
return self._remove(image, inpaint_method, inpaint, inpaint_strength, force)
``backend`` picks the fill (``auto`` = LaMa > MI-GAN > cv2, best available; or force
``cv2``/``migan``/``lama``). ``provenance`` relaxes the detector's trust gate
when metadata already confirms the vendor. ``force`` removes at the mark's
usual footprint even without a positive detection (the ``--no-detect`` path).
NB: the CLI does NOT use ``region`` to clear alpha on save -- that zeroing
caused the issue-#30 white box."""
loc = self.localize(image, provenance=provenance, force=force)
if loc.mask is None or not loc.mask.any():
return image.copy(), None
return fill(image, loc.mask, backend=backend), (loc.region if loc.detected else None)
# Gemini-sparkle confidence above which the registry treats it as a confident
# detection for arbitration. Matches identify's corpus-validated sparkle
# threshold (0.5): the gemini engine's own detect flag uses a looser internal
# threshold and weakly fires (~0.36) on unrelated bottom-right text (e.g. the
# Doubao mark), which would otherwise let it hijack `--mark auto`. 0.5 gives 0
# false positives on the corpus.
_GEMINI_AUTO_MIN_CONF = 0.5
# Single source of truth for the Gemini-sparkle "trust this as a real mark"
# confidence, shared by BOTH the removal arbitration here (`_gemini_detect`) and
# the provenance detector in `identify` (which imports it as its sparkle threshold).
# Defining it once removes the detect-vs-remove
# threshold drift the retained-corpus mining surfaced (2026-06-20): identify
# would report a sparkle while removal declined it, or vice versa, whenever the
# two independently-maintained 0.5 constants fell out of step. Now they cannot.
#
# Value 0.5 is corpus-validated: the gemini engine's own `detected` flag uses a
# looser internal threshold (0.35) and weakly fires (~0.36-0.42) on unrelated
# bottom-right text -- a real Doubao mark scores ~0.40-0.42 as a gemini match,
# and its core-ring brightness margin is HIGHER than a genuine faint sparkle's,
# so neither confidence nor the brightness gate separates them in the [0.35, 0.5)
# band. Lowering this gate to recover faint sparkles was evaluated against that
# band (2026-06-20) and REJECTED for the no-provenance case: it cannot be done
# without re-admitting the Doubao-text / content false positives. The band below
# the gate is therefore left to the metadata-confirmed path below.
GEMINI_SPARKLE_TRUST_CONF = 0.5
_GEMINI_AUTO_MIN_CONF = GEMINI_SPARKLE_TRUST_CONF
# Provenance-confirmed Gemini trust gate. When external metadata already proves the
# image is a Google generation (C2PA issuer "Google"/"Gemini"), the [0.35, 0.5)
# band that the no-provenance gate leaves out is no longer ambiguous with Doubao
# text: a Doubao image carries ByteDance provenance, not Google, so it never reaches
# this relaxed gate. The vendor moving/re-rendering the sparkle (bigger, lighter,
# shifted north-west) drops a real sparkle into this band, and the fixed-slot
# detector demotes it -- provenance is exactly the extra evidence that lets us trust
# it. Set to the engine's own internal `detected` floor (0.35); combined with the
# engine's FP-gate being skipped under provenance (see gemini_engine), this recovers
# the moved-mark misses without touching the no-provenance precision.
_GEMINI_PROVENANCE_MIN_CONF = 0.35
# ── Engine adapters (lazy singletons; engines are cv2-only, no model load) ──
@@ -121,83 +263,153 @@ def _engine(key: str) -> Any:
from remove_ai_watermarks.samsung_engine import SamsungEngine
_engines[key] = SamsungEngine()
elif key == "jimeng_pill":
from remove_ai_watermarks.pill_engine import PillEngine
_engines[key] = PillEngine()
else: # pragma: no cover - guarded by the registry keys
raise KeyError(key)
return _engines[key]
def _gemini_detect(image: NDArray[Any]) -> MarkDetection:
d = _engine("gemini").detect_watermark(image)
detected = bool(d.detected) and d.confidence >= _GEMINI_AUTO_MIN_CONF
def inpaint_model_available() -> bool:
"""True when any ONNX inpaint-model backend (MI-GAN or big-LaMa) can run."""
from remove_ai_watermarks import region_eraser
return region_eraser.migan_available() or region_eraser.lama_available()
_warned_cv2_fallback = False
def preferred_inpaint_backend() -> Literal["lama", "migan", "cv2"]:
"""Backend the ``auto`` fill resolves to, best-first: LaMa > MI-GAN > cv2.
LaMa is the highest-quality inpaint (it recovers the textured/structured backgrounds
the classical fill smears), so ``auto`` prefers it whenever a learned backend can run
(onnxruntime present). MI-GAN is the lighter learned model; both currently share the
SAME onnxruntime availability check, so ``auto`` cannot tell them apart and always
prefers the better one -- a memory-tight deployment that cannot afford LaMa's ~4.7 GB
peak pins MI-GAN explicitly via ``--backend migan`` / ``backend="migan"`` (that is the
deployment's call, not the library's). cv2 is the classical no-deps floor and the last
resort: it smears textured/structured backgrounds, so a one-time quality warning fires
when ``auto`` falls back to it."""
from remove_ai_watermarks import region_eraser
if region_eraser.lama_available():
return "lama"
if region_eraser.migan_available():
return "migan"
global _warned_cv2_fallback
if not _warned_cv2_fallback:
_warned_cv2_fallback = True
logger.warning(
"No learned-inpaint backend available (onnxruntime not installed); falling back "
"to the cv2 classical inpaint, which can smear textured or structured backgrounds. "
"Install the 'lama' (best) or 'migan' (lighter) extra for higher-quality fills."
)
return "cv2"
def resolve_backend(backend: Backend) -> Literal["cv2", "migan", "lama"]:
"""Resolve ``auto`` to the preferred installed backend; pass the rest through."""
if backend == "auto":
return preferred_inpaint_backend()
return backend
def fill(image: NDArray[Any], mask: NDArray[Any], *, backend: Backend = "auto") -> NDArray[Any]:
"""The ONE shared, mark-agnostic removal: erase ``mask`` (255 = remove) via the
chosen inpaint backend. Delegates to :func:`region_eraser.erase`; ``auto``
resolves to MI-GAN when installed else cv2 (see :func:`resolve_backend`)."""
from remove_ai_watermarks import region_eraser
return region_eraser.erase(image, mask=mask, backend=resolve_backend(backend))
# ── Detection adapters (verdict + bbox; no mask work on this path) ──
# The identify scan calls `detect_marks`, which must stay cheap (it runs every
# detector on the memory-tight identify host), so detection never builds a mask.
def _gemini_detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetection:
d = _engine("gemini").detect_watermark(image, trust_provenance=provenance)
gate = _GEMINI_PROVENANCE_MIN_CONF if provenance else _GEMINI_AUTO_MIN_CONF
detected = bool(d.detected) and d.confidence >= gate
return MarkDetection("gemini", "Google Gemini sparkle", "bottom-right", detected, d.confidence, d.region)
def _gemini_remove(
image: NDArray[Any], inpaint_method: InpaintMethod, inpaint: bool, strength: float, force: bool
) -> tuple[NDArray[Any], Region | None]:
engine = _engine("gemini")
det = engine.detect_watermark(image)
if not det.detected:
if not force:
return image.copy(), None
# Forced (--no-detect): remove at the default sparkle slot for the size.
from remove_ai_watermarks.gemini_engine import get_watermark_config
h, w = image.shape[:2]
cfg = get_watermark_config(w, h)
px, py = cfg.get_position(w, h)
region = (px, py, cfg.logo_size, cfg.logo_size)
result = engine.remove_watermark_custom(image, region)
if inpaint:
result = engine.inpaint_residual(result, region, strength=strength, method=inpaint_method)
return result, region
result = engine.remove_watermark(image)
# Reverse-alpha leaves a faint residual at the sparkle edge; the engine's
# own residual inpaint cleans that seam (part of its reverse-alpha pipeline).
if inpaint:
result = engine.inpaint_residual(result, det.region, strength=strength, method=inpaint_method)
return result, det.region
def _gemini_mask(
image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None
) -> NDArray[Any] | None:
# Reuse the decision's provenance-aware region (skip the strict re-detect that would
# otherwise re-demote a relaxed sparkle to None); None region -> footprint_mask
# falls back to its own detect-then-force path (direct/--no-detect callers).
region = detection.region if (detection is not None and detection.detected) else None
return _engine("gemini").footprint_mask(image, force=force, region=region)
# The three text-mark engines (Doubao/Jimeng/Samsung) share the TextMarkEngine
# interface, so one parameterized adapter pair drives all of them -- a new
# reverse-alpha text mark is one `_text_mark(...)` row below, not another copy-paste
# of these bodies. Removal is reverse-alpha only: applied when the mark is detected
# (or forced) and the alpha asset loads, otherwise skipped (no hallucination on a
# clean corner).
def _text_mark_detect(key: str, label: str, location: str) -> Callable[[NDArray[Any]], MarkDetection]:
def detect(image: NDArray[Any]) -> MarkDetection:
d = _engine(key).detect(image)
# text mark is one `_text_mark(...)` row below, not another copy-paste of these
# bodies. Detection matches the glyph silhouette; the mask is the template-free
# glyph-bbox footprint (see TextMarkEngine.footprint_mask).
def _text_mark_detect(key: str, label: str, location: str) -> Callable[..., MarkDetection]:
def detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetection:
d = _engine(key).detect(image, provenance=provenance)
return MarkDetection(key, label, location, d.detected, d.confidence, d.region)
return detect
def _text_mark_remove(key: str) -> Callable[..., tuple[NDArray[Any], Region | None]]:
def remove(
image: NDArray[Any], _inpaint_method: InpaintMethod, _inpaint: bool, _strength: float, force: bool
) -> tuple[NDArray[Any], Region | None]:
engine = _engine(key)
det = engine.detect(image)
if (det.detected or force) and engine.reverse_alpha_available(image):
return engine.remove_watermark_reverse_alpha(image), (det.region if det.detected else None)
return image.copy(), None
def _text_mark_mask(key: str) -> Callable[..., NDArray[Any] | None]:
def mask(
image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None
) -> NDArray[Any] | None:
# Text masks rebuild the glyph blob template-free (no trust gate to re-apply), so
# the detection is not needed here; accepted for the uniform _mask signature.
del detection
return _engine(key).footprint_mask(image, force=force)
return remove
return mask
def _text_mark(key: str, label: str, location: str) -> KnownMark:
"""A reverse-alpha text-mark registry row (Doubao/Jimeng/Samsung)."""
return KnownMark(
key, label, location, True, "reverse-alpha", _text_mark_detect(key, label, location), _text_mark_remove(key)
)
"""A text-mark registry row (Doubao/Jimeng/Samsung): glyph-silhouette detect +
template-free glyph-bbox mask."""
return KnownMark(key, label, location, True, _text_mark_detect(key, label, location), _text_mark_mask(key))
# ── Capture-less mark: the Jimeng-basic "AI生成" pill (top-left) ──
# Detection is edge-NCC of a synthetic silhouette; the mask is a fixed top-left
# geometry box (see pill_engine). Removal is the same localize -> fill as the rest.
def _pill_detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetection:
del provenance # the pill detector is provenance-independent; its relaxation lives entirely in _keep_pill
d = _engine("jimeng_pill").detect(image)
return MarkDetection("jimeng_pill", "Jimeng AI生成 pill", "top-left", d.detected, d.confidence, d.region)
def _pill_mask(
image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None
) -> NDArray[Any] | None:
# The pill mask is a fixed top-left geometry box, independent of the detection;
# accepted for the uniform _mask signature.
del detection
return _engine("jimeng_pill").footprint_mask(image, force=force)
def _pill_features(image: NDArray[Any]) -> dict[str, float]:
"""The pill's own gate feature: top-left footprint flatness (1.0 = flat enough for
an invisible fill), read by the metadata/assume arm of :func:`_keep_pill`."""
return {"footprint_flat": float(_engine("jimeng_pill").footprint_is_flat(image))}
_REGISTRY: tuple[KnownMark, ...] = (
KnownMark("gemini", "Google Gemini sparkle", "bottom-right", True, "reverse-alpha", _gemini_detect, _gemini_remove),
KnownMark("gemini", "Google Gemini sparkle", "bottom-right", True, _gemini_detect, _gemini_mask),
_text_mark("doubao", "Doubao 豆包AI生成 text", "bottom-right"),
_text_mark("jimeng", "Jimeng 即梦AI wordmark", "bottom-right"),
_text_mark("samsung", "Samsung Galaxy AI text", "bottom-left"),
KnownMark("jimeng_pill", "Jimeng AI生成 pill", "top-left", True, _pill_detect, _pill_mask, _pill_features),
)
@@ -219,17 +431,153 @@ def get_mark(key: str) -> KnownMark:
raise KeyError(key)
def detect_marks(image: NDArray[Any], *, include_explicit: bool = True) -> list[MarkDetection]:
def detect_marks(
image: NDArray[Any],
*,
include_explicit: bool = True,
provenance: frozenset[str] = frozenset(),
) -> list[MarkDetection]:
"""Detect every known mark in its usual place.
Returns one MarkDetection per scanned mark (``detected`` flags which fired).
``include_explicit=False`` scans only the ``in_auto`` marks -- the set used
by ``--mark auto``.
"""
return [m.detect(image) for m in _REGISTRY if include_explicit or m.in_auto]
by ``--mark auto``. ``provenance`` names the vendor keys that external metadata
already confirms, so each named mark's detector may relax its trust gate."""
return [m.detect(image, provenance=m.key in provenance) for m in _REGISTRY if include_explicit or m.in_auto]
def best_auto_mark(image: NDArray[Any]) -> MarkDetection | None:
"""The highest-confidence detected ``in_auto`` mark, or None if none fired."""
fired = [d for d in detect_marks(image, include_explicit=False) if d.detected]
return max(fired, key=lambda d: d.confidence) if fired else None
def resolve_relax(
key: str,
*,
sensitivity: Sensitivity,
provenance: frozenset[str],
strict_keys: set[str],
) -> bool:
"""Whether mark ``key``'s detection gate is relaxed (strict -> assume level).
The single place that turns the ``sensitivity`` policy + evidence into a per-mark
boolean (which the engines consume): ``strict`` never relaxes, ``assume_ai`` always
relaxes, and ``auto`` relaxes only on same-product evidence -- the vendor confirmed
by metadata (``key in provenance``) or a confidently strict-detected sibling of the
same product (``_PRODUCT_OF``)."""
if sensitivity == "strict":
return False
if sensitivity == "assume_ai":
return True
if key in provenance:
return True
product = _PRODUCT_OF[key]
return any(_PRODUCT_OF[k] == product for k in strict_keys if k != key)
def _keep_pill(keys: set[str], *, provenance: frozenset[str], sensitivity: Sensitivity, footprint_flat: bool) -> bool:
"""Whether to auto-remove the capture-less 'AI生成' pill given the fired marks.
Pure decision (the flatness feature is precomputed at perception time and passed
in). The pill detector is weak (~7% raw false-fire) and metadata/intent confirms
the platform, not pill presence, so a naive confirmation-OR gate over-fires: on a
32k real-upload corpus (2026-07) the metadata-only arm was only ~27% precise and
its false fires were textured ceilings/walls that the fill visibly SMEARS. Arms:
* bottom-right "★ 即梦AI" wordmark fired -> ~94% precise, and it survives
metadata-STRIPPED uploads: remove the pill unrestricted;
* TC260 metadata confirms Jimeng (``"jimeng" in provenance``, no wordmark) OR the
caller asserts AI (``sensitivity == "assume_ai"``) -> remove ONLY when the
top-left footprint is flat enough for an invisible fill (``footprint_flat``),
so real flat-scene pills (and harmless flat false fires) are cleaned while the
damaging textured false fires are left untouched even under assume_ai.
A Doubao image is TC260 too but is not Jimeng-basic, so the pill never rides on a
Doubao detection. No confirmation at all -> never remove (blocks false fires on
non-Jimeng content)."""
if "doubao" in keys:
return False
if "jimeng" in keys:
return True
if "jimeng" in provenance or sensitivity == "assume_ai":
return footprint_flat
return False
def _build_candidates(image: NDArray[Any]) -> list[Candidate]:
"""PERCEPTION pass: run every ``in_auto`` mark's detector at both trust levels and
package the raw verdicts + physical features into :class:`Candidate` objects. No
policy here -- the arbiter (:func:`decide`) makes every keep/drop call.
Each mark is detected at the strict AND the relaxed (``provenance=True``) level so
:func:`decide` can pick per mark without re-running detection; a relaxed gate is
monotonically more permissive, so this reproduces the old strict-then-relax pass
exactly. The loop is uniform -- it knows nothing about any specific mark: each mark
reports its own gate features via :meth:`KnownMark.features` (computed only when the
mark is detected, so a clean image pays nothing extra)."""
cands: list[Candidate] = []
for m in _REGISTRY:
if not m.in_auto:
continue
strict = m.detect(image, provenance=False)
relaxed = m.detect(image, provenance=True)
feats = m.features(image) if (strict.detected or relaxed.detected) else {}
cands.append(Candidate(m.key, m.label, strict.detected, relaxed.detected, feats))
return cands
def decide(candidates: list[Candidate], context: Context) -> list[Decision]:
"""The removal ARBITER: a pure function turning perception + context into the
ordered list of marks to remove (and the trust level each was accepted at).
All policy lives here, in one place: per-mark relaxation (:func:`resolve_relax`,
which needs the strict-detected siblings for ``auto`` cross-mark corroboration) and
the capture-less pill gate (:func:`_keep_pill`). No image, no I/O -- so it is
unit-testable in isolation and the same decision drives every caller."""
strict_keys = {c.key for c in candidates if c.detected_strict}
fired: list[Decision] = []
for c in candidates:
relax = resolve_relax(
c.key, sensitivity=context.sensitivity, provenance=context.provenance, strict_keys=strict_keys
)
if c.detected_relaxed if relax else c.detected_strict:
fired.append(Decision(c, relax))
keys = {d.candidate.key for d in fired}
if "jimeng_pill" in keys:
pill = next(d for d in fired if d.candidate.key == "jimeng_pill")
flat = bool(pill.candidate.features.get("footprint_flat", 0.0))
if not _keep_pill(
keys,
provenance=context.provenance,
sensitivity=context.sensitivity,
footprint_flat=flat,
):
fired = [d for d in fired if d.candidate.key != "jimeng_pill"]
return fired
def remove_auto_marks(
image: NDArray[Any],
*,
sensitivity: Sensitivity = "auto",
provenance: frozenset[str] = frozenset(),
backend: Backend = "auto",
) -> tuple[NDArray[Any], list[str]]:
"""Remove EVERY decided ``in_auto`` mark in one pass, chaining the result.
The three stages are separated: PERCEPTION (:func:`_build_candidates` -- engines
report what they see, no policy), DECISION (:func:`decide` -- the pure arbiter over
``(candidates, Context)``), ACTION (localize -> :func:`fill` per winner). Marks
coexist in different corners -- a Jimeng-basic image carries BOTH the top-left pill
AND the bottom-right wordmark -- so every decided mark is removed, chained on the
progressively-cleaned image (order does not matter, each re-localizes its corner).
Three orthogonal knobs: ``sensitivity`` (how hard to trust a borderline mark --
see :data:`Sensitivity`), ``provenance`` (vendor keys external metadata confirms,
the evidence that drives ``auto``; the TC260 label maps to ``jimeng``/``doubao``),
and ``backend`` (the shared fill). Returns ``(result, [labels removed])``; empty
means nothing fired."""
context = Context(sensitivity=sensitivity, provenance=provenance)
result = image
labels: list[str] = []
for d in decide(_build_candidates(image), context):
result, region = get_mark(d.candidate.key).remove(result, backend=backend, provenance=d.relax, force=False)
# Only report the mark as removed when a fill actually happened: remove() returns
# a None region when the localized mask came back empty, and reporting it anyway
# would claim a removal that left the pixels unchanged.
if region is not None:
labels.append(d.candidate.label)
return result, labels
+128
View File
@@ -0,0 +1,128 @@
"""High-level convenience API (remove_visible / visible_provenance) and the lazy
top-level re-exports."""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
import remove_ai_watermarks as raiw
from remove_ai_watermarks import api
SAMPLES = Path(__file__).resolve().parents[1] / "data" / "samples"
DOUBAO = SAMPLES / "doubao-1.png"
CHATGPT = SAMPLES / "chatgpt-1.png"
class TestTopLevelExports:
def test_lazy_reexports_resolve(self):
assert raiw.remove_visible is api.remove_visible
assert raiw.visible_provenance is api.visible_provenance
def test_unknown_attribute_raises(self):
with pytest.raises(AttributeError):
_ = raiw.does_not_exist
def test_bare_import_is_light(self):
# importing the package must not pull the heavy cv2/torch stack (PEP 562 lazy).
# Checked in a FRESH interpreter -- another test in this process may already
# have imported cv2, so an in-process sys.modules check would be flaky.
import subprocess
import sys
code = "import remove_ai_watermarks, sys; print(int(any(m in sys.modules for m in ('cv2','torch'))))"
out = subprocess.run( # noqa: S603 -- fixed sys.executable + literal code, no untrusted input
[sys.executable, "-c", code], check=True, capture_output=True, text=True
)
assert out.stdout.strip() == "0", f"bare import pulled a heavy module: {out.stdout!r}"
class TestRemoveVisibleArray:
def test_array_no_mark_is_noop_copy(self):
arr = np.zeros((256, 256, 3), np.uint8)
result, removed = raiw.remove_visible(arr, backend="cv2")
assert removed == []
assert result.shape == arr.shape
assert np.array_equal(result, arr)
def test_array_accepts_knobs(self):
arr = np.zeros((256, 256, 3), np.uint8)
result, removed = raiw.remove_visible(arr, sensitivity="assume_ai", backend="cv2")
assert removed == []
assert result.shape == arr.shape
def test_bad_source_raises(self, tmp_path):
with pytest.raises(ValueError, match="Could not read image"):
raiw.remove_visible(tmp_path / "nope.png")
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
class TestRemoveVisiblePath:
def test_path_removes_and_writes(self, tmp_path):
out = tmp_path / "clean.png"
result, removed = raiw.remove_visible(DOUBAO, out, backend="cv2")
assert out.exists()
assert any("Doubao" in lbl for lbl in removed)
assert result.shape[2] == 3
def test_path_no_output_returns_without_writing(self, tmp_path):
# output=None returns the array but writes nothing
result, _ = raiw.remove_visible(DOUBAO, backend="cv2")
assert result.ndim == 3
class TestNoOpPreservesOriginal:
def test_no_mark_copies_original_bytes(self, tmp_path):
# A clean image (no mark) same-format-out must be copied VERBATIM, not
# re-encoded -- so a no-op never degrades the original ("work with originals").
import filecmp
from PIL import Image
src = tmp_path / "clean.jpg"
Image.fromarray(np.full((40, 40, 3), 120, np.uint8), "RGB").save(src, quality=90)
out = tmp_path / "clean_out.jpg"
_, removed = raiw.remove_visible(str(src), str(out), sensitivity="strict", backend="cv2")
assert removed == []
assert filecmp.cmp(str(src), str(out), shallow=False) # byte-identical
class TestVisibleProvenance:
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
def test_doubao_tc260_maps_to_bytedance(self):
prov = raiw.visible_provenance(DOUBAO)
# TC260 label -> ByteDance family (both doubao and jimeng)
assert {"doubao", "jimeng"} <= prov
@pytest.mark.skipif(not CHATGPT.exists(), reason="chatgpt sample not present")
def test_openai_image_has_no_visible_vendor(self):
# OpenAI C2PA is not one of the visible-mark vendors -> empty provenance
assert raiw.visible_provenance(CHATGPT) == frozenset()
def test_unreadable_path_is_empty(self, tmp_path):
assert raiw.visible_provenance(tmp_path / "missing.png") == frozenset()
class TestRemoveVisibleOutputPath:
"""Output-path robustness: in-place clean (#3) and a missing output dir (#4)."""
def _write_clean(self, p: Path) -> None:
from remove_ai_watermarks import image_io
image_io.imwrite(str(p), np.full((128, 128, 3), 200, np.uint8))
def test_inplace_clean_no_crash(self, tmp_path: Path):
p = tmp_path / "clean.png"
self._write_clean(p)
_, removed = raiw.remove_visible(str(p), str(p), backend="cv2")
assert removed == []
assert p.exists()
def test_creates_missing_output_dir(self, tmp_path: Path):
src = tmp_path / "in.png"
self._write_clean(src)
out = tmp_path / "sub" / "out.png"
raiw.remove_visible(str(src), str(out), backend="cv2")
assert out.exists()
+219 -18
View File
@@ -172,7 +172,9 @@ class TestVisibleCommand:
expected = sample_png.with_stem(sample_png.stem + "_clean")
assert expected.exists()
def test_visible_no_inpaint(self, runner, sample_png, tmp_path):
def test_visible_backend_cv2(self, runner, sample_png, tmp_path):
# The old --inpaint/--no-inpaint flags are gone; the fill backend is picked
# with --backend (localize -> fill). cv2 needs no ONNX model.
output = tmp_path / "clean.png"
result = runner.invoke(
main,
@@ -181,7 +183,8 @@ class TestVisibleCommand:
str(sample_png),
"-o",
str(output),
"--no-inpaint",
"--backend",
"cv2",
"--no-detect",
],
)
@@ -290,7 +293,7 @@ class TestInvisibleCommand:
):
result = runner.invoke(
main,
["invisible", str(sample_png), "-o", str(output)],
["invisible", str(sample_png), "-o", str(output), "--force"],
)
assert result.exit_code == 0, result.output
assert output.exists()
@@ -303,7 +306,7 @@ class TestInvisibleCommand:
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
):
result = runner.invoke(main, ["invisible", str(sample_png)])
result = runner.invoke(main, ["invisible", str(sample_png), "--force"])
assert result.exit_code == 0, result.output
expected = sample_png.with_stem(sample_png.stem + "_clean")
assert expected.exists()
@@ -315,7 +318,7 @@ class TestInvisibleCommand:
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
):
result = runner.invoke(main, ["invisible", str(sample_png)])
result = runner.invoke(main, ["invisible", str(sample_png), "--force"])
assert result.exit_code == 0, result.output
# adaptive_polish is ON by default (self-gating, so a no-op where not needed).
assert mock_engine.remove_watermark.call_args.kwargs["adaptive_polish"] is True
@@ -330,7 +333,7 @@ class TestInvisibleCommand:
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
):
result = runner.invoke(main, ["invisible", str(sample_png), "--no-adaptive-polish"])
result = runner.invoke(main, ["invisible", str(sample_png), "--no-adaptive-polish", "--force"])
assert result.exit_code == 0, result.output
assert mock_engine.remove_watermark.call_args.kwargs["adaptive_polish"] is False
@@ -343,7 +346,7 @@ class TestInvisibleCommand:
):
result = runner.invoke(
main,
["invisible", str(sample_png), "--model", "org/custom-sdxl", "--guidance-scale", "5.5"],
["invisible", str(sample_png), "--model", "org/custom-sdxl", "--guidance-scale", "5.5", "--force"],
)
assert result.exit_code == 0, result.output
assert mock_cls.call_args.kwargs["model_id"] == "org/custom-sdxl"
@@ -356,7 +359,7 @@ class TestInvisibleCommand:
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
):
result = runner.invoke(main, ["invisible", str(sample_png), "--pipeline", "default"])
result = runner.invoke(main, ["invisible", str(sample_png), "--pipeline", "default", "--force"])
assert result.exit_code == 0, result.output
# The legacy value warns and is normalized to "sdxl" before the engine is built.
assert "deprecated" in result.output.lower()
@@ -369,7 +372,7 @@ class TestInvisibleCommand:
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
):
result = runner.invoke(main, ["invisible", str(sample_png), "--pipeline", "sdxl"])
result = runner.invoke(main, ["invisible", str(sample_png), "--pipeline", "sdxl", "--force"])
assert result.exit_code == 0, result.output
assert "deprecated" not in result.output.lower()
assert mock_cls.call_args.kwargs["pipeline"] == "sdxl"
@@ -378,6 +381,57 @@ class TestInvisibleCommand:
result = runner.invoke(main, ["invisible", "/nonexistent/file.png"])
assert result.exit_code != 0
def test_invisible_no_signal_skips_and_exits_two(self, runner, sample_png, tmp_path):
"""P0#5: when no invisible AI watermark is locally detectable, the diffusion
scrub must NOT run (it would only degrade a clean image). Mirrors the visible
no-mark contract: write no output, exit 2, and DO NOT imply the image is
clean (a stripped SynthID proxy is not proof of absence)."""
mock_cls, mock_engine = _mock_invisible_engine()
output = tmp_path / "clean.png"
with (
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
):
result = runner.invoke(main, ["invisible", str(sample_png), "-o", str(output)])
assert result.exit_code == 2, result.output
assert not output.exists()
mock_engine.remove_watermark.assert_not_called()
assert "--force" in result.output
assert "SynthID" in result.output # the message must preserve removal uncertainty
def test_invisible_force_runs_scrub_on_no_signal(self, runner, sample_png, tmp_path):
"""--force overrides the no-signal skip: the scrub runs regardless."""
mock_cls, mock_engine = _mock_invisible_engine()
output = tmp_path / "clean.png"
with (
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
):
result = runner.invoke(main, ["invisible", str(sample_png), "-o", str(output), "--force"])
assert result.exit_code == 0, result.output
mock_engine.remove_watermark.assert_called_once()
def test_invisible_runs_without_force_when_signal_present(self, runner, tmp_path):
"""An image carrying an AI metadata signal IS a scrub target, so the run
proceeds with no --force needed."""
img = Image.fromarray(np.random.default_rng(0).integers(0, 255, (200, 200, 3), dtype=np.uint8))
pnginfo = PngInfo()
pnginfo.add_text("parameters", "Steps: 20, Sampler: Euler, a test landscape")
src = tmp_path / "ai.png"
img.save(src, pnginfo=pnginfo)
output = tmp_path / "clean.png"
mock_cls, mock_engine = _mock_invisible_engine()
with (
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
):
result = runner.invoke(main, ["invisible", str(src), "-o", str(output)])
assert result.exit_code == 0, result.output
mock_engine.remove_watermark.assert_called_once()
class TestAllCommand:
"""Tests for the 'all' subcommand (full pipeline)."""
@@ -397,7 +451,7 @@ class TestAllCommand:
):
result = runner.invoke(
main,
["all", str(sample_png), "-o", str(output)],
["all", str(sample_png), "-o", str(output), "--force"],
)
assert result.exit_code == 0, result.output
assert output.exists()
@@ -408,19 +462,43 @@ class TestAllCommand:
def test_all_visible_step_uses_registry(self, runner, sample_png, tmp_path):
"""Regression (#1): the `all` visible step must route through the registry
(best_auto_mark), so Doubao/Jimeng/Samsung text marks are handled -- not just
the Gemini sparkle via a hardcoded GeminiEngine."""
(remove_auto_marks), so Doubao/Jimeng/Samsung/pill marks are handled -- not
just the Gemini sparkle via a hardcoded GeminiEngine."""
mock_cls, _mock_engine = _mock_invisible_engine()
output = tmp_path / "clean.png"
def _fake_remove_auto(image, **kwargs):
return image, []
with (
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
patch(
"remove_ai_watermarks.watermark_registry.remove_auto_marks", side_effect=_fake_remove_auto
) as mock_auto,
):
result = runner.invoke(main, ["all", str(sample_png), "-o", str(output), "--force"])
assert result.exit_code == 0, result.output
mock_auto.assert_called() # the registry auto-detector drove the visible pass
def test_all_skips_invisible_on_no_signal_but_succeeds(self, runner, sample_png, tmp_path):
"""P0#5: with no detectable invisible watermark and no --force, `all` skips
the destructive step 2 (pixels left intact) but STILL succeeds (exit 0) --
visible removal + metadata strip ran and a file is written. Distinct from the
GPU-missing skip, which is a non-zero failure."""
mock_cls, mock_engine = _mock_invisible_engine()
output = tmp_path / "clean.png"
with (
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
patch("remove_ai_watermarks.watermark_registry.best_auto_mark", return_value=None) as mock_best,
):
result = runner.invoke(main, ["all", str(sample_png), "-o", str(output)])
assert result.exit_code == 0, result.output
mock_best.assert_called() # the registry auto-detector drove the visible pass
assert output.exists()
mock_engine.remove_watermark.assert_not_called()
assert "Skipped (no invisible" in result.output
def test_all_loud_warning_and_nonzero_exit_when_gpu_missing(self, runner, sample_png, tmp_path):
"""Regression (#14/#47): when the GPU extra is absent the invisible step is
@@ -453,7 +531,7 @@ class TestAllCommand:
patch("remove_ai_watermarks.cli.invisible_available", return_value=True, create=True),
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
):
result = runner.invoke(main, ["all", str(src), "-o", str(output)])
result = runner.invoke(main, ["all", str(src), "-o", str(output), "--force"])
assert result.exit_code == 0, result.output
out = cv2.imread(str(output), cv2.IMREAD_UNCHANGED)
@@ -495,6 +573,16 @@ class TestMetadataCommand:
assert result.exit_code == 0
assert "stripped" in result.output
def test_metadata_remove_in_place(self, runner, tmp_png_with_ai_metadata):
"""With ``-o`` omitted, the strip overwrites the source in place (default
output_path=None). Previously every test passed an explicit ``-o``."""
from remove_ai_watermarks.metadata import has_ai_metadata
assert has_ai_metadata(tmp_png_with_ai_metadata) # precondition
result = runner.invoke(main, ["metadata", str(tmp_png_with_ai_metadata), "--remove"])
assert result.exit_code == 0, result.output
assert not has_ai_metadata(tmp_png_with_ai_metadata) # source overwritten, AI metadata gone
class TestIdentifyCommand:
"""Tests for the 'identify' subcommand."""
@@ -522,6 +610,18 @@ class TestIdentifyCommand:
assert "AI-generated" in result.output
assert "Stable Diffusion" in result.output
def test_identify_reports_generated_source_kind(self, runner):
"""The C2PA trainedAlgorithmicMedia source type sharpens the verdict to
'AI-generated (fully synthetic)' at the CLI (the ai_source_kind branch)."""
from pathlib import Path
sample = Path(__file__).resolve().parent.parent / "data" / "samples" / "chatgpt-1.png"
if not sample.exists():
pytest.skip("chatgpt sample not present")
result = runner.invoke(main, ["identify", str(sample), "--no-visible"])
assert result.exit_code == 0
assert "AI-generated (fully synthetic)" in result.output
def test_identify_json_is_valid(self, runner, tmp_png_with_ai_metadata):
result = runner.invoke(main, ["identify", str(tmp_png_with_ai_metadata), "--no-visible", "--json"])
assert result.exit_code == 0
@@ -580,6 +680,26 @@ class TestBatchCommand:
input_dir = _make_batch_dir(tmp_path)
output_dir = tmp_path / "output"
mock_cls, _mock_engine = _mock_invisible_engine()
with (
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
patch("remove_ai_watermarks.cli.invisible_available", return_value=True, create=True),
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
):
result = runner.invoke(
main,
["batch", str(input_dir), "-o", str(output_dir), "--mode", "invisible", "--force"],
)
assert result.exit_code == 0, result.output
assert "3 processed" in result.output
def test_batch_invisible_skips_no_signal_and_copies_through(self, runner, tmp_path):
"""P0#5: batch invisible mode skips the scrub on signal-less images (no
--force) and copies the input through, so the output dir is complete with the
pixels left intact and the engine never called."""
input_dir = _make_batch_dir(tmp_path)
output_dir = tmp_path / "output"
mock_cls, mock_engine = _mock_invisible_engine()
with (
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
@@ -592,6 +712,8 @@ class TestBatchCommand:
)
assert result.exit_code == 0, result.output
assert "3 processed" in result.output
assert len(list(output_dir.glob("*.png"))) == 3 # inputs copied through
mock_engine.remove_watermark.assert_not_called()
def test_batch_all_mode(self, runner, tmp_path):
input_dir = _make_batch_dir(tmp_path)
@@ -605,7 +727,7 @@ class TestBatchCommand:
):
result = runner.invoke(
main,
["batch", str(input_dir), "-o", str(output_dir), "--mode", "all"],
["batch", str(input_dir), "-o", str(output_dir), "--mode", "all", "--force"],
)
assert result.exit_code == 0, result.output
assert "3 processed" in result.output
@@ -631,7 +753,7 @@ class TestBatchCommand:
):
result = runner.invoke(
main,
["batch", str(input_dir), "-o", str(output_dir), "--mode", "all"],
["batch", str(input_dir), "-o", str(output_dir), "--mode", "all", "--force"],
)
assert result.exit_code == 0, result.output
@@ -655,7 +777,7 @@ class TestBatchCommand:
):
result = runner.invoke(
main,
["batch", str(input_dir), "-o", str(output_dir), "--mode", "invisible", "--auto"],
["batch", str(input_dir), "-o", str(output_dir), "--mode", "invisible", "--auto", "--force"],
)
assert result.exit_code == 0, result.output
assert "2 processed" in result.output
@@ -674,6 +796,34 @@ class TestBatchCommand:
expected_dir = tmp_path / "input_clean"
assert expected_dir.exists()
def test_batch_errors_exit_nonzero(self, runner, tmp_path):
"""Regression: batch used to always exit 0 even when every image errored,
hiding failure from a wrapping service. A corrupt image must yield a non-zero
exit and an error count."""
input_dir = tmp_path / "input"
input_dir.mkdir()
(input_dir / "corrupt.png").write_bytes(b"this is not a PNG at all" * 50)
result = runner.invoke(main, ["batch", str(input_dir), "--mode", "visible"])
assert result.exit_code != 0, result.output
assert "error" in result.output.lower()
def test_batch_invisible_gpu_missing_writes_output_and_exits_nonzero(self, runner, tmp_path):
"""Regression: batch --mode invisible with a signal-bearing image but no GPU
deps used to write NO output for that image and still exit 0, silently dropping
the files that most needed processing. It must now copy the input through (so the
output dir is complete), warn about the retained SynthID watermark, and exit
non-zero -- mirroring the single ``all`` command."""
input_dir = _make_batch_dir_with_metadata(tmp_path, count=3) # SD params = invisible signal
output_dir = tmp_path / "output"
with patch("remove_ai_watermarks.invisible_engine.is_available", return_value=False):
result = runner.invoke(
main,
["batch", str(input_dir), "-o", str(output_dir), "--mode", "invisible"],
)
assert result.exit_code != 0, result.output
assert "NOT removed" in result.output
assert len(list(output_dir.glob("*.png"))) == 3 # every input copied through, none dropped
class TestGpuHintMarkup:
"""The GPU-extra install hint must reach the user with the ``[gpu]`` token
@@ -766,3 +916,54 @@ class TestEraseCommand:
assert result.exit_code != 0
assert "onnxruntime" in result.output.lower()
assert not output.exists()
def test_visible_backend_runtime_error_exits_cleanly(runner, tmp_path, monkeypatch):
# A backend whose extra is missing raises RuntimeError in region_eraser.erase;
# the visible --mark auto path must surface it cleanly, not as a raw traceback (#9).
from pathlib import Path
from remove_ai_watermarks import region_eraser
doubao = Path(__file__).resolve().parent.parent / "data" / "samples" / "doubao-1.png"
if not doubao.exists():
pytest.skip("doubao sample not present")
def boom(*_a, **_k):
raise RuntimeError("MI-GAN backend requires onnxruntime. Install the extra: ...")
monkeypatch.setattr(region_eraser, "erase", boom)
out = tmp_path / "out.png"
result = runner.invoke(main, ["visible", str(doubao), "-o", str(out), "--backend", "migan"])
assert result.exit_code == 1
assert not isinstance(result.exception, RuntimeError), "RuntimeError leaked as a traceback"
@pytest.mark.parametrize(
("name", "content"),
[
("empty.png", b""),
("notimage.jpg", b"plain text, not an image at all " * 20),
("truncated.png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 40),
],
)
@pytest.mark.parametrize("cmd", [["metadata", "--remove"], ["visible", "--backend", "cv2"]])
def test_unreadable_input_exits_cleanly(runner, tmp_path, name, content, cmd):
"""Regression: a corrupt / empty / non-image file (real prod uploads include ~0.2%
truncated files) must NEVER leak a raw PIL/OSError/ValueError traceback. `metadata
--remove` is fail-safe -- an undecodable file is copied through unchanged (exit 0),
a strip that cannot parse the file is a no-op, not a crash; `visible` must decode to
remove a mark, so it is a clean error (exit 1). Found by the runtime mode fuzz."""
bad = tmp_path / name
bad.write_bytes(content)
out = tmp_path / "out.png"
result = runner.invoke(main, [cmd[0], str(bad), "-o", str(out), *cmd[1:]])
assert result.exception is None or isinstance(result.exception, SystemExit), (
f"leaked a raw traceback: {result.exception!r}"
)
if cmd[0] == "metadata":
assert result.exit_code == 0, result.output # fail-safe copy-through
assert out.read_bytes() == content # input passed through unchanged
else:
assert result.exit_code == 1, result.output # cannot remove a mark from unreadable input
assert "Error" in result.output
+98 -88
View File
@@ -1,4 +1,4 @@
"""Tests for the Doubao visible-watermark engine (reverse-alpha only)."""
"""Tests for the Doubao visible-watermark engine (localize -> fill)."""
from __future__ import annotations
@@ -8,11 +8,9 @@ import cv2
import numpy as np
import pytest
from remove_ai_watermarks import watermark_registry as registry
from remove_ai_watermarks.doubao_engine import (
_ALPHA_HEIGHT_FRAC,
_ALPHA_LOGO_BGR,
_ALPHA_MARGIN_BOTTOM_FRAC,
_ALPHA_MARGIN_RIGHT_FRAC,
_ALPHA_NATIVE_WIDTH,
_ALPHA_WIDTH_FRAC,
DETECT_NCC_THRESHOLD,
@@ -26,6 +24,22 @@ from remove_ai_watermarks.doubao_engine import (
SAMPLE = Path(__file__).resolve().parents[1] / "data" / "samples" / "doubao-1.png"
def _compose(w: int, h: int, bg: float = 100.0):
"""Composite the real alpha (scaled to width ``w``) onto a flat bg.
Returns ``(watermarked_uint8, mark_bool_mask)``."""
img = np.full((h, w, 3), bg, np.float32)
at = _alpha_template()
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
margin = int(0.015 * w)
ax = w - margin - gw
ay = h - margin - gh
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
wm = (a3 * 255.0 + (1 - a3) * img).clip(0, 255).astype(np.uint8)
return wm, amap > 0.2
class TestLocate:
def test_box_anchored_bottom_right(self):
eng = DoubaoEngine()
@@ -76,6 +90,16 @@ class TestDetect:
solid = np.full_like(box, 255)
assert _template_match_score(solid, _ALPHA_NATIVE_WIDTH) < DETECT_NCC_THRESHOLD
def test_small_image_guarded_from_false_positive(self):
"""Below the minimum short side a tiny geometric shape spuriously NCC-matches
the CJK silhouette (2026-06-26 FP: a 48x48 app-icon chevron scored 0.41). The
size guard suppresses detection there. Bracket it: a real mark is detected at
native size, but the same content downscaled below the guard is not."""
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
eng = DoubaoEngine()
assert eng.detect(wm).detected # native: real mark detected
assert not eng.detect(cv2.resize(wm, (150, 150))).detected # below guard: suppressed
@pytest.mark.skipif(not SAMPLE.exists(), reason="sample image not present")
class TestRealSample:
@@ -84,25 +108,25 @@ class TestRealSample:
assert det.detected
assert det.confidence >= DETECT_NCC_THRESHOLD
def test_reverse_alpha_removes_mark(self):
eng = DoubaoEngine()
def test_fill_lowers_confidence(self):
img = load_image_bgr(SAMPLE)
assert eng.reverse_alpha_available(img) # sample is at the captured width
out = eng.remove_watermark_reverse_alpha(img)
assert not eng.detect(out).detected # mark gone after recovery
before = DoubaoEngine().detect(img)
assert before.detected
out, region = registry.get_mark("doubao").remove(img, backend="cv2")
assert region is not None
assert DoubaoEngine().detect(out).confidence < before.confidence
def test_far_region_untouched(self):
eng = DoubaoEngine()
img = load_image_bgr(SAMPLE)
out = eng.remove_watermark_reverse_alpha(img)
out, _ = registry.get_mark("doubao").remove(img, backend="cv2")
h, w = img.shape[:2]
assert np.array_equal(img[: h // 2, : w // 2], out[: h // 2, : w // 2])
# ── Reverse-alpha (exact recovery) ──────────────────────────────────
# ── Alpha asset + footprint mask + localize -> fill removal ─────────
class TestReverseAlpha:
class TestAlphaAsset:
def test_alpha_asset_loads(self):
at = _alpha_template()
assert at is not None
@@ -110,104 +134,90 @@ class TestReverseAlpha:
assert float(at.min()) >= 0.0
assert float(at.max()) <= 1.0
def test_available_whenever_asset_present(self):
# NCC alignment generalizes to any resolution, so availability is just
# "asset loadable" (any non-empty image); the caller gates on detect.
eng = DoubaoEngine()
assert eng.reverse_alpha_available(np.zeros((1024, 1024, 3), np.uint8))
assert eng.reverse_alpha_available(np.zeros((1773, 1535, 3), np.uint8))
assert not eng.reverse_alpha_available(np.zeros((0, 0, 3), np.uint8))
@staticmethod
def _compose(w: int, h: int, bg: float = 100.0):
"""Composite the real alpha (scaled to width ``w``) onto a flat bg.
Returns ``(watermarked_uint8, mark_bool_mask)``."""
img = np.full((h, w, 3), bg, np.float32)
at = _alpha_template()
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
ax = w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw
ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * img).clip(0, 255).astype(np.uint8)
return wm, amap > 0.2
class TestFootprintMaskAndRemoval:
def test_footprint_mask_in_bottom_right(self):
"""A composed mark yields a footprint mask localized to the bottom-right."""
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
mask = DoubaoEngine().footprint_mask(wm)
assert mask is not None
assert mask.shape == wm.shape[:2]
ys, xs = np.where(mask > 0)
assert ys.mean() > wm.shape[0] / 2
assert xs.mean() > wm.shape[1] / 2
def test_removes_synthetic_mark(self):
"""Reverse-alpha + thin residual inpaint clears a mark composed from the
real alpha (re-detect no longer fires)."""
eng = DoubaoEngine()
wm, _mark = self._compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
assert eng.detect(wm).detected
out = eng.remove_watermark_reverse_alpha(wm)
assert not eng.detect(out).detected
"""localize -> cv2 fill clears a mark composed from the real alpha
(re-detect no longer fires)."""
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
assert DoubaoEngine().detect(wm).detected
out, region = registry.get_mark("doubao").remove(wm, backend="cv2")
assert region is not None
assert not DoubaoEngine().detect(out).detected
@pytest.mark.parametrize(
("w", "h", "max_err"),
("w", "h"),
[
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH, 5.0), # captured width
(1773, 2364, 8.0), # 3:4 portrait -> NCC alignment generalizes the single capture
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH), # captured width
(1773, 2364), # 3:4 portrait -> template-free footprint generalizes
],
)
def test_recovers_flat_background(self, w, h, max_err):
"""Recovers the flat background at the captured width AND a non-native
resolution (NCC alignment generalizes the single capture)."""
eng = DoubaoEngine()
wm, mark = self._compose(w, h)
def test_fill_removes_and_leaves_far_region(self, w, h):
"""The fill lowers re-detect confidence and leaves the far corner exact."""
wm, mark = _compose(w, h)
assert float(np.abs(wm.astype(np.float32)[mark] - 100.0).mean()) > 15 # mark visible
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
assert float(np.abs(out[mark] - 100.0).mean()) < max_err
@staticmethod
def _textured_bg(w: int, h: int):
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
base = 120 + 40 * np.sin(xx / 90.0) + 30 * np.cos(yy / 70.0)
return np.clip(np.stack([base, base * 0.95, base * 1.05], axis=-1), 0, 255)
def test_recovers_shifted_mark_on_texture(self):
"""A real mark is re-rasterized a few px off its fixed slot, so removal
must NCC-align to it. Regression guard for the issue-#13 follow-up defect:
a too-tight locate box let a corner-ward shift fall outside the alignment
search, leaving a readable outline that the detector did not flag. Composes
the real alpha SHIFTED on a known texture and asserts the texture is
recovered (a misaligned removal would leave the bright glyph outline)."""
eng = DoubaoEngine()
w = h = _ALPHA_NATIVE_WIDTH
at = _alpha_template()
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
ax = w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw + 12 # shift toward the corner
ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh + 8
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
bg = self._textured_bg(w, h)
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * bg).clip(0, 255).astype(np.uint8)
mark = amap > 0.15
assert float(np.abs(wm.astype(np.float32)[mark] - bg[mark]).mean()) > 30 # mark clearly visible
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
assert float(np.abs(out[mark] - bg[mark]).mean()) < 8.0 # texture recovered, no outline
before = DoubaoEngine().detect(wm)
out, _ = registry.get_mark("doubao").remove(wm, backend="cv2")
assert DoubaoEngine().detect(out).confidence < before.confidence
assert np.array_equal(out[: h // 2, : w // 2], wm[: h // 2, : w // 2])
class TestDegenerateAndChannelInputs:
"""Removal must not crash on degenerate sizes or non-3-channel inputs."""
"""footprint_mask must not crash on degenerate sizes or non-3-channel inputs."""
@pytest.mark.parametrize(("w", "h"), [(2048, 1), (1, 2048), (2048, 8)])
def test_wide_short_does_not_raise(self, w, h):
"""A wide/short image at native width makes the width-derived glyph box
taller than the image; the slice assignment must not ValueError."""
taller than the image; masking must not ValueError."""
eng = DoubaoEngine()
img = np.zeros((h, w, 3), np.uint8)
out = eng.remove_watermark_reverse_alpha(img)
assert out.shape == img.shape
mask = eng.footprint_mask(img, force=True)
assert mask is None or mask.shape == (h, w)
def test_grayscale_2d_does_not_raise(self):
eng = DoubaoEngine()
gray = np.zeros((2048, 2048), np.uint8)
out = eng.remove_watermark_reverse_alpha(gray)
assert out.shape == (2048, 2048, 3)
mask = eng.footprint_mask(gray, force=True)
assert mask is None or mask.shape == (2048, 2048)
def test_bgra_4channel_does_not_raise(self):
eng = DoubaoEngine()
bgra = np.zeros((2048, 2048, 4), np.uint8)
out = eng.remove_watermark_reverse_alpha(bgra)
assert out.shape == (2048, 2048, 3)
mask = eng.footprint_mask(bgra, force=True)
assert mask is None or mask.shape == (2048, 2048)
def test_template_match_score_guards_return_zero(self):
# Guards return 0.0 (never a false positive) for a mask that cannot hold a
# glyph: empty, narrower than min_gw, or shorter than the 4-px floor.
assert _template_match_score(np.zeros((0, 5), np.uint8), 1000) == 0.0
assert _template_match_score(np.zeros((10, 3), np.uint8), 1000) == 0.0 # width-1 < min_gw
assert _template_match_score(np.zeros((3, 200), np.uint8), 1000) == 0.0 # height-1 < 4
@pytest.mark.parametrize("shape", [(20, 20, 3), (10, 400, 3), (400, 10, 3), (1, 1, 3), (2000, 2000, 3)])
def test_locate_box_stays_in_bounds(self, shape):
"""locate() must clamp its geometry box inside the image for ANY size/aspect --
wide-short, tall-narrow, 1x1, huge -- for both bottom corners (br + bl)."""
from remove_ai_watermarks._text_mark_engine import TextMarkEngine
from remove_ai_watermarks.doubao_engine import _CONFIG as BR_CONFIG
from remove_ai_watermarks.samsung_engine import _CONFIG as BL_CONFIG
h, w = shape[:2]
img = np.zeros(shape, np.uint8)
for cfg in (BR_CONFIG, BL_CONFIG):
loc = TextMarkEngine(cfg).locate(img)
assert loc.x >= 0
assert loc.y >= 0
assert loc.x + loc.w <= w
assert loc.y + loc.h <= h
assert loc.w > 0
assert loc.h > 0
+76
View File
@@ -0,0 +1,76 @@
"""Regression test for the one-to-one face matcher in ``scripts/fidelity_metrics.py``.
The shipped per-face nearest matcher collided on multi-face images (two original faces
both picking the same variant face when regeneration dropped a face), which inflated/
corrupted the identity metric. ``assign_faces_one_to_one`` is the collision-free
replacement. The function is pure (centers + diagonals in, index map out), so it is
tested here without insightface / the heavy PEP723 env. Caught on the gemini_3 Qwen
ControlNet experiment, where the original had 18 faces but the regenerated variants had
17, producing two collisions under the old matcher.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
_SCRIPTS = Path(__file__).resolve().parent.parent / "scripts"
def _load_assign():
# fidelity_metrics is a standalone PEP723 script, not an installed module; load it by
# path with scripts/ on sys.path so its `_plain_console` shim import resolves.
sys.path.insert(0, str(_SCRIPTS))
try:
spec = importlib.util.spec_from_file_location("fidelity_metrics", _SCRIPTS / "fidelity_metrics.py")
assert spec is not None
assert spec.loader is not None
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod # @dataclass introspection needs the module registered
spec.loader.exec_module(mod)
except ImportError as exc: # cv2/click absent in a bare env -> skip, not fail
pytest.skip(f"fidelity_metrics import deps missing: {exc}")
finally:
sys.path.remove(str(_SCRIPTS))
return mod.assign_faces_one_to_one
def test_distinct_faces_match_nearest() -> None:
assign = _load_assign()
ref = [(0.0, 0.0), (100.0, 100.0)]
var = [(2.0, 1.0), (98.0, 102.0)]
diags = [50.0, 50.0]
assert assign(ref, var, diags) == {0: 0, 1: 1}
def test_no_collision_when_variant_drops_a_face() -> None:
# Two original faces near the SAME single variant face: the old nearest matcher mapped
# BOTH to index 0; one-to-one must give the nearer ref the match and drop the other.
assign = _load_assign()
ref = [(10.0, 10.0), (14.0, 10.0)] # both close to the lone variant
var = [(12.0, 10.0)]
diags = [50.0, 50.0]
matched = assign(ref, var, diags)
assert sorted(matched.values()) == [0] # variant 0 used at most once
assert len(matched) == 1
def test_gate_drops_implausibly_far_match() -> None:
assign = _load_assign()
ref = [(0.0, 0.0)]
var = [(1000.0, 1000.0)] # far beyond 0.6 * diag
diags = [50.0]
assert assign(ref, var, diags) == {}
def test_assignment_is_one_to_one_over_many_faces() -> None:
assign = _load_assign()
ref = [(float(i * 100), 0.0) for i in range(18)]
var = [(float(i * 100) + 3.0, 0.0) for i in range(17)] # one fewer, as in the experiment
diags = [50.0] * 18
matched = assign(ref, var, diags)
assert len(matched) == 17
assert len(set(matched.values())) == 17 # every variant used at most once
+96 -273
View File
@@ -102,28 +102,6 @@ class TestGeminiEngine:
assert small.shape == (48, 48)
assert large.shape == (96, 96)
def test_remove_watermark_returns_same_shape(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.remove_watermark(image)
assert result.shape == image.shape
assert result.dtype == np.uint8
def test_remove_watermark_does_not_modify_input(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
original = image.copy()
self.engine.remove_watermark(image)
np.testing.assert_array_equal(image, original)
def test_remove_watermark_large_image(self, tmp_large_image_path):
image = cv2.imread(str(tmp_large_image_path), cv2.IMREAD_COLOR)
result = self.engine.remove_watermark(image)
assert result.shape == image.shape
def test_remove_watermark_custom_region(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.remove_watermark_custom(image, (10, 10, 48, 48))
assert result.shape == image.shape
def test_detect_on_grayscale_does_not_crash(self):
# A 2D grayscale array reaching detect_watermark (registry adapter / library
# API) must not crash the FP-gate's axis=2 reduction; it is normalized to BGR.
@@ -131,26 +109,59 @@ class TestGeminiEngine:
result = self.engine.detect_watermark(gray)
assert result is not None
def test_remove_on_bgra_returns_3_channel(self):
# ── Removal: localize -> fill ───────────────────────────────────────
def _composite_sparkle(engine, bg_value: int = 160, size: int = 1400):
"""Composite the captured sparkle at the configured position on a textured
mid-tone image (texture so the filled footprint no longer NCC-matches the
template). Returns ``(watermarked_uint8, (x, y, w, h))``."""
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
base = bg_value + 18 * np.sin(xx / 40.0) + 14 * np.cos(yy / 55.0)
img = np.clip(np.stack([base, base * 0.97, base * 1.03], axis=-1), 0, 255)
config = get_watermark_config(size, size)
x, y = config.get_position(size, size)
alpha = engine.get_interpolated_alpha(config.logo_size)
ah, aw = alpha.shape[:2]
a = alpha[:, :, None]
roi = img[y : y + ah, x : x + aw]
img[y : y + ah, x : x + aw] = a * 255.0 + (1.0 - a) * roi
return np.clip(img, 0, 255).astype(np.uint8), (x, y, aw, ah)
class TestRemovalLocalizeFill:
"""Removal is localize (footprint_mask) -> fill (shared inpaint backend)."""
@pytest.fixture(autouse=True)
def _setup_engine(self):
self.engine = GeminiEngine()
def test_footprint_mask_covers_detected_sparkle(self):
img, (x, y, w, h) = _composite_sparkle(self.engine)
assert self.engine.detect_watermark(img).detected
mask = self.engine.footprint_mask(img)
assert mask is not None
assert mask.shape == img.shape[:2]
# The mask mass overlaps the sparkle footprint box.
assert int(mask[y : y + h, x : x + w].sum()) > 0
def test_fill_lowers_confidence(self):
import remove_ai_watermarks.watermark_registry as registry
img, _ = _composite_sparkle(self.engine)
before = self.engine.detect_watermark(img).confidence
mask = self.engine.footprint_mask(img)
assert mask is not None
out = registry.fill(img, mask, backend="cv2")
assert self.engine.detect_watermark(out).confidence < before
def test_footprint_mask_bgra_input(self):
# A 4-channel input is normalized before masking; no crash, right shape.
bgra = np.zeros((300, 300, 4), dtype=np.uint8)
bgra[..., 3] = 255
result = self.engine.remove_watermark(bgra)
assert result.shape == (300, 300, 3)
def test_remove_watermark_custom_large_region(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.remove_watermark_custom(image, (10, 10, 96, 96))
assert result.shape == image.shape
def test_remove_watermark_custom_arbitrary_region(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.remove_watermark_custom(image, (5, 5, 60, 60))
assert result.shape == image.shape
def test_force_size(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.remove_watermark(image, force_size=WatermarkSize.LARGE)
assert result.shape == image.shape
mask = self.engine.footprint_mask(bgra, force=True)
assert mask is None or mask.shape == (300, 300)
# ── Detection ───────────────────────────────────────────────────────
@@ -203,239 +214,6 @@ class TestDetectSparkleConfidence:
assert detect_sparkle_confidence(bogus) is None
# ── Inpainting ──────────────────────────────────────────────────────
class TestInpainting:
"""Tests for residual inpainting."""
@pytest.fixture(autouse=True)
def _setup_engine(self):
self.engine = GeminiEngine()
def test_inpaint_ns(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.inpaint_residual(image, (150, 150, 48, 48), method="ns")
assert result.shape == image.shape
def test_inpaint_telea(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.inpaint_residual(image, (150, 150, 48, 48), method="telea")
assert result.shape == image.shape
def test_inpaint_gaussian(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.inpaint_residual(image, (150, 150, 48, 48), method="gaussian")
assert result.shape == image.shape
def test_inpaint_zero_strength(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.inpaint_residual(image, (150, 150, 48, 48), strength=0.0)
np.testing.assert_array_equal(result, image)
def test_inpaint_tiny_region_returns_unchanged(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
result = self.engine.inpaint_residual(image, (10, 10, 2, 2))
np.testing.assert_array_equal(result, image)
def test_inpaint_does_not_modify_input(self, tmp_image_path):
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
original = image.copy()
self.engine.inpaint_residual(image, (150, 150, 48, 48))
np.testing.assert_array_equal(image, original)
class TestOverSubtractionGuard:
"""Issue #30: reverse-alpha must not turn the sparkle into a black pit.
On a dark background the captured alpha over-estimates the real sparkle opacity,
so the fixed-alpha reverse blend over-subtracts and drives the footprint to black.
The engine detects this and inpaints the footprint instead.
"""
# Composite the mark at ~60% of the captured opacity: the engine's alpha maxes at
# ~0.51, real dark-background sparkles sit nearer ~0.31, so 0.6x reproduces the
# capture-over-estimates-reality mismatch that triggers the bug.
_REALISTIC_ALPHA_SCALE = 0.6
@pytest.fixture(autouse=True)
def _setup_engine(self):
self.engine = GeminiEngine()
def _composite_sparkle(self, bg_value: int, size: int = 1400, alpha_scale: float = _REALISTIC_ALPHA_SCALE):
"""Build a flat BGR image of ``bg_value`` with the sparkle composited in.
The mark is composited at a LOWER effective opacity than the engine's captured
alpha map (``alpha_scale`` < 1), reproducing the real-world mismatch behind
issue #30: the captured alpha (~0.51) over-estimates a real sparkle whose
effective opacity is lower, so the fixed-alpha reverse blend over-subtracts.
Placed at the configured large-image position so the detector locates it.
"""
img = np.full((size, size, 3), bg_value, dtype=np.float32)
config = get_watermark_config(size, size)
x, y = config.get_position(size, size)
alpha = self.engine.get_alpha_map(WatermarkSize.LARGE)
ah, aw = alpha.shape[:2]
a = (alpha * alpha_scale)[:, :, None]
roi = img[y : y + ah, x : x + aw]
img[y : y + ah, x : x + aw] = a * 255.0 + (1.0 - a) * roi
return np.clip(img, 0, 255).astype(np.uint8), (x, y, aw, ah)
def test_dark_background_does_not_leave_black_pit(self):
image, (x, y, w, h) = self._composite_sparkle(bg_value=60)
out = self.engine.remove_watermark(image)
footprint = out[y : y + h, x : x + w]
# The recovered footprint must read like the dark background, not a black hole.
assert footprint.min() > 25, f"black pit: min={footprint.min()}"
assert abs(float(footprint.mean()) - 60.0) < 25.0
def test_bright_background_keeps_reverse_alpha(self):
"""A bright background does not over-subtract, so reverse-alpha is used."""
bright, pos = self._composite_sparkle(bg_value=230)
alpha = self.engine.get_interpolated_alpha(pos[2])
assert self.engine._reverse_alpha_oversubtracts(bright, alpha, (pos[0], pos[1])) is False
dark, dpos = self._composite_sparkle(bg_value=60)
dalpha = self.engine.get_interpolated_alpha(dpos[2])
assert self.engine._reverse_alpha_oversubtracts(dark, dalpha, (dpos[0], dpos[1])) is True
def test_midtone_background_does_not_leave_dark_diamond(self):
"""2026-06-18 prod report: a faint sparkle on a MID-TONE background was
over-subtracted into a darker-than-background diamond ("the color just
changed, not removed"). No footprint pixel crosses zero there, so the
numerator gate misses it -- the dark-margin gate must catch it and inpaint.
"""
image, (x, y, w, h) = self._composite_sparkle(bg_value=160)
footprint = image[y : y + h, x : x + w]
# The numerator (black-pit) gate alone does NOT fire on a mid-tone background.
alpha = self.engine.get_interpolated_alpha(w)
roi = footprint.astype(np.float32).mean(axis=2)
body = alpha[:h, :w] >= self.engine._FOOTPRINT_ALPHA
numerator = roi - np.clip(alpha[:h, :w], 0.0, 0.99) * self.engine.logo_value
assert float((numerator[body] < 0).sum()) / float(body.sum()) <= self.engine._OVERSUB_FOOTPRINT_FRAC
# ...but the over-subtraction guard still trips (via the dark-margin path) and
# removal leaves the footprint reading like the mid-tone background, not darker.
assert self.engine._reverse_alpha_oversubtracts(image, alpha, (x, y)) is True
out = self.engine.remove_watermark(image)
cleaned = out[y : y + h, x : x + w]
assert abs(float(cleaned.mean()) - 160.0) < 15.0, f"dark diamond: mean={cleaned.mean()}"
assert int(cleaned.min()) > 160 - 30, f"dark pit: min={cleaned.min()}"
class TestUnderSubtractionGain:
"""Under-subtraction fix: a sparkle MORE opaque than the captured alpha must not
survive removal. The captured alpha (~0.51) under-represents such marks, so the
fixed-alpha reverse blend leaves a bright residual; the per-image gain scales the
alpha up to match this image's opacity. Mirror of TestOverSubtractionGuard.
"""
@pytest.fixture(autouse=True)
def _setup_engine(self):
self.engine = GeminiEngine()
def _composite_sparkle(self, bg_value: int, alpha_scale: float, size: int = 1400):
"""Flat ``bg_value`` image with the sparkle composited at ``alpha_scale`` opacity.
``alpha_scale`` > 1 makes the mark MORE opaque than the engine's captured alpha,
reproducing the under-subtraction case (real under-removed marks estimate ~1.47).
"""
img = np.full((size, size, 3), bg_value, dtype=np.float32)
config = get_watermark_config(size, size)
x, y = config.get_position(size, size)
alpha = self.engine.get_alpha_map(WatermarkSize.LARGE)
ah, aw = alpha.shape[:2]
a = np.clip(alpha * alpha_scale, 0.0, 1.0)[:, :, None]
roi = img[y : y + ah, x : x + aw]
img[y : y + ah, x : x + aw] = a * 255.0 + (1.0 - a) * roi
return np.clip(img, 0, 255).astype(np.uint8), (x, y, aw, ah)
def test_more_opaque_sparkle_estimates_gain_above_deadband(self):
image, pos = self._composite_sparkle(bg_value=80, alpha_scale=1.3)
alpha = self.engine.get_interpolated_alpha(pos[2])
gain = self.engine._estimate_alpha_gain(image, alpha, (pos[0], pos[1]))
assert gain > self.engine._ALPHA_GAIN_DEADBAND, f"gain {gain} did not exceed deadband"
def test_matching_sparkle_estimates_unit_gain(self):
"""A sparkle that matches the captured opacity gets ~1.0 (no scaling)."""
image, pos = self._composite_sparkle(bg_value=80, alpha_scale=1.0)
alpha = self.engine.get_interpolated_alpha(pos[2])
gain = self.engine._estimate_alpha_gain(image, alpha, (pos[0], pos[1]))
assert gain <= self.engine._ALPHA_GAIN_DEADBAND, f"matching sparkle scaled by {gain}"
def test_more_opaque_sparkle_is_removed(self):
"""The gain-scaled removal clears a more-opaque sparkle without a black pit.
Asserted on the footprint PIXELS, not the detector: the detector's NCC is
degenerate on a perfectly flat synthetic background (zero-variance regions
spuriously match), so a re-detect conf is meaningless here -- on real textured
images the same removal drops the detector from ~0.80 to ~0.27 (spaces corpus).
"""
image, (x, y, w, h) = self._composite_sparkle(bg_value=80, alpha_scale=1.3)
assert self.engine.detect_watermark(image).detected
before_max = int(image[y : y + h, x : x + w].max()) # bright sparkle present
assert before_max > 150
out = self.engine.remove_watermark(image)
footprint = out[y : y + h, x : x + w]
# Sparkle gone: no bright residual, no black pit, footprint reads like the bg.
assert int(footprint.max()) < 80 + 30, f"bright residual: max={footprint.max()}"
assert int(footprint.min()) > 25, f"black pit: min={footprint.min()}"
assert abs(float(footprint.mean()) - 80.0) < 20.0
class TestVerifyAndRepair:
"""Self-verify fallback: a sparkle that survives reverse-alpha is inpaint-repaired,
but only when that lowers the re-detect confidence (so it can never regress).
The detector NCC is degenerate on flat synthetic backgrounds, so the keep-best
control flow is driven through a stubbed ``detect_watermark`` rather than a real
re-detect (mirroring the reasoning in TestUnderSubtractionGain).
"""
@pytest.fixture(autouse=True)
def _setup_engine(self):
self.engine = GeminiEngine()
self.alpha = self.engine.get_interpolated_alpha(96)
self.pos = (200, 200)
def _stub_detect(self, confidences):
"""detect_watermark stub yielding the given confidences in order."""
seq = iter(confidences)
def fake(image, force_size=None):
return DetectionResult(detected=True, confidence=next(seq))
return fake
def _repair(self, result):
return self.engine._verify_and_repair(result, self.alpha, self.pos, WatermarkSize.LARGE)
def test_clean_removal_returned_untouched(self, monkeypatch):
"""Below the fallback threshold, the input is returned byte-identical."""
img = np.full((600, 600, 3), 90, dtype=np.uint8)
monkeypatch.setattr(self.engine, "detect_watermark", self._stub_detect([0.2]))
out = self._repair(img)
assert out is img # no copy, no inpaint
def test_keeps_footprint_inpaint_when_it_helps(self, monkeypatch):
"""A surviving sparkle is footprint-inpainted when that re-detects lower; the
footprint pixels change."""
img = np.full((600, 600, 3), 90, dtype=np.uint8)
# Bright residual block over the footprint so the inpaint visibly changes it.
img[self.pos[1] : self.pos[1] + 96, self.pos[0] : self.pos[0] + 96] = 240
# residual 0.7 (>= 0.5 triggers), candidate re-detects 0.2 (< residual -> keep).
monkeypatch.setattr(self.engine, "detect_watermark", self._stub_detect([0.7, 0.2]))
out = self._repair(img)
assert out is not img
assert not np.array_equal(out, img) # footprint was inpainted from surroundings
def test_repair_rejected_when_inpaint_does_not_help(self, monkeypatch):
"""When the inpaint does not lower the re-detect confidence, keep the original."""
img = np.full((600, 600, 3), 90, dtype=np.uint8)
# residual 0.7, candidate re-detects 0.75 (>= residual -> reject the inpaint).
monkeypatch.setattr(self.engine, "detect_watermark", self._stub_detect([0.7, 0.75]))
out = self._repair(img)
assert out is img
class TestSparkleFalsePositiveGate:
"""False-positive gate: a low-confidence shape match whose core is NOT brighter
than its surroundings (ornate/flat content, not a white sparkle overlay) is
@@ -514,6 +292,51 @@ class TestSparkleFalsePositiveGate:
assert det.confidence < 0.5
assert not det.detected
def test_bright_white_blurred_sparkle_rescued(self):
"""A strong, bright, WHITE blurred sparkle is now KEPT (white-core rescue). A
faint / re-compressed real sparkle has soft edges (low gradient) like a smooth
blob, but its core is bright AND near-WHITE and its fused confidence clears the
trust gate, so the rescue (``_SPARKLE_WHITE_SAT`` / ``_SPARKLE_KEEP_CONF``) keeps
it -- recovering the metadata-stripped faint sparkles the grad gate used to drop.
The weaker (~0.51) bright-background FPs the grad gate was added for still get
demoted, because they fall below ``_SPARKLE_KEEP_CONF`` (0.52); the colored-blob
case below is demoted on core saturation.
"""
size = 1400
config = get_watermark_config(size, size)
x, y = config.get_position(size, size)
alpha = self.engine.get_alpha_map(WatermarkSize.LARGE)
ah, aw = alpha.shape[:2]
img = np.full((size, size, 3), 110, dtype=np.float32)
a = np.clip(alpha * 1.6, 0.0, 1.0)[:, :, None]
img[y : y + ah, x : x + aw] = a * 255.0 + (1.0 - a) * img[y : y + ah, x : x + aw]
img = cv2.GaussianBlur(np.clip(img, 0, 255).astype(np.uint8), (39, 39), 0)
det = self.engine.detect_watermark(img)
assert det.gradient_score < self.engine._SPARKLE_FP_GRAD # soft edges, low gradient
assert det.confidence >= self.engine._SPARKLE_KEEP_CONF # strong white-core match, kept
assert det.detected
def test_bright_colored_blob_still_demoted(self):
"""The white-core rescue must NOT re-admit a COLORED bright-corner FP (sky, sun,
a warm light): its core saturation is high, so the rescue does not apply and the
gradient gate still demotes it. Same blurred construction as the white sparkle
above, but tinted -- proving the discriminator is core color, not just brightness.
"""
size = 1400
config = get_watermark_config(size, size)
x, y = config.get_position(size, size)
alpha = self.engine.get_alpha_map(WatermarkSize.LARGE)
ah, aw = alpha.shape[:2]
img = np.full((size, size, 3), 110, dtype=np.float32)
a = np.clip(alpha * 1.6, 0.0, 1.0)[:, :, None]
color = np.array([40.0, 140.0, 235.0]) # BGR warm/orange -> saturated (non-white) core
img[y : y + ah, x : x + aw] = a * color + (1.0 - a) * img[y : y + ah, x : x + aw]
img = cv2.GaussianBlur(np.clip(img, 0, 255).astype(np.uint8), (39, 39), 0)
det = self.engine.detect_watermark(img)
assert det.gradient_score < self.engine._SPARKLE_FP_GRAD
assert det.confidence < 0.5
assert not det.detected
class TestCornerPromotion:
"""Issue #36: a small sparkle in the corner must not be lost to a larger decoy.
+26
View File
@@ -1,4 +1,5 @@
import numpy as np
import pytest
from remove_ai_watermarks.humanizer import apply_analog_humanizer, unsharp_mask
@@ -72,6 +73,15 @@ def test_chromatic_shift_does_not_wrap_opposite_edge():
assert result[:, -shift:, 2].min() > 195
@pytest.mark.parametrize(("width", "shift"), [(1, 1), (3, 3), (3, 5), (5, 10)])
def test_chromatic_shift_wider_than_image_no_crash(width: int, shift: int):
"""Regression: a chromatic_shift >= image width left the edge-replication slices
empty and crashed the broadcast (ValueError). The shift must clamp to width-1."""
img = np.full((4, width, 3), 120, np.uint8)
result = apply_analog_humanizer(img, grain_intensity=0.0, chromatic_shift=shift)
assert result.shape == img.shape
def test_unsharp_disabled_returns_unchanged_copy():
img = np.full((20, 20, 3), 128, dtype=np.uint8)
img[10, 10] = [100, 150, 200]
@@ -147,3 +157,19 @@ class TestAdaptivePolish:
a = adaptive_polish(soft, reference, seed=7)
b = adaptive_polish(soft, reference, seed=7)
assert np.array_equal(a, b)
def test_all_edges_reference_grain_mask_near_zero(self):
# An all-high-frequency target: _smooth_grain_mask suppresses edges, so the grain
# mask is ~all-zero (grain adds nothing) -- adaptive_polish must still return a
# valid same-shape image, not crash on the empty-mask branch.
import cv2
from remove_ai_watermarks.humanizer import _smooth_grain_mask, adaptive_polish
rng = np.random.default_rng(5)
edges = rng.integers(0, 256, (120, 120, 3), dtype=np.uint8) # all high-frequency
assert _smooth_grain_mask(edges).mean() < _smooth_grain_mask(np.full((120, 120, 3), 128, np.uint8)).mean()
soft = cv2.GaussianBlur(edges, (0, 0), sigmaX=3.0)
out = adaptive_polish(soft, edges, seed=0)
assert out.shape == soft.shape
assert out.dtype == np.uint8
+229
View File
@@ -22,8 +22,10 @@ from remove_ai_watermarks.identify import (
_integrity_clashes,
_issuers_in,
_vendor_of,
has_invisible_target,
identify,
)
from remove_ai_watermarks.watermark_registry import GEMINI_SPARKLE_TRUST_CONF
# Where the lazy import inside identify._visible_sparkle resolves the detector.
_SPARKLE_TARGET = "remove_ai_watermarks.gemini_engine.detect_sparkle_confidence"
@@ -66,6 +68,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
@@ -127,6 +142,35 @@ class TestIdentifyNonPng:
assert r.is_ai_generated is True
assert "ByteDance" in (r.platform or "")
def test_bytedance_chinese_legal_name_attributed(self, tmp_path: Path):
# Some Volcano Engine certs name the signer with the Chinese legal entity
# rather than the latin "volcengine"; the latin needle misses it, so the
# Chinese-name registry entry is what attributes real ByteDance output.
blob = "北京火山引擎科技有限公司".encode() + b" ... trainedAlgorithmicMedia"
path = self._c2pa_jpeg(tmp_path, blob)
r = identify(path, check_visible=False, check_invisible=False)
assert r.is_ai_generated is True
assert "ByteDance" in (r.platform or "")
def test_dreamina_attributed_without_source_type(self, tmp_path: Path):
# Dreamina (ByteDance's international Jimeng brand) signs C2PA as
# "Bytedance Pte. Ltd." with a "Dreamina/x.y" claim generator and NO
# digitalSourceType assertion -- the generator name is the only AI signal.
# It is an identity-AI vendor (a pure generator), so attribution must not
# depend on trainedAlgorithmicMedia the way the incidental-mention-prone
# common-word issuers (Adobe/Google/OpenAI) do.
path = self._c2pa_jpeg(tmp_path, b"Bytedance Pte. Ltd. Dreamina/7.5.0 c2pa.created")
r = identify(path, check_visible=False, check_invisible=False)
assert r.is_ai_generated is True
assert "ByteDance" in (r.platform or "")
def test_elevenlabs_attributed(self, tmp_path: Path):
path = self._c2pa_jpeg(tmp_path, b"Eleven Labs Inc. ... trainedAlgorithmicMedia")
r = identify(path, check_visible=False, check_invisible=False)
assert r.is_ai_generated is True
assert r.platform == "ElevenLabs"
assert not any("SynthID" in w for w in r.watermarks) # ElevenLabs does not use SynthID
def test_stability_ai_issuer_attributed_no_synthid(self, tmp_path: Path):
path = self._c2pa_jpeg(tmp_path, b"Stability AI ... trainedAlgorithmicMedia")
r = identify(path, check_visible=False)
@@ -135,6 +179,21 @@ class TestIdentifyNonPng:
assert "Stability AI" in r.platform
assert not any("SynthID" in w for w in r.watermarks) # Stability does not use SynthID
def test_trained_source_is_generated_kind(self, tmp_path: Path):
path = self._c2pa_jpeg(tmp_path, b"OpenAI ... trainedAlgorithmicMedia")
r = identify(path, check_visible=False, check_invisible=False)
assert r.is_ai_generated is True
assert r.ai_source_kind == "generated"
def test_composite_source_is_enhanced_kind(self, tmp_path: Path):
# compositeWithTrainedAlgorithmicMedia: a real photo with an AI-composited
# region. Still AI (is_ai True), but the kind must read "enhanced" so a
# caller can do region-targeted cleaning instead of a full-frame regen.
path = self._c2pa_jpeg(tmp_path, b"Adobe ... compositeWithTrainedAlgorithmicMedia")
r = identify(path, check_visible=False, check_invisible=False)
assert r.is_ai_generated is True
assert r.ai_source_kind == "enhanced"
def test_c2pa_without_ai_marker_is_unknown(self, tmp_path: Path):
# Adobe signs C2PA on plain Photoshop edits too. Without an AI digital-
# source marker, the honest verdict is unknown -- the C2PA watermark is
@@ -189,6 +248,16 @@ class TestIdentifySamsungGalaxy:
assert r.platform == "ASUS Gallery (C2PA signer)"
assert any("C2PA" in w for w in r.watermarks)
def test_galaxy_capture_without_ai_marker_is_not_ai(self, tmp_path: Path):
# A genuine Galaxy phone capture carries Samsung Galaxy C2PA provenance but
# NO AI source-type / genAIType. It must stay is_ai=None -- the device cert
# is authenticity provenance of a real photo, not an AI-generation signal.
path = self._jpeg(tmp_path, "s25_capture.jpg", b"Samsung Galaxy Galaxy S25 c2pa-rs no ai marker")
r = identify(path, check_visible=False, check_invisible=False)
assert r.is_ai_generated is None
assert r.platform == "Samsung Galaxy (C2PA)"
assert any("C2PA" in w for w in r.watermarks)
# ── End-to-end verdicts on real fixtures ────────────────────────────
@@ -216,6 +285,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
@@ -223,6 +305,19 @@ class TestIdentifyRealSamples:
assert r.confidence == "none"
assert r.watermarks == []
def test_has_invisible_target_true_on_metadata_ai(self):
# The scrub gate: a C2PA/SynthID image and an IPTC "Made with AI" image are
# both invisible/metadata targets, so the diffusion scrub should run.
assert has_invisible_target(SAMPLES_DIR / "chatgpt-1.png") is True
assert has_invisible_target(SAMPLES_DIR / "mj-1.png") is True
# ai_from_metadata mirrors confidence == "high" and backs the helper.
assert identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False).ai_from_metadata is True
def test_has_invisible_target_false_on_clean_photo(self, clean_photo: Path):
# No detectable invisible signal -> skip the scrub (do not degrade a clean image).
assert has_invisible_target(clean_photo) is False
assert identify(clean_photo, check_visible=False).ai_from_metadata is False
def test_strip_caveat_always_present(self, clean_photo: Path):
r = identify(clean_photo, check_visible=False)
assert any("not proof" in c for c in r.caveats)
@@ -231,6 +326,30 @@ class TestIdentifyRealSamples:
assert isinstance(identify(SAMPLES_DIR / "firefly-1.png", check_visible=False), ProvenanceReport)
class TestHasInvisibleTargetFailSafe:
"""The scrub gate fails SAFE: when a detector errors, it runs the removal."""
def test_detector_error_defaults_to_run(self, tmp_path: Path):
# If identify raises (a detector crash), the gate must return True so the
# caller still attempts removal -- leaving a watermark on a paid removal is
# worse than over-regenerating. (Garbage bytes do NOT raise; identify returns
# a clean None verdict there, so that path correctly skips -- see below.)
bad = tmp_path / "x.png"
bad.write_bytes(b"not image bytes")
with patch("remove_ai_watermarks.identify.identify", side_effect=RuntimeError("boom")):
assert has_invisible_target(bad) is True
def test_unreadable_bytes_are_not_a_target(self, tmp_path: Path):
# No raise, no signal -> not a scrub target (the CLI rejects undecodable
# images earlier anyway; this only documents the gate's own verdict).
bad = tmp_path / "x.png"
bad.write_bytes(b"not image bytes")
assert has_invisible_target(bad) is False
def test_local_ai_params_are_a_target(self, tmp_png_with_ai_metadata: Path):
assert has_invisible_target(tmp_png_with_ai_metadata) is True
# ── Local diffusion parameters (Stable Diffusion / ComfyUI) ─────────
@@ -251,6 +370,12 @@ class TestIdentifyLocalParams:
assert "parameters" in signal.detail
assert signal.confidence == "high"
def test_local_gen_params_have_no_c2pa_source_kind(self, tmp_png_with_ai_metadata: Path):
# AI verdict from local SD params (not C2PA) -> ai_source_kind stays None.
r = identify(tmp_png_with_ai_metadata, check_visible=False)
assert r.is_ai_generated is True
assert r.ai_source_kind is None
def test_clean_png_is_unknown(self, tmp_clean_png: Path):
r = identify(tmp_clean_png, check_visible=False)
assert r.is_ai_generated is None
@@ -373,6 +498,66 @@ class TestIdentifyVisibleSparkle:
assert r.confidence == "high"
REPO_ROOT = Path(__file__).resolve().parent.parent
_DEMO_BEFORE = REPO_ROOT / "demo_banana_before.png"
_DEMO_AFTER = REPO_ROOT / "demo_banana_after.png"
@pytest.mark.skipif(not (_DEMO_BEFORE.exists() and _DEMO_AFTER.exists()), reason="demo banana pair not present")
class TestSparkleDetectRemoveAlignment:
"""Detect (identify) and remove (registry.detect_marks) must agree on the
same image -- the retained-corpus desync where identify reported a sparkle the
removal arbitration declined (or vice versa). Both gate on the single shared
GEMINI_SPARKLE_TRUST_CONF, so a sparkle just over the line is taken by BOTH
and one just under is declined by BOTH. Fixtures composite the real captured
sparkle (before-minus-after) back at reduced opacity to land on either side.
"""
@staticmethod
def _faint_sparkle(tmp_path: Path, opacity: float) -> Path:
import numpy as np
from remove_ai_watermarks import image_io
before = image_io.imread(_DEMO_BEFORE).astype("float32")
after = image_io.imread(_DEMO_AFTER).astype("float32")
faint = np.clip(after + opacity * (before - after), 0, 255).astype("uint8")
out = tmp_path / f"sparkle_{int(opacity * 100)}.png"
image_io.imwrite(out, faint)
return out
def _detect_remove(self, path: Path) -> tuple[bool, bool, float]:
from remove_ai_watermarks import image_io, watermark_registry
from remove_ai_watermarks.gemini_engine import detect_sparkle_confidence
conf = detect_sparkle_confidence(path) or 0.0
identify_fires = conf >= GEMINI_SPARKLE_TRUST_CONF
dets = watermark_registry.detect_marks(image_io.imread(path), include_explicit=False)
remove_takes_gemini = any(d.key == "gemini" and d.detected for d in dets)
return identify_fires, remove_takes_gemini, conf
def test_above_threshold_both_fire(self, tmp_path: Path):
path = self._faint_sparkle(tmp_path, 0.7) # ~0.55 conf, just over the line
identify_fires, remove_takes, conf = self._detect_remove(path)
assert conf >= GEMINI_SPARKLE_TRUST_CONF
assert identify_fires, f"identify declined a sparkle above threshold (conf={conf:.3f})"
assert remove_takes, f"removal declined a sparkle above threshold (conf={conf:.3f})"
def test_below_threshold_both_decline(self, tmp_path: Path):
path = self._faint_sparkle(tmp_path, 0.5) # ~0.37 conf, just under the line
identify_fires, remove_takes, conf = self._detect_remove(path)
assert conf < GEMINI_SPARKLE_TRUST_CONF
assert not identify_fires, f"identify fired below threshold (conf={conf:.3f})"
assert not remove_takes, f"removal fired below threshold (conf={conf:.3f})"
def test_full_strength_both_fire(self):
# The shipped demo sparkle at full strength: unambiguous agreement.
identify_fires, remove_takes, conf = self._detect_remove(_DEMO_BEFORE)
assert conf >= GEMINI_SPARKLE_TRUST_CONF
assert identify_fires
assert remove_takes
class TestIdentifyImportIsLight:
"""`import identify` must stay torch-free (lazy noai/__init__): the package
is deployed on a 512 MB host where eagerly pulling torch/diffusers OOMs."""
@@ -514,6 +699,16 @@ class TestOpenAiCaveatVendorScoped:
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)
def test_dreamina_png_cabx_without_source_type(self, tmp_path: Path):
# Real Dreamina PNGs carry the "Dreamina/x.y" generator in an ingredient
# manifest (the active one is often a c2pa-tool transcode), so the caBX
# byte-scan -- which sees the whole store -- is the reliable detector, and
# there is no trainedAlgorithmicMedia to lean on.
png = self._png(tmp_path, "dreamina.png", self._png_chunk(b"caBX", b"jumbc2pa Dreamina/7.5.0 c2pa.created"))
r = identify(png, check_visible=False, check_invisible=False)
assert r.is_ai_generated is True
assert "ByteDance" in (r.platform or "")
class TestReportSerializable:
def test_report_is_json_serializable(self, tmp_png_with_ai_metadata: Path):
@@ -731,6 +926,15 @@ class TestVendorOf:
assert _vendor_of("a regular photo") is None
assert _vendor_of(None) is None
def test_registered_vendors_normalize(self):
# Regression: these registered C2PA vendors returned None, so their claims never
# entered clash detection (a coverage hole). They now normalize to one origin.
assert _vendor_of("ByteDance (Doubao / Jimeng / Volcano Engine)") == "ByteDance"
assert _vendor_of("Dreamina/1.2") == "ByteDance"
assert _vendor_of("Canva (Magic Media)") == "Canva"
assert _vendor_of("Black Forest Labs (FLUX)") == "Black Forest Labs"
assert _vendor_of("Eleven Labs Inc.") == "ElevenLabs"
class TestIntegrityClashesHelper:
def test_two_ai_vendors_clash(self):
@@ -754,6 +958,31 @@ class TestIntegrityClashesHelper:
== []
)
def test_bytedance_c2pa_plus_own_aigc_no_clash(self):
# A legit ByteDance/Doubao image carries BOTH a ByteDance C2PA manifest and its
# own China TC260 AIGC label. The label is ByteDance's own regulatory stamp, so
# it must be attributed to ByteDance and NOT read as a competing origin.
assert (
_integrity_clashes({"c2pa": "ByteDance", "aigc": "China AIGC (TC260)"}, None, camera_has_ai_marker=True)
== []
)
def test_foreign_vendor_plus_aigc_still_clashes(self):
# But a NON-Chinese vendor's C2PA next to a China TC260 label names two different
# origins -- a laundering tell that must still fire (the generic label stays generic).
clashes = _integrity_clashes({"c2pa": "OpenAI", "aigc": "China AIGC (TC260)"}, None, camera_has_ai_marker=True)
assert len(clashes) == 1
assert "Conflicting AI-origin" in clashes[0]
def test_bytedance_c2pa_plus_foreign_generator_clashes(self):
# Coverage win: a transplanted ByteDance C2PA manifest next to an independent
# foreign generator stamp is a laundering tell that went undetected before
# ByteDance was added to _vendor_of.
clashes = _integrity_clashes({"c2pa": "ByteDance", "exif_generator": "OpenAI"}, None, camera_has_ai_marker=True)
assert len(clashes) == 1
assert "ByteDance" in clashes[0]
assert "OpenAI" in clashes[0]
def test_manifest_vendor_vs_independent_signal_clashes(self):
# A vendor named only inside the manifest still clashes with a genuinely
# independent stamp (here an EXIF/XMP generator tag) naming a third vendor.
+104
View File
@@ -2,10 +2,12 @@
from __future__ import annotations
import io
from typing import TYPE_CHECKING
import cv2
import numpy as np
import pytest
from remove_ai_watermarks import image_io
@@ -103,3 +105,105 @@ class TestFailureSemantics:
# An unwritable path must return False (cv2.imwrite contract), not raise.
path = tmp_path / "no-such-dir" / "out.png"
assert image_io.imwrite(path, _make_bgr()) is False
def _avif_writable() -> bool:
"""True when the installed Pillow can ENCODE AVIF (needed to synthesize a fixture);
read support is what we test, but we need a writer to make the sample."""
from PIL import Image, features
if not (hasattr(features, "check") and features.check("avif")):
return False
try:
Image.fromarray(np.zeros((8, 8, 3), np.uint8)).save(io.BytesIO(), format="AVIF")
return True
except Exception:
return False
_HAS_AVIF_WRITE = _avif_writable()
class TestPillowFallback:
"""OpenCV cannot decode HEIC/AVIF; :func:`imread` falls back to Pillow (with the
pillow-heif plugin) so the pixel/removal path reads them. Uses a synthetic AVIF
(no corpus files); HEIC is exercised the same way in production."""
@pytest.mark.skipif(not _HAS_AVIF_WRITE, reason="Pillow AVIF encoder unavailable")
def test_avif_decodes_via_fallback(self, tmp_path: Path) -> None:
from PIL import Image
path = tmp_path / "x.avif"
Image.fromarray(np.full((32, 48, 3), (10, 20, 200), np.uint8), "RGB").save(path)
img = image_io.imread(path) # cv2 fails on AVIF -> Pillow fallback
assert img is not None
assert img.shape == (32, 48, 3)
# Source RGB (R=10, G=20, B=200) -> BGR, so blue [...,0] is high, red [...,2] low
# (thresholds loose for AVIF's lossy compression).
assert int(img[0, 0, 0]) > 150
assert int(img[0, 0, 2]) < 70
@pytest.mark.skipif(not _HAS_AVIF_WRITE, reason="Pillow AVIF encoder unavailable")
def test_avif_alpha_survives_read_bgr_and_alpha(self, tmp_path: Path) -> None:
from PIL import Image
path = tmp_path / "x.avif"
rgba = np.dstack([np.full((20, 20, 3), 80, np.uint8), np.full((20, 20), 128, np.uint8)])
Image.fromarray(rgba, "RGBA").save(path)
bgr, alpha = image_io.read_bgr_and_alpha(path)
assert bgr is not None
assert bgr.shape == (20, 20, 3)
assert alpha is not None # the source alpha plane survives the fallback
assert alpha.shape == (20, 20)
def _heif_writable(fmt: str) -> bool:
try:
image_io._register_heif()
from PIL import Image
Image.fromarray(np.zeros((8, 8, 3), np.uint8), "RGB").save(io.BytesIO(), format=fmt, quality=100)
return True
except Exception:
return False
class TestQualityPreservingWrite:
"""The removal only touches the mark footprint, so the container re-encode must
not degrade the untouched pixels: JPEG/WebP are written at max quality, HEIC/AVIF
(which cv2 cannot encode) via Pillow instead of crashing."""
def test_jpeg_written_near_lossless(self, tmp_path: Path) -> None:
# a smooth gradient -> JPEG at quality 100 / 4:4:4 is near-lossless
g = np.tile(np.linspace(30, 210, 64, dtype=np.uint8), (48, 1))
img = np.dstack([g, g, g])
p = tmp_path / "x.jpg"
assert image_io.imwrite(p, img) is True
back = image_io.imread(p)
assert back is not None
assert float(np.abs(img.astype(int) - back.astype(int)).mean()) < 1.0
def test_webp_written_lossless(self, tmp_path: Path) -> None:
# Regression: cv2 WebP quality 1-100 is LOSSY; lossless needs > 100. A
# mark-removal .webp re-encode must NOT degrade the untouched pixels, so
# a full-frame round-trip of random data must be bit-identical.
img = np.random.default_rng(0).integers(0, 256, (80, 80, 3), dtype=np.uint8)
p = tmp_path / "x.webp"
assert image_io.imwrite(p, img) is True
back = image_io.imread(p)
assert back is not None
assert np.array_equal(back, img), "WebP re-encode was lossy"
@pytest.mark.skipif(not _heif_writable("HEIF"), reason="no HEIC encoder in this env")
def test_heic_write_roundtrips(self, tmp_path: Path) -> None:
# cv2 cannot encode HEIC (used to raise); imwrite must route through Pillow.
img = np.full((32, 48, 3), (30, 140, 200), np.uint8)
p = tmp_path / "x.heic"
assert image_io.imwrite(p, img) is True
back = image_io.imread(p)
assert back is not None
assert back.shape == (32, 48, 3)
def test_unencodable_ext_returns_false_not_raises(self, tmp_path: Path) -> None:
# a bogus extension cv2 can't encode returns False (never raises the cv2.error).
assert image_io.imwrite(tmp_path / "x.zzz", _make_bgr()) is False
+138
View File
@@ -0,0 +1,138 @@
"""Visible removal via localize -> fill: backend resolution, footprint masks, dispatch.
Every known mark is removed by LOCALIZING it to a full-frame footprint mask and
handing that mask to ONE shared fill backend (MI-GAN when the ``migan`` extra is
installed, else cv2). These tests avoid any ONNX model download by pinning the
backend to cv2; only pure cv2/numpy paths run.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import cv2
import numpy as np
from remove_ai_watermarks import watermark_registry as registry
from remove_ai_watermarks._text_mark_engine import load_alpha_template
from remove_ai_watermarks.doubao_engine import DoubaoEngine
from remove_ai_watermarks.gemini_engine import GeminiEngine
if TYPE_CHECKING:
import pytest
def _compose_textmark(engine, bg: float = 120.0, w: int = 1024, h: int = 1024):
"""Composite the engine's captured mark onto a flat ``bg`` at full opacity so
the mark is detectable. Returns ``(watermarked_uint8, (ax, ay, gw, gh))``."""
c = engine.config
at = load_alpha_template(c.asset_name)
gw = max(c.min_gw, int(c.alpha_width_frac * w))
gh = max(4, int(c.alpha_height_frac * w))
margin = int(0.015 * w)
ax = (w - margin - gw) if c.corner == "br" else margin
ay = h - margin - gh
block = cv2.resize(at, (gw, gh))
img = np.full((h, w, 3), float(bg), np.float32)
a = np.clip(block, 0.0, 0.99)[:, :, None]
img[ay : ay + gh, ax : ax + gw] = img[ay : ay + gh, ax : ax + gw] * (1 - a) + 255.0 * a
return np.clip(img, 0, 255).astype(np.uint8), (ax, ay, gw, gh)
class TestResolveBackend:
def test_auto_resolves_to_available_backend(self) -> None:
# auto picks the best available model (LaMa > MI-GAN) or cv2; any is fine.
assert registry.resolve_backend("auto") in {"cv2", "migan", "lama"}
def test_cv2_passthrough(self) -> None:
assert registry.resolve_backend("cv2") == "cv2"
def test_lama_passthrough(self) -> None:
assert registry.resolve_backend("lama") == "lama"
class TestFootprintMask:
def test_textmark_footprint_geometry(self) -> None:
# A clean flat corner has no glyph, so force=True yields the geometry box.
mask = DoubaoEngine().footprint_mask(np.full((1024, 1024, 3), 120, np.uint8), force=True)
assert mask is not None
assert mask.shape == (1024, 1024)
assert mask.dtype == np.uint8
assert mask.any()
# Doubao sits bottom-right: the mask mass is in the bottom-right quadrant.
ys, xs = np.where(mask > 0)
assert ys.mean() > 512
assert xs.mean() > 512
def test_textmark_small_image_returns_none(self) -> None:
assert DoubaoEngine().footprint_mask(np.full((20, 20, 3), 120, np.uint8)) is None
def test_gemini_footprint_needs_detection_or_force(self) -> None:
eng = GeminiEngine()
clean = np.full((1024, 1024, 3), 128, np.uint8)
assert eng.footprint_mask(clean) is None # nothing detected -> no mask
forced = eng.footprint_mask(clean, force=True) # default sparkle slot
assert forced is not None
assert forced.any()
class TestFillDispatch:
"""Force the cv2 backend so no ONNX model downloads; the dispatch/gating logic
is backend-agnostic."""
def test_clean_image_is_untouched(self) -> None:
img = np.full((1024, 1024, 3), 120, np.uint8)
out, region = registry.get_mark("doubao").remove(img, backend="cv2")
assert region is None
assert np.array_equal(out, img) # not detected, not forced -> no-op
def test_forced_fill_edits_only_footprint(self) -> None:
img, (ax, ay, gw, gh) = _compose_textmark(DoubaoEngine())
out, _ = registry.get_mark("doubao").remove(img, backend="cv2", force=True)
assert not np.array_equal(out[ay : ay + gh, ax : ax + gw], img[ay : ay + gh, ax : ax + gw])
assert np.array_equal(out[:200, :200], img[:200, :200]) # far corner untouched
def test_detected_fill_lowers_confidence(self) -> None:
mark = registry.get_mark("doubao")
img, _ = _compose_textmark(DoubaoEngine())
before = mark.detect(img)
assert before.detected # the composed mark is detectable
out, region = mark.remove(img, backend="cv2")
assert region is not None
assert mark.detect(out).confidence < before.confidence
class TestBackendSelection:
"""auto resolves to the best available inpaint backend: LaMa > MI-GAN > cv2.
cv2 is the floor when no learned ONNX model is present (and warns once)."""
def test_prefers_lama_when_available(self, monkeypatch: pytest.MonkeyPatch) -> None:
from remove_ai_watermarks import region_eraser
monkeypatch.setattr(region_eraser, "lama_available", lambda: True)
monkeypatch.setattr(region_eraser, "migan_available", lambda: True)
assert registry.preferred_inpaint_backend() == "lama"
def test_migan_when_only_migan(self, monkeypatch: pytest.MonkeyPatch) -> None:
from remove_ai_watermarks import region_eraser
monkeypatch.setattr(region_eraser, "lama_available", lambda: False)
monkeypatch.setattr(region_eraser, "migan_available", lambda: True)
assert registry.preferred_inpaint_backend() == "migan"
def test_cv2_when_no_model(self, monkeypatch: pytest.MonkeyPatch) -> None:
from remove_ai_watermarks import region_eraser
monkeypatch.setattr(region_eraser, "lama_available", lambda: False)
monkeypatch.setattr(region_eraser, "migan_available", lambda: False)
monkeypatch.setattr(registry, "_warned_cv2_fallback", True)
assert registry.preferred_inpaint_backend() == "cv2"
def test_inpaint_model_available_reflects_either(self, monkeypatch: pytest.MonkeyPatch) -> None:
from remove_ai_watermarks import region_eraser
monkeypatch.setattr(region_eraser, "migan_available", lambda: False)
monkeypatch.setattr(region_eraser, "lama_available", lambda: False)
assert not registry.inpaint_model_available()
monkeypatch.setattr(region_eraser, "lama_available", lambda: True)
assert registry.inpaint_model_available()
+22
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from types import SimpleNamespace
import pytest
from PIL import Image
from remove_ai_watermarks.invisible_engine import InvisibleEngine, _target_size, is_available
@@ -195,3 +196,24 @@ class TestEsrganUpscale:
out = InvisibleEngine._esrgan_upscale(self._fake_engine(), img, (512, 341))
assert out.size == (512, 341)
assert np.array_equal(np.asarray(out), np.asarray(img.resize((512, 341), Image.Resampling.LANCZOS)))
class TestCannyControlImage:
"""The ControlNet canny conditioning image builder (pure cv2/numpy; behind the gpu
extra since it lives on WatermarkRemover). Skips when torch/diffusers are absent."""
def test_edge_map_is_3channel_rgb(self):
if not is_available():
pytest.skip("gpu extra (torch/diffusers) not installed")
import numpy as np
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover
rng = np.random.default_rng(0)
img = Image.fromarray(rng.integers(0, 256, (64, 80, 3), dtype=np.uint8))
# The method uses no instance state, so call it unbound with a dummy self.
out = WatermarkRemover._build_canny_control_image(None, img) # type: ignore[arg-type]
arr = np.array(out)
assert out.mode == "RGB"
assert arr.shape == (64, 80, 3)
assert arr.max() <= 255
+44 -67
View File
@@ -11,11 +11,9 @@ import cv2
import numpy as np
import pytest
from remove_ai_watermarks import watermark_registry as registry
from remove_ai_watermarks.jimeng_engine import (
_ALPHA_HEIGHT_FRAC,
_ALPHA_LOGO_BGR,
_ALPHA_MARGIN_BOTTOM_FRAC,
_ALPHA_MARGIN_RIGHT_FRAC,
_ALPHA_NATIVE_WIDTH,
_ALPHA_WIDTH_FRAC,
DETECT_NCC_THRESHOLD,
@@ -32,12 +30,13 @@ def _compose(w: int, h: int, bg: float = 100.0):
img = np.full((h, w, 3), bg, np.float32)
at = _alpha_template()
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
ax = w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw
ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh
margin = int(0.015 * w)
ax = w - margin - gw
ay = h - margin - gh
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * img).clip(0, 255).astype(np.uint8)
wm = (a3 * 255.0 + (1 - a3) * img).clip(0, 255).astype(np.uint8)
return wm, amap > 0.2
@@ -86,6 +85,16 @@ class TestDetect:
solid = np.full_like(box, 255)
assert _template_match_score(solid, _ALPHA_NATIVE_WIDTH) < DETECT_NCC_THRESHOLD
def test_small_image_guarded_from_false_positive(self):
"""Below the minimum short side a tiny geometric shape spuriously NCC-matches
the CJK silhouette (2026-06-26 FP: a 48x48 app-icon chevron scored 0.47). The
size guard suppresses detection there. Bracket it: a real mark is detected at
native size, but the same content downscaled below the guard is not."""
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
eng = JimengEngine()
assert eng.detect(wm).detected # native: real mark detected
assert not eng.detect(cv2.resize(wm, (150, 150))).detected # below guard: suppressed
def test_synthetic_mark_detected(self):
"""A watermark composed from the real alpha is detected at its threshold."""
eng = JimengEngine()
@@ -95,7 +104,7 @@ class TestDetect:
assert det.confidence >= DETECT_NCC_THRESHOLD
class TestReverseAlpha:
class TestAlphaAssetAndRemoval:
def test_alpha_asset_loads(self):
at = _alpha_template()
assert at is not None
@@ -103,89 +112,57 @@ class TestReverseAlpha:
assert float(at.min()) >= 0.0
assert float(at.max()) <= 1.0
def test_logo_is_white(self):
assert _ALPHA_LOGO_BGR == (255.0, 255.0, 255.0)
def test_available_whenever_asset_present(self):
eng = JimengEngine()
assert eng.reverse_alpha_available(np.zeros((1024, 1024, 3), np.uint8))
assert eng.reverse_alpha_available(np.zeros((1440, 2560, 3), np.uint8))
assert not eng.reverse_alpha_available(np.zeros((0, 0, 3), np.uint8))
def test_footprint_mask_in_bottom_right(self):
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
mask = JimengEngine().footprint_mask(wm)
assert mask is not None
assert mask.shape == wm.shape[:2]
ys, xs = np.where(mask > 0)
assert ys.mean() > wm.shape[0] / 2
assert xs.mean() > wm.shape[1] / 2
def test_removes_synthetic_mark(self):
"""Reverse-alpha + residual inpaint clears the composed mark (re-detect
no longer fires)."""
eng = JimengEngine()
"""localize -> cv2 fill clears the composed mark (re-detect no longer fires)."""
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
assert eng.detect(wm).detected
out = eng.remove_watermark_reverse_alpha(wm)
assert not eng.detect(out).detected
assert JimengEngine().detect(wm).detected
out, region = registry.get_mark("jimeng").remove(wm, backend="cv2")
assert region is not None
assert not JimengEngine().detect(out).detected
@pytest.mark.parametrize(
("w", "h", "max_err"),
("w", "h"),
[
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH, 4.0), # captured width
(1440, 2560, 8.0), # off-native -> NCC alignment generalizes the capture
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH), # captured width
(1440, 2560), # off-native -> template-free footprint generalizes
],
)
def test_recovers_flat_background(self, w, h, max_err):
eng = JimengEngine()
def test_fill_removes_and_leaves_far_region(self, w, h):
wm, mark = _compose(w, h)
assert float(np.abs(wm.astype(np.float32)[mark] - 100.0).mean()) > 15 # mark visible
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
assert float(np.abs(out[mark] - 100.0).mean()) < max_err
def test_far_region_untouched(self):
"""The residual inpaint only touches the bottom-right footprint; the
opposite corner stays pixel-identical."""
eng = JimengEngine()
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
out = eng.remove_watermark_reverse_alpha(wm)
h, w = wm.shape[:2]
assert np.array_equal(wm[: h // 2, : w // 2], out[: h // 2, : w // 2])
def test_recovers_shifted_mark_on_texture(self):
"""A real mark is re-rasterized a few px off its fixed slot, so removal
must NCC-align to it (a too-tight locate box would let a corner-ward shift
escape the search and leave a readable outline). Composes the real alpha
SHIFTED on a known texture and asserts the texture is recovered."""
eng = JimengEngine()
w = h = _ALPHA_NATIVE_WIDTH
at = _alpha_template()
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
ax = w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw + 12 # shift toward the corner
ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh + 8
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
base = 120 + 40 * np.sin(xx / 90.0) + 30 * np.cos(yy / 70.0)
bg = np.clip(np.stack([base, base * 0.95, base * 1.05], axis=-1), 0, 255)
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * bg).clip(0, 255).astype(np.uint8)
mark = amap > 0.15
assert float(np.abs(wm.astype(np.float32)[mark] - bg[mark]).mean()) > 30 # mark clearly visible
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
assert float(np.abs(out[mark] - bg[mark]).mean()) < 8.0 # texture recovered, no outline
before = JimengEngine().detect(wm)
out, _ = registry.get_mark("jimeng").remove(wm, backend="cv2")
assert JimengEngine().detect(out).confidence < before.confidence
assert np.array_equal(out[: h // 2, : w // 2], wm[: h // 2, : w // 2])
class TestDegenerateAndChannelInputs:
"""Removal must not crash on degenerate sizes or non-3-channel inputs."""
"""footprint_mask must not crash on degenerate sizes or non-3-channel inputs."""
@pytest.mark.parametrize(("w", "h"), [(2048, 1), (1, 2048), (2048, 8)])
def test_wide_short_does_not_raise(self, w, h):
eng = JimengEngine()
img = np.zeros((h, w, 3), np.uint8)
out = eng.remove_watermark_reverse_alpha(img)
assert out.shape == img.shape
mask = eng.footprint_mask(img, force=True)
assert mask is None or mask.shape == (h, w)
def test_grayscale_2d_does_not_raise(self):
eng = JimengEngine()
gray = np.zeros((2048, 2048), np.uint8)
out = eng.remove_watermark_reverse_alpha(gray)
assert out.shape == (2048, 2048, 3)
mask = eng.footprint_mask(gray, force=True)
assert mask is None or mask.shape == (2048, 2048)
def test_bgra_4channel_does_not_raise(self):
eng = JimengEngine()
bgra = np.zeros((2048, 2048, 4), np.uint8)
out = eng.remove_watermark_reverse_alpha(bgra)
assert out.shape == (2048, 2048, 3)
mask = eng.footprint_mask(bgra, force=True)
assert mask is None or mask.shape == (2048, 2048)
+336 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import shutil
import struct
import subprocess
from pathlib import Path
@@ -125,6 +126,24 @@ class TestHasAiMetadata:
assert not has_ai_metadata(out)
def test_remove_ai_metadata_blanks_exif_token_item_in_avif(self, tmp_path: Path):
"""End-to-end: ``remove_ai_metadata`` blanks an AI-generator EXIF token
stored as a meta-box Exif item (bytes in mdat) without re-encoding."""
from remove_ai_watermarks.metadata import remove_ai_metadata
ftyp = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avifmif1"
blob = piexif.dump({"0th": {piexif.ImageIFD.Software: b"Midjourney", piexif.ImageIFD.Make: b"NIKON"}})
mdat = struct.pack(">I", 8 + len(blob)) + b"mdat" + blob
src = tmp_path / "in.avif"
src.write_bytes(ftyp + mdat)
out = tmp_path / "out.avif"
remove_ai_metadata(src, out)
cleaned = out.read_bytes()
assert len(cleaned) == len(ftyp + mdat) # in place, no re-encode
assert b"Midjourney" not in cleaned # AI token gone
assert b"NIKON" in cleaned # camera tag preserved
def test_detects_iptc_trained_algorithmic_media_marker(self, tmp_path: Path):
"""Some pipelines embed only the IPTC AI marker in XMP, no C2PA manifest."""
path = tmp_path / "fake.jpg"
@@ -137,6 +156,97 @@ class TestHasAiMetadata:
path.write_bytes(b"\xff\xd8\xff\xe1" + xmp + b"\xff\xd9")
assert has_ai_metadata(path)
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/samples not present")
def test_jpeg_metadata_strip_is_pixel_lossless(self, tmp_path: Path):
"""A JPEG metadata strip must NOT re-encode the DCT scan: pixels stay
bit-identical, only the AI provenance APP segments are removed. Verified on real
committed fixtures -- grok-1.jpg (EXIF signature), flux-1.jpg (C2PA APP11)."""
import numpy as np
from remove_ai_watermarks import image_io
from remove_ai_watermarks.metadata import remove_ai_metadata
for name in ("grok-1.jpg", "flux-1.jpg"):
src = SAMPLES_DIR / name
if not src.exists():
continue
before = image_io.imread(str(src))
out = tmp_path / name
remove_ai_metadata(src, out)
after = image_io.imread(str(out))
assert before is not None
assert after is not None
assert np.array_equal(before, after), f"{name}: pixels changed (DCT was re-encoded)"
assert not has_ai_metadata(out), f"{name}: AI metadata survived the strip"
@staticmethod
def _xmp_iptc_jpeg(tmp_path: Path, name: str, marker: bytes) -> Path:
"""A real (decodable) JPEG carrying the IPTC AI marker in a well-formed APP1
XMP segment -- the layout Instagram/Facebook/X and MidJourney/Meta use, where
``digitalSourceType`` lives in XMP rather than the APP13 IPTC-IIM record.
Synthetic (no corpus): a solid cv2 JPEG with the APP1 spliced in after SOI."""
import cv2
import numpy as np
real = tmp_path / f"real-{name}"
cv2.imwrite(str(real), np.full((32, 32, 3), 200, np.uint8), [cv2.IMWRITE_JPEG_QUALITY, 100])
data = real.read_bytes()
xmp = (
b"http://ns.adobe.com/xap/1.0/\x00"
b'<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF><rdf:Description>'
b"<Iptc4xmpExt:DigitalSourceType>http://cv.iptc.org/newscodes/digitalsourcetype/"
+ marker
+ b"</Iptc4xmpExt:DigitalSourceType></rdf:Description></rdf:RDF></x:xmpmeta>"
)
seg = b"\xff\xe1" + (len(xmp) + 2).to_bytes(2, "big") + xmp
path = tmp_path / name
path.write_bytes(data[:2] + seg + data[2:]) # splice APP1 right after SOI
return path
@pytest.mark.parametrize("marker", [b"trainedAlgorithmicMedia", b"AISystemUsed"])
def test_jpeg_strip_removes_iptc_marker_in_xmp(self, tmp_path: Path, marker: bytes):
"""Regression: the lossless JPEG strip must drop an AI-bearing APP1 XMP packet
when the AI signal is an IPTC ``digitalSourceType`` / 2025.1 field, not only a
C2PA or China-AIGC token. Before the fix these survived because the APP1 branch
of ``_jpeg_app_carries_ai`` checked only c2pa + AIGC markers, leaving the
Instagram/MidJourney/Meta 'Made with AI' XMP intact. Pixels stay bit-identical."""
import numpy as np
from remove_ai_watermarks import image_io
from remove_ai_watermarks.metadata import remove_ai_metadata
src = self._xmp_iptc_jpeg(tmp_path, "iptc-xmp.jpg", marker)
assert has_ai_metadata(src) # detected before
before = image_io.imread(str(src))
out = tmp_path / "clean.jpg"
remove_ai_metadata(src, out)
after = image_io.imread(str(out))
assert before is not None
assert after is not None
assert np.array_equal(before, after), "pixels changed: the DCT scan was re-encoded"
assert not has_ai_metadata(out), "IPTC AI marker in XMP survived the strip"
def test_remove_ai_metadata_failsafe_on_truncated_png(self, tmp_path: Path):
"""Regression: a truncated / corrupt image must NOT crash remove_ai_metadata (a
direct library caller like a web worker would 500 on a partial upload). PIL raises
OSError decoding a truncated PNG; the strip must fail SAFE -- copy the input through
unchanged and return, mirroring strip_c2pa_boxes. Real prod corpus: ~0.2% of
uploads are truncated."""
import cv2
import numpy as np
from remove_ai_watermarks.metadata import remove_ai_metadata
real = tmp_path / "real.png"
cv2.imwrite(str(real), np.random.default_rng(0).integers(0, 256, (128, 128, 3), dtype=np.uint8))
truncated = tmp_path / "truncated.png"
truncated.write_bytes(real.read_bytes()[: real.stat().st_size // 2]) # chop the IDAT stream
out = tmp_path / "out.png"
# Must not raise; output is the input copied through (undecodable -> nothing to strip).
result = remove_ai_metadata(truncated, out)
assert result == out
assert out.read_bytes() == truncated.read_bytes()
class TestC2paMarkerIn:
"""The C2PA presence check requires a JUMBF wrapper or the C2PA uuid box, so
@@ -193,6 +303,59 @@ class TestSamsungGenai:
p = self._samsung_jpeg(tmp_path, "stray.jpg", b'some other blob "genAIType":1 elsewhere')
assert samsung_genai(p) is None
def test_remove_strips_post_eoi_trailer(self, tmp_path: Path):
"""Regression: Galaxy AI appends ``PhotoEditor_Re_Edit_Data`` as a trailer AFTER
the JPEG EOI, so the verbatim scan copy in the lossless strip carried it through
(survived 0/8 on the corpus). The strip now truncates a Samsung AI trailer at EOI;
pixels stay bit-identical and a non-Samsung trailer is preserved."""
import cv2
import numpy as np
from remove_ai_watermarks import image_io
from remove_ai_watermarks.metadata import remove_ai_metadata
real = tmp_path / "real.jpg"
cv2.imwrite(str(real), np.full((32, 32, 3), 180, np.uint8), [cv2.IMWRITE_JPEG_QUALITY, 100])
src = tmp_path / "galaxy.jpg"
src.write_bytes(real.read_bytes() + b'PhotoEditor_Re_Edit_Data{"genAIType":1}')
assert samsung_genai(src) == 1 # detected before
before = image_io.imread(str(src))
out = tmp_path / "clean.jpg"
remove_ai_metadata(src, out)
after = image_io.imread(str(out))
assert before is not None
assert after is not None
assert np.array_equal(before, after), "pixels changed: the DCT scan was re-encoded"
assert samsung_genai(out) is None, "Samsung genAIType trailer survived the strip"
def test_non_samsung_trailer_preserved(self, tmp_path: Path):
"""A benign post-EOI trailer (e.g. an MPF block) must NOT be truncated."""
import cv2
import numpy as np
from remove_ai_watermarks.metadata import _strip_samsung_trailer
real = tmp_path / "real.jpg"
cv2.imwrite(str(real), np.full((16, 16, 3), 90, np.uint8))
tail = real.read_bytes() + b"MPF-benign-trailer-bytes"
assert _strip_samsung_trailer(tail) == tail
def test_detects_trailer_past_scan_window(self, tmp_path: Path):
"""Regression: the marker is a trailer AFTER the JPEG EOI, so on a multi-MB
photo it sits past the 512 KB quick-scan window. Detection must read the file
tail too, else it disagrees with removal (which reads the whole file). A random
1400x1400 q100 JPEG exceeds 512 KB; the marker is only in its post-EOI tail."""
import cv2
import numpy as np
real = tmp_path / "big.jpg"
big = np.random.default_rng(0).integers(0, 256, (1400, 1400, 3), dtype=np.uint8)
cv2.imwrite(str(real), big, [cv2.IMWRITE_JPEG_QUALITY, 100])
assert real.stat().st_size > 512 * 1024 # trailer will be past the quick-scan window
p = tmp_path / "galaxy_big.jpg"
p.write_bytes(real.read_bytes() + b'PhotoEditor_Re_Edit_Data{"genAIType":1}')
assert samsung_genai(p) == 1
def test_clean_image_is_none(self, tmp_clean_png):
assert samsung_genai(tmp_clean_png) is None
@@ -258,7 +421,6 @@ class TestGetAiMetadataRealSample:
[
b"trainedAlgorithmicMedia",
b"compositeSynthetic",
b"algorithmicMedia",
b"compositeWithTrainedAlgorithmicMedia",
],
)
@@ -269,6 +431,21 @@ def test_has_ai_metadata_detects_each_iptc_marker(tmp_path: Path, marker: bytes)
assert has_ai_metadata(path)
def test_bare_algorithmic_media_not_flagged_ai(tmp_path: Path):
"""Regression: the IPTC ``algorithmicMedia`` digitalSourceType is PROCEDURAL (an
algorithm not trained on sampled data), NOT AI/ML generation. It must NOT be flagged
-- flagging it made identify assert is_ai=high + has_invisible_target=True, which
would trigger a diffusion scrub of clean procedural content. It is a distinct token
from ``trainedAlgorithmicMedia``, so real 'Made with AI' labels are unaffected."""
path = tmp_path / "proc.jpg"
path.write_bytes(
b"\xff\xd8\xff\xe1<x:xmpmeta><Iptc4xmpExt:DigitalSourceType>"
b"http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicMedia"
b"</Iptc4xmpExt:DigitalSourceType></x:xmpmeta>\xff\xd9"
)
assert not has_ai_metadata(path)
# ── SynthID-source detection (metadata proxy) ────────────────────────
@@ -519,6 +696,56 @@ class TestExifGenerator:
Image.new("RGB", (64, 64)).save(path, exif=exif)
assert exif_generator(path) == "Midjourney"
def test_novelai_png_text_chunk_detected(self, tmp_path: Path):
# NovelAI (mined corpus) stamps its generator in PNG tEXt Software/Source/
# Title chunks, not EXIF -- the PNG-text path must catch it.
from PIL.PngImagePlugin import PngInfo
info = PngInfo()
info.add_text("Software", "NovelAI")
info.add_text("Source", "NovelAI Diffusion V4.5 C02D4F98")
info.add_text("Title", "NovelAI generated image")
path = tmp_path / "novelai.png"
Image.new("RGB", (64, 64)).save(path, pnginfo=info)
assert exif_generator(path) == "NovelAI"
def test_reve_software_detected(self, tmp_path: Path):
# Reve Image (mined corpus) writes EXIF Software="reve.com".
path = _img_with_software(tmp_path, "jpg", "reve.com")
assert exif_generator(path) == "reve.com"
def test_reve_token_not_overmatched(self, tmp_path: Path):
# The "reve.com" token must not fire on incidental words like "forever".
path = _img_with_software(tmp_path, "jpg", "Forever Editor 2.0")
assert exif_generator(path) is None
def test_aphrodite_make_detected(self, tmp_path: Path):
# Aphrodite AI (mined corpus) writes EXIF Make="Aphrodite AI".
exif = piexif.dump({"0th": {piexif.ImageIFD.Make: b"Aphrodite AI"}, "Exif": {}, "GPS": {}, "1st": {}})
path = tmp_path / "aphrodite.jpg"
Image.new("RGB", (64, 64)).save(path, exif=exif)
assert exif_generator(path) == "Aphrodite AI"
def test_novelai_removal_parity(self, tmp_path: Path):
# Detection and removal must stay in parity: NovelAI stamps Title/Source
# under non-AI keys (AI-shaped VALUE), so removal must drop them by value,
# not only by key -- else the cleaned file still reads as NovelAI.
from PIL.PngImagePlugin import PngInfo
from remove_ai_watermarks.metadata import remove_ai_metadata
info = PngInfo()
info.add_text("Software", "NovelAI")
info.add_text("Source", "NovelAI Diffusion V4.5 C02D4F98")
info.add_text("Title", "NovelAI generated image")
src = tmp_path / "novelai.png"
Image.new("RGB", (64, 64)).save(src, pnginfo=info)
assert exif_generator(src) == "NovelAI"
out = tmp_path / "clean.png"
remove_ai_metadata(src, out)
assert exif_generator(out) is None
def test_imagedescription_tag_ai_tool_detected(self, tmp_path: Path):
# ...and the EXIF ImageDescription field.
exif = piexif.dump(
@@ -757,6 +984,44 @@ class TestAIGCLabel:
def test_has_ai_metadata_detects_png_chunk_form(self, tmp_path: Path):
assert has_ai_metadata(self._aigc_chunk_png(tmp_path))
def _aigc_serviceprovider_png(self, tmp_path: Path, provider: str = "腾讯云") -> Path:
"""Tencent Cloud's AIGC variant uses a service-provider schema
(``ServiceProvider`` / ``ServiceUser`` / ``Time`` / ``ContentId``) rather
than the producer schema, embedded in EXIF ``ImageDescription`` -- none of
its fields overlap the producer-side ``_TC260_FIELDS``, so the generic
``"AIGC":{...}`` gate must recognize them too."""
import json
import piexif
p = tmp_path / "tencent_aigc.png"
payload = json.dumps(
{
"AIGC": {
"ServiceProvider": provider,
"ServiceUser": "1379431822",
"Time": "2026-06-30 23:54:50.067",
"ContentId": "a284dd3d-15ae-4cce-b2dd-010be84921df",
}
},
ensure_ascii=False,
)
exif = piexif.dump(
{"0th": {piexif.ImageIFD.ImageDescription: payload.encode("utf-8")}, "Exif": {}, "GPS": {}, "1st": {}}
)
Image.new("RGB", (32, 32)).save(p, exif=exif)
return p
def test_parses_serviceprovider_schema(self, tmp_path: Path):
from remove_ai_watermarks.metadata import aigc_label
info = aigc_label(self._aigc_serviceprovider_png(tmp_path))
assert info is not None
assert "ServiceProvider" in info
def test_has_ai_metadata_detects_serviceprovider_schema(self, tmp_path: Path):
assert has_ai_metadata(self._aigc_serviceprovider_png(tmp_path))
def test_remove_strips_png_chunk_form(self, tmp_path: Path):
from remove_ai_watermarks.metadata import aigc_label, remove_ai_metadata
@@ -790,6 +1055,18 @@ class TestAIGCLabel:
def test_has_ai_metadata_detects_raw_json_exif_form(self, tmp_path: Path):
assert has_ai_metadata(self._aigc_exif_jpeg(tmp_path))
def test_remove_strips_raw_json_exif_form(self, tmp_path: Path):
"""Regression: the TC260 AIGC ``{"AIGC":{...}}`` block Doubao embeds in EXIF
UserComment must be scrubbed on removal. Before the fix it survived because
``_scrub_ai_exif`` only touched Software/Make/Artist/ImageDescription in the
0th IFD, never UserComment in the Exif sub-IFD."""
from remove_ai_watermarks.metadata import aigc_label, remove_ai_metadata
out = tmp_path / "clean.jpg"
remove_ai_metadata(self._aigc_exif_jpeg(tmp_path), out)
assert aigc_label(out) is None
assert not has_ai_metadata(out)
def _aigc_bare_jpeg(self, tmp_path: Path, producer: str = "00119144030008867405X210002") -> Path:
"""Some China-served generators glue the TC260 label straight to its JSON
as a bare ``AIGC{...}`` blob inside a JPEG APP segment (no ``"AIGC":``
@@ -814,6 +1091,18 @@ class TestAIGCLabel:
def test_has_ai_metadata_detects_bare_aigc_jpeg_form(self, tmp_path: Path):
assert has_ai_metadata(self._aigc_bare_jpeg(tmp_path))
def test_remove_strips_bare_aigc_jpeg_form(self, tmp_path: Path):
"""Regression: a bare ``AIGC{...}`` blob in a non-standard JPEG APP segment
(APP9 here) is detected by aigc_label, so removal must drop that segment too.
Before the fix ``_jpeg_app_carries_ai`` only inspected APP11/APP1-XMP/APP13, so
the blob survived the lossless strip (detection<->removal parity break)."""
from remove_ai_watermarks.metadata import aigc_label, remove_ai_metadata
out = tmp_path / "clean.jpg"
remove_ai_metadata(self._aigc_bare_jpeg(tmp_path), out)
assert aigc_label(out) is None
assert not has_ai_metadata(out)
def test_bare_aigc_without_tc260_field_ignored(self, tmp_path: Path):
"""A bare ``AIGC{...}`` blob with no TC260 field must not false-positive."""
from remove_ai_watermarks.metadata import aigc_label
@@ -1062,6 +1351,27 @@ class TestLateProvenanceBox:
p.write_bytes(b"\x89PNG\r\n\x1a\n not an isobmff file")
assert scan_c2pa_region(p) == b""
def test_scan_c2pa_region_reads_largesize_uuid(self, tmp_path: Path):
"""A 64-bit largesize (size32 == 1) uuid box must be walked and collected."""
import struct
from remove_ai_watermarks.noai.isobmff import scan_c2pa_region
payload = b"LARGESIZE-C2PA-MANIFEST"
total = 16 + len(payload) # 4 (size32=1) + 4 (type) + 8 (largesize) + payload
uuid_box = struct.pack(">I", 1) + b"uuid" + struct.pack(">Q", total) + payload
p = tmp_path / "large.mp4"
p.write_bytes(_MP4_FTYP + uuid_box)
assert payload in scan_c2pa_region(p)
def test_scan_c2pa_region_caps_at_max_total(self, tmp_path: Path):
"""The collected payload is bounded by ``max_total`` (never unbounded)."""
from remove_ai_watermarks.noai.isobmff import scan_c2pa_region
p = tmp_path / "big.mp4"
p.write_bytes(_MP4_FTYP + _box(b"uuid", b"A" * 5000))
assert len(scan_c2pa_region(p, max_total=1000)) <= 1000
def test_front_placed_manifest_still_detected(self, tmp_path: Path):
# Regression: a faststart MP4 (manifest before mdat) is unaffected.
from remove_ai_watermarks.metadata import C2PA_UUID
@@ -1267,3 +1577,28 @@ class TestC2paCloudManifest:
assert r.is_ai_generated is None
assert any("Durable Content Credentials" in w for w in r.watermarks)
assert any(s.name == "c2pa_cloud" for s in r.signals)
def test_remove_all_bypasses_lossless_jpeg(tmp_path, monkeypatch):
# keep_standard=False (--remove-all) must NOT take the AI-only lossless JPEG path,
# which leaves standard (non-AI) metadata in place; it must fall through to the full
# re-encode strip (#7).
import remove_ai_watermarks.metadata as md
calls: list[int] = []
real = md._strip_jpeg_metadata_lossless
def spy(s, o):
calls.append(1)
return real(s, o)
monkeypatch.setattr(md, "_strip_jpeg_metadata_lossless", spy)
src = tmp_path / "x.jpg"
Image.new("RGB", (32, 32), (128, 128, 128)).save(src, "JPEG")
out = tmp_path / "o.jpg"
md.remove_ai_metadata(src, out, keep_standard=True)
assert calls, "lossless JPEG strip should run when keeping standard metadata"
calls.clear()
md.remove_ai_metadata(src, out, keep_standard=False)
assert not calls, "keep_standard=False must bypass the AI-only lossless JPEG path"
+202 -9
View File
@@ -1,4 +1,5 @@
"""Tests for vendored noai submodules: constants, extractor, cleaner, c2pa."""
"""Tests for vendored noai submodules: constants, extractor, c2pa, plus the
consolidated metadata strip (formerly noai.cleaner)."""
from __future__ import annotations
@@ -7,6 +8,9 @@ from pathlib import Path
import pytest
from remove_ai_watermarks.metadata import (
remove_ai_metadata as noai_remove_ai_metadata,
)
from remove_ai_watermarks.noai.c2pa import (
_parse_c2pa_chunk,
cbor_text_after,
@@ -16,12 +20,6 @@ from remove_ai_watermarks.noai.c2pa import (
inject_c2pa_chunk,
synthid_verdict,
)
from remove_ai_watermarks.noai.cleaner import (
has_ai_content,
)
from remove_ai_watermarks.noai.cleaner import (
remove_ai_metadata as noai_remove_ai_metadata,
)
from remove_ai_watermarks.noai.constants import (
AI_KEYWORDS,
AI_METADATA_KEYS,
@@ -36,6 +34,7 @@ from remove_ai_watermarks.noai.extractor import (
has_ai_metadata,
)
from remove_ai_watermarks.noai.isobmff import (
blank_ai_exif_tokens,
is_isobmff,
strip_c2pa_boxes,
)
@@ -52,6 +51,15 @@ class TestConstants:
def test_supported_formats_include_jpg(self):
assert ".jpg" in SUPPORTED_FORMATS
def test_supported_formats_include_heic_avif(self):
# HEIC/AVIF are first-class on the pixel path now (read+write via pillow-heif),
# so batch discovers them and the CLI does not warn.
assert {".heic", ".heif", ".avif"} <= SUPPORTED_FORMATS
def test_supported_formats_exclude_jpeg_xl(self):
# JPEG-XL stays metadata/strip-only -- no pixel decoder without pillow-jxl.
assert ".jxl" not in SUPPORTED_FORMATS
def test_ai_metadata_keys_not_empty(self):
assert len(AI_METADATA_KEYS) > 0
@@ -106,7 +114,8 @@ class TestExtractor:
class TestCleaner:
"""Tests for noai.cleaner functions."""
"""Metadata stripping via the single, consolidated ``metadata.remove_ai_metadata``
(the legacy ``noai.cleaner`` duplicate was retired)."""
def test_remove_ai_metadata(self, tmp_png_with_ai_metadata, tmp_path):
output = tmp_path / "cleaned.png"
@@ -117,7 +126,7 @@ class TestCleaner:
assert "parameters" not in meta
def test_has_ai_content(self, tmp_png_with_ai_metadata):
assert has_ai_content(tmp_png_with_ai_metadata)
assert has_ai_metadata(tmp_png_with_ai_metadata)
# ── C2PA ────────────────────────────────────────────────────────────
@@ -182,6 +191,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
@@ -304,6 +321,18 @@ class TestC2PADigitalSourceType:
assert "compositeWithTrainedAlgorithmicMedia" in info["source_type"]
assert "synthid_watermark" in info # AI-enhanced + OpenAI issuer
def test_composite_and_bare_algorithmic_cooccur_is_ai(self):
"""Regression: a manifest carrying BOTH ``compositeWithTrainedAlgorithmicMedia``
(AI-enhanced) and a bare procedural ``algorithmicMedia`` token must classify as
AI-enhanced. Before the reorder the bare-token elif fired first and returned
non-AI, dropping the composite AI signal (a false negative)."""
from remove_ai_watermarks.noai.c2pa import _populate_registry_fields
info: dict = {}
_populate_registry_fields(b"x compositeWithTrainedAlgorithmicMedia x algorithmicMedia x", info)
assert info.get("ai_source_kind") == "enhanced"
assert "compositeWithTrainedAlgorithmicMedia" in info["source_type"]
# ── ISOBMFF (AVIF / HEIF / JPEG-XL container stripping) ──────────────
@@ -358,6 +387,170 @@ class TestISOBMFF:
assert stripped == 0
assert cleaned == data
@staticmethod
def _avif_with_exif(exif_0th: dict) -> bytes:
"""A fake AVIF (ftyp + mdat) whose mdat carries an EXIF TIFF block, as a
HEIF/AVIF ``Exif`` meta-box item stores it (bytes in mdat)."""
import piexif
blob = piexif.dump({"0th": exif_0th})
mdat = struct.pack(">I", 8 + len(blob)) + b"mdat" + blob
return FTYP + mdat
def test_blank_ai_token_in_exif_item(self):
import piexif
data = self._avif_with_exif({piexif.ImageIFD.Software: b"DALL-E", piexif.ImageIFD.Make: b"Canon"})
out, blanked = blank_ai_exif_tokens(data)
assert blanked == 1
assert len(out) == len(data) # same length -> box sizes / iloc stay valid
assert b"DALL-E" not in out # AI token destroyed
assert b"Canon" in out # camera tag preserved
# The TIFF structure still parses, with the AI value blanked and Make kept.
blob = out[out.index(b"Exif\x00\x00") + 6 :]
ifd = piexif.load(blob)["0th"]
assert ifd[piexif.ImageIFD.Software].strip() == b""
assert ifd[piexif.ImageIFD.Make] == b"Canon"
def test_blank_aigc_block_in_exif(self):
"""Parity with the JPEG path: the China TC260 ``{"AIGC":{...}}`` block in EXIF
ImageDescription must be blanked on the ISOBMFF path too -- ``blank_ai_exif_tokens``
is the ONLY EXIF scrubber for HEIC/AVIF (``_scrub_ai_exif`` never runs there)."""
import piexif
aigc = b'{"AIGC":{"Label":"1","ContentProducer":"00119144030008867405X210002","ProduceID":"abc"}}'
data = self._avif_with_exif({piexif.ImageIFD.ImageDescription: aigc, piexif.ImageIFD.Make: b"Canon"})
out, blanked = blank_ai_exif_tokens(data)
assert blanked >= 1
assert len(out) == len(data) # same length -> box sizes / iloc stay valid
assert b'"AIGC"' not in out # TC260 block destroyed
assert b"Canon" in out # camera tag preserved
def test_blank_xai_signature_pair_in_exif(self):
"""Parity: the xAI/Grok ``Signature:`` blob + UUID ``Artist`` pair in EXIF is
dropped together on the ISOBMFF path too."""
import piexif
sig = b"Signature: " + b"A" * 80
art = b"12345678-1234-1234-1234-123456789012"
data = self._avif_with_exif({piexif.ImageIFD.ImageDescription: sig, piexif.ImageIFD.Artist: art})
out, blanked = blank_ai_exif_tokens(data)
assert blanked == 2 # both the signature and the UUID artist
assert len(out) == len(data)
assert b"Signature: AAAA" not in out
def test_blank_leaves_clean_exif_untouched(self):
import piexif
data = self._avif_with_exif({piexif.ImageIFD.Software: b"Adobe Photoshop", piexif.ImageIFD.Make: b"NIKON"})
out, blanked = blank_ai_exif_tokens(data)
assert blanked == 0
assert out == data # no AI token -> byte-for-byte unchanged
def test_blank_no_exif_is_noop(self):
out, blanked = blank_ai_exif_tokens(FTYP + b"\x00\x00\x00\x0cmdat" + b"pixels!!")
assert blanked == 0
assert out == FTYP + b"\x00\x00\x00\x0cmdat" + b"pixels!!"
class TestIterTopLevelBoxes:
"""The box walker's three size encodings and its underflow/overflow guards."""
def test_64bit_largesize(self):
from remove_ai_watermarks.noai.isobmff import _iter_top_level_boxes
# size32 == 1 -> a 64-bit largesize follows the type; total box length = 24.
box = struct.pack(">I", 1) + b"uuid" + struct.pack(">Q", 24) + b"payload!"
boxes = list(_iter_top_level_boxes(box))
assert len(boxes) == 1
start, end, btype, payload_off = boxes[0]
assert (start, end, btype, payload_off) == (0, 24, b"uuid", 16)
def test_size0_runs_to_eof(self):
from remove_ai_watermarks.noai.isobmff import _iter_top_level_boxes
box = struct.pack(">I", 0) + b"mdat" + b"tail-to-eof"
boxes = list(_iter_top_level_boxes(box))
assert len(boxes) == 1
start, end, btype, payload_off = boxes[0]
assert (start, end, btype, payload_off) == (0, len(box), b"mdat", 8)
def test_underflow_size_stops_safely(self):
from remove_ai_watermarks.noai.isobmff import _iter_top_level_boxes
# size (4) < the 8-byte header -> the guard returns without yielding a box.
assert list(_iter_top_level_boxes(struct.pack(">I", 4) + b"ftyp" + b"more")) == []
def test_overflow_size_stops_safely(self):
from remove_ai_watermarks.noai.isobmff import _iter_top_level_boxes
# size claims 999 but the buffer is far shorter -> guard returns, no partial box.
assert list(_iter_top_level_boxes(struct.pack(">I", 999) + b"uuid" + b"x")) == []
class TestBlankAiXmpPackets:
"""XMP-packet blanking: same-length overwrite only for AI-marked packets, and only
when the packet is fully delimited."""
AIMARK = b"trainedAlgorithmicMedia"
def test_ai_packet_blanked_same_length(self):
from remove_ai_watermarks.noai.isobmff import blank_ai_xmp_packets
packet = b'<?xpacket begin="x"?><x:xmpmeta>' + self.AIMARK + b'</x:xmpmeta><?xpacket end="w"?>'
data = b"boxhdr" + packet + b"tail"
out, n = blank_ai_xmp_packets(data)
assert n == 1
assert len(out) == len(data) # same length -> iloc offsets stay valid
assert self.AIMARK not in out
assert b"boxhdr" in out
assert b"tail" in out
def test_clean_packet_left_intact(self):
from remove_ai_watermarks.noai.isobmff import blank_ai_xmp_packets
packet = b'<?xpacket begin="x"?><x:xmpmeta>plain copyright</x:xmpmeta><?xpacket end="w"?>'
out, n = blank_ai_xmp_packets(packet)
assert n == 0
assert out == packet
def test_missing_end_delimiter_not_blanked(self):
from remove_ai_watermarks.noai.isobmff import blank_ai_xmp_packets
# No <?xpacket end?> -> the packet regex cannot match, so it is left unchanged.
data = b'<?xpacket begin="x"?><x:xmpmeta>' + self.AIMARK + b"</x:xmpmeta>"
out, n = blank_ai_xmp_packets(data)
assert n == 0
assert out == data
class TestC2paBufferScans:
"""The shared buffer-scan helpers (used by both the PNG caBX parser and the
format-agnostic binary scan). Data-driven off the registries so they stay valid
as vendors are added."""
def test_soft_binding_vendors_in(self):
from remove_ai_watermarks.noai.c2pa import C2PA_SOFT_BINDINGS, soft_binding_vendors_in
sig, name = next(iter(C2PA_SOFT_BINDINGS.items()))
assert name in soft_binding_vendors_in(b"...manifest..." + sig + b"...tail...")
assert soft_binding_vendors_in(b"") == []
assert soft_binding_vendors_in(b"no soft-binding assertion here") == []
def test_synthid_vendors_in_requires_synthid_issuer(self):
from remove_ai_watermarks.noai.c2pa import C2PA_ISSUERS, SYNTHID_C2PA_ISSUERS, synthid_vendors_in
syn_sig = next(s for s in C2PA_ISSUERS if s in SYNTHID_C2PA_ISSUERS)
non_sig = next(s for s in C2PA_ISSUERS if s not in SYNTHID_C2PA_ISSUERS)
assert C2PA_ISSUERS[syn_sig] in synthid_vendors_in(b"x" + syn_sig + b"x")
# an issuer that does NOT pair SynthID with C2PA must not be reported as one
assert C2PA_ISSUERS[non_sig] not in synthid_vendors_in(b"x" + non_sig + b"x")
def test_synthid_verdict_format(self):
from remove_ai_watermarks.noai.c2pa import synthid_verdict
assert synthid_verdict("Google LLC") == "likely present (Google LLC embeds SynthID with C2PA)"
class TestC2PAInvalidSignature:
"""A .png file that is not actually PNG-signed must read as clean, not crash."""
+186
View File
@@ -0,0 +1,186 @@
"""Jimeng-basic 'AI生成' pill: capture-less mark (detect via synthetic silhouette
edge-NCC, remove via inpaint). No model download -- cv2 fallback / pure logic only."""
from __future__ import annotations
import numpy as np
import pytest
from PIL import Image, ImageDraw, ImageFont
from remove_ai_watermarks import watermark_registry as registry
from remove_ai_watermarks.pill_engine import _DETECT_THRESHOLD, PillEngine
_FONT = "/System/Library/Fonts/STHeiti Medium.ttc"
def _font_ok() -> bool:
try:
ImageFont.truetype(_FONT, 20)
return True
except Exception:
return False
_HAS_FONT = _font_ok()
_needs_font = pytest.mark.skipif(
not _HAS_FONT, reason="CJK font unavailable (compose helper needs it; asset is committed)"
)
def _compose_pill(w: int = 1200, h: int = 1600, bg: int = 150) -> np.ndarray:
"""Composite a semi-transparent 'AI生成' pill top-left onto a flat BGR frame."""
img = Image.new("RGB", (w, h), (bg, bg, bg))
ov = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(ov)
mw, mh = int(0.167 * w), int(0.09 * w)
mx, my = int(0.03 * w), int(0.02 * w)
d.rounded_rectangle([mx, my, mx + mw, my + mh], radius=mh // 3, outline=(255, 255, 255, 150), width=3)
font = ImageFont.truetype(_FONT, int(mh * 0.5))
d.text((mx + mw // 6, my + mh // 5), "AI生成", font=font, fill=(255, 255, 255, 170))
out = Image.alpha_composite(img.convert("RGBA"), ov).convert("RGB")
return np.asarray(out)[:, :, ::-1].copy() # RGB->BGR
class TestPillDetect:
@_needs_font
def test_detects_composited_pill(self) -> None:
det = PillEngine().detect(_compose_pill())
assert det.detected
assert det.confidence >= _DETECT_THRESHOLD
def test_clean_frame_does_not_fire(self) -> None:
clean = np.full((1600, 1200, 3), 150, np.uint8)
assert not PillEngine().detect(clean).detected
def test_small_image_no_fire(self) -> None:
assert not PillEngine().detect(np.full((40, 40, 3), 150, np.uint8)).detected
def _textured_frame(w: int = 300, h: int = 400, bg: int = 150) -> np.ndarray:
"""Flat frame with a high-frequency checkerboard over the top-left footprint,
so the pill footprint reads as TEXTURED (an inpaint there would smear)."""
img = np.full((h, w, 3), bg, np.uint8)
fx, fy, fw, fh = int(0.012 * w), int(0.006 * h), int(0.205 * w), int(0.115 * w)
yy, xx = np.mgrid[0:fh, 0:fw]
checker = (((xx // 3) + (yy // 3)) % 2 * 255).astype(np.uint8)
img[fy : fy + fh, fx : fx + fw] = checker[:, :, None]
return img
class TestPillMask:
def test_footprint_mask_top_left_geometry(self) -> None:
mask = PillEngine().footprint_mask(np.full((1600, 1200, 3), 150, np.uint8))
assert mask is not None
assert mask.shape == (1600, 1200)
assert mask.any()
ys, xs = np.where(mask > 0)
# pill sits top-left: mask mass in the top-left quadrant
assert ys.mean() < 800
assert xs.mean() < 600
class TestFootprintFlatness:
"""The metadata-only pill arm removes only on a flat footprint (safe inpaint)."""
def test_flat_frame_is_flat(self) -> None:
assert PillEngine().footprint_is_flat(np.full((1600, 1200, 3), 150, np.uint8))
def test_textured_frame_is_not_flat(self) -> None:
eng = PillEngine()
assert not eng.footprint_is_flat(_textured_frame(1200, 1600))
# median-Sobel texture is well above the flat threshold on the checkerboard
assert eng.footprint_texture(_textured_frame(1200, 1600)) > 6.0
class TestPillRegistry:
def test_pill_registered_top_left(self) -> None:
m = registry.get_mark("jimeng_pill")
assert m.location == "top-left"
assert m.in_auto is True
def test_pill_mask_is_top_left_via_registry(self) -> None:
# The registry mask callable delegates to the pill engine's top-left footprint.
mask = registry.get_mark("jimeng_pill")._mask(np.full((1600, 1200, 3), 150, np.uint8))
assert mask is not None
assert mask.any()
class TestPillGate:
"""Pill removal is gated (``_keep_pill``): the reliable bottom-right wordmark
removes it unrestricted, the metadata (``"jimeng"`` provenance) / assume_ai arm
removes it ONLY on a flat footprint (safe fill), Doubao/no-confirmation never
remove it. Fakes each mark's detect so no image content is needed; cv2 backend so
nothing downloads. Frame flatness matters, so tests pass a flat or textured frame."""
@staticmethod
def _fakes(monkeypatch: pytest.MonkeyPatch, keys: set[str]) -> None:
from remove_ai_watermarks.watermark_registry import KnownMark, MarkDetection
labels = {
"doubao": "Doubao 豆包AI生成 text",
"jimeng": "Jimeng 即梦AI wordmark",
"jimeng_pill": "Jimeng AI生成 pill",
}
monkeypatch.setattr(registry, "preferred_inpaint_backend", lambda: "cv2")
def fake_detect(self: KnownMark, image: object, *, provenance: bool = False) -> MarkDetection:
return MarkDetection(
self.key, labels.get(self.key, self.key), "loc", self.key in keys, 0.6, (10, 10, 40, 40)
)
monkeypatch.setattr(registry.KnownMark, "detect", fake_detect)
def test_pill_kept_with_metadata_on_flat_footprint(self, monkeypatch: pytest.MonkeyPatch) -> None:
# jimeng provenance (TC260) + flat background -> safe fill, remove
self._fakes(monkeypatch, {"jimeng_pill"})
_, removed = registry.remove_auto_marks(np.full((400, 300, 3), 150, np.uint8), provenance=frozenset({"jimeng"}))
assert "Jimeng AI生成 pill" in removed
def test_pill_dropped_with_metadata_on_textured_footprint(self, monkeypatch: pytest.MonkeyPatch) -> None:
# jimeng provenance + textured background (ceiling-like) -> fill would smear, skip
self._fakes(monkeypatch, {"jimeng_pill"})
_, removed = registry.remove_auto_marks(_textured_frame(), provenance=frozenset({"jimeng"}))
assert "Jimeng AI生成 pill" not in removed
def test_pill_kept_via_wordmark_ignores_texture(self, monkeypatch: pytest.MonkeyPatch) -> None:
# wordmark confirmation (~94% precise, survives metadata stripping) is NOT
# texture-gated: a wordmark-confirmed pill is removed even on a textured frame
self._fakes(monkeypatch, {"jimeng", "jimeng_pill"})
_, removed = registry.remove_auto_marks(_textured_frame())
assert "Jimeng AI生成 pill" in removed
def test_pill_kept_via_assume_ai_on_flat_footprint(self, monkeypatch: pytest.MonkeyPatch) -> None:
# assume_ai (no metadata) removes the pill on a flat footprint (safe fill)...
self._fakes(monkeypatch, {"jimeng_pill"})
_, removed = registry.remove_auto_marks(np.full((400, 300, 3), 150, np.uint8), sensitivity="assume_ai")
assert "Jimeng AI生成 pill" in removed
def test_pill_dropped_via_assume_ai_on_textured_footprint(self, monkeypatch: pytest.MonkeyPatch) -> None:
# ...but even assume_ai keeps the flatness guard (textured false fires smear).
self._fakes(monkeypatch, {"jimeng_pill"})
_, removed = registry.remove_auto_marks(_textured_frame(), sensitivity="assume_ai")
assert "Jimeng AI生成 pill" not in removed
def test_pill_dropped_without_metadata_or_wordmark(self, monkeypatch: pytest.MonkeyPatch) -> None:
self._fakes(monkeypatch, {"jimeng_pill"})
_, removed = registry.remove_auto_marks(np.full((400, 300, 3), 150, np.uint8))
assert "Jimeng AI生成 pill" not in removed
def test_pill_dropped_on_doubao_even_with_metadata(self, monkeypatch: pytest.MonkeyPatch) -> None:
# doubao is faked as detected, which drives the pill gate (pill never rides on a
# Doubao detection). The same flat + jimeng-metadata setup WITHOUT doubao keeps the
# pill (test_pill_kept_with_metadata_on_flat_footprint), so doubao is the
# differentiator. Doubao itself is not asserted in `removed` here: this synthetic
# frame is flat with no real glyph, so the text mask has nothing to fill (its real
# removal is covered by TestRealSample on the committed doubao sample).
self._fakes(monkeypatch, {"doubao", "jimeng_pill"})
_, removed = registry.remove_auto_marks(np.full((400, 300, 3), 150, np.uint8), provenance=frozenset({"jimeng"}))
assert "Jimeng AI生成 pill" not in removed
def test_detect_bgra_no_crash() -> None:
# A 4-channel BGRA array must be normalized, not crash cv2.cvtColor(BGR2GRAY) (#10).
bgra = np.zeros((256, 256, 4), np.uint8)
det = PillEngine().detect(bgra)
assert det.detected in (True, False)
assert PillEngine().footprint_texture(bgra) >= 0.0
+130 -3
View File
@@ -115,6 +115,7 @@ class TestModelProfiles:
def test_canonical_profiles_unchanged(self):
assert normalize_profile("sdxl") == "sdxl"
assert normalize_profile("controlnet") == "controlnet"
assert normalize_profile("qwen") == "qwen"
def test_default_alias_resolves_to_sdxl(self):
# "default" is the legacy alias for "sdxl" (back-compat for existing scripts).
@@ -125,19 +126,145 @@ class TestModelProfiles:
assert normalize_profile("CONTROLNET") == "controlnet"
class TestFp16WeightVariant:
"""_load_from_pretrained reads the fp16 weight variant on fp16, with a fallback.
Loading the fp16 ``variant`` reads the half-precision weight files (~half the bytes)
instead of the fp32 defaults + a downcast, which roughly halves the cold-start weight
read. fp32 (cpu/mps) and bf16 (qwen) must never request the variant; a checkpoint
without fp16 files must fall back to the default weights (prior behavior).
"""
def _remover(self, dtype: object):
if not is_watermark_removal_available():
pytest.skip("torch/diffusers not installed")
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover
# device="cpu" alone would force fp32; the explicit torch_dtype override lets us
# exercise the fp16 path with no GPU (construction loads no weights).
return WatermarkRemover(device="cpu", torch_dtype=dtype)
def test_fp16_requests_variant(self):
import torch
remover = self._remover(torch.float16)
cls = MagicMock()
cls.from_pretrained.return_value = "PIPE"
out = remover._load_from_pretrained(cls, "some/model", token="t")
assert out == "PIPE"
cls.from_pretrained.assert_called_once_with("some/model", variant="fp16", token="t")
def test_fp16_falls_back_when_variant_missing(self):
import torch
remover = self._remover(torch.float16)
cls = MagicMock()
cls.from_pretrained.side_effect = [OSError("no fp16 weight files"), "PIPE"]
out = remover._load_from_pretrained(cls, "some/model", token="t")
assert out == "PIPE"
assert cls.from_pretrained.call_count == 2
first, second = cls.from_pretrained.call_args_list
assert first.kwargs.get("variant") == "fp16"
assert "variant" not in second.kwargs # the fallback drops the variant
def test_fp32_never_requests_variant(self):
import torch
remover = self._remover(torch.float32)
cls = MagicMock()
cls.from_pretrained.return_value = "PIPE"
remover._load_from_pretrained(cls, "some/model")
cls.from_pretrained.assert_called_once_with("some/model")
assert "variant" not in cls.from_pretrained.call_args.kwargs
class _StubImage:
"""Minimal PIL.Image stand-in: just the ``width``/``height`` the pure helper reads."""
def __init__(self, width: int, height: int) -> None:
self.width = width
self.height = height
class TestQwenKwargs:
"""_build_qwen_kwargs is pure (no torch); guards the Qwen-Image call shape.
watermark_remover imports torch under a try/except, so the module (and this pure
helper) imports fine in the core+dev CI env where torch is absent.
"""
def test_uses_true_cfg_not_guidance_scale(self):
from remove_ai_watermarks.noai.watermark_remover import _build_qwen_kwargs
gen = object()
img = _StubImage(2816, 1536)
kwargs = _build_qwen_kwargs(img, strength=0.3, num_inference_steps=40, true_cfg_scale=4.0, generator=gen)
# Qwen uses true_cfg_scale, NOT SDXL's guidance_scale.
assert kwargs["true_cfg_scale"] == 4.0
assert "guidance_scale" not in kwargs
# The scrub still comes from strength; image + generator pass through.
assert kwargs["strength"] == 0.3
assert kwargs["image"] is img
assert kwargs["generator"] is gen
# Faithful-regeneration prompt + an explicit negative prompt.
assert kwargs["prompt"]
assert kwargs["negative_prompt"]
def test_passes_explicit_aspect_preserving_size(self):
# Without height/width the pipeline defaults to 1024x1024 and squishes non-square
# input (the abba mixed-seam regression). Both already multiples of 16 -> unchanged.
from remove_ai_watermarks.noai.watermark_remover import _build_qwen_kwargs
kwargs = _build_qwen_kwargs(
_StubImage(2816, 1536), strength=0.25, num_inference_steps=40, true_cfg_scale=4.0, generator=None
)
assert kwargs["width"] == 2816
assert kwargs["height"] == 1536
def test_qwen_target_size_floors_to_multiple_of_16(self):
from remove_ai_watermarks.noai.watermark_remover import _qwen_target_size
assert _qwen_target_size(2816, 1536) == (2816, 1536) # already /16
assert _qwen_target_size(1122, 1402) == (1120, 1392) # floored
assert _qwen_target_size(10, 10) == (16, 16) # min clamp, never 0
def test_qwen_model_id_is_qwen_image(self):
from remove_ai_watermarks.noai.watermark_profiles import QWEN_MODEL_ID
assert QWEN_MODEL_ID == "Qwen/Qwen-Image"
class TestResolveStrength:
"""resolve_strength applies the vendor default only when strength is unset."""
def test_none_is_vendor_adaptive(self):
# No vendor -> unknown default; OpenAI lower, Google == unknown. The SAME ladder
# applies to both pipelines (the certified controlnet floors), so there is no
# pipeline argument.
# No vendor -> unknown default; OpenAI lower, Google == unknown. The sdxl/controlnet
# pipelines share this ladder (the certified controlnet floors); qwen has its own
# (see test_qwen_pipeline_uses_its_own_higher_ladder).
assert resolve_strength(None) == UNKNOWN_STRENGTH
assert resolve_strength(None, "openai") == OPENAI_STRENGTH
assert resolve_strength(None, "google") == GEMINI_STRENGTH
assert resolve_strength(None, None) == UNKNOWN_STRENGTH
# An unrecognized vendor string falls through to the unknown default.
assert resolve_strength(None, "adobe") == UNKNOWN_STRENGTH
# sdxl/controlnet pipelines (and the "default" alias) use the same shared ladder.
assert resolve_strength(None, "google", "controlnet") == GEMINI_STRENGTH
assert resolve_strength(None, "google", "sdxl") == GEMINI_STRENGTH
def test_qwen_pipeline_uses_its_own_higher_ladder(self):
# Qwen's certified Gemini floor (0.25) is HIGHER than controlnet's (0.15); OpenAI
# matches (0.10). Unknown vendor on qwen tracks the higher Gemini value. This retires
# the old manual "pass --strength 0.25 for Gemini on qwen" workaround.
from remove_ai_watermarks.noai.watermark_profiles import QWEN_GEMINI_STRENGTH, QWEN_OPENAI_STRENGTH
assert QWEN_GEMINI_STRENGTH == 0.25
assert QWEN_OPENAI_STRENGTH == 0.10
assert resolve_strength(None, "google", "qwen") == QWEN_GEMINI_STRENGTH
assert resolve_strength(None, "openai", "qwen") == QWEN_OPENAI_STRENGTH
assert resolve_strength(None, None, "qwen") == QWEN_GEMINI_STRENGTH # unknown -> higher floor
assert resolve_strength(None, "google", "qwen") > resolve_strength(None, "google", "controlnet")
# An explicit strength still wins on qwen.
assert resolve_strength(0.12, "google", "qwen") == 0.12
def test_ladder_is_the_certified_controlnet_floors(self):
# The unified ladder == the oracle-certified controlnet floors. Lowered on the
+122 -1
View File
@@ -5,7 +5,41 @@ from __future__ import annotations
import numpy as np
import pytest
from remove_ai_watermarks.region_eraser import boxes_to_mask, erase, lama_available
from remove_ai_watermarks.region_eraser import boxes_to_mask, erase, lama_available, migan_available
class TestPaddedCropBox:
"""The padded bounding box that bounds the learned backends' ONNX working set."""
def test_empty_mask_returns_none(self):
from remove_ai_watermarks.region_eraser import _padded_crop_box
assert _padded_crop_box(np.zeros((100, 100), np.uint8), 100, 100, pad_frac=0.1, pad_min=8) is None
def test_pad_min_dominates_and_clamps_at_border(self):
from remove_ai_watermarks.region_eraser import _padded_crop_box
mask = np.zeros((100, 100), np.uint8)
mask[0:5, 0:5] = 255 # 5-px mark in the top-left corner
# pad = max(8, int(0.1*5)) = 8; x0 clamps to 0 (not -8), x1 = min(100, 4+1+8) = 13.
assert _padded_crop_box(mask, 100, 100, pad_frac=0.1, pad_min=8) == (0, 0, 13, 13)
def test_pad_frac_dominates_for_large_mark(self):
from remove_ai_watermarks.region_eraser import _padded_crop_box
mask = np.zeros((400, 400), np.uint8)
mask[100:300, 100:300] = 255 # 200-px span
# pad = max(8, int(0.2*200)) = 40; box = (100-40, .., 299+1+40, ..).
assert _padded_crop_box(mask, 400, 400, pad_frac=0.2, pad_min=8) == (60, 60, 340, 340)
def test_clamps_at_far_border(self):
from remove_ai_watermarks.region_eraser import _padded_crop_box
mask = np.zeros((50, 60), np.uint8)
mask[45:50, 55:60] = 255 # bottom-right corner
_x0, _y0, x1, y1 = _padded_crop_box(mask, 50, 60, pad_frac=0.1, pad_min=8)
assert x1 == 60 # clamped to w, no overflow
assert y1 == 50 # clamped to h, no overflow
class TestBoxesToMask:
@@ -134,3 +168,90 @@ class TestLamaChannelHandling:
out = erase(bgra, boxes=[(40, 40, 20, 20)], backend="lama")
assert out.shape == bgra.shape
assert np.array_equal(out[..., 3], bgra[..., 3]) # alpha carried through unchanged
class TestMiganBackend:
def test_migan_raises_when_unavailable(self):
img = np.full((100, 100, 3), 50, np.uint8)
if migan_available():
pytest.skip("onnxruntime installed; cannot test the unavailable path")
with pytest.raises(RuntimeError, match="onnxruntime"):
erase(img, boxes=[(10, 10, 20, 20)], backend="migan")
class TestMiganWrapper:
"""erase_migan without the real model: fake session returns a solid-red field
and captures the fed mask. Exercises the mask-polarity inversion, masked-only
compositing, and grayscale/BGRA channel handling."""
captured: dict
@pytest.fixture
def _fake_migan(self, monkeypatch: pytest.MonkeyPatch):
from remove_ai_watermarks import region_eraser
self.captured = {}
class _In:
def __init__(self, name: str):
self.name = name
class _FakeSession:
def __init__(self, outer):
self.outer = outer
def get_inputs(self):
return [_In("image"), _In("mask")]
def run(self, _outputs, feeds):
self.outer.captured["mask"] = feeds["mask"]
self.outer.captured["image_shape"] = feeds["image"].shape
img = feeds["image"] # (1,3,H,W) RGB
red = np.zeros_like(img)
red[:, 0] = 255 # pure red in RGB
return [red]
monkeypatch.setattr(region_eraser, "migan_available", lambda: True)
monkeypatch.setattr(region_eraser, "_get_migan_session", lambda: _FakeSession(self))
@pytest.mark.usefixtures("_fake_migan")
def test_composites_only_masked_region_and_inverts_mask(self):
img = np.full((100, 100, 3), 120, np.uint8) # BGR
out = erase(img, boxes=[(40, 40, 20, 20)], backend="migan", dilate=0)
# inside the box -> red (BGR (0,0,255)); outside -> untouched
assert tuple(int(v) for v in out[50, 50]) == (0, 0, 255)
assert np.array_equal(out[:30, :30], img[:30, :30])
# mask fed to MI-GAN is inverted: 0 (hole) inside the box, 255 (known) outside
m = self.captured["mask"][0, 0]
assert m[50, 50] == 0
assert m[10, 10] == 255
@pytest.mark.usefixtures("_fake_migan")
def test_crops_around_mask_so_onnx_input_is_bounded(self):
# Large frame, small corner mark: the tensor fed to MI-GAN is the padded
# CROP (pad = max(256, 2*bbox)), not the full image -- this is what holds the
# ONNX working set roughly constant on big uploads instead of scaling with
# the image (the memory fix). Untouched pixels stay exact; the mark is filled.
img = np.full((2000, 3000, 3), 120, np.uint8)
out = erase(img, boxes=[(2900, 1900, 60, 60)], backend="migan", dilate=0)
assert out.shape == img.shape
_, _, fh, fw = self.captured["image_shape"]
assert fh < 700 # crop height, not the 2000px frame
assert fw < 700 # crop width, not the 3000px frame
assert tuple(int(v) for v in out[1930, 2930]) == (0, 0, 255) # mark -> red fill
assert np.array_equal(out[:100, :100], img[:100, :100]) # far corner untouched
@pytest.mark.usefixtures("_fake_migan")
def test_grayscale_2d_does_not_raise(self):
gray = np.full((100, 100), 120, np.uint8)
out = erase(gray, boxes=[(40, 40, 20, 20)], backend="migan", dilate=0)
assert out.ndim == 2
assert out.shape == gray.shape
@pytest.mark.usefixtures("_fake_migan")
def test_bgra_preserves_alpha(self):
bgra = np.full((100, 100, 4), 120, np.uint8)
bgra[..., 3] = 200
out = erase(bgra, boxes=[(40, 40, 20, 20)], backend="migan", dilate=0)
assert out.shape == bgra.shape
assert np.array_equal(out[..., 3], bgra[..., 3])
+45 -67
View File
@@ -12,11 +12,9 @@ import cv2
import numpy as np
import pytest
from remove_ai_watermarks import watermark_registry as registry
from remove_ai_watermarks.samsung_engine import (
_ALPHA_HEIGHT_FRAC,
_ALPHA_LOGO_BGR,
_ALPHA_MARGIN_BOTTOM_FRAC,
_ALPHA_MARGIN_LEFT_FRAC,
_ALPHA_NATIVE_WIDTH,
_ALPHA_WIDTH_FRAC,
DETECT_NCC_THRESHOLD,
@@ -33,12 +31,13 @@ def _compose(w: int, h: int, bg: float = 100.0):
img = np.full((h, w, 3), bg, np.float32)
at = _alpha_template()
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
ax = int(_ALPHA_MARGIN_LEFT_FRAC * w)
ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh
margin = int(0.015 * w)
ax = margin
ay = h - margin - gh
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * img).clip(0, 255).astype(np.uint8)
wm = (a3 * 255.0 + (1 - a3) * img).clip(0, 255).astype(np.uint8)
return wm, amap > 0.15
@@ -87,6 +86,16 @@ class TestDetect:
solid = np.full_like(box, 255)
assert _template_match_score(solid, _ALPHA_NATIVE_WIDTH) < DETECT_NCC_THRESHOLD
def test_small_image_guarded_from_false_positive(self):
"""Below the minimum short side a tiny geometric shape spuriously NCC-matches
the glyph silhouette (the 2026-06-26 small-icon FP class). The size guard
suppresses detection there. Bracket it: a real mark is detected at native
size, but the same content downscaled below the guard is not."""
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, int(_ALPHA_NATIVE_WIDTH * 1.33))
eng = SamsungEngine()
assert eng.detect(wm).detected # native: real mark detected
assert not eng.detect(cv2.resize(wm, (150, 150))).detected # below guard: suppressed
def test_synthetic_mark_detected(self):
"""A watermark composed from the real alpha is detected at its threshold."""
eng = SamsungEngine()
@@ -96,7 +105,7 @@ class TestDetect:
assert det.confidence >= DETECT_NCC_THRESHOLD
class TestReverseAlpha:
class TestAlphaAssetAndRemoval:
def test_alpha_asset_loads(self):
at = _alpha_template()
assert at is not None
@@ -104,89 +113,58 @@ class TestReverseAlpha:
assert float(at.min()) >= 0.0
assert float(at.max()) <= 1.0
def test_logo_is_white(self):
assert _ALPHA_LOGO_BGR == (255.0, 255.0, 255.0)
def test_available_whenever_asset_present(self):
eng = SamsungEngine()
assert eng.reverse_alpha_available(np.zeros((1086, 1086, 3), np.uint8))
assert eng.reverse_alpha_available(np.zeros((4054, 2958, 3), np.uint8))
assert not eng.reverse_alpha_available(np.zeros((0, 0, 3), np.uint8))
def test_footprint_mask_in_bottom_left(self):
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, int(_ALPHA_NATIVE_WIDTH * 1.33))
mask = SamsungEngine().footprint_mask(wm)
assert mask is not None
assert mask.shape == wm.shape[:2]
ys, xs = np.where(mask > 0)
assert ys.mean() > wm.shape[0] / 2 # bottom
assert xs.mean() < wm.shape[1] / 2 # left
def test_removes_synthetic_mark(self):
"""Reverse-alpha + residual inpaint clears the composed mark (re-detect no
longer fires)."""
eng = SamsungEngine()
"""localize -> cv2 fill clears the composed mark (re-detect no longer fires)."""
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, int(_ALPHA_NATIVE_WIDTH * 1.33))
assert eng.detect(wm).detected
out = eng.remove_watermark_reverse_alpha(wm)
assert not eng.detect(out).detected
assert SamsungEngine().detect(wm).detected
out, region = registry.get_mark("samsung").remove(wm, backend="cv2")
assert region is not None
assert not SamsungEngine().detect(out).detected
@pytest.mark.parametrize(
("w", "h", "max_err"),
("w", "h"),
[
(_ALPHA_NATIVE_WIDTH, int(_ALPHA_NATIVE_WIDTH * 1.33), 5.0), # captured width
(2958, 4054, 10.0), # real-photo width (~2.7x native) -> NCC alignment generalizes
(_ALPHA_NATIVE_WIDTH, int(_ALPHA_NATIVE_WIDTH * 1.33)), # captured width
(2958, 4054), # real-photo width (~2.7x native) -> template-free footprint generalizes
],
)
def test_recovers_flat_background(self, w, h, max_err):
eng = SamsungEngine()
def test_fill_removes_and_leaves_far_region(self, w, h):
wm, mark = _compose(w, h)
assert float(np.abs(wm.astype(np.float32)[mark] - 100.0).mean()) > 15 # mark visible
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
assert float(np.abs(out[mark] - 100.0).mean()) < max_err
def test_far_region_untouched(self):
"""The residual inpaint only touches the bottom-left footprint; the
opposite (top-right) corner stays pixel-identical."""
eng = SamsungEngine()
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, int(_ALPHA_NATIVE_WIDTH * 1.33))
out = eng.remove_watermark_reverse_alpha(wm)
h, w = wm.shape[:2]
assert np.array_equal(wm[: h // 2, w // 2 :], out[: h // 2, w // 2 :])
def test_recovers_shifted_mark_on_texture(self):
"""A real mark is re-rasterized a few px off its fixed slot, so removal must
NCC-align to it (a too-tight locate box would let a corner-ward shift escape
the search and leave a readable outline). Composes the real alpha SHIFTED on
a known texture and asserts the texture is recovered."""
eng = SamsungEngine()
w, h = _ALPHA_NATIVE_WIDTH, int(_ALPHA_NATIVE_WIDTH * 1.33)
at = _alpha_template()
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
ax = max(0, int(_ALPHA_MARGIN_LEFT_FRAC * w) + 9) # shift right of the fixed slot
ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh - 7 # shift up
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
base = 120 + 40 * np.sin(xx / 90.0) + 30 * np.cos(yy / 70.0)
bg = np.clip(np.stack([base, base * 0.95, base * 1.05], axis=-1), 0, 255)
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * bg).clip(0, 255).astype(np.uint8)
mark = amap > 0.15
assert float(np.abs(wm.astype(np.float32)[mark] - bg[mark]).mean()) > 20 # mark clearly visible
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
assert float(np.abs(out[mark] - bg[mark]).mean()) < 10.0 # texture recovered, no outline
before = SamsungEngine().detect(wm)
out, _ = registry.get_mark("samsung").remove(wm, backend="cv2")
assert SamsungEngine().detect(out).confidence < before.confidence
# The mark is bottom-left; the opposite (top-right) corner stays exact.
assert np.array_equal(out[: h // 2, w // 2 :], wm[: h // 2, w // 2 :])
class TestDegenerateAndChannelInputs:
"""Removal must not crash on degenerate sizes or non-3-channel inputs."""
"""footprint_mask must not crash on degenerate sizes or non-3-channel inputs."""
@pytest.mark.parametrize(("w", "h"), [(2048, 1), (1, 2048), (2048, 8)])
def test_wide_short_does_not_raise(self, w, h):
eng = SamsungEngine()
img = np.zeros((h, w, 3), np.uint8)
out = eng.remove_watermark_reverse_alpha(img)
assert out.shape == img.shape
mask = eng.footprint_mask(img, force=True)
assert mask is None or mask.shape == (h, w)
def test_grayscale_2d_does_not_raise(self):
eng = SamsungEngine()
gray = np.zeros((1448, 1086), np.uint8)
out = eng.remove_watermark_reverse_alpha(gray)
assert out.shape == (1448, 1086, 3)
mask = eng.footprint_mask(gray, force=True)
assert mask is None or mask.shape == (1448, 1086)
def test_bgra_4channel_does_not_raise(self):
eng = SamsungEngine()
bgra = np.zeros((1448, 1086, 4), np.uint8)
out = eng.remove_watermark_reverse_alpha(bgra)
assert out.shape == (1448, 1086, 3)
mask = eng.footprint_mask(bgra, force=True)
assert mask is None or mask.shape == (1448, 1086)
+58
View File
@@ -0,0 +1,58 @@
"""Memory guard for the text-mark engine ``extract_mask``.
``extract_mask`` used to build a full ``(h, w)`` uint8 mask that every caller
cropped to the located box; it now returns the box-sized mask directly. This test
locks in the O(footprint) memory characteristic so a regression back to a
full-frame allocation fails loudly.
"""
from __future__ import annotations
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 (white) onto a flat mid-gray field at
the captured native width, anchored in the mark's configured corner."""
cfg = engine.config
nw = module._ALPHA_NATIVE_WIDTH
at = module._alpha_template()
gw, gh = int(cfg.alpha_width_frac * nw), int(cfg.alpha_height_frac * nw)
margin = int(0.015 * nw)
ax = (nw - margin - gw) if cfg.corner == "br" else margin
ay = nw - margin - gh
amap = np.zeros((nw, nw), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
img = np.full((nw, nw, 3), 100.0, np.float32)
return (a3 * 255.0 + (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]
+210
View File
@@ -0,0 +1,210 @@
"""Unit tests for the sliding-window tiled-diffusion helpers (no GPU/model).
``tiling`` is pure numpy/PIL: the geometry (``plan_tiles`` / ``_axis_positions``),
the feather window (``feather_weights``), and the blend loop (``run_tiled``) are all
exercised here with a plain callable standing in for the diffusion pass, so the
seam-free reconstruction and the tile layout are guarded without loading SDXL.
"""
from __future__ import annotations
import numpy as np
import pytest
from PIL import Image
from remove_ai_watermarks.noai.tiling import (
Tile,
_axis_positions,
feather_region_composite,
feather_weights,
plan_tiles,
run_tiled,
)
class TestAxisPositions:
def test_single_tile_when_length_fits(self):
assert _axis_positions(800, 1024, 128) == [0]
assert _axis_positions(1024, 1024, 128) == [0]
def test_two_tiles_last_flush_to_edge(self):
# 1500 wide, tile 1024, step 1024-128=896: starts [0], then last = 1500-1024=476.
assert _axis_positions(1500, 1024, 128) == [0, 476]
def test_uniform_step_then_flush(self):
# 3000, tile 1024, step 896 -> 0, 896, 1792; last = 1976 appended (2688 would
# overrun). The regular range stops at 1792 (next 2688 > 1976), so flush adds 1976.
assert _axis_positions(3000, 1024, 128) == [0, 896, 1792, 1976]
def test_no_duplicate_when_range_already_hits_edge(self):
# length-tile divisible by step: the last range entry already equals the edge.
# 1024*2-128 = 1920 width, step 896, last = 896 -> range gives [0, 896]; no dup.
assert _axis_positions(1920, 1024, 128) == [0, 896]
def test_overlap_at_least_tile_still_progresses(self):
# Pathological overlap >= tile is clamped to tile-1 so step stays >= 1.
positions = _axis_positions(2000, 1024, 5000)
assert positions[0] == 0
assert positions[-1] == 2000 - 1024
assert positions == sorted(positions)
def test_invalid_tile_raises(self):
with pytest.raises(ValueError, match="tile must be positive"):
_axis_positions(100, 0, 10)
class TestPlanTiles:
def test_single_tile_for_small_image(self):
tiles = plan_tiles(800, 600, 1024, 128)
assert tiles == [Tile(0, 0, 800, 600)]
def test_grid_is_row_major_and_uniform_size(self):
tiles = plan_tiles(1500, 1500, 1024, 128)
# 2x2 grid: xs/ys both [0, 476].
assert [(t.x, t.y) for t in tiles] == [(0, 0), (476, 0), (0, 476), (476, 476)]
# Every tile is exactly tile_size (uniform -> SDXL-friendly).
assert all((t.width, t.height) == (1024, 1024) for t in tiles)
def test_tiles_cover_the_full_canvas(self):
width, height = 2600, 1800
tiles = plan_tiles(width, height, 1024, 128)
covered = np.zeros((height, width), dtype=bool)
for t in tiles:
covered[t.y : t.y + t.height, t.x : t.x + t.width] = True
assert covered.all()
class TestFeatherWeights:
def test_shape_matches_tile(self):
assert feather_weights(64, 48, 8).shape == (48, 64)
def test_strictly_positive(self):
assert (feather_weights(64, 64, 16) > 0).all()
def test_interior_higher_than_edge(self):
w = feather_weights(64, 64, 16)
assert w[32, 32] == pytest.approx(1.0)
# The corner sits in the taper, so it is well below the interior.
assert w[0, 0] < w[32, 32]
def test_symmetric(self):
w = feather_weights(64, 64, 16)
assert np.allclose(w, w[::-1, :])
assert np.allclose(w, w[:, ::-1])
def test_zero_overlap_is_flat(self):
# No taper requested -> a flat window of ones.
assert np.allclose(feather_weights(32, 32, 0), 1.0)
class TestRunTiled:
def test_identity_generate_reconstructs_image(self):
# A blend of identical (unchanged) tiles must reproduce the input exactly,
# regardless of overlap -- the feather weights are a partition-of-unity once
# normalised. This is the seam-free guarantee.
rng = np.random.default_rng(0)
arr = rng.integers(0, 256, size=(1500, 1300, 3), dtype=np.uint8)
image = Image.fromarray(arr)
out = run_tiled(lambda tile: tile, image, tile_size=512, overlap=64)
assert out.size == image.size
assert np.abs(np.asarray(out, dtype=np.int16) - arr.astype(np.int16)).max() <= 1
def test_generate_called_once_per_tile(self):
calls: list[tuple[int, int]] = []
def generate(tile: Image.Image) -> Image.Image:
calls.append(tile.size)
return tile
image = Image.new("RGB", (1500, 1500), (120, 130, 140))
run_tiled(generate, image, tile_size=1024, overlap=128)
assert len(calls) == len(plan_tiles(1500, 1500, 1024, 128)) == 4
def test_single_tile_path_for_small_image(self):
image = Image.new("RGB", (300, 200), (10, 20, 30))
out = run_tiled(lambda tile: tile, image, tile_size=1024, overlap=128)
assert out.size == (300, 200)
assert np.asarray(out)[0, 0].tolist() == [10, 20, 30]
def test_mismatched_generate_output_is_resized_back(self):
# A pipeline that rounds dims to the latent grid returns a slightly different
# size; run_tiled must resize it back so the blend buffers line up.
def generate(tile: Image.Image) -> Image.Image:
w, h = tile.size
return tile.resize((w - w % 8, h - h % 8), Image.Resampling.LANCZOS)
image = Image.new("RGB", (1500, 1100), (200, 100, 50))
out = run_tiled(generate, image, tile_size=1024, overlap=128)
assert out.size == (1500, 1100)
class TestFeatherRegionComposite:
"""Region-targeted compositing for AI-enhanced composites: only the AI box is
regenerated, the real photo outside it stays pixel-exact (roadmap P1#8)."""
@staticmethod
def _frames(h=200, w=300):
base = np.full((h, w, 3), 80, np.uint8)
regenerated = np.full((h, w, 3), 200, np.uint8)
return base, regenerated
def test_outside_box_is_pixel_exact(self):
base, regen = self._frames()
out = feather_region_composite(base, regen, (100, 60, 80, 50), feather=8)
# Far corners are well outside the box -> identical to base.
assert np.array_equal(out[:50, :80], base[:50, :80])
assert np.array_equal(out[150:, 220:], base[150:, 220:])
def test_interior_equals_regenerated(self):
base, regen = self._frames()
out = feather_region_composite(base, regen, (100, 60, 80, 50), feather=8)
# Deep interior of the box (past the feather ramp) is fully regenerated.
assert np.array_equal(out[80:90, 130:150], regen[80:90, 130:150])
def test_hard_paste_when_no_feather(self):
base, regen = self._frames()
out = feather_region_composite(base, regen, (100, 60, 80, 50), feather=0)
assert np.array_equal(out[60:110, 100:180], regen[60:110, 100:180])
assert np.array_equal(out[:60], base[:60])
def test_seam_is_monotonic_ramp(self):
base, regen = self._frames()
out = feather_region_composite(base, regen, (100, 60, 80, 50), feather=10).astype(np.float32)
# Along a horizontal line crossing the left edge, values rise from base(80)
# toward regenerated(200) monotonically through the feather band.
row = out[85, 100:115, 0]
assert row[0] < row[-1]
assert np.all(np.diff(row) >= -1e-3)
def test_dtype_preserved(self):
base, regen = self._frames()
out = feather_region_composite(base, regen, (50, 50, 40, 40), feather=4)
assert out.dtype == base.dtype
def test_grayscale_2d_supported(self):
base = np.full((100, 120), 30, np.uint8)
regen = np.full((100, 120), 220, np.uint8)
out = feather_region_composite(base, regen, (40, 30, 30, 30), feather=4)
assert out.shape == base.shape
assert np.array_equal(out[:30], base[:30])
def test_empty_or_offimage_box_returns_base(self):
base, regen = self._frames()
assert np.array_equal(feather_region_composite(base, regen, (0, 0, 0, 0)), base)
assert np.array_equal(feather_region_composite(base, regen, (500, 500, 40, 40)), base)
def test_box_clamped_to_image_bounds(self):
base, regen = self._frames()
# Box overhangs the bottom-right; only the in-image part is composited.
out = feather_region_composite(base, regen, (280, 180, 60, 60), feather=0)
assert np.array_equal(out[180:, 280:], regen[180:, 280:])
assert out.shape == base.shape
def test_shape_mismatch_raises(self):
base, _ = self._frames(200, 300)
bad = np.full((100, 100, 3), 200, np.uint8)
with pytest.raises(ValueError, match="shape mismatch"):
feather_region_composite(base, bad, (10, 10, 20, 20))
+241 -17
View File
@@ -1,4 +1,4 @@
"""Tests for the known-visible-watermark registry (reverse-alpha only)."""
"""Tests for the known-visible-watermark registry (localize -> fill)."""
from __future__ import annotations
@@ -14,14 +14,17 @@ DOUBAO_SAMPLE = Path(__file__).resolve().parents[1] / "data" / "samples" / "doub
class TestCatalog:
def test_keys(self):
assert reg.mark_keys() == ["gemini", "doubao", "jimeng", "samsung"]
assert reg.mark_keys() == ["gemini", "doubao", "jimeng", "samsung", "jimeng_pill"]
def test_all_in_auto(self):
assert all(m.in_auto for m in reg.known_marks())
def test_recovery_is_reverse_alpha(self):
# Every catalogued mark is removed by exact reverse-alpha (no inpaint).
assert all(m.recovery == "reverse-alpha" for m in reg.known_marks())
def test_marks_expose_detect_and_mask(self):
# Every mark drives the uniform localize -> fill contract: a detect callable
# (verdict + bbox, no mask) and a mask callable (full-frame footprint).
for m in reg.known_marks():
assert callable(m._detect)
assert callable(m._mask)
def test_locations(self):
by_key = {m.key: m for m in reg.known_marks()}
@@ -29,6 +32,7 @@ class TestCatalog:
assert by_key["doubao"].location == "bottom-right"
assert by_key["jimeng"].location == "bottom-right"
assert by_key["samsung"].location == "bottom-left"
assert by_key["jimeng_pill"].location == "top-left"
def test_get_mark_unknown_raises(self):
with pytest.raises(KeyError):
@@ -39,34 +43,254 @@ 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", "jimeng", "samsung"}
assert keys == {"gemini", "doubao", "jimeng", "samsung", "jimeng_pill"}
def test_blank_image_no_auto_mark(self):
assert reg.best_auto_mark(np.zeros((256, 256, 3), np.uint8)) is None
dets = reg.detect_marks(np.zeros((256, 256, 3), np.uint8), include_explicit=False)
assert not any(d.detected for d in dets)
@pytest.mark.parametrize("shape", [(1, 1, 3), (8, 8, 3), (15, 15, 3), (12, 300, 3), (300, 10, 3)])
def test_tiny_image_no_crash(self, shape):
"""Regression: an image whose short side is < 16 px (below the Gemini template
floor) must yield no detection, not crash. detect_marks/remove_auto_marks are
the public visible/all/batch path; a tiny thumbnail in a batch used to take the
whole auto pass down with an IndexError (empty candidate list dereference)."""
img = np.full(shape, 100, np.uint8)
assert not any(d.detected for d in reg.detect_marks(img, include_explicit=False))
result, removed = reg.remove_auto_marks(img, backend="cv2")
assert removed == []
assert result.shape == img.shape
@pytest.mark.parametrize("shape", [(0, 5), (5, 0), (0, 5, 4), (0, 0)])
def test_forced_remove_on_empty_array_no_crash(self, shape):
"""Regression: footprint_mask ran to_bgr (cvtColor) before any size check, so a
forced remove on a zero-size ndarray crashed (cv2.error on an empty Mat). detect
already guarded this; footprint_mask must too. Covers the text + gemini engines."""
empty = np.zeros(shape, np.uint8)
for key in ("doubao", "jimeng", "samsung", "gemini"):
_result, mask = reg.get_mark(key).remove(empty, force=True)
assert mask is None
class TestBackendResolution:
def test_auto_resolves_to_available_backend(self):
assert reg.resolve_backend("auto") in ("cv2", "migan", "lama")
def test_explicit_backend_passes_through(self):
assert reg.resolve_backend("cv2") == "cv2"
assert reg.resolve_backend("lama") == "lama"
def test_cv2_fallback_warns_once(self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture):
import logging
from remove_ai_watermarks import region_eraser
monkeypatch.setattr(region_eraser, "lama_available", lambda: False)
monkeypatch.setattr(region_eraser, "migan_available", lambda: False)
monkeypatch.setattr(reg, "_warned_cv2_fallback", False)
with caplog.at_level(logging.WARNING):
assert reg.preferred_inpaint_backend() == "cv2"
assert reg.preferred_inpaint_backend() == "cv2"
assert sum("cv2 classical inpaint" in r.message for r in caplog.records) == 1
class TestFill:
def test_fill_erases_masked_region(self):
# A bright square on a flat field, masked, is inpainted away (cv2 backend).
img = np.full((128, 128, 3), 60, np.uint8)
img[40:70, 40:70] = 240
mask = np.zeros((128, 128), np.uint8)
mask[36:74, 36:74] = 255
out = reg.fill(img, mask, backend="cv2")
assert out.shape == img.shape
# the masked bright square is pulled toward the surrounding field
assert int(out[55, 55].mean()) < 160
def test_fill_empty_mask_is_noop(self):
img = np.full((64, 64, 3), 100, np.uint8)
out = reg.fill(img, np.zeros((64, 64), np.uint8), backend="cv2")
assert np.array_equal(out, img)
class TestProvenanceGate:
"""The Gemini trust gate relaxes from 0.5 to 0.35 when provenance confirms Google;
tested deterministically by stubbing the engine's raw detection confidence."""
def _stub(self, monkeypatch: pytest.MonkeyPatch, conf: float) -> None:
from remove_ai_watermarks.gemini_engine import DetectionResult
def fake_detect(image, force_size=None, *, trust_provenance=False):
return DetectionResult(detected=conf >= 0.35, confidence=conf, region=(10, 10, 48, 48))
monkeypatch.setattr(reg._engine("gemini"), "detect_watermark", fake_detect)
def test_midband_conf_needs_provenance(self, monkeypatch: pytest.MonkeyPatch):
# conf 0.42 sits in [0.35, 0.5): demoted without provenance, trusted with it.
self._stub(monkeypatch, 0.42)
img = np.zeros((256, 256, 3), np.uint8)
assert reg.get_mark("gemini").detect(img).detected is False
assert reg.get_mark("gemini").detect(img, provenance=True).detected is True
def test_high_conf_detected_either_way(self, monkeypatch: pytest.MonkeyPatch):
self._stub(monkeypatch, 0.72)
img = np.zeros((256, 256, 3), np.uint8)
assert reg.get_mark("gemini").detect(img).detected is True
assert reg.get_mark("gemini").detect(img, provenance=True).detected is True
@pytest.mark.skipif(not DOUBAO_SAMPLE.exists(), reason="doubao sample not present")
class TestRealSample:
def test_doubao_sample_wins_auto(self):
def test_doubao_sample_detected(self):
from remove_ai_watermarks.image_io import imread
best = reg.best_auto_mark(imread(DOUBAO_SAMPLE))
assert best is not None
assert best.key == "doubao"
fired = [d.key for d in reg.detect_marks(imread(DOUBAO_SAMPLE), include_explicit=False) if d.detected]
assert "doubao" in fired
def test_doubao_remove_returns_region(self):
from remove_ai_watermarks.image_io import imread
img = imread(DOUBAO_SAMPLE) # 2048 wide -> reverse-alpha applies
result, region = reg.get_mark("doubao").remove(img)
img = imread(DOUBAO_SAMPLE)
result, region = reg.get_mark("doubao").remove(img, backend="cv2")
assert region is not None
assert result.shape == img.shape
class TestReverseAlphaOnly:
def test_doubao_off_resolution_is_skipped(self):
# No alpha capture for this width -> no inpaint fallback, image untouched.
class TestLocalizeFill:
def test_clean_corner_is_untouched(self):
# No glyph in the corner -> no mask -> remove is a no-op copy.
img = np.zeros((512, 512, 3), np.uint8)
result, region = reg.get_mark("doubao").remove(img)
result, region = reg.get_mark("doubao").remove(img, backend="cv2")
assert region is None
assert np.array_equal(result, img)
class TestSensitivity:
"""``resolve_relax`` turns the sensitivity policy + evidence into the per-mark
relaxation boolean the engines consume."""
def test_strict_never_relaxes(self):
# even with metadata provenance, strict keeps the conservative gate
assert (
reg.resolve_relax("gemini", sensitivity="strict", provenance=frozenset({"gemini"}), strict_keys=set())
is False
)
def test_assume_ai_always_relaxes(self):
assert reg.resolve_relax("gemini", sensitivity="assume_ai", provenance=frozenset(), strict_keys=set()) is True
def test_auto_relaxes_on_own_metadata(self):
assert (
reg.resolve_relax("gemini", sensitivity="auto", provenance=frozenset({"gemini"}), strict_keys=set()) is True
)
def test_auto_strict_without_evidence(self):
assert reg.resolve_relax("gemini", sensitivity="auto", provenance=frozenset(), strict_keys=set()) is False
def test_auto_cross_mark_same_product(self):
# a detected Jimeng wordmark relaxes the Jimeng pill (same product, other corner)
assert (
reg.resolve_relax("jimeng_pill", sensitivity="auto", provenance=frozenset(), strict_keys={"jimeng"}) is True
)
def test_auto_no_cross_mark_across_products(self):
# a detected Jimeng wordmark must NOT relax Doubao (distinct products, same corner)
assert reg.resolve_relax("doubao", sensitivity="auto", provenance=frozenset(), strict_keys={"jimeng"}) is False
def test_remove_auto_marks_accepts_all_sensitivities(self):
blank = np.zeros((256, 256, 3), np.uint8)
for s in ("auto", "strict", "assume_ai"):
_, removed = reg.remove_auto_marks(blank, sensitivity=s, backend="cv2")
assert removed == []
class TestArbiter:
"""``decide`` is the PURE removal arbiter: (candidates, context) -> ordered
winners, no image / no I/O. Tested in isolation by handing it fabricated
Candidates -- this is the payoff of separating decision from perception."""
@staticmethod
def _c(key, *, strict=False, relaxed=False, flat=False):
feats = {"footprint_flat": 1.0} if flat else {}
return reg.Candidate(key, f"L:{key}", strict, relaxed, feats)
def _keys(self, cands, ctx):
return {d.candidate.key for d in reg.decide(cands, ctx)}
def test_empty(self):
assert reg.decide([], reg.Context()) == []
def test_strict_uses_strict_verdict(self):
# relaxed-only detection must NOT fire under strict
assert self._keys([self._c("gemini", relaxed=True)], reg.Context(sensitivity="strict")) == set()
def test_assume_ai_uses_relaxed(self):
fired = reg.decide([self._c("gemini", relaxed=True)], reg.Context(sensitivity="assume_ai"))
assert [d.candidate.key for d in fired] == ["gemini"]
assert fired[0].relax is True
def test_auto_relaxes_on_provenance(self):
c = [self._c("gemini", relaxed=True)]
assert self._keys(c, reg.Context(provenance=frozenset({"gemini"}))) == {"gemini"}
assert self._keys(c, reg.Context()) == set() # no evidence -> strict verdict (not fired)
def test_cross_mark_relaxes_pill_via_jimeng(self):
cands = [self._c("jimeng", strict=True, relaxed=True), self._c("jimeng_pill", relaxed=True, flat=True)]
assert self._keys(cands, reg.Context()) == {"jimeng", "jimeng_pill"}
def test_pill_dropped_on_doubao(self):
cands = [
self._c("doubao", strict=True, relaxed=True),
self._c("jimeng_pill", strict=True, relaxed=True, flat=True),
]
keys = self._keys(cands, reg.Context(provenance=frozenset({"jimeng"})))
assert "doubao" in keys
assert "jimeng_pill" not in keys
def test_pill_metadata_arm_gated_on_flatness(self):
ctx = reg.Context(provenance=frozenset({"jimeng"}))
assert self._keys([self._c("jimeng_pill", strict=True, relaxed=True, flat=True)], ctx) == {"jimeng_pill"}
assert self._keys([self._c("jimeng_pill", strict=True, relaxed=True, flat=False)], ctx) == set()
def test_pill_wordmark_arm_ignores_flatness(self):
# wordmark present -> pill removed even on a textured (non-flat) footprint
cands = [
self._c("jimeng", strict=True, relaxed=True),
self._c("jimeng_pill", strict=True, relaxed=True, flat=False),
]
assert "jimeng_pill" in self._keys(cands, reg.Context())
class TestProvenanceMaskThreading:
"""Regression for the provenance-relaxed Gemini no-op (#1) and the false 'removed'
label (#2). Before the fix, footprint_mask re-detected WITHOUT trust_provenance, the
FP gate demoted the sparkle to detected=False, the mask came back None, yet
remove_auto_marks still reported the mark as removed."""
def test_relaxed_sparkle_yields_mask(self, monkeypatch: pytest.MonkeyPatch):
# A sparkle a strict re-detect would demote (detected False) but a
# provenance-relaxed detect accepts must still produce a removal mask.
from remove_ai_watermarks.gemini_engine import DetectionResult
def fake(image, force_size=None, *, trust_provenance=False):
return DetectionResult(
detected=trust_provenance, confidence=0.42 if trust_provenance else 0.30, region=(400, 400, 60)
)
monkeypatch.setattr(reg._engine("gemini"), "detect_watermark", fake)
img = np.full((512, 512, 3), 90, np.uint8)
assert reg.get_mark("gemini").localize(img, provenance=True).mask is not None
assert reg.get_mark("gemini").localize(img, provenance=False).mask is None
def test_no_label_when_mask_none(self, monkeypatch: pytest.MonkeyPatch):
# A decided mark whose mask comes back None must NOT be reported as removed.
from remove_ai_watermarks.gemini_engine import DetectionResult
eng = reg._engine("gemini")
monkeypatch.setattr(
eng,
"detect_watermark",
lambda image, force_size=None, *, trust_provenance=False: DetectionResult(True, 0.9, (10, 10, 40)),
)
monkeypatch.setattr(eng, "footprint_mask", lambda image, *, force=False, region=None, dilate=None: None)
_, removed = reg.remove_auto_marks(np.zeros((256, 256, 3), np.uint8), sensitivity="strict", backend="cv2")
assert "Google Gemini sparkle" not in removed
+3 -1
View File
@@ -14,7 +14,9 @@ class ImageIFD:
Artist: int
ImageDescription: int
class ExifIFD: ...
class ExifIFD:
UserComment: int
class GPSIFD: ...
def load(input_data: bytes | str, key_is_name: bool = ...) -> dict[str, Any]: ...
Generated
+413 -376
View File
File diff suppressed because it is too large. Load diff