Compare commits

...
37 Commits
Author SHA1 Message Date
Victor KuznetsovandClaude Opus 4.8 9be66752c5 chore(release): v0.7.1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:21:02 -07:00
Victor KuznetsovandClaude Opus 4.8 69559226d7 Clarify metadata command supports video/audio, drop misfiring format warning (#33)
The `metadata` command handles more than images: `remove_ai_metadata` strips
C2PA / AIGC provenance from MP4/MOV/M4V/M4A and from WebM/MP3/WAV/FLAC/OGG via
ffmpeg. But the help said "from images" and the shared `_validate_image` call
printed "Warning: .mp4 may not be supported" on exactly those supported
containers. The argument's `exists=True` already enforces the file exists, so
the validation call only added the wrong warning here.

Update the docstring to list the real format coverage and drop the
image-only validation from this command. The image commands keep it.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 13:20:04 -07:00
Victor KuznetsovandClaude Opus 4.8 29da3c52b6 Raise default SynthID-removal strength 0.05 → 0.10 (current Google SynthID) (#32)
* Raise default SynthID-removal strength 0.05 -> 0.10 (current Google SynthID)

The old default (0.04/0.05) no longer removes the CURRENT Google SynthID (Nano
Banana / Gemini 3): verified 2026-05-30 via the Gemini 'Verify with SynthID'
oracle on a real image -- 0.05 still detected, 0.10 not detected (OpenAI's was
already cleared at 0.05). Add DEFAULT_STRENGTH=0.10 in watermark_profiles, route
the engine + CLI defaults to it. At 0.10 small text deforms more, which is why
text protection (_run_region_hires) runs by default. CLAUDE.md SynthID note
corrected. CAVEAT: n=1 Google + n=1 OpenAI; broad corpus oracle validation
pending (task tracked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Drop unused LOW/MEDIUM/HIGH strength profiles; CLI --strength defaults to DEFAULT_STRENGTH

The fixed strength presets (and get_recommended_strength) were dead -- nothing in
the pipeline used them, only tests. One knob now: DEFAULT_STRENGTH (0.10),
overridable per-call via the CLI --strength flag, which now defaults to that
constant (single source of truth). Removed the WatermarkRemover.LOW/MEDIUM/HIGH
class attrs and the get_recommended_strength tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:15:58 -07:00
Victor KuznetsovandClaude Opus 4.8 e4f558dccf Add per-region high-resolution text protection (regenerate crisp, scrub everywhere) (#31)
Replace the default text-protection path. Differential Diffusion froze text in
latent space, which left SynthID intact inside text (violating remove-everywhere)
and still softened sub-8px strokes (VAE latent limit). _run_region_hires instead
scrubs the whole image, then re-scrubs each detected text block at high resolution
and feather-composites it back: every pixel is regenerated (watermark removed
everywhere) while small text stays crisp (high-res strokes span >1 latent cell).

merge_text_regions + feather_paste are pure and unit-tested; each re-scrubbed
patch is phase-correlated back to the original crop to null the ~1-2px round-trip
offset. Synthetic 18px multilingual text: text-region SSIM 0.28 -> 0.48, visually
garbled -> readable across Latin/Cyrillic/CJK. Legacy _run_differential /
build_change_map remain but are no longer the default. Prod use still requires
confirming via the SynthID oracle that re-scrubbed text zones read watermark-free.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:59:29 -07:00
Victor KuznetsovandClaude Opus 4.8 c928ee6e42 chore(release): v0.7.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 12:35:32 -07:00
Victor KuznetsovandClaude Opus 4.8 89f427852f Fix #30 white box: stop zeroing alpha in the watermark region on save
On RGBA inputs the CLI forced the watermark bbox alpha to 0 on save, so the
removed-sparkle area became a transparent hole that renders as a solid white
box on any non-transparent viewer. The Gemini app exports opaque RGBA, so
every user hit it. Reverse-alpha already recovers the real pixels there (and
`erase` inpaints them), so there is no artifact to hide -- the hole was the
bug, introduced as an over-correction in d091b9f.

`_write_bgr_with_alpha` now rejoins the input alpha plane unchanged (drops the
`clear_region`/`pad` params); the `visible` / `erase` / `all` / `batch` call
sites drop the cleared-region argument and the orphaned region bookkeeping.
The registry `remove()` still returns the mark bbox (used for inpaint_residual
positioning); the CLI just no longer clears alpha with it.

Inverts the test that locked in the old behavior into a #30 regression guard
(watermark-region alpha stays opaque, no pixel forced transparent). Verified
end-to-end on a real Gemini RGBA export: sparkle gone, zero transparent
pixels, clean over a white background.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 12:27:37 -07:00
Victor KuznetsovandClaude Opus 4.8 25a1acc53b Detect TC260 AIGC label in JPEG EXIF and late/attribute PNG XMP
A corpus audit surfaced China TC260 AIGC-labeled images that `identify`
missed. Three detection gaps in `aigc_label`, all fixed:

- raw-JSON `{"AIGC":{...}}` in JPEG EXIF (UserComment): brace-matched from
  the scan head with `json.raw_decode`, gated on a TC260 field like the
  PNG-chunk path. (Doubao-class output via that export surface.)
- XMP attribute form `TC260:AIGC="{...}"` (PicWish): folded into the
  element regex as a second alternation.
- TC260 XMP packet appended after a large `IDAT`, past the 1 MB scan
  window: `scan_head` now appends late PNG metadata chunks via
  `_png_late_metadata`, mirroring the existing ISOBMFF late-box scan.

Adds `scripts/corpus_gap_scan.py`: runs `identify` over a corpus, writes
the per-file report CSV, and flags `unknown` files that carry a known
marker in their metadata region (the audit that found these gaps).
Scanning only the metadata region — not the whole file — avoids the
random short-token collisions inside compressed PNG/JPEG streams.

On the local corpus this lifts 3 files from `unknown` to AI (China AIGC)
and leaves zero false gap candidates. Synthetic piexif/PngInfo fixtures
cover all three forms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 11:44:53 -07:00
Victor KuznetsovandClaude Opus 4.8 58bdf51c59 Visible-watermark registry: reverse-alpha-only Doubao + Gemini, exact native recovery (#28)
* fix(trustmark): gate detection on re-encode durability to kill false positives

TrustMark's wm_present flag is a BCH validity check that spuriously
validates on a content-correlated fraction of un-watermarked images
(AI textures trip it more than camera photos). On a 1343-image set all
20 raw detections were false, several on Gemini/OpenAI/Doubao output that
cannot carry Adobe's watermark, with random-bytes secrets.

A genuine TrustMark is a durable soft binding that survives re-encoding,
so detect_trustmark now re-decodes after a mild JPEG round-trip and
requires the same schema both times. Every observed false positive
collapsed under this gate; the second decode runs only on the rare hit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(identify): Samsung Galaxy AI, FLUX, ByteDance C2PA; fix C2PA substring FP

Detection extensions verified on real signed files (2026-05-29):

- Samsung Galaxy AI: signer attribution via a new _SIGNER_C2PA_PLATFORM
  (Samsung Galaxy / ASUS Gallery) kept separate from the capture-camera
  _DEVICE_C2PA_PLATFORM so a Galaxy AI edit (device cert + AI source type)
  does not trip the camera-vs-AI integrity clash. Plus metadata.samsung_genai:
  the proprietary genAIType marker in PhotoEditor_Re_Edit_Data, a medium-
  confidence AI-editing signal (samsung_only branch).
- Black Forest Labs (FLUX) and ByteDance Volcano Engine (Doubao/Jimeng)
  added as C2PA issuers + issuer->platform mappings.
- fix: C2PA presence required only the bare 4-byte 'c2pa' substring, which
  false-positives on compressed pixel data (a recompressed PNG IDAT re-flagged
  C2PA after its manifest was correctly stripped). New c2pa_marker_in() requires
  the JUMBF wrapper (jumb+c2pa) or the C2PA uuid box; applied in identify +
  metadata. Verified: all 535 real C2PA files carry jumb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(doubao): gate detection on text structure to cut ~95% of false positives (#23)

Coverage alone over-fired: any textured bottom-right corner cleared the
threshold, so the detector false-positived on ~28% of arbitrary images.
The real '豆包AI生成' mark is six glyphs in one row, so detect now also
requires the text-structure signature (_glyph_structure): many connected
components, no single dominant blob, concentration in a thin horizontal
band. False positives dropped 343 -> 17 across the corpus while keeping
real-mark recall and the doubao-1.png sample. Also accept a no-op force
kwarg for remover-interface symmetry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(samsung): add Samsung Galaxy AI visible-badge remover

New samsung_engine.py removes the bottom-left sparkle + localized
'AI-generated content' badge that Galaxy AI tools stamp. Mirrors the
Doubao locate->mask->inpaint pattern but bottom-left, with a dual-polarity
top-hat mask (the badge is light-on-dark or dark-on-light). Detection gates
on a band + left-anchor signature (the Doubao CJK-component gate does not
transfer: Latin badge letters connect into few blobs). Explicit-only --
tuned on few real badges with a ~4% FP floor, so it is not used in auto.
Synthetic byte-blob fixtures (real badges are user content, not shipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(visible): unified known-watermark registry + LaMa inpaint backend

watermark_registry.py is a single catalog of known visible marks, each
tying {usual location, in_auto flag, recovery strategy, detect adapter,
remove adapter}: gemini (reverse-alpha, exact), doubao, samsung. cmd_visible
is now registry-driven (best_auto_mark for --mark auto; mark_keys() feeds the
CLI choices) -- the per-mark _run_doubao/_run_samsung helper branches are gone.

Cross-engine confidences are not comparable, so the gemini adapter applies the
corpus-validated 0.5 sparkle threshold for auto arbitration (its engine flag is
loose and weakly fired ~0.36 on Doubao text, hijacking auto).

--backend auto|cv2|lama chooses background reconstruction for the mask-based
marks; auto = LaMa when onnxruntime is present, else cv2. For LaMa the mask is
the FILLED glyph bounding box (sparse glyph masks leave anti-aliased edges
behind). cv2 stays the zero-dependency fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: watermark registry, Samsung/FLUX/ByteDance detection, LaMa backend, trustmark gate

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(doubao): exact reverse-alpha removal from captured alpha map

The Doubao '豆包AI生成' mark is a fixed semi-transparent white overlay, so
given its alpha map the original pixels are recovered exactly:
original = (wm - a*logo)/(1-a) -- no inpaint hallucination.

The alpha map + logo colour were solved from real black+gray Doubao captures
on a controlled background: on black captured = a*logo, and the black/gray pair
solves a per-pixel without assuming the logo colour (a_max~0.65, logo near-white);
the white capture cross-validates (mark vanishes to a flat fill). Bundled as
assets/doubao_alpha.png + geometry constants.

remove_watermark_reverse_alpha applies it scaled to image width; exact at the
captured width, so the registry routes doubao through it only when
reverse_alpha_available (width within the calibrated band) and the mark is
detected, falling back to mask inpaint (cv2/LaMa) otherwise. A light residual
inpaint cleans the sub-pixel rescaling error. Add captures at more resolutions
to widen exact coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(visible): reverse-alpha only -- drop inpaint removal + heuristic detection

Per the principle that we only remove/detect what we can do exactly, the
visible-mark path is now reverse-alpha only:

- Doubao detect is reverse-alpha-consistent: match the bundled alpha glyph
  silhouette against the corner via TM_CCOEFF_NORMED (DETECT_NCC_THRESHOLD 0.4)
  -- keys on the '豆包AI生成' SHAPE, not coverage/structure heuristics. FP
  7/1243 (0.6%). Removes the cv2 inpaint path + the _glyph_structure gate.
- Registry is reverse-alpha only: dropped the cv2/LaMa backend (_glyph_remove,
  _lama_box_inpaint, default_backend, --backend) and the Samsung entry. Doubao
  outside the alpha resolution band is skipped, never inpainted.
- Removed samsung_engine.py + tests + --mark samsung (no alpha map captured;
  Samsung C2PA/genAIType metadata detection in identify is unaffected).
- The universal erase --region (cv2/LaMa) is unchanged -- arbitrary-region
  inpainting stays a user-directed tool, separate from the known-mark registry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(doubao): NCC sub-pixel alignment -> reverse-alpha at any resolution

A pure width-scale of the captured alpha map is only sub-pixel-accurate at the
captured width and leaves a faint ghost elsewhere. remove_watermark_reverse_alpha
now registers the alpha glyph to the actual mark via a TM_CCOEFF_NORMED
scale+position search (_aligned_alpha_map) before inverting the blend, so the
single 2048 capture works at any resolution -- verified clean on the 1773x2364
(3:4) corpus size, the biggest coverage gap (23 files).

reverse_alpha_available is now just 'asset present' (no width band); the registry
still gates removal on detect so a clean corner is never touched. Drops the
_ALPHA_WIDTH_TOLERANCE gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(doubao): keep native recovery exact -- fixed geometry at captured width

Integer-pixel NCC alignment landed ~1px off at the captured width, degrading the
otherwise-exact native reverse-alpha (synthetic recovery error 0.94 -> 1.39).
remove_watermark_reverse_alpha now uses exact width-relative geometry within
_ALPHA_NATIVE_BAND of the captured width and the NCC search only off it -- best
of both: native back to 0.94, other resolutions still aligned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(doubao): harden alignment -- try fixed+aligned, keep least residual (56/56)

On a faint/busy-background mark the NCC alignment peak can wander a few px off
the true mark and leave a residual (2/56 real corpus files). Off the captured
width, remove_watermark_reverse_alpha now builds BOTH the fixed-geometry and the
NCC-aligned alpha map, applies each, and keeps whichever leaves the least
residual mark (re-detect confidence on the bare reverse-alpha) -- geometry wins
on faint marks, alignment on clear ones, no magic threshold. Real-file round-trip
now removes 56/56 detected Doubao clean across every corpus resolution (was 54).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(doubao): skip residual inpaint at native width for exact recovery

At the captured width the fixed-geometry reverse-alpha is pixel-exact, so
inpainting over it only replaced exactly-recovered interior pixels with a
cv2 hallucination -- measured worse on a textured background (native error
vs true bg 1.6 reverse-alpha-only vs 2.6 with the old always-on
full-footprint inpaint). Native now returns the bare recovery untouched;
off-native, where NCC alignment is only sub-pixel-approximate, the footprint
inpaint stays to clean the seam. Real round-trip still 56/56 across all
corpus resolutions; negatives 0/60, Gemini unaffected.

Add test_native_returns_exact_reverse_alpha_no_inpaint as the regression
guard. Sync CLAUDE.md + README (the table cell and prose described the
pre-NCC "skipped off native / cv2-LaMa" behavior, now stale). Gitignore the
session scheduled_tasks.lock, and add the text-protection research note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:49:09 -07:00
Victor KuznetsovandClaude Opus 4.8 ef6fdaeeec Detect text at native resolution (capped), fixing small-text recall on large images (#27)
The text-protection detector scaled every image to a fixed 736 px long side, so
small text on large canvases (e.g. ~16 px on 2048) was downscaled below the
detector and missed -> deformed by the SDXL pass (issue #14). Detect at the
native long side capped at 1536, never upscaled (_detection_input_size, a pure
unit-tested helper). Detection is script-agnostic (DB segments regions, not
characters), so this is language-agnostic: a new benchmark
(scripts/text_detection_benchmark.py) measures recall across Latin/Cyrillic/CJK/
Hangul/Arabic/digits x sizes x canvas -> overall hit-rate 0.91 -> 1.00, worst
cell (2048/16 px) 0.06 -> 1.00. Docs updated.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:28:30 -07:00
0c7ff1874e feat(device): support xpu backend (#24)
* feat(device): support xpu backend

* Fall back to CPU seed generator when device RNG unsupported (xpu)

Some torch-xpu builds have no device-side RNG, so torch.Generator(device="xpu")
raises when --seed is used. _make_seed_generator tries the device generator and
falls back to a backend-agnostic CPU generator. Adds a fallback unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Victor Kuznetsov <kuznetsov.va@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:13:23 -07:00
Victor KuznetsovandClaude Opus 4.8 1598c499fe Record Doubao reverse-alpha finding: needs black-background capture, not more content images (#26)
Session 2026-05-29: content-image reverse-alpha distillation fails (persistent
ghost) because the mark is never observed on a dark background (median darkest
bg over glyph pixels 58/255), so alpha is unidentifiable; no dark halo (white-
logo model is right); LaMa O is a hallucination. Gemini is clean only because
its map is the watermark on pure black (alpha=capture/255). Real unlock = black
Doubao captures (requested in #13). Shipped direction stays mask + cv2/lama.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:12:58 -07:00
Victor KuznetsovandClaude Opus 4.8 a46268f6eb Add cross-platform CI test matrix + PyPI classifiers (#25)
* Add cross-platform CI test matrix, PyPI classifiers

CI: new test.yml runs lint (ubuntu) + a test matrix (ubuntu/macos/windows
x py3.10/3.12, core+dev, GPU tests skip) on push to main and PRs, closing the
gap where only the release publish.yml ran (ubuntu, no tests). Add PyPI
classifiers (OS/Python/topic). README Tests badge, CLAUDE.md CI note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Make availability tests reflect installed deps, not assume gpu extra

The new core+dev CI matrix has no diffusers, so the invisible-engine
availability tests (asserting is_available() is True unconditionally) and the
two mocked invisible CLI tests (whose command gates on is_available before the
mock) failed. Assert availability == actual importability of torch+diffusers,
and patch the CLI availability gate so the mocked-engine tests run regardless of
the gpu extra.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:04:12 -07:00
Victor KuznetsovandClaude Opus 4.7 96b3653b9e chore(release): v0.6.12
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:54:44 -07:00
Victor KuznetsovandClaude Opus 4.7 9aaa53fe32 fix(metadata): preserve upload format and quality on strip
remove_ai_metadata now writes JPEG at quality 95 with 4:4:4 (no chroma
subsampling) instead of the lossy PIL defaults (q75, 4:2:0), and preserves
WebP losslessly instead of silently rewriting it as PNG.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:46:26 -07:00
Victor KuznetsovandClaude Opus 4.7 41e4365cd4 fix(identify): explain the unknown verdict inline (#22)
A bare "unknown" verdict reads as the tool being broken. Print a one-line note
right under the verdict explaining that no locally-readable AI signal was found,
that this is not the same as clean (metadata is often stripped), and that
SynthID-class pixel watermarks have no local detector. The why was previously
only in the caveats section below.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:16:14 -07:00
Victor KuznetsovandClaude Opus 4.7 888c8c2556 chore(types): clear strict-pyright debt across src (0 errors)
Make `pyright src/` strict-clean via a hybrid: pure-logic files are fully typed
(piexif gets a local typings/ stub; PIL info-dict loops guard isinstance(key, str);
progress returns Callable[..., None]; availability checks use importlib.util.find_spec
instead of unused imports), while the irreducibly-untyped cv2/torch/diffusers boundary
files carry a documented per-file `# pyright:` relax pragma (or a ctrlregen
executionEnvironment) that disables only the unknown-type rules. Public ndarray-returning
signatures on the relaxed engines are annotated NDArray[Any] so strict consumers (cli.py)
stay clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:00:15 -07:00
Victor KuznetsovandClaude Opus 4.7 f326bab189 chore(release): v0.6.11
Ships the China TC260 AIGC PNG-chunk and HuggingFace hf-job-id provenance
detectors (223cbcf). Also syncs src/__init__.__version__, which had drifted to
0.6.9 (not bumped in the 0.6.10 release).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:43:52 -07:00
Victor KuznetsovandClaude Opus 4.7 223cbcf171 feat(metadata): detect China TC260 AIGC PNG chunk and HuggingFace hf-job-id
aigc_label now reads the TC260 label from a raw-JSON `AIGC` PNG tEXt chunk
(as Doubao/ByteDance write it, with no namespaced XMP marker) in addition to
the `<TC260:AIGC>` XMP block, via a shared _parse helper gated on a TC260 field
so a generic AIGC key cannot false-positive. New huggingface_job() reads the
hf-job-id PNG chunk; identify surfaces it as a medium-confidence hf_job signal
(parallel to the visible sparkle, never overriding a hard metadata verdict).
Both wired into has_ai_metadata/get_ai_metadata; the PNG save whitelist already
strips them on removal. Found by auditing 646 corpus originals: 28 AIGC and 3
hf-job files the library previously reported as Unknown.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:40:17 -07:00
Victor KuznetsovandClaude Opus 4.7 0eec3001bb feat(invisible): protect text automatically by default (#21)
Mirror protect_faces: protect_text defaults to True in invisible_engine and
watermark_remover, so the SDXL pipeline detects text per image and switches to
Differential Diffusion only when glyphs are found. Text-free inputs fall back to
plain img2img with no differential-pipeline load, so the autonomy is free. The
CLI now exposes a single off-switch --no-protect-text instead of the positive
flag, keeping the interface minimal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:24:09 -07:00
Victor KuznetsovandClaude Opus 4.7 a0bf62e601 feat(invisible): preserve text/CJK via Differential Diffusion (--protect-text) (v0.6.10)
SDXL img2img regenerates every pixel, so small text and CJK glyphs deform
at the strengths that defeat SynthID (issue #21). With --protect-text a
CJK-native PP-OCRv3 detector (2.4 MB ONNX, cv2.dnn, no torch, cached on
first use) locates text regions and the pass switches to the SDXL
Differential-Diffusion community pipeline: a per-pixel change map keeps
text regions largely intact while the background is regenerated to strip
the watermark. Gated to the SDXL default model; falls back to plain
img2img with a warning when unavailable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:59:15 -07:00
Victor KuznetsovandClaude Opus 4.7 7db4e231e8 fix(deps): require transformers 5.x with stable tokenizers for SDXL load
diffusers 0.38's auto-pipeline registry imports a transformers 5.x-only
symbol, so the gpu extra needs transformers>=5. Cap tokenizers to the
stable 0.22 line so the global prerelease="allow" no longer drags in the
0.23.0rc0 whose CLIP tokenizer breaks SDXL loading.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:58:53 -07:00
test-userandClaude Opus 4.7 27539c0da9 docs: tidy roadmap to open items only (drop shipped v0.6.7-0.6.9)
The Roadmap is the project TODO; shipped features (Integrity Clash, streaming-MP4
scan window, meta-box XMP blanking) no longer belong under "not yet implemented".
Removed them and kept the still-open remainder as its own item (AVIF/HEIF Exif
*item* inside the meta box). Net open TODO: SynthID v2 regression test, local
SynthID pixel detector, grow the SynthID corpus, real non-PNG C2PA fixtures,
pyright maintenance debt, meta-box Exif item, Canon/Samsung device signers,
Resemble PerTh (dead end), video pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:18:28 -07:00
test-userandClaude Opus 4.7 5bfed00553 feat(metadata): blank AI-label XMP inside the HEIF/AVIF meta box (v0.6.9)
HEIF/AVIF store XMP as a meta-box `mime` item whose bytes live in mdat/idat, out
of reach of the top-level uuid/jumb box stripper. An AI-label XMP packet there
(TC260 AIGC, IPTC "Made with AI", IPTC 2025.1) was therefore left in place.

isobmff.blank_ai_xmp_packets locates each XMP packet by its <?xpacket begin ...
end?> delimiters and, if it carries an AI marker (_AI_LABEL_MARKERS), overwrites
it with spaces of the SAME length. Equal length means no box size or iloc offset
shifts -- the coded image stays bit-for-bit intact, the item stays structurally
valid, only the AI label content is destroyed. Plain (non-AI) XMP is left alone,
mirroring the top-level XMP-uuid content match. Wired into remove_ai_metadata's
ISOBMFF branch after strip_c2pa_boxes.

Chosen over exiftool (a non-bundled binary dep) to stay pure-Python and
droplet-compatible; over full iinf/iloc surgery to avoid offset-rewrite
corruption risk. The AI labels we target are all XMP, so this closes the
practical gap. An Exif *item* inside the meta box (rare) still needs iinf/iloc
surgery or exiftool -- documented.

4 new tests (TestMetaBoxXmpBlanking): AI packet blanked (same length, marker
gone, surrounding image bytes intact), plain XMP preserved, no-packet no-op, and
end-to-end remove_ai_metadata on a .heic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:15:48 -07:00
test-userandClaude Opus 4.7 31f0a82906 feat(metadata): detect C2PA/AIGC/IPTC manifests after a large mdat in MP4 (v0.6.8)
Provenance detection no longer relies on a fixed first-MB read. In a streaming /
non-faststart MP4 the C2PA manifest sits AFTER a multi-megabyte mdat, beyond the
1 MB scan window, so it was missed.

- isobmff.scan_c2pa_region(path): a file-seeking top-level box walker that
  returns the payloads of uuid/jumb (provenance) boxes, seeking past mdat by
  size without reading it -- works on multi-GB files. Returns b"" for
  non-ISOBMFF or on read error. Mirrors the box-size encoding of the existing
  in-memory _iter_top_level_boxes (largesize / size==0).
- metadata.scan_head(path, size): the shared input for every C2PA/AIGC/IPTC
  byte scan -- first __TEXT	__DATA	__OBJC	others	dec	hex bytes plus, for ISOBMFF, the late provenance-box
  payloads. Behavior-neutral (f.read(size)) for non-ISOBMFF inputs.
- Routed all six metadata scan sites (has_ai_metadata, aigc_label,
  iptc_ai_system, synthid_source, exif_generator XMP, get_ai_metadata
  soft-binding) and identify's head read through scan_head.

6 new tests: late box found by scan_c2pa_region / scan_head, the fixed window
provably misses it, non-ISOBMFF -> b"", front-placed (faststart) regression.

The remaining gap stays documented: EXIF/XMP stored as items inside the meta
box (AVIF/HEIF stills) still needs meta-box surgery or exiftool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:42:29 -07:00
test-userandClaude Opus 4.7 18160fe269 feat(identify): integrity-clash detection for contradictory provenance (v0.6.7)
Surface contradictions between independent provenance signals instead of
collapsing to a single verdict -- a strong tell of spoofed, transplanted, or
laundered metadata. Inspired by arXiv:2603.02378.

Two rules in the new _integrity_clashes helper:
- Conflicting AI-origin attributions: two or more distinct AI vendors named by
  independent generator stamps (e.g. a C2PA OpenAI manifest on an image whose
  EXIF says Make="Ideogram AI").
- Camera + AI: a camera-capture C2PA device (Pixel/Leica/Sony/Nikon/Truepic)
  coexisting with an AI-generation marker -- a genuine capture is not AI.

High-precision by design: only hard generator stamps feed it (C2PA issuer when
the source is AI, SynthID proxy, 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. Vendor
normalization (_vendor_of over _AI_VENDOR_TOKENS) keeps consistent signals from
clashing (C2PA "Google (Gemini)" + SynthID-Google agree); the C2PA vendor is
read from the issuer attribution, not the resolved platform, so a camera label
like "Google Pixel" cannot mis-normalize to an AI vendor.

Surfaced as ProvenanceReport.integrity_clashes (red in the table view, included
in --json). 19 new tests; all real single-origin fixtures (chatgpt/firefly/
doubao/grok/mj) verified to produce zero clashes (false-positive guard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:27:25 -07:00
dependabot[bot] 6694a79514 chore(deps): bump ultralytics in the minor-and-patch group (#20)
Bumps the minor-and-patch group with 1 update: [ultralytics](https://github.com/ultralytics/ultralytics).


Updates `ultralytics` from 8.4.55 to 8.4.56
- [Release notes](https://github.com/ultralytics/ultralytics/releases)
- [Commits](https://github.com/ultralytics/ultralytics/compare/v8.4.55...v8.4.56)

---
updated-dependencies:
- dependency-name: ultralytics
  dependency-version: 8.4.56
  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-05-27 12:04:47 -07:00
test-userandClaude Opus 4.7 7b47fa9f6a fix(io): Unicode-safe cv2 image IO + un-eat the [gpu] install hint (v0.6.6)
Two CLI/IO robustness bugs surfaced by issues #17 and #19.

#17 -- non-ASCII image paths (Chinese/Cyrillic/accented) failed on Windows:
cv2.imread/imwrite use the platform ANSI code-page API, so the decode came back
empty with a "can't open/read file" warning. New image_io.imread/imwrite route
through np.fromfile+cv2.imdecode / cv2.imencode+tofile (Unicode-safe, byte-
identical output, cv2.imread None-semantics preserved); all 8 cv2 read/write
call sites now go through it. Behavior-neutral on macOS/Linux (already accept
UTF-8 paths), so the fix is correct-by-construction for the Windows-only bug.

#19 (incidental) -- rich parsed the "[gpu]" in the GPU-extra install hint as a
style tag and dropped it, so the printed command was the un-installable
"pip install 'remove-ai-watermarks'". Escaped as \[gpu] at both call sites.

Tests: test_image_io.py (non-ASCII round-trip, alpha, missing/empty/garbage
semantics); test_cli.py::TestGpuHintMarkup (install hint keeps the extra).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:52:48 -07:00
test-userandClaude Opus 4.7 d847b39292 docs: lawful-use disclaimer + primary-source Legal-table corrections
README:
- surface a lawful-use / no-liability disclaimer near the top
- reword two feature bullets away from detection-evasion framing
  ("bypass AI image classifiers" -> neutral post-processing; drop the
  platform-targeting language from the "Made with AI" bullet)
- Legal table, each corrected against the primary text:
  - CA AB 2655 was struck down on Section 230 ONLY (Kohls v. Bonta,
    E.D. Cal., Aug 2025); the court did not reach the First Amendment
    (the companion AB 2839 was separately enjoined on 1A grounds)
  - COPIED Act: add the bill number (S. 1396, 119th Cong.)
  - South Korea AI Framework Act: in force 22 January 2026 (exact date)

CLAUDE.md: sync the South Korea date to 22 January 2026.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:50:20 -07:00
test-userandClaude Opus 4.7 fee0e139af docs: refresh README roadmap + metadata-strip coverage for v0.6.x
- Metadata-strip feature now lists the audio/video container coverage shipped in
  v0.6.0-v0.6.4 (MP4/MOV/M4V/M4A via ISOBMFF box walker; WebM/MP3/WAV/FLAC/OGG
  losslessly via ffmpeg).
- Roadmap updated: the AVIF/HEIF item now reflects that top-level XMP/C2PA boxes
  and non-ISOBMFF audio/video are handled, with only meta-box-item EXIF/XMP left
  (needs exiftool). Added the open backlog: multi-signal "Integrity Clash"
  reporting (arXiv:2603.02378), Canon/Samsung device signers pending a real
  sample, the streaming-MP4 scan-window limit, and Resemble PerTh audio as
  evaluated-but-infeasible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:42:40 -07:00
test-userandClaude Opus 4.7 e1c99b5937 fix(identify): gate C2PA issuer->generator attribution on AI source type (v0.6.5)
Prevents an unmapped C2PA device whose manifest incidentally contains a mapped
issuer substring (e.g. the "Adobe XMP" toolkit string in a Canon/Sony camera
capture) from being mislabeled as that AI generator ("Adobe Firefly").
_attribute_platform now names a specific AI-generator platform only when the
digital-source-type is trainedAlgorithmicMedia; otherwise it degrades to the
neutral "C2PA signer: X" label. Real Firefly/OpenAI/Google output carries the
AI source-type and is unaffected (verified: chatgpt-1.png->OpenAI,
firefly-1.png->Adobe Firefly still attribute). Closes the only real downside of
leaving Canon/Samsung/Bria device signers unmapped: detection and removal were
already unaffected; now the platform label degrades gracefully too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:29:12 -07:00
test-userandClaude Opus 4.7 f9cf14c372 feat(metadata): strip container metadata from WebM/MP3/WAV/FLAC/OGG via ffmpeg (v0.6.4)
remove_ai_metadata now handles non-ISOBMFF audio/video (which the box walker
can't reach) by shelling out to ffmpeg with a lossless stream copy
(`-map_metadata -1 -map_chapters -1 -c copy`): codec data is untouched, only
container tags/chapters (ID3 / RIFF / Vorbis comments / EBML tags) are dropped.
Requires ffmpeg on PATH; raises a clear RuntimeError if absent or if ffmpeg
can't parse the input (instead of crashing in the image path).

Verified end-to-end: a real ffmpeg-made WAV/MP3 with a "Suno AI" title tag ->
tag gone, audio bytes preserved.

NOT built (evaluated, deliberate): Resemble PerTh audio *detection* --
`get_watermark()` returns a raw bit array with no presence/confidence flag, so
reliably telling watermarked from clean needs Resemble's fixed payload or a
confidence API (neither public; no real sample to calibrate). Same wall as the
SynthID pixel detector. AVIF/HEIF meta-box EXIF/XMP stripping also stays a gap
(needs exiftool, a non-installed binary). Both documented in CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:39:42 -07:00
test-userandClaude Opus 4.7 bc3228d387 feat(visible): Doubao text-mark removal + universal region eraser
Add deterministic, CPU-only removal of the visible Doubao "豆包AI生成" mark and
a position-agnostic region eraser for any other visible watermark/logo.

- doubao_engine.py: locate (geometry, scales with width) + polarity-aware
  white-top-hat glyph mask + cv2 inpaint; coverage-gated detection and a
  dense-text safety guard. No GPU, ~30ms.
- region_eraser.py + `erase` command: inpaint arbitrary --region box(es).
  Default cv2 backend (no deps); optional big-LaMa via onnxruntime (`lama`
  extra, Carve/LaMa-ONNX, model downloaded on first use, never bundled).
- cli `visible --mark auto|gemini|doubao`: auto routes by detector confidence.
- tests for both engines; seed previously-unseeded CLI image fixtures to stop
  the Doubao detector flaking on random corners.
- .gitignore: doubao_capture/{seeds,captures} scratch (alpha-map calibration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:31:51 -07:00
test-userandClaude Opus 4.7 9f93d9c0c5 feat(identify): add Sony C2PA device attribution, verified (v0.6.3)
Adds Sony to _DEVICE_C2PA_PLATFORM, matching Sony's own `sony.sig` / `sony.cert`
C2PA assertion namespace (NOT bare "Sony", which is a common EXIF Make). Verified
against a real Sony-signed file (Sony PXW-Z300, signer "Sony Corporation") found
in the Security4Media/c2pa-video-player repo. The sample is video (MP4) -- our
ISOBMFF C2PA path detects it; Sony Alpha stills likely share the namespace.

Verified device set is now Leica, Nikon, Google Pixel, Sony, Truepic. Canon /
Samsung / Bria still have no public direct-download C2PA sample to verify.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:13:49 -07:00
test-userandClaude Opus 4.7 64be9598f2 fix(identify): device-token-first C2PA attribution; add verified Pixel (v0.6.2)
Replaces the claim-generator-string match with a distinctive device-token scan
of the manifest bytes (_device_platform / _DEVICE_C2PA_PLATFORM), which is more
robust: it catches devices where the generator name lives under a non-standard
CBOR key (Pixel uses `claim_generator_info`, so it has no `claim_generator`).

- Adds Google Pixel, verified against a real Pixel 10 Pro C2PA file (attached to
  c2pa-rs issue #1609/#1554): cert CN "Pixel Camera", digitalSourceType
  `computationalCapture` -> capture authenticity, not AI (is_ai stays None).
- Token distinctiveness is load-bearing: bare "Truepic" matched the OpenAI
  chatgpt-1.png fixture (Truepic is a trust-chain signing authority), so the
  token is the specific "Truepic_Lens"; "Pixel Camera" (cert CN) not "Pixel".
- Verified Leica/Nikon/Truepic/Pixel attribute correctly and OpenAI/Adobe/MJ
  do not regress. Sony/Canon/Samsung/Bria stay unmapped: no public direct-
  download C2PA sample exists to verify their in-manifest string.
- Regression tests: device token beats incidental issuer mentions (Leica,
  Pixel-vs-Google).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:43:40 -07:00
test-userandClaude Opus 4.7 dda2ee7fbb fix(identify): attribute C2PA by claim_generator, not incidental issuer tokens (v0.6.1)
Verified on real signed files that the issuer byte-scan mis-attributes
multi-entity manifests: Leica read as "Truepic" (timestamp authority in the
chain), Nikon as "Adobe Firefly" (XMP-toolkit "Adobe" + the sample's
"Adobe_MAX" name), Truepic as "Google". Platform attribution now prefers the
claim generator (what produced the asset) and falls back to the issuer scan.

- New _CLAIM_GENERATOR_PLATFORM map + _platform_from_generator; claim generator
  read for non-PNG via the now-public c2pa.cbor_text_after.
- Device tokens listed only where verified against a real C2PA file (Leica
  lc_c2pa, Nikon, Truepic Lens); Pixel/Samsung/Sony/Canon/Bria deferred until a
  real sample confirms the in-manifest string. Camera C2PA marks capture
  authenticity, so these never set is_ai.
- cbor_text_after made public (was _cbor_text_after); call sites + tests updated.
- Regression test: claim_generator beats incidental Adobe/Google/Truepic tokens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:10:07 -07:00
test-userandClaude Opus 4.7 2676325184 feat(c2pa): expand soft-binding vendor map with registry-verified algs
Adds Trufo, Overlai, MarkAny, Mentaport, LumaTrace, VerdaAI, ContentLens, ISCC
(io.iscc content code), and Adobe ICN fingerprint to C2PA_SOFT_BINDINGS, and
notes AIWatermark wraps Meta PixelSeal. All `alg` prefixes verified against the
official c2pa-org/softbinding-algorithm-list registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:00:16 -07:00
test-userandClaude Opus 4.7 c196a16900 feat: detect soft-binding vendors, IPTC 2025.1, video/audio C2PA, TrustMark (v0.6.0)
Broadens metadata provenance coverage at the detection and container-strip level.

Detection:
- C2PA soft-binding `alg` -> forensic-watermark vendor (Adobe TrustMark,
  Digimarc, Imatag, Steg.AI, Microsoft, ...) via C2PA_SOFT_BINDINGS +
  soft_binding_vendors_in(); names the watermark vendor even when the watermark
  itself can't be decoded.
- IPTC Photo Metadata 2025.1 AI-disclosure XMP fields (AISystemUsed etc.) via
  iptc_ai_system() + IPTC_AI_FIELD_MARKERS.
- Adobe TrustMark open keyless decoder (trustmark_detector.py, optional extra
  `trustmark`) -- the watermark behind Adobe Durable Content Credentials.
  Detects provenance, not AI origin, so it does not assert is_ai.

Removal / containers:
- isobmff.strip_c2pa_boxes now also drops a top-level XMP uuid box that carries
  an AI label (matched by AI-marker content, byte-order-robust; plain XMP kept).
- remove_ai_metadata routes MP4/MOV/M4V/M4A (and any ftyp-sniffed ISOBMFF)
  through the box stripper; raises a clear error for non-ISOBMFF audio/video
  (WebM/MP3/WAV) instead of crashing in the image path.

Tests: soft-binding scan, IPTC element/attribute/presence, MP4 + M4A detect/
strip, ISOBMFF XMP surgical strip, content-sniff, unsupported-container guard,
TrustMark absent-safety + identify integration. ruff clean; pyright clean on
all new modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:56:48 -07:00
49 changed files with 7073 additions and 1187 deletions

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
name: Tests
on:
push:
branches: [main]
pull_request:
concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v7
- name: Sync dev environment
run: uv sync --frozen --extra dev
- name: Ruff lint
run: uv run ruff check
- name: Ruff format check
run: uv run ruff format --check
test:
name: test ${{ matrix.os }} py${{ matrix.python-version }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# Cross-OS x Python-floor/recent. The default-install surface only
# needs the core deps plus dev tooling (no GPU extra): the model-running
# paths are availability-gated and skip when torch/diffusers are absent,
# so this matrix exercises metadata, identify, visible, and the cv2
# eraser on every OS without pulling the heavy ML stack.
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.10", "3.12"]
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v7
with:
python-version: ${{ matrix.python-version }}
- name: Sync dev environment
run: uv sync --frozen --extra dev
- name: Run tests
run: uv run pytest -q
+8
View File
@@ -34,3 +34,11 @@ yolov8n.pt
# Claude Code local settings
.claude/settings.local.json
.claude/scheduled_tasks.lock
# Doubao watermark calibration (local only; ship only the derived alpha-map asset).
# Synthetic seeds + raw Doubao captures are regenerable and not committed.
# Non-ours reference artifacts go in any _refs/ dir (already ignored above): usable
# locally for bootstrap/validation, never redistributed in the repo.
data/doubao_capture/seeds/
data/doubao_capture/captures/
+38 -11
View File
@@ -5,52 +5,79 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r
## How to run
- `uv run remove-ai-watermarks all <image.png> -o <output.png>`
- `uv run remove-ai-watermarks visible <image.png> -o <out.png>` — known-visible-mark removal, CPU, no GPU. **Reverse-alpha only**: every mark is removed by inverting its captured alpha map (exact pixel recovery, no inpaint). `--mark auto` (default) picks the strongest detected of the Gemini sparkle and the Doubao "豆包AI生成" text strip; `--mark gemini` / `--mark doubao` force one. For arbitrary logos/objects use `erase`.
- `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.
- `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
## 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`. `publish.yml` stays release-only.
- `bash maintain.sh` — uv-outdated, uv-secure, ruff check/fix, ruff format, pyright, pytest -n auto
- `maintain.sh` may not finish fully green (pre-existing, not per-change): strict pyright carries debt in `remove_ai_metadata` / `cli.py` (untyped piexif/PIL/click/rich). (`uv-secure` is clean since idna was bumped 3.11 -> 3.16, fixing GHSA-65pc-fj4g-8rjx.) To gate a change, run `uv run ruff check`, `uv run pyright <changed files>`, `uv run pytest` directly.
- **Strict pyright is clean across `src/` (0 errors).** The cv2/torch/diffusers boundary files (`gemini_engine`, `region_eraser`, `doubao_engine`, `face_protector`, `humanizer`, `invisible_engine`, `noai/watermark_remover`, and the whole `noai/ctrlregen/` subpackage) carry a documented per-file `# pyright:` relax pragma (or, for `ctrlregen`, a `tool.pyright.executionEnvironments` entry) 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.)
- **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.
- 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 and `noai/__init__` (eager pipeline import) then fails. `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).
- 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.
## Configuration
- GPU/ML modules (invisible_engine, ctrlregen, watermark_remover) are optional — guard imports with `is_available()` checks
- Optional detection extras: `detect` (imwatermark — open SD/SDXL/FLUX watermark) and `trustmark` (Adobe TrustMark decoder; pulls torch + downloads weights). Both are guarded by `is_available()` and skipped by `identify` when absent.
- Tests for the *model-running* paths are limited to availability checks (multi-GB downloads). But the **pure helpers inside ML-adjacent modules are unit-tested without any download** and must stay that way: `_target_size` (native-vs-downscale, `test_invisible_engine.py`), the MPS->CPU fallback control flow via mocked pipelines (`test_img2img_runner.py`, 100% cover), and the tiling math `tile_positions`/`make_blend_weight`/`resize_center_crop` (`test_tiling.py`; `pytest.importorskip("torch")` since `tiling.py` imports torch at module top). Don't skip these as "ML, needs a model" — only `run_tiled`/`remove_watermark`/the diffusion bodies do.
## Key modules
- `noai/c2pa.py` — PNG chunk parser; use `extract_c2pa_chunk(path)` to get raw caBX payload, `has_c2pa_metadata(path)` to detect. Do not reimplement chunk parsing. `extract_c2pa_info(path)` sets `synthid_watermark`/`synthid_vendors` when the manifest is signed by a SynthID-using vendor.
- `noai/constants.py` — PNG_SIGNATURE, C2PA_CHUNK_TYPE, C2PA_SIGNATURES, C2PA_ISSUERS, and `SYNTHID_C2PA_ISSUERS` (issuers that pair SynthID with C2PA: Google, OpenAI). Add a new issuer here, not inline.
- `metadata.py` — `synthid_source(path)` returns the vendor name(s) if the C2PA manifest implies a SynthID pixel watermark, else None. Format-agnostic: PNG via the caBX parser, JPEG/WebP/AVIF/HEIF/JXL via a binary scan (C2PA marker + SynthID issuer + AI-source marker). `get_ai_metadata` surfaces the verdict, and `metadata --check` prints it as a callout. Both `get_ai_metadata` and `has_ai_metadata` guard the PIL open with `except Exception` (HEIC/unknown formats raise non-OSError) and fall through to the binary scan. `xai_signature(path)` detects xAI/Grok's EXIF-only scheme (`ImageDescription` = `Signature: <base64>` + UUID `Artist`); it feeds `has_ai_metadata`, `get_ai_metadata` (key `xai_signature`), and `identify`.
- `identify.py` — `identify(path)` aggregates every locally-readable signal (C2PA issuer→platform, IPTC "Made with AI", embedded SD/ComfyUI params, SynthID proxy, xAI/Grok EXIF signature via `metadata.xai_signature`, visible Gemini sparkle) into one `ProvenanceReport`. `is_ai_generated` is True or None (never asserted False — stripped metadata is not proof of clean origin). Visible-sparkle is promoted only at confidence ≥ `_SPARKLE_THRESHOLD` (0.5; corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49). The cv2 dependency lives in `gemini_engine.detect_sparkle_confidence`, not here. Add platform mappings to `_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. **Ideogram tags its output with EXIF `Make="Ideogram AI"`** (verified on a real download 2026-05-24) — that's why `Make` is read.
- `noai/c2pa.py` — PNG chunk parser; use `extract_c2pa_chunk(path)` to get raw caBX payload, `has_c2pa_metadata(path)` to detect. Do not reimplement chunk parsing. `extract_c2pa_info(path)` sets `synthid_watermark`/`synthid_vendors` when the manifest is signed by a SynthID-using vendor, and `soft_binding`/`soft_binding_vendors` when a `c2pa.soft-binding` `alg` names a forensic-watermark vendor (`soft_binding_vendors_in(buffer)` is the shared byte-scan, used by both the PNG parser and the non-PNG binary path).
- `noai/constants.py` — PNG_SIGNATURE, C2PA_CHUNK_TYPE, C2PA_SIGNATURES, C2PA_ISSUERS, `SYNTHID_C2PA_ISSUERS` (issuers that pair SynthID with C2PA: Google, OpenAI), and `C2PA_SOFT_BINDINGS` (soft-binding `alg` prefix → forensic-watermark vendor: Adobe TrustMark, Digimarc, Imatag, Steg.AI, Microsoft, ...). Add a new issuer/binding here, not inline.
- `metadata.py` — `scan_head(path, size=1MB)` is the shared input for every C2PA/AIGC/IPTC byte scan: first `size` bytes plus the payloads of any provenance metadata found beyond that window — for ISOBMFF, the late provenance boxes from `isobmff.scan_c2pa_region` (catches a manifest after a large `mdat`); for **PNG**, the late `tEXt`/`iTXt`/`zTXt`/`eXIf`/`iCCP` chunks from `_png_late_metadata` (catches an XMP/EXIF packet appended after a large `IDAT`, e.g. a TC260 AIGC label at ~2.7 MB). Behavior-neutral (`f.read(size)`) for non-ISOBMFF inputs and for any file that fits within `size`. Use it instead of `open().read(1MB)` for any new marker scan. `synthid_source(path)` returns the vendor name(s) if the C2PA manifest implies a SynthID pixel watermark, else None. Format-agnostic: PNG via the caBX parser, JPEG/WebP/AVIF/HEIF/JXL via a binary scan (C2PA marker + SynthID issuer + AI-source marker). `get_ai_metadata` surfaces the verdict, and `metadata --check` prints it as a callout. Both `get_ai_metadata` and `has_ai_metadata` guard the PIL open with `except Exception` (HEIC/unknown formats raise non-OSError) and fall through to the binary scan. `xai_signature(path)` detects xAI/Grok's EXIF-only scheme (`ImageDescription` = `Signature: <base64>` + UUID `Artist`); it feeds `has_ai_metadata`, `get_ai_metadata` (key `xai_signature`), and `identify`. `iptc_ai_system(path)` detects the IPTC Photo Metadata 2025.1 AI-disclosure XMP properties (`IPTC_AI_FIELD_MARKERS` = `AISystemUsed`/`AISystemVersionUsed`/`AIPromptInformation`/`AIPromptWriterName`) and returns the `AISystemUsed` generator name (or `"fields present"`). `remove_ai_metadata` routes **ISOBMFF video** (`.mp4`/`.mov`/`.m4v`) through the same `isobmff.strip_c2pa_boxes` as AVIF/HEIF (MP4 is ISOBMFF), and `_scrub_ai_exif` removes the xAI signature + AI-generator EXIF tags on JPEG output.
- `identify.py` — `identify(path)` aggregates every locally-readable signal (C2PA issuer→platform, C2PA soft-binding forensic-watermark vendor, 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`, visible Gemini sparkle, 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-sparkle, 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) but is excluded from the high-confidence `ai_from_metadata` set, so none overrides a hard metadata signal. Visible-sparkle is promoted only at confidence ≥ `_SPARKLE_THRESHOLD` (0.5; corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49). The cv2 dependency lives in `gemini_engine.detect_sparkle_confidence`, not here. **C2PA platform attribution is device-token-first, issuer-scan fallback** (`_device_platform` scans manifest bytes for `_DEVICE_C2PA_PLATFORM` tokens, then `_attribute_platform`/`_ISSUER_PLATFORM`). **Why, verified on real signed files 2026-05-26:** the old issuer-only byte-scan matched ANY issuer substring anywhere, so multi-entity manifests mis-attributed -- Leica→"Truepic" (a signing authority in the trust chain), Nikon→"Adobe Firefly" (XMP-toolkit "Adobe" + the sample's "Adobe_MAX" name), Pixel→"Google (Gemini)" ("Google LLC" cert org), Truepic→"Google". A distinctive device token wins instead. **Token distinctiveness is load-bearing:** bare `b"Truepic"` mis-fires (it appears in unrelated trust chains -- it mis-attributed the OpenAI `chatgpt-1.png` fixture), so the token is the specific `b"Truepic_Lens"` from the Lens SDK claim generator; likewise `b"Pixel Camera"` (cert CN) not bare `b"Pixel"`. `_DEVICE_C2PA_PLATFORM` lists ONLY tokens **verified against a real C2PA file**: Leica (`lc_c2pa`/`Leica Camera`), Nikon (`NIKON`), Pixel (`Pixel Camera` -- from a real Pixel 10 Pro file attached to c2pa-rs issue #1609/#1554), Sony (`sony.sig`/`sony.cert` -- Sony's own C2PA assertion namespace, verified on a real Sony PXW-Z300 file; NOT bare "Sony" which is a common EXIF Make), Truepic (`Truepic_Lens`). Canon/Bria have **no public direct-download C2PA sample** (checked exhaustively: GitHub issue/PR attachments, contentcredentials gallery, HF datasets -- all upload-to-verify or token-gated; Canon's only public file was a self-signed hobbyist CR3, not factory), so they stay unmapped until a real file is captured (same fixture discipline as Grok/Doubao). The Sony sample is video (MP4) -- our ISOBMFF C2PA path detects it; Sony Alpha stills likely share the `sony.*` namespace but are not separately verified. **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). **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 Line truncated
- `watermark_registry.py` — **single catalog of known visible watermarks**, the unified "find known marks in their usual places, recognize, remove" entry. **Reverse-alpha only 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)`, exact recovery) — no inpaint/heuristic removal here (arbitrary-region inpainting 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) and `doubao` (bottom-right "豆包AI生成"). `detect_marks` scans all; `best_auto_mark` picks the highest-confidence detection. **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`. `cli.cmd_visible` is registry-driven: `--mark auto` → `best_auto_mark`, `--mark <key>` → that mark; `--mark` choices come from `mark_keys()`. `_doubao_remove` applies reverse-alpha only when the mark is detected AND `reverse_alpha_available` (resolution in the alpha band); outside that, removal is **skipped** (not inpainted). Add a new visible mark = one `KnownMark` entry + its engine (with a captured alpha map); do not re-add per-mark `if` branches in the CLI. **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.
- `gemini_engine.py` — visible Gemini-sparkle remover/detector (cv2/numpy, no GPU). `detect_sparkle_confidence(path)` is the file-level entry point used by `identify.py`.
- `doubao_engine.py` — visible Doubao "豆包AI生成" remover/detector (cv2/numpy, no GPU), **reverse-alpha only**. `DoubaoEngine.locate` anchors a bottom-right box by **geometry** (mark scales with image WIDTH), `extract_mask` pulls the light low-saturation glyphs (the detection candidate). `detect` is **reverse-alpha-consistent**: it matches the bundled alpha glyph silhouette (`assets/doubao_alpha.png`, the exact shape we invert) 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/structure heuristics) fixed #23: corpus FP fell to 7/1243 (0.6%); old coverage-only fired on ~28%. **Removal is exact reverse-alpha** (`remove_watermark_reverse_alpha`): `original = (wm - a*logo)/(1-a)` from the bundled alpha map + `_ALPHA_LOGO_BGR` (near-white ~253) + `_ALPHA_*_FRAC` geometry. The alpha map + logo were **solved from real black+gray Doubao captures** (`data/doubao_capture/captures/`, gitignored): on black `captured = a*logo`, the black/gray pair solves `a` per-pixel without assuming the logo colour (white capture cross-validates: mark → flat fill). The single captured alpha map (at width 2048) **generalizes to any resolution**: at (near) the captured width (`_ALPHA_NATIVE_BAND` of `_ALPHA_NATIVE_WIDTH`) `_fixed_alpha_map` places it by exact width-relative geometry (pixel-exact recovery, ~0.9 mean error — the whole point of reverse-alpha); off that width it **tries BOTH placements -- fixed geometry AND `_aligned_alpha_map`'s `TM_CCOEFF_NORMED` scale+position search (`_ALPHA_ALIGN_SEARCH`) -- and keeps whichever leaves the least residual mark** (re-`detect` confidence on the bare reverse-alpha). On a faint/busy-background mark the NCC peak wanders a few px and geometry wins; on a clear mark alignment wins -- no magic threshold, it just picks the better removal. Verified **56/56 real detected-Doubao removed clean across all corpus resolutions** (2048 fixed 27/27, 1773 22/22, plus 1185/1187/1535/1672); a single fixed-vs-aligned choice left 2/56 busy-background residuals, try-both fixed them. `reverse_alpha_available` is just "asset present"; the registry still gates removal on `detect` so a clean corner is never touched. **Residual inpaint is off-native-only:** at the captured width the fixed-geometry recovery is exact, so it is returned untouched -- inpainting over exactly-recovered interior pixels only swaps them for a cv2 hallucination (measured worse, native textured-bg error vs true bg **1.6 reverse-alpha-only vs 2.6 with the old always-on full-footprint inpaint**; regression-guarded by `test_native_returns_exact_reverse_alpha_no_inpaint`). Off-native the NCC alignment is only sub-pixel-approximate, so the interior is no longer exact and a residual inpaint over the glyph footprint cleans the seam (costs nothing there and reliably clears the mark). The shipped third-party `_refs/zhengsuanfa_doubao_alpha_120x20.png` is NOT a usable alpha (≈0.85 everywhere → blacks out on inversion; wrong resolution/version), verified 2026-05-29. There is no inpaint-based removal here (removed 2026-05-29; arbitrary-region inpainting is `region_eraser`/`erase`).
- `region_eraser.py` — universal region eraser (`erase` CLI). `erase(image, boxes=|mask=, backend=)`: `boxes_to_mask` → `cv2.inpaint` (`cv2` backend, default, no deps) or big-LaMa via onnxruntime (`lama` backend, extra `lama`, `Carve/LaMa-ONNX` Apache-2.0 model downloaded on first use, never bundled). `erase_lama` crops a padded region around the mask, runs LaMa at its fixed 512² input, pastes only masked pixels back (untouched areas stay pixel-exact). Lazy `_get_lama_session` singleton; `lama_available()` guards the optional import. **LaMa-ONNX costs ~3.5-4 GB peak RAM and ~5-6 s/call on CPU** (FFC working set, not arena — `enable_cpu_mem_arena=False` does not help), so it does NOT fit a minimal droplet; the cv2 backend (tens of MB, ~30 ms) does. LaMa quality at low RAM = serverless/GPU, mirroring how raiw.cc offloads SDXL to fal.
- `invisible_watermark.py` — `detect_invisible_watermark(path)` decodes the OPEN DWT-DCT watermarks (public decoder, no key) embedded by Stable Diffusion / SDXL / FLUX via the `imwatermark` library. Known fixed patterns (verified against upstream source) live in `_BITS_48` (SDXL 48-bit, FLUX.2 48-bit) and `_SD1_STRING` ("StableDiffusionV1", SD 1.x/2.x). Optional dep (extra `detect`); returns None when absent. The `detect` extra pulls **torch** transitively (invisible-watermark declares torch a hard dep, and `WatermarkDecoder` eagerly imports `rivaGan` -> `torch` at import time), so detection needs torch present even though dwtDct runs CPU-only on cv2/numpy/pywavelets — no GPU and no separate `gpu` extra required. **Unlike SynthID this is locally detectable**, but the watermark is fragile (does not survive JPEG re-encode/resize — verified gone after JPEG q90), so it confirms origin only on pristine files. Add new known patterns here. The file carries a top-of-module pyright pragma because imwatermark/cv2 ship no type stubs.
- `trustmark_detector.py` — `detect_trustmark(path)` decodes the OPEN, keyless **Adobe TrustMark** watermark (the soft binding behind Adobe Durable Content Credentials, `alg` `com.adobe.trustmark.P`) via the optional `trustmark` package (extra `trustmark`; pulls torch, downloads model weights on first use). Mirrors `invisible_watermark.py` (lazy singleton, top-of-module pyright pragma, returns None when absent). It detects *provenance*, not AI origin as such (TrustMark also marks human-authored content), so `identify` lists it as a watermark without setting `is_ai_generated`. Other soft-binding vendors (Digimarc/Imatag/Steg.AI/...) have no public decoder — they are only *named* via the `C2PA_SOFT_BINDINGS` scan, not decoded. **False-positive gate (added 2026-05-29):** TrustMark's `wm_present` is a BCH error-correction validity flag that spuriously validates on a content-correlated fraction of un-watermarked images — AI-generated textures trip it far more than camera photos (verified 2026-05-29 on real files: it fires on Gemini/OpenAI/Doubao output that *cannot* carry Adobe's watermark, with a random-bytes decoded secret, while signal-free camera photos did not trip it). A genuine TrustMark is a *durable* soft binding engineered to survive re-encoding, so `detect_trustmark` re-decodes after a mild JPEG round-trip (`_survives_reencode`, `_REENCODE_QUALITY` 95) and requires the same schema both times; every observed false positive collapsed (none survived even q95), so the gate is the durability property the watermark guarantees. The second decode runs only on the rare initial hit, so the cost is negligible. Do NOT remove the gate to "catch more" — a lone TrustMark hit without it is almost always content noise.
- `text_protector.py` — text-region protection for the `invisible` SDXL img2img pass (issue #21: CJK/small text deforms at watermark-removal strengths). `is_available()` gates on `cv2.dnn.TextDetectionModel_DB`; `TextProtector.detect_text_boxes(bgr)` runs the **PP-OCRv3 DB** ONNX detector (~2.4 MB, Apache-2.0, opencv_zoo, returns rotated quad polygons) — downloaded+cached to `~/.cache/remove-ai-watermarks` on first use via atomic temp-rename, never bundled, **no torch (cv2.dnn only)**. **Detection is script-agnostic** (DB segments text *regions*, not characters), so Latin / Cyrillic / CJK / Hangul / Arabic / digits all detect identically — language was never the recall lever, **resolution was**. `_detection_input_size(h, w)` (pure, unit-tested) detects at the **native long side capped at `_DET_MAX_LONG_SIDE` (1536), never upscaled**: the old fixed 736 downscaled large canvases so small text fell below the detector and was missed (issue #14, e.g. ~16 px text on a 2048 image). `scripts/text_detection_benchmark.py` measures recall across scripts × sizes × canvas: the cap fix lifts overall hit-rate 0.91 → 1.00 (worst cell 2048/16 px: 0.06 → 1.00) at ~100 ms CPU. Very large canvases with tiny text may still need tiling (documented limit, not built). `build_change_map(boxes, h, w, preserve=0.9, feather=15)` paints a Differential-Diffusion change map. **Polarity (verified empirically):** white(1.0)=PRESERVE original pixels, black(0.0)=MAX change; map is black bg + `preserve` inside text polygons, Gaussian-feathered edges, clipped to [0,1]. `preserve` stays below a hard 1.0 freeze by default so text still scrubs lightly (SynthID survives cropping). **Default text protection is `watermark_remover._run_region_hires`, NOT the differential change map.** Differential Diffusion froze text in latent space (`preserve`<1.0), so the watermark survived *inside* text — violating the "remove SynthID everywhere" requirement; and the SDXL VAE's 8px latent cell softens sub-8px strokes regardless of `preserve` (architectural limit, confirmed by the DD authors — see `docs/text-protection-research.md`). `_run_region_hires` instead: (1) scrubs the whole image (plain img2img), (2) RE-scrubs each detected text block at HIGH resolution and feather-composites it back. `merge_text_regions(boxes,h,w)` groups boxes into local blocks; each crop is upscaled by an **integer** factor (`_REGION_HIRES_SCALE` 3, capped so a region stays under `_REGION_MAX_MEGAPIXELS` 1.3 to avoid OOM; skipped if it can't reach 2x — very large text areas then fall back to the global scrub, tiling is the future fix), img2img-scrubbed, downscaled, **phase-correlated back to the original crop to null the ~1-2px round-trip offset** (a sub-pixel shift garbles the composite even when text is crisp; integer scale alone did NOT fix it because the diffusion pipeline rounds dims to a multiple of 8), then `feather_paste`d. Every pixel is regenerated, so the watermark is removed everywhere AND small text stays crisp (high-res strokes span >1 latent cell). Validated on synthetic 18px multilingual text: text-region SSIM 0.28 (plain) → 0.48 (region-hires), visually garbled → readable across Latin/Cyrillic/CJK, residual shift ~0.5px. Gated to the SDXL `DEFAULT_MODEL_ID` + detector (`_can_protect_text`); no text → plain global scrub (text-free inputs pay only the cheap cv2 detection). CLI off-switch `--no-protect-text` on `invisible`/`all`. `merge_text_regions` + `feather_paste` are pure, unit-tested without a model (`tests/test_text_protector.py`). **MUST still be confirmed by the SynthID oracle** (openai.com/verify / Gemini app) that a region-rescrubbed text zone reads watermark-free before trusting it in prod. The legacy `_run_differential` / `build_change_map` / `_load_differential_pipeline` (community `pipeline_stable_diffusion_xl_differential_img2img`, `custom_revision="0.38.0"`) remain in the file but are no longer the default; the diff pipeline upcasts the VAE to fp32 internally, so do **not** add `upcast_vae()`/`enable_attention_slicing` there (NaN/black on fp16 MPS). `build_change_map` is still unit-tested.
- `face_protector.py` — YOLO detect + soft-blend pattern; mirror this for any "protect region during diffusion" features
- `image_io.py` — Unicode-safe cv2 IO (issue #17). `imread(path, flags=None)` / `imwrite(path, img)` wrap `np.fromfile`+`cv2.imdecode` / `cv2.imencode`+`tofile` so non-ASCII paths work on Windows -- bare `cv2.imread`/`cv2.imwrite` use the platform ANSI code-page API there and fail (empty decode + `can't open/read file`) on Chinese/Cyrillic/accented filenames. `imread` keeps `cv2.imread` semantics (defaults to `IMREAD_COLOR`, returns `None` on missing/empty/undecodable). **Every cv2 file read/write in the package routes through here; do not call `cv2.imread`/`cv2.imwrite` directly.** macOS/Linux already accept UTF-8 paths, so it is behavior-neutral there (the bug only reproduces on Windows). cv2/numpy are imported lazily inside the functions, so the module is cheap to import in a bare env.
### Doubao clean-reverse-alpha distillation (re-investigated 2026-05-29)
**RESOLVED 2026-05-29: black+gray Doubao captures were obtained and the exact reverse-alpha is built** (`doubao_engine.remove_watermark_reverse_alpha`, `assets/doubao_alpha.png`; see the `doubao_engine.py` bullet above). The captures (`data/doubao_capture/captures/`, gitignored) confirmed the alpha-composite model: on black `captured = a*logo`, the black/gray pair solves `a` per-pixel (`a_max≈0.65`, logo near-white), and the white capture cross-validates. A single 2048 capture suffices: sub-pixel NCC alignment (`_aligned_alpha_map`) registers the alpha glyph to the real mark, so it works at any resolution (verified on the 1773×2364 3:4 corpus size), not just 2048. The notes below (the failed content-image distillation) are retained as the record of why captures were necessary.
**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.**
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.
**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.
**Until black captures arrive, the shipped direction is precise canonical glyph mask + inpaint (cv2 default, lama optional), NOT reverse-alpha.** The consensus glyph silhouette across the aligned marks distills cleanly (proto: a tight "豆包AI生成" strip, width ≈ 0.156 × image-width) and is good both as an exact inpaint mask and as an NCC localiser -- the latter also fixes the #23 detector false-positives (match the real glyph shape, not any bright low-saturation corner). Do **not** retry content-image reverse-alpha: it is data-limited by physics (no dark-background observations), not by effort.
## Watermarking landscape (research 2026-05-24)
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).
- **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). **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). **ASUS Gallery** also signs edited photos as C2PA (`com.asus.gallery`) but with no AI source type — a signer, not an AI marker. **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).
- **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)"). **Format confirmed stable across n=3 genuine generations:** exactly three EXIF tags (`Artist`, `ExifOffset`, `ImageDescription`), `Signature:` prefix constant, base64 payload 300-1004 chars. Two capture facts: (a) the `Artist` UUID **equals the public image id** in the asset URL (`https://imagine-public.x.ai/imagine-public/images/<uuid>.jpg`), so it is NOT a private per-user secret — only the `Signature` blob is; (b) the Grok web-UI image is a re-encoded **WebP with no signature** — the EXIF survives only in the *original* JPEG (download button or that public tokenless URL), which is why screenshots / re-encodes are metadata-stripped. A real fixture `data/samples/grok-1.jpg` plus **synthetic** JPEG fixtures (fake UUID + fake `Signature:` blob) cover the detector; never add a real Grok image carrying private content (the repo is public). **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` json-decodes the (HTML-entity-encoded) block; container-agnostic raw-byte scan.
- **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 **three 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"`), **and** 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"`). Both generic forms (the PNG chunk and the bare `{"AIGC":...}` object) 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).
- **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.
- **C2PA 2.4 "Durable Content Credentials" (April 2026; verified against the spec) raise the bar for metadata stripping.** 2.4 defines soft bindings (an invisible watermark or a content fingerprint) plus a server-side manifest repository and a new `c2pa.repository-receipt` assertion. Per the spec: "if a C2PA manifest is removed from an asset, but a copy of that manifest remains in a provenance store elsewhere, the manifest and asset may be matched using available soft bindings." So our local `metadata --remove` deletes the *embedded* manifest, but a fingerprint/watermark soft binding can still re-link the image to its manifest in a repository server-side. Stripping the file is becoming necessary-but-not-sufficient against durable provenance. (Our parsers target the stable embedded-manifest format documented in C2PA 2.1 §11; that format is unchanged in 2.4 -- the new pieces are repository/soft-binding infra, not the on-file box layout, so no parser change is implied.) Spec: https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html
- **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 Jan 2026 (verified via Kim & Chang; 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). When extending the README table, verify each date/article against the statute/bill text before committing, not against search summaries.
- **C2PA 2.4 "Durable Content Credentials" (April 2026; verified against the spec) raise the bar for metadata stripping.** 2.4 defines soft bindings (an invisible watermark or a content fingerprint) plus a server-side manifest repository and a new `c2pa.repository-receipt` assertion. Per the spec: "if a C2PA manifest is removed from an asset, but a copy of that manifest remains in a provenance store elsewhere, the manifest and asset may be matched using available soft bindings." So our local `metadata --remove` deletes the *embedded* manifest, but a fingerprint/watermark soft binding can still re-link the image to its manifest in a repository server-side. Stripping the file is becoming necessary-but-not-sufficient against durable provenance. (Our parsers target the stable embedded-manifest format documented in C2PA 2.1 §11; that format is unchanged in 2.4 -- the new pieces are repository/soft-binding infra, not the on-file box layout, so no parser change is implied.) Spec: https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html We now READ the soft-binding `alg` (`C2PA_SOFT_BINDINGS` / `soft_binding_vendors_in`) to name the forensic-watermark vendor, and locally DECODE the one open scheme, Adobe TrustMark (`trustmark_detector`); the rest (Digimarc/Imatag/Steg.AI/...) stay name-only (proprietary decoders).
- **Built 2026-05-26 (this batch):** soft-binding `alg` vendor detection; IPTC Photo Metadata 2025.1 AI-disclosure fields (`AISystemUsed` etc.); **video C2PA metadata** detect + strip for MP4/MOV/M4V (free — `isobmff.py` is format-agnostic, MP4 is ISOBMFF); Adobe TrustMark open decoder. NOT done (out of cheap reach, per the feasibility review): visible video-logo removal (needs a video frame pipeline) and audio (SynthID/ElevenLabs/Resemble/Suno all oracle-only or unmarked). **Box detection window — now handled (v0.6.8):** detection no longer relies on a fixed first-MB read. `metadata.scan_head(path, size)` reads the first `size` bytes and, for ISOBMFF, appends the payloads of late provenance boxes found by `isobmff.scan_c2pa_region` (a file-seeking top-level box walker that skips past `mdat` by size without reading it), so a C2PA/AIGC/IPTC manifest placed AFTER a large `mdat` in a streaming/non-faststart MP4 is now caught. Every C2PA/marker byte scan (`has_ai_metadata`, `aigc_label`, `iptc_ai_system`, `synthid_source`, `exif_generator` XMP, `get_ai_metadata` soft-binding, and `identify`) goes through `scan_head`; it is behavior-neutral for non-ISOBMFF inputs (exactly `f.read(size)`). **Meta-box XMP removal — now handled (v0.6.9):** an AI-label XMP packet stored as a meta-box `mime` item (HEIF/AVIF; out of reach of the top-level box stripper) is blanked in place by `isobmff.blank_ai_xmp_packets` — it locates the packet by its `<?xpacket begin … end?>` delimiters and, if it carries an AI marker (`_AI_LABEL_MARKERS`), overwrites it with spaces of the SAME length, so box sizes / `iloc` offsets stay valid and the coded image is untouched (selective: plain non-AI XMP is left alone, mirroring the top-level uuid logic). Wired into `remove_ai_metadata`'s ISOBMFF branch after `strip_c2pa_boxes`. The remaining gap is an `Exif` meta-box *item* (rare; the AI labels are XMP) — still needs `iinf`/`iloc` surgery or exiftool.
- **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.
## Known limitations
- `invisible` pipeline processes at **native resolution by default** (`max_resolution=0`), matching the hosted raiw.cc backend (fal fast-sdxl, no pre-downscale). The old forced downscale-to-1024 -> upscale-back round-trip was the main quality loss (issue #10) and is gone; at strength ~0.05 SDXL img2img does not need the ~1024 downscale. `--max-resolution N` re-introduces an opt-in long-side cap purely to bound GPU/MPS memory on very large inputs (it reintroduces the lossy round-trip). For huge images that OOM at native, tile-based diffusion is still the proper long-term fix. **Concrete MPS data point (verified 2026-05-25 on a 1254x1254 gpt-image SDXL run, fp32, 20 GB MPS ceiling):** native res OOMs at the *UNet* step (peak ~17 GiB), not only the VAE decode, and the auto-fallback in `img2img_runner` reloads on CPU and finishes (slow, ~13 min) -- the output is still weight-identical and defeats SynthID, so "looks hung/crashed" on Mac is usually this CPU fallback, not a pipeline error. Adding `enable_vae_tiling()` alone does NOT prevent it (the peak is the UNet, not the VAE). The fast Mac workarounds are fp16 on MPS (roughly halves memory) or `--max-resolution` to cap the long side; neither is wired as the default. The native-vs-downscale decision lives in the pure helper `invisible_engine._target_size(w, h, max_resolution)` (returns `None` for native, a clamped target tuple otherwise) so it is unit-tested (`tests/test_invisible_engine.py::TestTargetSize`, the #10/#15 regression guard) without loading the model -- keep that logic in the helper, don't re-inline it.
- Pyright first run is slow (2-3 min) due to ML deps (torch/diffusers/transformers stubs); full-project `uv run pyright` can stall for many minutes — scope it to changed files.
- `ultralytics` monkey-patches `PIL.Image.open` and tries to autoload `pi_heif`. When `pi_heif` is missing, opening files raises `ModuleNotFoundError`, not `UnidentifiedImageError`. Code that opens user-supplied or unknown-format files should `except Exception`, not just `OSError`/`UnidentifiedImageError`.
- Metadata detection for AVIF/HEIF/JPEG-XL relies on a binary scan for `C2PA_UUID` + `IPTC_AI_MARKERS`, plus EXIF `Software` / XMP `CreatorTool` generator tags via `metadata.exif_generator` (validated with synthesized AVIF/JPEG fixtures + an XMP raw-scan fixture). C2PA removal in those containers is implemented via `noai/isobmff.py` (top-level ``uuid`` / ``jumb`` box stripper, no re-encoding). EXIF/XMP boxes inside those containers are read for detection but not yet **scrubbed** on removal.
- **rich `console.print` parses `[word]` as a style tag and silently drops unknown ones.** A literal bracketed token in a print string disappears: `pip install 'remove-ai-watermarks[gpu]'` rendered as `...remove-ai-watermarks'` (the `[gpu]` extra eaten), which sent users a broken install command (surfaced via #19). Escape the literal bracket as `\[gpu]` (in a normal Python string that is `"\\[gpu]"`) in any rich string carrying user-facing brackets. Regression-guarded by `tests/test_cli.py::TestGpuHintMarkup`.
- Metadata detection for AVIF/HEIF/JPEG-XL relies on a binary scan for `C2PA_UUID` + `IPTC_AI_MARKERS`, plus EXIF `Software` / XMP `CreatorTool` generator tags via `metadata.exif_generator` (validated with synthesized AVIF/JPEG fixtures + an XMP raw-scan fixture). C2PA removal in those containers is implemented via `noai/isobmff.py` (top-level ``uuid`` / ``jumb`` box stripper, no re-encoding), which now also drops a top-level XMP ``uuid`` box that carries an AI label (matched by AI-marker content, not by the XMP UUID, so byte-order-robust) and covers MP4/MOV/M4V/M4A by content sniff. **Non-ISOBMFF audio/video removal is via ffmpeg** (`_FFMPEG_STRIP_EXTS` -> `_strip_with_ffmpeg`): WebM/Matroska (EBML), MP3 (ID3), WAV/FLAC/OGG (RIFF/Vorbis) are stripped losslessly with `ffmpeg -map_metadata -1 -map_chapters -1 -c copy` (codec data untouched). Requires ffmpeg on PATH; raises `RuntimeError` if absent or if ffmpeg can't parse the file. Verified end-to-end (a real ffmpeg-made WAV/MP3 with a `title=Suno AI` tag -> tag gone, audio bytes preserved). **Meta-box XMP now handled (`isobmff.blank_ai_xmp_packets`, v0.6.9):** an AI-label XMP packet stored as a meta-box `mime` item (AVIF/HEIF) is blanked in place (overwritten with spaces of the same length, so `iloc` offsets and the coded image stay valid). **Still NOT built:** an `Exif` *item* inside the `meta` box (rare -- AI labels are XMP) needs full `iinf`/`iloc` surgery (offset rewrite) with corruption risk -- exiftool (R/W/C for HEIC/AVIF EXIF+XMP, verified on exiftool.org 2026-05-27) would do it but is a non-installed binary dep, so it stays a documented gap. **Audio watermark DETECTION (Resemble PerTh) was evaluated and NOT built (2026-05-26):** `resemble-perth`'s `PerthImplicitWatermarker.get_watermark()` returns a raw bit-array with **no presence/confidence flag** (clean audio decodes to arbitrary bits too), so reliably distinguishing watermarked-from-clean needs either Resemble's fixed payload or a confidence API -- neither is public, and there's no real Resemble sample to calibrate against. Same wall-class as the SynthID pixel detector: the decode exists, reliable presence-detection does not. (perth's top-level `PerthImplicitWatermarker` is also gated to None unless `librosa` is importable.)
- **SynthID detection is metadata-only.** There is no reliable *local* detector of the SynthID *pixel* watermark — Google's decoder is proprietary, no public spec or API (only a waitlisted portal). Authoritative confirmation: Google DeepMind's own paper "SynthID-Image: Image watermarking at internet scale" (Gowal et al., arXiv:2510.09263) states the verification service is restricted to "trusted testers" and does not release detector weights or a reproducible algorithm — so a local pixel detector is infeasible by design, not just unbuilt. https://arxiv.org/abs/2510.09263 We detect SynthID by its C2PA companion (`synthid_source` / `SYNTHID_C2PA_ISSUERS`), which is reliable while the manifest is intact but says nothing once C2PA is stripped. **Surface-dependent blind spot (verified 2026-05-24):** the same Google model emits different metadata per surface -- the Gemini *app* wraps outputs in Google C2PA, but the *API/playground* (AI Studio, Nano Banana / gemini-2.5-flash-image) emits the SynthID *pixel* watermark (confirmed via the Gemini-app oracle) + the visible sparkle but **no C2PA/IPTC at all**, so `synthid_source` returns None despite SynthID being present. Only the pixel oracle or the visible-sparkle detector catches those. (Meta AI is another surface mismatch: it writes the IPTC `digitalSourceType=trainedAlgorithmicMedia` marker, not C2PA and not SynthID.) Google→SynthID is long-standing; OpenAI→SynthID is confirmed by OpenAI's Help Center (ChatGPT/Codex/API "include both C2PA metadata and SynthID watermarks", updated 2026-05-21) but time-gated (pre-rollout OpenAI images carry C2PA without SynthID), so the OpenAI verdict is hedged "likely". Oracles: Gemini app "Verify with SynthID" (Google), openai.com/verify (OpenAI). The spectral phase-coherence approach from `github.com/aloshdenny/reverse-SynthID` was evaluated (May 2026) and **does not work for real-content detection**: on its own shipped codebook + validation set, watermarked and cleaned images were indistinguishable (conf within noise, cleaned often higher); it only fires on pure-black 1024x1024 reference images at exact resolution (the controlled case it was calibrated on). The README's "90% / conf=0.91" reproduces only in that lab condition. Do not build a production detector on it; if revisited, it is experimental/diagnostic only and needs a per-resolution, per-model reference corpus. A from-scratch gpt-image pilot (2026-05-24) confirmed this independently: 5 independent solid-black gpt-image outputs share a near-identical fixed signature (pairwise residual correlation **0.92**, avg-template retains 97% energy), so the watermark/carrier IS strongly present and consistent on flat content — but the carrier frequencies extracted from it do NOT discriminate real content (carrier-to-random ratio: cleaned 1.86 > watermarked 1.53; a non-gpt-image image scored highest at 3.67). The signature drowns in content texture. Net: a perfectly consistent solid-color signature still yields no real-content pixel detector with magnitude/carrier methods. A corpus discrimination test (2026-05-24, `scripts/synthid_pixel_probe.py`, raw zero-mean residual NCC) independently re-confirms this: at matched resolution, SynthID positives do NOT cluster apart from negatives (within-Gemini 0.07; at 1024 px pos-vs-neg >= pos-vs-pos). The only high correlations were near-duplicate *content* (5 ChatGPT renders of one prompt at ~0.92, while a distinct ChatGPT image scored ~0 against them) — content, not a carrier. The probe is solid-fills-only and EXPERIMENTAL/DIAGNOSTIC; do not use it on real content. **Correction (deeper re-examination 2026-05-25):** the carrier IS real on solid fills — the earlier "no carrier" was a *method* artifact of using spatial / FFT-magnitude NCC, which can't see it. The carrier is a fixed *phase* at specific low frequencies, so the right metric is **per-bin phase coherence**. On 8 white `gemini-2.5-flash-image` fills (generated via the reverse-SynthID trick: identity-edit prompt "Recreate this image exactly as it is" on a synthetic pure-white PNG — this bypasses the recitation block that rejects text prompts for pure colors), phase coherence at the white carriers `(0,±7..±12,±20..±23)` = **0.86** vs **0.31** random; single-image leave-one-out phase-match **+0.83** vs real photos **-0.24**. (Black `2.5-flash` fills clip to std≈0 — SynthID can't push values below 0, so no carrier in black; the repo's dark carriers come from nano-banana-pro.) **But it does not generalize:** (a) carriers are model-version + resolution + color specific — the repo's v4 codebook (built for `gemini-3.1-flash-image-preview` + `nano-banana-pro-preview`) scores ~0.527 on my 2.5-flash white fills, indistinguishable from negatives (~0.50), i.e. carriers shift across model versions and need a per-model codebook; (b) on real content (30 `2.5-flash` images) the carrier collapses — set phase coherence at carriers 0.37 ≈ random 0.42, and the repo's v4 deteLine truncated
- **External AI-vs-real classifier models are out of scope (decided 2026-05-24).** Generic HuggingFace detectors (`Organika/sdxl-detector` Swin Transformer, `umm-maybe/AI-image-detector`, and fine-tunes) exist and report ~0.98 on their *own* SDXL-vs-real validation sets, but they are per-generator and the model cards themselves note degraded accuracy off-distribution; they are untested on gpt-image / Gemini Nano Banana (the metadata-stripped surfaces we care about), and our own light SDXL pass would likely defeat them the same way it defeats SynthID. Detection here stays local + signal-based (metadata + visible sparkle); do not add a bundled classifier dependency.
- **SynthID v2 vs default pipeline:** the SDXL-based default profile (since May 2026) defeats SynthID v2. **Verified end-to-end (May 2026):** local SDXL run on a Gemini 3 Pro output, checked via the Gemini app's "Verify with SynthID" feature, returned "no SynthID watermark detected". Also confirmed against **OpenAI's** SynthID (2026-05-23): a fresh ChatGPT/gpt-image output read "SynthID detected" on openai.com/verify before the local SDXL run and "SynthID not detected" after (corpus regression chain: pos `4ef377bd` -> cleaned `47188e88`). The same configuration is used in raiw-app production (`fal-ai/fast-sdxl/image-to-image`, strength 0.05, steps 50, guidance 7.5, no pre-downscale). fal's own `llms.txt` for `fast-sdxl` names the base checkpoint as `stabilityai/stable-diffusion-xl-base-1.0` (verified 2026-05-25) -- the exact checkpoint the local CLI defaults to (`DEFAULT_MODEL_ID`). So the local `invisible` default is weight-for-weight identical to prod; "fast-sdxl" is fal's optimized serving, not different weights. After the native-resolution fix the local pipeline matches prod on weights + strength + steps + guidance + resolution. SD-1.5 dreamshaper at 768 px was previously the default and does NOT defeat v2 — verified empirically against the same feature (strength 0.04, 0.10, and elastic warp α∈{5,8} all flagged positive). That SD-1.5 path was removed; only `default` (SDXL) and `ctrlregen` profiles remain. **Scope of the claim: defeating the SynthID verifier is NOT the same as forensic invisibility.** "Removing the Watermark Is Not Enough: Forensic Stealth in Generative-AI Watermark Removal" (arXiv:2605.09203, 2026-05) shows that six removal attacks across four families (UnMarker, CtrlRegen+, WatermarkAttacker, etc.) all leave forensic traces: independent detectors flag *removal-processed* images vs genuinely-clean ones at **>98% TPR at 1% FPR**. So our SDXL pass makes the oracle read "SynthID not detected," but the output can still be classifiable as "an image that went through a removal pipeline." Do not over-claim "indistinguishable from a real photo." https://arxiv.org/abs/2605.09203
- **SynthID v2 vs default pipeline:** **CORRECTION (2026-05-30): strength 0.05 does NOT remove the CURRENT Google SynthID (Nano Banana / Gemini 3).** Re-verified via the Gemini "Verify with SynthID" oracle on a real image: at 0.05 SynthID is still detected; at **0.10 it is removed** (OpenAI's SynthID was already cleared at 0.05). So the default strength was raised 0.05 -> **0.10** (`DEFAULT_STRENGTH` in `watermark_profiles.py`; CLI `--strength` defaults to 0.10), and that higher strength is exactly why text protection (`_run_region_hires`) runs by default (text deforms more at 0.10). Caveat: n=1 Google + n=1 OpenAI image so far -- broad oracle validation across the corpus is pending (different images may need a different strength). The original claim below (0.05 defeats SynthID v2) held for the specific May-2026 Gemini output tested then but is stale for current Google SynthID. **Verified end-to-end (May 2026):** local SDXL run on a Gemini 3 Pro output, checked via the Gemini app's "Verify with SynthID" feature, returned "no SynthID watermark detected". Also confirmed against **OpenAI's** SynthID (2026-05-23): a fresh ChatGPT/gpt-image output read "SynthID detected" on openai.com/verify before the local SDXL run and "SynthID not detected" after (corpus regression chain: pos `4ef377bd` -> cleaned `47188e88`). The same configuration is used in raiw-app production (`fal-ai/fast-sdxl/image-to-image`, strength 0.05, steps 50, guidance 7.5, no pre-downscale). fal's own `llms.txt` for `fast-sdxl` names the base checkpoint as `stabilityai/stable-diffusion-xl-base-1.0` (verified 2026-05-25) -- the exact checkpoint the local CLI defaults to (`DEFAULT_MODEL_ID`). So the local `invisible` default is weight-for-weight identical to prod; "fast-sdxl" is fal's optimized serving, not different weights. After the native-resolution fix the local pipeline matches prod on weights + strength + steps + guidance + resolution. SD-1.5 dreamshaper at 768 px was previously the default and does NOT defeat v2 — verified empirically against the same feature (strength 0.04, 0.10, and elastic warp α∈{5,8} all flagged positive). That SD-1.5 path was removed; only `default` (SDXL) and `ctrlregen` profiles remain. **Scope of the claim: defeating the SynthID verifier is NOT the same as forensic invisibility.** "Removing the Watermark Is Not Enough: Forensic Stealth in Generative-AI Watermark Removal" (arXiv:2605.09203, 2026-05) shows that six removal attacks across four families (UnMarker, CtrlRegen+, WatermarkAttacker, etc.) all leave forensic traces: independent detectors flag *removal-processed* images vs genuinely-clean ones at **>98% TPR at 1% FPR**. So our SDXL pass makes the oracle read "SynthID not detected," but the output can still be classifiable as "an image that went through a removal pipeline." Do not over-claim "indistinguishable from a real photo." https://arxiv.org/abs/2605.09203
+57 -16
View File
@@ -8,21 +8,25 @@ Strips SynthID, C2PA Content Credentials, EXIF/XMP "Made with AI" labels, and vi
>
> No Python, no GPU, no setup. Visible-watermark and metadata removal are **free**. Invisible-watermark removal (SynthID / SDXL regeneration) normally needs a local GPU and ~2 GB of models. On **[raiw.cc](https://raiw.cc)** it runs on cloud GPUs in one click for a small per-image fee.
[![Tests](https://github.com/wiltodelta/remove-ai-watermarks/actions/workflows/test.yml/badge.svg)](https://github.com/wiltodelta/remove-ai-watermarks/actions/workflows/test.yml)
[![Sponsor](https://img.shields.io/badge/Sponsor-GitHub-db61a2?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/wiltodelta)
If this tool saves you time, consider [sponsoring its development](https://github.com/sponsors/wiltodelta).
> **Intended for lawful use only.** Publishing and running this software is lawful; responsibility for any downstream use, and for compliance with local law, rests entirely with the user. Some jurisdictions restrict removing an AI label as such (see [Legal](#legal)). The authors do not condone use for deception, fraud, or any unlawful activity.
## Features
- **Visible watermark removal** — Gemini / Nano Banana sparkle logo via reverse alpha blending (fast, offline, deterministic)
- **Visible watermark removal** — a registry of known marks in their usual places: the Gemini / Nano Banana sparkle and the Doubao "豆包AI生成" text strip. Each is removed by **exact reverse-alpha blending** against a captured alpha map (`original = (wm − α·logo)/(1−α)`), recovering the true pixels rather than inpainting a guess. Fast, offline, no GPU. `visible --mark auto` finds and removes the strongest detected mark. (For arbitrary logos/objects, see `erase`.)
- **Universal region eraser (`erase`)** — remove any logo / watermark / object inside boxes you specify, regardless of position or colour. Default cv2 inpainting (CPU, instant); optional big-LaMa via onnxruntime (`lama` extra) for higher quality
- **Invisible watermark removal** — SynthID, StableSignature, TreeRing via diffusion-based regeneration (needs a local GPU, or run it with no setup on [raiw.cc](https://raiw.cc))
- **AI metadata stripping** — EXIF, PNG text chunks, C2PA provenance manifests (PNG / JPEG / AVIF / HEIF / JPEG-XL), XMP DigitalSourceType
- **"Made with AI" label removal** — removes the metadata that triggers AI labels on Instagram, Facebook, X (Twitter)
- **Analog Humanizer** — film grain and chromatic aberration to bypass AI image classifiers
- **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
- **Smart Face Protection** — automatic extraction and blending of human faces to prevent AI distortion
- **Batch processing** — process entire directories
- **Detection** — three-stage NCC watermark detection with confidence scoring
- **Provenance detection (`identify`)** — aggregate C2PA issuer, IPTC "Made with AI", embedded SD/ComfyUI params, EXIF/XMP generator tags, the SynthID metadata proxy, the visible sparkle, and the open SD/SDXL/FLUX invisible watermark into one origin-platform + watermark-inventory verdict (`--json` for machine output)
- **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, or EXIF), the HuggingFace `hf-job-id` job marker, the SynthID metadata proxy, the visible sparkle, 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)
## Examples
@@ -45,13 +49,15 @@ If this tool saves you time, consider [sponsoring its development](https://githu
| **xAI Grok (Aurora)** | — | — | ✅ EXIF signature scheme (no C2PA): `Signature:` blob + UUID `Artist` | Detected (`identify`); metadata strip |
| **Midjourney** | — | — | ✅ EXIF + XMP (prompt, model, seed) | Metadata strip |
| **Meta AI** | — | — | ✅ IPTC "Made with AI" (digitalSourceType) | Metadata strip (removes the label) |
| **Doubao** (ByteDance) / China AIGC generators | — | — | ✅ TC260 `<TC260:AIGC>` XMP label (China's mandatory AI labeling) | 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`) | Exact reverse-alpha (captured α map): pixel-exact at native width, NCC-aligned at other resolutions, + metadata strip |
| **Samsung Galaxy AI** (Generative Edit, Sketch to Image, ...) | — | — | ✅ C2PA (signer "Samsung Galaxy") + `trainedAlgorithmicMedia` / proprietary `genAIType` marker | Detected (`identify`) + metadata strip |
| **Black Forest Labs** (FLUX API) | — | — | ✅ C2PA (`Black Forest Labs API` + `c2pa.ai_generated_content` + `trainedAlgorithmicMedia`) | Metadata strip |
| **StableSignature** (Meta) | — | ✅ In-model watermark | — | Diffusion regeneration |
| **TreeRing** | — | ✅ Latent space watermark | — | Diffusion regeneration |
> Visible watermarks (logo overlays) are currently used only by Google Gemini / Nano Banana. Other services rely on invisible watermarks and/or metadata. Our diffusion-based regeneration works against any invisible watermark in pixel or frequency domain.
> Visible overlays are used by Google Gemini / Nano Banana (sparkle logo) and by Doubao / China AIGC generators (the mandated "...AI生成" corner text). Both are removed deterministically on CPU. Other services rely on invisible watermarks and/or metadata; our diffusion-based regeneration works against any invisible watermark in pixel or frequency domain. For a visible mark from any other source (any position, any colour), use the universal `erase --region` command.
> **Detection:** `remove-ai-watermarks identify <image>` reports the origin platform and watermark inventory for all the signals above — C2PA issuer, IPTC "Made with AI", the China TC260 AIGC label, embedded generation params, EXIF/XMP generator tags, the SynthID metadata proxy, the visible sparkle, and (with the `[detect]` extra) the open SD/SDXL/FLUX invisible watermark. The SynthID *pixel* watermark has no local decoder, so it is reported as a metadata proxy only.
> **Detection:** `remove-ai-watermarks identify <image>` reports the origin platform and watermark inventory for all the signals above — C2PA issuer, the C2PA soft-binding forensic-watermark vendor (TrustMark / Digimarc / Imatag / ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, the China TC260 AIGC label (XMP, PNG chunk, or EXIF), the HuggingFace `hf-job-id` job marker, embedded generation params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the SynthID metadata proxy, the visible sparkle, and (with the `[detect]` / `[trustmark]` extras) the open SD/SDXL/FLUX and Adobe TrustMark invisible watermarks. SynthID and the proprietary soft-binding watermarks (Digimarc etc.) have no local decoder, so they are reported by metadata proxy / vendor name only.
## How it works
@@ -73,6 +79,16 @@ A three-stage NCC (Normalized Cross-Correlation) detector finds the watermark po
**Speed**: ~0.05s per image. 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 — like the Gemini sparkle — it is removed by **exact reverse-alpha blending**: `original = (watermarked - α·logo) / (1 - α)`, recovering the true pixels instead of hallucinating them. The α map and logo colour were solved from controlled black + gray captures (on black, `captured = α·logo`; the black/gray pair solves α per-pixel). At the captured width the placement is exact, so the recovery is returned untouched (inpainting over exactly-recovered pixels only degrades them). The single capture generalizes to any resolution: off the captured width an NCC scale-and-position search registers the α template to the actual mark, and a light residual inpaint cleans the sub-pixel seam there. Detection is consistent with removal: it matches the same alpha glyph silhouette against the corner (normalized correlation), so it keys on the actual "豆包AI生成" shape, not on textured corners.
**Speed**: ~0.05s, no GPU needed. Reverse-alpha at the captured resolution recovers the true background pixels exactly.
### 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.
### Removing SynthID and other invisible watermarks
Google embeds **SynthID** into every image generated by Gemini / Nano Banana. Other AI services use StableSignature, TreeRing, and similar schemes. These imperceptible frequency-domain patterns survive cropping, resizing, and JPEG compression.
@@ -94,6 +110,8 @@ SDXL is the default since May 2026: empirically defeats SynthID v2 on Gemini 3 P
**Analog Humanizer**: optional film grain and chromatic aberration injection that mimics a photo of a screen, raising the bar for AI-generated image classifiers. (It frustrates generic classifiers but does not guarantee forensic invisibility — see the [arXiv:2605.09203](https://arxiv.org/abs/2605.09203) note above.)
**Text Protection** (automatic): SDXL img2img regenerates every pixel, so small text and glyphs get deformed at the strengths that defeat SynthID. The SDXL pipeline guards against this by default: a PP-OCRv3 text detector (a 2.4 MB ONNX model run on CPU via OpenCV's DNN module, downloaded and cached on first use) locates text regions, and if any are found the whole image is scrubbed normally and then each text block is **re-scrubbed at high resolution** and composited back. Because the text is regenerated (not copied), the watermark is still removed inside it, while running the text region at higher resolution keeps small strokes crisp through the regeneration. Detection is **language-agnostic** (it finds text regions, not characters), so Latin, Cyrillic, CJK, Hangul, Arabic, and digits are all protected, and it runs at the image's native resolution so small text on large images is not missed. Text-free images run the standard pass at no extra cost. Pass `--no-protect-text` to turn it off. SDXL default pipeline only.
### Stripping C2PA, EXIF, and "Made with AI" metadata
AI tools embed generation metadata that social platforms use to show "Made with AI" labels:
@@ -159,6 +177,14 @@ After installation the `remove-ai-watermarks` command is available system-wide.
> ```bash
> pip install -e ".[detect]" # or: uv pip install -e ".[detect]"
> ```
>
> To also decode the open **Adobe TrustMark** watermark (behind Adobe Durable
> Content Credentials), install the `trustmark` extra (pulls torch and downloads
> model weights on first use):
>
> ```bash
> pip install -e ".[trustmark]" # or: uv pip install -e ".[trustmark]"
> ```
#### Invisible watermark removal
@@ -213,13 +239,20 @@ 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 (Gemini / Nano Banana sparkle) — fast, offline
# Visible watermark only — fast, offline, CPU. --mark auto (default) finds the
# strongest known mark (Gemini sparkle / Doubao "豆包AI生成" text); force one
# with --mark gemini / doubao. Removed by exact reverse-alpha (true-pixel recovery).
remove-ai-watermarks visible image.png -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
# Invisible watermark only (SynthID etc.) — requires GPU
remove-ai-watermarks invisible image.png -o clean.png --humanize 4.0
# Runs at native resolution by default. On a very large image that OOMs the
# GPU/MPS, cap the long side: --max-resolution 2048
# Text / CJK glyphs are preserved automatically; disable with --no-protect-text
# Check / strip AI metadata (C2PA, EXIF, "Made with AI" labels)
# --check also flags SynthID-bearing sources: a C2PA manifest signed by
@@ -296,8 +329,10 @@ Tracked but not yet implemented:
- **Local SynthID *pixel* detector**. Not feasible today: Google's decoder is proprietary, and magnitude/carrier spectral methods do not separate real content (confirmed by three independent evaluations, including a from-scratch gpt-image pilot; see CLAUDE.md). Blocked on either (a) a programmatic generation path (OpenAI / Gemini API) to build a per-(model, resolution) labeled corpus at scale, or (b) a raw watermarked-output dataset. If data arrives, the next approach to try is a learned classifier on diverse content rather than a fixed carrier codebook.
- **Grow the SynthID reference corpus** (`data/synthid_corpus/`) with oracle-labeled samples per model and resolution (Gemini app for Google, openai.com/verify for OpenAI). Prerequisite for any pixel-detector attempt and for an automated removal-regression set.
- **Real non-PNG C2PA fixtures**. SynthID-source detection for JPEG / WebP / AVIF is currently covered only by synthetic byte blobs; replace with real vendor-emitted files to ground the binary-scan path.
- **Maintenance debt**. Clear strict-pyright debt in `remove_ai_metadata` / `cli.py` (untyped piexif / PIL / click / rich) so `maintain.sh` can finish green. (`uv-secure` is already clean since `idna` was bumped to 3.16.)
- **AVIF / HEIF / JPEG-XL detection limits**. Removal strips top-level C2PA `uuid` and JUMBF `jumb` boxes. EXIF/XMP boxes inside these containers are not yet scrubbed (PNG and JPEG are fully covered).
- **Maintenance debt**. Strict pyright is now clean across `src/` (0 errors): pure-logic files are fully typed, the cv2 / torch / diffusers boundary files carry a documented per-file relax pragma, and a local `typings/piexif` stub covers piexif. Remaining: full-project `pyright` (no path) still OOMs node on this ML-heavy repo, so it must be scoped to `src/`; narrowing the boundary pragmas back toward full strict (as upstream stubs improve) is the long tail. (`uv-secure` is already clean since `idna` was bumped to 3.16.)
- **AVIF / HEIF `Exif` item inside the `meta` box**. An AI-label *XMP* packet in a `meta`-box item is now blanked in place (v0.6.9), but EXIF stored as a `meta`-box `Exif` *item* is still not removed — it needs full `iinf`/`iloc` surgery (offset rewrite, corruption risk) or `exiftool` (a non-bundled binary dependency). Low priority: the AI labels we target are XMP, not EXIF, so an EXIF-only meta-box case is rare.
- **More C2PA device signers**. Leica, Nikon, Google Pixel, Sony, and Truepic capture cameras are mapped (each verified against a real signed file); **Samsung Galaxy AI**, **Black Forest Labs (FLUX)**, and **ByteDance Volcano Engine** (Doubao / Jimeng) are now attributed too (verified on real signed files). Canon is still deferred until a real signed sample surfaces — no public direct-download C2PA file exists for it today (upload-to-verify / news-agency-licensed only).
- **Resemble PerTh audio detection** — evaluated, not feasible with the public API: `get_watermark()` returns a raw bit array with no presence/confidence flag, so watermarked vs. clean audio can't be reliably separated without Resemble's fixed payload or a confidence service. Same wall as the SynthID pixel detector.
- **Video pipeline (`noai-video`)**: per-frame inpainting and tracking for Sora 2 dynamic logo, Veo 3.1 badge, Kling, Runway. Separate package, not folded into this repo.
Won't fix:
@@ -311,11 +346,12 @@ Watermarking and provenance for AI-generated content is now regulated in several
| Jurisdiction | Instrument | Status (May 2026) | Relevance |
| --- | --- | --- | --- |
| EU | AI Act, Article 50 | Transparency duties apply from **2 August 2026**. Legacy generative systems (placed on the market before that date) get a grandfathering period to **2 December 2026** for the Article 50(2) marking duty, under the Digital Omnibus (Commission proposal Nov 2025; co-legislator political agreement 7 May 2026). Article 50 guidelines and a marking Code of Practice are being finalised through 2026. | Removing mandated provenance markers with intent to deceive may be sanctioned under national implementations. |
| US (federal) | COPIED Act | **Reintroduced April 2025; not enacted** (pending in the Senate). | If passed, would set NIST provenance standards and prohibit tampering with / removing provenance information. The tool itself is lawful; usage may not be. |
| US (state) | CA AB 2655, TX SB 751, similar | TX SB 751 in force; **CA AB 2655 struck down** by a federal court (Aug 2025, Section 230 / First Amendment). | Content-specific (election deepfakes, sexual deepfakes). Not tool-specific. |
| US (federal) | COPIED Act (S. 1396, 119th Cong.) | **Reintroduced April 2025; not enacted** (referred to Senate Commerce Committee). | If passed, would set NIST provenance standards and prohibit tampering with / removing provenance information. The tool itself is lawful; usage may not be. |
| US (state) | CA AB 2655, TX SB 751 (2019), similar | TX SB 751 (2019) in force; **CA AB 2655 struck down** by a federal court (E.D. Cal., Aug 2025, *Kohls v. Bonta*) as preempted by **Section 230**; the court did not reach the First Amendment (the companion law AB 2839 was separately enjoined on First Amendment grounds). | Content-specific (election deepfakes, sexual deepfakes). Not tool-specific. |
| US (state) | CA AB 853 (amends the California AI Transparency Act) | Core provider duties operative **2 August 2026** (delayed from 1 January 2026); large platforms 1 January 2027; capture devices 1 January 2028. | Covered providers (1M+ monthly users) must embed a latent disclosure that is "permanent or extraordinarily difficult to remove" and offer a free detection tool. Removing that disclosure is what this tool does. |
| South Korea | AI Framework Act (Basic Act on AI), Article 31 | In force since **January 2026** (one-year transition after promulgation). | Art. 31(3): AI output "difficult to distinguish from reality" must be labeled so users "clearly recognize" it; the draft Enforcement Decree accepts a machine-readable (invisible-watermark) label. Artistic/creative works get a presentation exception. |
| China | Measures for Labeling AI-Generated Content (+ GB 45438-2025) | In force since **1 September 2025**. | Mandatory explicit (visible) + implicit (metadata) labels for AI content; tampering with or removing labels is prohibited. |
| South Korea | AI Framework Act (Basic Act on AI), Article 31 | In force since **22 January 2026** (one-year transition after promulgation). | Art. 31(3): AI output "difficult to distinguish from reality" must be labeled so users "clearly recognize" it; the draft Enforcement Decree accepts a machine-readable (invisible-watermark) label. Artistic/creative works get a presentation exception. |
| China | Measures for Labeling AI-Generated Content (+ GB 45438-2025) | In force since **1 September 2025**. | Mandatory explicit (visible) + implicit (metadata) labels across image / audio / video; tampering with, forging, or removing labels is prohibited. |
| India | IT (Intermediary Guidelines and Digital Media Ethics Code) Amendment Rules, 2026 | In force since **20 February 2026** (notified 10 February 2026). | All "synthetically generated information" must be **prominently labelled** and carry **permanent metadata / a provenance identifier**; the rules expressly **prohibit modifying, suppressing, or removing** that label or metadata. Covers image, audio, and audio-visual content. |
| UK | Online Safety Act 2023 / Ofcom guidance | In force, but **no statutory AI-provenance or watermarking obligation**. | Ofcom encourages watermarking / provenance metadata as voluntary "attribution measures"; platform duties, not user obligations. |
## Threat model
@@ -340,7 +376,12 @@ This tool is intended for legitimate purposes such as:
- Removing false-positive "Made with AI" labels from human-edited photographs.
- Security research and watermark robustness study.
Removing AI provenance markers to misrepresent AI-generated content as human-created may violate the laws above, the DMCA, and platform terms of service. Users are solely responsible for ensuring their use complies with all applicable laws. The authors do not condone use of this tool for deception, fraud, or any activity that violates applicable laws or regulations.
**Who bears the liability.** This is general-purpose software and is itself lawful to publish and run; legal responsibility attaches to the person who removes a marker and to how the result is then used, and the hinge is intent. Removing AI provenance to pass AI-generated content off as human-made, to commit fraud, to produce non-consensual deepfakes, or to conceal copyright infringement can expose the remover to liability. Two kinds of exposure are worth knowing:
- **The downstream act.** Deception, fraud, defamation, IP infringement, or breaking a platform's terms — judged by intent and harm, not by the act of editing metadata itself. In the US, the **DMCA (17 U.S.C. § 1202)** specifically bars removing "copyright management information" *with intent to conceal or enable infringement*.
- **The removal itself.** Some jurisdictions penalise tampering with the label/metadata as such, regardless of downstream use — notably **China** (Labeling Measures) and **India** (IT Amendment Rules 2026), which expressly prohibit removing or suppressing the AI label and provenance metadata. The US **COPIED Act** would do the same if enacted.
Legitimate uses — publishing your own work, privacy (stripping metadata that leaks an account identifier), security / robustness research, or removing a false-positive "Made with AI" label from a human-edited photograph — are generally lawful. Users are solely responsible for ensuring their use complies with all applicable laws. The authors do not condone use of this tool for deception, fraud, or any activity that violates applicable laws or regulations. None of this is legal advice.
## License
+78
View File
@@ -0,0 +1,78 @@
# Doubao visible watermark capture
Goal: capture the Doubao "豆包AI生成" visible watermark over known flat backgrounds so we can
build a per-pixel alpha map and a reverse-alpha-blend remover, the same way the Gemini sparkle
engine works (`src/remove_ai_watermarks/gemini_engine.py`).
## What we already know (verified from prior art, 2026-05-26)
- Blend model: **alpha compositing with a white logo** `watermarked = a*logo + (1-a)*original`,
`logo = (255,255,255)`. Inversion: `original = (watermarked - a*logo) / (1-a)`.
Confirmed by two independent sources (an open-source remover's algorithm doc + aiwatermarkremover.dev,
both say "alpha map"). One commercial blog (pixelcleanai) claims "screen blend" instead; the gray
capture below settles it empirically.
- Position: **bottom-right corner**, small margins (right ~8-20px, bottom ~5px), scales with image size.
Confirmed by our sample `data/samples/doubao-1.png` (2048x2048) plus three sources.
- Size **scales with resolution**. Third-party numbers (~90x18 at <=1024, ~180x40 at >1024) are
approximate and calibrated for ~1024-1280 outputs; at 2048 the strip is much larger. A shipped
third-party alpha map is only 120x20, too small for our 2K/4K target -> capture fresh.
- In practice clean inversion leaves residue on textured backgrounds, so the remover pairs the alpha
map with inpainting (our Gemini engine already does gradient-masked inpainting for residual edges).
## Use doubao.com specifically
The "豆包AI生成" mark is Doubao's. Jimeng / Dreamina use a different mark. Generate on doubao.com so
the captured template matches our target.
## How to capture (image-edit path, most reliable)
For each seed in `seeds/`:
1. Open Doubao image generation, use the image-edit / reference mode, upload the seed.
2. Prompt (Chinese preferred):
`请完全按照原图重新生成这张图片,保持完全一致,不要添加或修改任何内容`
(English: `Recreate this image exactly as it is, keep it identical, do not add or change anything`)
3. Download the ORIGINAL output file (not a screenshot). Do not crop / edit / re-save.
Prior art confirms uploading a pure-black image and letting Doubao stamp it works.
If edit mode is unavailable and text-to-image refuses a solid color, fall back to generating 10-12
normal-content images at one fixed resolution; the mark is the only constant across them and can be
extracted by per-pixel min/median.
## What to capture (priority top to bottom)
| Aspect | black | white | gray128 | why |
|--------|-------|-------|---------|-----|
| 1:1 | 3 | 1 | 1 | primary alpha map + confirm the stamp is pixel-identical across runs + settle blend mode |
| 16:9 | 2 | 1 | 1 | anchor rule in landscape |
| 9:16 | 2 | 1 | 1 | anchor rule in portrait |
| 4:3, 3:4 | 1 each | - | - | optional, refines anchor rule |
- 3 blacks on 1:1: if the first two are byte-identical in the watermark region, the third is optional.
- gray128 is the blend-mode test: predict the gray result from the black capture under alpha vs screen;
whichever matches the real gray output is the true blend.
- If the UI offers multiple output resolutions (1K / 2K / 4K), capture one black per resolution on 1:1 -
needed to learn how the watermark scales.
- Also grab 3-5 normal-content images on 1:1 for end-to-end removal validation.
## Hygiene
- Original download, never a screenshot. PNG preferred; if Doubao only gives JPEG, note it.
- No crop / edit / re-save. Default settings, watermark left ON.
## Naming, drop into `captures/`
```
doubao_black_1x1_1.png
doubao_white_1x1_1.png
doubao_gray128_1x1_1.png
doubao_black_16x9_1.png
doubao_content_1x1_1.png
```
## Also report back
1. Which resolutions and aspect ratios the Doubao UI actually offers.
2. Whether there is a watermark on/off toggle in the UI.
3. Download format (PNG or JPEG).
+138
View File
@@ -0,0 +1,138 @@
# Text protection research: crisp text under a "watermark removed everywhere" constraint
Date: 2026-05-29. Source: a deep-research run (104 agents, 5 search angles, sources
fetched and 3-vote adversarially verified). Not committed automatically — saved as a
research note for the next session.
## The constraint that frames everything
The invisible watermark (Google SynthID) must be removed **everywhere, including inside
text regions**. Therefore any technique that keeps or composites the **original
(watermarked) text pixels** is disqualified — the text must be *regenerated / freshly
synthesized* enough to scrub the watermark, yet rendered crisply. This single rule is the
filter applied to every candidate below.
## Problem recap
The `invisible` pipeline is SDXL base 1.0 img2img at low strength (~0.05) to defeat
SynthID with minimal visible change. Text is protected via Differential Diffusion with a
per-pixel change map (`preserve` ~0.9) driven by the PP-OCRv3 DB detector
(`text_protector.py`). Large text survives; **small text (sub ~8 px strokes) softens or
garbles** (issue #14, confirmed on real content).
## Executive summary
The fine-text softening is an **architectural consequence of latent-space processing, not
a tuning problem**: SDXL's 4-channel VAE (~48x compression) discards high-frequency signal
on encode, and Differential Diffusion blends in latent space with the change map
downsampled by 8x, so any stroke under ~8 px sits inside one latent cell and cannot be
preserved or edited cleanly **regardless of `preserve`** (the Differential Diffusion
authors state this limit explicitly). Two structurally sound directions keep the
"watermark removed everywhere" guarantee because they **synthesize fresh glyph pixels**
rather than compositing originals: (1) glyph/text-conditioned diffusion re-render of
detected text (AnyText2, EasyText), and (2) a two-stage architecture — global scrub, then
a dedicated text-restoration / text-aware super-resolution pass over detected regions
(TIGER, TextSR, TeReDiff/TAIR). **EasyText** and **TextSR** are the most promising for this
CJK-first pipeline (both multilingual via DiT/ByT5, both regenerate from glyph or
character-shape priors). The deepest fix — a 16-channel (SD3/FLUX) VAE — materially reduces
the softening but means switching the base model, not a drop-in VAE swap.
## Constraint reconciliation (important)
The generic research "quick win: bump `preserve` toward 1.0" is **invalid under our hard
constraint**: raising `preserve` freezes the text region, so SynthID there is **not
scrubbed**. Likewise, pixel paste-back of the original text is disqualified. The only
constraint-compatible quick win is **higher resolution / tiled diffusion** (strokes span
more latent cells, less VAE softening, while the text is still fully regenerated and thus
scrubbed). The real answer is **regenerate text crisply**, not freeze it.
## Findings (with confidence and sources)
### Finding 1 — confidence: high
**Claim.** The small-text softening is an architectural latent-space limit, not a tuning issue. SDXL's VAE compressively encodes (losing exact color and fine detail on every round-trip), and Differential Diffusion blends in latent space with the change map downsampled to latent resolution (8x), so the method explicitly caps edit/preserve granularity at ~8 px under SD settings. Text strokes below one latent cell cannot be cleanly preserved even at preserve ~0.9.
**Evidence.** Differential Diffusion's paper states a "cap on the resolution of the change map ... can limit the ability to precisely edit small objects (less than 8 pixels for Stable-Diffusion's settings)"; the official SDXL pipeline downsamples the map by `vae_scale_factor=8` and blends `latents = original*mask + latents*(1-mask)` in latent space. The VAE encode is "compressive ... exact color qualities and exact visual fine-details are lost." arXiv:2512.05198 confirms "resizing the pixel mask to latent resolution discards fine structure ... downsamples by 1/8" and that linear latent blending "cannot be pixel-equivalent." Higher compression = more high-frequency loss (arXiv:2305.02541).
**Sources.** https://onlinelibrary.wiley.com/doi/10.1111/cgf.70040 · https://differential-diffusion.github.io/ · https://github.com/exx8/differential-diffusion · https://arxiv.org/abs/2512.05198 · https://omriavrahami.com/blended-latent-diffusion-page/ · https://arxiv.org/pdf/2305.02541
### Finding 2 — confidence: low (do not build on it yet)
**Claim.** Pixel-space differential / blended-latent variants exist as a research direction, but the specific full-resolution-mask solution (PELC/DecFormer, arXiv:2512.05198) was NOT verified to deliver its claimed seam/edge improvements.
**Evidence.** arXiv:2512.05198 argues linear latent blending is not pixel-equivalent and proposes decoder-equivariant compositing; PixPerfect (arXiv:2512.03247) does pixel-space refinement of chromatic shifts at edit boundaries. But the specific PELC full-resolution-mask and DecFormer "53% error reduction" claims were **refuted on adversarial vote (0-3 and 1-2)**. Treat pixel-equivalent latent compositing as an emerging idea to watch, not a production fix.
**Sources.** https://arxiv.org/abs/2512.05198 · https://arxiv.org/abs/2512.03247
### Finding 3 — confidence: high
**Claim.** Glyph/text-conditioned diffusion can re-render detected text as freshly synthesized pixels (not copied), which inherently scrubs any watermark in the text region while rendering glyphs crisply. AnyText/AnyText2 inject text-rendering into a pretrained T2I model and support generation AND editing of existing scene images; multilingual including CJK and English.
**Evidence.** AnyText2 "enables precise control over multilingual text attributes in natural scene image generation and editing" (WriteNet+AttnX); +3.3% (Chinese) / +9.3% (English) accuracy over AnyText v1. AnyText "can be plugged into existing diffusion models ... for rendering or editing text" and synthesizes text latent features through diffusion (fresh pixels), supporting zh/en/ja/ko/ar/bn/hi. **Caveat:** both are SD1.5-based, so NOT a drop-in into the SDXL scrub (separate base model); AnyText's own limitation: "the inpainting manner ... impedes editing quality on small text," and it ranks weak on STRICT (EMNLP 2025) — small-text crispness not guaranteed.
**Sources.** https://github.com/tyxsspa/AnyText2 · https://arxiv.org/abs/2411.15245 · https://arxiv.org/abs/2311.03054
### Finding 4 — confidence: high
**Claim.** EasyText is a strong glyph-conditioned re-render candidate: built on the FLUX-dev DiT framework with LoRA tuning, renders compact per-character glyph patches (64px-high adaptive for alphabetic, 64x64 for logographic) concatenated in latent space, supports 10+ languages including Chinese, Japanese, Korean, Thai, Vietnamese, Greek, and Latin.
**Evidence.** AAAI 2025 + arXiv:2505.24417: "implemented based on the open-source FLUX-dev framework with LoRA-based parameter-efficient tuning," VAE and text encoder frozen, two-stage 512->1024 training. Glyph conditioning via "64-pixel-high images ... adaptive widths for alphabetic; fixed 64x64 for logographic," VAE-encoded and concatenated with denoised latents, "less than one-tenth the spatial size of layout-matching methods." FLUX-based (16-channel VAE, DiT) also sidesteps the SDXL 4-channel wall. Fresh-pixel generation preserves the watermark-removal guarantee. Cyrillic/Arabic crispness not separately benchmarked.
**Sources.** https://arxiv.org/html/2505.24417 · https://ojs.aaai.org/index.php/AAAI/article/view/37697
### Finding 5 — confidence: high
**Claim.** A two-stage "global watermark scrub then text-restoration pass" architecture is validated by recent literature, and the restoration stage can synthesize glyph pixels from priors (no original-pixel reintroduction). TIGER reconstructs stroke geometry then injects it as guidance into full-image super-resolution; TextSR uses a detector + multilingual OCR to regenerate text from character-shape priors; TeReDiff/TAIR couples a jointly-trained text-spotter with diffusion.
**Evidence.** TIGER (arXiv:2510.21590): "a diffusion-based local text refiner ... reconstructing fine-grained stroke geometry ... injected as conditional guidance into the subsequent full-image restoration." TextSR (arXiv:2505.23119, Google): "leverages a text detector ... then employs OCR to extract multilingual text," regenerating from "multilingual character-to-shape diffusion priors" that "produce character shapes solely based on text prompts, even without visual input" — fresh pixels. TAIR/TeReDiff (ICLR 2026): standard restoration "frequently generates plausible but incorrect textures"; TeReDiff feeds text-spotter outputs back as prompts. **Caveat:** TIGER orders text-first then global (reverse of scrub-then-text); these target degraded-input super-resolution, not watermark removal, so the SynthID-scrub of the restoration stage must be verified empirically (the stages are themselves diffusion-based, so fresh-pixel = no SynthID is plausible but unproven here).
**Sources.** https://arxiv.org/html/2510.21590v1 · https://arxiv.org/html/2505.23119v1 · https://cvlab-kaist.github.io/TAIR/ · https://arxiv.org/abs/2506.09993
### Finding 6 — confidence: high
**Claim.** Switching to a 16-channel VAE (SD3/FLUX class) materially reduces small-text/latent softening vs SDXL's 4-channel VAE, but it requires switching the base model — not a drop-in latent swap into an SDXL UNet img2img pipeline. RAE approaches are DiT-native and likewise not drop-in.
**Evidence.** SD3/FLUX moved from 4-channel (48x) to 16-channel (12x) VAEs specifically to preserve fine detail (diffusers Discussion #8713; madebyollin VAE notes; arXiv:2305.02541). RAE (arXiv:2510.11690) "should be the new default for diffusion transformer training" but produces high-dimensional latents needing a DiT wide-DDT head — NOT compatible with an SDXL 4-channel UNet. EasyText shows the practical path: adopt a FLUX-DiT base rather than retrofit SDXL. The VAE upgrade couples to a base-model migration.
**Sources.** https://arxiv.org/abs/2510.11690 · https://arxiv.org/pdf/2305.02541 · https://arxiv.org/html/2505.24417
## Recommendation
Under the hard constraint, the correct architecture is **not "protect text during the
scrub" (Differential Diffusion)** but **"scrub everywhere, then restore text crisply by
regeneration"**:
1. Global SDXL scrub with text protection OFF (text region is scrubbed too).
2. On detected text regions, a **glyph-conditioned restoration** that re-renders the same
glyphs as fresh pixels (no original reused).
This is the only path that delivers both "watermark everywhere" and crisp text.
**Top-2 to prototype:**
- **TextSR** — detector + multilingual OCR + character-shape diffusion priors; closest to
the existing detector-driven pipeline.
- **EasyText** — FLUX-DiT glyph re-render, multilingual incl. CJK; also gets the 16-channel
VAE for free.
**Honest costs / unknowns:** this is a re-architecture, not a quick fix. It needs a new
**OCR-recognition** step (we currently only detect text; we must know *what* to re-render).
Models are FLUX/DiT-class (heavy) -> serverless GPU. Maturity is research-grade; CJK is
covered, Cyrillic/Arabic crispness is not separately benchmarked -> a prototype must
measure real fidelity. The restoration stage being diffusion-based makes "fresh pixels =
no SynthID" plausible but **must be verified empirically** (run the SynthID oracle on the
restored output).
**Constraint-compatible quick win to try first:** run the global scrub at **higher
resolution / tiled** so strokes exceed the latent cell — less softening, full scrub, no
freezing. Cheap to test; quantify recall/quality vs cost.
**Do not pursue:** raising `preserve` toward 1.0 or pixel paste-back (both leave original
watermarked pixels in text); PELC/DecFormer pixel-equivalent latent compositing (refuted,
not production-ready).
## Provenance
Deep-research workflow run `wf_118b9a03-3eb` (2026-05-29). Findings adversarially verified
(2/3 refutes required to kill a claim). This note records research only; no code change is
implied until a prototype validates fidelity and the SynthID-scrub guarantee on the
restored output.
+86 -3
View File
@@ -1,10 +1,20 @@
[project]
name = "remove-ai-watermarks"
version = "0.5.6"
version = "0.7.1"
description = "Remove visible and invisible AI watermarks from images (Gemini / Nano Banana, ChatGPT, Stable Diffusion)"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
classifiers = [
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Multimedia :: Graphics",
"Topic :: Scientific/Engineering :: Image Processing",
]
dependencies = [
"pillow>=10.0.0",
"piexif>=1.1.3",
@@ -18,8 +28,27 @@ dependencies = [
[project.optional-dependencies]
gpu = [
"torch>=2.0.0",
# The default PyPI torch wheel is a CPU/CUDA build. To drive an Intel GPU
# (Arc / Data Center) via ``--device xpu`` you need an XPU-enabled torch
# from PyTorch's XPU wheel index (Linux/Windows only -- there is no macOS
# XPU build). Install that build first, then this extra (torch is then
# already satisfied and won't be re-pulled):
# pip install torch --index-url https://download.pytorch.org/whl/xpu
# pip install 'remove-ai-watermarks[gpu]'
# uv users can target the ``pytorch-xpu`` index declared under [tool.uv]:
# uv pip install torch --index-url https://download.pytorch.org/whl/xpu
"diffusers>=0.38.0",
"transformers>=4.35.0",
# diffusers 0.38's auto-pipeline registry imports ``Qwen3VLForConditional
# Generation`` (its ``nucleusmoe_image`` pipeline), which only exists in
# transformers 5.x -- so ``from diffusers import AutoPipelineForImage2Image``
# fails on transformers 4.x. The real SDXL-loading break was NOT transformers
# 5.x but the tokenizers *release candidate* (0.23.0rc0) that the global
# ``prerelease = "allow"`` drags in: its CLIP tokenizer raises
# ``RobertaProcessing.__new__() got an unexpected keyword argument 'cls'``.
# Cap tokenizers to the stable 0.22 line (transformers 5.x accepts
# >=0.22,<=0.23.0) so the rc is excluded while SDXL still loads.
"transformers>=5,<6",
"tokenizers>=0.22,<0.23",
"accelerate>=0.25.0",
"controlnet-aux>=0.0.9",
"safetensors",
@@ -36,6 +65,21 @@ gpu = [
detect = [
"invisible-watermark>=0.2.0",
]
# Adobe TrustMark decoder -- the open, keyless watermark behind Adobe Durable
# Content Credentials (soft-binding alg ``com.adobe.trustmark.P``). Optional
# because it pulls torch and downloads model weights on first use. identify()
# guards the import and skips the TrustMark signal when absent.
trustmark = [
"trustmark>=0.8.0",
]
# Universal region eraser backend -- big-LaMa via onnxruntime (Carve/LaMa-ONNX,
# Apache-2.0). CPU, no torch. Model (~200 MB) is downloaded on first use and
# cached by huggingface_hub; it is never bundled in this repo. The default cv2
# eraser backend needs none of this.
lama = [
"onnxruntime>=1.16.0",
"huggingface-hub>=0.20.0",
]
dev = [
"pytest>=8.0.0",
"pytest-cov>=4.1.0",
@@ -43,7 +87,7 @@ dev = [
"pyright>=1.1.0",
"invisible-watermark>=0.2.0",
]
all = ["remove-ai-watermarks[gpu,detect,dev]"]
all = ["remove-ai-watermarks[gpu,detect,trustmark,lama,dev]"]
# diffusers 0.38.0 (security fix for GHSA-98h9-4798-4q5v) declares a dependency
# on safetensors>=0.8.0rc0 — a pre-release. Allow pre-releases globally so the
@@ -52,6 +96,15 @@ all = ["remove-ai-watermarks[gpu,detect,dev]"]
[tool.uv]
prerelease = "allow"
# 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
# explicitly targets it (see the ``gpu`` extra comment), so it does not alter
# the locked CPU/CUDA resolution. Linux/Windows only -- no macOS XPU build.
[[tool.uv.index]]
name = "pytorch-xpu"
url = "https://download.pytorch.org/whl/xpu"
explicit = true
[project.scripts]
remove-ai-watermarks = "remove_ai_watermarks.cli:main"
@@ -108,3 +161,33 @@ reportUnknownMemberType = false
reportUnknownArgumentType = false
reportUnknownVariableType = false
reportMissingTypeArgument = false
# CtrlRegen is a torch/diffusers/controlnet-aux boundary: those libs ship no
# usable types, so strict pyright cannot know the tensor element types. Relax the
# unknown-type rules for this subpackage only (mirrors the per-file pragmas used
# for the cv2 engines); the rest of the codebase stays strict.
[[tool.pyright.executionEnvironments]]
root = "src/remove_ai_watermarks/noai/ctrlregen"
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
+215
View File
@@ -0,0 +1,215 @@
"""Audit a local image corpus against the library's own ``identify`` detector.
Two jobs in one pass:
1. **Report** -- run ``identify`` over every image and write one CSV row per file
(verdict, platform, confidence, watermarks, signals, integrity clashes).
2. **Gap audit** -- for every ``unknown``-verdict file, scan only its *metadata
region* (PNG text/eXIf chunks, JPEG APPn segments before SOS, or the file
head for other containers) for known provenance markers. A marker found there
on a file the detector calls ``unknown`` is a concrete lib gap: a serialization
or generator we do not yet parse. Scanning the metadata region -- not the whole
file -- is deliberate: short tokens collide randomly inside compressed PNG
``IDAT`` / JPEG scan data, which produced false "xAI/Flux/AIGC" hits when the
first audit naively scanned the first megabyte.
This is how new detector gaps get found (it is what surfaced the JPEG-EXIF
``{"AIGC":{...}}`` form). Re-run after collecting a fresh corpus batch.
Usage:
uv run python scripts/corpus_gap_scan.py --corpus data/spaces/originals
uv run python scripts/corpus_gap_scan.py --corpus data/spaces/originals \\
--report data/spaces/detector_report.csv
"""
from __future__ import annotations
import csv
import logging
from collections import Counter
from pathlib import Path
import click
from rich.console import Console
from rich.table import Table
from remove_ai_watermarks.identify import identify
from remove_ai_watermarks.metadata import _png_late_metadata
log = logging.getLogger(__name__)
console = Console()
# Distinctive, multi-byte provenance markers worth flagging when they appear in a
# file the detector calls `unknown`. Kept long enough that a random collision in a
# (non-scanned) compressed stream is implausible; the metadata-region restriction
# below is the primary guard, this list is the second. Group: C2PA/JUMBF infra,
# AI source-type / labeling schemes, and distinctive generator name strings.
MARKERS: tuple[bytes, ...] = (
# C2PA / JUMBF infrastructure and AI source-type / labeling schemes.
b"c2pa",
b"jumbf",
b"contentauth",
b"trainedAlgorithmicMedia",
b"digitalSourceType",
b'"AIGC"',
b"<TC260:AIGC>",
b"TC260:AIGC",
b"tc260.org.cn",
b"AISystemUsed",
b"SynthID",
b"hf-job-id",
b"genAIType",
b"PhotoEditor_Re_Edit",
b"Signature:",
# Distinctive multi-word generator strings only. Bare single words (Luma,
# Gemini, Sora, ...) are omitted: they collide with unrelated metadata prose
# (e.g. "Luma" in Lightroom's EnhanceDenoiseLumaAmount), defeating precision.
b"Midjourney",
b"Stable Diffusion",
b"StableDiffusion",
b"ComfyUI",
b"Automatic1111",
b"DALL-E",
b"Ideogram AI",
b"Adobe Firefly",
b"Black Forest",
b"volcengine",
b"Doubao",
b"\xe8\xb1\x86\xe5\x8c\x85",
b"Nano Banana",
b"Stability AI",
b"Samsung Galaxy",
)
def _metadata_region(path: Path) -> bytes:
"""Return only the bytes where provenance metadata can live, never the
compressed pixel stream (which produces random short-token collisions)."""
try:
head = path.read_bytes()
except OSError:
return b""
if head[:8] == b"\x89PNG\r\n\x1a\n":
# All ancillary metadata chunks (window=0), via the library's own walker.
return _png_late_metadata(path, 0)
if head[:2] == b"\xff\xd8": # JPEG: APPn segments up to Start-Of-Scan
out = bytearray()
p = 2
n = len(head)
while p + 4 <= n and head[p] == 0xFF:
marker = head[p + 1]
if marker == 0xDA: # SOS -> compressed scan data follows
break
seg_len = (head[p + 2] << 8) | head[p + 3]
out += head[p + 4 : p + 2 + seg_len]
p += 2 + seg_len
return bytes(out)
return head[:65536] # webp/avif/heif/jxl: metadata sits near the head
def _row(rep) -> dict[str, str]: # noqa: ANN001 (ProvenanceReport)
return {
"path": "", # filled by caller (relative)
"is_ai": str(rep.is_ai_generated),
"platform": rep.platform or "",
"confidence": rep.confidence,
"watermarks": "|".join(rep.watermarks),
"signals": "|".join(s.name for s in rep.signals),
"integrity_clashes": "|".join(rep.integrity_clashes),
}
@click.command()
@click.option(
"--corpus",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=Path("data/spaces/originals"),
show_default=True,
help="Directory of images to scan (recursively).",
)
@click.option(
"--report",
type=click.Path(path_type=Path),
default=None,
help="Write the per-file CSV here (default: <corpus>/../detector_report.csv).",
)
@click.option("--limit", type=int, default=0, help="Scan at most N files (0 = all).")
def main(corpus: Path, report: Path | None, limit: int) -> None:
logging.basicConfig(level=logging.WARNING, format="%(message)s")
report = report or corpus.parent / "detector_report.csv"
files = sorted(p for p in corpus.rglob("*") if p.is_file())
if limit:
files = files[:limit]
console.print(f"Scanning [bold]{len(files)}[/bold] files under {corpus} ...")
verdicts: Counter[str] = Counter()
platforms: Counter[str] = Counter()
gap_tokens: Counter[str] = Counter()
gaps: list[tuple[str, list[str]]] = []
rows: list[dict[str, str]] = []
errors = 0
with click.progressbar(files, label="identify") as bar:
for p in bar:
rel = str(p.relative_to(corpus))
try:
rep = identify(p)
except Exception as exc:
log.warning("identify failed on %s: %s", rel, exc)
errors += 1
continue
row = _row(rep)
row["path"] = rel
rows.append(row)
if rep.is_ai_generated:
verdicts["ai"] += 1
platforms[rep.platform or "?"] += 1
continue
verdicts["unknown"] += 1
# A gap candidate is a file identify is *blind* to (no signal at all)
# yet whose metadata carries a known marker. A file that produced a
# signal but no AI verdict (e.g. an ASUS Gallery C2PA signer, which we
# attribute but do not call AI) is handled correctly -- not a gap.
if rep.signals:
continue
region = _metadata_region(p)
hits = sorted({m.decode("latin-1", "replace") for m in MARKERS if m in region})
if hits:
gaps.append((rel, hits))
gap_tokens.update(hits)
with report.open("w", newline="") as f:
writer = csv.DictWriter(
f,
fieldnames=["path", "is_ai", "platform", "confidence", "watermarks", "signals", "integrity_clashes"],
)
writer.writeheader()
writer.writerows(rows)
console.print(f"\nWrote [bold]{len(rows)}[/bold] rows -> {report}")
console.print(f"\n[bold]Verdicts:[/bold] AI {verdicts['ai']} | unknown {verdicts['unknown']} | errors {errors}")
plat = Table(title="AI platforms", show_header=False)
for name, n in platforms.most_common():
plat.add_row(str(n), name)
console.print(plat)
if gaps:
console.print(
f"\n[bold red]Gap candidates[/bold red]: {len(gaps)} unknown files carry a known "
f"marker in their metadata region (potential undetected serialization/generator):"
)
tok = Table(title="markers seen in unknown files")
tok.add_column("count", justify="right")
tok.add_column("marker")
for name, n in gap_tokens.most_common():
tok.add_row(str(n), name)
console.print(tok)
for rel, hits in gaps:
console.print(f" {rel} -> {', '.join(hits)}")
else:
console.print("\n[green]No gap candidates: every unknown file is metadata-free.[/green]")
if __name__ == "__main__":
main()
+150
View File
@@ -0,0 +1,150 @@
"""Multilingual recall benchmark for the text-protection detector.
Measures the core lever of text protection (`text_protector.TextProtector`): if
the PP-OCRv3 DB detector misses a text region, that text is NOT preserved during
the SDXL watermark-removal pass and gets deformed (issue #14). This renders short
text in several scripts at several font sizes on two canvas sizes, runs detection,
and reports the fraction of each known text bbox the detector covers.
Findings (2026-05-29):
- Detection is script-agnostic: DB segments text *regions*, not characters, so
Latin / Cyrillic / CJK / Hangul / Arabic / digits score identically. Language
was never the lever.
- The only lever is resolution. A fixed small detector input downscaled large
canvases so far that small text was missed. Detecting at the native long side
(capped, see ``text_protector._DET_MAX_LONG_SIDE``) lifts overall hit-rate
from 0.91 to 1.00 and the worst cell (~16 px text on a 2048 canvas) from
0.06 to 1.00.
This needs the detector model (downloaded on first use) and a font that covers
all the scripts (macOS "Arial Unicode"; on Linux install a Noto super-font).
No GPU. Run:
uv run python scripts/text_detection_benchmark.py
"""
from __future__ import annotations
import logging
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from rich.console import Console
from remove_ai_watermarks import text_protector as tp
log = logging.getLogger(__name__)
console = Console()
# A single font covering every tested script isolates "language" from "font".
_FONT_CANDIDATES = [
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
"/Library/Fonts/Arial Unicode.ttf",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/unifont/unifont.ttf",
]
SCRIPTS = {
"Latin": "Generated by AI",
"Cyrillic": "Сгенерировано ИИ",
"CJK": "豆包AI生成内容",
"Hangul": "AI로 생성됨",
"Arabic": "أنشئ بالذكاء",
"Digits": "0123456789",
}
FONT_SIZES = [16, 24, 32, 48, 64]
CANVASES = [1024, 2048]
PLACEMENTS = [(0.08, 0.15), (0.30, 0.55), (0.10, 0.82)]
BG_COLORS = [(35, 40, 60), (210, 205, 200)]
def _find_font() -> str:
for path in _FONT_CANDIDATES:
if Path(path).exists():
return path
raise SystemExit(
"No multi-script font found. Install one (macOS ships 'Arial Unicode'; "
"on Linux: a Noto CJK/super font) and add its path to _FONT_CANDIDATES."
)
def _render(
font_path: str,
canvas: int,
text: str,
font_size: int,
place: tuple[float, float],
bg: tuple[int, int, int],
) -> tuple[Image.Image, tuple[int, int, int, int]]:
img = Image.new("RGB", (canvas, canvas), bg)
draw = ImageDraw.Draw(img)
font = ImageFont.truetype(font_path, font_size)
x, y = int(place[0] * canvas), int(place[1] * canvas)
fg = (245, 245, 245) if sum(bg) < 360 else (20, 20, 20)
draw.text((x, y), text, font=font, fill=fg)
return img, draw.textbbox((x, y), text, font=font)
def _coverage(boxes: list[Any], bbox: tuple[int, int, int, int], h: int, w: int) -> float | None:
gt = np.zeros((h, w), np.uint8)
cv2.rectangle(gt, (bbox[0], bbox[1]), (bbox[2], bbox[3]), 1, -1)
area = int(gt.sum())
if area == 0:
return None
det = np.zeros((h, w), np.uint8)
if boxes:
cv2.fillPoly(det, [np.asarray(b, np.int32) for b in boxes], 1)
return int((gt & det).sum()) / area
def _hitrate(values: list[float], thr: float = 0.5) -> float:
return sum(c >= thr for c in values) / len(values) if values else float("nan")
def main() -> int:
logging.basicConfig(level=logging.WARNING)
if not tp.is_available():
raise SystemExit("text detector unavailable (need opencv with cv2.dnn.TextDetectionModel_DB)")
font_path = _find_font()
detector = tp.TextProtector()
by_script_size: dict[tuple[str, int], list[float]] = defaultdict(list)
by_size_canvas: dict[tuple[int, int], list[float]] = defaultdict(list)
by_script: dict[str, list[float]] = defaultdict(list)
for canvas in CANVASES:
for script, text in SCRIPTS.items():
for font_size in FONT_SIZES:
for idx, place in enumerate(PLACEMENTS):
img, bbox = _render(font_path, canvas, text, font_size, place, BG_COLORS[idx % len(BG_COLORS)])
bgr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
cov = _coverage(detector.detect_text_boxes(bgr), bbox, canvas, canvas)
if cov is None:
continue
by_script_size[(script, font_size)].append(cov)
by_size_canvas[(font_size, canvas)].append(cov)
by_script[script].append(cov)
console.print("=== hit-rate (coverage>=0.5) by script x font-size ===")
console.print("script".ljust(10) + "".join(f"{fs:>7}" for fs in FONT_SIZES))
for script in SCRIPTS:
console.print(
script.ljust(10) + "".join(f"{_hitrate(by_script_size[(script, fs)]):>7.2f}" for fs in FONT_SIZES)
)
console.print("\n=== hit-rate by font-size x canvas (the downscale effect) ===")
console.print("size".ljust(8) + "".join(f"{c:>8}" for c in CANVASES))
for fs in FONT_SIZES:
console.print(str(fs).ljust(8) + "".join(f"{_hitrate(by_size_canvas[(fs, c)]):>8.2f}" for c in CANVASES))
overall = _hitrate([c for vals in by_script.values() for c in vals])
console.print(f"\nOVERALL hit-rate: {overall:.2f} (detector max long side = {tp._DET_MAX_LONG_SIDE})")
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -1,3 +1,3 @@
"""Remove-AI-Watermarks: Unified tool for removing visible and invisible AI watermarks."""
__version__ = "0.5.6"
__version__ = "0.7.1"
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

+234 -87
View File
@@ -12,7 +12,7 @@ import json
import logging
import time
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any, Literal
import click
from rich.console import Console
@@ -20,10 +20,11 @@ from rich.panel import Panel
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
from rich.table import Table
from remove_ai_watermarks import __version__
from remove_ai_watermarks import __version__, watermark_registry
from remove_ai_watermarks.noai.watermark_profiles import DEFAULT_STRENGTH
if TYPE_CHECKING:
import numpy as np
from numpy.typing import NDArray
from remove_ai_watermarks.gemini_engine import DetectionResult
@@ -76,7 +77,7 @@ def _watermark_region(det: DetectionResult, width: int, height: int) -> tuple[in
return (px, py, config.logo_size, config.logo_size)
def _read_bgr_and_alpha(path: Path) -> tuple[np.ndarray | None, np.ndarray | None]:
def _read_bgr_and_alpha(path: Path) -> tuple[NDArray[Any] | None, NDArray[Any] | None]:
"""Read an image preserving its alpha channel separately.
Returns ``(bgr, alpha)`` where ``alpha`` is a single-channel ndarray when the
@@ -85,7 +86,9 @@ def _read_bgr_and_alpha(path: Path) -> tuple[np.ndarray | None, np.ndarray | Non
"""
import cv2
image = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
from remove_ai_watermarks import image_io
image = image_io.imread(path, cv2.IMREAD_UNCHANGED)
if image is None:
return None, None
if image.ndim == 2:
@@ -97,37 +100,28 @@ def _read_bgr_and_alpha(path: Path) -> tuple[np.ndarray | None, np.ndarray | Non
def _write_bgr_with_alpha(
path: Path,
bgr: np.ndarray,
alpha: np.ndarray | None,
clear_region: tuple[int, int, int, int] | None = None,
pad: int = 6,
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, writes a
4-channel image. If ``clear_region`` is given as ``(x, y, w, h)``, alpha is
forced to 0 inside that bbox (expanded by ``pad`` px) so the watermark area
becomes fully transparent in the saved file.
When ``alpha`` is provided and the output extension supports it, the original
alpha plane is rejoined unchanged. The watermark region is NOT made
transparent: reverse-alpha (and inpaint) recover real pixels there, so
zeroing alpha would punch a transparent hole that renders as a white box on
any non-transparent viewer (issue #30). Preserving the input alpha keeps
genuinely transparent backgrounds intact without inventing new holes.
"""
import cv2
import numpy as np
from remove_ai_watermarks import image_io
if alpha is None or path.suffix.lower() not in _ALPHA_FORMATS:
cv2.imwrite(str(path), bgr)
image_io.imwrite(path, bgr)
return
alpha_out = alpha
if clear_region is not None:
alpha_out = alpha.copy()
x, y, w, h = clear_region
height, width = alpha.shape[:2]
x0, y0 = max(0, x - pad), max(0, y - pad)
x1, y1 = min(width, x + w + pad), min(height, y + h + pad)
if x1 > x0 and y1 > y0:
alpha_out[y0:y1, x0:x1] = 0
bgra = np.dstack([bgr, alpha_out])
cv2.imwrite(str(path), bgra)
bgra = np.dstack([bgr, alpha])
image_io.imwrite(path, bgra)
# ── Main group ───────────────────────────────────────────────────────
@@ -167,6 +161,13 @@ def main(ctx: click.Context, verbose: bool) -> None:
@click.option("--inpaint-strength", type=float, default=0.85, help="Inpainting blend strength (0.0-1.0).")
@click.option("--detect/--no-detect", default=True, help="Detect watermark before removal.")
@click.option("--detect-threshold", type=float, default=0.25, help="Detection confidence threshold.")
@click.option(
"--mark",
type=click.Choice(["auto", *watermark_registry.mark_keys()]),
default="auto",
help="Which known visible mark to target (auto picks the strongest detected). "
"All marks are removed by exact reverse-alpha against a captured alpha map.",
)
@click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.")
@click.pass_context
def cmd_visible(
@@ -174,17 +175,21 @@ def cmd_visible(
source: Path,
output: Path | None,
inpaint: bool,
inpaint_method: str,
inpaint_method: Literal["ns", "telea", "gaussian"],
inpaint_strength: float,
detect: bool,
detect_threshold: float,
mark: str,
strip_metadata: bool,
) -> None:
"""Remove visible Gemini watermark (sparkle logo) from an image.
"""Remove a known visible AI watermark from an image.
Uses reverse alpha blending — fast, deterministic, offline.
Finds a known mark in its usual place (Gemini sparkle / Doubao text) via the
watermark registry and removes it by exact reverse-alpha against a captured
alpha map -- recovering the true pixels, not an inpaint guess. ``--mark auto``
picks the strongest detected mark. For arbitrary logos/objects, use ``erase``.
"""
from remove_ai_watermarks.gemini_engine import GeminiEngine
from remove_ai_watermarks import watermark_registry as registry
_banner()
source = _validate_image(source)
@@ -192,8 +197,6 @@ def cmd_visible(
if output is None:
output = source.with_stem(source.stem + "_clean")
engine = GeminiEngine()
# Load image (preserving any alpha channel separately)
image, alpha = _read_bgr_and_alpha(source)
if image is None:
@@ -203,44 +206,49 @@ def cmd_visible(
h, w = image.shape[:2]
console.print(f" [dim]Input:[/] {source.name} ({w}x{h})")
# Detection (we always detect softly, to find dynamic region for inpainting)
with console.status("[cyan]Detecting watermark…[/]"):
det = engine.detect_watermark(image)
if detect:
if det.detected:
console.print(
f" [green]✓[/] Watermark detected "
f"[dim](confidence: {det.confidence:.1%}, "
f"spatial: {det.spatial_score:.3f}, "
f"gradient: {det.gradient_score:.3f})[/]"
)
else:
console.print(f" [yellow]⚠[/] Watermark not detected [dim](confidence: {det.confidence:.1%})[/]")
if det.confidence < detect_threshold:
console.print(" [dim]Skipping. Use --no-detect to force removal.[/]")
# Resolve the target mark from the known-watermark registry. ``auto`` scans
# every in-auto mark in its usual place and picks the strongest; an explicit
# ``--mark <key>`` targets that one (the user asserts its presence).
if mark == "auto":
best = registry.best_auto_mark(image)
if best is None:
console.print(" [yellow]⚠[/] No known visible mark detected (gemini / doubao).")
if detect:
console.print(" [dim]Skipping. Use --mark <name> --no-detect to force.[/]")
raise SystemExit(0)
target = "gemini" # forced (no-detect): fall back to the default mark
else:
target = best.key
console.print(f" [dim]Mark auto:[/] {best.label} [dim]({best.location}, conf {best.confidence:.2f})[/]")
else:
target = mark
# Removal
chosen = registry.get_mark(target)
det = chosen.detect(image)
if detect and not det.detected:
console.print(
f" [yellow]⚠[/] {chosen.label} not detected "
f"[dim](conf {det.confidence:.2f}). Use --no-detect to force.[/]"
)
raise SystemExit(0)
if det.detected:
console.print(f" [green]✓[/] {chosen.label} detected [dim]({chosen.location}, conf {det.confidence:.2f})[/]")
method: Literal["telea", "ns"] = "ns" if inpaint_method == "ns" else "telea"
t0 = time.monotonic()
region: tuple[int, int, int, int] | None = None
with console.status("[cyan]Removing watermark…[/]"):
result = engine.remove_watermark(image)
if inpaint:
region = _watermark_region(det, w, h)
result = engine.inpaint_residual(
result,
region,
strength=inpaint_strength,
method=inpaint_method,
)
with console.status(f"[cyan]Removing {chosen.label}… ({chosen.recovery})[/]"):
result, _ = chosen.remove(
image,
inpaint_method=method,
inpaint=inpaint,
inpaint_strength=inpaint_strength,
force=not detect,
)
elapsed = time.monotonic() - t0
# Save (preserves transparency by clearing alpha in the watermark region)
# Save (rejoins the original alpha plane unchanged)
output.parent.mkdir(parents=True, exist_ok=True)
_write_bgr_with_alpha(output, result, alpha, clear_region=region)
_write_bgr_with_alpha(output, result, alpha)
# Strip metadata
if strip_metadata:
@@ -256,6 +264,97 @@ def cmd_visible(
console.print(f" [green]✓[/] Saved: {output} [dim]({size_kb:.0f} KB, {elapsed:.2f}s)[/]")
# ── Universal region eraser ─────────────────────────────────────────
def _parse_region(spec: str) -> tuple[int, int, int, int]:
"""Parse an ``x,y,w,h`` region string into a 4-int tuple."""
parts = spec.replace(" ", "").split(",")
if len(parts) != 4:
raise click.BadParameter(f"region must be 'x,y,w,h', got: {spec!r}")
try:
x, y, w, h = (int(p) for p in parts)
except ValueError as e:
raise click.BadParameter(f"region values must be integers: {spec!r}") from e
if w <= 0 or h <= 0:
raise click.BadParameter(f"region width/height must be positive: {spec!r}")
return x, y, w, h
@main.command("erase")
@click.argument("source", type=click.Path(exists=True, path_type=Path))
@click.option("--region", "regions", multiple=True, required=True, help="x,y,w,h box to erase (repeatable).")
@click.option(
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
)
@click.option(
"--backend",
type=click.Choice(["cv2", "lama"]),
default="cv2",
help="Inpaint backend. cv2: instant, no deps. lama: onnxruntime big-LaMa, better quality (extra 'lama').",
)
@click.option("--inpaint-method", type=click.Choice(["telea", "ns"]), default="telea", help="cv2 inpaint method.")
@click.option("--dilate", type=int, default=3, help="Grow the box by this many px before inpainting.")
@click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.")
@click.pass_context
def cmd_erase(
ctx: click.Context,
source: Path,
regions: tuple[str, ...],
output: Path | None,
backend: Literal["cv2", "lama"],
inpaint_method: str,
dilate: int,
strip_metadata: bool,
) -> None:
"""Erase arbitrary region(s) from an image via inpainting.
Universal and position-agnostic: removes any logo / watermark / object inside
the boxes you pass, regardless of colour or location. Runs on CPU. Use this
for marks the dedicated ``visible`` engines (Gemini, Doubao) do not cover.
"""
from remove_ai_watermarks.region_eraser import erase
_banner()
source = _validate_image(source)
if output is None:
output = source.with_stem(source.stem + "_clean")
boxes = [_parse_region(r) for r in regions]
image, alpha = _read_bgr_and_alpha(source)
if image is None:
console.print(f"[red]Error:[/] Failed to read image: {source}")
raise SystemExit(1)
h, w = image.shape[:2]
console.print(f" [dim]Input:[/] {source.name} ({w}x{h}) [dim]{len(boxes)} region(s), backend={backend}[/]")
t0 = time.monotonic()
method: Literal["telea", "ns"] = "ns" if inpaint_method == "ns" else "telea"
try:
with console.status(f"[cyan]Erasing ({backend})…[/]"):
result = erase(image, boxes=boxes, backend=backend, dilate=dilate, cv2_method=method)
except RuntimeError as e:
console.print(f" [red]Error:[/] {e}")
raise SystemExit(1) from e
elapsed = time.monotonic() - t0
output.parent.mkdir(parents=True, exist_ok=True)
_write_bgr_with_alpha(output, result, alpha)
if strip_metadata:
try:
from remove_ai_watermarks.metadata import remove_ai_metadata
remove_ai_metadata(output, output)
except Exception as e:
if ctx.obj.get("verbose"):
console.print(f" [yellow]⚠[/] Failed to strip metadata: {e}")
size_kb = output.stat().st_size / 1024
console.print(f" [green]✓[/] Erased {len(boxes)} region(s) → {output} [dim]({size_kb:.0f} KB, {elapsed:.2f}s)[/]")
# ── Invisible watermark removal ─────────────────────────────────────
@@ -264,7 +363,7 @@ def cmd_visible(
@click.option(
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
)
@click.option("--strength", type=float, default=0.05, help="Denoising strength (0.0-1.0). Default: 0.05.")
@click.option("--strength", type=float, default=DEFAULT_STRENGTH, help="Denoising strength (0.0-1.0). Default: 0.10.")
@click.option("--steps", type=int, default=50, help="Number of denoising steps. Default: 50.")
@click.option(
"--pipeline",
@@ -272,7 +371,12 @@ def cmd_visible(
default="default",
help="Pipeline profile (default=SDXL, ctrlregen=CtrlRegen).",
)
@click.option("--device", type=click.Choice(["auto", "cpu", "mps", "cuda"]), default="auto", help="Inference device.")
@click.option(
"--device",
type=click.Choice(["auto", "cpu", "mps", "cuda", "xpu"]),
default="auto",
help="Inference device.",
)
@click.option("--seed", type=int, default=None, help="Random seed for reproducibility.")
@click.option("--hf-token", type=str, default=None, help="HuggingFace API token.")
@click.option(
@@ -284,6 +388,12 @@ def cmd_visible(
default=0,
help="Cap long side (px) before diffusion; 0 = native (best quality, like raiw.cc). Raise only on GPU/MPS OOM.",
)
@click.option(
"--no-protect-text",
is_flag=True,
default=False,
help="Disable automatic text protection (text/CJK is preserved by default on the SDXL pipeline).",
)
@click.pass_context
def cmd_invisible(
ctx: click.Context,
@@ -297,6 +407,7 @@ def cmd_invisible(
hf_token: str | None,
humanize: float,
max_resolution: int,
no_protect_text: bool,
) -> None:
"""Remove invisible AI watermarks (SynthID, StableSignature, TreeRing).
@@ -308,7 +419,7 @@ def cmd_invisible(
if not invisible_available():
console.print(
"[red]Error:[/] GPU dependencies not installed.\n"
" Install them with: [bold]pip install 'remove-ai-watermarks[gpu]'[/]"
" Install them with: [bold]pip install 'remove-ai-watermarks\\[gpu]'[/]"
)
raise SystemExit(1)
@@ -343,6 +454,7 @@ def cmd_invisible(
guidance_scale=None,
seed=seed,
humanize=humanize,
protect_text=not no_protect_text,
max_resolution=max_resolution,
)
elapsed = time.monotonic() - t0
@@ -371,14 +483,20 @@ def cmd_metadata(
output: Path | None,
keep_standard: bool,
) -> None:
"""Check or remove AI-generation metadata from images.
"""Check or remove AI-generation metadata (images, video, and audio).
Strips EXIF AI tags, PNG text chunks, and C2PA provenance manifests.
Strips EXIF AI tags, PNG text chunks, C2PA provenance manifests, and the
China TC260 AIGC label. Beyond images (PNG/JPEG/WebP/AVIF/HEIF/JXL) it also
strips provenance metadata from MP4/MOV/M4V/M4A containers and, via ffmpeg,
from WebM/MP3/WAV/FLAC/OGG. The coded image, audio, and video data are left
untouched.
"""
from remove_ai_watermarks.metadata import get_ai_metadata, has_ai_metadata, remove_ai_metadata
# No _validate_image() here: unlike the image-only commands, metadata also
# accepts video/audio containers, so the image-format warning would misfire.
# click's `exists=True` on the argument already enforces the file exists.
_banner()
source = _validate_image(source)
if check or (not remove):
has_ai = has_ai_metadata(source)
@@ -442,6 +560,19 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
console.print(f"\n Verdict: {verdict} [dim](confidence: {report.confidence})[/]")
console.print(f" Platform: {report.platform or '[dim]undetermined[/]'}")
if report.is_ai_generated is None:
console.print(
" [dim]No locally-readable AI signal found. This is not the same as 'clean': "
"metadata is often stripped by re-encoding, screenshots, or upload, and SynthID-class "
"pixel watermarks (Gemini / Nano Banana / gpt-image) have no local detector. "
"See caveats below.[/]"
)
if report.integrity_clashes:
console.print("\n [bold red]⚠ Integrity clash[/] [dim](provenance signals contradict each other)[/]")
for clash in report.integrity_clashes:
console.print(f" [red]- {clash}[/]")
if report.watermarks:
table = Table(show_header=True, header_style="bold", title="Watermarks / provenance markers")
table.add_column("Marker", style="cyan")
@@ -469,7 +600,9 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
@click.option(
"--inpaint-method", type=click.Choice(["ns", "telea", "gaussian"]), default="ns", help="Inpainting method."
)
@click.option("--strength", type=float, default=0.05, help="Invisible watermark denoising strength (0.0-1.0).")
@click.option(
"--strength", type=float, default=DEFAULT_STRENGTH, help="Invisible watermark denoising strength. Default: 0.10."
)
@click.option("--steps", type=int, default=50, help="Number of denoising steps for invisible removal.")
@click.option(
"--pipeline",
@@ -478,7 +611,12 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
help="Pipeline profile (default=SDXL, ctrlregen=CtrlRegen).",
)
@click.option("--model", type=str, default=None, help="HuggingFace model ID for invisible removal.")
@click.option("--device", type=click.Choice(["auto", "cpu", "mps", "cuda"]), default="auto", help="Inference device.")
@click.option(
"--device",
type=click.Choice(["auto", "cpu", "mps", "cuda", "xpu"]),
default="auto",
help="Inference device.",
)
@click.option("--seed", type=int, default=None, help="Random seed for reproducibility.")
@click.option("--hf-token", type=str, default=None, help="HuggingFace API token.")
@click.option(
@@ -490,13 +628,19 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
default=0,
help="Cap long side (px) before diffusion; 0 = native (best quality, like raiw.cc). Raise only on GPU/MPS OOM.",
)
@click.option(
"--no-protect-text",
is_flag=True,
default=False,
help="Disable automatic text protection (text/CJK is preserved by default on the SDXL pipeline).",
)
@click.pass_context
def cmd_all(
ctx: click.Context,
source: Path,
output: Path | None,
inpaint: bool,
inpaint_method: str,
inpaint_method: Literal["ns", "telea", "gaussian"],
strength: float,
steps: int,
pipeline: str,
@@ -506,6 +650,7 @@ def cmd_all(
hf_token: str | None,
humanize: float,
max_resolution: int,
no_protect_text: bool,
) -> None:
"""Remove ALL watermarks: visible + invisible + metadata.
@@ -548,7 +693,6 @@ def cmd_all(
h, w = image.shape[:2]
console.print(f" [dim]Input:[/] {source.name} ({w}x{h})")
region: tuple[int, int, int, int] | None = None
with console.status("[cyan]Removing visible watermark…[/]"):
det = engine.detect_watermark(image)
if det.detected:
@@ -562,7 +706,7 @@ def cmd_all(
console.print(" [dim]Skipped (no visible watermark detected)[/]")
# Save to temp file for invisible engine input (preserve alpha if present)
_write_bgr_with_alpha(tmp_path, result, alpha, clear_region=region)
_write_bgr_with_alpha(tmp_path, result, alpha)
# ── Step 2: Invisible watermark ──────────────────────────────
console.print("\n [bold cyan]② Invisible watermark removal[/]")
@@ -571,7 +715,7 @@ def cmd_all(
if not invisible_available():
console.print(
" [yellow]⚠[/] Skipped — GPU dependencies not installed.\n"
" Install them with: [bold]pip install 'remove-ai-watermarks[gpu]'[/]"
" Install them with: [bold]pip install 'remove-ai-watermarks\\[gpu]'[/]"
)
else:
from remove_ai_watermarks.invisible_engine import InvisibleEngine
@@ -597,6 +741,7 @@ def cmd_all(
num_inference_steps=steps,
seed=seed,
humanize=humanize,
protect_text=not no_protect_text,
max_resolution=max_resolution,
)
console.print(" [green]✓[/] Invisible watermark removed")
@@ -613,14 +758,14 @@ def cmd_all(
# ── Write final result ────────────────────────────────────────
# The invisible step (and downstream cv2.IMREAD_COLOR paths) drops alpha,
# so re-attach the original alpha (with the watermark region cleared)
# when writing the final output for transparent formats.
# so re-attach the original alpha plane unchanged when writing the final
# output for transparent formats.
output.parent.mkdir(parents=True, exist_ok=True)
final_bgr, _ = _read_bgr_and_alpha(tmp_path)
if final_bgr is None:
console.print(f"[red]Error:[/] Failed to read intermediate file: {tmp_path}")
raise SystemExit(1)
_write_bgr_with_alpha(output, final_bgr, alpha, clear_region=region)
_write_bgr_with_alpha(output, final_bgr, alpha)
finally:
# Clean up temp file if it still exists
@@ -659,8 +804,7 @@ def _process_batch_image(
Raises:
ValueError: If the image cannot be opened.
"""
saved_alpha: np.ndarray | None = None
saved_region: tuple[int, int, int, int] | None = None
saved_alpha: NDArray[Any] | None = None
if mode in ("visible", "all"):
from remove_ai_watermarks.gemini_engine import GeminiEngine
@@ -675,7 +819,6 @@ def _process_batch_image(
if image is None:
raise ValueError("Failed to read image")
region: tuple[int, int, int, int] | None = None
det = engine.detect_watermark(image)
if det.detected:
result = engine.remove_watermark(image)
@@ -686,9 +829,8 @@ def _process_batch_image(
else:
result = image.copy()
_write_bgr_with_alpha(out_path, result, alpha, clear_region=region)
_write_bgr_with_alpha(out_path, result, alpha)
saved_alpha = alpha
saved_region = region
if mode in ("invisible", "all"):
from remove_ai_watermarks.invisible_engine import (
@@ -725,7 +867,7 @@ def _process_batch_image(
if mode == "all" and saved_alpha is not None:
final_bgr, _ = _read_bgr_and_alpha(out_path)
if final_bgr is not None:
_write_bgr_with_alpha(out_path, final_bgr, saved_alpha, clear_region=saved_region)
_write_bgr_with_alpha(out_path, final_bgr, saved_alpha)
@main.command("batch")
@@ -752,7 +894,12 @@ def _process_batch_image(
default="default",
help="Pipeline profile (default=SDXL, ctrlregen=CtrlRegen).",
)
@click.option("--device", type=click.Choice(["auto", "cpu", "mps", "cuda"]), default="auto", help="Inference device.")
@click.option(
"--device",
type=click.Choice(["auto", "cpu", "mps", "cuda", "xpu"]),
default="auto",
help="Inference device.",
)
@click.option("--seed", type=int, default=None, help="Random seed for reproducibility.")
@click.option("--hf-token", type=str, default=None, help="HuggingFace API token.")
@click.option(
+386
View File
@@ -0,0 +1,386 @@
"""Doubao visible watermark removal engine.
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.
Like the Gemini sparkle, it is a fixed overlay, so it is removed by **exact
reverse-alpha blending** against a captured alpha map (``remove_watermark_reverse_alpha``):
``original = (wm - a*logo)/(1-a)`` -- recovering the true pixels, not an inpaint
guess. The alpha map + logo colour were solved from black+gray Doubao captures
(see data/doubao_capture/ and the reverse-alpha section below) and bundled as
``assets/doubao_alpha.png``.
Detection (``detect``) is reverse-alpha-consistent: it matches that same alpha
glyph silhouette against the corner via normalized correlation, so it keys on
the actual "豆包AI生成" shape rather than coverage/structure heuristics.
``locate`` (geometry box, scales with image WIDTH) and ``extract_mask`` (the
candidate glyph mask the detector correlates) remain; there is no inpaint-based
removal here -- arbitrary-region inpainting lives in ``region_eraser`` / the
``erase`` command. Fast, offline, no GPU.
"""
# cv2/numpy boundary: third-party libs ship no usable element types; relax the
# unknown-type rules for this file only.
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import cv2
import numpy as np
if TYPE_CHECKING:
from pathlib import Path
from numpy.typing import NDArray
logger = logging.getLogger(__name__)
# Geometry as a fraction of image WIDTH. The Doubao mark scales with width and
# is anchored bottom-right. The box is intentionally generous (the glyph mask
# tightens it); values cover measured outputs across resolutions and aspect
# ratios (square 2048, portrait, ultra-wide). Margins are width-relative too.
WM_WIDTH_FRAC = 0.185
WM_HEIGHT_FRAC = 0.065
MARGIN_RIGHT_FRAC = 0.012
MARGIN_BOTTOM_FRAC = 0.014
# Glyph appearance: the label is a low-saturation light gray, rendered brighter
# than the surrounding content (the common case: a generated photo/illustration).
# We detect it as a local bright feature (white top-hat: brighter than a blurred
# local background) intersected with the grayish + minimum-brightness tests.
# This is polarity-correct for bright-on-darker backgrounds and, crucially,
# leaves white-paper documents untouched (there the mark is not brighter than
# its surroundings, so nothing is masked rather than damaging the document text).
MAX_SATURATION = 55 # max channel spread to count a pixel as "grayish"
LOGO_MIN_LUMA = 150 # glyphs are at least this bright in absolute terms
TOPHAT_DELTA = 12 # glyph must exceed the local background by this many levels
# Detection is reverse-alpha-consistent: the mark is recognized by matching the
# bundled alpha-template glyph silhouette (assets/doubao_alpha.png -- the exact
# shape we invert) against the extracted candidate mask via zero-mean normalized
# correlation (cv2 TM_CCOEFF_NORMED). It keys on the actual "豆包AI生成" glyph
# SHAPE, not on coverage/structure heuristics, so a merely-textured corner does
# not fire (the old coverage detector false-positived on ~28% of images; #23).
# Corpus-tuned: real marks score median ~0.61, arbitrary corners <=0.17 (p99);
# threshold 0.4 -> false positives 7/1243 (0.6%). A small coverage floor skips
# the template match on a near-empty candidate box.
DETECT_MIN_COVERAGE = 0.04
DETECT_NCC_THRESHOLD = 0.4
# ── Reverse-alpha (exact recovery, Gemini-style) ─────────────────────
# The Doubao mark is a fixed semi-transparent white overlay, so given its alpha
# map the original pixels are recovered exactly: original = (wm - a*logo)/(1-a).
# The alpha map + logo colour were solved from black+gray Doubao captures on a
# controlled background (data/doubao_capture/): on black, captured = a*logo, and
# the black/gray pair solves a per-pixel WITHOUT assuming the logo colour. The
# bundled asset (assets/doubao_alpha.png) is the alpha template (a*255) at the
# captured width. The mark scales with image WIDTH, but a pure width-scale is
# only sub-pixel-accurate at the captured width and ghosts elsewhere, so removal
# does NOT trust fixed geometry: `_aligned_alpha_map` registers the template to
# the actual mark by a TM_CCOEFF_NORMED scale+position search, which makes the
# single capture work at any resolution (verified clean on 1773x2364). Verified
# 2026-05-29: white-capture cross-check -> mark vanishes to a flat fill; clean on
# doubao-1.png (2048) and the 3:4 portrait corpus size.
_ALPHA_NATIVE_WIDTH = 2048
_ALPHA_LOGO_BGR: tuple[float, float, float] = (252.0, 255.0, 255.0)
_ALPHA_WIDTH_FRAC = 0.1572 # glyph width / image width -- the alignment scale seed
_ALPHA_HEIGHT_FRAC = 0.0347
# Margins (of image WIDTH) of the captured mark -- the geometry record / where to
# seed; alignment refines the actual position, so these are not load-bearing.
_ALPHA_MARGIN_RIGHT_FRAC = 0.0166
_ALPHA_MARGIN_BOTTOM_FRAC = 0.0195
# Alignment scale search (np.linspace args) around the width-scaled glyph size.
_ALPHA_ALIGN_SEARCH = (0.88, 1.12, 13)
# At (near) the captured width the fixed geometry is pixel-exact, so we use it
# directly there -- NCC alignment is integer-pixel and would land ~1px off,
# degrading the otherwise-exact native recovery. Off this band, alignment wins.
_ALPHA_NATIVE_BAND = 0.03
_alpha_template_cache: NDArray[Any] | None = None
def _alpha_template() -> NDArray[Any] | None:
"""Lazily load the bundled Doubao alpha template (float [0,1]), or None."""
global _alpha_template_cache
if _alpha_template_cache is None:
from pathlib import Path
from remove_ai_watermarks import image_io
path = Path(__file__).parent / "assets" / "doubao_alpha.png"
img = image_io.imread(str(path), cv2.IMREAD_GRAYSCALE)
if img is None:
return None
_alpha_template_cache = img.astype(np.float32) / 255.0
return _alpha_template_cache
@dataclass(frozen=True)
class DoubaoLocation:
"""Located watermark box (bottom-right), in absolute pixel coordinates."""
x: int
y: int
w: int
h: int
is_fallback: bool = True # geometry anchor (no template match) -> always True for now
@property
def bbox(self) -> tuple[int, int, int, int]:
return self.x, self.y, self.w, self.h
@dataclass
class DoubaoDetection:
"""Result of visible Doubao watermark detection."""
detected: bool = False
confidence: float = 0.0
region: tuple[int, int, int, int] = (0, 0, 0, 0)
coverage: float = 0.0 # fraction of the box occupied by glyph pixels
_silhouette_cache: NDArray[Any] | None = None
def _glyph_silhouette() -> NDArray[Any] | None:
"""Binary "豆包AI生成" silhouette (255 = glyph) from the bundled alpha map,
used as the detection template. None if the alpha asset is missing."""
global _silhouette_cache
if _silhouette_cache is None:
at = _alpha_template()
if at is None:
return None
_silhouette_cache = (at > 0.15).astype(np.uint8) * 255
return _silhouette_cache
def _template_match_score(box_mask: NDArray[Any], image_width: int) -> float:
"""Zero-mean normalized correlation of the alpha-template glyph silhouette
(scaled to the mark's expected size) against the candidate ``box_mask``.
TM_CCOEFF_NORMED keys on glyph SHAPE, not coverage, so a dense textured
corner does not score highly -- only the actual "豆包AI生成" shape does.
"""
sil = _glyph_silhouette()
if sil is None or box_mask.size == 0:
return 0.0
gw = min(box_mask.shape[1] - 1, max(8, int(_ALPHA_WIDTH_FRAC * image_width)))
gh = min(box_mask.shape[0] - 1, max(4, int(_ALPHA_HEIGHT_FRAC * image_width)))
if gw < 8 or gh < 4:
return 0.0
template = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_NEAREST)
return float(cv2.matchTemplate(box_mask, template, cv2.TM_CCOEFF_NORMED).max())
class DoubaoEngine:
"""Remove the visible Doubao "豆包AI生成" watermark (locate -> mask -> inpaint)."""
def __init__(
self,
*,
width_frac: float = WM_WIDTH_FRAC,
height_frac: float = WM_HEIGHT_FRAC,
margin_right_frac: float = MARGIN_RIGHT_FRAC,
margin_bottom_frac: float = MARGIN_BOTTOM_FRAC,
) -> None:
self.width_frac = width_frac
self.height_frac = height_frac
self.margin_right_frac = margin_right_frac
self.margin_bottom_frac = margin_bottom_frac
# ── Locate ────────────────────────────────────────────────────────
def locate(self, image: NDArray[Any]) -> DoubaoLocation:
"""Anchor the watermark box in the bottom-right corner by geometry."""
h, w = image.shape[:2]
wm_w = max(40, int(w * self.width_frac))
wm_h = max(16, int(w * self.height_frac))
margin_r = max(4, int(w * self.margin_right_frac))
margin_b = max(4, int(w * self.margin_bottom_frac))
x = max(0, w - margin_r - wm_w)
y = max(0, h - margin_b - wm_h)
wm_w = min(wm_w, w - x)
wm_h = min(wm_h, h - y)
return DoubaoLocation(x=x, y=y, w=wm_w, h=wm_h, is_fallback=True)
# ── Mask ──────────────────────────────────────────────────────────
def extract_mask(self, image: NDArray[Any], loc: DoubaoLocation) -> NDArray[Any]:
"""Build a full-image uint8 mask (255 = watermark glyph) for the box.
Polarity-aware: the mark is a light, low-saturation gray. On a dark
background it is the bright region; on a light background it is the
off-white gray below paper-white. Both cases are captured by the logo
luminance band intersected with the grayish constraint, plus a
brighter-than-local-background test on dark backgrounds.
"""
h, w = image.shape[:2]
x, y, bw, bh = loc.bbox
roi = image[y : y + bh, x : x + bw].astype(np.float32)
luma = roi.mean(axis=2)
sat = roi.max(axis=2) - roi.min(axis=2)
grayish = sat < MAX_SATURATION
# Local background model: a strong Gaussian blur (sigma ~ box height)
# approximates the content under the glyphs. The white top-hat
# (luma - local_bg) lights up bright thin strokes regardless of the
# absolute background level.
sigma = max(4.0, bh * 0.4)
local_bg = cv2.GaussianBlur(luma, (0, 0), sigmaX=sigma, sigmaY=sigma)
tophat = luma - local_bg
cand = grayish & (tophat > TOPHAT_DELTA) & (luma > LOGO_MIN_LUMA)
glyph = cand.astype(np.uint8) * 255
# Connect glyph parts, then drop isolated specks (5x5 open clears the
# scattered grayish pixels that random/textured corners produce).
glyph = cv2.morphologyEx(glyph, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
glyph = cv2.morphologyEx(glyph, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8))
mask = np.zeros((h, w), np.uint8)
mask[y : y + bh, x : x + bw] = glyph
return mask
# ── Detect ────────────────────────────────────────────────────────
def detect(self, image: NDArray[Any]) -> DoubaoDetection:
"""Detect the visible Doubao mark by matching the alpha-template glyph
silhouette against the corner candidate (TM_CCOEFF_NORMED).
Keys on the "豆包AI生成" SHAPE, not coverage, so a textured corner does
not fire. ``confidence`` is the correlation score; ``detected`` is it
clearing ``DETECT_NCC_THRESHOLD``.
"""
det = DoubaoDetection()
if image is None or image.size == 0:
return det
loc = self.locate(image)
mask = self.extract_mask(image, loc)
x, y, bw, bh = loc.bbox
box = mask[y : y + bh, x : x + bw]
coverage = float((box > 0).sum()) / float(max(1, bw * bh))
det.region = loc.bbox
det.coverage = coverage
if coverage >= DETECT_MIN_COVERAGE:
score = _template_match_score(box, image.shape[1])
det.confidence = score
det.detected = score >= DETECT_NCC_THRESHOLD
logger.debug("Doubao detect: coverage=%.3f ncc=%.2f detected=%s", coverage, score, det.detected)
return det
# ── Reverse-alpha (exact recovery) ────────────────────────────────
def reverse_alpha_available(self, image: NDArray[Any]) -> bool:
"""True if the bundled alpha map is loadable. Sub-pixel NCC alignment
(see ``_aligned_alpha_map``) places it on the actual mark at ANY
resolution, so there is no width gate -- the caller still gates on
``detect`` so a clean corner is never touched."""
return image is not None and image.size > 0 and _alpha_template() is not None
def _fixed_alpha_map(self, image: NDArray[Any]) -> tuple[NDArray[Any], tuple[int, int, int, int]] | None:
"""Place the template by fixed width-relative geometry -- pixel-exact at
the captured width (used there instead of integer-pixel NCC alignment)."""
at = _alpha_template()
if at is None:
return None
h, w = image.shape[:2]
gw, gh = max(1, int(_ALPHA_WIDTH_FRAC * w)), max(1, int(_ALPHA_HEIGHT_FRAC * w))
ax = max(0, w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw)
ay = max(0, h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh)
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh), interpolation=cv2.INTER_LINEAR)
return amap, (ax, ay, gw, gh)
def _aligned_alpha_map(self, image: NDArray[Any]) -> tuple[NDArray[Any], tuple[int, int, int, int]] | None:
"""Build a full-image alpha map with the captured template registered to
the actual mark via a TM_CCOEFF_NORMED scale + position search -- so the
single capture works off the captured width (a pure width-scale ghosts).
Returns ``(alpha_map, glyph_bbox)`` or None."""
at = _alpha_template()
sil = _glyph_silhouette()
if at is None or sil is None:
return None
h, w = image.shape[:2]
loc = self.locate(image)
bx, by, bw, bh = loc.bbox
box_mask = self.extract_mask(image, loc)[by : by + bh, bx : bx + bw]
expected = _ALPHA_WIDTH_FRAC * w
best: tuple[float, int, int, int, int] | None = None
for scale in np.linspace(*_ALPHA_ALIGN_SEARCH):
gw, gh = int(expected * scale), int(_ALPHA_HEIGHT_FRAC * w * scale)
if gw < 8 or gh < 4 or gw >= bw or gh >= bh:
continue
t = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_NEAREST)
_, score, _, top_left = cv2.minMaxLoc(cv2.matchTemplate(box_mask, t, cv2.TM_CCOEFF_NORMED))
if best is None or score > best[0]:
best = (score, gw, gh, top_left[0], top_left[1])
if best is None:
return None
_, gw, gh, ox, oy = best
ax, ay = bx + ox, by + oy
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh), interpolation=cv2.INTER_LINEAR)
return amap, (ax, ay, gw, gh)
def _apply_reverse_alpha(self, image: NDArray[Any], amap: NDArray[Any]) -> NDArray[Any]:
"""Invert the alpha blend with ``amap``: ``original = (wm - a*logo)/(1-a)``."""
a3 = np.clip(amap, 0.0, 1.0)[:, :, None]
logo = np.array(_ALPHA_LOGO_BGR, np.float32)
return np.clip((image.astype(np.float32) - a3 * logo) / np.clip(1.0 - a3, 0.25, 1.0), 0, 255).astype(np.uint8)
def remove_watermark_reverse_alpha(self, image: NDArray[Any], *, residual_inpaint: bool = True) -> NDArray[Any]:
"""Recover the original pixels by inverting the alpha blend
``original = (wm - a*logo)/(1-a)``.
Placement: at (near) the captured width the fixed geometry is pixel-exact,
so the recovery is returned UNTOUCHED -- inpainting over exactly-recovered
interior pixels only swaps them for a cv2 hallucination (measured worse on
textured backgrounds: native error vs true bg 1.6 reverse-alpha-only vs
2.6 with full-footprint inpaint). Off-native, NCC alignment registers the
template to the real mark; the alignment is only sub-pixel-approximate, so
the interior recovery is no longer exact and the seam can re-trip the
detector. There we try BOTH placements and keep whichever leaves the least
residual mark (on a faint/busy-background mark the NCC peak can wander a
few px, where geometry wins; on a clear mark alignment wins) -- no magic
threshold, it just picks the better removal -- then a residual inpaint over
the glyph footprint cleans the seam (the interior is approximate anyway, so
inpaint there costs nothing and reliably clears the mark).
Call only when :meth:`reverse_alpha_available` and the mark is detected.
"""
at_native = abs(image.shape[1] / _ALPHA_NATIVE_WIDTH - 1.0) <= _ALPHA_NATIVE_BAND
if at_native:
amap = self._fixed_alpha_map(image)
return self._apply_reverse_alpha(image, amap[0]) if amap is not None else image.copy()
maps = [c for c in (self._fixed_alpha_map(image), self._aligned_alpha_map(image)) if c is not None]
if not maps:
return image.copy()
best_out: NDArray[Any] | None = None
best_amap: NDArray[Any] | None = None
best_residual = float("inf")
for amap, _region in maps:
out = self._apply_reverse_alpha(image, amap)
residual = self.detect(out).confidence
if residual < best_residual:
best_residual, best_out, best_amap = residual, out, amap
if best_out is None or best_amap is None: # pragma: no cover - maps is non-empty
return image.copy()
if residual_inpaint:
rm = cv2.dilate((best_amap > 0.10).astype(np.uint8) * 255, np.ones((3, 3), np.uint8))
best_out = cv2.inpaint(best_out, rm, 3, cv2.INPAINT_TELEA)
return best_out
def load_image_bgr(path: str | Path) -> NDArray[Any]:
"""Read an image as BGR ndarray (helper for scripts/tests)."""
from remove_ai_watermarks import image_io
img = image_io.imread(path, cv2.IMREAD_COLOR)
if img is None:
raise FileNotFoundError(f"Failed to read image: {path}")
return img
@@ -1,5 +1,8 @@
"""YOLO-based face detection and soft-blend restoration for diffusion pipelines."""
# cv2/numpy/ultralytics boundary: these libs ship no usable element types; relax
# the unknown-type rules for this file only.
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false, reportPossiblyUnboundVariable=false
import logging
from pathlib import Path
+21 -15
View File
@@ -13,13 +13,17 @@ The alpha maps are derived from background captures of the Gemini watermark
on pure-black backgrounds (48x48 for small images, 96x96 for large images).
"""
# cv2/numpy boundary: cv2 and numpy ship no usable type info for the array ops
# below, so strict pyright cannot know their 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, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
from __future__ import annotations
import logging
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING, Any, Literal
import cv2
import numpy as np
@@ -86,7 +90,7 @@ def get_watermark_size(width: int, height: int) -> WatermarkSize:
return WatermarkSize.SMALL
def _calculate_alpha_map(bg_capture: NDArray) -> NDArray:
def _calculate_alpha_map(bg_capture: NDArray[Any]) -> NDArray[Any]:
"""Calculate alpha map from a background capture.
The alpha map represents how much the watermark affects each pixel.
@@ -103,7 +107,7 @@ def _calculate_alpha_map(bg_capture: NDArray) -> NDArray:
return gray / 255.0
def _load_embedded_asset(name: str) -> NDArray:
def _load_embedded_asset(name: str) -> NDArray[Any]:
"""Load an embedded PNG asset and decode it with OpenCV."""
asset_path = Path(__file__).parent / "assets" / name
if not asset_path.exists():
@@ -151,13 +155,13 @@ class GeminiEngine:
self._alpha_large.shape,
)
def get_alpha_map(self, size: WatermarkSize) -> NDArray:
def get_alpha_map(self, size: WatermarkSize) -> NDArray[Any]:
"""Get the base alpha map for a specific standard size."""
if size == WatermarkSize.SMALL:
return self._alpha_small
return self._alpha_large
def get_interpolated_alpha(self, size_px: int) -> NDArray:
def get_interpolated_alpha(self, size_px: int) -> NDArray[Any]:
"""Create an interpolated alpha map dynamically scaled from the high-res 96x96 base."""
source = self._alpha_large
if size_px == source.shape[1]:
@@ -170,7 +174,7 @@ class GeminiEngine:
def detect_watermark(
self,
image: NDArray,
image: NDArray[Any],
force_size: WatermarkSize | None = None,
) -> DetectionResult:
"""Detect Gemini watermark using multi-scale Snap Engine logic (ported from C++ vendor algorithm)."""
@@ -304,9 +308,9 @@ class GeminiEngine:
def remove_watermark(
self,
image: NDArray,
image: NDArray[Any],
force_size: WatermarkSize | None = None,
) -> NDArray:
) -> NDArray[Any]:
"""Remove Gemini visible watermark from an image using reverse alpha blending.
No-op when the detector does not find a watermark: returns an unmodified
@@ -359,9 +363,9 @@ class GeminiEngine:
def remove_watermark_custom(
self,
image: NDArray,
image: NDArray[Any],
region: tuple[int, int, int, int],
) -> NDArray:
) -> NDArray[Any]:
"""Remove watermark from a custom region with interpolated alpha map.
Args:
@@ -390,8 +394,8 @@ class GeminiEngine:
def _reverse_alpha_blend(
self,
image: NDArray,
alpha_map: NDArray,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> None:
"""Apply reverse alpha blending in-place.
@@ -442,13 +446,13 @@ class GeminiEngine:
def inpaint_residual(
self,
image: NDArray,
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:
) -> NDArray[Any]:
"""Apply inpaint cleanup on residual artifacts after reverse alpha blend.
Uses a sparse mask derived from alpha map gradient to repair only
@@ -556,7 +560,9 @@ def detect_sparkle_confidence(image_path: Path) -> float | None:
(cv2 returns None for unsupported containers such as HEIC). Kept here so the
cv2 dependency stays in this module; callers apply their own threshold.
"""
img = cv2.imread(str(image_path))
from remove_ai_watermarks import image_io
img = image_io.imread(image_path)
if img is None:
return None
return float(GeminiEngine().detect_watermark(img).confidence)
+3
View File
@@ -4,6 +4,9 @@ Simulates analog film imperfections to defeat digital AI perfection
classifiers. Ported from NeuralBleach.
"""
# cv2/numpy boundary: third-party libs ship no usable element types; relax the
# unknown-type rules for this file only.
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
import cv2
import numpy as np
from numpy.typing import NDArray
+322 -17
View File
@@ -25,14 +25,19 @@ from typing import TYPE_CHECKING
from remove_ai_watermarks.metadata import (
AI_METADATA_KEYS,
AIGC_MARKERS,
C2PA_UUID,
IPTC_AI_FIELD_MARKERS,
IPTC_AI_MARKERS,
aigc_label,
c2pa_marker_in,
exif_generator,
get_ai_metadata,
huggingface_job,
iptc_ai_system,
samsung_genai,
scan_head,
xai_signature,
)
from remove_ai_watermarks.noai.c2pa import extract_c2pa_info
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_ISSUERS
if TYPE_CHECKING:
@@ -61,6 +66,8 @@ _ISSUER_PLATFORM: tuple[tuple[str, str], ...] = (
("OpenAI", "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)"),
("Google", "Google (Gemini / Imagen)"),
("Stability AI", "Stability AI (Stable Image / DreamStudio)"),
("Black Forest Labs", "Black Forest Labs (FLUX)"),
("ByteDance", "ByteDance (Doubao / Jimeng / Volcano Engine)"),
)
# PNG-text / EXIF keys that indicate a local diffusion pipeline (vs. a hosted
@@ -86,6 +93,17 @@ _INVISIBLE_WM_CAVEAT = (
"The open invisible watermark is fragile: it does not survive JPEG re-encoding "
"or resizing, so it confirms origin only on a pristine (un-re-encoded) file."
)
_HF_JOB_CAVEAT = (
"The hf-job-id tag marks a HuggingFace-hosted job (commonly diffusion "
"generation) but names neither the model nor the content type, so it is a "
"medium-confidence signal, not proof the pixels are AI-generated."
)
_SAMSUNG_GENAI_CAVEAT = (
"Samsung's genAIType marker shows a Galaxy AI editing tool (Generative Edit, "
"Sketch to Image, ...) touched the image; it is an undocumented proprietary "
"field, so it is a medium-confidence signal of AI editing, not proof the "
"whole image is AI-generated."
)
@dataclass
@@ -108,6 +126,11 @@ class ProvenanceReport:
watermarks: list[str] = field(default_factory=list[str])
signals: list[Signal] = field(default_factory=list["Signal"])
caveats: list[str] = field(default_factory=list[str])
# Contradictions between independent provenance signals (e.g. two different
# AI vendors both claiming the image, or camera-capture credentials next to
# AI-generation markers). Non-empty means the provenance is internally
# inconsistent -- a strong tell of spoofed, transplanted, or laundered metadata.
integrity_clashes: list[str] = field(default_factory=list[str])
def _issuers_in(data: bytes) -> list[str]:
@@ -125,17 +148,177 @@ def _ai_tools_in(data: bytes) -> list[str]:
return sorted({name for sig, name in C2PA_AI_TOOLS.items() if sig in data})
def _attribute_platform(issuers: list[str]) -> str | None:
"""Map a set of C2PA issuer names to a human-readable generating platform."""
joined = " ".join(issuers)
for needle, platform in _ISSUER_PLATFORM:
if needle in joined:
# Distinctive C2PA device/camera tokens (cert CN, cert org, or claim-generator
# substrings) scanned in the manifest bytes -> platform. This is more reliable
# than mapping an issuer name (which also matches incidental mentions: a
# timestamp authority like "Truepic" in a Leica chain, an XMP-toolkit "Adobe"
# string in a Nikon file, or "Google" in a Pixel camera's cert -- all verified
# on real samples), and more robust than parsing the claim generator (which
# lives under varying CBOR keys, e.g. `claim_generator` vs `claim_generator_info`,
# and is absent on the Pixel sample where only the cert CN "Pixel Camera"
# identifies it). Camera C2PA marks CAPTURE authenticity, not AI, so these never
# assert is_ai on their own (the verdict still comes from the digital-source-type:
# the Pixel sample carries `computationalCapture`, not `trainedAlgorithmicMedia`).
# Only tokens verified against a real signed file are listed (Leica, Nikon,
# Sony, Truepic, Google Pixel); add Canon/Bria as real samples are captured.
# Samsung Galaxy is an AI-capable editing device, not a pure-capture camera, so
# it lives in `_SIGNER_C2PA_PLATFORM` below (it must not feed the camera clash).
_DEVICE_C2PA_PLATFORM: tuple[tuple[bytes, str], ...] = (
(b"lc_c2pa", "Leica (camera, C2PA capture)"),
(b"Leica Camera", "Leica (camera, C2PA capture)"),
(b"NIKON", "Nikon (camera, C2PA capture)"),
(b"Pixel Camera", "Google Pixel (camera, C2PA capture)"),
# Sony uses its own ``sony.*`` C2PA assertion namespace (sony.sig / sony.cert);
# match that, NOT bare "Sony" (which is an EXIF Make on countless photos).
# Verified on a real Sony-signed file (Sony PXW-Z300, signer "Sony Corporation").
(b"sony.sig", "Sony (camera, C2PA capture)"),
(b"sony.cert", "Sony (camera, C2PA capture)"),
# "Truepic_Lens" (from the Lens SDK claim generator), NOT bare "Truepic" --
# Truepic is a C2PA signing authority whose name appears in the trust chain
# of unrelated manifests (e.g. OpenAI), so the bare token mis-attributes.
(b"Truepic_Lens", "Truepic Lens (verified capture)"),
)
def _device_platform(head: bytes) -> str | None:
"""Map a distinctive C2PA device/camera token in the manifest bytes to a platform."""
for token, platform in _DEVICE_C2PA_PLATFORM:
if token in head:
return platform
return None
# C2PA signers that are an editing app or AI-capable device rather than a
# verified-capture camera. Unlike `_DEVICE_C2PA_PLATFORM`, these do NOT feed the
# camera-vs-AI integrity clash (rule 2 in `_integrity_clashes`): a Galaxy phone
# legitimately stamps BOTH its device credentials AND a `trainedAlgorithmicMedia`
# source type on a Generative-Edit image, so treating it as a "genuine camera
# capture" would false-flag every Galaxy AI edit. They only resolve the platform
# label; the AI verdict still comes from the digital-source-type / genAIType.
# Tokens verified against real signed files (2026-05-29):
# Samsung Galaxy -- cert org on Galaxy S23 FE / S24 / S25 C2PA JPEGs/PNGs
# (distinct from the EXIF "SM-xxxx" model string on ordinary Samsung photos).
# com.asus.gallery -- ASUS Gallery claim_generator (a C2PA-signed edit, no AI
# source type or genAIType on the samples, so it never asserts is_ai).
_SIGNER_C2PA_PLATFORM: tuple[tuple[bytes, str], ...] = (
(b"Samsung Galaxy", "Samsung Galaxy (C2PA)"),
(b"com.asus.gallery", "ASUS Gallery (C2PA signer)"),
)
def _signer_platform(head: bytes) -> str | None:
"""Map a C2PA editing-app / AI-capable-device signer token to a platform."""
for token, platform in _SIGNER_C2PA_PLATFORM:
if token in head:
return platform
return None
def _attribute_platform(issuers: list[str], *, is_ai: bool = True) -> str | None:
"""Map a set of C2PA issuer names to a human-readable generating platform.
A specific AI-generator platform (Adobe Firefly, OpenAI, ...) is named only
when the content is actually AI (``is_ai``, i.e. digital-source-type
``trainedAlgorithmicMedia``). Otherwise an issuer-name byte match is likely
incidental -- e.g. an "Adobe XMP" toolkit string in a Canon/Sony camera
capture, or a "Google" cert org -- so we fall back to a neutral signer label
rather than mislabel a camera photo as "Adobe Firefly". Real Firefly/OpenAI/
Google AI output carries the AI source-type, so it is unaffected. ``is_ai``
defaults True so the issuer->platform mapping can still be unit-tested in
isolation; ``identify`` passes the file's actual ``c2pa_is_ai``.
"""
joined = " ".join(issuers)
if is_ai:
for needle, platform in _ISSUER_PLATFORM:
if needle in joined:
return platform
if issuers: # e.g. Truepic alone -- a signing authority, not a generator
return f"C2PA signer: {', '.join(issuers)} (no known AI generator named)"
return None
# Coarse origin-vendor normalization for integrity-clash detection. Two signals
# that resolve to the SAME key are consistent (a C2PA "Google (Gemini)" issuer
# and a SynthID-Google proxy, or Adobe Firefly + its Adobe TrustMark soft
# binding); two DIFFERENT keys from independent generator stamps are a
# contradiction (a C2PA OpenAI manifest on an image whose EXIF says "Ideogram
# AI"). Substring match on the lowercased platform/detail string; first hit wins,
# so order specific tokens before brand umbrellas where they overlap.
_AI_VENDOR_TOKENS: tuple[tuple[str, str], ...] = (
("gpt-image", "OpenAI"),
("dall", "OpenAI"),
("sora", "OpenAI"),
("openai", "OpenAI"),
("gemini", "Google"),
("imagen", "Google"),
("nano banana", "Google"),
("google", "Google"),
("firefly", "Adobe"),
("adobe", "Adobe"),
("bing", "Microsoft"),
("designer", "Microsoft"),
("microsoft", "Microsoft"),
("stability", "Stability AI"),
("stable diffusion", "Stability AI"),
("sdxl", "Stability AI"),
("ideogram", "Ideogram"),
("grok", "xAI"),
("aurora", "xAI"),
("xai", "xAI"),
)
def _vendor_of(text: str | None) -> str | None:
"""Normalize a platform/generator string to a coarse origin-vendor key, or None."""
if not text:
return None
low = text.lower()
for token, vendor in _AI_VENDOR_TOKENS:
if token in low:
return vendor
return None
def _integrity_clashes(
ai_vendors: dict[str, str], camera_label: str | None, *, camera_has_ai_marker: bool
) -> list[str]:
"""Surface contradictions between independent provenance signals.
Args:
ai_vendors: family name -> normalized AI-origin vendor, one entry per
generator-stamped signal (C2PA issuer when the source is AI, SynthID
proxy, EXIF/XMP generator tag, IPTC AISystemUsed, xAI, AIGC label).
camera_label: a camera/verified-capture C2PA device platform, if one was
identified (Pixel, Leica, Sony, Nikon, Truepic), else None.
camera_has_ai_marker: True when an AI-generation stamp coexists with the
camera credentials.
Returns:
Human-readable clash descriptions; empty when the signals agree.
"""
clashes: list[str] = []
by_vendor: dict[str, list[str]] = {}
for family, vendor in ai_vendors.items():
by_vendor.setdefault(vendor, []).append(family)
if len(by_vendor) >= 2:
parts = [f"{vendor} (via {', '.join(sorted(fams))})" for vendor, fams in sorted(by_vendor.items())]
clashes.append(
"Conflicting AI-origin attributions from independent signals: "
+ " vs ".join(parts)
+ " -- one provenance set was likely spoofed, transplanted, or laundered."
)
if camera_label and camera_has_ai_marker:
vendors = ", ".join(sorted(set(ai_vendors.values()))) or "present"
clashes.append(
f"Camera-capture C2PA credentials ({camera_label}) coexist with AI-generation markers "
f"({vendors}) -- a genuine camera capture is not AI-generated, so the provenance is inconsistent."
)
return clashes
def _visible_sparkle(image_path: Path) -> float | None:
"""Visible Gemini-sparkle confidence in [0, 1], or None if unavailable.
@@ -162,6 +345,17 @@ def _invisible_watermark(image_path: Path) -> str | None:
return detect_invisible_watermark(image_path)
def _trustmark(image_path: Path) -> str | None:
"""Adobe TrustMark scheme name or None.
Optional: needs the ``trustmark`` decoder (extra ``trustmark``). Returns None
if it is not installed or no TrustMark watermark decodes.
"""
from remove_ai_watermarks.trustmark_detector import detect_trustmark
return detect_trustmark(image_path)
def identify(image_path: Path, *, check_visible: bool = True, check_invisible: bool = True) -> ProvenanceReport:
"""Identify an image's origin platform and watermark inventory.
@@ -182,26 +376,53 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
# First MB covers C2PA (PNG caBX, JPEG APP11, AVIF/HEIF/JXL uuid box) and
# IPTC markers for the non-PNG path where extract_c2pa_info returns {}.
with open(image_path, "rb") as f:
head = f.read(_SCAN_BYTES)
# scan_head also seeks out late ISOBMFF provenance boxes (manifest after a
# large mdat in a streaming MP4) that a fixed first-MB read would miss.
head = scan_head(image_path, _SCAN_BYTES)
signals: list[Signal] = []
watermarks: list[str] = []
caveats: list[str] = []
# One normalized origin vendor per generator-stamped signal, for integrity-
# clash detection (see _integrity_clashes). Visible sparkle and the open
# invisible watermark are deliberately excluded: the former is a fuzzy visual
# score, the latter can be a by-product of our own SDXL removal pass, so
# neither is a trustworthy "the generator stamped its identity" claim.
ai_vendor_claims: dict[str, str] = {}
camera_label = _device_platform(head)
signer_label = _signer_platform(head)
# ── C2PA Content Credentials ────────────────────────────────────
has_c2pa = bool(info) or b"c2pa" in head.lower() or C2PA_UUID in head
has_c2pa = bool(info) or c2pa_marker_in(head)
issuers = [info["issuer"]] if info.get("issuer") else _issuers_in(head)
platform = _attribute_platform(issuers) if has_c2pa else None
c2pa_is_ai = "trainedAlgorithmicMedia" in info.get("source_type", "") or any(
m in head for m in (b"trainedAlgorithmicMedia", b"compositeWithTrainedAlgorithmicMedia")
)
# Generator: structured for PNG, binary-scanned for other containers.
generator = info.get("claim_generator") or (", ".join(tools) if (tools := _ai_tools_in(head)) else None)
# 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
# identified by `_device_platform`.
generator = (
info.get("claim_generator")
or cbor_text_after(head, b"claim_generator")
or (", ".join(tools) if (tools := _ai_tools_in(head)) else None)
)
# Platform: a distinctive device/camera token in the manifest wins (it is the
# signer/producer), then an editing-app/AI-device signer (Samsung Galaxy,
# ASUS Gallery), with the issuer byte-scan only as fallback. The issuer scan
# alone mis-attributed real samples (Leica->Truepic timestamp authority,
# Nikon->Adobe namespace, Pixel->Google Gemini) -- the token scans fix that.
platform = (camera_label or signer_label or _attribute_platform(issuers, is_ai=c2pa_is_ai)) if has_c2pa else None
if has_c2pa:
detail = ", ".join(filter(None, [", ".join(issuers), generator, info.get("source_type")]))
signals.append(Signal("c2pa", detail or "C2PA manifest present", "high"))
watermarks.append(f"C2PA Content Credentials ({', '.join(issuers) or 'unknown signer'})")
# Record the AI-origin vendor for clash detection only when the source is
# actually AI -- classify the issuer attribution / generator, NOT the
# resolved `platform` (which may be a camera device token whose label,
# e.g. "Google Pixel", would mis-normalize to an AI vendor).
if c2pa_is_ai and (v := (_vendor_of(_attribute_platform(issuers, is_ai=True)) or _vendor_of(generator))):
ai_vendor_claims["c2pa"] = v
# ── SynthID metadata proxy ──────────────────────────────────────
# get_ai_metadata already sets synthid_watermark for both PNG (caBX parser)
@@ -212,6 +433,16 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
caveats.append(_SYNTHID_CAVEAT)
if "OpenAI" in (" ".join(issuers) + synthid):
caveats.append(_OPENAI_CAVEAT)
if v := _vendor_of(synthid):
ai_vendor_claims["synthid"] = v
# ── C2PA soft-binding: a named forensic/third-party watermark vendor ─
# (Adobe TrustMark, Digimarc, Imatag, ...). Present in the manifest even when
# the watermark itself can't be decoded; names whose watermark stamped the pixels.
soft_binding = meta.get("soft_binding") or (", ".join(v) if (v := soft_binding_vendors_in(head)) else None)
if soft_binding:
signals.append(Signal("soft_binding", f"C2PA soft binding: {soft_binding}", "high"))
watermarks.append(f"Forensic watermark soft binding ({soft_binding})")
# ── IPTC "Made with AI" (Meta etc.), only meaningful without C2PA ─
iptc = any(m in head for m in IPTC_AI_MARKERS)
@@ -222,14 +453,34 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
if platform is None:
platform = "Made-with-AI tag (e.g. Meta AI); platform not specified"
# ── IPTC 2025.1 AI-disclosure fields (Iptc4xmpExt:AISystemUsed etc.) ─
iptc_ai = any(m in head for m in IPTC_AI_FIELD_MARKERS)
if iptc_ai:
system = iptc_ai_system(image_path)
named = bool(system) and system != "fields present"
signals.append(
Signal("iptc_ai_system", f"IPTC AI disclosure ({system})" if named else "IPTC AI disclosure fields", "high")
)
watermarks.append(f"IPTC 2025.1 AI disclosure ({system})" if named else "IPTC 2025.1 AI disclosure fields")
if platform is None and named:
platform = f"{system} (IPTC AISystemUsed)"
if named and (v := _vendor_of(system)):
ai_vendor_claims["iptc_ai_system"] = v
# ── China TC260 AIGC label (Doubao and other China-served gens) ──
aigc = any(m in head for m in AIGC_MARKERS)
# Fire on either the namespaced byte marker (``TC260:AIGC`` / the TC260 ns
# URL, present in XMP and as a laundering tell even when the JSON payload is
# truncated) OR the parsed label, which additionally catches the raw-JSON
# PNG ``AIGC`` tEXt chunk that carries no namespaced marker at all.
aigc_data = aigc_label(image_path)
aigc = aigc_data is not None or any(m in head for m in AIGC_MARKERS)
if aigc:
producer = (aigc_label(image_path) or {}).get("ContentProducer", "")
producer = (aigc_data or {}).get("ContentProducer", "")
signals.append(Signal("aigc", f"TC260 AIGC label{f' (producer {producer})' if producer else ''}", "high"))
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)"
# ── Local diffusion parameters (Stable Diffusion / ComfyUI) ──────
local_keys = sorted(k for k in meta if k.lower() in _LOCAL_GEN_KEYS)
@@ -246,6 +497,8 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
watermarks.append(f"Embedded generator tag: {generator_tag}")
if platform is None:
platform = f"{generator_tag} (EXIF/XMP generator tag)"
if v := _vendor_of(generator_tag):
ai_vendor_claims["exif_generator"] = v
# ── xAI / Grok EXIF signature scheme (no C2PA/SynthID/IPTC) ──────
# Grok's only provenance signal: EXIF ImageDescription "Signature: <base64>"
@@ -256,6 +509,35 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
watermarks.append("xAI/Grok EXIF signature")
if platform is None:
platform = "xAI (Grok / Aurora)"
ai_vendor_claims["xai"] = "xAI"
# ── HuggingFace-hosted job marker (hf-job-id PNG text chunk) ─────
# Marks the hosting job, not a model -- medium confidence (commonly diffusion
# output). Like the visible sparkle, it lifts an otherwise-Unknown verdict to
# a tentative AI, but never overrides a high-confidence metadata signal.
hf_job = huggingface_job(image_path)
if hf_job:
signals.append(Signal("hf_job", f"HuggingFace job {hf_job}", "medium"))
watermarks.append("HuggingFace-hosted job (hf-job-id)")
caveats.append(_HF_JOB_CAVEAT)
if platform is None:
platform = "HuggingFace-hosted job (model not identified)"
# ── Samsung Galaxy AI editing marker (genAIType) ─────────────────
# Galaxy AI tools stamp a proprietary genAIType in PhotoEditor_Re_Edit_Data.
# Medium confidence: it co-occurs with the C2PA trainedAlgorithmicMedia type
# on Galaxy files that record one, and is the SOLE AI marker on a Galaxy S24
# sample that omits the source type -- so it lifts an otherwise-Unknown
# verdict, but the field is undocumented, so it never overrides a high-
# confidence signal. The platform is usually already "Samsung Galaxy" via the
# signer-token scan; the fallback covers a future file without the cert org.
samsung_genai_type = samsung_genai(image_path)
if samsung_genai_type is not None:
signals.append(Signal("samsung_genai", f"Samsung genAIType={samsung_genai_type}", "medium"))
watermarks.append("Samsung Galaxy AI editing marker (genAIType)")
caveats.append(_SAMSUNG_GENAI_CAVEAT)
if platform is None:
platform = "Samsung Galaxy (Galaxy AI editing)"
# ── Open invisible watermark (SD / SDXL / FLUX, dwtDct) ──────────
# Public decoder, no key -- a definitive embedded signal on pristine files.
@@ -266,12 +548,29 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
if platform is None:
platform = f"{scheme} (open DWT-DCT watermark)"
# ── Adobe TrustMark invisible watermark (open decoder, no key) ───
# The watermark behind Adobe Durable Content Credentials. Decoded locally,
# but it binds provenance for human-authored content too, so it enriches the
# watermark inventory without by itself asserting AI origin.
if check_invisible and (tm_scheme := _trustmark(image_path)) is not None:
signals.append(Signal("trustmark", tm_scheme, "high"))
watermarks.append(f"Adobe TrustMark invisible watermark ({tm_scheme})")
if platform is None:
platform = "Adobe (TrustMark / Content Credentials)"
# ── Verdict so far (metadata + embedded watermark) ──────────────
invisible_wm = any(s.name == "invisible_watermark" for s in signals)
exif_gen = any(s.name == "exif_generator" for s in signals)
xai_sig = any(s.name == "xai_signature" for s in signals)
ai_from_metadata = bool(
(has_c2pa and (c2pa_is_ai or synthid)) or iptc or aigc or local_keys or invisible_wm or exif_gen or xai_sig
(has_c2pa and (c2pa_is_ai or synthid))
or iptc
or iptc_ai
or aigc
or local_keys
or invisible_wm
or exif_gen
or xai_sig
)
# ── Visible Gemini sparkle (fallback for stripped-metadata case) ─
@@ -282,17 +581,22 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
platform = "Google Gemini family (visible sparkle detected)"
visible_only = any(s.name == "visible_sparkle" for s in signals) and not ai_from_metadata
hf_only = bool(hf_job) and not ai_from_metadata
samsung_only = samsung_genai_type is not None and not ai_from_metadata
if ai_from_metadata:
is_ai: bool | None = True
confidence = "high"
elif visible_only:
elif visible_only or hf_only or samsung_only:
is_ai = True
confidence = "medium"
else:
is_ai = None
confidence = "none"
# ── Integrity clashes: contradictions between independent signals ─
clashes = _integrity_clashes(ai_vendor_claims, camera_label, camera_has_ai_marker=bool(ai_vendor_claims))
caveats.append(_STRIP_CAVEAT)
# De-duplicate while preserving order.
caveats = list(dict.fromkeys(caveats))
@@ -305,4 +609,5 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
watermarks=watermarks,
signals=signals,
caveats=caveats,
integrity_clashes=clashes,
)
+66
View File
@@ -0,0 +1,66 @@
"""Unicode-safe cv2 image IO (issue #17).
``cv2.imread`` / ``cv2.imwrite`` pass the path to the platform C runtime, which
on Windows uses the narrow (ANSI) code-page API and therefore fails on paths
containing non-ASCII characters (Chinese, Cyrillic, ...). The symptom is a
``can't open/read file`` warning and a ``None`` decode even though the file
exists.
These wrappers route through numpy buffers instead: ``np.fromfile`` /
``ndarray.tofile`` open the path in Python (full Unicode), and
``cv2.imdecode`` / ``cv2.imencode`` do the codec work. The decoded/encoded
bytes are byte-for-byte identical to ``imread`` / ``imwrite``. On macOS/Linux
cv2 already accepts UTF-8 paths, so the wrappers are behavior-neutral there.
cv2/numpy are imported lazily inside the functions so importing this module
stays cheap in a bare environment (matching the rest of the package).
"""
# cv2 ships no type stubs; mirror the pragma used by the other cv2-using modules.
# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from numpy.typing import NDArray
def imread(path: str | Path, flags: int | None = None) -> NDArray[Any] | None:
"""Unicode-safe ``cv2.imread``.
``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.
"""
import cv2
import numpy as np
if flags is None:
flags = cv2.IMREAD_COLOR
try:
data = np.fromfile(str(path), dtype=np.uint8)
except OSError:
return None
if data.size == 0:
return None
return cv2.imdecode(data, flags)
def imwrite(path: str | Path, img: NDArray[Any]) -> bool:
"""Unicode-safe ``cv2.imwrite``.
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.
"""
import cv2
ext = Path(path).suffix or ".png"
ok, buf = cv2.imencode(ext, img)
if not ok:
return False
buf.tofile(str(path))
return True
+24 -13
View File
@@ -7,6 +7,10 @@ This module requires the 'gpu' extra dependencies:
uv pip install 'remove-ai-watermarks[gpu]'
"""
# cv2/torch boundary: this engine wraps cv2 (resize/imwrite/cvtColor), the YOLO
# face protector, and the humanizer, none of which carry usable element types;
# relax the unknown-type rules for this file only.
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
from __future__ import annotations
import logging
@@ -15,6 +19,8 @@ import warnings
from pathlib import Path
from typing import TYPE_CHECKING
from remove_ai_watermarks.noai.watermark_profiles import DEFAULT_STRENGTH
if TYPE_CHECKING:
from collections.abc import Callable
@@ -33,13 +39,9 @@ logger = logging.getLogger(__name__)
def is_available() -> bool:
"""Check if invisible watermark removal dependencies are installed."""
try:
import diffusers # noqa: F401
import torch # noqa: F401
import importlib.util
return True
except ImportError:
return False
return importlib.util.find_spec("diffusers") is not None and importlib.util.find_spec("torch") is not None
def _target_size(width: int, height: int, max_resolution: int) -> tuple[int, int] | None:
@@ -88,7 +90,7 @@ class InvisibleEngine:
Args:
model_id: HuggingFace model ID. None = use default for pipeline.
device: Device for inference (auto/cpu/mps/cuda). None = auto.
device: Device for inference (auto/cpu/mps/cuda/xpu). None = auto.
pipeline: Pipeline profile. "default" (SDXL base, defeats SynthID
v2) or "ctrlregen" (CtrlRegen).
hf_token: HuggingFace API token.
@@ -125,6 +127,7 @@ class InvisibleEngine:
seed: int | None = None,
humanize: float = 0.0,
protect_faces: bool = True,
protect_text: bool = True,
max_resolution: int = 0,
) -> Path:
"""Remove invisible watermark from an image.
@@ -132,12 +135,15 @@ class InvisibleEngine:
Args:
image_path: Path to the watermarked image.
output_path: Output path (None = overwrite source).
strength: Denoising strength (0.0-1.0). Default 0.04.
strength: Denoising strength (0.0-1.0). None -> DEFAULT_STRENGTH (0.10).
steps: Number of denoising steps.
guidance_scale: Classifier-free guidance scale.
seed: Random seed for reproducibility.
humanize: Intensity of Analog Humanizer film grain (0 = off).
protect_faces: Boolean to extract and restore faces intact.
protect_text: Detect text regions and preserve them via Differential
Diffusion when any are found, so glyphs (incl. CJK) survive the
removal pass. On by default; the detector decides per image.
max_resolution: Cap the long side (px) before diffusion. 0 (default)
= native resolution, no pre-downscale -- matches the hosted
raiw.cc backend. Set a positive value only to bound GPU/MPS
@@ -210,6 +216,7 @@ class InvisibleEngine:
num_inference_steps=num_inference_steps,
guidance_scale=guidance_scale,
seed=seed,
protect_text=protect_text,
)
# Optional: Face restoration & Humanizer (Phase 2 - Post-processing)
@@ -217,7 +224,9 @@ class InvisibleEngine:
import cv2
import numpy as np
out_cv = cv2.imread(str(out_path), cv2.IMREAD_COLOR)
from remove_ai_watermarks import image_io
out_cv = image_io.imread(out_path, cv2.IMREAD_COLOR)
if protect_faces and original_faces:
if self._progress_callback:
@@ -243,20 +252,22 @@ class InvisibleEngine:
# Using INTER_LANCZOS4 for high-quality upscaling back to original
out_cv = cv2.resize(out_cv, orig_size, interpolation=cv2.INTER_LANCZOS4)
cv2.imwrite(str(out_path), out_cv)
image_io.imwrite(out_path, out_cv)
else:
# Even if no protect_faces or humanize, we must restore original size if needed
import cv2
out_cv = cv2.imread(str(out_path), cv2.IMREAD_COLOR)
from remove_ai_watermarks import image_io
out_cv = image_io.imread(out_path, cv2.IMREAD_COLOR)
if out_cv is not None and (out_cv.shape[1], out_cv.shape[0]) != orig_size:
if self._progress_callback:
self._progress_callback(
f"Upscaling result back to original resolution {orig_size[0]}x{orig_size[1]}..."
)
out_cv = cv2.resize(out_cv, orig_size, interpolation=cv2.INTER_LANCZOS4)
cv2.imwrite(str(out_path), out_cv)
image_io.imwrite(out_path, out_cv)
return out_path
finally:
@@ -268,7 +279,7 @@ class InvisibleEngine:
self,
input_dir: Path,
output_dir: Path,
strength: float = 0.04,
strength: float = DEFAULT_STRENGTH,
steps: int = 50,
) -> list[Path]:
"""Remove invisible watermarks from all images in a directory."""
@@ -78,10 +78,11 @@ def detect_invisible_watermark(image_path: Path) -> str | None:
"""
if not is_available():
return None
import cv2
from imwatermark import WatermarkDecoder
img = cv2.imread(str(image_path))
from remove_ai_watermarks import image_io
img = image_io.imread(image_path)
if img is None:
return None
+398 -40
View File
@@ -11,7 +11,8 @@ from __future__ import annotations
import contextlib
import logging
import re
from typing import TYPE_CHECKING
import struct
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pathlib import Path
@@ -65,6 +66,22 @@ AI_KEYWORDS: tuple[str, ...] = (
# Reference: https://spec.c2pa.org/specifications/specifications/2.1/specs/C2PA_Specification.html
C2PA_UUID: bytes = bytes.fromhex("d8fec3d61b0e483c92975828877ec481")
def c2pa_marker_in(data: bytes) -> bool:
"""True if ``data`` carries a real C2PA manifest marker, not just an
incidental 4-byte ``c2pa`` substring.
A bare ``c2pa`` byte match false-positives on compressed pixel data -- a
recompressed PNG IDAT (or any large binary) can contain the bytes ``c2pa``
by chance (verified 2026-05-29: 4 cleaned PNGs re-flagged this way after
their manifest was correctly stripped). Every real manifest is JUMBF-wrapped
(the ``jumb`` box FourCC accompanies the ``c2pa`` content type) or uses the
standalone C2PA ``uuid`` box in ISOBMFF, so we require one of those: the
joint ``jumb`` + ``c2pa`` match has negligible random-collision probability.
"""
return C2PA_UUID in data or (b"jumb" in data and b"c2pa" in data.lower())
# IPTC ``digitalSourceType`` values (IPTC 2025.1) that flag AI provenance.
# Used by Instagram, Facebook, X (Twitter) to show "Made with AI" labels.
IPTC_AI_MARKERS: tuple[bytes, ...] = (
@@ -74,6 +91,29 @@ IPTC_AI_MARKERS: tuple[bytes, ...] = (
b"compositeWithTrainedAlgorithmicMedia",
)
# 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
# signal; ``AISystemUsed`` additionally carries the generator name. Property
# tokens verified against the IPTC 2025.1 specification.
IPTC_AI_FIELD_MARKERS: tuple[bytes, ...] = (
b"AISystemUsed",
b"AISystemVersionUsed",
b"AIPromptInformation",
b"AIPromptWriterName",
)
# ISOBMFF containers whose AI-provenance boxes ``remove_ai_metadata`` strips at
# the container level (image, video, audio -- all ISOBMFF). A content sniff
# (``ftyp``) is also accepted, so this is a fast-path hint, not the sole gate.
_ISOBMFF_EXTS: frozenset[str] = frozenset({".avif", ".heif", ".heic", ".jxl", ".mp4", ".mov", ".m4v", ".m4a"})
# Non-ISOBMFF audio/video the ISOBMFF box walker can't reach (EBML / framed /
# RIFF / Vorbis). remove_ai_metadata strips their container metadata losslessly
# via ffmpeg (`-c copy`), so it needs ffmpeg on PATH for these.
_FFMPEG_STRIP_EXTS: frozenset[str] = frozenset(
{".webm", ".mkv", ".mka", ".mp3", ".wav", ".flac", ".ogg", ".oga", ".opus", ".aac"}
)
# China's mandatory AI-content labeling (TC260, the national cybersecurity
# standards committee). AI generators serving China embed an XMP block in the
# TC260 namespace -- ``<TC260:AIGC>{"Label":"1",...}``. Doubao (ByteDance) uses
@@ -85,6 +125,27 @@ AIGC_MARKERS: tuple[bytes, ...] = (
b"TC260:AIGC",
)
# TC260 AIGC-label JSON fields (the standard's labeling object). Doubao writes
# the same object as a PNG ``tEXt`` chunk keyed ``AIGC`` (raw JSON, not XMP), so
# a JSON object carrying at least one of these is accepted as a valid TC260
# label even when the namespaced XMP element is absent.
_TC260_FIELDS: frozenset[str] = frozenset(
{
"Label",
"ContentProducer",
"ProduceID",
"ContentPropagator",
"PropagateID",
"ReservedCode1",
"ReservedCode2",
}
)
# HuggingFace-hosted GPU jobs (Jobs / Spaces) stamp generated PNGs with this
# ``tEXt`` chunk key holding the job UUID. It marks the hosting job, not a
# specific model -- a medium-confidence AI signal (commonly diffusion output).
_HF_JOB_KEY: str = "hf-job-id"
STANDARD_METADATA_KEYS: frozenset[str] = frozenset(
[
"Author",
@@ -109,6 +170,75 @@ def _is_ai_key(key: str) -> bool:
return any(kw in key_lower for kw in AI_KEYWORDS)
# 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"})
def _png_late_metadata(image_path: Path, window: int) -> bytes:
"""Payloads of PNG metadata chunks that start *beyond* the first ``window``
bytes, found by seeking past the (large) ``IDAT`` pixel stream.
A PNG encoder may append the XMP/EXIF packet after the image data, so a
fixed first-``size`` read misses it (e.g. a TC260 AIGC label in an XMP
``iTXt`` chunk at ~2.7 MB). This is the PNG analogue of the ISOBMFF
late-box scan in :func:`scan_head`. Returns only chunks past ``window`` so
bytes already in the head are not duplicated; empty when there are none.
"""
out = bytearray()
try:
with open(image_path, "rb") as f:
if f.read(8) != b"\x89PNG\r\n\x1a\n":
return b""
pos = 8
while True:
f.seek(pos)
header = f.read(8)
if len(header) < 8:
break
(length,) = struct.unpack(">I", header[:4])
chunk_type = header[4:8]
if chunk_type == b"IEND":
break
data_start = pos + 8
if chunk_type in _PNG_META_CHUNKS and data_start >= window:
f.seek(data_start)
out += f.read(length)
pos = data_start + length + 4 # data + CRC
except OSError as exc:
logger.debug("PNG late-metadata scan failed on %s: %s", image_path, exc)
return b""
return bytes(out)
def scan_head(image_path: Path, size: int = 1024 * 1024) -> bytes:
"""First ``size`` bytes of the file, plus the payloads of any provenance
metadata found beyond that window: ISOBMFF ``uuid`` / ``jumb`` boxes (seeking
past large boxes like ``mdat``) and PNG ``tEXt`` / ``iTXt`` / ``eXIf`` chunks
(seeking past ``IDAT``).
This is the shared input for every C2PA / AIGC / IPTC byte scan. The
extensions catch a manifest or XMP packet placed AFTER the media data -- a
non-faststart MP4 manifest, or a PNG XMP packet appended after the pixels --
which a fixed first-MB read would miss. For other inputs, and for files that
fit within ``size``, it is exactly ``f.read(size)`` -- behavior-neutral.
"""
with open(image_path, "rb") as f:
head = f.read(size)
# Lazy import: isobmff imports this module's constants at top level.
from remove_ai_watermarks.noai import isobmff
if isobmff.is_isobmff(head):
region = isobmff.scan_c2pa_region(image_path)
if region:
head += region
elif head[:8] == b"\x89PNG\r\n\x1a\n" and len(head) == size:
# len(head) == size means the file is at least `size` bytes, so metadata
# chunks may lie beyond the window; otherwise the whole PNG is in `head`.
head += _png_late_metadata(image_path, size)
return head
def has_ai_metadata(image_path: Path) -> bool:
"""Check if an image contains AI-generation metadata.
@@ -127,7 +257,7 @@ def has_ai_metadata(image_path: Path) -> bool:
try:
with Image.open(image_path) as img:
for key in img.info:
if _is_ai_key(key):
if isinstance(key, str) and _is_ai_key(key):
return True
except Exception as exc:
logger.debug("PIL could not open %s for metadata scan: %s", image_path, exc)
@@ -136,7 +266,8 @@ def has_ai_metadata(image_path: Path) -> bool:
# binary scan that also catches AVIF/HEIF/JPEG-XL containers (PIL doesn't
# expose their metadata uniformly).
try:
from c2pa import has_c2pa_metadata
# optional official lib, not a declared dep -> falls back to the binary scan
from c2pa import has_c2pa_metadata # pyright: ignore[reportMissingImports, reportUnknownVariableType]
if has_c2pa_metadata(image_path):
return True
@@ -144,43 +275,180 @@ def has_ai_metadata(image_path: Path) -> bool:
pass
# Binary scan covers C2PA (PNG caBX, JPEG APP11, AVIF/HEIF/JXL uuid boxes)
# and IPTC AI markers in XMP. Read only the first 512KB to bound memory.
with open(image_path, "rb") as f:
data = f.read(512 * 1024)
if b"c2pa" in data.lower() or b"C2PA" in data:
return True
if C2PA_UUID in data:
# and IPTC AI markers in XMP. First 512KB (plus late ISOBMFF provenance boxes).
data = scan_head(image_path, 512 * 1024)
if c2pa_marker_in(data):
return True
if any(marker in data for marker in AIGC_MARKERS):
return True
if any(marker in data for marker in IPTC_AI_MARKERS):
return True
# IPTC 2025.1 AI-disclosure XMP properties (their presence flags AI content).
if any(marker in data for marker in IPTC_AI_FIELD_MARKERS):
return True
# China TC260 AIGC label as a PNG text chunk (the byte scan above catches
# only the XMP form; the raw-JSON tEXt chunk needs the PIL-based parse).
if aigc_label(image_path):
return True
# HuggingFace-hosted job marker (hf-job-id PNG text chunk).
if huggingface_job(image_path):
return True
# xAI / Grok: no C2PA/IPTC/XMP -- only the EXIF Signature + UUID-Artist pair.
return xai_signature(image_path)
def aigc_label(image_path: Path) -> dict[str, str] | None:
"""Parse a China TC260 ``<TC260:AIGC>`` AI-labeling block, if present.
"""Parse a China TC260 AI-labeling block, if present.
Three serializations are recognized:
- a PNG ``tEXt``/``iTXt`` chunk keyed ``AIGC`` carrying the raw JSON object
(as written by Doubao / ByteDance), read via PIL;
- an XMP ``<TC260:AIGC>{...}</TC260:AIGC>`` block (HTML-entity encoded text),
found by a container-agnostic raw-byte scan (PNG/JPEG/WebP alike); and
- a raw-JSON ``{"AIGC":{...}}`` block with no namespace, as embedded in JPEG
EXIF (UserComment) by some China-served generators, brace-matched from the
scan head.
Returns the decoded JSON (e.g. ``{"Label": "1", "ContentProducer": ...}``)
or None. The block is XMP text (HTML-entity encoded), so it is found by a
container-agnostic raw-byte scan and works for PNG/JPEG/WebP alike.
or None. The generic forms (the PNG-chunk key ``AIGC`` and the bare
``{"AIGC":...}`` object) are accepted only if they carry at least one known
TC260 field (``_TC260_FIELDS``); the namespaced XMP element is unambiguous,
so any JSON object is accepted.
"""
import html
import json
import re
from typing import cast
with open(image_path, "rb") as f:
data = f.read(1024 * 1024)
match = re.search(rb"<TC260:AIGC>(.*?)</TC260:AIGC>", data, re.DOTALL)
if not match:
return None
raw = html.unescape(match.group(1).decode("utf-8", "replace"))
def _parse(text: str, *, require_tc260_field: bool) -> dict[str, str] | None:
try:
parsed = json.loads(text)
except ValueError:
return None
if not isinstance(parsed, dict):
return None
fields = {str(k): str(v) for k, v in cast("dict[object, object]", parsed).items()}
if require_tc260_field and not (_TC260_FIELDS & fields.keys()):
return None
return fields
# PNG tEXt chunk keyed "AIGC" with raw JSON (Doubao and other China gens).
# The key is generic, so require a TC260 field to avoid a false positive.
try:
parsed = json.loads(raw)
except ValueError:
from PIL import Image
with Image.open(image_path) as img:
value = img.info.get("AIGC")
except Exception as exc:
logger.debug("PIL could not open %s for AIGC chunk scan: %s", image_path, exc)
value = None
if isinstance(value, str) and (result := _parse(value, require_tc260_field=True)):
return result
# XMP TC260:AIGC, namespaced (unambiguous) in either serialization RDF allows:
# an element <TC260:AIGC>{...}</TC260:AIGC> or an attribute TC260:AIGC="{...}"
# (the attribute form is what PicWish writes). Both are HTML-entity encoded.
data = scan_head(image_path)
match = re.search(
rb'<TC260:AIGC>(.*?)</TC260:AIGC>|TC260:AIGC\s*=\s*"(.*?)"',
data,
re.DOTALL,
)
if match:
body = match.group(1) if match.group(1) is not None else match.group(2)
return _parse(html.unescape(body.decode("utf-8", "replace")), require_tc260_field=False)
# Raw-JSON {"AIGC":{...}} block (no namespace), as written into JPEG EXIF
# (UserComment) by some China-served generators -- the PNG-chunk and XMP
# paths above both miss it. The bytes pre-check keeps the common (no-AIGC)
# path off the full-buffer decode; raw_decode then brace-matches the inner
# object (respecting nested braces / quoted strings) and `_parse` applies the
# same dict coercion + TC260-field gate as the generic PNG-chunk path.
if b'"AIGC"' in data:
text = data.decode("latin-1")
brace = text.find("{", text.find('"AIGC"') + len('"AIGC"'))
if brace != -1:
try:
_, end = json.JSONDecoder().raw_decode(text, brace)
except ValueError:
return None
return _parse(text[brace:end], require_tc260_field=True)
return None
def huggingface_job(image_path: Path) -> str | None:
"""Return the HuggingFace job id if the image carries an ``hf-job-id`` PNG
text chunk, else None.
HuggingFace-hosted GPU jobs (Jobs / Spaces) stamp generated PNGs with an
``hf-job-id`` ``tEXt`` chunk holding the job's UUID. It identifies the
*hosting job*, not a specific model, and is most commonly seen on diffusion-
generation output -- a medium-confidence AI signal, not proof of AI pixels
on its own.
"""
try:
from PIL import Image
with Image.open(image_path) as img:
value = img.info.get(_HF_JOB_KEY)
except Exception as exc:
logger.debug("PIL could not open %s for hf-job-id scan: %s", image_path, exc)
return None
return {str(k): str(v) for k, v in parsed.items()} if isinstance(parsed, dict) else None
if isinstance(value, str) and value.strip():
return value.strip()
return None
# Samsung Galaxy AI editing marker. Galaxy AI tools (Generative Edit, Sketch to
# Image, Portrait Studio, Drawing Assist, ...) record their re-edit data as a
# proprietary ``PhotoEditor_Re_Edit_Data`` JSON that carries a ``genAIType``
# field; a non-zero value flags that a generative-AI tool produced or altered
# the pixels. The field is undocumented by Samsung (verified 2026-05-29: absent
# from the C2PA spec and Samsung's public docs/forums), so detection is
# empirical -- on real Galaxy S23/S24/S25 files it co-occurs with the C2PA
# ``trainedAlgorithmicMedia`` source type (3/3 of the verified files that record
# that type), and on a Galaxy S24 sample it is the *only* AI marker (the C2PA
# source type was absent there). Medium confidence: it signals Galaxy AI editing
# without proving the whole image is AI-generated. Scoped to the Samsung editor
# container to avoid matching a stray ``genAIType`` token elsewhere.
_SAMSUNG_GENAI_RE = re.compile(rb'genAIType"\s*:\s*(-?\d+)')
_SAMSUNG_EDITOR_MARKER = b"PhotoEditor_Re_Edit_Data"
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.
"""
head = scan_head(image_path, 512 * 1024)
if _SAMSUNG_EDITOR_MARKER not in head:
return None
m = _SAMSUNG_GENAI_RE.search(head)
if m is None:
return None
return int(m.group(1)) or None
def iptc_ai_system(image_path: Path) -> str | None:
"""Return an IPTC 2025.1 AI-disclosure note if the file carries those XMP
properties, else None.
IPTC Photo Metadata 2025.1 added ``Iptc4xmpExt`` AI-disclosure properties
(see ``IPTC_AI_FIELD_MARKERS``); their presence alone flags AI content, and
``AISystemUsed`` names the generator. Returns the ``AISystemUsed`` value when
extractable, otherwise the literal ``"fields present"``. Container-agnostic
raw-byte scan; handles both XMP element and attribute serializations.
"""
data = scan_head(image_path)
if not any(marker in data for marker in IPTC_AI_FIELD_MARKERS):
return None
match = re.search(rb"AISystemUsed[=:\s]*[\"'>]\s*([^<\"']{1,120})", data)
if match and (value := match.group(1).decode("utf-8", "replace").strip()):
return value
return "fields present"
def synthid_source(image_path: Path) -> str | None:
@@ -213,9 +481,8 @@ def synthid_source(image_path: Path) -> str | None:
# Non-PNG containers (JPEG APP11, WebP, AVIF/HEIF/JXL uuid box) keep the
# C2PA manifest where the PNG parser can't reach it. Binary-scan for the
# same signal: a C2PA manifest from a SynthID-using issuer on AI content.
with open(image_path, "rb") as f:
data = f.read(1024 * 1024)
has_c2pa = b"c2pa" in data.lower() or C2PA_UUID in data
data = scan_head(image_path)
has_c2pa = c2pa_marker_in(data)
# Matches both "trainedAlgorithmicMedia" and "compositeWithTrainedAlgorithmicMedia".
ai_source = b"trainedAlgorithmicMedia" in data or b"TrainedAlgorithmicMedia" in data
if not (has_c2pa and ai_source):
@@ -265,8 +532,7 @@ def exif_generator(image_path: Path) -> str | None:
# XMP CreatorTool: text, container-agnostic (covers HEIF/JXL via raw scan).
try:
with open(image_path, "rb") as f:
head = f.read(1024 * 1024)
head = scan_head(image_path)
for match in re.finditer(rb"CreatorTool[>\"'=\s]{1,4}([^<\"']{1,80})", head):
candidates.append(match.group(1).decode("latin1", "replace"))
except Exception as exc:
@@ -290,7 +556,7 @@ def _is_xai_signature_pair(description: str, artist: str) -> bool:
return _XAI_SIGNATURE_RE.match(description) is not None and _UUID_RE.fullmatch(artist) is not None
def _exif_text(ifd: dict, tag: int) -> str:
def _exif_text(ifd: dict[int, Any], tag: int) -> str:
"""Decode a piexif 0th-IFD byte tag to a stripped string ('' if absent)."""
value = ifd.get(tag)
return value.decode("latin1", "replace").strip() if isinstance(value, bytes) else ""
@@ -326,7 +592,7 @@ def xai_signature(image_path: Path) -> bool:
)
def _scrub_ai_exif(exif_dict: dict) -> list[str]:
def _scrub_ai_exif(exif_dict: dict[str, Any]) -> list[str]:
"""Delete AI-provenance tags from a piexif dict's ``0th`` IFD, in place.
Removes (a) the xAI/Grok signature pair (``ImageDescription`` "Signature: ..."
@@ -380,7 +646,7 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
"""
from PIL import Image
from remove_ai_watermarks.noai.c2pa import extract_c2pa_info, synthid_verdict
from remove_ai_watermarks.noai.c2pa import extract_c2pa_info, soft_binding_vendors_in, synthid_verdict
result: dict[str, str] = {}
@@ -390,7 +656,7 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
try:
with Image.open(image_path) as img:
for key, value in img.info.items():
if _is_ai_key(key):
if isinstance(key, str) and _is_ai_key(key):
if isinstance(value, bytes):
result[key] = f"<binary {len(value)} bytes>"
elif isinstance(value, str) and len(value) > 200:
@@ -410,14 +676,20 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
"source_type",
"actions",
"synthid_watermark",
"soft_binding",
):
if key in c2pa:
result.setdefault(key, str(c2pa[key]))
# Non-PNG containers (JPEG/WebP/AVIF): extract_c2pa_info is PNG-only, so
# fall back to the format-agnostic source check for the SynthID verdict.
# Non-PNG containers (JPEG/WebP/AVIF/MP4): extract_c2pa_info is PNG-only, so
# fall back to the format-agnostic source check for the SynthID verdict and
# the soft-binding (forensic-watermark vendor) scan.
if "synthid_watermark" not in result and (vendor := synthid_source(image_path)):
result.setdefault("synthid_watermark", synthid_verdict(vendor))
if "soft_binding" not in result:
head = scan_head(image_path)
if vendors := soft_binding_vendors_in(head):
result["soft_binding"] = ", ".join(vendors)
# China TC260 AI-content label (Doubao and other China-served generators).
if aigc := aigc_label(image_path):
@@ -427,9 +699,61 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
# xAI / Grok EXIF signature scheme (its only provenance signal).
if xai_signature(image_path):
result.setdefault("xai_signature", "xAI/Grok EXIF signature (Artist UUID + Signature blob)")
# IPTC 2025.1 AI-disclosure XMP fields (Iptc4xmpExt:AISystemUsed etc.).
if system := iptc_ai_system(image_path):
result.setdefault("ai_system", f"IPTC 2025.1 AI disclosure ({system})")
# HuggingFace-hosted job marker (hf-job-id PNG text chunk).
if job := huggingface_job(image_path):
result.setdefault("huggingface_job", f"HuggingFace-hosted job ({job})")
# Samsung Galaxy AI editing marker (genAIType in PhotoEditor_Re_Edit_Data).
if (genai := samsung_genai(image_path)) is not None:
result.setdefault("samsung_genai", f"Samsung Galaxy AI editing marker (genAIType={genai})")
return result
def _strip_with_ffmpeg(source_path: Path, output_path: Path) -> Path:
"""Strip container metadata from a non-ISOBMFF audio/video file via ffmpeg.
Uses a lossless stream copy (``-c copy``), so codec data is untouched and only
container-level tags/chapters are dropped -- the metadata strip for WebM /
Matroska (EBML), MP3 (ID3), WAV / FLAC / OGG (RIFF / Vorbis comments) that the
ISOBMFF box walker cannot reach. Requires ffmpeg on PATH (raises if absent).
The output extension should match the source so ``-c copy`` can re-mux.
"""
import shutil
import subprocess
ffmpeg = shutil.which("ffmpeg")
if ffmpeg is None:
raise RuntimeError(
f"ffmpeg is required to strip metadata from {source_path.suffix} files but was not found on "
"PATH; install ffmpeg (e.g. `brew install ffmpeg`) or re-encode the file with another tool"
)
output_path.parent.mkdir(parents=True, exist_ok=True)
cmd = [
ffmpeg,
"-y",
"-loglevel",
"error",
"-i",
str(source_path),
"-map_metadata",
"-1",
"-map_chapters",
"-1",
"-c",
"copy",
str(output_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False) # noqa: S603
if result.returncode != 0:
raise RuntimeError(f"ffmpeg failed to strip metadata from {source_path}: {result.stderr.strip()[:300]}")
logger.info("Stripped container metadata via ffmpeg -> %s", output_path)
return output_path
def remove_ai_metadata(
source_path: Path,
output_path: Path | None = None,
@@ -455,29 +779,61 @@ def remove_ai_metadata(
if output_path is None:
output_path = source_path
# AVIF/HEIF/JPEG-XL: strip C2PA boxes at the container level without
# re-encoding. Avoids needing PIL plugins (pillow-heif / pillow-jxl) and
# preserves pixel data bit-for-bit.
if source_path.suffix.lower() in (".avif", ".heif", ".heic", ".jxl"):
from remove_ai_watermarks.noai.isobmff import strip_c2pa_boxes
# ISOBMFF containers (AVIF/HEIF/JPEG-XL images, MP4/MOV/M4V video, M4A audio):
# strip C2PA + AI-label boxes at the container level without re-encoding.
# Avoids needing PIL plugins (pillow-heif / pillow-jxl) and preserves the
# 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
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.
cleaned, stripped = strip_c2pa_boxes(data)
cleaned, blanked = blank_ai_xmp_packets(cleaned)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(cleaned)
logger.info("Stripped %d C2PA box(es) → %s", stripped, output_path)
logger.info(
"Stripped %d AI-provenance box(es), blanked %d meta-box XMP packet(s) → %s",
stripped,
blanked,
output_path,
)
return output_path
# Non-ISOBMFF audio/video (WebM/Matroska EBML, MP3 ID3, WAV/FLAC/OGG): the
# box walker can't reach these, so strip container metadata losslessly via
# ffmpeg (-c copy -- codec data untouched, only tags/chapters dropped).
if source_path.suffix.lower() in _FFMPEG_STRIP_EXTS:
return _strip_with_ffmpeg(source_path, output_path)
# Read image and filter metadata
with Image.open(source_path) as img:
img = img.copy()
fmt = output_path.suffix.lower()
save_kwargs: dict = {}
save_kwargs: dict[str, Any] = {}
if fmt in (".jpg", ".jpeg"):
save_kwargs["format"] = "JPEG"
# JPEG output is unavoidably lossy, so minimize the loss: high quality
# and no chroma subsampling (4:4:4). Without these PIL defaults to
# quality 75 + 4:2:0, which visibly degrades a re-saved image.
save_kwargs["quality"] = 95
save_kwargs["subsampling"] = 0
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
elif fmt == ".webp":
# Preserve the WebP container losslessly instead of silently rewriting
# it as PNG (which changes the format and bloats the file).
save_kwargs["format"] = "WEBP"
save_kwargs["lossless"] = True
if img.mode == "P": # WebP cannot encode palette mode
img = img.convert("RGBA" if "transparency" in img.info else "RGB")
else:
save_kwargs["format"] = "PNG"
@@ -486,6 +842,8 @@ def remove_ai_metadata(
exif_data = None
for key, value in img.info.items():
if not isinstance(key, str):
continue
if _is_ai_key(key):
continue
if key == "exif":
+23 -3
View File
@@ -28,6 +28,7 @@ from remove_ai_watermarks.noai.constants import (
C2PA_CHUNK_TYPE,
C2PA_ISSUERS,
C2PA_SIGNATURES,
C2PA_SOFT_BINDINGS,
PNG_SIGNATURE,
SYNTHID_C2PA_ISSUERS,
)
@@ -130,7 +131,7 @@ def extract_c2pa_info(image_path: Path) -> dict[str, Any]:
return c2pa_info
def _cbor_text_after(payload: bytes, key: bytes) -> str | None:
def cbor_text_after(payload: bytes, key: bytes) -> str | None:
"""Return the CBOR text-string immediately following ``key`` in ``payload``.
Handles CBOR major-type 3 length prefixes: direct (0x60-0x77), 1-byte
@@ -174,6 +175,18 @@ def synthid_vendors_in(buffer: bytes) -> list[str]:
return sorted({name for sig, name in C2PA_ISSUERS.items() if sig in buffer and sig in SYNTHID_C2PA_ISSUERS})
def soft_binding_vendors_in(buffer: bytes) -> list[str]:
"""Return forensic-watermark vendor names whose C2PA soft-binding ``alg``
identifier appears in ``buffer``.
A ``c2pa.soft-binding`` assertion names the watermark scheme that stamped the
pixels (Adobe TrustMark, Digimarc, Imatag, Steg.AI, ...). Shared by the PNG
caBX parser and the format-agnostic binary scan so both apply the same
C2PA_SOFT_BINDINGS rule against their respective bytes.
"""
return sorted({name for sig, name in C2PA_SOFT_BINDINGS.items() if sig in buffer})
def _parse_c2pa_chunk(chunk_data: bytes, c2pa_info: dict[str, Any]) -> None:
"""Parse C2PA chunk data and populate info dictionary."""
c2pa_info["c2pa_manifest"] = f"C2PA manifest ({len(chunk_data)} bytes)"
@@ -199,9 +212,9 @@ def _parse_c2pa_chunk(chunk_data: bytes, c2pa_info: dict[str, Any]) -> None:
# Guard with isprintable(): on some manifests (e.g. Microsoft Designer) the
# first ``name`` key precedes a binary field (a hash), not the generator
# string, which would otherwise surface as control-char garbage.
if (generator := _cbor_text_after(chunk_data, b"name")) and generator.isprintable():
if (generator := cbor_text_after(chunk_data, b"name")) and generator.isprintable():
c2pa_info["claim_generator"] = generator
if (spec := _cbor_text_after(chunk_data, b"specVersion")) and spec.isprintable():
if (spec := cbor_text_after(chunk_data, b"specVersion")) and spec.isprintable():
c2pa_info["c2pa_spec"] = spec
# Find actions
@@ -238,6 +251,13 @@ def _parse_c2pa_chunk(chunk_data: bytes, c2pa_info: dict[str, Any]) -> None:
c2pa_info["synthid_vendors"] = synthid_vendors
c2pa_info["synthid_watermark"] = synthid_verdict(", ".join(synthid_vendors))
# Soft-binding: a forensic/third-party watermark vendor named in the
# manifest (Adobe TrustMark, Digimarc, ...), independent of the issuer.
soft_binding_vendors = soft_binding_vendors_in(chunk_data)
if soft_binding_vendors:
c2pa_info["soft_binding_vendors"] = soft_binding_vendors
c2pa_info["soft_binding"] = ", ".join(soft_binding_vendors)
def extract_c2pa_chunk(image_path: Path) -> bytes | None:
"""
+3 -1
View File
@@ -94,6 +94,8 @@ def _extract_non_ai_metadata(source_path: Path, keep_standard: bool) -> dict[str
# Extract non-AI metadata
for key, value in img.info.items():
if not isinstance(key, str):
continue
if _is_ai_metadata_key(key):
continue
@@ -127,7 +129,7 @@ def _is_ai_metadata_key(key: str) -> bool:
def _prepare_clean_png_kwargs(save_kwargs: dict[str, Any], metadata: dict[str, Any]) -> dict[str, Any]:
"""Prepare save kwargs for clean PNG."""
pnginfo = {}
pnginfo: dict[str, Any] = {}
exclude_keys = ["exif", "exif_raw", "dpi", "gamma"]
for key, value in metadata.items():
@@ -88,6 +88,14 @@ C2PA_ISSUERS = {
# Stability AI signs C2PA as "Stability AI" (cert org "Stability AI Ltd").
# Verified on a live Brand Studio (DreamStudio successor) output, 2026-05-24.
b"Stability AI": "Stability AI",
# Black Forest Labs (FLUX) API output: claim_generator_info "Black Forest
# Labs API" + a c2pa.ai_generated_content assertion + trainedAlgorithmicMedia.
# Verified on a real signed FLUX JPEG, 2026-05-29.
b"Black Forest Labs": "Black Forest Labs",
# ByteDance's Volcano Engine (Volcengine) signs its AI image output with a
# cert from certificate_center@volcengine.com -- the platform behind Doubao /
# Jimeng. Verified on two real signed JPEGs, 2026-05-29.
b"volcengine": "ByteDance (Volcano Engine)",
}
# C2PA issuers whose signed outputs also carry an invisible SynthID pixel
@@ -122,6 +130,35 @@ C2PA_AI_TOOLS = {
b"Firefly": "Firefly",
}
# C2PA ``c2pa.soft-binding`` algorithm identifiers -> the forensic-watermark
# vendor that stamped the pixels. The manifest's ``alg`` field names the
# watermark scheme even when the watermark itself cannot be decoded locally, so
# a byte-scan for these (keyed on a distinctive prefix to catch all variants)
# tells us a third-party forensic watermark is present and whose. Verified
# against the official C2PA registry (github.com/c2pa-org/softbinding-algorithm-list).
# Adobe TrustMark is additionally decodable locally (see ``trustmark_detector``);
# the rest (Digimarc, Imatag, Steg.AI, etc.) are proprietary oracle-only decoders.
C2PA_SOFT_BINDINGS = {
b"com.adobe.trustmark": "Adobe TrustMark",
b"com.adobe.icn": "Adobe (content fingerprint)",
b"com.digimarc": "Digimarc",
b"com.imatag.lamark": "Imatag (Lamark)",
b"ai.steg": "Steg.AI",
b"com.microsoft.invismark": "Microsoft InvisMark",
b"com.microsoft.wavmark": "Microsoft WavMark",
b"com.verimatrix": "Verimatrix",
b"com.nagra.nexguard": "NAGRA NexGuard",
b"com.aiwatermark": "AIWatermark (Meta PixelSeal)",
b"ai.trufo": "Trufo",
b"app.overlai": "Overlai",
b"com.markany": "MarkAny",
b"com.mentaport": "Mentaport",
b"es.lumatrace": "LumaTrace",
b"ai.verda": "VerdaAI",
b"ai.contentlens": "ContentLens",
b"io.iscc": "ISCC (content code)",
}
# Lowercased substrings that mark an AI generator when found in an EXIF
# ``Software`` / XMP ``CreatorTool`` value. Conservative on purpose: plain
# editors like "Adobe Photoshop" or "GIMP" must NOT match (no AI token), so only
+6 -2
View File
@@ -6,7 +6,7 @@ human-readable summary without modifying the source file.
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
from pathlib import Path
@@ -46,6 +46,8 @@ def extract_metadata(source_path: Path) -> dict[str, Any]:
# Extract all other metadata including AI-specific
for key, value in img.info.items():
if not isinstance(key, str):
continue
if key not in metadata and key not in ["exif"]:
metadata[key] = value
@@ -83,6 +85,8 @@ def extract_ai_metadata(source_path: Path) -> dict[str, Any]:
ai_metadata[key] = img.info[key]
for key, value in img.info.items():
if not isinstance(key, str):
continue
key_lower = key.lower()
if key not in ai_metadata and any(kw in key_lower for kw in AI_KEYWORDS):
ai_metadata[key] = value
@@ -138,7 +142,7 @@ def get_ai_metadata_summary(source_path: Path) -> str:
continue
if key == "c2pa" and isinstance(value, dict):
lines.append("C2PA Metadata:")
for ck, cv in value.items():
for ck, cv in cast("dict[str, Any]", value).items():
lines.append(f" {ck}: {cv}")
elif isinstance(value, str) and len(value) > 100:
value = value[:100] + "..."
@@ -121,6 +121,96 @@ def run_img2img_with_mps_fallback(
raise
def run_differential(
pipeline: Any,
image: Image.Image,
change_map: Any,
strength: float,
num_inference_steps: int,
guidance_scale: float,
generator: Any,
device: str,
set_progress: Callable[[str], None],
) -> Image.Image:
"""Run the SDXL Differential-Diffusion pipeline and return the image.
Unlike standard img2img, the differential pipeline needs pre-processed image
tensors plus a per-pixel change map (HxW float32 in [0, 1]); white preserves
the original pixels, black regenerates them. Runs without a step callback --
the community pipeline's callback signature differs across diffusers
versions, and a protect-text pass is short.
"""
import torch
image_tensor = pipeline.image_processor.preprocess(image).to(device)
map_tensor = torch.from_numpy(change_map)[None].to(device) # pyright: ignore[reportPrivateImportUsage, reportUnknownMemberType]
set_progress(f"Running protected regeneration ({device}, strength={strength})...")
result = pipeline(
prompt="",
image=image_tensor,
original_image=image_tensor,
map=map_tensor,
strength=strength,
num_inference_steps=num_inference_steps,
guidance_scale=guidance_scale,
generator=generator,
)
return result.images[0]
def run_differential_with_mps_fallback(
load_pipeline: Callable[[], Any],
image: Image.Image,
change_map: Any,
strength: float,
num_inference_steps: int,
guidance_scale: float,
generator: Any,
device: str,
set_progress: Callable[[str], None],
*,
reload_on_cpu: Callable[[], Any],
) -> tuple[Image.Image, str]:
"""Run differential img2img; on MPS error, fall back to CPU.
Returns:
(result_image, final_device) -- device may change to ``"cpu"`` on fallback.
"""
pipeline = load_pipeline()
try:
img = run_differential(
pipeline,
image,
change_map,
strength,
num_inference_steps,
guidance_scale,
generator,
device,
set_progress,
)
return img, device
except RuntimeError as error:
if device == "mps" and is_mps_error(error):
logger.warning("MPS error detected: %s. Falling back to CPU.", error)
set_progress("MPS error! Clearing cache and retrying on CPU...")
_try_clear_mps_cache()
pipeline = reload_on_cpu()
img = run_differential(
pipeline,
image,
change_map,
strength,
num_inference_steps,
guidance_scale,
None,
"cpu",
set_progress,
)
return img, "cpu"
raise
def _call_pipeline(
pipeline: Any,
image: Image.Image,
+128 -17
View File
@@ -17,19 +17,39 @@ Reference: ISO/IEC 14496-12 (ISOBMFF) and C2PA 2.1 spec §11.
from __future__ import annotations
import re
import struct
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
from remove_ai_watermarks.metadata import C2PA_UUID
from remove_ai_watermarks.metadata import (
AIGC_MARKERS,
C2PA_UUID,
IPTC_AI_FIELD_MARKERS,
IPTC_AI_MARKERS,
)
# Top-level box types that carry C2PA payload. ``uuid`` boxes are checked
# against ``C2PA_UUID`` before being stripped; ``jumb`` boxes are always
# stripped (JPEG-XL uses them exclusively for JUMBF).
# Top-level box types that may carry AI provenance. ``uuid`` boxes are checked
# against ``C2PA_UUID`` / AI-label markers before being stripped; ``jumb`` boxes
# are always stripped (JPEG-XL uses them exclusively for JUMBF).
C2PA_BOX_TYPES: frozenset[bytes] = frozenset({b"uuid", b"jumb"})
# AI-label byte markers (TC260 AIGC, IPTC "Made with AI", IPTC 2025.1 AI fields)
# whose presence inside an XMP ``uuid`` box means the box carries an AI label.
# Matching the payload rather than a fixed XMP UUID avoids the XMP-box UUID
# byte-order ambiguity and stays surgical: only AI-bearing XMP is dropped, plain
# XMP (copyright, camera info) is kept.
_AI_LABEL_MARKERS: tuple[bytes, ...] = AIGC_MARKERS + IPTC_AI_MARKERS + IPTC_AI_FIELD_MARKERS
# Adobe XMP packet delimiters (XMP spec part 3). In HEIF/AVIF the XMP packet
# sits inside a ``meta``-box ``mime`` item whose bytes live in ``mdat`` / ``idat``,
# out of reach of the top-level box stripper, so an AI-label packet there is
# blanked in place (see ``blank_ai_xmp_packets``).
_XMP_PACKET_RE = re.compile(rb"<\?xpacket begin=.*?<\?xpacket end=[^>]*?\?>", re.DOTALL)
def _iter_top_level_boxes(data: bytes) -> Iterator[tuple[int, int, bytes, int]]:
"""Yield ``(start, end, type, payload_offset)`` for each top-level box.
@@ -66,13 +86,76 @@ def is_isobmff(data: bytes) -> bool:
return len(data) >= 8 and data[4:8] == b"ftyp"
def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
"""Return ``(cleaned_bytes, stripped_count)``.
def scan_c2pa_region(path: str | Path, *, max_total: int = 4 * 1024 * 1024) -> bytes:
"""Concatenated payloads of top-level ``uuid`` / ``jumb`` boxes in an ISOBMFF
file, found by seeking past other boxes (``mdat`` etc.) by size.
Walks top-level boxes; drops any ``uuid`` box whose UUID equals
``C2PA_UUID`` and any ``jumb`` box (JPEG-XL JUMBF container). All other
boxes are emitted verbatim. If the input is not ISOBMFF-shaped, returns
it unchanged.
C2PA manifests and XMP packets (incl. AI labels) live in top-level ``uuid``
boxes; JPEG-XL uses ``jumb``. In a streaming / non-faststart MP4 the manifest
sits AFTER a multi-megabyte ``mdat``, so a fixed first-MB read misses it. This
walks box headers (8-16 bytes each) and seeks past payloads it does not need,
so it never loads ``mdat`` into memory and works on multi-GB files. Returns
the relevant box payloads (capped at ``max_total``), or ``b""`` for a
non-ISOBMFF file or on any read error.
"""
collected = bytearray()
try:
with open(path, "rb") as f:
sniff = f.read(8)
if len(sniff) < 8 or sniff[4:8] != b"ftyp":
return b""
f.seek(0, 2)
file_size = f.tell()
pos = 0
while pos + 8 <= file_size and len(collected) < max_total:
f.seek(pos)
header = f.read(8)
if len(header) < 8:
break
size32 = struct.unpack(">I", header[:4])[0]
box_type = header[4:8]
payload_off = pos + 8
if size32 == 1:
ext = f.read(8)
if len(ext) < 8:
break
size = struct.unpack(">Q", ext)[0]
payload_off = pos + 16
elif size32 == 0:
size = file_size - pos
else:
size = size32
if size < (payload_off - pos) or pos + size > file_size:
break
if box_type in C2PA_BOX_TYPES:
f.seek(payload_off)
to_read = min(pos + size - payload_off, max_total - len(collected))
if to_read > 0:
collected += f.read(to_read)
pos += size
except OSError:
return b""
return bytes(collected)
def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
"""Return ``(cleaned_bytes, stripped_count)`` with AI-provenance boxes removed.
Walks top-level boxes and drops:
- any ``uuid`` box whose UUID equals ``C2PA_UUID`` (a C2PA manifest);
- any ``uuid`` box whose payload carries an AI-label marker (an XMP packet
with a TC260 / IPTC / IPTC-2025.1 AI field -- caught by content, not by the
XMP UUID, so it works regardless of the UUID's byte order, and leaves plain
non-AI XMP intact);
- any ``jumb`` box (JPEG-XL JUMBF container).
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.
"""
if not is_isobmff(data):
return data, 0
@@ -80,14 +163,42 @@ def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
out = bytearray()
stripped = 0
for start, end, box_type, payload_off in _iter_top_level_boxes(data):
if box_type in C2PA_BOX_TYPES:
if box_type == b"uuid":
# uuid boxes carry the 16-byte UUID immediately after the type.
if payload_off + 16 <= end and data[payload_off : payload_off + 16] == C2PA_UUID:
stripped += 1
continue
else: # b"jumb"
if box_type == b"uuid":
# uuid boxes carry the 16-byte UUID immediately after the type.
is_c2pa = payload_off + 16 <= end and data[payload_off : payload_off + 16] == C2PA_UUID
has_ai_label = any(marker in data[payload_off:end] for marker in _AI_LABEL_MARKERS)
if is_c2pa or has_ai_label:
stripped += 1
continue
elif box_type == b"jumb":
stripped += 1
continue
out.extend(data[start:end])
return bytes(out), stripped
def blank_ai_xmp_packets(data: bytes) -> tuple[bytes, int]:
"""Overwrite (with spaces, in place) any XMP packet carrying an AI-label
marker; return ``(data, blanked_count)``.
HEIF/AVIF store XMP as a ``meta``-box ``mime`` item whose bytes live in
``mdat`` / ``idat``, which ``strip_c2pa_boxes`` cannot remove without
meta-box surgery (``iinf`` / ``iloc`` rewrite). Instead, the XMP packet is
located by its ``<?xpacket begin ... end?>`` delimiters and, when it carries
an AI-label marker (TC260 AIGC / IPTC / IPTC-2025.1), overwritten with spaces.
Because the replacement is the **same length**, every box size and ``iloc``
offset stays valid and the coded image data is untouched -- only the AI label
content is destroyed. Packets without an AI marker (plain copyright / camera
XMP) are left intact, mirroring the top-level XMP-``uuid`` content match.
"""
blanked = 0
def _scrub(match: re.Match[bytes]) -> bytes:
nonlocal blanked
packet = match.group()
if any(marker in packet for marker in _AI_LABEL_MARKERS):
blanked += 1
return b" " * len(packet)
return packet
return _XMP_PACKET_RE.sub(_scrub, data), blanked
+1 -1
View File
@@ -259,7 +259,7 @@ def make_pipeline_progress(
label: str = "Denoising",
pre_phases: list[tuple[int, str]] | None = None,
post_phases: list[tuple[int, str]] | None = None,
) -> tuple[Callable, threading.Event, threading.Event, Callable[[], threading.Thread]]:
) -> tuple[Callable[..., None], threading.Event, threading.Event, Callable[[], threading.Thread]]:
"""Create step callback and background updater for a diffusion pipeline.
Returns:
@@ -1,4 +1,4 @@
"""Watermark removal model profiles, strength presets, and profile detection.
"""Watermark removal model profiles, the default strength, and profile detection.
Pure configuration and lookup functions with no ML dependencies.
"""
@@ -8,12 +8,18 @@ from __future__ import annotations
DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
CTRLREGEN_MODEL_ID = "yepengliu/ctrlregen"
LOW_STRENGTH = 0.04
MEDIUM_STRENGTH = 0.35
HIGH_STRENGTH = 0.7
_HIGH_PERTURBATION = ("stegasamp", "stegastamp", "treering", "ringid")
_LOW_PERTURBATION = ("stablesignature", "dwtectsvd", "rivagan", "ssl", "hidden")
# Single default denoising strength for the SDXL img2img scrub, overridable from
# the CLI (`--strength`). Raised from the old 0.04/0.05 because that no longer
# removes the CURRENT Google SynthID (Nano Banana / Gemini 3): verified 2026-05-30
# via the Gemini "Verify with SynthID" oracle on a real image -- 0.05 still
# detected, 0.10 not detected (OpenAI's SynthID was already cleared at 0.05). 0.10
# keeps the visible change modest while removing both. CAVEAT: confirmed on n=1
# Google + n=1 OpenAI image; broad oracle validation across the corpus is pending
# (different images may need a different strength). At this higher strength small
# text deforms more -- which is exactly why text protection (`_run_region_hires`)
# runs by default. (Fixed LOW/MEDIUM/HIGH presets were removed -- unused; the one
# knob is this default plus the per-call `--strength` override.)
DEFAULT_STRENGTH = 0.10
def get_model_id_for_profile(profile: str) -> str:
@@ -31,21 +37,3 @@ def detect_model_profile(model_id: str) -> str:
if "ctrlregen" in model_id.lower():
return "ctrlregen"
return "default"
def get_recommended_strength(watermark_type: str) -> float:
"""Get recommended strength for different watermark types.
Args:
watermark_type: Type of watermark. One of: 'low', 'medium', 'high',
or specific names like 'stegastamp', 'treering', etc.
Returns:
Recommended strength value.
"""
wt = watermark_type.lower()
if any(name in wt for name in _HIGH_PERTURBATION):
return HIGH_STRENGTH
if any(name in wt for name in _LOW_PERTURBATION):
return LOW_STRENGTH
return MEDIUM_STRENGTH
@@ -10,6 +10,9 @@ This module implements a simple regeneration attack that:
4. Decodes back to pixel space
"""
# torch/diffusers/cv2 boundary: these libs ship no usable types for the tensor and
# array ops below; relax the unknown-type rules for this file only.
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
from __future__ import annotations
import contextlib
@@ -28,9 +31,7 @@ from PIL import Image
from remove_ai_watermarks.noai.watermark_profiles import (
CTRLREGEN_MODEL_ID,
DEFAULT_MODEL_ID,
HIGH_STRENGTH,
LOW_STRENGTH,
MEDIUM_STRENGTH,
DEFAULT_STRENGTH,
detect_model_profile,
)
@@ -219,6 +220,19 @@ def get_device() -> str:
return "cuda"
except (AssertionError, RuntimeError):
pass
# Intel GPU (Arc / Data Center) via the torch XPU backend. The torch.xpu
# namespace exists in stock wheels, but is_available() is only True on an
# XPU-enabled build (download.pytorch.org/whl/xpu), so this is inert on the
# default CPU/CUDA install. Checked before the nvidia-smi path so an Intel
# box never triggers the CUDA reinstaller.
if hasattr(torch, "xpu") and torch.xpu.is_available(): # type: ignore
try:
t = torch.tensor([1.0], device="xpu")
_ = t + t
del t
return "xpu"
except (AssertionError, RuntimeError):
pass
if _has_nvidia_gpu() and not os.environ.get(_CUDA_FIX_ENV_KEY):
_reinstall_torch_cuda_and_restart()
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
@@ -226,23 +240,42 @@ def get_device() -> str:
return "cpu"
def _make_seed_generator(device: str, seed: int) -> Any:
"""Build a seeded ``torch.Generator``, falling back to a CPU generator.
Some backends have no device-side RNG (notably certain torch-xpu builds),
so ``torch.Generator(device="xpu")`` can raise. A CPU generator is
backend-agnostic and still seeds the pipeline reproducibly, so fall back to
it rather than failing the run when ``--seed`` is used on such a device.
"""
try:
return torch.Generator(device=device).manual_seed(seed) # type: ignore
except (RuntimeError, TypeError):
return torch.Generator().manual_seed(seed) # type: ignore
# Keep legacy name available for backwards compatibility
_detect_model_profile_from_id = detect_model_profile
# SDXL Differential-Diffusion community pipeline, pinned to the installed
# diffusers version so the fetched pipeline code matches the library (see #21).
# Diffusers' dynamic-module loader resolves ``custom_revision`` against the
# package version string (``0.38.0``), NOT the GitHub git tag (``v0.38.0``).
_DIFF_PIPELINE_NAME = "pipeline_stable_diffusion_xl_differential_img2img"
_DIFF_PIPELINE_REVISION = "0.38.0"
class WatermarkRemover:
"""Remove watermarks from images using diffusion model regeneration.
Attributes:
model_id: HuggingFace model ID for the diffusion model.
device: Device to run inference on (cuda, mps, or cpu).
device: Device to run inference on (cuda, xpu, mps, or cpu).
"""
DEFAULT_MODEL_ID = DEFAULT_MODEL_ID
CTRLREGEN_MODEL_ID = CTRLREGEN_MODEL_ID
LOW_STRENGTH = LOW_STRENGTH
MEDIUM_STRENGTH = MEDIUM_STRENGTH
HIGH_STRENGTH = HIGH_STRENGTH
DEFAULT_STRENGTH = DEFAULT_STRENGTH
def __init__(
self,
@@ -260,8 +293,8 @@ class WatermarkRemover:
self.device = (device or get_device()).lower()
if self.device == "auto":
self.device = get_device()
if self.device not in {"cpu", "mps", "cuda"}:
raise ValueError(f"Unsupported device '{device}'. Use one of: auto, cpu, mps, cuda.")
if self.device not in {"cpu", "mps", "cuda", "xpu"}:
raise ValueError(f"Unsupported device '{device}'. Use one of: auto, cpu, mps, cuda, xpu.")
if torch_dtype is None:
if self.device == "cpu" or self.device == "mps":
self.torch_dtype = torch.float32 # type: ignore
@@ -271,6 +304,7 @@ class WatermarkRemover:
self.torch_dtype = torch_dtype
self._pipeline: AutoImg2ImgPipeline | None = None
self._diff_pipeline: Any = None
self._ctrlregen_engine: Any = None
self._progress_callback = progress_callback
self.hf_token: str | None = hf_token or os.environ.get("HF_TOKEN")
@@ -379,6 +413,7 @@ class WatermarkRemover:
num_inference_steps: int = 50,
guidance_scale: float | None = None,
seed: int | None = None,
protect_text: bool = True,
) -> Path:
"""Remove watermark from an image using regeneration attack.
@@ -389,6 +424,10 @@ class WatermarkRemover:
num_inference_steps: Number of denoising steps.
guidance_scale: Classifier-free guidance scale.
seed: Random seed for reproducibility.
protect_text: Detect text regions and preserve them via Differential
Diffusion when any are found (SDXL default profile only). On by
default; the detector decides per image, and text-free inputs run
the standard pass at no extra cost.
Returns:
Path to the cleaned image.
@@ -403,7 +442,7 @@ class WatermarkRemover:
if output_path is None:
output_path = image_path
strength = strength or self.LOW_STRENGTH
strength = strength or self.DEFAULT_STRENGTH
if not 0.0 <= strength <= 1.0:
raise ValueError(f"Strength must be between 0.0 and 1.0, got {strength}")
@@ -419,7 +458,7 @@ class WatermarkRemover:
generator = None
if seed is not None and _HAS_TORCH:
self._set_progress(f"Setting reproducible seed: {seed}")
generator = torch.Generator(device=self.device).manual_seed(seed) # type: ignore
generator = _make_seed_generator(self.device, seed)
effective_steps = max(1, int(num_inference_steps * strength))
self._set_progress(
@@ -437,7 +476,21 @@ class WatermarkRemover:
guidance_scale,
generator,
)
elif protect_text and self._can_protect_text():
cleaned_image = self._run_region_hires(
init_image,
strength,
num_inference_steps,
guidance_scale,
generator,
)
else:
if protect_text:
logger.debug(
"Text protection unavailable "
"(needs the SDXL default model and the cv2 text detector); "
"running standard img2img."
)
cleaned_image = self._run_img2img(
init_image,
strength,
@@ -520,6 +573,183 @@ class WatermarkRemover:
self._pipeline = None
return self._load_pipeline()
# ── Text-protected differential runner ───────────────────────────
def _can_protect_text(self) -> bool:
"""True when text protection can run: SDXL default model + cv2 detector."""
from remove_ai_watermarks import text_protector
return self.model_id == self.DEFAULT_MODEL_ID and text_protector.is_available()
def _load_differential_pipeline(self) -> Any:
"""Load the SDXL Differential-Diffusion community pipeline lazily."""
if self._diff_pipeline is None:
from diffusers import DiffusionPipeline
self._set_progress("Loading Differential-Diffusion pipeline (protect-text)...")
use_fp16 = self.device in {"mps", "cuda", "xpu"}
load_kwargs: dict[str, Any] = {
"custom_pipeline": _DIFF_PIPELINE_NAME,
"custom_revision": _DIFF_PIPELINE_REVISION,
"torch_dtype": torch.float16 if use_fp16 else torch.float32, # type: ignore[attr-defined]
"use_safetensors": True,
}
if use_fp16:
load_kwargs["variant"] = "fp16"
if self.hf_token:
load_kwargs["token"] = self.hf_token
pipeline = DiffusionPipeline.from_pretrained(self.model_id, **load_kwargs).to(self.device)
# The differential pipeline upcasts the SDXL VAE to fp32 internally
# (the fp16 VAE decodes to NaN/black otherwise), so we add no extra
# VAE handling here. Attention slicing is also left off on MPS: it
# produced NaN latents with this pipeline, and the protect-text pass
# is short enough not to need it.
with contextlib.suppress(Exception):
pipeline.set_progress_bar_config(disable=True)
self._diff_pipeline = pipeline
return self._diff_pipeline
def _reload_differential_on_cpu(self) -> Any:
"""Reload the differential pipeline on CPU after an MPS failure."""
self.device = "cpu"
self.torch_dtype = torch.float32 # type: ignore[assignment]
self._diff_pipeline = None
return self._load_differential_pipeline()
# Region high-res text scrub: defaults tuned so each text block is upscaled
# enough that strokes exceed the VAE's ~8px latent cell, capped so a single
# region never blows past the GPU/MPS memory budget.
_REGION_HIRES_SCALE = 3.0
_REGION_MAX_MEGAPIXELS = 1.3
def _run_region_hires(
self,
init_image: Image.Image,
strength: float,
num_inference_steps: int,
guidance_scale: float,
generator: Any,
) -> Image.Image:
"""Scrub the whole image, then RE-scrub each detected text block at high
resolution and composite it back.
Unlike the Differential-Diffusion path (which freezes text in latent space
and so leaves the watermark intact there), every pixel here is regenerated
-- the watermark is removed everywhere. Small text survives because each
text block is upscaled before its img2img pass, so strokes span more than
one VAE latent cell (the ~8px floor that softens text at native scale);
the scrubbed crop is downscaled and feather-composited back. Falls back to
the plain global scrub when no text is detected.
"""
import math
import cv2
import numpy as np
from remove_ai_watermarks import text_protector
base = self._run_img2img(init_image, strength, num_inference_steps, guidance_scale, generator)
bgr = cv2.cvtColor(np.array(init_image), cv2.COLOR_RGB2BGR)
try:
boxes = text_protector.TextProtector().detect_text_boxes(bgr)
except Exception as exc:
logger.warning("Text detection failed (%s); keeping the global scrub.", exc)
return base
if not boxes:
self._set_progress("No text detected; global scrub only.")
return base
width, height = init_image.size
regions = text_protector.merge_text_regions(boxes, height, width)
orig_bgr = cv2.cvtColor(np.array(init_image), cv2.COLOR_RGB2BGR)
out_bgr = cv2.cvtColor(np.array(base), cv2.COLOR_RGB2BGR)
budget = self._REGION_MAX_MEGAPIXELS * 1_000_000
done = 0
for x, y, w, h in regions:
area = max(1, w * h)
# INTEGER scale so the upscale -> scrub -> downscale round-trip is an
# exact dimensional inverse (a fractional factor truncates and shifts
# the composited text ~1-2px, which is invisible but tanks alignment).
scale = int(min(self._REGION_HIRES_SCALE, math.sqrt(budget / area)))
if scale < 2:
# Region too large to even double within the budget: upscaling
# buys nothing here; the global scrub covers it (documented limit
# for very large text areas -- tiling is the future fix).
continue
crop = orig_bgr[y : y + h, x : x + w]
up = cv2.resize(crop, (w * scale, h * scale), interpolation=cv2.INTER_LANCZOS4)
up_pil = Image.fromarray(cv2.cvtColor(up, cv2.COLOR_BGR2RGB))
scrubbed = self._run_img2img(up_pil, strength, num_inference_steps, guidance_scale, generator)
down = cv2.resize(cv2.cvtColor(np.array(scrubbed), cv2.COLOR_RGB2BGR), (w, h), interpolation=cv2.INTER_AREA)
# The up -> scrub -> down round-trip can offset the re-rendered text by
# a pixel or two (the diffusion pipeline rounds dims to a multiple of
# 8, so the inverse resize is not perfectly centered). Phase-correlate
# the patch back to the original crop and translate it so the glyphs
# land exactly where they were -- otherwise a sub-pixel shift garbles
# the composite even though the text is crisp.
cg = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY).astype(np.float32)
dg = cv2.cvtColor(down, cv2.COLOR_BGR2GRAY).astype(np.float32)
(sx, sy), _resp = cv2.phaseCorrelate(cg, dg)
if abs(sx) > 0.1 or abs(sy) > 0.1:
m = np.float32([[1, 0, -sx], [0, 1, -sy]])
down = cv2.warpAffine(down, m, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
out_bgr = text_protector.feather_paste(out_bgr, down, x, y)
done += 1
self._set_progress(f"Re-scrubbed {done}/{len(regions)} text region(s) at high resolution.")
return Image.fromarray(cv2.cvtColor(out_bgr, cv2.COLOR_BGR2RGB))
def _run_differential(
self,
init_image: Image.Image,
strength: float,
num_inference_steps: int,
guidance_scale: float,
generator: Any,
) -> Image.Image:
"""Run differential img2img that preserves detected text regions."""
import cv2
import numpy as np
from remove_ai_watermarks import text_protector
self._set_progress("Detecting text regions to protect (protect-text)...")
bgr = cv2.cvtColor(np.array(init_image), cv2.COLOR_RGB2BGR)
try:
boxes = text_protector.TextProtector().detect_text_boxes(bgr)
except Exception as exc:
logger.warning("Text detection failed (%s); running standard img2img.", exc)
return self._run_img2img(init_image, strength, num_inference_steps, guidance_scale, generator)
if not boxes:
self._set_progress("No text detected; running standard img2img.")
return self._run_img2img(init_image, strength, num_inference_steps, guidance_scale, generator)
width, height = init_image.size
change_map = text_protector.build_change_map(boxes, height, width)
self._set_progress(f"Protecting {len(boxes)} text region(s) via Differential Diffusion...")
from remove_ai_watermarks.noai.img2img_runner import run_differential_with_mps_fallback
result_image, final_device = run_differential_with_mps_fallback(
load_pipeline=self._load_differential_pipeline,
image=init_image,
change_map=change_map,
strength=strength,
num_inference_steps=num_inference_steps,
guidance_scale=guidance_scale,
generator=generator,
device=self.device,
set_progress=self._set_progress,
reload_on_cpu=self._reload_differential_on_cpu,
)
if final_device != self.device:
self.device = final_device
self.torch_dtype = torch.float32 # type: ignore[assignment]
return result_image
# ── CtrlRegen runner ─────────────────────────────────────────────
def _run_ctrlregen(
@@ -619,7 +849,7 @@ class WatermarkRemover:
def remove_watermark(
image_path: Path,
output_path: Path | None = None,
strength: float = 0.04,
strength: float = DEFAULT_STRENGTH,
model_id: str | None = None,
device: str | None = None,
hf_token: str | None = None,
+180
View File
@@ -0,0 +1,180 @@
"""Universal region eraser: remove anything inside user-given boxes via inpainting.
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
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.
- ``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.
"""
# 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, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, Literal
import cv2
import numpy as np
if TYPE_CHECKING:
from numpy.typing import NDArray
logger = logging.getLogger(__name__)
Backend = Literal["cv2", "lama"]
_LAMA_REPO = "Carve/LaMa-ONNX"
_LAMA_FILE = "lama_fp32.onnx"
# Cached onnxruntime session (loading is expensive; reuse across calls).
_lama_session: object | None = None
def boxes_to_mask(
shape: tuple[int, int],
boxes: list[tuple[int, int, int, int]],
dilate: int = 3,
) -> NDArray[Any]:
"""Build a uint8 mask (255 inside boxes) from ``(x, y, w, h)`` rectangles."""
h, w = shape
mask = np.zeros((h, w), np.uint8)
for x, y, bw, bh in boxes:
x0, y0 = max(0, x), max(0, y)
x1, y1 = min(w, x + bw), min(h, y + bh)
if x1 > x0 and y1 > y0:
mask[y0:y1, x0:x1] = 255
if dilate > 0 and mask.any():
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * dilate + 1, 2 * dilate + 1))
mask = cv2.dilate(mask, k)
return mask
def erase_cv2(
image_bgr: NDArray[Any],
mask: NDArray[Any],
*,
method: Literal["telea", "ns"] = "telea",
radius: int = 6,
) -> NDArray[Any]:
"""Inpaint ``mask`` with classical cv2 inpainting (CPU, no extra deps)."""
flag = cv2.INPAINT_TELEA if method == "telea" else cv2.INPAINT_NS
return cv2.inpaint(image_bgr, mask, radius, flag)
def lama_available() -> bool:
"""True when the optional LaMa-ONNX backend can run (onnxruntime installed)."""
import importlib.util
return importlib.util.find_spec("onnxruntime") is not None
def _get_lama_session() -> object:
"""Load (once) the big-LaMa ONNX session, downloading the model on first use."""
global _lama_session
if _lama_session is not None:
return _lama_session
import onnxruntime as ort
from huggingface_hub import hf_hub_download
model_path = hf_hub_download(repo_id=_LAMA_REPO, filename=_LAMA_FILE)
logger.info("Loading LaMa-ONNX model: %s", model_path)
_lama_session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
return _lama_session
def erase_lama(image_bgr: NDArray[Any], mask: NDArray[Any]) -> NDArray[Any]:
"""Inpaint ``mask`` with big-LaMa via onnxruntime (CPU).
LaMa runs at a fixed square input size. To preserve full-image resolution we
crop a padded region around the mask, inpaint that crop at the model size,
and paste only the masked pixels back -- so untouched areas stay pixel-exact.
"""
session = _get_lama_session()
inp = session.get_inputs() # type: ignore[attr-defined]
img_name = inp[0].name
mask_name = inp[1].name
# Model declares a fixed square spatial size (e.g. 512); fall back to 512.
dims = inp[0].shape
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:
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)
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.
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
img_in = np.transpose(img_in, (2, 0, 1))[None] # (1,3,size,size)
mask_in = (mask_rs > 127).astype(np.float32)[None, None] # (1,1,size,size), 1=hole
out = session.run(None, {img_name: img_in, mask_name: mask_in})[0] # type: ignore[attr-defined]
out = np.asarray(out)[0] # (3,size,size)
out = np.transpose(out, (1, 2, 0))
if float(out.max()) <= 1.5: # model emits [0,1]; otherwise already [0,255]
out = out * 255.0
out = np.clip(out, 0, 255).astype(np.uint8)
out_bgr = cv2.cvtColor(out, cv2.COLOR_RGB2BGR)
# Resize back to crop size and paste only the masked pixels.
out_crop = cv2.resize(out_bgr, (cw, ch), interpolation=cv2.INTER_LINEAR)
result = image_bgr.copy()
region = result[cy0:cy1, cx0:cx1]
paste = crop_mask > 127
region[paste] = out_crop[paste]
result[cy0:cy1, cx0:cx1] = region
return result
def erase(
image_bgr: NDArray[Any],
*,
boxes: list[tuple[int, int, int, int]] | None = None,
mask: NDArray[Any] | None = None,
backend: Backend = "cv2",
dilate: int = 3,
cv2_method: Literal["telea", "ns"] = "telea",
cv2_radius: int = 6,
) -> NDArray[Any]:
"""Erase the given boxes (or mask) via the chosen inpainting backend.
Provide either ``boxes`` (list of ``(x, y, w, h)``) or a precomputed ``mask``
(uint8, 255 = erase). Returns an unmodified copy when nothing is selected.
"""
if image_bgr is None or image_bgr.size == 0:
return image_bgr
if mask is None:
if not boxes:
return image_bgr.copy()
mask = boxes_to_mask(image_bgr.shape[:2], boxes, dilate=dilate)
if not mask.any():
return image_bgr.copy()
if backend == "lama":
if not lama_available():
raise RuntimeError(
"LaMa backend requires onnxruntime. Install the extra: pip install 'remove-ai-watermarks[lama]'"
)
return erase_lama(image_bgr, mask)
return erase_cv2(image_bgr, mask, method=cv2_method, radius=cv2_radius)
+271
View File
@@ -0,0 +1,271 @@
"""Text-region protection for diffusion-based watermark removal.
SDXL img2img (the ``invisible`` pipeline) regenerates every pixel, so small text
and CJK glyphs get deformed at the strengths that defeat SynthID (issue #21).
This module detects text regions and builds a per-pixel "change map" for
Differential Diffusion: the background is regenerated normally while text
regions are largely preserved, so glyphs survive the watermark-removal pass.
Detection uses only OpenCV's DNN module (no torch): the PP-OCRv3 text detector
is a ~2.4 MB ONNX model (Apache-2.0, from opencv_zoo) that is CJK-native and
returns rotated quadrilaterals. The model is downloaded and cached on first use;
it is never bundled in this repo.
Change-map polarity (verified empirically against the differential pipeline):
white (1.0) = PRESERVE the original pixels, black (0.0) = MAXIMUM change. So the
map is black everywhere except the text polygons, which are painted toward
white. ``preserve`` stays below a hard 1.0 freeze by default: SynthID is
designed to survive cropping, so totally freezing text pixels would leave the
watermark intact there. A high-but-partial preserve still scrubs lightly.
"""
# cv2 ships no type stubs; mirror the pragma used by the other cv2-using modules.
# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportCallIssue=false, reportArgumentType=false, reportReturnType=false
from __future__ import annotations
import logging
import os
import tempfile
import urllib.request
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from numpy.typing import NDArray
logger = logging.getLogger(__name__)
# PP-OCRv3 Chinese text detector (DB head), opencv_zoo, Apache-2.0.
_MODEL_URL = (
"https://github.com/opencv/opencv_zoo/raw/main/models/text_detection_ppocr/text_detection_cn_ppocrv3_2023may.onnx"
)
_MODEL_FILENAME = "text_detection_cn_ppocrv3_2023may.onnx"
# DB detector input: the image is detected at its NATIVE long side, capped at
# this value (rounded to a multiple of 32), never upscaled. A fixed small input
# (the old 736) downscaled large images so far that small text fell below the
# detector's resolution and was missed -- the cause of the "small text still
# distorts" reports (issue #14). Detection is script-agnostic (DB segments text
# *regions*, not characters), so this recall fix applies to every language; the
# only lever that mattered was resolution. 1536 recovers full recall down to
# ~12 px text on a 2048 canvas at ~100 ms on CPU (a fixed 736 missed it); going
# higher buys no measured recall at 2x+ the cost. Benchmarked in
# scripts/text_detection_benchmark.py. Very large canvases with tiny text may
# still need tiling -- a documented limit, not yet built.
_DET_MAX_LONG_SIDE = 1536
# ImageNet mean (x255) and 1/255 scale -- the normalization PP-OCRv3 expects.
_DET_MEAN = (0.485 * 255, 0.456 * 255, 0.406 * 255)
_DET_SCALE = 1 / 255.0
def is_available() -> bool:
"""True when OpenCV's DNN text-detection model is importable."""
try:
import cv2
return hasattr(cv2.dnn, "TextDetectionModel_DB")
except ImportError:
return False
def _cache_dir() -> Path:
"""Local cache directory for the detector model (created on demand)."""
cache = Path.home() / ".cache" / "remove-ai-watermarks"
cache.mkdir(parents=True, exist_ok=True)
return cache
def _model_path() -> Path:
"""Return the cached detector path, downloading it on first use."""
target = _cache_dir() / _MODEL_FILENAME
if target.exists() and target.stat().st_size > 0:
return target
logger.info("Downloading PP-OCRv3 text detector (~2.4 MB) to %s", target)
# Download to a temp file in the same dir, then atomically rename so a
# partial download never leaves a corrupt model cached.
fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), suffix=".onnx.part")
tmp_path = Path(tmp_name)
try:
os.close(fd)
with urllib.request.urlopen(_MODEL_URL) as resp: # noqa: S310 (trusted GitHub URL)
tmp_path.write_bytes(resp.read())
tmp_path.replace(target)
finally:
if tmp_path.exists():
tmp_path.unlink()
return target
def _detection_input_size(height: int, width: int) -> tuple[int, int]:
"""DB-detector input ``(in_w, in_h)`` for an image of the given size.
Detect at the native long side, capped at ``_DET_MAX_LONG_SIDE`` and never
upscaled, each side rounded down to a multiple of 32 (the DB head requires
/32 dims), floored at 32. Pure function so the resolution contract (the
issue #14 small-text recall fix) is unit-testable without the model.
"""
long_side = max(height, width)
scale = min(_DET_MAX_LONG_SIDE, long_side) / long_side
in_w = max((round(width * scale) // 32) * 32, 32)
in_h = max((round(height * scale) // 32) * 32, 32)
return in_w, in_h
def build_change_map(
boxes: list[NDArray[Any]],
height: int,
width: int,
preserve: float = 0.9,
feather: int = 15,
) -> NDArray[Any]:
"""Build a Differential-Diffusion change map from text polygons.
Args:
boxes: Text-region polygons as arrays of (x, y) vertices.
height: Output map height in pixels.
width: Output map width in pixels.
preserve: Map value painted inside text polygons (0..1). White (1.0)
fully preserves the original pixels; the default 0.9 preserves
strongly while still letting a light scrub through.
feather: Gaussian-blur kernel size for soft polygon edges (forced odd).
Returns:
Float32 HxW array in [0, 1]: ~0 in the background (full change),
``preserve`` inside text regions, blended at the edges.
"""
import cv2
import numpy as np
change_map = np.zeros((height, width), np.float32)
if boxes:
polys = [np.asarray(b, np.int32) for b in boxes]
cv2.fillPoly(change_map, polys, float(preserve))
if feather > 0:
if feather % 2 == 0:
feather += 1
change_map = cv2.GaussianBlur(change_map, (feather, feather), 0)
# GaussianBlur can overshoot the painted value by a float epsilon; keep
# the contract that the map stays a valid [0, 1] change map.
np.clip(change_map, 0.0, 1.0, out=change_map)
return change_map
def merge_text_regions(
boxes: list[NDArray[Any]],
height: int,
width: int,
dilate_frac: float = 0.012,
pad_frac: float = 0.02,
max_regions: int = 8,
) -> list[tuple[int, int, int, int]]:
"""Group detected text polygons into a few padded axis-aligned rectangles.
The DB detector returns one box per word/line; the region-high-res text scrub
runs a separate diffusion pass per region, so we coalesce nearby boxes into a
handful of *local* blocks (a light dilation merges within a paragraph but not
across the whole image, so each block stays small enough to upscale within a
memory budget). Returns ``(x, y, w, h)`` rects, largest-area first, clipped to
the image and capped at ``max_regions``.
"""
import cv2
import numpy as np
mask = np.zeros((height, width), np.uint8)
if not boxes:
return []
cv2.fillPoly(mask, [np.asarray(b, np.int32) for b in boxes], 1)
k = max(1, int(min(height, width) * dilate_frac))
mask = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_RECT, (k, k)))
n, _labels, stats, _c = cv2.connectedComponentsWithStats(mask, 8)
pad = int(min(height, width) * pad_frac)
rects: list[tuple[int, int, int, int]] = []
for i in range(1, n):
x, y, w, h = (
int(stats[i, cv2.CC_STAT_LEFT]),
int(stats[i, cv2.CC_STAT_TOP]),
int(stats[i, cv2.CC_STAT_WIDTH]),
int(stats[i, cv2.CC_STAT_HEIGHT]),
)
x0, y0 = max(0, x - pad), max(0, y - pad)
x1, y1 = min(width, x + w + pad), min(height, y + h + pad)
rects.append((x0, y0, x1 - x0, y1 - y0))
rects.sort(key=lambda r: -(r[2] * r[3]))
return rects[:max_regions]
def feather_paste(
base: NDArray[Any],
patch: NDArray[Any],
x: int,
y: int,
feather: int = 8,
) -> NDArray[Any]:
"""Alpha-composite ``patch`` into ``base`` at ``(x, y)`` with a feathered edge.
Used to drop a separately re-scrubbed (high-resolution) text region back into
the globally-scrubbed image without a visible seam. Returns a new array;
``base`` is not modified. ``patch`` is clipped to ``base`` bounds.
"""
import numpy as np
out = base.copy()
bh, bw = base.shape[:2]
ph, pw = patch.shape[:2]
x0, y0 = max(0, x), max(0, y)
x1, y1 = min(bw, x + pw), min(bh, y + ph)
if x1 <= x0 or y1 <= y0:
return out
patch_roi = patch[y0 - y : y1 - y, x0 - x : x1 - x].astype(np.float32)
base_roi = out[y0:y1, x0:x1].astype(np.float32)
rh, rw = base_roi.shape[:2]
alpha = np.ones((rh, rw), np.float32)
f = max(0, min(feather, rh // 2, rw // 2))
if f > 0:
ramp = np.linspace(0.0, 1.0, f, dtype=np.float32)
alpha[:f, :] *= ramp[:, None]
alpha[rh - f :, :] *= ramp[::-1, None]
alpha[:, :f] *= ramp[None, :]
alpha[:, rw - f :] *= ramp[None, ::-1]
a3 = alpha[:, :, None]
out[y0:y1, x0:x1] = (patch_roi * a3 + base_roi * (1.0 - a3)).astype(base.dtype)
return out
class TextProtector:
"""Detect text regions with PP-OCRv3 for diffusion change-map protection."""
def __init__(
self,
binary_threshold: float = 0.3,
polygon_threshold: float = 0.5,
max_candidates: int = 200,
unclip_ratio: float = 2.0,
) -> None:
import cv2
self._detector = cv2.dnn.TextDetectionModel_DB(str(_model_path()))
self._detector.setBinaryThreshold(binary_threshold)
self._detector.setPolygonThreshold(polygon_threshold)
self._detector.setMaxCandidates(max_candidates)
self._detector.setUnclipRatio(unclip_ratio)
def detect_text_boxes(self, bgr_image: NDArray[Any]) -> list[NDArray[Any]]:
"""Detect text regions, returning a list of rotated quad polygons.
Args:
bgr_image: Image as an HxWx3 BGR uint8 array (OpenCV convention).
Returns:
One array of four (x, y) vertices per detected text region.
"""
height, width = bgr_image.shape[:2]
in_w, in_h = _detection_input_size(height, width)
self._detector.setInputParams(
scale=_DET_SCALE,
size=(in_w, in_h),
mean=_DET_MEAN,
swapRB=True,
)
boxes, _confidences = self._detector.detect(bgr_image)
return list(boxes)
@@ -0,0 +1,113 @@
"""Detect Adobe TrustMark invisible watermarks.
TrustMark (github.com/adobe/trustmark, MIT) is the open, keyless image watermark
behind Adobe "Durable Content Credentials": when a C2PA manifest is stripped, a
TrustMark soft binding can still re-link the asset to its manifest in a
repository. Unlike SynthID it has a PUBLIC decoder with no secret key, so a
TrustMark-stamped image can be identified locally. Adobe's shipping products use
Variant P (the ``com.adobe.trustmark.P`` soft-binding ``alg``); this wrapper
loads that model.
Optional dependency (extra: ``trustmark``); the model weights download on first
use. ``detect_trustmark`` returns None when the package is absent. This detects
provenance (Adobe Content Credentials), NOT AI generation as such -- TrustMark
also marks human-authored content -- so callers should treat it as a watermark
signal, not proof of AI origin.
"""
# trustmark ships no type stubs; relax untyped-library diagnostics for this thin
# wrapper module only.
# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingImports=false
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pathlib import Path
log = logging.getLogger(__name__)
# Adobe ships Variant P in production (com.adobe.trustmark.P).
_MODEL_TYPE = "P"
# Lazily constructed singleton -- model load + first-use download is expensive.
_tm: Any = None
def is_available() -> bool:
"""True if the optional ``trustmark`` package is installed."""
import importlib.util
return importlib.util.find_spec("trustmark") is not None
def _decoder() -> Any:
global _tm
if _tm is None:
from trustmark import TrustMark
_tm = TrustMark(verbose=False, model_type=_MODEL_TYPE)
return _tm
# JPEG quality for the false-positive durability gate (see detect_trustmark).
# Deliberately mild: a genuine TrustMark survives far harsher, while every
# observed false positive collapsed even at this quality.
_REENCODE_QUALITY = 95
def detect_trustmark(image_path: Path) -> str | None:
"""Return a TrustMark scheme note if a *durable* TrustMark watermark is
decoded, else None.
Returns e.g. ``"Adobe TrustMark (variant P, schema 0)"`` when the decoder
reports the watermark present AND it survives a mild JPEG re-encode, or None
if it is absent, the optional ``trustmark`` package is not installed, or the
image cannot be read/decoded.
**False-positive gate.** TrustMark's ``wm_present`` flag is a BCH
error-correction validity check, which spuriously validates on a small
fraction of un-watermarked images -- content-correlated, so AI-generated
textures trip it more often than camera photos (verified 2026-05-29 on real
files: the false "detections" were on Gemini / OpenAI / Doubao output that
cannot carry Adobe's watermark, and decoded a random-bytes secret). A genuine
TrustMark is a *durable* soft binding engineered to survive re-encoding (that
is its entire purpose once C2PA is stripped), so we re-decode after a mild
JPEG round-trip and require the same schema both times. Every observed false
positive collapsed under this gate.
"""
if not is_available():
return None
try:
from PIL import Image
with Image.open(image_path) as img:
cover = img.convert("RGB")
decoder = _decoder()
_wm_secret, wm_present, wm_schema = decoder.decode(cover)
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)
return None
except Exception as exc: # model download / decode failure / unreadable image
log.debug("TrustMark decode failed for %s: %s", image_path, exc)
return None
return f"Adobe TrustMark (variant {_MODEL_TYPE}, schema {wm_schema})"
def _survives_reencode(decoder: Any, cover: Any, schema: int) -> bool:
"""True if the watermark re-decodes with the same schema after a mild JPEG
round-trip -- the durability a genuine TrustMark guarantees, which a BCH
false positive (content noise) does not."""
import io
from PIL import Image
buffer = io.BytesIO()
cover.save(buffer, "JPEG", quality=_REENCODE_QUALITY)
buffer.seek(0)
with Image.open(buffer) as reencoded:
_secret, present, reencoded_schema = decoder.decode(reencoded.convert("RGB"))
return bool(present) and reencoded_schema == schema
@@ -0,0 +1,204 @@
"""Registry of known visible watermarks.
A single catalog that ties each known visible mark to (a) where it usually sits,
(b) how to recognize it there, and (c) how to remove it. One pass over the
registry detects every known mark in its usual place and removes the ones
present.
**Reverse-alpha only.** A known mark is a fixed semi-transparent overlay, so it
is removed by inverting the alpha blend against a captured alpha map
(``original = (wm - a*logo)/(1-a)``) -- exact recovery of the true pixels, not an
inpaint guess. Detection is consistent with that: each mark is recognized by
matching its known shape/template (the thing we invert), not by heuristics. A
mark is therefore listed here only once a real alpha map has been captured for
it; everything else (arbitrary logos/objects) is the user-directed
``erase --region`` tool, not this catalog.
Entries:
- ``gemini`` -- Google Gemini / Nano Banana sparkle, bottom-right.
- ``doubao`` -- ByteDance Doubao "豆包AI生成" text strip, bottom-right.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING:
from collections.abc import Callable
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"]
Region = tuple[int, int, int, int]
@dataclass(frozen=True)
class MarkDetection:
"""Uniform detection result for a known mark (across heterogeneous engines)."""
key: str
label: str
location: str
detected: bool
confidence: float
region: Region
@dataclass(frozen=True)
class KnownMark:
"""A known visible watermark: where it lives, how to find and remove it."""
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]]
def detect(self, image: NDArray[Any]) -> MarkDetection:
return self._detect(image)
def remove(
self,
image: NDArray[Any],
*,
inpaint_method: InpaintMethod = "ns",
inpaint: bool = True,
inpaint_strength: float = 0.85,
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.
``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)
# 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
# ── Engine adapters (lazy singletons; engines are cv2-only, no model load) ──
_engines: dict[str, Any] = {}
def _engine(key: str) -> Any:
if key not in _engines:
if key == "gemini":
from remove_ai_watermarks.gemini_engine import GeminiEngine
_engines[key] = GeminiEngine()
elif key == "doubao":
from remove_ai_watermarks.doubao_engine import DoubaoEngine
_engines[key] = DoubaoEngine()
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
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 _doubao_detect(image: NDArray[Any]) -> MarkDetection:
d = _engine("doubao").detect(image)
return MarkDetection("doubao", "Doubao 豆包AI生成 text", "bottom-right", d.detected, d.confidence, d.region)
def _doubao_remove(
image: NDArray[Any], _inpaint_method: InpaintMethod, _inpaint: bool, _strength: float, force: bool
) -> tuple[NDArray[Any], Region | None]:
# Reverse-alpha only: apply when the mark is present AND the resolution is in
# the alpha map's calibrated band. Outside it we do NOT inpaint (no
# hallucination) -- removal is skipped until a capture for that resolution.
engine = _engine("doubao")
det = engine.detect(image)
if (det.detected or force) and engine.reverse_alpha_available(image):
return engine.remove_watermark_reverse_alpha(image), (det.region if det.detected else None)
return image.copy(), None
_REGISTRY: tuple[KnownMark, ...] = (
KnownMark("gemini", "Google Gemini sparkle", "bottom-right", True, "reverse-alpha", _gemini_detect, _gemini_remove),
KnownMark(
"doubao", "Doubao 豆包AI生成 text", "bottom-right", True, "reverse-alpha", _doubao_detect, _doubao_remove
),
)
def known_marks() -> tuple[KnownMark, ...]:
"""All registered known visible watermarks."""
return _REGISTRY
def mark_keys() -> list[str]:
"""Keys of all registered marks (for CLI choices)."""
return [m.key for m in _REGISTRY]
def get_mark(key: str) -> KnownMark:
"""Look up a known mark by key (raises KeyError if unknown)."""
for m in _REGISTRY:
if m.key == key:
return m
raise KeyError(key)
def detect_marks(image: NDArray[Any], *, include_explicit: bool = True) -> 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]
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
+45 -9
View File
@@ -27,7 +27,9 @@ def runner():
@pytest.fixture
def sample_png(tmp_path: Path) -> Path:
"""Create a sample PNG for CLI testing."""
img = np.random.randint(0, 255, (200, 200, 3), dtype=np.uint8)
# Seeded: an unseeded random corner can occasionally trip the Doubao
# visible-mark detector, making `visible --mark auto` flaky.
img = np.random.default_rng(0).integers(0, 255, (200, 200, 3), dtype=np.uint8)
path = tmp_path / "input.png"
cv2.imwrite(str(path), img)
return path
@@ -37,8 +39,9 @@ def _make_batch_dir(tmp_path: Path, count: int = 3) -> Path:
"""Create a directory with test images for batch testing."""
input_dir = tmp_path / "input"
input_dir.mkdir()
rng = np.random.default_rng(0)
for i in range(count):
img = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)
img = rng.integers(0, 255, (100, 100, 3), dtype=np.uint8)
cv2.imwrite(str(input_dir / f"img_{i}.png"), img)
return input_dir
@@ -119,7 +122,8 @@ class TestVisibleCommand:
def test_visible_help(self, runner):
result = runner.invoke(main, ["visible", "--help"])
assert result.exit_code == 0
assert "Gemini watermark" in result.output
assert "visible AI watermark" in result.output
assert "--mark" in result.output
def test_visible_basic(self, runner, sample_png, tmp_path):
output = tmp_path / "clean.png"
@@ -194,15 +198,17 @@ class TestVisibleCommand:
# which doesn't overlap the centre square at 200x200).
assert out[100, 100, 3] == 255
def test_visible_clears_alpha_in_watermark_region(self, runner, tmp_path):
"""When inpainting an RGBA image, the watermark region must be cleared
in the alpha channel so the sparkle area becomes transparent, not opaque-black.
def test_visible_keeps_alpha_opaque_in_watermark_region(self, runner, tmp_path):
"""Regression for issue #30 (white box): on an opaque RGBA image, the
watermark region must stay OPAQUE. Reverse-alpha recovers real pixels
there, so zeroing alpha would punch a transparent hole that renders as a
solid white box on any non-transparent viewer.
"""
rgba = np.full((200, 200, 4), 255, dtype=np.uint8) # fully opaque white
src = tmp_path / "rgba_full.png"
cv2.imwrite(str(src), rgba)
output = tmp_path / "rgba_cleared.png"
output = tmp_path / "rgba_kept.png"
result = runner.invoke(
main,
["visible", str(src), "-o", str(output), "--no-detect"],
@@ -211,13 +217,15 @@ class TestVisibleCommand:
assert result.exit_code == 0, result.output
out = cv2.imread(str(output), cv2.IMREAD_UNCHANGED)
assert out.shape[2] == 4
# Default sparkle position is in the bottom-right; alpha there must be 0.
# Default sparkle position is in the bottom-right; alpha there must stay 255.
from remove_ai_watermarks.gemini_engine import get_watermark_config
cfg = get_watermark_config(200, 200)
px, py = cfg.get_position(200, 200)
size = cfg.logo_size
assert out[py + size // 2, px + size // 2, 3] == 0, "alpha in the watermark region was not cleared"
assert out[py + size // 2, px + size // 2, 3] == 255, "watermark region alpha was zeroed (white-box regression)"
# No pixel anywhere should have been forced transparent.
assert int((out[:, :, 3] == 0).sum()) == 0, "spurious transparent pixels introduced"
def test_visible_rgb_input_stays_rgb(self, runner, sample_png, tmp_path):
"""Regression: a plain RGB PNG must NOT gain a spurious alpha channel."""
@@ -245,6 +253,7 @@ class TestInvisibleCommand:
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),
):
@@ -259,6 +268,7 @@ class TestInvisibleCommand:
def test_invisible_default_output(self, runner, sample_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),
):
@@ -371,6 +381,14 @@ class TestIdentifyCommand:
assert result.exit_code == 0
assert "unknown" in result.output
def test_identify_unknown_explains_why(self, runner, tmp_clean_png):
# An unknown verdict must explain itself inline (issue #22: users read a bare
# "unknown" as the tool being broken) rather than only in the caveats section.
result = runner.invoke(main, ["identify", str(tmp_clean_png), "--no-visible"])
assert result.exit_code == 0
assert "No locally-readable AI signal found" in result.output
assert "not the same as 'clean'" in result.output
def test_identify_ai_png_reports_platform(self, runner, tmp_png_with_ai_metadata):
result = runner.invoke(main, ["identify", str(tmp_png_with_ai_metadata), "--no-visible"])
assert result.exit_code == 0
@@ -505,3 +523,21 @@ class TestBatchCommand:
assert result.exit_code == 0
expected_dir = tmp_path / "input_clean"
assert expected_dir.exists()
class TestGpuHintMarkup:
"""The GPU-extra install hint must survive rich markup (the ``[gpu]`` token
is otherwise parsed as a style tag and silently dropped)."""
def test_invisible_install_hint_keeps_gpu_extra(self, runner, sample_png):
with patch("remove_ai_watermarks.invisible_engine.is_available", return_value=False):
result = runner.invoke(main, ["invisible", str(sample_png)])
assert result.exit_code != 0
assert "remove-ai-watermarks[gpu]" in result.output
def test_all_install_hint_keeps_gpu_extra(self, runner, sample_png):
# The `all` pipeline skips the invisible step with a warning that carries
# the same hint; it must keep the [gpu] extra too.
with patch("remove_ai_watermarks.invisible_engine.is_available", return_value=False):
result = runner.invoke(main, ["all", str(sample_png)])
assert "remove-ai-watermarks[gpu]" in result.output
+163
View File
@@ -0,0 +1,163 @@
"""Tests for the Doubao visible-watermark engine (reverse-alpha only)."""
from __future__ import annotations
from pathlib import Path
import cv2
import numpy as np
import pytest
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,
DoubaoEngine,
_alpha_template,
_glyph_silhouette,
_template_match_score,
load_image_bgr,
)
SAMPLE = Path(__file__).resolve().parents[1] / "data" / "samples" / "doubao-1.png"
class TestLocate:
def test_box_anchored_bottom_right(self):
eng = DoubaoEngine()
img = np.zeros((2048, 2048, 3), np.uint8)
loc = eng.locate(img)
assert 2048 - (loc.x + loc.w) < int(2048 * 0.03)
assert 2048 - (loc.y + loc.h) < int(2048 * 0.03)
def test_box_scales_with_width(self):
eng = DoubaoEngine()
small = eng.locate(np.zeros((1024, 1024, 3), np.uint8))
large = eng.locate(np.zeros((2048, 2048, 3), np.uint8))
assert large.w == pytest.approx(small.w * 2, rel=0.1)
# ── Detection: alpha-template NCC ───────────────────────────────────
class TestDetect:
def test_clean_gradient_not_detected(self):
eng = DoubaoEngine()
ramp = np.tile(np.linspace(0, 255, 1024, dtype=np.uint8), (1024, 1))
img = cv2.cvtColor(ramp, cv2.COLOR_GRAY2BGR)
assert not eng.detect(img).detected
def test_solid_blob_corner_not_detected(self):
"""A bright blob is not the glyph shape -> low correlation, not detected."""
eng = DoubaoEngine()
img = np.zeros((1024, 1024, 3), np.uint8)
x, y, bw, bh = eng.locate(img).bbox
img[y + bh // 4 : y + bh * 3 // 4, x : x + bw // 2] = 200
assert not eng.detect(img).detected
def test_silhouette_loads(self):
sil = _glyph_silhouette()
assert sil is not None
assert set(np.unique(sil)).issubset({0, 255})
def test_match_score_shape_sensitive(self):
"""The glyph silhouette correlates with itself, not with a filled block."""
sil = _glyph_silhouette()
h, w = sil.shape
# box that contains the silhouette -> high score
box = np.zeros((h + 8, int(w / _ALPHA_WIDTH_FRAC * 0.2) + w), np.uint8)
box[4 : 4 + h, 4 : 4 + w] = sil
assert _template_match_score(box, _ALPHA_NATIVE_WIDTH) >= DETECT_NCC_THRESHOLD
# a uniformly filled box has no glyph structure -> low score
solid = np.full_like(box, 255)
assert _template_match_score(solid, _ALPHA_NATIVE_WIDTH) < DETECT_NCC_THRESHOLD
@pytest.mark.skipif(not SAMPLE.exists(), reason="sample image not present")
class TestRealSample:
def test_detects_watermark(self):
det = DoubaoEngine().detect(load_image_bgr(SAMPLE))
assert det.detected
assert det.confidence >= DETECT_NCC_THRESHOLD
def test_reverse_alpha_removes_mark(self):
eng = DoubaoEngine()
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
def test_far_region_untouched(self):
eng = DoubaoEngine()
img = load_image_bgr(SAMPLE)
out = eng.remove_watermark_reverse_alpha(img)
h, w = img.shape[:2]
assert np.array_equal(img[: h // 2, : w // 2], out[: h // 2, : w // 2])
# ── Reverse-alpha (exact recovery) ──────────────────────────────────
class TestReverseAlpha:
def test_alpha_asset_loads(self):
at = _alpha_template()
assert at is not None
assert at.dtype.kind == "f"
assert float(at.min()) >= 0.0
assert float(at.max()) <= 1.0
def test_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
def test_native_returns_exact_reverse_alpha_no_inpaint(self):
"""At native width the recovery is exact, so it must be returned untouched
-- inpainting over exactly-recovered interior pixels degrades quality
(regression: native textured error 1.6 reverse-alpha-only vs 2.6 with the
old full-footprint inpaint). The output must equal pure reverse-alpha."""
eng = DoubaoEngine()
wm, _mark = self._compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
out = eng.remove_watermark_reverse_alpha(wm)
amap = eng._fixed_alpha_map(wm)
assert amap is not None
expected = eng._apply_reverse_alpha(wm, amap[0])
assert np.array_equal(out, expected) # no inpaint touched the recovery
@pytest.mark.parametrize(
("w", "h", "max_err"),
[
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH, 5.0), # native 1:1 -> fixed geometry, ~exact
(1773, 2364, 8.0), # 3:4 portrait -> NCC alignment generalizes the single capture
],
)
def test_recovers_flat_background(self, w, h, max_err):
"""Recovers the flat background at native (fixed geometry, exact) AND a
non-native resolution (NCC alignment generalizes the single capture)."""
eng = DoubaoEngine()
wm, mark = self._compose(w, h)
assert float(np.abs(wm.astype(np.float32)[mark] - 100.0).mean()) > 15 # mark visible
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
assert float(np.abs(out[mark] - 100.0).mean()) < max_err
+299
View File
@@ -17,7 +17,9 @@ from remove_ai_watermarks.identify import (
ProvenanceReport,
_ai_tools_in,
_attribute_platform,
_integrity_clashes,
_issuers_in,
_vendor_of,
identify,
)
@@ -111,6 +113,18 @@ class TestIdentifyNonPng:
r = identify(path, check_visible=False)
assert any("SynthID" in w for w in r.watermarks)
def test_black_forest_labs_flux_attributed(self, tmp_path: Path):
path = self._c2pa_jpeg(tmp_path, b"Black Forest Labs API ... trainedAlgorithmicMedia")
r = identify(path, check_visible=False, check_invisible=False)
assert r.is_ai_generated is True
assert r.platform == "Black Forest Labs (FLUX)"
def test_bytedance_volcengine_attributed(self, tmp_path: Path):
path = self._c2pa_jpeg(tmp_path, b"certificate_center@volcengine.com ... trainedAlgorithmicMedia")
r = identify(path, check_visible=False, check_invisible=False)
assert r.is_ai_generated is True
assert "ByteDance" in (r.platform or "")
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)
@@ -130,6 +144,50 @@ class TestIdentifyNonPng:
assert not any("SynthID" in w for w in r.watermarks)
class TestIdentifySamsungGalaxy:
"""Samsung Galaxy / ASUS Gallery C2PA signers (verified on real signed files
2026-05-29; synthetic byte blobs here since the originals are private).
Galaxy AI edits stamp BOTH the device cert AND an AI source-type / genAIType,
so the signer attribution must NOT trip the camera-vs-AI integrity clash.
"""
def _jpeg(self, tmp_path: Path, name: str, blob: bytes) -> Path:
path = tmp_path / name
path.write_bytes(b"\xff\xd8\xff\xe1jumbc2pa" + blob + b"\xff\xd9")
return path
def test_galaxy_trained_source_is_high_ai(self, tmp_path: Path):
path = self._jpeg(tmp_path, "s25.jpg", b"Samsung Galaxy Galaxy S25 c2pa-rs trainedAlgorithmicMedia")
r = identify(path, check_visible=False, check_invisible=False)
assert r.is_ai_generated is True
assert r.confidence == "high"
assert r.platform == "Samsung Galaxy (C2PA)"
assert r.integrity_clashes == [] # device cert + AI source-type is legitimate, not a clash
def test_galaxy_genai_only_is_medium_ai(self, tmp_path: Path):
# The Galaxy S24 case: no trainedAlgorithmicMedia, genAIType is the only
# AI marker -- previously missed, now a medium-confidence verdict.
path = self._jpeg(
tmp_path, "s24.jpg", b'Samsung Galaxy Galaxy S24 c2pa-rs PhotoEditor_Re_Edit_Data{"genAIType":1}'
)
r = identify(path, check_visible=False, check_invisible=False)
assert r.is_ai_generated is True
assert r.confidence == "medium"
assert r.platform == "Samsung Galaxy (C2PA)"
assert any(s.name == "samsung_genai" for s in r.signals)
assert r.integrity_clashes == []
def test_asus_gallery_signer_not_ai(self, tmp_path: Path):
# ASUS Gallery signs edited photos; no AI source-type or genAIType, so the
# platform is attributed but the verdict stays unknown.
path = self._jpeg(tmp_path, "asus.jpg", b"/com.asus.gallery/3.8.0.98 c2pa-rs no ai marker")
r = identify(path, check_visible=False, check_invisible=False)
assert r.is_ai_generated is None
assert r.platform == "ASUS Gallery (C2PA signer)"
assert any("C2PA" in w for w in r.watermarks)
# ── End-to-end verdicts on real fixtures ────────────────────────────
@@ -199,6 +257,78 @@ class TestIdentifyLocalParams:
assert r.signals == []
# ── China TC260 AIGC label as a PNG text chunk (Doubao) ─────────────
class TestIdentifyAigcPngChunk:
"""The raw-JSON ``AIGC`` PNG chunk (no namespaced XMP marker) is a high-
confidence AI verdict, same as the XMP form."""
def _aigc_chunk_png(self, tmp_path: Path) -> Path:
from PIL import Image
from PIL.PngImagePlugin import PngInfo
p = tmp_path / "doubao_chunk.png"
pnginfo = PngInfo()
pnginfo.add_text("AIGC", json.dumps({"Label": "1", "ContentProducer": "doubao"}))
Image.new("RGB", (32, 32)).save(p, pnginfo=pnginfo)
return p
def test_png_chunk_detected_high(self, tmp_path: Path):
r = identify(self._aigc_chunk_png(tmp_path), check_visible=False)
assert r.is_ai_generated is True
assert r.confidence == "high"
assert r.platform is not None
assert "AIGC" in r.platform
signal = next(s for s in r.signals if s.name == "aigc")
assert "doubao" in signal.detail
# ── HuggingFace-hosted job marker (medium confidence) ───────────────
class TestIdentifyHuggingFaceJob:
"""The hf-job-id chunk lifts an otherwise-Unknown verdict to a tentative
(medium) AI, never overriding a high-confidence metadata signal."""
def _hf_png(self, tmp_path: Path) -> Path:
from PIL import Image
from PIL.PngImagePlugin import PngInfo
p = tmp_path / "hfjob.png"
pnginfo = PngInfo()
pnginfo.add_text("hf-job-id", "ec8380a6-2091-423a-b835-209420f99ee1")
Image.new("RGB", (32, 32)).save(p, pnginfo=pnginfo)
return p
def test_hf_job_promotes_to_medium(self, tmp_path: Path):
r = identify(self._hf_png(tmp_path), check_visible=False)
assert r.is_ai_generated is True
assert r.confidence == "medium"
assert r.platform is not None
assert "HuggingFace" in r.platform
signal = next(s for s in r.signals if s.name == "hf_job")
assert signal.confidence == "medium"
def test_hf_job_caveat_present(self, tmp_path: Path):
r = identify(self._hf_png(tmp_path), check_visible=False)
assert any("hf-job-id" in c for c in r.caveats)
def test_metadata_keeps_high_even_with_hf_job(self, tmp_png_with_ai_metadata: Path):
# A high-confidence metadata verdict is not downgraded by an hf-job hit.
from PIL import Image
from PIL.PngImagePlugin import PngInfo
img = Image.open(tmp_png_with_ai_metadata)
pnginfo = PngInfo()
for k, v in img.text.items():
pnginfo.add_text(k, v)
pnginfo.add_text("hf-job-id", "ec8380a6-2091-423a-b835-209420f99ee1")
img.save(tmp_png_with_ai_metadata, pnginfo=pnginfo)
r = identify(tmp_png_with_ai_metadata, check_visible=False)
assert r.confidence == "high"
# ── Visible-sparkle fallback (mocked detector) ──────────────────────
@@ -311,6 +441,77 @@ class TestIdentifyXaiSignature:
assert any("xAI/Grok" in w for w in r.watermarks)
class TestIdentifySoftBinding:
"""A C2PA soft-binding alg names a forensic-watermark vendor in the inventory."""
def test_soft_binding_vendor_listed(self, tmp_path: Path):
p = tmp_path / "sb.jpg"
p.write_bytes(b"\xff\xd8\xff\xe1 c2pa jumb com.digimarc.validate.1 \xff\xd9")
r = identify(p, check_visible=False, check_invisible=False)
assert any("Digimarc" in w for w in r.watermarks)
assert any(s.name == "soft_binding" for s in r.signals)
class TestIdentifyIptcAi:
"""IPTC 2025.1 AISystemUsed drives an AI verdict + platform attribution."""
def test_iptc_ai_system_attributed(self, tmp_path: Path):
p = tmp_path / "iptc.jpg"
p.write_bytes(
b"\xff\xd8\xff\xe1<x:xmpmeta><Iptc4xmpExt:AISystemUsed>Google Gemini"
b"</Iptc4xmpExt:AISystemUsed></x:xmpmeta>\xff\xd9"
)
r = identify(p, check_visible=False, check_invisible=False)
assert r.is_ai_generated is True
assert r.platform is not None
assert "Gemini" in r.platform
class TestIdentifyC2paDevice:
"""A distinctive C2PA device token wins platform attribution over incidental
issuer-name mentions (regression guard for real-sample mis-attribution:
Leica->Truepic, Nikon->Adobe, Pixel->Google Gemini)."""
def test_leica_token_beats_incidental_tokens(self, tmp_path: Path):
# "Adobe"/"Google"/"Truepic" appear incidentally; Leica's lc_c2pa wins.
blob = b"\xff\xd8\xff\xe1 c2pa.claim jumbf Adobe Google Truepic lc_c2pa \xff\xd9"
p = tmp_path / "leica_like.jpg"
p.write_bytes(blob)
r = identify(p, check_visible=False, check_invisible=False)
assert r.platform == "Leica (camera, C2PA capture)"
def test_pixel_camera_cert_beats_incidental_google(self, tmp_path: Path):
# Pixel's cert CN is "Pixel Camera"; "Google LLC" appears as the cert org
# but must NOT yield "Google (Gemini / Imagen)" -- it is a camera capture.
blob = b"\xff\xd8\xff\xe1 c2pa.claim jumbf Google LLC Adobe Pixel Camera \xff\xd9"
p = tmp_path / "pixel_like.jpg"
p.write_bytes(blob)
r = identify(p, check_visible=False, check_invisible=False)
assert r.platform == "Google Pixel (camera, C2PA capture)"
assert r.is_ai_generated is None # camera capture, not AI
def test_sony_namespace_beats_bare_make(self, tmp_path: Path):
# Sony's own C2PA assertion namespace (sony.sig), not the bare "Sony"
# EXIF Make that appears on ordinary photos.
blob = b"\xff\xd8\xff\xe1 c2pa.claim jumbf Adobe Sony sony.sig.v1_1 \xff\xd9"
p = tmp_path / "sony_like.jpg"
p.write_bytes(blob)
r = identify(p, check_visible=False, check_invisible=False)
assert r.platform == "Sony (camera, C2PA capture)"
def test_unmapped_device_not_mislabeled_via_incidental_issuer(self, tmp_path: Path):
# An unmapped camera (Canon) whose manifest incidentally contains the
# "Adobe" XMP-toolkit string, with NO AI source type, must NOT be labeled
# "Adobe Firefly". The issuer->generator mapping only applies to AI content.
blob = b"\xff\xd8\xff\xe1 c2pa.claim jumbf Canon EOS Adobe XMP Core \xff\xd9"
p = tmp_path / "canon_like.jpg"
p.write_bytes(blob)
r = identify(p, check_visible=False, check_invisible=False)
assert r.is_ai_generated is None # camera capture, not AI
assert r.platform is not None
assert "Firefly" not in r.platform # not mislabeled as an AI generator
# ── Open invisible watermark (SD/SDXL/FLUX) integration ─────────────
from remove_ai_watermarks.invisible_watermark import is_available as _wm_available # noqa: E402
@@ -376,3 +577,101 @@ class TestIdentifyAIGC:
r = identify(self._aigc_png(tmp_path), check_visible=False)
sig = next(s for s in r.signals if s.name == "aigc")
assert "BYTEDANCE001" in sig.detail
# ── Integrity clashes (contradictions between independent signals) ──────
class TestVendorOf:
def test_openai_variants(self):
assert _vendor_of("OpenAI (ChatGPT / gpt-image / DALL-E / Sora)") == "OpenAI"
assert _vendor_of("DALL-E 3") == "OpenAI"
def test_google_variants(self):
assert _vendor_of("Google (Gemini / Imagen)") == "Google"
assert _vendor_of("Imagen 3") == "Google"
def test_other_vendors(self):
assert _vendor_of("Ideogram AI") == "Ideogram"
assert _vendor_of("Adobe Firefly") == "Adobe"
assert _vendor_of("Stability AI (Stable Image)") == "Stability AI"
def test_camera_label_is_not_an_ai_vendor(self):
# Camera platform labels must NOT normalize to an AI vendor, or a camera
# capture would be mistaken for AI-generation in clash detection.
assert _vendor_of("Leica (camera, C2PA capture)") is None
def test_unknown_is_none(self):
assert _vendor_of("a regular photo") is None
assert _vendor_of(None) is None
class TestIntegrityClashesHelper:
def test_two_ai_vendors_clash(self):
clashes = _integrity_clashes({"c2pa": "OpenAI", "exif_generator": "Ideogram"}, None, camera_has_ai_marker=True)
assert len(clashes) == 1
assert "OpenAI" in clashes[0]
assert "Ideogram" in clashes[0]
def test_same_vendor_two_signals_no_clash(self):
# C2PA Google + SynthID-Google proxy is consistent, not a contradiction.
assert _integrity_clashes({"c2pa": "Google", "synthid": "Google"}, None, camera_has_ai_marker=True) == []
def test_single_vendor_no_clash(self):
assert _integrity_clashes({"c2pa": "OpenAI"}, None, camera_has_ai_marker=True) == []
def test_empty_no_clash(self):
assert _integrity_clashes({}, None, camera_has_ai_marker=False) == []
def test_camera_plus_ai_marker_clashes(self):
clashes = _integrity_clashes(
{"exif_generator": "Ideogram"},
"Google Pixel (camera, C2PA capture)",
camera_has_ai_marker=True,
)
assert any("Camera-capture" in c and "Pixel" in c for c in clashes)
def test_camera_without_ai_marker_no_clash(self):
# A clean camera capture (the normal case for our Pixel/Leica/Sony files)
# must NOT raise a clash.
assert _integrity_clashes({}, "Leica (camera, C2PA capture)", camera_has_ai_marker=False) == []
class TestIntegrityClashEndToEnd:
def _c2pa_jpeg(self, tmp_path: Path, blob: bytes) -> Path:
path = tmp_path / "img.jpg"
path.write_bytes(b"\xff\xd8\xff\xe1jumbc2pa" + blob + b"\xff\xd9")
return path
def test_two_generator_stamps_clash(self, tmp_path: Path):
# An OpenAI C2PA manifest (AI source) on an image that ALSO carries a
# China TC260 AIGC label = two independent generator stamps naming
# different origins -> a laundering tell.
path = self._c2pa_jpeg(tmp_path, b"OpenAI ... trainedAlgorithmicMedia ... TC260:AIGC label")
r = identify(path, check_visible=False, check_invisible=False)
assert r.integrity_clashes
assert any("Conflicting AI-origin" in c for c in r.integrity_clashes)
def test_single_stamp_no_clash(self, tmp_path: Path):
path = self._c2pa_jpeg(tmp_path, b"OpenAI ... trainedAlgorithmicMedia")
r = identify(path, check_visible=False, check_invisible=False)
assert r.integrity_clashes == []
def test_clash_serializes_to_json(self, tmp_path: Path):
path = self._c2pa_jpeg(tmp_path, b"OpenAI ... trainedAlgorithmicMedia ... TC260:AIGC label")
r = identify(path, check_visible=False, check_invisible=False)
payload = json.loads(json.dumps(asdict(r), default=str))
assert payload["integrity_clashes"] == r.integrity_clashes
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/samples not present")
@pytest.mark.parametrize("fixture", ["chatgpt-1.png", "firefly-1.png", "doubao-1.png", "grok-1.jpg", "mj-1.png"])
class TestRealSamplesHaveNoClash:
"""Every real single-origin fixture must report zero clashes (false-positive guard)."""
def test_no_false_positive_clash(self, fixture: str):
path = SAMPLES_DIR / fixture
if not path.exists():
pytest.skip(f"{fixture} not present")
r = identify(path, check_visible=False, check_invisible=False)
assert r.integrity_clashes == []
+74
View File
@@ -0,0 +1,74 @@
"""Tests for Unicode-safe cv2 image IO (issue #17)."""
from __future__ import annotations
from typing import TYPE_CHECKING
import cv2
import numpy as np
from remove_ai_watermarks import image_io
if TYPE_CHECKING:
from pathlib import Path
# Non-ASCII filenames that break cv2.imread/imwrite on Windows (issue #17).
_UNICODE_NAMES = [
"jimeng-2026-05-27-一面白色的墙.png", # Chinese
"тест-изображение.png", # Cyrillic
"café-señor.png", # accented Latin
]
def _make_bgr() -> np.ndarray:
img = np.zeros((8, 8, 3), dtype=np.uint8)
img[2:6, 2:6] = (10, 120, 240) # a BGR block so the round-trip is checkable
return img
class TestUnicodeRoundTrip:
def test_write_then_read_preserves_pixels(self, tmp_path: Path) -> None:
for name in _UNICODE_NAMES:
path = tmp_path / name
src = _make_bgr()
assert image_io.imwrite(path, src) is True
assert path.exists()
out = image_io.imread(path)
assert out is not None
# PNG is lossless: pixels must match exactly.
assert np.array_equal(out, src)
def test_alpha_round_trip_with_unchanged_flag(self, tmp_path: Path) -> None:
path = tmp_path / "豆包-alpha.png"
bgra = np.zeros((8, 8, 4), dtype=np.uint8)
bgra[..., 3] = 128
assert image_io.imwrite(path, bgra) is True
out = image_io.imread(path, cv2.IMREAD_UNCHANGED)
assert out is not None
assert out.shape[2] == 4
assert np.array_equal(out, bgra)
def test_reads_file_written_by_raw_cv2(self, tmp_path: Path) -> None:
# An ASCII file written by plain cv2 must read back identically through
# the wrapper (decode path is byte-compatible with cv2.imread).
path = tmp_path / "ascii.png"
src = _make_bgr()
cv2.imwrite(str(path), src)
out = image_io.imread(path)
assert out is not None
assert np.array_equal(out, src)
class TestFailureSemantics:
def test_missing_file_returns_none(self, tmp_path: Path) -> None:
assert image_io.imread(tmp_path / "does-not-exist-不存在.png") is None
def test_empty_file_returns_none(self, tmp_path: Path) -> None:
path = tmp_path / "empty.png"
path.write_bytes(b"")
assert image_io.imread(path) is None
def test_undecodable_file_returns_none(self, tmp_path: Path) -> None:
path = tmp_path / "garbage.png"
path.write_bytes(b"not an image")
assert image_io.imread(path) is None
+79 -1
View File
@@ -14,7 +14,11 @@ from unittest.mock import Mock
import pytest
from remove_ai_watermarks.noai import img2img_runner
from remove_ai_watermarks.noai.img2img_runner import run_img2img, run_img2img_with_mps_fallback
from remove_ai_watermarks.noai.img2img_runner import (
run_differential_with_mps_fallback,
run_img2img,
run_img2img_with_mps_fallback,
)
_MPS_OOM = "MPS backend out of memory (MPS allocated: 17.21 GiB, max allowed: 20.13 GiB)"
@@ -106,6 +110,80 @@ class TestMpsFallback:
reload_on_cpu.assert_not_called()
class TestDifferentialMpsFallback:
"""The protect-text (Differential Diffusion) path shares the MPS->CPU
fallback contract; mock ``run_differential`` so no torch/model is needed."""
def test_mps_error_reloads_on_cpu_and_retries(self, monkeypatch: pytest.MonkeyPatch):
sentinel = object()
inner = Mock(side_effect=[RuntimeError(_MPS_OOM), sentinel])
monkeypatch.setattr(img2img_runner, "run_differential", inner)
reload_on_cpu = Mock(return_value="cpu_pipe")
img, device = run_differential_with_mps_fallback(
load_pipeline=Mock(return_value="gpu_pipe"),
image=object(),
change_map=object(),
strength=0.05,
num_inference_steps=50,
guidance_scale=7.5,
generator="gen",
device="mps",
set_progress=lambda _m: None,
reload_on_cpu=reload_on_cpu,
)
assert (img, device) == (sentinel, "cpu")
reload_on_cpu.assert_called_once()
assert inner.call_count == 2
# Retry uses the reloaded CPU pipeline, device "cpu", and drops the MPS
# generator (generator=None) for deterministic CPU execution.
retry_args = inner.call_args_list[1].args
assert retry_args[0] == "cpu_pipe"
assert retry_args[6] is None # generator
assert retry_args[7] == "cpu" # device
def test_happy_path_returns_original_device_without_reload(self, monkeypatch: pytest.MonkeyPatch):
sentinel = object()
monkeypatch.setattr(img2img_runner, "run_differential", Mock(return_value=sentinel))
reload_on_cpu = Mock()
img, device = run_differential_with_mps_fallback(
load_pipeline=Mock(return_value="gpu_pipe"),
image=object(),
change_map=object(),
strength=0.05,
num_inference_steps=50,
guidance_scale=7.5,
generator="gen",
device="mps",
set_progress=lambda _m: None,
reload_on_cpu=reload_on_cpu,
)
assert (img, device) == (sentinel, "mps")
reload_on_cpu.assert_not_called()
def test_non_mps_runtime_error_propagates(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(img2img_runner, "run_differential", Mock(side_effect=RuntimeError("CUDA out of memory")))
reload_on_cpu = Mock()
with pytest.raises(RuntimeError, match="CUDA"):
run_differential_with_mps_fallback(
load_pipeline=Mock(return_value="gpu_pipe"),
image=object(),
change_map=object(),
strength=0.05,
num_inference_steps=50,
guidance_scale=7.5,
generator="gen",
device="mps",
set_progress=lambda _m: None,
reload_on_cpu=reload_on_cpu,
)
reload_on_cpu.assert_not_called()
class TestRunImg2Img:
def test_returns_first_image_from_pipeline_result(self):
sentinel = object()
+9 -3
View File
@@ -12,9 +12,15 @@ class TestIsAvailable:
result = is_available()
assert isinstance(result, bool)
def test_available_when_torch_installed(self):
"""torch + diffusers should be installed in dev env."""
assert is_available() is True
def test_available_reflects_dependencies(self):
"""is_available() is True iff torch + diffusers (the gpu extra) import.
Must not assume the full stack: the core+dev CI env has no diffusers.
"""
import importlib.util
expected = all(importlib.util.find_spec(m) is not None for m in ("torch", "diffusers"))
assert is_available() is expected
class TestInvisibleEngineInit:
+558
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
import piexif
@@ -10,11 +12,15 @@ from PIL import Image
from PIL.PngImagePlugin import PngInfo
from remove_ai_watermarks.metadata import (
C2PA_UUID,
_is_ai_key,
c2pa_marker_in,
exif_generator,
get_ai_metadata,
has_ai_metadata,
iptc_ai_system,
remove_ai_metadata,
samsung_genai,
synthid_source,
xai_signature,
)
@@ -132,6 +138,71 @@ class TestHasAiMetadata:
assert has_ai_metadata(path)
class TestC2paMarkerIn:
"""The C2PA presence check requires a JUMBF wrapper or the C2PA uuid box, so
a bare 4-byte ``c2pa`` substring (e.g. random compressed pixel data) does not
false-positive -- the regression behind 4 cleaned PNGs re-flagging C2PA."""
def test_jumbf_wrapped_c2pa_detected(self):
assert c2pa_marker_in(b"....jumbc2pa....manifest....") is True
def test_c2pa_uuid_box_detected(self):
assert c2pa_marker_in(b"\x00\x00\x00\x18uuid" + C2PA_UUID + b"payload") is True
def test_bare_c2pa_substring_not_detected(self):
# The exact false positive: "c2pa" appears in noise but no JUMBF/uuid box.
assert c2pa_marker_in(b"\x9c\xc3\xa7B1\x11c2pa\x80b\x804\xc5\xf9random idat") is False
def test_jumb_without_c2pa_not_detected(self):
assert c2pa_marker_in(b"some jumb box but no manifest label") is False
def test_empty_not_detected(self):
assert c2pa_marker_in(b"") is False
class TestSamsungGenai:
"""Samsung Galaxy AI editing marker (genAIType in PhotoEditor_Re_Edit_Data).
Synthetic byte blobs -- real Galaxy files are user content and not shipped
(public repo), same discipline as the Grok/Doubao fixtures.
"""
@staticmethod
def _samsung_jpeg(tmp_path: Path, name: str, payload: bytes) -> Path:
path = tmp_path / name
path.write_bytes(b"\xff\xd8\xff\xe1" + payload + b"\xff\xd9")
return path
def test_nonzero_genai_type_detected(self, tmp_path: Path):
p = self._samsung_jpeg(
tmp_path, "galaxy.jpg", b'PhotoEditor_Re_Edit_Data{"connectorType":"srvg","genAIType":1}'
)
assert samsung_genai(p) == 1
def test_other_nonzero_value_detected(self, tmp_path: Path):
p = self._samsung_jpeg(tmp_path, "galaxy5.jpg", b'PhotoEditor_Re_Edit_Data{"genAIType":5}')
assert samsung_genai(p) == 5
def test_zero_genai_type_is_none(self, tmp_path: Path):
"""genAIType:0 means no generative AI was used -- not a positive signal."""
p = self._samsung_jpeg(tmp_path, "edit.jpg", b'PhotoEditor_Re_Edit_Data{"genAIType":0}')
assert samsung_genai(p) is None
def test_genai_without_editor_container_ignored(self, tmp_path: Path):
"""An incidental genAIType token outside Samsung's editor JSON is ignored."""
p = self._samsung_jpeg(tmp_path, "stray.jpg", b'some other blob "genAIType":1 elsewhere')
assert samsung_genai(p) is None
def test_clean_image_is_none(self, tmp_clean_png):
assert samsung_genai(tmp_clean_png) is None
def test_surfaced_in_get_ai_metadata(self, tmp_path: Path):
p = self._samsung_jpeg(tmp_path, "galaxy.jpg", b'PhotoEditor_Re_Edit_Data{"genAIType":1}')
meta = get_ai_metadata(p)
assert "samsung_genai" in meta
assert "genAIType=1" in meta["samsung_genai"]
class TestGetAiMetadata:
"""Tests for extracting AI metadata."""
@@ -319,6 +390,44 @@ class TestRemoveAiMetadata:
assert result == jpg_path
assert jpg_path.exists()
def test_jpeg_output_is_high_quality(self, tmp_path):
"""JPEG output uses high quality + 4:4:4 (no chroma subsampling), not the
lossy PIL defaults (quality 75, 4:2:0) that visibly degrade the image."""
from PIL.JpegImagePlugin import get_sampling
img = Image.new("RGB", (64, 64), color=(100, 150, 200))
png_path = tmp_path / "source.png"
img.save(png_path)
jpg_path = tmp_path / "output.jpg"
remove_ai_metadata(png_path, jpg_path)
with Image.open(jpg_path) as out:
assert get_sampling(out) == 0 # 4:4:4, no chroma subsampling
# quality 95 quantization tables stay well below the q75 defaults
# (whose max quant value is ~40+); q95 tops out around 12.
assert max(max(t) for t in out.quantization.values()) <= 15
def test_webp_output_preserves_format_losslessly(self, tmp_path):
"""A .webp output keeps the WebP format (not silently rewritten to PNG)
and is pixel-identical to the source (lossless)."""
import numpy as np
rng = np.random.default_rng(0)
arr = rng.integers(0, 255, (48, 48, 3), dtype=np.uint8)
src = Image.fromarray(arr, "RGB")
pnginfo = PngInfo()
pnginfo.add_text("parameters", "ai stuff")
png_path = tmp_path / "source.png"
src.save(png_path, pnginfo=pnginfo)
webp_path = tmp_path / "output.webp"
remove_ai_metadata(png_path, webp_path)
with Image.open(webp_path) as out:
assert out.format == "WEBP"
assert np.array_equal(np.asarray(out.convert("RGB")), arr)
def test_creates_parent_directories(self, tmp_path):
img = Image.new("RGB", (32, 32))
pnginfo = PngInfo()
@@ -551,6 +660,164 @@ class TestAIGCLabel:
assert "aigc_label" in meta
assert "TC260" in meta["aigc_label"]
def _aigc_chunk_png(self, tmp_path: Path, producer: str = "doubao") -> Path:
"""Doubao writes the TC260 object as a PNG ``tEXt`` chunk keyed ``AIGC``
with raw JSON (no XMP, no namespaced marker)."""
import json
p = tmp_path / "doubao_chunk.png"
pnginfo = PngInfo()
pnginfo.add_text(
"AIGC",
json.dumps({"Label": "1", "ContentProducer": producer, "ProduceID": "abc123"}),
)
Image.new("RGB", (32, 32)).save(p, pnginfo=pnginfo)
return p
def test_parses_png_text_chunk_form(self, tmp_path: Path):
from remove_ai_watermarks.metadata import aigc_label
info = aigc_label(self._aigc_chunk_png(tmp_path))
assert info is not None
assert info["Label"] == "1"
assert info["ContentProducer"] == "doubao"
def test_png_chunk_without_tc260_field_ignored(self, tmp_path: Path):
"""A generic ``AIGC`` chunk with no TC260 field must not false-positive."""
import json
from remove_ai_watermarks.metadata import aigc_label
p = tmp_path / "unrelated.png"
pnginfo = PngInfo()
pnginfo.add_text("AIGC", json.dumps({"unrelated": "value"}))
Image.new("RGB", (32, 32)).save(p, pnginfo=pnginfo)
assert aigc_label(p) is None
def test_has_ai_metadata_detects_png_chunk_form(self, tmp_path: Path):
assert has_ai_metadata(self._aigc_chunk_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
out = tmp_path / "clean.png"
remove_ai_metadata(self._aigc_chunk_png(tmp_path), out)
assert aigc_label(out) is None
assert not has_ai_metadata(out)
def _aigc_exif_jpeg(self, tmp_path: Path, producer: str = "001191440300708461136T1308L") -> Path:
"""Some China-served generators embed the raw-JSON ``{"AIGC":{...}}``
block in JPEG EXIF (UserComment) -- no PNG chunk, no namespaced XMP."""
import json
import piexif
p = tmp_path / "aigc_exif.jpg"
Image.new("RGB", (32, 32)).save(p)
payload = json.dumps({"AIGC": {"Label": "1", "ContentProducer": producer, "ProduceID": "abc123"}})
exif = {"Exif": {piexif.ExifIFD.UserComment: payload.encode("ascii")}}
piexif.insert(piexif.dump(exif), str(p))
return p
def test_parses_raw_json_exif_form(self, tmp_path: Path):
from remove_ai_watermarks.metadata import aigc_label
info = aigc_label(self._aigc_exif_jpeg(tmp_path))
assert info is not None
assert info["Label"] == "1"
assert info["ContentProducer"] == "001191440300708461136T1308L"
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_raw_json_without_tc260_field_ignored(self, tmp_path: Path):
"""A bare ``{"AIGC":{...}}`` object with no TC260 field must not fire."""
import json
import piexif
from remove_ai_watermarks.metadata import aigc_label
p = tmp_path / "unrelated.jpg"
Image.new("RGB", (32, 32)).save(p)
payload = json.dumps({"AIGC": {"unrelated": "value"}})
exif = {"Exif": {piexif.ExifIFD.UserComment: payload.encode("ascii")}}
piexif.insert(piexif.dump(exif), str(p))
assert aigc_label(p) is None
def _aigc_attr_png(self, tmp_path: Path, producer: str = "picwish") -> Path:
"""PicWish writes the TC260 label as an XMP *attribute*
(``TC260:AIGC="{...}"``), not the nested element form."""
p = tmp_path / "picwish.png"
Image.new("RGB", (32, 32)).save(p)
xmp = (
'<rdf:Description rdf:about="" '
'xmlns:TC260="http://www.tc260.org.cn/ns/AIGC/1.0/" '
f'TC260:AIGC="{{&quot;Label&quot;:&quot;1&quot;,&quot;ContentProducer&quot;:&quot;{producer}&quot;}}"/>'
)
with open(p, "ab") as f:
f.write(xmp.encode())
return p
def test_parses_xmp_attribute_form(self, tmp_path: Path):
from remove_ai_watermarks.metadata import aigc_label
info = aigc_label(self._aigc_attr_png(tmp_path))
assert info is not None
assert info["ContentProducer"] == "picwish"
def test_scan_head_collects_png_metadata_past_window(self, tmp_path: Path):
"""A PNG metadata chunk beyond the read window is still reachable -- the
regression for a TC260 XMP packet appended after a large IDAT."""
import json
from remove_ai_watermarks.metadata import _png_late_metadata, scan_head
p = tmp_path / "late.png"
pnginfo = PngInfo()
pnginfo.add_text("AIGC", json.dumps({"Label": "1", "ContentProducer": "doubao"}))
Image.new("RGB", (16, 16)).save(p, pnginfo=pnginfo)
# window = 8 (just the signature) makes the text chunk "late".
assert b"ContentProducer" in _png_late_metadata(p, 8)
assert b"ContentProducer" in scan_head(p, 8)
class TestHuggingFaceJob:
"""HuggingFace-hosted job marker (``hf-job-id`` PNG text chunk)."""
def _hf_png(self, tmp_path: Path, job_id: str = "ec8380a6-2091-423a-b835-209420f99ee1") -> Path:
p = tmp_path / "hfjob.png"
pnginfo = PngInfo()
pnginfo.add_text("hf-job-id", job_id)
Image.new("RGB", (32, 32)).save(p, pnginfo=pnginfo)
return p
def test_returns_job_id(self, tmp_path: Path):
from remove_ai_watermarks.metadata import huggingface_job
assert huggingface_job(self._hf_png(tmp_path)) == "ec8380a6-2091-423a-b835-209420f99ee1"
def test_none_when_absent(self, tmp_clean_png):
from remove_ai_watermarks.metadata import huggingface_job
assert huggingface_job(tmp_clean_png) is None
def test_has_ai_metadata_detects_hf_job(self, tmp_path: Path):
assert has_ai_metadata(self._hf_png(tmp_path))
def test_get_ai_metadata_surfaces_hf_job(self, tmp_path: Path):
meta = get_ai_metadata(self._hf_png(tmp_path))
assert "huggingface_job" in meta
assert "ec8380a6" in meta["huggingface_job"]
def test_remove_strips_hf_job(self, tmp_path: Path):
from remove_ai_watermarks.metadata import huggingface_job, remove_ai_metadata
out = tmp_path / "clean.png"
remove_ai_metadata(self._hf_png(tmp_path), out)
assert huggingface_job(out) is None
assert not has_ai_metadata(out)
@pytest.mark.skipif(not (SAMPLES_DIR / "doubao-1.png").exists(), reason="doubao sample not present")
class TestAIGCRealSample:
@@ -567,3 +834,294 @@ class TestAIGCRealSample:
def test_doubao_detected_as_ai(self):
assert has_ai_metadata(SAMPLES_DIR / "doubao-1.png")
assert "aigc_label" in get_ai_metadata(SAMPLES_DIR / "doubao-1.png")
class TestSoftBinding:
"""C2PA soft-binding alg identifier -> forensic-watermark vendor name."""
def test_vendors_in_recognizes_known_algs(self):
from remove_ai_watermarks.noai.c2pa import soft_binding_vendors_in
assert soft_binding_vendors_in(b"...alg...com.adobe.trustmark.P...") == ["Adobe TrustMark"]
assert soft_binding_vendors_in(b"com.digimarc.validate.1") == ["Digimarc"]
assert soft_binding_vendors_in(b"ai.steg.api blah") == ["Steg.AI"]
# Registry-verified vendors added in v0.6.x.
assert soft_binding_vendors_in(b"ai.trufo.gen1.image") == ["Trufo"]
assert soft_binding_vendors_in(b"io.iscc.v0") == ["ISCC (content code)"]
def test_vendors_in_empty_when_absent(self):
from remove_ai_watermarks.noai.c2pa import soft_binding_vendors_in
assert soft_binding_vendors_in(b"no soft binding here") == []
def test_get_ai_metadata_surfaces_soft_binding(self, tmp_path: Path):
# Non-PNG binary-scan path: a manifest naming a soft-binding vendor.
p = tmp_path / "fake.jpg"
p.write_bytes(b"\xff\xd8\xff\xe1 c2pa jumb com.adobe.trustmark.P \xff\xd9")
assert get_ai_metadata(p).get("soft_binding") == "Adobe TrustMark"
class TestIptcAiFields:
"""IPTC 2025.1 AI-disclosure XMP properties (Iptc4xmpExt:AISystemUsed etc.)."""
def test_detects_ai_system_used_element_form(self, tmp_path: Path):
p = tmp_path / "iptc_ai.jpg"
p.write_bytes(
b"\xff\xd8\xff\xe1<x:xmpmeta><Iptc4xmpExt:AISystemUsed>ChatGPT DALL-E"
b"</Iptc4xmpExt:AISystemUsed></x:xmpmeta>\xff\xd9"
)
assert has_ai_metadata(p) is True
assert iptc_ai_system(p) == "ChatGPT DALL-E"
assert "ChatGPT DALL-E" in get_ai_metadata(p)["ai_system"]
def test_attribute_serialization(self, tmp_path: Path):
p = tmp_path / "attr.jpg"
p.write_bytes(b'\xff\xd8\xff\xe1 Iptc4xmpExt:AISystemUsed="Google Gemini" \xff\xd9')
assert iptc_ai_system(p) == "Google Gemini"
def test_present_without_value(self, tmp_path: Path):
# A disclosure field with no extractable value still flags presence.
p = tmp_path / "novalue.jpg"
p.write_bytes(b"\xff\xd8\xff\xe1 Iptc4xmpExt:AIPromptWriterName \xff\xd9")
assert iptc_ai_system(p) == "fields present"
assert has_ai_metadata(p) is True
def test_clean_image_none(self, tmp_clean_png: Path):
assert iptc_ai_system(tmp_clean_png) is None
# Synthetic MP4 (ISOBMFF): ftyp + C2PA uuid box + mdat. Same box format as AVIF.
_MP4_FTYP = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom"
_MP4_MDAT = b"\x00\x00\x00\x10mdat" + b"videodat"
def _box(box_type: bytes, payload: bytes) -> bytes:
"""Build a 32-bit-size ISOBMFF box: [size:4][type:4][payload]."""
return (8 + len(payload)).to_bytes(4, "big") + box_type + payload
class TestVideoC2pa:
"""C2PA in MP4 (ISOBMFF) -- detect + strip, reusing the image box walker."""
def test_detects_c2pa_in_mp4(self, tmp_path: Path):
from remove_ai_watermarks.metadata import C2PA_UUID
uuid_box = b"\x00\x00\x00\x20uuid" + C2PA_UUID + b"manifest"
p = tmp_path / "ai.mp4"
p.write_bytes(_MP4_FTYP + uuid_box + _MP4_MDAT)
assert has_ai_metadata(p) is True
def test_strips_c2pa_in_mp4(self, tmp_path: Path):
from remove_ai_watermarks.metadata import C2PA_UUID
uuid_box = b"\x00\x00\x00\x20uuid" + C2PA_UUID + b"manifest"
src = tmp_path / "in.mp4"
src.write_bytes(_MP4_FTYP + uuid_box + _MP4_MDAT)
out = tmp_path / "out.mp4"
remove_ai_metadata(src, out)
assert out.read_bytes() == _MP4_FTYP + _MP4_MDAT
assert has_ai_metadata(out) is False
class TestLateProvenanceBox:
"""A C2PA / provenance box placed AFTER a large mdat (streaming / non-faststart
MP4) must still be detected -- the fixed first-MB scan would miss it."""
def _mp4_late_c2pa(self, tmp_path: Path, gap: int = 1_500_000) -> Path:
from remove_ai_watermarks.metadata import C2PA_UUID
big_mdat = _box(b"mdat", b"\x00" * gap) # > 1 MB pushes the manifest past the scan window
manifest = C2PA_UUID + b"OpenAI jumbf c2pa ... trainedAlgorithmicMedia ..."
p = tmp_path / "stream.mp4"
p.write_bytes(_MP4_FTYP + big_mdat + _box(b"uuid", manifest))
return p
def test_scan_c2pa_region_finds_late_box(self, tmp_path: Path):
from remove_ai_watermarks.metadata import C2PA_UUID
from remove_ai_watermarks.noai.isobmff import scan_c2pa_region
region = scan_c2pa_region(self._mp4_late_c2pa(tmp_path))
assert C2PA_UUID in region
assert b"trainedAlgorithmicMedia" in region
def test_fixed_window_would_have_missed_it(self, tmp_path: Path):
# Documents the regression the box walk fixes: the manifest is beyond 1 MB.
from remove_ai_watermarks.metadata import C2PA_UUID
p = self._mp4_late_c2pa(tmp_path)
assert C2PA_UUID not in p.read_bytes()[: 1024 * 1024]
def test_scan_head_includes_late_box(self, tmp_path: Path):
from remove_ai_watermarks.metadata import C2PA_UUID, scan_head
assert C2PA_UUID in scan_head(self._mp4_late_c2pa(tmp_path))
def test_has_ai_metadata_detects_late_manifest(self, tmp_path: Path):
assert has_ai_metadata(self._mp4_late_c2pa(tmp_path)) is True
def test_scan_c2pa_region_non_isobmff_is_empty(self, tmp_path: Path):
from remove_ai_watermarks.noai.isobmff import scan_c2pa_region
p = tmp_path / "not.bin"
p.write_bytes(b"\x89PNG\r\n\x1a\n not an isobmff file")
assert scan_c2pa_region(p) == b""
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
manifest = C2PA_UUID + b"OpenAI ... trainedAlgorithmicMedia ..."
p = tmp_path / "front.mp4"
p.write_bytes(_MP4_FTYP + _box(b"uuid", manifest) + _box(b"mdat", b"\x00" * 100))
assert has_ai_metadata(p) is True
_AI_XMP = (
b'<?xpacket begin="\xef\xbb\xbf" id="W5M0MpCehiHzreSzNTczkc9d"?>'
b'<x:xmpmeta><TC260:AIGC>{"Label":"1"}</TC260:AIGC></x:xmpmeta>'
b'<?xpacket end="w"?>'
)
_PLAIN_XMP = (
b'<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>'
b"<x:xmpmeta><dc:rights>(c) me</dc:rights></x:xmpmeta>"
b'<?xpacket end="w"?>'
)
class TestMetaBoxXmpBlanking:
"""HEIF/AVIF store XMP as a meta-box ``mime`` item (bytes in mdat/idat), out of
reach of the top-level box stripper. An AI-label XMP packet there is blanked
in place (same length -> iloc offsets and image data stay intact)."""
def test_blanks_ai_packet_only(self):
from remove_ai_watermarks.noai.isobmff import blank_ai_xmp_packets
before, after = b"IMG_BEFORE" * 4, b"IMG_AFTER" * 4
data = before + _AI_XMP + after + _PLAIN_XMP
out, n = blank_ai_xmp_packets(data)
assert n == 1
assert len(out) == len(data) # same length -> no offset shifts
assert b"TC260:AIGC" not in out # AI label destroyed
assert before in out # surrounding (image) bytes intact
assert after in out
assert b"dc:rights" in out # plain XMP left alone
def test_no_packet_is_noop(self):
from remove_ai_watermarks.noai.isobmff import blank_ai_xmp_packets
data = b"just some mdat bytes, no xmp here"
assert blank_ai_xmp_packets(data) == (data, 0)
def test_plain_xmp_untouched(self):
from remove_ai_watermarks.noai.isobmff import blank_ai_xmp_packets
out, n = blank_ai_xmp_packets(_PLAIN_XMP)
assert n == 0
assert out == _PLAIN_XMP
def test_remove_ai_metadata_blanks_meta_box_xmp(self, tmp_path: Path):
# End-to-end: a HEIF with an AI XMP packet inside mdat is cleaned without
# touching the surrounding (coded image) bytes or the file length.
heic_ftyp = b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00heicmif1"
img = b"CODEDIMAGE" * 8
mdat = _box(b"mdat", img + _AI_XMP + img)
src = tmp_path / "ai.heic"
src.write_bytes(heic_ftyp + mdat)
assert has_ai_metadata(src) is True
out = tmp_path / "clean.heic"
remove_ai_metadata(src, out)
res = out.read_bytes()
assert len(res) == src.stat().st_size # length preserved
assert b"TC260:AIGC" not in res
assert img in res # coded image bytes intact
assert has_ai_metadata(out) is False
class TestIsobmffMetadataRemoval:
"""Container-level AI-provenance stripping across ISOBMFF image/video/audio."""
def test_strips_ai_xmp_uuid_box(self):
# A uuid box carrying a TC260 AIGC label is dropped by content match,
# regardless of the (non-C2PA) XMP UUID's byte order.
from remove_ai_watermarks.noai.isobmff import strip_c2pa_boxes
xmp_uuid = bytes(range(16)) # arbitrary, not the C2PA UUID
payload = b'<x:xmpmeta><TC260:AIGC>{"Label":"1"}</TC260:AIGC></x:xmpmeta>'
box = (24 + len(payload)).to_bytes(4, "big") + b"uuid" + xmp_uuid + payload
cleaned, stripped = strip_c2pa_boxes(_MP4_FTYP + box + _MP4_MDAT)
assert stripped == 1
assert cleaned == _MP4_FTYP + _MP4_MDAT
def test_keeps_plain_non_ai_xmp(self):
# A uuid box with ordinary (non-AI) XMP must be preserved.
from remove_ai_watermarks.noai.isobmff import strip_c2pa_boxes
xmp_uuid = bytes(range(16))
payload = b"<x:xmpmeta><dc:rights>(c) me</dc:rights></x:xmpmeta>"
box = (24 + len(payload)).to_bytes(4, "big") + b"uuid" + xmp_uuid + payload
cleaned, stripped = strip_c2pa_boxes(_MP4_FTYP + box + _MP4_MDAT)
assert stripped == 0
assert cleaned == _MP4_FTYP + box + _MP4_MDAT
def test_m4a_c2pa_stripped(self, tmp_path: Path):
from remove_ai_watermarks.metadata import C2PA_UUID
uuid_box = b"\x00\x00\x00\x20uuid" + C2PA_UUID + b"manifest"
src = tmp_path / "voice.m4a"
src.write_bytes(_MP4_FTYP + uuid_box + _MP4_MDAT)
out = tmp_path / "clean.m4a"
remove_ai_metadata(src, out)
assert out.read_bytes() == _MP4_FTYP + _MP4_MDAT
def test_content_sniff_routes_unknown_suffix(self, tmp_path: Path):
# An ISOBMFF file with a non-standard extension is still box-stripped.
from remove_ai_watermarks.metadata import C2PA_UUID
uuid_box = b"\x00\x00\x00\x20uuid" + C2PA_UUID + b"manifest"
src = tmp_path / "mystery.bin"
src.write_bytes(_MP4_FTYP + uuid_box + _MP4_MDAT)
out = tmp_path / "out.bin"
remove_ai_metadata(src, out)
assert out.read_bytes() == _MP4_FTYP + _MP4_MDAT
def test_unparseable_audio_raises(self, tmp_path: Path):
# Garbage that ffmpeg can't parse must raise a clear error, not crash in
# the image path. (When ffmpeg is absent this still raises RuntimeError.)
src = tmp_path / "audio.mp3"
src.write_bytes(b"ID3\x04\x00\x00\x00\x00\x00\x00 not real mp3 frames")
out = tmp_path / "out.mp3"
with pytest.raises(RuntimeError):
remove_ai_metadata(src, out)
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not installed")
class TestFfmpegMetadataStrip:
"""Lossless container-metadata strip for non-ISOBMFF audio/video via ffmpeg."""
def _wav_with_tag(self, path: Path, tag: str = "Suno AI") -> None:
subprocess.run( # noqa: S603
[
shutil.which("ffmpeg"),
"-y",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=0.1",
"-metadata",
f"title={tag}",
str(path),
],
check=True,
)
def test_strips_wav_title_metadata(self, tmp_path: Path):
src = tmp_path / "in.wav"
self._wav_with_tag(src, "Suno AI generated")
assert b"Suno AI generated" in src.read_bytes() # tag is present pre-strip
out = tmp_path / "clean.wav"
remove_ai_metadata(src, out)
assert out.exists()
assert b"Suno AI generated" not in out.read_bytes() # tag stripped, audio kept
+9 -9
View File
@@ -8,8 +8,8 @@ from pathlib import Path
import pytest
from remove_ai_watermarks.noai.c2pa import (
_cbor_text_after,
_parse_c2pa_chunk,
cbor_text_after,
extract_c2pa_chunk,
extract_c2pa_info,
has_c2pa_metadata,
@@ -193,36 +193,36 @@ class TestC2PAInjectValidation:
class TestCborTextAfter:
"""_cbor_text_after handles the three CBOR text-string length prefixes."""
"""cbor_text_after handles the three CBOR text-string length prefixes."""
def test_direct_length(self):
# major-type 3, direct length (0x60 + len). "abc" -> 0x63.
payload = b"name" + bytes([0x63]) + b"abc"
assert _cbor_text_after(payload, b"name") == "abc"
assert cbor_text_after(payload, b"name") == "abc"
def test_one_byte_length(self):
s = b"x" * 30
payload = b"name" + bytes([0x78, 30]) + s
assert _cbor_text_after(payload, b"name") == "x" * 30
assert cbor_text_after(payload, b"name") == "x" * 30
def test_two_byte_length(self):
s = b"y" * 300
payload = b"name" + bytes([0x79]) + struct.pack(">H", 300) + s
assert _cbor_text_after(payload, b"name") == "y" * 300
assert cbor_text_after(payload, b"name") == "y" * 300
def test_key_not_found_returns_none(self):
assert _cbor_text_after(b"nothing here", b"name") is None
assert cbor_text_after(b"nothing here", b"name") is None
def test_key_at_end_returns_none(self):
assert _cbor_text_after(b"prefixname", b"name") is None
assert cbor_text_after(b"prefixname", b"name") is None
def test_invalid_head_returns_none(self):
# 0x00 is not a text-string head.
assert _cbor_text_after(b"name" + bytes([0x00]) + b"abc", b"name") is None
assert cbor_text_after(b"name" + bytes([0x00]) + b"abc", b"name") is None
def test_latin1_fallback_on_invalid_utf8(self):
payload = b"name" + bytes([0x61]) + b"\xff" # len 1, invalid utf-8
assert _cbor_text_after(payload, b"name") is not None
assert cbor_text_after(payload, b"name") is not None
class TestSynthIDVerdict:
+56 -33
View File
@@ -6,7 +6,7 @@ code paths work correctly on CPU, MPS (macOS), and CUDA (Linux/Windows).
from __future__ import annotations
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
@@ -15,7 +15,6 @@ from remove_ai_watermarks.noai.utils import get_image_format, is_supported_forma
from remove_ai_watermarks.noai.watermark_profiles import (
detect_model_profile,
get_model_id_for_profile,
get_recommended_strength,
)
from remove_ai_watermarks.noai.watermark_remover import get_device, is_watermark_removal_available
@@ -27,7 +26,7 @@ class TestDeviceDetection:
def test_returns_valid_device(self):
device = get_device()
assert device in ("cpu", "mps", "cuda")
assert device in ("cpu", "mps", "cuda", "xpu")
def test_cpu_fallback_when_no_gpu(self):
"""On CI / machines without GPU, should fall back to cpu or mps."""
@@ -39,6 +38,49 @@ class TestDeviceDetection:
def test_no_torch_returns_cpu(self):
assert get_device() == "cpu"
def test_xpu_selected_when_available(self):
"""An XPU-enabled torch (no CUDA) routes to the Intel GPU backend.
The whole torch module is mocked so the smoke-test ops succeed without
any real device; cuda must read False so the cuda branch is skipped.
"""
fake_torch = MagicMock()
fake_torch.cuda.is_available.return_value = False
fake_torch.xpu.is_available.return_value = True
with patch("remove_ai_watermarks.noai.watermark_remover.torch", fake_torch):
assert get_device() == "xpu"
fake_torch.tensor.assert_called_with([1.0], device="xpu")
def test_init_accepts_xpu_and_selects_fp16(self):
"""WatermarkRemover accepts device='xpu' and picks fp16 (not fp32)."""
if not is_watermark_removal_available():
pytest.skip("torch/diffusers not installed")
import torch
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover
remover = WatermarkRemover(device="xpu")
assert remover.device == "xpu"
assert remover.torch_dtype == torch.float16
def test_seed_generator_falls_back_to_cpu_when_device_rng_unsupported(self):
"""A device with no RNG backend (e.g. some torch-xpu builds) falls back
to a CPU generator instead of raising when --seed is used."""
from remove_ai_watermarks.noai import watermark_remover as wr
def fake_generator(device="cpu"):
if device == "xpu":
raise RuntimeError("Device type xpu is not supported for torch.Generator()")
gen = MagicMock()
gen.manual_seed.return_value = f"gen:{device}"
return gen
fake_torch = MagicMock()
fake_torch.Generator.side_effect = fake_generator
with patch.object(wr, "torch", fake_torch):
assert wr._make_seed_generator("xpu", 123) == "gen:cpu"
assert wr._make_seed_generator("cuda", 123) == "gen:cuda"
class TestMpsErrorDetection:
"""Tests for MPS error detection helper."""
@@ -78,33 +120,6 @@ class TestModelProfiles:
def test_detect_ctrlregen(self):
assert detect_model_profile("yepengliu/ctrlregen") == "ctrlregen"
def test_recommended_strength_high(self):
assert get_recommended_strength("treering") == 0.7
def test_recommended_strength_low(self):
assert get_recommended_strength("stablesignature") == 0.04
def test_recommended_strength_medium(self):
assert get_recommended_strength("unknown_type") == 0.35
@pytest.mark.parametrize("wm_type", ["stegastamp", "stegasamp", "treering", "ringid"])
def test_high_perturbation_watermark_types(self, wm_type):
"""Robust spatial watermarks need aggressive (0.7) regeneration."""
assert get_recommended_strength(wm_type) == 0.7
@pytest.mark.parametrize("wm_type", ["stablesignature", "dwtectsvd", "rivagan", "ssl", "hidden"])
def test_low_perturbation_watermark_types(self, wm_type):
"""Fragile frequency/latent watermarks break at low (0.04) strength."""
assert get_recommended_strength(wm_type) == 0.04
def test_strength_match_is_case_insensitive(self):
assert get_recommended_strength("TreeRing") == 0.7
assert get_recommended_strength("StableSignature") == 0.04
def test_strength_matches_substring_in_descriptive_name(self):
# e.g. a CLI passing "treering_v2" or "synthid-stegastamp" still maps.
assert get_recommended_strength("treering_v2") == 0.7
# ── Format utilities ────────────────────────────────────────────────
@@ -151,13 +166,21 @@ class TestAvailability:
"""Tests for dependency availability checks."""
def test_watermark_removal_available(self):
# In dev env with torch+diffusers installed
assert is_watermark_removal_available() is True
# Reflects the actual environment: True iff torch + diffusers (the gpu
# extra) are importable. The core+dev CI env has no diffusers, so this
# must not assume the full stack is present.
import importlib.util
expected = all(importlib.util.find_spec(m) is not None for m in ("torch", "diffusers"))
assert is_watermark_removal_available() is expected
def test_invisible_is_available(self):
import importlib.util
from remove_ai_watermarks.invisible_engine import is_available
assert is_available() is True
expected = all(importlib.util.find_spec(m) is not None for m in ("torch", "diffusers"))
assert is_available() is expected
# ── Platform-specific path handling ─────────────────────────────────
+75
View File
@@ -0,0 +1,75 @@
"""Tests for the universal region eraser."""
from __future__ import annotations
import numpy as np
import pytest
from remove_ai_watermarks.region_eraser import boxes_to_mask, erase, lama_available
class TestBoxesToMask:
def test_mask_set_inside_box(self):
mask = boxes_to_mask((100, 100), [(10, 20, 30, 40)], dilate=0)
assert mask[25, 15] == 255 # inside
assert mask[0, 0] == 0 # outside
assert mask.shape == (100, 100)
def test_multiple_boxes(self):
mask = boxes_to_mask((100, 100), [(0, 0, 10, 10), (90, 90, 10, 10)], dilate=0)
assert mask[5, 5] == 255
assert mask[95, 95] == 255
assert mask[50, 50] == 0
def test_dilate_grows_mask(self):
m0 = boxes_to_mask((100, 100), [(40, 40, 10, 10)], dilate=0)
m5 = boxes_to_mask((100, 100), [(40, 40, 10, 10)], dilate=5)
assert m5.sum() > m0.sum()
def test_box_clipped_to_bounds(self):
# box partly outside the image must not raise and stays in-bounds
mask = boxes_to_mask((50, 50), [(40, 40, 100, 100)], dilate=0)
assert mask[45, 45] == 255
class TestEraseCv2:
def _image_with_logo(self) -> tuple[np.ndarray, tuple[int, int, int, int]]:
img = np.full((200, 200, 3), 120, np.uint8) # flat gray background
box = (140, 160, 50, 30)
x, y, w, h = box
img[y : y + h, x : x + w] = (255, 255, 255) # bright "logo"
return img, box
def test_erase_changes_region(self):
img, box = self._image_with_logo()
out = erase(img, boxes=[box], backend="cv2")
x, y, w, h = box
# on a flat background the logo region should be repainted near gray
region = out[y : y + h, x : x + w]
assert abs(float(region.mean()) - 120) < 20
assert not np.array_equal(out, img)
def test_pixels_outside_box_untouched(self):
img, box = self._image_with_logo()
out = erase(img, boxes=[box], backend="cv2", dilate=0)
# a far corner must be identical
assert np.array_equal(img[:50, :50], out[:50, :50])
def test_no_boxes_returns_copy(self):
img = np.full((100, 100, 3), 50, np.uint8)
out = erase(img, boxes=[], backend="cv2")
assert np.array_equal(img, out)
def test_empty_mask_returns_copy(self):
img = np.full((100, 100, 3), 50, np.uint8)
out = erase(img, mask=np.zeros((100, 100), np.uint8), backend="cv2")
assert np.array_equal(img, out)
class TestLamaBackend:
def test_lama_raises_when_unavailable(self):
img = np.full((100, 100, 3), 50, np.uint8)
if lama_available():
pytest.skip("onnxruntime installed; cannot test the unavailable path")
with pytest.raises(RuntimeError, match="onnxruntime"):
erase(img, boxes=[(10, 10, 20, 20)], backend="lama")
+170
View File
@@ -0,0 +1,170 @@
"""Unit tests for the text-protection change-map helper (no model download).
``build_change_map`` is the pure cv2/numpy part of ``text_protector``: it turns
detected text polygons into a Differential-Diffusion change map. The polarity is
load-bearing and was verified empirically (white = preserve, black = change), so
a regression here would either freeze the whole image or fail to protect text.
The PP-OCRv3 detector itself needs a model download and is not exercised here.
"""
from __future__ import annotations
import numpy as np
from remove_ai_watermarks.text_protector import (
_DET_MAX_LONG_SIDE,
_detection_input_size,
build_change_map,
feather_paste,
merge_text_regions,
)
def _quad(x0, y0, x1, y1):
"""An axis-aligned 4-vertex polygon as the detector returns."""
return np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], np.int32)
class TestMergeTextRegions:
def test_empty(self):
assert merge_text_regions([], 256, 256) == []
def test_far_apart_boxes_stay_separate(self):
boxes = [_quad(10, 10, 60, 30), _quad(10, 200, 60, 220)]
regions = merge_text_regions(boxes, 256, 256, dilate_frac=0.005, pad_frac=0.0)
assert len(regions) == 2
def test_close_boxes_merge(self):
# two boxes on the same line, a few px apart -> one block
boxes = [_quad(10, 10, 60, 30), _quad(64, 10, 110, 30)]
# dilate_frac sized to close the few-px inter-word gap on one line
regions = merge_text_regions(boxes, 256, 256, dilate_frac=0.03)
assert len(regions) == 1
def test_rects_in_bounds_and_padded(self):
boxes = [_quad(100, 100, 150, 130)]
(x, y, w, h) = merge_text_regions(boxes, 256, 256, pad_frac=0.05)[0]
assert x >= 0
assert y >= 0
assert x + w <= 256
assert y + h <= 256
assert w > 50 # padded beyond the raw 50px box
def test_caps_region_count(self):
boxes = [_quad(20 * i, 0, 20 * i + 8, 8) for i in range(20)]
regions = merge_text_regions(boxes, 64, 512, dilate_frac=0.002, pad_frac=0.0, max_regions=5)
assert len(regions) <= 5
class TestFeatherPaste:
def test_patch_lands_at_location_center(self):
base = np.zeros((100, 100, 3), np.uint8)
patch = np.full((40, 40, 3), 200, np.uint8)
out = feather_paste(base, patch, 30, 30, feather=6)
# center of the pasted region is (near) the patch value
assert out[50, 50, 0] >= 190
# far corner untouched
assert out[2, 2, 0] == 0
def test_does_not_mutate_base(self):
base = np.zeros((50, 50, 3), np.uint8)
feather_paste(base, np.full((20, 20, 3), 255, np.uint8), 10, 10)
assert base.sum() == 0
def test_shape_preserved(self):
base = np.zeros((50, 60, 3), np.uint8)
out = feather_paste(base, np.full((10, 10, 3), 100, np.uint8), 5, 5)
assert out.shape == base.shape
def test_partial_out_of_bounds_no_crash(self):
base = np.zeros((40, 40, 3), np.uint8)
out = feather_paste(base, np.full((30, 30, 3), 150, np.uint8), 25, 25, feather=4)
assert out.shape == (40, 40, 3)
class TestDetectionInputSize:
"""Resolution contract for the DB detector input (issue #14 recall fix).
A fixed small input (the old 736) downscaled large canvases so far that small
text fell below the detector's resolution and was missed. Detection now runs
at the native long side, capped and never upscaled.
"""
def test_large_canvas_not_downscaled_to_old_736(self):
# The #14 regression: a 2048 canvas must detect well above the old 736
# so ~12-16 px text survives. Capped at the max long side.
in_w, in_h = _detection_input_size(2048, 2048)
assert in_w == _DET_MAX_LONG_SIDE
assert in_h == _DET_MAX_LONG_SIDE
assert in_w > 736 # the old fixed input that missed small text
def test_native_resolution_not_upscaled(self):
# A 1024 canvas detects at native 1024 (not upscaled to the cap, not
# downscaled to the old 736).
assert _detection_input_size(1024, 1024) == (1024, 1024)
def test_small_image_is_native(self):
assert _detection_input_size(512, 512) == (512, 512)
def test_dims_are_multiples_of_32(self):
for h, w in [(2048, 1024), (1234, 567), (4096, 4096), (1000, 1000)]:
in_w, in_h = _detection_input_size(h, w)
assert in_w % 32 == 0
assert in_h % 32 == 0
def test_aspect_ratio_preserved_when_capped(self):
# Portrait 2048x1024: long side capped to the max, short side scaled by
# the same factor (so the 2:1 aspect is roughly kept).
in_w, in_h = _detection_input_size(2048, 1024)
assert in_h == _DET_MAX_LONG_SIDE
assert abs((in_w / in_h) - 0.5) < 0.05
def test_floor_at_32(self):
in_w, in_h = _detection_input_size(10, 5)
assert in_w >= 32
assert in_h >= 32
class TestBuildChangeMap:
def test_no_boxes_is_all_change(self):
m = build_change_map([], 32, 48)
assert m.shape == (32, 48)
assert m.dtype == np.float32
assert float(m.max()) == 0.0
def test_text_region_is_preserved_background_is_change(self):
# A 20x20 box centered in a 64x64 map, no feather for a crisp check.
box = np.array([[22, 22], [42, 22], [42, 42], [22, 42]])
m = build_change_map([box], 64, 64, preserve=0.9, feather=0)
# Inside the polygon: painted to preserve value.
assert m[32, 32] == np.float32(0.9)
# Far background: untouched -> full change (0.0).
assert m[2, 2] == 0.0
# Polarity: text preserved more than background.
assert m[32, 32] > m[2, 2]
def test_preserve_value_is_respected(self):
box = np.array([[10, 10], [30, 10], [30, 30], [10, 30]])
m = build_change_map([box], 40, 40, preserve=0.5, feather=0)
assert m[20, 20] == np.float32(0.5)
def test_feather_creates_soft_edge_gradient(self):
box = np.array([[20, 20], [44, 20], [44, 44], [20, 44]])
m = build_change_map([box], 64, 64, preserve=1.0, feather=15)
center = m[32, 32]
# An edge pixel just outside the polygon should be partially blended:
# strictly between full-change (0) and the preserved center.
edge = m[32, 47]
assert 0.0 < edge < center
assert center <= 1.0
def test_even_feather_does_not_crash(self):
box = np.array([[10, 10], [30, 10], [30, 30], [10, 30]])
m = build_change_map([box], 40, 40, feather=14)
assert m.shape == (40, 40)
def test_values_stay_in_unit_range(self):
box = np.array([[5, 5], [35, 5], [35, 35], [5, 35]])
m = build_change_map([box], 40, 40, preserve=1.0, feather=9)
assert float(m.min()) >= 0.0
assert float(m.max()) <= 1.0
+89
View File
@@ -0,0 +1,89 @@
"""Tests for the optional Adobe TrustMark detector.
TrustMark is an optional dependency (extra ``trustmark``) that downloads model
weights on first use, so the decode path is only exercised when it is installed
(mirrors the imwatermark handling). The always-on test pins the graceful
absent/error behaviour: detect must return None, never raise.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from remove_ai_watermarks import trustmark_detector
from remove_ai_watermarks.trustmark_detector import detect_trustmark, is_available
if TYPE_CHECKING:
from pathlib import Path
class _FakeDecoder:
"""A TrustMark decoder whose successive ``decode`` calls return scripted
``(secret, present, schema)`` tuples -- the first for the original image, the
second for the re-encoded copy used by the false-positive durability gate."""
def __init__(self, *results: tuple[bytes, bool, int]):
self._results = list(results)
self.calls = 0
def decode(self, _img: object) -> tuple[bytes, bool, int]:
result = self._results[min(self.calls, len(self._results) - 1)]
self.calls += 1
return result
def test_detect_never_raises(tmp_clean_png: Path):
# Whether or not trustmark is installed, a clean image must yield None
# (no watermark) without raising. When absent, the import guard returns None.
assert detect_trustmark(tmp_clean_png) is None
def test_unreadable_file_returns_none(tmp_path: Path):
bad = tmp_path / "not_an_image.txt"
bad.write_bytes(b"not an image")
assert detect_trustmark(bad) is None
@pytest.mark.skipif(not is_available(), reason="trustmark not installed")
def test_clean_image_reports_no_watermark(tmp_clean_png: Path):
# With the decoder present, an un-watermarked image must report absent.
assert detect_trustmark(tmp_clean_png) is None
class TestFalsePositiveGate:
"""The re-encode durability gate keeps real (durable) TrustMarks and drops
BCH false positives that collapse under a mild JPEG round-trip."""
@pytest.fixture(autouse=True)
def _force_available(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(trustmark_detector, "is_available", lambda: True)
def _patch_decoder(self, monkeypatch: pytest.MonkeyPatch, decoder: _FakeDecoder) -> None:
monkeypatch.setattr(trustmark_detector, "_decoder", lambda: decoder)
def test_durable_watermark_survives_and_is_reported(self, monkeypatch, tmp_clean_png: Path):
decoder = _FakeDecoder((b"secret", True, 2), (b"secret", True, 2))
self._patch_decoder(monkeypatch, decoder)
result = detect_trustmark(tmp_clean_png)
assert result == "Adobe TrustMark (variant P, schema 2)"
assert decoder.calls == 2 # original + re-encode
def test_false_positive_collapsing_on_reencode_is_dropped(self, monkeypatch, tmp_clean_png: Path):
# Present on the original, absent after re-encode -> content-noise FP.
decoder = _FakeDecoder((b"\x00\x01", True, 3), (b"", False, -1))
self._patch_decoder(monkeypatch, decoder)
assert detect_trustmark(tmp_clean_png) is None
def test_schema_drift_on_reencode_is_dropped(self, monkeypatch, tmp_clean_png: Path):
# Present both times but the schema changes -> not a stable watermark.
decoder = _FakeDecoder((b"\x00", True, 2), (b"\x00", True, 3))
self._patch_decoder(monkeypatch, decoder)
assert detect_trustmark(tmp_clean_png) is None
def test_absent_skips_reencode(self, monkeypatch, tmp_clean_png: Path):
decoder = _FakeDecoder((b"", False, -1))
self._patch_decoder(monkeypatch, decoder)
assert detect_trustmark(tmp_clean_png) is None
assert decoder.calls == 1 # no second decode when the first is absent
+70
View File
@@ -0,0 +1,70 @@
"""Tests for the known-visible-watermark registry (reverse-alpha only)."""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
from remove_ai_watermarks import watermark_registry as reg
DOUBAO_SAMPLE = Path(__file__).resolve().parents[1] / "data" / "samples" / "doubao-1.png"
class TestCatalog:
def test_keys(self):
assert reg.mark_keys() == ["gemini", "doubao"]
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_locations(self):
by_key = {m.key: m for m in reg.known_marks()}
assert by_key["gemini"].location == "bottom-right"
assert by_key["doubao"].location == "bottom-right"
def test_get_mark_unknown_raises(self):
with pytest.raises(KeyError):
reg.get_mark("nope")
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"}
def test_blank_image_no_auto_mark(self):
assert reg.best_auto_mark(np.zeros((256, 256, 3), np.uint8)) is None
@pytest.mark.skipif(not DOUBAO_SAMPLE.exists(), reason="doubao sample not present")
class TestRealSample:
def test_doubao_sample_wins_auto(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"
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)
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.
img = np.zeros((512, 512, 3), np.uint8)
result, region = reg.get_mark("doubao").remove(img)
assert region is None
assert np.array_equal(result, img)
+23
View File
@@ -0,0 +1,23 @@
"""Minimal local type stub for the untyped ``piexif`` package.
Covers only the API surface used in this repo (``load``/``dump`` plus the
``ImageIFD`` tag ids), so strict pyright resolves the otherwise-Unknown values
that ``piexif.load`` returns instead of carrying type debt. piexif ships no
``py.typed`` marker; extend this stub if new piexif APIs are adopted.
"""
from typing import Any
class ImageIFD:
Software: int
Make: int
Artist: int
ImageDescription: int
class ExifIFD: ...
class GPSIFD: ...
def load(input_data: bytes | str, key_is_name: bool = ...) -> dict[str, Any]: ...
def dump(exif_dict_original: dict[str, Any]) -> bytes: ...
def insert(exif: bytes, image: str, new_file: str | None = ...) -> None: ...
def remove(src: str, new_file: str | None = ...) -> None: ...
Generated
+1766 -866
View File
File diff suppressed because it is too large. Load diff