Compare commits

...
35 Commits
Author SHA1 Message Date
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
test-userandClaude Opus 4.7 ba94de8275 feat: strip AI-provenance EXIF tags on removal (v0.5.6)
remove_ai_metadata now scrubs AI tags from the JPEG EXIF instead of passing
the block through wholesale. Closes the v0.5.5 follow-up: the xAI/Grok
Signature + UUID-Artist pair was detected but not removed.

- metadata._scrub_ai_exif(): deletes the xAI signature pair and any
  Software/Make/Artist/ImageDescription tag carrying an AI_GENERATOR_TOKENS
  token (so Ideogram's Make="Ideogram AI" is scrubbed too), keeping genuine
  camera/editor EXIF intact.
- Shared _is_xai_signature_pair / _exif_text helpers (module-level compiled
  regexes) are now the single source of truth, used by both xai_signature
  and _scrub_ai_exif.
- Tests: Grok signature stripped on JPEG output, Ideogram Make stripped,
  real-camera Make ("Apple") preserved. 325 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:26:20 -07:00
test-userandClaude Opus 4.7 74618b91a7 feat: detect xAI/Grok EXIF signature; refresh watermarking landscape (v0.5.5)
xAI Grok (Aurora) images carry no C2PA/SynthID/IPTC -- their only provenance
signal is an EXIF pair: ImageDescription "Signature: <base64>" + a UUID Artist.
Verified stable across 3 genuine generations (a real download previously read
as unknown / "no AI metadata").

- metadata.xai_signature(): matches the Signature blob + UUID Artist pair;
  wired into has_ai_metadata, get_ai_metadata, and identify (platform
  "xAI (Grok / Aurora)").
- data/samples/grok-1.jpg: real Grok fixture (neutral content; the Artist UUID
  is the public image id, not PII).
- Tests: synthetic-fixture unit tests, real-sample assertion, identify
  integration (322 passing).

Docs (research refresh, May 2026):
- C2PA 2.4 Durable Content Credentials (soft-binding re-discovery after the
  embedded manifest is stripped).
- New AI-labeling laws, primary-source verified: EU AI Act Art 50 (2026-08-02),
  South Korea AI Framework Act Art 31(3), California AB 853.
- Hedge removal claims: defeating the SynthID verifier is not forensic
  invisibility (arXiv:2605.09203); cite SynthID-Image (arXiv:2510.09263).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:14:35 -07:00
test-userandClaude Opus 4.7 5e29c69e7b README: lead with hosted raiw.cc CTA, honest free/paid split
Move the raiw.cc call-to-action above the sponsor ask and drop the
misleading "free web service" framing: visible-watermark and metadata
removal are free, invisible removal runs on paid cloud GPUs. Also point
no-GPU users to the hosted service from the invisible-removal feature
bullet, where the GPU requirement is stated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:47:28 -07:00
test-userandClaude Opus 4.7 8ed4a754ff Add GitHub Sponsors donation button (FUNDING.yml + README badge)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:09:52 -07:00
test-userandClaude Opus 4.7 03fb460f77 Track the labeled SynthID corpus; complete metadata-source test coverage
Corpus images were gitignored (local-only). The negatives were reviewed and
cleared for publishing, so the labeled set is now committed (regular git, 65 MB
across 25 files) -- making the removal regression set reproducible and CI-able.

Corpus:
- Track data/synthid_corpus/images/ (pos 9, neg 15, cleaned 1); keep only the
  synthetic refs/ calibration fills gitignored.
- Reconcile manifest.csv to the on-disk files: 117 -> 25 rows (92 dangling rows
  for removed images pruned; dedup left one cleaned output, f6dd47a5).
- Rewrite the corpus README layout/policy (images committed; review every image
  for private content before adding -- public repo, permanent history).

Test fixtures:
- Remove data/samples/not-ai-1/2/3 (personal iPhone photos, incl. GPS EXIF).
- Add the clean_photo conftest fixture serving a verified-negative image from
  the corpus neg/ set; repoint the three "non-AI / clean photo" tests onto it
  (skips if the corpus is absent).

Metadata-source coverage (close the last sub-variant gaps):
- c2pa digitalSourceType: algorithmicMedia (procedural, not flagged AI) and
  compositeWithTrainedAlgorithmicMedia (AI + SynthID proxy).
- exif_generator: EXIF Artist and ImageDescription fields (Software/Make/XMP
  CreatorTool were already covered).

All 8 metadata-source kinds are now tested at both the unit and identify()
level. 313 tests pass. CLAUDE.md updated (corpus tracked, clean_photo fixture).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:46:47 -07:00
test-userandClaude Opus 4.7 3ebdee57b8 Test the untested pure logic: MPS fallback, tiling, isobmff/c2pa edges
Coverage audit (pytest --cov) found real, non-model logic at 0%/low cover.
Add unit tests that need no model download:

- img2img_runner.py 0% -> 100%: the MPS->CPU fallback orchestration, mocked
  via injected load_pipeline/reload_on_cpu callables. Guards the production
  behavior hit this session (native-res SDXL OOMs on MPS, must retry on CPU;
  non-MPS errors must propagate; "mps"-worded error on a cpu device must not
  reload).
- ctrlregen/tiling.py 0% -> 40%: the pure tile math (tile_positions,
  make_blend_weight, resize_center_crop) that decides how large images are
  split and blended. (run_tiled stays model-bound, untested.)
- isobmff.py 93% -> 100%: size==0 (box-to-EOF) and truncated 64-bit largesize
  parsing branches for AVIF/HEIF/JXL C2PA stripping.
- c2pa.py: non-PNG-signed .png reads as clean (has_c2pa_metadata /
  extract_c2pa_chunk) instead of mis-parsing.

309 tests pass (+23). Document in CLAUDE.md that these pure helpers are
unit-tested without downloads so future sessions don't skip them as "ML".
No src/ change, no release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:21:32 -07:00
test-userandClaude Opus 4.7 d24d8a4b14 Extract _target_size helper + regression-test native resolution (v0.5.4)
The native-vs-downscale decision in InvisibleEngine.remove_watermark (the
issue #10/#15 fix: max_resolution=0 must not pre-downscale, since any
downscale both loses quality and lets SynthID survive) had no test. Extract
it into a pure helper invisible_engine._target_size(w, h, max_resolution)
and cover it with tests/test_invisible_engine.py::TestTargetSize so a
re-introduced forced downscale fails CI instead of silently regressing #15.

Also:
- Clamp the short side to >=1 in _target_size: extreme aspect ratios (e.g.
  5000x3 with --max-resolution 1024) truncated it to 0 and crashed
  image.resize(). Pre-existing in the inline math; fixed now that it is a
  named, tested function.
- Consolidate the two duplicated temp-file save blocks into one
  unconditional save (behavior unchanged: the EXIF-transposed image is
  still always persisted before WatermarkRemover reloads it by path), and
  drop the now-redundant `_tmp_path is not None` guard in finally.
- Bump version 0.5.3 -> 0.5.4 (pyproject, __init__, uv.lock); document the
  helper as the regression guard in CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:09:33 -07:00
test-userandClaude Opus 4.7 28fe13db8f Document native-res MPS OOM -> CPU-fallback behavior in limitations
Concrete data point from the 2026-05-25 gpt-image SDXL run: native
1254x1254 fp32 OOMs at the UNet step (not just VAE) on a 20 GB MPS
ceiling, and img2img_runner auto-falls back to CPU and completes
(slow, weight-identical, still defeats SynthID). enable_vae_tiling()
alone does not prevent it. Fast Mac workarounds: fp16 on MPS or
--max-resolution; neither is the default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:57:13 -07:00
test-userandClaude Opus 4.7 59d72c5db7 Record verified gpt-image-2 SynthID-cleaned chain in corpus
Add manifest row for the 4ef377bd -> f6dd47a5 chain: a gpt-image-2 sample
(openai.com/verify: SynthID + C2PA detected) cleaned via v0.5.3 `all` at
native 1254x1254 (prod-equivalent SDXL base, strength 0.05, 50 steps).
openai.com/verify reports SynthID NOT detected after the run, re-confirming
that the #10 native-resolution default defeats OpenAI SynthID and resolving
the #15 root cause (older SD-1.5/768px downscale default did not).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:55:10 -07:00
test-userandClaude Opus 4.7 e27f24f520 test(samples): commit real Doubao fixture + AIGC real-sample test
data/samples/doubao-1.png is the real #13 sample: carries the China TC260
<TC260:AIGC> XMP label and a visible '豆包AI生成' text mark (bottom-right).
Grounds the AIGC detection on a real file (alongside the synthetic tests)
and serves as the fixture for visible-watermark removal work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:37:15 -07:00
test-userandClaude Opus 4.7 1afc1e60ef test(samples): add real Doubao TC260 AIGC reference sample
2048x2048 PNG carrying China's TC260 <TC260:AIGC> label; identify reports
it as a China AIGC-labeled generator (TC260). Reference fixture for manual
re-verification of the TC260 detection path -- the automated tests use
synthetic blobs, so nothing depends on this file being present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:36:28 -07:00
test-userandClaude Opus 4.7 d45f0806a0 chore(release): v0.5.3 — detect China TC260 AIGC label (Doubao)
- feat(identify): detect the China TC260 <TC260:AIGC> XMP label (Doubao
  and other China-served generators); reports platform + ContentProducer.
  Removal already strips it via the existing metadata cleaner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:30:40 -07:00
test-userandClaude Opus 4.7 c7f0d71f90 feat(identify): detect China TC260 AIGC label (Doubao et al.)
China-served generators embed an XMP <TC260:AIGC>{"Label":"1",...} block
(China's mandatory AI-content labeling, TC260 standard). Doubao (ByteDance)
uses it -- verified on the real #13 sample. It's none of C2PA / SynthID /
imwatermark / IPTC, so identify() previously returned unknown.

- metadata: AIGC_MARKERS + aigc_label() (json-decodes the HTML-entity-encoded
  block); has_ai_metadata + get_ai_metadata now surface it.
- identify: new 'aigc' signal -> is_ai True, platform 'China AIGC-labeled
  generator (TC260; e.g. Doubao)', carries the ContentProducer code.
- Container-agnostic raw-byte scan, so it covers the whole China-AIGC ecosystem
  (Jimeng/Kling/Qwen/Ernie share the standard).
- Tests: synthetic TC260 block (metadata + identify). Docs updated.

Addresses #13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:29:51 -07:00
test-userandClaude Opus 4.7 768d997ef0 docs: scope SynthID provenance claims to source-verified facts
Threat model: replace the unverified deployment list (Gemini 3 Pro /
Nano Banana Pro / Imagen 4 / Veo) with the source-verified scope -- SynthID
across Imagen / Veo / Lyria plus Gemini app outputs (>10B items by Dec 2025),
and attribute the 136-bit payload to the paper's SynthID-O variant.

openai-images-2 sample: note the file predates the 19 May 2026 SynthID
rollout across ChatGPT / Codex / API, and that openai.com/verify is now the
public oracle (still no local decoder).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:18:13 -07:00
70 changed files with 5683 additions and 1122 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
# Enables the "Sponsor" button on the repository page.
github: wiltodelta
+10 -3
View File
@@ -21,8 +21,9 @@ Thumbs.db
# Test results
data/results/
# SynthID corpus images (manifest.csv + README.md stay tracked)
data/synthid_corpus/images/
# SynthID corpus reference fills (synthetic black/white calibration tiles,
# regenerable; the labeled pos/neg/cleaned images ARE tracked, see README)
data/synthid_corpus/refs/
# Reference materials
_refs/
@@ -33,4 +34,10 @@ yolov8n.pt
# Claude Code local settings
.claude/settings.local.json
data/synthid_corpus/refs/
# 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/
+32 -12
View File
@@ -5,6 +5,8 @@ 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>` — visible-mark removal, CPU, no GPU. `--mark auto` (default) routes between the Gemini sparkle and the Doubao "豆包AI生成" text strip by detector confidence; `--mark gemini` / `--mark doubao` force one.
- `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
@@ -14,23 +16,34 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r
- `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.
- Run `uv run` from the repo root — from another cwd it falls back to a bare env without numpy/cv2/torch.
- Metadata/C2PA tests assert against real committed fixtures in `data/samples/` (`chatgpt-*.png` = OpenAI C2PA, `firefly-1.png` = Adobe, `not-ai-*` = clean); synthetic byte blobs cover the JPEG/ISOBMFF format paths.
- SynthID reference corpus: `scripts/synthid_corpus.py` ingests labeled images into `data/synthid_corpus/` (`manifest.csv` tracked, `images/` gitignored); see its README for the collection protocol and verification oracles.
- 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
- Tests for ML modules are limited to availability checks (require multi-GB downloads)
- 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.
- `identify.py` — `identify(path)` aggregates every locally-readable signal (C2PA issuer→platform, IPTC "Made with AI", embedded SD/ComfyUI params, SynthID proxy, 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, for ISOBMFF, the late provenance-box payloads from `isobmff.scan_c2pa_region` (catches a manifest after a large `mdat`); behavior-neutral (`f.read(size)`) for non-ISOBMFF. 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`, 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` and visible-sparkle signals are **medium** confidence: each lifts an otherwise-Unknown verdict to a tentative AI (`hf_only` / `visible_only`, parallel branches) but is excluded from the high-confidence `ai_from_metadata` set, so neither 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/Samsung/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. Camera C2PA marks capture authenticity, not AI (Pixel carries `computationalCapture`, not `trainedAlgorithmicMedia`), so these never set `is_ai` -- that stays driven by digital-source-type. `c2pa.cbor_text_after` (now public) is best-effort for the `generator` detail string only and can be None when the manifest keys it `claim_generator_info` (Pixel). **Issuer→generator mapping is `is_ai`-gated** (`_attribute_platform(issuers, is_ai=c2pa_is_ai)`): a specific AI-generator platform is named only when the digital-source-type is `trainedAlgorithmicMedia`; on a non-AI source an issuer substring is treated as incidental (an "Adobe XMP" toolkit string in an *unmapped* Canon/Sony capture would otherwise mislabel it "Adobe Firefly"), so it degrades to the neutral "C2PA signer: X" label. Real Firefly/OpenAI/Google output carries the AI source-type, so it is unaffected (verified: chatgpt-1.png→OpenAI, firefly-1.png→Adobe Firefly still attribute). `_attribute_platform` defaults `is_ai=True` so the mapping stays unit-testable in isolation. Add device tokens to `_DEVICE_C2PA_PLATFORM`, generator/issuer platforms 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. **Integrity-clash detection** (`_integrity_clashes`, surfaced as `ProvenanceReport.integrLine truncated
- `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`.
- `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. **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.
- `doubao_engine.py` — visible Doubao "豆包AI生成" remover/detector (cv2/numpy, no GPU). `DoubaoEngine.locate` anchors a bottom-right box by **geometry** (mark scales with image WIDTH, fractions in module constants; no bundled template), `extract_mask` pulls the light low-saturation glyphs with a **polarity-aware white top-hat** (brighter-than-blurred-local-bg, so white-paper documents are left untouched instead of smeared), `detect` thresholds glyph coverage (`DETECT_MIN_COVERAGE` 0.16 separates real marks ≥0.20 from corner noise, which stays ≤0.06 on large images but can spike to ~0.15 on tiny ones), `remove_watermark` inpaints (cv2 Telea/NS) and **bails when coverage > `MAX_INPAINT_COVERAGE` 0.50** (dense-text background → would smear). Wired into `visible --mark` via `cli._run_doubao_if_selected`. **Logo is near-white (~253), not the gray some third-party tools assume.** Best on photo/illustration backgrounds; high-contrast edges leave faint residue (cv2-inpaint limit). Clean per-pixel reverse-alpha (Gemini-style) is the future upgrade but needs a captured/distilled alpha map — see below.
- `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.
- `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 CN** ONNX detector (~2.4 MB, Apache-2.0, opencv_zoo, CJK-native, 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)**. `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). Wired into `watermark_remover._run_differential` via the community `pipeline_stable_diffusion_xl_differential_img2img` (loaded with `custom_revision="0.38.0"` — HF resolves the **PyPI** version string, not the `v0.38.0` git tag); gated to the SDXL `DEFAULT_MODEL_ID` only (`_can_protect_text`), falls back to plain img2img otherwise. **Autonomous by default** (`protect_text=True` in `invisible_engine`/`watermark_remover`, mirroring `protect_faces`): the detector runs per image and `_run_differential` falls back to plain img2img when **no boxes** are found, so text-free inputs pay only the cheap cv2 detection (no differential-pipeline load). CLI exposes a single off-switch `--no-protect-text` on `invisible`/`all` (passed as `protect_text=not no_protect_text`); the unavailable-model case logs at debug, not warning, since it is now the default path. The diff pipeline upcasts the VAE to fp32 internally, so do **not** add `upcast_vae()`/`enable_attention_slicing` (both produced NaN/black on fp16 MPS). `build_change_map` is unit-tested without any model download (`tests/test_text_protector.py`).
- `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 (researched 2026-05-26, NOT yet shipped)
Plan to get Gemini-style pixel-accurate reverse-alpha for Doubao at cv2 cost (so it runs on a minimal CPU droplet): use LaMa **offline** to reconstruct the clean background `O`, then per-pixel solve `I=(1-α)O+αL` for the alpha map + logo colour (this is Google's CVPR-2017 multi-image idea, with LaMa standing in for the multi-image optimisation). Validated in principle (glyph error drops as alignment improves) but **blocked on data**, confirmed empirically over 6 attempts: a clean canonical alpha map needs **several PRISTINE images at ONE resolution** with varied backgrounds. The findable public Doubao images (ayaya, Tools_CleanMark, ours) are all different resolutions AND lossy (jpg/webp), and Doubao's mark position drifts ~±50 px even at the same resolution, so cross-image registration (ECC / phase-correlation) fails (1/11 templates aligned) — error floor ~64/255 on glyph pixels, not shippable. To finish: ~5-8 pristine same-resolution Doubao originals → per-image α via LaMa → align the clean α templates → median → ship the tiny map + an NCC localiser (Doubao's mark drifts, so runtime needs the same locate-then-reverse-alpha the Gemini engine already does). Don't retry on lossy/mixed-resolution scraped images — proven insufficient.
## Watermarking landscape (research 2026-05-24)
@@ -38,15 +51,22 @@ Who embeds what, and whether it is locally detectable (so we know which gaps are
- **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).
- **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` reads **two serializations** through a shared `_parse` helper: the HTML-entity-encoded XMP `<TC260:AIGC>` block (container-agnostic raw-byte scan, any JSON object accepted) **and** a raw-JSON PNG `AIGC` tEXt chunk — Doubao also writes the label this way, with no namespaced marker at all (confirmed on the corpus 2026-05-28, `ContentProducer="doubao"`). The PNG-chunk path is gated on at least one TC260 field (`_TC260_FIELDS`) so a generic `AIGC` key cannot false-positive. 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 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.
- `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.
- **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). 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 detector gives content 0.518 ≈ negatives 0.504 (no separation; a faint +0.24 single-image lean is likely a brightness confound). Net: the spectral/phase approach is a real *controlled-fill* characterizer, NOT an arbitrary-real-content detector, and is brittle to model version. Metadata proxy + visible sparkle + online oracles remain the ceiling for real content.
- **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.
- **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
+67 -19
View File
@@ -4,19 +4,28 @@ Remove **visible** and **invisible** AI watermarks from images generated by Goog
Strips SynthID, C2PA Content Credentials, EXIF/XMP "Made with AI" labels, and visible sparkle overlays — all in one command.
> ## Try it online: [raiw.cc](https://raiw.cc)
>
> 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.
[![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)
- **Invisible watermark removal** — SynthID, StableSignature, TreeRing via diffusion-based regeneration
- **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
- **Visible watermark removal** — Gemini / Nano Banana sparkle logo (reverse alpha blending) and the Doubao "豆包AI生成" text strip (locate + mask + inpaint); fast, offline, deterministic, no GPU. `visible --mark auto` picks the right one
- **Universal region eraser (`erase`)** — remove any logo / watermark / object inside boxes you specify, regardless of position or colour. Default cv2 inpainting (CPU, instant); optional big-LaMa via onnxruntime (`lama` extra) for higher quality
- **Invisible watermark removal** — SynthID, StableSignature, TreeRing via diffusion-based regeneration (needs a local GPU, or run it with no setup on [raiw.cc](https://raiw.cc))
- **AI metadata stripping** — EXIF, PNG text chunks, C2PA provenance manifests (PNG / JPEG / AVIF / HEIF / JPEG-XL, **MP4 / MOV / M4V / M4A** at the container level, and **WebM / MP3 / WAV / FLAC / OGG** losslessly via ffmpeg), XMP DigitalSourceType
- **"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)
> **Try it online** — don't want to install anything? Use [raiw.cc](https://raiw.cc), a free web service powered by this library.
- **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 or PNG chunk), 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
@@ -36,14 +45,16 @@ Strips SynthID, C2PA Content Credentials, EXIF/XMP "Made with AI" labels, and vi
| **Adobe Firefly** | — | — | ✅ Content Credentials (C2PA) | Metadata strip |
| **Stability AI** (DreamStudio / Stable Image) | — | — | ✅ C2PA ("Stability AI Ltd") | Metadata strip |
| **Microsoft Designer / Bing Image Creator** | — | ✅ SynthID via DALL-E backend (Designer) | ✅ C2PA (Bing runs MAI-Image, signed "Microsoft") | Metadata strip |
| **xAI Grok (Aurora)** | — | — | ✅ EXIF signature scheme (no C2PA): `Signature:` blob + UUID `Artist` | Detected (`identify`); metadata strip |
| **Midjourney** | — | — | ✅ EXIF + XMP (prompt, model, seed) | Metadata strip |
| **Meta AI** | — | — | ✅ IPTC "Made with AI" (digitalSourceType) | Metadata strip (removes the label) |
| **Doubao** (ByteDance) / China AIGC generators | ✅ "豆包AI生成" text strip (bottom-right) | — | ✅ TC260 AIGC label — `<TC260:AIGC>` XMP **or** `AIGC` PNG chunk (China's mandatory AI labeling) | Locate + mask + inpaint (cv2, CPU) + 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", 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 or PNG chunk), 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
@@ -65,6 +76,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. Unlike the fixed-size Gemini sparkle, it is a text strip that scales with image width, so we anchor a generous bottom-right box by geometry, extract the light low-saturation glyph pixels with a polarity-aware white top-hat mask, and inpaint them (cv2 Telea/NS). The mask is background-relative, so it leaves white-paper documents untouched instead of smearing their text. On dense-text backgrounds where the mask would explode, removal is skipped rather than guessed.
**Speed**: ~0.03s per image. No GPU needed. Best on photo / illustration backgrounds; on high-contrast edges a faint residue can remain (use `erase --backend lama` for neural-quality fill).
### 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.
@@ -80,11 +101,13 @@ image → encode to latent space (VAE) at native resolution
By default the image is processed at its **native resolution** with no pre-downscale, matching the hosted raiw.cc backend (fal `fast-sdxl`, which is `stabilityai/stable-diffusion-xl-base-1.0` — the same checkpoint the CLI defaults to). At strength ~0.05 SDXL img2img does not need the input shrunk, and the old forced downscale-to-1024 then upscale-back round-trip was the main quality loss. Pass `--max-resolution N` to cap the long side only when a very large image runs out of GPU/MPS memory (it reintroduces that lossy round-trip).
SDXL is the default since May 2026: empirically defeats SynthID v2 on Gemini 3 Pro outputs, where the older SD-1.5 pipeline at 768 px did not. The SD-1.5 path was removed once it was verified not to handle v2.
SDXL is the default since May 2026: empirically defeats SynthID v2 on Gemini 3 Pro outputs, where the older SD-1.5 pipeline at 768 px did not. The SD-1.5 path was removed once it was verified not to handle v2. Note the scope: this defeats the SynthID *verifier*, which is not the same as being forensically indistinguishable from a real photo. Recent work ([arXiv:2605.09203](https://arxiv.org/abs/2605.09203)) shows watermark-removal pipelines leave detectable traces, so a separate "this image was processed" classifier can still flag the output.
**Face Protection**: before diffusion, YOLO detects people in the image and extracts them. After diffusion, the original faces are blended back with a soft elliptical mask to prevent AI distortion of facial features.
**Analog Humanizer**: optional film grain and chromatic aberration injection that makes the output indistinguishable from a photo of a screen, defeating AI-generated image classifiers.
**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 CJK glyphs get deformed at the strengths that defeat SynthID. The SDXL pipeline guards against this by default: a CJK-native 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 pass switches to Differential Diffusion so a per-pixel change map keeps the text regions largely intact while the background is regenerated normally. 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
@@ -151,6 +174,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
@@ -205,13 +236,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) picks
# between the Gemini sparkle and the Doubao "豆包AI生成" text strip; force one
# with --mark gemini / --mark doubao.
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
@@ -289,7 +327,9 @@ Tracked but not yet implemented:
- **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).
- **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 are mapped (each verified against a real signed file). Canon and Samsung Galaxy (AI-edit) are deferred until a real signed sample surfaces — no public direct-download C2PA file exists for them 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:
@@ -303,16 +343,19 @@ 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. |
| 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. |
| 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 **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
This tool defends already-distributed AI imagery against automatic detection systems (social-platform "Made with AI" labels, third-party classifiers, content-policy filters). It does **not** retroactively anonymise generation.
In particular, **SynthID-Image** (Google, deployed 2025 with Gemini 3 Pro / Nano Banana Pro / Imagen 4 / Veo) carries a **multi-bit payload** — the paper's SynthID-O variant encodes 136-bit payloads in 512x512 images ([arxiv 2510.09263](https://arxiv.org/abs/2510.09263)). The payload is believed to encode a user / session identifier. If the original watermarked file ever passed through a system controlled by the prompt originator (a saved Gemini account history, a screenshot uploaded to a Google product, a backup), Google retains the ability to link that original to the generating account. Stripping the watermark from a copy you possess does not erase Google's server-side record.
In particular, **SynthID** (Google DeepMind) is embedded across Google's generative media stack — Imagen (images), Veo (video), Lyria (audio) — and Gemini app image outputs (Nano Banana / Gemini 3 Pro, which we verified positive via the Gemini app's SynthID oracle); Google reported over 10 billion items watermarked by December 2025. It carries a **multi-bit payload** — the research paper's SynthID-O variant encodes 136-bit payloads in 512x512 images ([arxiv 2510.09263](https://arxiv.org/abs/2510.09263)). The payload is believed to encode a user / session identifier. If the original watermarked file ever passed through a system controlled by the prompt originator (a saved Gemini account history, a screenshot uploaded to a Google product, a backup), Google retains the ability to link that original to the generating account. Stripping the watermark from a copy you possess does not erase Google's server-side record.
Use cases where the threat model fits:
- You generated the image yourself, want to publish it as your own work, and accept the consequences if Google ever publishes their detector logs.
@@ -330,7 +373,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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 KiB

+1 -1
View File
@@ -26,7 +26,7 @@ The `trainedAlgorithmicMedia` tag is what triggers "Made with AI" labels on Inst
### Invisible pixel-level watermark
OpenAI's system card for Images 2.0 states the model embeds an "imperceptible, robust, and content-specific" pixel-level watermark alongside C2PA. No public detector exists, so bypass cannot be verified empirically for this sample.
OpenAI's system card for Images 2.0 states the model embeds an "imperceptible, robust, and content-specific" pixel-level watermark alongside C2PA. This sample was downloaded 2026-04-22, before OpenAI's 19 May 2026 rollout of Google's SynthID watermark across ChatGPT / Codex / the API, so it likely predates SynthID and carries only the original content-specific watermark. Since that rollout, the openai.com/verify tool (in preview) is the public oracle for both signals; there is still no local decoder, so bypass cannot be verified empirically here without the oracle.
## Reproducing the removal
+13 -7
View File
@@ -17,16 +17,21 @@ external oracle, recorded per image in `verified_via` (see below).
```
data/synthid_corpus/
README.md # this protocol (committed)
manifest.csv # labels + provenance (committed, reviewable)
images/ # the actual files (gitignored, local-only)
manifest.csv # labels + provenance (committed; one row per tracked image)
images/ # the labeled corpus (committed)
pos/ # SynthID present
neg/ # SynthID absent
neg/ # SynthID absent (incl. reviewed real photos)
cleaned/ # our pipeline output from a pos image
refs/ # synthetic black/white calibration fills (gitignored, regenerable)
```
Images are gitignored on purpose: the corpus is large, may contain personal or
licensed content, and SynthID-positive outputs are best kept local. The
`manifest.csv` (sha256 + labels + extracted metadata) is the durable artifact.
The labeled images are committed so the corpus is reproducible and the removal
regression set runs in CI. `manifest.csv` is kept in sync with the files on
disk (one row per tracked image; dangling rows are pruned when files are
removed). Before adding any image, confirm it carries no private or
identifiable content you would not publish -- this is a public repo and git
history is permanent. The synthetic `refs/` fills stay gitignored (regenerable,
not part of the labeled set).
## Verification levels (`verified_via`)
@@ -58,7 +63,8 @@ For the **regression set**:
- `neg` controls: non-AI photos and outputs from non-SynthID models (SD,
Midjourney, Firefly) verified negative.
Avoid personal or identifiable content; the corpus stays local.
The corpus is committed to a public repo: review every image before adding it
and keep out anything private or identifiable you would not publish.
## Ingesting
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 MiB

+1 -93
View File
@@ -1,118 +1,26 @@
sha256,filename,label,source,model,width,height,format,c2pa_issuer,synthid_metadata,verified_via,added,notes
4ef377bde1a1d4eff141972841938643b173f5052992a018b9a21b31ac31731e,"4ef377bd-ChatGPT Image May 23, 2026, 02_43_02 PM.png",pos,ChatGPT,gpt-image,1254,1254,png,OpenAI,yes,openai-verify,2026-05-23T21:48:12Z,fresh post-rollout 2026-05-23; openai.com/verify: SynthID+C2PA detected
d09f84c0e4c6d8b336bf4a9a7277314e940dcb5052ae7051e785cbb3bb42d656,d09f84c0-Gemini_Generated_Image_vq7wkwvq7wkwvq7w.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,c2pa-metadata,2026-05-23T21:52:40Z,"user: latest Gemini, SynthID v2"
47188e88f956291bd38ab6906e5f21eb273d4a697ddc8b4479deac9f48915e1a,47188e88-disco_synthid_removed.png,cleaned,our pipeline (invisible/SDXL),stabilityai/stable-diffusion-xl-base-1.0,1254,1254,png,,,openai-verify,2026-05-23T22:06:54Z,cleaned from 4ef377bd disco; openai.com/verify: SynthID NOT detected (defeated)
52bb6bd524a74bff1b74ed893784c1c0ee76f48a9b45a009bae95ad4a57b7759,"52bb6bd5-ChatGPT Image May 24, 2026, 11_01_02 AM.png",pos,ChatGPT,gpt-image,1254,1254,png,OpenAI,yes,c2pa-metadata,2026-05-24T18:49:35Z,solid black ref (spectral pilot 2026-05-24)
ee75ade07914d6306eae3443b3028782e0ac8c125a31b2d5a141f75aebdafb18,"ee75ade0-ChatGPT Image May 24, 2026, 11_10_04 AM.png",pos,ChatGPT,gpt-image,1254,1254,png,OpenAI,yes,c2pa-metadata,2026-05-24T18:49:35Z,solid black ref (spectral pilot 2026-05-24)
9398c74dfad0f030633bd3ac224ba53e56a7ff9711d7b3a4c464e0073ece51b5,"9398c74d-ChatGPT Image May 24, 2026, 11_10_09 AM.png",pos,ChatGPT,gpt-image,1254,1254,png,OpenAI,yes,c2pa-metadata,2026-05-24T18:49:35Z,solid black ref (spectral pilot 2026-05-24)
79c3733895e82e3c9e506de0ddd6dfbf20ba09171263d88444520777151868c8,"79c37338-ChatGPT Image May 24, 2026, 11_10_13 AM.png",pos,ChatGPT,gpt-image,1254,1254,png,OpenAI,yes,c2pa-metadata,2026-05-24T18:49:35Z,solid black ref (spectral pilot 2026-05-24)
38315a0a83aa0e094a50520ea44e01aa26115927f09a532ff2ab8636de743e0a,"38315a0a-ChatGPT Image May 24, 2026, 11_10_19 AM.png",pos,ChatGPT,gpt-image,1254,1254,png,OpenAI,yes,c2pa-metadata,2026-05-24T18:49:35Z,solid black ref (spectral pilot 2026-05-24)
f3a1fbc3bc8f768265400724bb9800d322f8e0b1461b2c585540845ea8352c5d,f3a1fbc3-winter_scene_X.png,neg,local (SDXL/processed),sdxl-or-processed,2816,1536,png,,,none,2026-05-24T18:52:23Z,metadata-clean: no C2PA/SynthID source
f07bc0bcad09a5a5687ae312a1298c9ddd110a5e414265efc52ef4d524b36f86,f07bc0bc-api_enterprise_gate_ax.png,neg,local (SDXL/processed),sdxl-or-processed,1080,1350,png,,,none,2026-05-24T18:52:23Z,metadata-clean: no C2PA/SynthID source
89571987e368f1ce82f9dedfa9101584434def842e50d1f4c759de64db5c21d9,89571987-c87bd3c48a4443a68cb84a65604dacd3_clean.png,neg,local (SDXL/processed),sdxl-or-processed,2816,1536,png,,,none,2026-05-24T18:52:23Z,metadata-clean: no C2PA/SynthID source
7b650522d42db09568e249c04d683c469fb3e280a2c53fcd1031cb9df27c619a,"7b650522-ChatGPT Image May 24, 2026, 12_19_54 PM.png",pos,ChatGPT,gpt-image,1602,982,png,OpenAI,yes,c2pa-metadata,2026-05-24T19:20:25Z,content: misty pine forest at dawn
fb28dba2a82cc101a92fdee5714867b32610d0564f37737fe4bb70782b8ecf32,fb28dba2-Gemini_Generated_Image_dsjlnsdsjlnsdsjl.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,c2pa-metadata,2026-05-24T19:30:25Z,content: elderly fisherman portrait
d20d4cc936dbdfe909c52502039a9e84ba93d97b42b24a0acee5b7d6c71930ae,d20d4cc9-Gemini_Generated_Image_ug6kdpug6kdpug6k.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,c2pa-metadata,2026-05-24T19:33:15Z,content: red coffee mug product shot
28f323345f6496d936c3f1a72f671ddf59d0f81565c24a63bf3286860f633afe,28f32334-chatgpt_fisherman.png,pos,ChatGPT,gpt-image,1023,1537,png,OpenAI,yes,c2pa-metadata,2026-05-24T19:39:20Z,content: elderly fisherman portrait (fetch+blob dl)
88e61a384c2e0b12d97bc66046e4a10542b2987448ba89c4b49e66311e969c84,88e61a38-chatgpt_tokyo.png,pos,ChatGPT,gpt-image,1023,1537,png,OpenAI,yes,c2pa-metadata,2026-05-24T19:42:02Z,content: tokyo street night (fetch+blob dl)
607087e40da1ac2636f7233c06784295d374d503ab928bb0c0c9b09e0140b3bc,607087e4-IMG_0505.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
1fa5f77f710c11a4cc69fed60195450def734401ed57c2600a84ce191f985440,1fa5f77f-IMG_0786.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
ac32346771d3ebb6b9d7d395e1efad995972fb708b76082905d15299d61dcfaa,ac323467-IMG_0114.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
2ea228ed270cd169e9d38bc3f1a162de7edbf54b6e5ad3c701a3c90010ec7067,2ea228ed-IMG_1791.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
eb0e14f7c8dfe88d710804fe0d2e74eb033d4d72db5525a526bb3751f2d52f4f,eb0e14f7-ai_price_software_not_hardware.png,neg,iPhone (me/post_photos),,1200,675,png,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
cd2c0eba3625bf7a90178063b4a769667cf00904db968efff7502734f8456892,cd2c0eba-IMG_3201.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
208b21ccfa8c81d2e75903a6083f2806ac068d856ad12957e6f6ea7870af0096,208b21cc-IMG_0712.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
f9c64806fd2b1ea4cf39a49f67e651f2146e9c892f1a401d9981e9d7f0601062,f9c64806-grep_vs_vector_agentic_search.png,neg,iPhone (me/post_photos),,1600,900,png,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
beaae48cf40c8d471c9c1a49d205cd87cf6c079211847f66ced4d4310aa7cff4,beaae48c-IMG_1699.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
41307ceeb9c981fa766ec4d711fe72f647fce86d21f1026e63c45dcd95063a65,41307cee-IMG_2148.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
3801fbe36a77134336508f888f658deb03657c5b765c8b3205b2d928e1b3a5f8,3801fbe3-IMG_2364.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
3012ff6d1db0535423c4dfb22e8d4d2da77b94b776c38cf06fa06958b7a71860,3012ff6d-IMG_2489.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
b978efbdec5ec96b320e53fb23a1aec306f5a32a796a56a032ae66929c803e7b,b978efbd-IMG_0334.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
1136f4df74e842e4ba95c91731656e4bf125361b0aa83b1fc4e128161668a8fe,1136f4df-IMG_0363.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
6e768835f52e840ec0f6af4a9604bd31ed38b2ce3fa187cfe5cc7daa2acb56e4,6e768835-IMG_3492.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
5470ded7892073b1deb31980b3cc541ce73b4cf025d56a43eb6e80c2db810ff2,5470ded7-IMG_1417.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
0bb4c176d83fbcbf4e628de7587d6183b9134c4f3fa85f55b96e94185273c7f6,0bb4c176-IMG_1790.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
c1f3267973e33e91c82ab889d21b6be18a6128e2178a85403bf89b04a6ea8c36,c1f32679-IMG_2981.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
efcb985a4ecc055029bea0df82ba161442340b1df6aa54639c2c717f8ebe7c4e,efcb985a-IMG_0841.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
383c6d9f4d74d1d30eeab2bfbe9b83212dcedb52b36b8ac403efa45730563326,383c6d9f-IMG_1616.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
b284d9ad7f58a44dc2b0d2e69b12236cf004d5052fb5484cfa1c21548bfd3821,b284d9ad-IMG_1250.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
85802ce2d2d8a7a155e631ef36a82cb912691fbbab4b2c28bb2438efed0b9225,85802ce2-IMG_2973.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
15b1e4e26340630345fc18e439ec98995ddca2816f38e13d4b2ee9e99ed26bac,15b1e4e2-IMG_1563.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
d8a1eed3cc327b5db7f5fd635fd4c37b29ff24a3360fce57622ea1cf35af284a,d8a1eed3-IMG_1666.heic,neg,iPhone (me/post_photos),,3024,4032,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
505047f592db5fd2ce4fc0fc3580e0b28abc19b6720a98a73530f2cb7158f7c1,505047f5-IMG_2569.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
a499773829f0ed206f9bd8c46740fc01955c1d43f45e8e775582015d9db9956c,a4997738-IMG_1980.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
a2f98b6a8887c7b4909601c46d03fb1f6e5145e714ffe3bc8b7056bc636bfee0,a2f98b6a-IMG_0780.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
9172987b14366231857e74a3f7b554ec0fc8cc23ebb7f88cd8eca3ce95ef8498,9172987b-IMG_3293.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
2f21fcd7b1ace88aa368028a7a97ffadd6ebe7cffe6c221d47abbed2a205074b,2f21fcd7-IMG_3144.PNG,neg,iPhone (me/post_photos),,2250,3000,png,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
d95e9af4d343501c4597918b5dfc66a1afc338b126e29c9eba13e7b2a61174d5,d95e9af4-IMG_1390.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
25b72de3a2ba4110c5dea68e82af6d19592eecc7000b2f12f52f8512afd48e73,25b72de3-IMG_1369.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
d18874c51b17ced8f736e24cd12ba54bd6c4f76292cba702a9c8a2d97c72f1c4,d18874c5-IMG_1447.HEIC,neg,iPhone (me/post_photos),,3088,2316,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
bf5abcd01037f44b2418b586c3b864340188a00f95047433832972ab797ae2d5,bf5abcd0-IMG_0743.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
a2782e0e6bb3d611925cace6ca3da10259ae70229aad3f366bc25b9e94538ab4,a2782e0e-IMG_0345.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
3fc0c4253f86427777904f41355eacbdcc1a29f6897ec694c7fc68b6e7f70846,3fc0c425-IMG_1450.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
56023f628e6d8ad0a3fb204b5bd10c9e9182446bad9032b0ab8c8d068a311bc7,56023f62-IMG_1796.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
100a121953049a83a714cd8771c7ac3f5afba69d8f8abb073535e98358395014,100a1219-IMG_0037.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
3e287f9e838f4f1bef5d8417957ea504954e5851de61656bbe32915c90db3a1f,3e287f9e-IMG_2323.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
237ff92d14163216687649f1fe222d5427f8600d8a97cb667386e71466a5d908,237ff92d-IMG_2270.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
a3f4a1a85bbd1cc6236e20c5a7d58bc18ccf695bc471cc02f36c1ad885551df8,a3f4a1a8-IMG_1309.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
be2f8bcb71f4bc5a4e79821bb4691e0f3c817ce916a35b05ffacce003a380c1c,be2f8bcb-IMG_0999.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
d5a4f2f81e86696cd18d38796c6b92ce96dcfe073bfd3a9c5dedf9d574aa66d6,d5a4f2f8-span_vs_flattening.png,neg,iPhone (me/post_photos),,1600,1000,png,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
2b148db2b1a314a87647a03828b3d235e7c4c939252f448b572b7658c7bb9723,2b148db2-IMG_1832.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
2d02fed0b6d60ce1142eadac9837a83896569dddbdad7cc5cfdec018f0506d36,2d02fed0-IMG_1520.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
68bf936a5491478484a295b3d882fef042f77e9784cae1b64a9e6e1b72d0628c,68bf936a-IMG_1107.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
32a9b71a13ecec3c90dcd7b54b90206acbe0f646a2296c955e227baa83823eec,32a9b71a-IMG_1238.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
8f170c06f843c2bcf4cf6e249dd76365287fdb3a322637ba93c570f50cb19772,8f170c06-IMG_1078.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
f7634a3779812f52de0599736b4c35f55b61027bae191e4ca060b10341661731,f7634a37-IMG_1605.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
e2ae859a96a5076f37b6efc5a1dad4952e29550f93cee4bba4899ef6fda09305,e2ae859a-IMG_3394.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
b4ea1e6f9134dfc91b4334ed723a15c937eee4fe3fd65b210cc31959884e5c4e,b4ea1e6f-IMG_1595.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
bf9d4d9ebb17cbae46407446d440b273fc136ea3e157a648e4959b116fca376d,bf9d4d9e-IMG_0916.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
2546689c376f365188a5e5a85e72982ff099b5680608e861c8490bde340e8a29,2546689c-IMG_2481.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
88944f46e3d85ea5c4f6d4559f8da9360f68060f73bb8b8c3344c3fdd3b54476,88944f46-IMG_2157.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
3fc4c8316012abc462df5534d4d995e5af84d35076a2927331107ff994293d9e,3fc4c831-IMG_2566.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
af59d9a72a859a72b6ad8e6178992e320934f74eb2a88d74c9d4c64b5bdb89f5,af59d9a7-IMG_2198.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
4f9d6807ff42d0bcd9494dc5d4c9d15f7b96691e29bfe2d670b8e3bceba934de,4f9d6807-IMG_0371.HEIC,neg,iPhone (me/post_photos),,3088,2316,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
237492e65e73acacf8135a2a903a2a231ee9c68ddcc2c590e90ec6e54c6a4520,237492e6-IMG_0473.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
d8a339db6b19c71d4ccf1a467c8999c5d2947b5c8cfd07a8b17b343f579e3f26,d8a339db-IMG_1798.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
abe73f771fb57c959663070cb560cfd5365999ef6b9aa419b18ea1031fab4a9d,abe73f77-IMG_3434.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
15fbe3e101bce529ec9b7a49fb668ecdad62c3aceb6e649f4668b58af1784c32,15fbe3e1-IMG_0481.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
455d618e94ebf2e469ede016e06c48def5cc14ef68c090584b31570b9fc5e34e,455d618e-IMG_2957.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
dae46cfca7baa921675ffaf74701617b6fe10e629c5e7f099c67bf21a3f2a62c,dae46cfc-IMG_0356.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
cb4d61e66982736d552b043866d80bfb304fe1f44ec07f3acabeff71d08b3708,cb4d61e6-IMG_1681.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
c7776595d517bebab73370b6395deb1dc2bb507f99d2bd551bfe1a2416f5165b,c7776595-168E1031-ADC1-48A2-8B63-F5EB228DF5B4.JPG,neg,iPhone (me/post_photos),,3664,2062,jpeg,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
5fe59521d579b536340253d2bcaa7c28ddd2485fa964fc27a9b9e5118ad0cdd1,5fe59521-IMG_1496.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
81e4e532c124b182b1608d19feda52b829390adc9b77f47b72d89829035917ff,81e4e532-IMG_2193.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
4ead7918a5aeea7fba78b36af44358fea7ed1db7f46f4bc675152b9d04e68c38,4ead7918-IMG_1300.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
75f8a25c217374689d8224d58736f09245882334a9e4d39931025bd66a6df341,75f8a25c-IMG_3540.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
574acd7b8a49652d62df90539c2e18d1f3f510399a294f6c5209ab725c96a7df,574acd7b-IMG_3393.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
8115feaa7b758fd1b984c55355fc8f4c10dfe02192fe810bb6692556190d5176,8115feaa-IMG_1341.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
4c795238b89178cf52a3674e721ed7f4cd5068028385491d12171a3c62545c35,4c795238-IMG_3034.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
5abfaccb37c549de67c7f3a751a528a423e24a82c643fdb29094be8debbb206d,5abfaccb-IMG_3018.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
5fed1923d513c1e9ffcba2f240e617fa9344fb39d35144169570ece8b0bd0f33,5fed1923-IMG_0272.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
b3e1b034bc9ff1fd7e3db77ae0f0a695fcb4ed7bdcb473312427d78aa18700a1,b3e1b034-IMG_1199.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
79a12ccab0ccb954b1917d8edc75cb7c01d02c3a803b34f45be7afdb69cf80ea,79a12cca-IMG_0767.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
7252adc2103f7be6d04d56e0eed0d90122c8203af0ae08c28f922338b7c1bbb5,7252adc2-IMG_0623.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
06b04d8fe8e1cd6bee9a973f93bfda37586924cbfec7d372f59d52aa9196160b,06b04d8f-IMG_1474.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
591636c01c57864c44bdf7c1062bf6fca87efa8fae25bfc58c7cffb5a73424ab,591636c0-IMG_2809.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
ca2f226f45a5e37071605f074fa64de31cd09f923b22f57bb8255aa9046c6b92,ca2f226f-IMG_1419.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
ed751e2489ce4b26c268019942ed65a1f95a10f8056f26ac646d7d19e5db58ad,ed751e24-IMG_1225.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
3757e6100e567cbbf8eadf4cb8a064d79073cea1588251125a3b1ae13893e778,3757e610-IMG_2583.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
e973424c779cfa5dcd2403f103673423771192509395586f8efc459c92240d5d,e973424c-IMG_2215.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
46657fb69680b3e5db8fdb17aa306f2b6ee41a719408ac5812ff4cb399e15485,46657fb6-IMG_1439.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
dd112d116dda31847b50f40ab0990306d1c1f137780c67bb094ee6992f2d6f7e,dd112d11-IMG_1317.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
a0cdcc17d39eda2a7b2c34ff1f4ed97290fa996fef530c1d24da91722b1a6893,a0cdcc17-IMG_0005.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
70fabcbeedafb3c033d5805328ffd12bf644102e8666b878eca5968bd075b3fe,70fabcbe-IMG_3500.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
8fdb574a94e65e14ac29017cf2d5a2ede18a8c4e3f12e04c64292b0d38570062,8fdb574a-IMG_3557.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
cb6777332e357aa38916400c2c1fe76801aad54946e789e9fa6a7d4659d02948,cb677733-IMG_3582.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
72c71846fdb9eb3d960142c24627304349ee4fd8665d10d06e336430b0e20d80,72c71846-meta_lake.jpg,neg,Meta AI,meta,810,1440,jpeg,,,none,2026-05-24T21:33:47Z,"IPTC Made-with-AI (trainedAlgorithmicMedia); NO SynthID, NO C2PA"
c86973424817f62510e2a312b85c52e05adf47ace87a8e717fd442607596f501,c8697342-aistudio_lake.png,pos,Google AI Studio (Nano Banana),gemini-2.5-flash-image,1024,1024,png,,,gemini-app,2026-05-24T21:39:09Z,"API/playground: SynthID pixel CONFIRMED (Gemini-app oracle) + visible sparkle, but NO C2PA/IPTC -> synthid_source blind spot"
1f81827c06d67cf6f6c7f5d53ec8f9738183942a6d1d2717b161fea0fdcc540a,1f81827c-Designer.png,pos,Microsoft Designer,dall-e (Designer),1024,1024,png,"OpenAI, Microsoft",yes,c2pa-metadata,2026-05-24T22:18:40Z,C2PA issuer OpenAI+Microsoft; synthid_source=OpenAI (DALL-E surface inherits OpenAI SynthID+C2PA)
f3ebe4683fc40aa2a0b80cc3ced3dc8062385ae32b488f4b33cb2330949e658d,f3ebe468-leonardo_lake.jpg,neg,Leonardo.ai,lucid-origin,1024,1024,jpeg,,,none,2026-05-24T22:22:15Z,non-SynthID AI; NO provenance metadata (no C2PA/IPTC/SynthID)
12775f4c0aec1ecd615e88c4941a788a053d0258d08d31ad34d330d3e21969bd,12775f4c-grok_original.jpg,neg,Grok (xAI),aurora,784,1168,jpeg,,,none,2026-05-24T22:47:46Z,full-res original via Download button; xAI non-adopter; NO C2PA/IPTC/SynthID (has benign EXIF block)
74b605deb102d74443a182f70fbea34a6a470bed381d62ae0bfa0083a6ecc5a3,74b605de-_e1f7984d-98e7-4cdc-91d2-f72314a4bc56.jpeg,neg,Bing Image Creator,MAI-Image-2e,1024,1024,jpeg,,,c2pa-metadata,2026-05-25T00:10:28Z,C2PA issuer Microsoft; non-SynthID; issuer-coverage fixture
f9bb6b1039ee228221d2eead7daebd46422a36a4a8959a74fa0198e1aa19f9b8,f9bb6b10-a_red_ceramic_coffee_mug_on_a_wooden_table_soft_morning_light_photorealistic_20260525000725_04.png,neg,Stability Brand Studio,Stable Image,1024,1024,png,Stability AI,,c2pa-metadata,2026-05-25T00:10:31Z,C2PA issuer Stability AI; non-SynthID; no imwatermark
23a06e0067930a9ad09a803f634414eb8ca0234fc0bbaca8d33f138745175e2e,23a06e00-a-red-ceramic-coffee-mug-on-a-wooden-table--soft-m.png,neg,Recraft V4.1,recraft-v4,896,1152,png,,,none,2026-05-25T01:21:35Z,"PNG export, no C2PA/EXIF/IPTC/imwatermark; clean download"
4f3fea84c24fda8d72a6e7bfbae31827348d9edc755ec9a5e8c835860130de57,4f3fea84-a_red_ceramic_coffee_mug_on_a_wooden_table_soft_morning_light_photorealistic_67qgxtyin9kjk5yb5mph_0.png,neg,Krea (FLUX 2 host),flux-2-dev via Krea,832,1248,png,,,none,2026-05-25T01:21:38Z,no imwatermark despite FLUX; Krea host strips/omits; clean download
7bdff2c3131cdc21783282a8a95c88f4b14ad38494a1041d0d68691af7fa5cd8,7bdff2c3-0001_1_a-red-ceramic-coffee-mug-sits-on-weather_D3BRZfOyUwuh5Emjtu8v4g_F7yI1GFGTayDA1NAUmkraw_cover.jpeg,neg,Ideogram,ideogram-3,1024,1024,jpeg,,,none,2026-05-25T01:36:38Z,EXIF Make='Ideogram AI'; no C2PA/SynthID; caught via exif_generator
ca2162624607b8f030c63cb1371d6afdd3f0d7af3d9b17514f7ae64a2e8f378c,ca216262-0001_2_a-red-ceramic-coffee-mug-sits-on-weather_rs0Lt1beXV-Mwjs33vWKVw_F7yI1GFGTayDA1NAUmkraw.jpeg,neg,Ideogram,ideogram-3,1024,1024,jpeg,,,none,2026-05-25T01:36:38Z,EXIF Make='Ideogram AI'; no C2PA/SynthID; caught via exif_generator
8a90f2ddf83ceedd7944531d27248191c79dc321ec76ea1f36df6cff9f6b8916,8a90f2dd-0001_3_a-red-ceramic-coffee-mug-sits-on-weather_Lm96lBwcXsmdeCfCpm52vw_F7yI1GFGTayDA1NAUmkraw.jpeg,neg,Ideogram,ideogram-3,1024,1024,jpeg,,,none,2026-05-25T01:36:38Z,EXIF Make='Ideogram AI'; no C2PA/SynthID; caught via exif_generator
87fca198d5c436b27a16685972802a2ff0a310a941248c9ec8d81560d7f387a1,87fca198-0001_4_a-red-ceramic-coffee-mug-sits-on-weather_pGmlgcH4XqSaq41hc0a9AQ_F7yI1GFGTayDA1NAUmkraw.jpeg,neg,Ideogram,ideogram-3,1024,1024,jpeg,,,none,2026-05-25T01:36:38Z,EXIF Make='Ideogram AI'; no C2PA/SynthID; caught via exif_generator
f6dd47a5ffd319aea21bf10dcf9877097666420b02c2620080bac12b03976e7e,f6dd47a5-4ef377bd-gpt-image-2-cleaned.png,cleaned,"our pipeline (invisible/SDXL, native-res default)",stabilityai/stable-diffusion-xl-base-1.0,1254,1254,png,,,openai-verify,2026-05-25T20:50:38Z,"cleaned from 4ef377bd via v0.5.3 'all' at native 1254x1254 (prod-equivalent); openai.com/verify: SynthID NOT detected. Re-confirms #10 native-res default defeats OpenAI SynthID (closes #15 root cause). Note: native res OOMs on 20GB MPS, auto-fell back to CPU."
1 sha256 filename label source model width height format c2pa_issuer synthid_metadata verified_via added notes
2 4ef377bde1a1d4eff141972841938643b173f5052992a018b9a21b31ac31731e 4ef377bd-ChatGPT Image May 23, 2026, 02_43_02 PM.png pos ChatGPT gpt-image 1254 1254 png OpenAI yes openai-verify 2026-05-23T21:48:12Z fresh post-rollout 2026-05-23; openai.com/verify: SynthID+C2PA detected
3 d09f84c0e4c6d8b336bf4a9a7277314e940dcb5052ae7051e785cbb3bb42d656 d09f84c0-Gemini_Generated_Image_vq7wkwvq7wkwvq7w.png pos Gemini app gemini 2816 1536 png Google LLC yes c2pa-metadata 2026-05-23T21:52:40Z user: latest Gemini, SynthID v2
47188e88f956291bd38ab6906e5f21eb273d4a697ddc8b4479deac9f48915e1a 47188e88-disco_synthid_removed.png cleaned our pipeline (invisible/SDXL) stabilityai/stable-diffusion-xl-base-1.0 1254 1254 png openai-verify 2026-05-23T22:06:54Z cleaned from 4ef377bd disco; openai.com/verify: SynthID NOT detected (defeated)
52bb6bd524a74bff1b74ed893784c1c0ee76f48a9b45a009bae95ad4a57b7759 52bb6bd5-ChatGPT Image May 24, 2026, 11_01_02 AM.png pos ChatGPT gpt-image 1254 1254 png OpenAI yes c2pa-metadata 2026-05-24T18:49:35Z solid black ref (spectral pilot 2026-05-24)
ee75ade07914d6306eae3443b3028782e0ac8c125a31b2d5a141f75aebdafb18 ee75ade0-ChatGPT Image May 24, 2026, 11_10_04 AM.png pos ChatGPT gpt-image 1254 1254 png OpenAI yes c2pa-metadata 2026-05-24T18:49:35Z solid black ref (spectral pilot 2026-05-24)
9398c74dfad0f030633bd3ac224ba53e56a7ff9711d7b3a4c464e0073ece51b5 9398c74d-ChatGPT Image May 24, 2026, 11_10_09 AM.png pos ChatGPT gpt-image 1254 1254 png OpenAI yes c2pa-metadata 2026-05-24T18:49:35Z solid black ref (spectral pilot 2026-05-24)
79c3733895e82e3c9e506de0ddd6dfbf20ba09171263d88444520777151868c8 79c37338-ChatGPT Image May 24, 2026, 11_10_13 AM.png pos ChatGPT gpt-image 1254 1254 png OpenAI yes c2pa-metadata 2026-05-24T18:49:35Z solid black ref (spectral pilot 2026-05-24)
38315a0a83aa0e094a50520ea44e01aa26115927f09a532ff2ab8636de743e0a 38315a0a-ChatGPT Image May 24, 2026, 11_10_19 AM.png pos ChatGPT gpt-image 1254 1254 png OpenAI yes c2pa-metadata 2026-05-24T18:49:35Z solid black ref (spectral pilot 2026-05-24)
f3a1fbc3bc8f768265400724bb9800d322f8e0b1461b2c585540845ea8352c5d f3a1fbc3-winter_scene_X.png neg local (SDXL/processed) sdxl-or-processed 2816 1536 png none 2026-05-24T18:52:23Z metadata-clean: no C2PA/SynthID source
f07bc0bcad09a5a5687ae312a1298c9ddd110a5e414265efc52ef4d524b36f86 f07bc0bc-api_enterprise_gate_ax.png neg local (SDXL/processed) sdxl-or-processed 1080 1350 png none 2026-05-24T18:52:23Z metadata-clean: no C2PA/SynthID source
89571987e368f1ce82f9dedfa9101584434def842e50d1f4c759de64db5c21d9 89571987-c87bd3c48a4443a68cb84a65604dacd3_clean.png neg local (SDXL/processed) sdxl-or-processed 2816 1536 png none 2026-05-24T18:52:23Z metadata-clean: no C2PA/SynthID source
4 7b650522d42db09568e249c04d683c469fb3e280a2c53fcd1031cb9df27c619a 7b650522-ChatGPT Image May 24, 2026, 12_19_54 PM.png pos ChatGPT gpt-image 1602 982 png OpenAI yes c2pa-metadata 2026-05-24T19:20:25Z content: misty pine forest at dawn
5 fb28dba2a82cc101a92fdee5714867b32610d0564f37737fe4bb70782b8ecf32 fb28dba2-Gemini_Generated_Image_dsjlnsdsjlnsdsjl.png pos Gemini app gemini 2816 1536 png Google LLC yes c2pa-metadata 2026-05-24T19:30:25Z content: elderly fisherman portrait
6 d20d4cc936dbdfe909c52502039a9e84ba93d97b42b24a0acee5b7d6c71930ae d20d4cc9-Gemini_Generated_Image_ug6kdpug6kdpug6k.png pos Gemini app gemini 2816 1536 png Google LLC yes c2pa-metadata 2026-05-24T19:33:15Z content: red coffee mug product shot
7 28f323345f6496d936c3f1a72f671ddf59d0f81565c24a63bf3286860f633afe 28f32334-chatgpt_fisherman.png pos ChatGPT gpt-image 1023 1537 png OpenAI yes c2pa-metadata 2026-05-24T19:39:20Z content: elderly fisherman portrait (fetch+blob dl)
8 88e61a384c2e0b12d97bc66046e4a10542b2987448ba89c4b49e66311e969c84 88e61a38-chatgpt_tokyo.png pos ChatGPT gpt-image 1023 1537 png OpenAI yes c2pa-metadata 2026-05-24T19:42:02Z content: tokyo street night (fetch+blob dl)
607087e40da1ac2636f7233c06784295d374d503ab928bb0c0c9b09e0140b3bc 607087e4-IMG_0505.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
9 1fa5f77f710c11a4cc69fed60195450def734401ed57c2600a84ce191f985440 1fa5f77f-IMG_0786.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
ac32346771d3ebb6b9d7d395e1efad995972fb708b76082905d15299d61dcfaa ac323467-IMG_0114.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
10 2ea228ed270cd169e9d38bc3f1a162de7edbf54b6e5ad3c701a3c90010ec7067 2ea228ed-IMG_1791.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
eb0e14f7c8dfe88d710804fe0d2e74eb033d4d72db5525a526bb3751f2d52f4f eb0e14f7-ai_price_software_not_hardware.png neg iPhone (me/post_photos) 1200 675 png none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
cd2c0eba3625bf7a90178063b4a769667cf00904db968efff7502734f8456892 cd2c0eba-IMG_3201.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
208b21ccfa8c81d2e75903a6083f2806ac068d856ad12957e6f6ea7870af0096 208b21cc-IMG_0712.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
f9c64806fd2b1ea4cf39a49f67e651f2146e9c892f1a401d9981e9d7f0601062 f9c64806-grep_vs_vector_agentic_search.png neg iPhone (me/post_photos) 1600 900 png none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
beaae48cf40c8d471c9c1a49d205cd87cf6c079211847f66ced4d4310aa7cff4 beaae48c-IMG_1699.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
41307ceeb9c981fa766ec4d711fe72f647fce86d21f1026e63c45dcd95063a65 41307cee-IMG_2148.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
3801fbe36a77134336508f888f658deb03657c5b765c8b3205b2d928e1b3a5f8 3801fbe3-IMG_2364.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
3012ff6d1db0535423c4dfb22e8d4d2da77b94b776c38cf06fa06958b7a71860 3012ff6d-IMG_2489.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
b978efbdec5ec96b320e53fb23a1aec306f5a32a796a56a032ae66929c803e7b b978efbd-IMG_0334.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
1136f4df74e842e4ba95c91731656e4bf125361b0aa83b1fc4e128161668a8fe 1136f4df-IMG_0363.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
6e768835f52e840ec0f6af4a9604bd31ed38b2ce3fa187cfe5cc7daa2acb56e4 6e768835-IMG_3492.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
5470ded7892073b1deb31980b3cc541ce73b4cf025d56a43eb6e80c2db810ff2 5470ded7-IMG_1417.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
11 0bb4c176d83fbcbf4e628de7587d6183b9134c4f3fa85f55b96e94185273c7f6 0bb4c176-IMG_1790.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
c1f3267973e33e91c82ab889d21b6be18a6128e2178a85403bf89b04a6ea8c36 c1f32679-IMG_2981.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
efcb985a4ecc055029bea0df82ba161442340b1df6aa54639c2c717f8ebe7c4e efcb985a-IMG_0841.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
383c6d9f4d74d1d30eeab2bfbe9b83212dcedb52b36b8ac403efa45730563326 383c6d9f-IMG_1616.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
b284d9ad7f58a44dc2b0d2e69b12236cf004d5052fb5484cfa1c21548bfd3821 b284d9ad-IMG_1250.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
85802ce2d2d8a7a155e631ef36a82cb912691fbbab4b2c28bb2438efed0b9225 85802ce2-IMG_2973.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
15b1e4e26340630345fc18e439ec98995ddca2816f38e13d4b2ee9e99ed26bac 15b1e4e2-IMG_1563.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
d8a1eed3cc327b5db7f5fd635fd4c37b29ff24a3360fce57622ea1cf35af284a d8a1eed3-IMG_1666.heic neg iPhone (me/post_photos) 3024 4032 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
505047f592db5fd2ce4fc0fc3580e0b28abc19b6720a98a73530f2cb7158f7c1 505047f5-IMG_2569.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:47Z post photo; no C2PA/SynthID (verified)
a499773829f0ed206f9bd8c46740fc01955c1d43f45e8e775582015d9db9956c a4997738-IMG_1980.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
a2f98b6a8887c7b4909601c46d03fb1f6e5145e714ffe3bc8b7056bc636bfee0 a2f98b6a-IMG_0780.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
9172987b14366231857e74a3f7b554ec0fc8cc23ebb7f88cd8eca3ce95ef8498 9172987b-IMG_3293.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
2f21fcd7b1ace88aa368028a7a97ffadd6ebe7cffe6c221d47abbed2a205074b 2f21fcd7-IMG_3144.PNG neg iPhone (me/post_photos) 2250 3000 png none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
d95e9af4d343501c4597918b5dfc66a1afc338b126e29c9eba13e7b2a61174d5 d95e9af4-IMG_1390.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
25b72de3a2ba4110c5dea68e82af6d19592eecc7000b2f12f52f8512afd48e73 25b72de3-IMG_1369.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
d18874c51b17ced8f736e24cd12ba54bd6c4f76292cba702a9c8a2d97c72f1c4 d18874c5-IMG_1447.HEIC neg iPhone (me/post_photos) 3088 2316 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
bf5abcd01037f44b2418b586c3b864340188a00f95047433832972ab797ae2d5 bf5abcd0-IMG_0743.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
a2782e0e6bb3d611925cace6ca3da10259ae70229aad3f366bc25b9e94538ab4 a2782e0e-IMG_0345.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
12 3fc0c4253f86427777904f41355eacbdcc1a29f6897ec694c7fc68b6e7f70846 3fc0c425-IMG_1450.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
56023f628e6d8ad0a3fb204b5bd10c9e9182446bad9032b0ab8c8d068a311bc7 56023f62-IMG_1796.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
100a121953049a83a714cd8771c7ac3f5afba69d8f8abb073535e98358395014 100a1219-IMG_0037.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
3e287f9e838f4f1bef5d8417957ea504954e5851de61656bbe32915c90db3a1f 3e287f9e-IMG_2323.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
237ff92d14163216687649f1fe222d5427f8600d8a97cb667386e71466a5d908 237ff92d-IMG_2270.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
a3f4a1a85bbd1cc6236e20c5a7d58bc18ccf695bc471cc02f36c1ad885551df8 a3f4a1a8-IMG_1309.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
be2f8bcb71f4bc5a4e79821bb4691e0f3c817ce916a35b05ffacce003a380c1c be2f8bcb-IMG_0999.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
d5a4f2f81e86696cd18d38796c6b92ce96dcfe073bfd3a9c5dedf9d574aa66d6 d5a4f2f8-span_vs_flattening.png neg iPhone (me/post_photos) 1600 1000 png none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
13 2b148db2b1a314a87647a03828b3d235e7c4c939252f448b572b7658c7bb9723 2b148db2-IMG_1832.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
14 2d02fed0b6d60ce1142eadac9837a83896569dddbdad7cc5cfdec018f0506d36 2d02fed0-IMG_1520.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
68bf936a5491478484a295b3d882fef042f77e9784cae1b64a9e6e1b72d0628c 68bf936a-IMG_1107.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
32a9b71a13ecec3c90dcd7b54b90206acbe0f646a2296c955e227baa83823eec 32a9b71a-IMG_1238.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
15 8f170c06f843c2bcf4cf6e249dd76365287fdb3a322637ba93c570f50cb19772 8f170c06-IMG_1078.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
f7634a3779812f52de0599736b4c35f55b61027bae191e4ca060b10341661731 f7634a37-IMG_1605.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
e2ae859a96a5076f37b6efc5a1dad4952e29550f93cee4bba4899ef6fda09305 e2ae859a-IMG_3394.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
b4ea1e6f9134dfc91b4334ed723a15c937eee4fe3fd65b210cc31959884e5c4e b4ea1e6f-IMG_1595.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
bf9d4d9ebb17cbae46407446d440b273fc136ea3e157a648e4959b116fca376d bf9d4d9e-IMG_0916.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
2546689c376f365188a5e5a85e72982ff099b5680608e861c8490bde340e8a29 2546689c-IMG_2481.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
88944f46e3d85ea5c4f6d4559f8da9360f68060f73bb8b8c3344c3fdd3b54476 88944f46-IMG_2157.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
16 3fc4c8316012abc462df5534d4d995e5af84d35076a2927331107ff994293d9e 3fc4c831-IMG_2566.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
af59d9a72a859a72b6ad8e6178992e320934f74eb2a88d74c9d4c64b5bdb89f5 af59d9a7-IMG_2198.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
4f9d6807ff42d0bcd9494dc5d4c9d15f7b96691e29bfe2d670b8e3bceba934de 4f9d6807-IMG_0371.HEIC neg iPhone (me/post_photos) 3088 2316 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
237492e65e73acacf8135a2a903a2a231ee9c68ddcc2c590e90ec6e54c6a4520 237492e6-IMG_0473.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
d8a339db6b19c71d4ccf1a467c8999c5d2947b5c8cfd07a8b17b343f579e3f26 d8a339db-IMG_1798.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
abe73f771fb57c959663070cb560cfd5365999ef6b9aa419b18ea1031fab4a9d abe73f77-IMG_3434.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
15fbe3e101bce529ec9b7a49fb668ecdad62c3aceb6e649f4668b58af1784c32 15fbe3e1-IMG_0481.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
455d618e94ebf2e469ede016e06c48def5cc14ef68c090584b31570b9fc5e34e 455d618e-IMG_2957.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
dae46cfca7baa921675ffaf74701617b6fe10e629c5e7f099c67bf21a3f2a62c dae46cfc-IMG_0356.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
cb4d61e66982736d552b043866d80bfb304fe1f44ec07f3acabeff71d08b3708 cb4d61e6-IMG_1681.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
c7776595d517bebab73370b6395deb1dc2bb507f99d2bd551bfe1a2416f5165b c7776595-168E1031-ADC1-48A2-8B63-F5EB228DF5B4.JPG neg iPhone (me/post_photos) 3664 2062 jpeg none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
17 5fe59521d579b536340253d2bcaa7c28ddd2485fa964fc27a9b9e5118ad0cdd1 5fe59521-IMG_1496.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
81e4e532c124b182b1608d19feda52b829390adc9b77f47b72d89829035917ff 81e4e532-IMG_2193.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
18 4ead7918a5aeea7fba78b36af44358fea7ed1db7f46f4bc675152b9d04e68c38 4ead7918-IMG_1300.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:48Z post photo; no C2PA/SynthID (verified)
75f8a25c217374689d8224d58736f09245882334a9e4d39931025bd66a6df341 75f8a25c-IMG_3540.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
574acd7b8a49652d62df90539c2e18d1f3f510399a294f6c5209ab725c96a7df 574acd7b-IMG_3393.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
8115feaa7b758fd1b984c55355fc8f4c10dfe02192fe810bb6692556190d5176 8115feaa-IMG_1341.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
19 4c795238b89178cf52a3674e721ed7f4cd5068028385491d12171a3c62545c35 4c795238-IMG_3034.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
20 5abfaccb37c549de67c7f3a751a528a423e24a82c643fdb29094be8debbb206d 5abfaccb-IMG_3018.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
21 5fed1923d513c1e9ffcba2f240e617fa9344fb39d35144169570ece8b0bd0f33 5fed1923-IMG_0272.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
b3e1b034bc9ff1fd7e3db77ae0f0a695fcb4ed7bdcb473312427d78aa18700a1 b3e1b034-IMG_1199.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
79a12ccab0ccb954b1917d8edc75cb7c01d02c3a803b34f45be7afdb69cf80ea 79a12cca-IMG_0767.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
7252adc2103f7be6d04d56e0eed0d90122c8203af0ae08c28f922338b7c1bbb5 7252adc2-IMG_0623.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
22 06b04d8fe8e1cd6bee9a973f93bfda37586924cbfec7d372f59d52aa9196160b 06b04d8f-IMG_1474.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
591636c01c57864c44bdf7c1062bf6fca87efa8fae25bfc58c7cffb5a73424ab 591636c0-IMG_2809.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
ca2f226f45a5e37071605f074fa64de31cd09f923b22f57bb8255aa9046c6b92 ca2f226f-IMG_1419.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
ed751e2489ce4b26c268019942ed65a1f95a10f8056f26ac646d7d19e5db58ad ed751e24-IMG_1225.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
3757e6100e567cbbf8eadf4cb8a064d79073cea1588251125a3b1ae13893e778 3757e610-IMG_2583.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
e973424c779cfa5dcd2403f103673423771192509395586f8efc459c92240d5d e973424c-IMG_2215.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
46657fb69680b3e5db8fdb17aa306f2b6ee41a719408ac5812ff4cb399e15485 46657fb6-IMG_1439.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
dd112d116dda31847b50f40ab0990306d1c1f137780c67bb094ee6992f2d6f7e dd112d11-IMG_1317.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
a0cdcc17d39eda2a7b2c34ff1f4ed97290fa996fef530c1d24da91722b1a6893 a0cdcc17-IMG_0005.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
70fabcbeedafb3c033d5805328ffd12bf644102e8666b878eca5968bd075b3fe 70fabcbe-IMG_3500.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
23 8fdb574a94e65e14ac29017cf2d5a2ede18a8c4e3f12e04c64292b0d38570062 8fdb574a-IMG_3557.HEIC neg iPhone (me/post_photos) 4032 3024 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
cb6777332e357aa38916400c2c1fe76801aad54946e789e9fa6a7d4659d02948 cb677733-IMG_3582.HEIC neg iPhone (me/post_photos) 5712 4284 heic none 2026-05-24T20:58:49Z post photo; no C2PA/SynthID (verified)
72c71846fdb9eb3d960142c24627304349ee4fd8665d10d06e336430b0e20d80 72c71846-meta_lake.jpg neg Meta AI meta 810 1440 jpeg none 2026-05-24T21:33:47Z IPTC Made-with-AI (trainedAlgorithmicMedia); NO SynthID, NO C2PA
24 c86973424817f62510e2a312b85c52e05adf47ace87a8e717fd442607596f501 c8697342-aistudio_lake.png pos Google AI Studio (Nano Banana) gemini-2.5-flash-image 1024 1024 png gemini-app 2026-05-24T21:39:09Z API/playground: SynthID pixel CONFIRMED (Gemini-app oracle) + visible sparkle, but NO C2PA/IPTC -> synthid_source blind spot
25 1f81827c06d67cf6f6c7f5d53ec8f9738183942a6d1d2717b161fea0fdcc540a 1f81827c-Designer.png pos Microsoft Designer dall-e (Designer) 1024 1024 png OpenAI, Microsoft yes c2pa-metadata 2026-05-24T22:18:40Z C2PA issuer OpenAI+Microsoft; synthid_source=OpenAI (DALL-E surface inherits OpenAI SynthID+C2PA)
26 f3ebe4683fc40aa2a0b80cc3ced3dc8062385ae32b488f4b33cb2330949e658d f6dd47a5ffd319aea21bf10dcf9877097666420b02c2620080bac12b03976e7e f3ebe468-leonardo_lake.jpg f6dd47a5-4ef377bd-gpt-image-2-cleaned.png neg cleaned Leonardo.ai our pipeline (invisible/SDXL, native-res default) lucid-origin stabilityai/stable-diffusion-xl-base-1.0 1024 1254 1024 1254 jpeg png none openai-verify 2026-05-24T22:22:15Z 2026-05-25T20:50:38Z non-SynthID AI; NO provenance metadata (no C2PA/IPTC/SynthID) cleaned from 4ef377bd via v0.5.3 'all' at native 1254x1254 (prod-equivalent); openai.com/verify: SynthID NOT detected. Re-confirms #10 native-res default defeats OpenAI SynthID (closes #15 root cause). Note: native res OOMs on 20GB MPS, auto-fell back to CPU.
12775f4c0aec1ecd615e88c4941a788a053d0258d08d31ad34d330d3e21969bd 12775f4c-grok_original.jpg neg Grok (xAI) aurora 784 1168 jpeg none 2026-05-24T22:47:46Z full-res original via Download button; xAI non-adopter; NO C2PA/IPTC/SynthID (has benign EXIF block)
74b605deb102d74443a182f70fbea34a6a470bed381d62ae0bfa0083a6ecc5a3 74b605de-_e1f7984d-98e7-4cdc-91d2-f72314a4bc56.jpeg neg Bing Image Creator MAI-Image-2e 1024 1024 jpeg c2pa-metadata 2026-05-25T00:10:28Z C2PA issuer Microsoft; non-SynthID; issuer-coverage fixture
f9bb6b1039ee228221d2eead7daebd46422a36a4a8959a74fa0198e1aa19f9b8 f9bb6b10-a_red_ceramic_coffee_mug_on_a_wooden_table_soft_morning_light_photorealistic_20260525000725_04.png neg Stability Brand Studio Stable Image 1024 1024 png Stability AI c2pa-metadata 2026-05-25T00:10:31Z C2PA issuer Stability AI; non-SynthID; no imwatermark
23a06e0067930a9ad09a803f634414eb8ca0234fc0bbaca8d33f138745175e2e 23a06e00-a-red-ceramic-coffee-mug-on-a-wooden-table--soft-m.png neg Recraft V4.1 recraft-v4 896 1152 png none 2026-05-25T01:21:35Z PNG export, no C2PA/EXIF/IPTC/imwatermark; clean download
4f3fea84c24fda8d72a6e7bfbae31827348d9edc755ec9a5e8c835860130de57 4f3fea84-a_red_ceramic_coffee_mug_on_a_wooden_table_soft_morning_light_photorealistic_67qgxtyin9kjk5yb5mph_0.png neg Krea (FLUX 2 host) flux-2-dev via Krea 832 1248 png none 2026-05-25T01:21:38Z no imwatermark despite FLUX; Krea host strips/omits; clean download
7bdff2c3131cdc21783282a8a95c88f4b14ad38494a1041d0d68691af7fa5cd8 7bdff2c3-0001_1_a-red-ceramic-coffee-mug-sits-on-weather_D3BRZfOyUwuh5Emjtu8v4g_F7yI1GFGTayDA1NAUmkraw_cover.jpeg neg Ideogram ideogram-3 1024 1024 jpeg none 2026-05-25T01:36:38Z EXIF Make='Ideogram AI'; no C2PA/SynthID; caught via exif_generator
ca2162624607b8f030c63cb1371d6afdd3f0d7af3d9b17514f7ae64a2e8f378c ca216262-0001_2_a-red-ceramic-coffee-mug-sits-on-weather_rs0Lt1beXV-Mwjs33vWKVw_F7yI1GFGTayDA1NAUmkraw.jpeg neg Ideogram ideogram-3 1024 1024 jpeg none 2026-05-25T01:36:38Z EXIF Make='Ideogram AI'; no C2PA/SynthID; caught via exif_generator
8a90f2ddf83ceedd7944531d27248191c79dc321ec76ea1f36df6cff9f6b8916 8a90f2dd-0001_3_a-red-ceramic-coffee-mug-sits-on-weather_Lm96lBwcXsmdeCfCpm52vw_F7yI1GFGTayDA1NAUmkraw.jpeg neg Ideogram ideogram-3 1024 1024 jpeg none 2026-05-25T01:36:38Z EXIF Make='Ideogram AI'; no C2PA/SynthID; caught via exif_generator
87fca198d5c436b27a16685972802a2ff0a310a941248c9ec8d81560d7f387a1 87fca198-0001_4_a-red-ceramic-coffee-mug-sits-on-weather_pGmlgcH4XqSaq41hc0a9AQ_F7yI1GFGTayDA1NAUmkraw.jpeg neg Ideogram ideogram-3 1024 1024 jpeg none 2026-05-25T01:36:38Z EXIF Make='Ideogram AI'; no C2PA/SynthID; caught via exif_generator
+33 -5
View File
@@ -1,6 +1,6 @@
[project]
name = "remove-ai-watermarks"
version = "0.5.2"
version = "0.6.11"
description = "Remove visible and invisible AI watermarks from images (Gemini / Nano Banana, ChatGPT, Stable Diffusion)"
readme = "README.md"
requires-python = ">=3.10"
@@ -19,7 +19,17 @@ dependencies = [
gpu = [
"torch>=2.0.0",
"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",
@@ -28,11 +38,29 @@ gpu = [
]
# Open invisible-watermark (imwatermark) decoder for detecting the DWT-DCT
# watermarks embedded by Stable Diffusion / SDXL / FLUX. Optional because it
# pulls non-headless opencv; identify() guards the import and skips the signal
# when absent.
# pulls non-headless opencv AND torch (invisible-watermark declares torch a hard
# dependency, and WatermarkDecoder eagerly imports rivaGan -> torch at import
# time, so the dwtDct-only detect path still needs torch present even though it
# never runs on GPU). So `detect` alone pulls torch -- no need to add `gpu` for
# detection. identify() guards the import and skips the signal when absent.
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",
@@ -40,7 +68,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
+1 -1
View File
@@ -1,3 +1,3 @@
"""Remove-AI-Watermarks: Unified tool for removing visible and invisible AI watermarks."""
__version__ = "0.5.2"
__version__ = "0.6.11"
+207 -10
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, Literal
import click
from rich.console import Console
@@ -25,7 +25,7 @@ from remove_ai_watermarks import __version__
if TYPE_CHECKING:
import numpy as np
from remove_ai_watermarks.gemini_engine import DetectionResult
from remove_ai_watermarks.gemini_engine import DetectionResult, GeminiEngine
console = Console()
@@ -85,7 +85,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:
@@ -109,11 +111,12 @@ def _write_bgr_with_alpha(
forced to 0 inside that bbox (expanded by ``pad`` px) so the watermark area
becomes fully transparent in the saved file.
"""
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
@@ -127,7 +130,73 @@ def _write_bgr_with_alpha(
alpha_out[y0:y1, x0:x1] = 0
bgra = np.dstack([bgr, alpha_out])
cv2.imwrite(str(path), bgra)
image_io.imwrite(path, bgra)
def _run_doubao_if_selected(
ctx: click.Context,
image: np.ndarray,
alpha: np.ndarray | None,
output: Path,
mark: str,
gemini_engine: GeminiEngine,
detect: bool,
detect_threshold: float,
inpaint_method: str,
strip_metadata: bool,
) -> bool:
"""Run the Doubao text-strip removal path when it is the selected mark.
Returns True when this path handled the image (caller should stop). In
``auto`` mode the Doubao detector competes with the Gemini detector and wins
only when it is both positive and at least as confident.
"""
from remove_ai_watermarks.doubao_engine import DoubaoEngine
doubao = DoubaoEngine()
d_det = doubao.detect(image)
if mark == "auto":
g_det = gemini_engine.detect_watermark(image)
use_doubao = d_det.detected and d_det.confidence >= g_det.confidence
console.print(
f" [dim]Mark auto:[/] gemini={g_det.confidence:.2f} doubao={d_det.confidence:.2f} "
f"-> {'doubao' if use_doubao else 'gemini'}"
)
else:
use_doubao = mark == "doubao"
if not use_doubao:
return False
if detect and not d_det.detected and d_det.confidence < detect_threshold:
console.print(
f" [yellow]⚠[/] Doubao mark not detected [dim](coverage {d_det.coverage:.1%}). "
f"Use --no-detect to force.[/]"
)
raise SystemExit(0)
method: Literal["telea", "ns"] = "ns" if inpaint_method == "ns" else "telea"
t0 = time.monotonic()
with console.status("[cyan]Removing Doubao watermark…[/]"):
result = doubao.remove_watermark(image, inpaint_method=method)
elapsed = time.monotonic() - t0
output.parent.mkdir(parents=True, exist_ok=True)
_write_bgr_with_alpha(output, result, alpha, clear_region=d_det.region)
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]✓[/] Doubao mark removed → {output} [dim]({size_kb:.0f} KB, {elapsed:.2f}s)[/]")
return True
# ── Main group ───────────────────────────────────────────────────────
@@ -167,6 +236,12 @@ 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", "gemini", "doubao"]),
default="auto",
help="Which visible mark to target. auto picks the stronger of the two detectors.",
)
@click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.")
@click.pass_context
def cmd_visible(
@@ -178,11 +253,14 @@ def cmd_visible(
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 visible AI watermark from an image.
Uses reverse alpha blending — fast, deterministic, offline.
Targets the Gemini sparkle logo (reverse alpha blending) or the Doubao
"豆包AI生成" text strip (locate -> mask -> inpaint). Fast, deterministic,
offline. ``--mark auto`` picks whichever detector fires stronger.
"""
from remove_ai_watermarks.gemini_engine import GeminiEngine
@@ -203,6 +281,12 @@ def cmd_visible(
h, w = image.shape[:2]
console.print(f" [dim]Input:[/] {source.name} ({w}x{h})")
# Resolve which visible mark to target, then run the Doubao path if chosen.
if _run_doubao_if_selected(
ctx, image, alpha, output, mark, engine, detect, detect_threshold, inpaint_method, strip_metadata
):
return
# Detection (we always detect softly, to find dynamic region for inpainting)
with console.status("[cyan]Detecting watermark…[/]"):
det = engine.detect_watermark(image)
@@ -256,6 +340,98 @@ 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: str,
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)
clear = boxes[0] if len(boxes) == 1 else None
_write_bgr_with_alpha(output, result, alpha, clear_region=clear)
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 ─────────────────────────────────────
@@ -284,6 +460,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 +479,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 +491,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 +526,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
@@ -442,6 +626,11 @@ 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.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")
@@ -490,6 +679,12 @@ 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,
@@ -506,6 +701,7 @@ def cmd_all(
hf_token: str | None,
humanize: float,
max_resolution: int,
no_protect_text: bool,
) -> None:
"""Remove ALL watermarks: visible + invisible + metadata.
@@ -571,7 +767,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 +793,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")
+247
View File
@@ -0,0 +1,247 @@
"""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. This is the
explicit AIGC label mandated by China's TC260 standard, rendered as a
near-white / light-gray, low-saturation text overlay.
Unlike the Gemini sparkle (a fixed square logo removed by reverse alpha
blending against a captured alpha map), the Doubao mark is a text strip whose
exact alpha map we do not yet have. This engine therefore removes it by:
locate -> mask -> inpaint
1. Locate: the mark scales with image WIDTH and sits in the bottom-right at a
fixed margin, so we anchor a generous box there (geometry only -- no bundled
template). Constants below are derived from measured Doubao output.
2. Mask: within the box, extract the light, low-saturation glyph pixels with a
polarity-aware rule (the mark is brighter than dark backgrounds and a
distinct off-white gray against light backgrounds).
3. Inpaint: cv2 inpainting (TELEA / NS) reconstructs the covered pixels.
This is fast, offline, deterministic, and needs no GPU. A future upgrade path
is per-pixel reverse alpha blending once a Doubao alpha map is captured on a
controlled black background (see data/doubao_capture/), which would recover the
true pixels instead of hallucinating them -- the same approach as the Gemini
engine.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
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: a genuine label fills a meaningful fraction of the box. Measured
# coverage is >=0.20 on real Doubao outputs; random/textured corners stay <=0.06
# on large images but can spike to ~0.15 on tiny ones (small box -> high variance),
# so the threshold sits above that spike and below the real-mark floor.
DETECT_MIN_COVERAGE = 0.16
# Safety: a text strip fills a modest slice of the (generous) box. When the box
# is over a dense-text / document background the mask explodes and cv2 inpainting
# would smear the real content. Above this coverage we refuse to inpaint and
# leave the image untouched -- that hard case needs the neural path, not a guess.
MAX_INPAINT_COVERAGE = 0.50
@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
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) -> 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, loc: DoubaoLocation) -> NDArray:
"""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) -> DoubaoDetection:
"""Detect the visible Doubao mark by glyph coverage in the corner box.
Heuristic: a genuine label fills a meaningful fraction of the box with
text-like glyph pixels. Coverage maps to a confidence score.
"""
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
# Map coverage to a 0-1 confidence: ~0.06 (noise floor) -> 0, ~0.26 -> 1.
det.confidence = float(max(0.0, min(1.0, (coverage - 0.06) / 0.20)))
det.detected = coverage >= DETECT_MIN_COVERAGE
logger.debug("Doubao detect: coverage=%.3f conf=%.3f", coverage, det.confidence)
return det
# ── Remove ────────────────────────────────────────────────────────
def remove_watermark(
self,
image: NDArray,
*,
inpaint_method: Literal["telea", "ns"] = "telea",
inpaint_radius: int = 6,
dilate: int = 3,
) -> NDArray:
"""Remove the visible Doubao watermark by inpainting the glyph mask.
Returns an unmodified copy when no glyph pixels are found (so we never
smear a clean corner). ``dilate`` grows the mask to cover anti-aliased
glyph edges before inpainting.
"""
if image is None or image.size == 0:
return image
loc = self.locate(image)
mask = self.extract_mask(image, loc)
if not mask.any():
logger.debug("Doubao remove: no glyph pixels found; returning copy")
return image.copy()
x, y, bw, bh = loc.bbox
coverage = float((mask[y : y + bh, x : x + bw] > 0).sum()) / float(max(1, bw * bh))
if coverage > MAX_INPAINT_COVERAGE:
logger.warning(
"Doubao remove: box coverage %.2f exceeds %.2f (dense-text/document "
"background); leaving image untouched to avoid smearing content",
coverage,
MAX_INPAINT_COVERAGE,
)
return image.copy()
if dilate > 0:
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * dilate + 1, 2 * dilate + 1))
mask = cv2.dilate(mask, k)
flag = cv2.INPAINT_TELEA if inpaint_method == "telea" else cv2.INPAINT_NS
return cv2.inpaint(image, mask, inpaint_radius, flag)
def load_image_bgr(path: str | Path) -> NDArray:
"""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
+3 -1
View File
@@ -556,7 +556,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)
+287 -13
View File
@@ -24,12 +24,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,
exif_generator,
get_ai_metadata,
huggingface_job,
iptc_ai_system,
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:
@@ -83,6 +90,11 @@ _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."
)
@dataclass
@@ -105,6 +117,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]:
@@ -122,17 +139,149 @@ 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,
# Truepic, Google Pixel); add Sony/Canon/Samsung/Bria as real samples are captured.
_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
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.
@@ -159,6 +308,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.
@@ -179,26 +339,51 @@ 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)
# ── C2PA Content Credentials ────────────────────────────────────
has_c2pa = bool(info) or b"c2pa" in head.lower() or C2PA_UUID 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), 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 device scan fixes that.
platform = (camera_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)
@@ -209,6 +394,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)
@@ -219,6 +414,35 @@ 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) ──
# 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_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)
if local_keys:
@@ -234,6 +458,31 @@ 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>"
# + a UUID Artist. Distinct from exif_generator (which matches generator
# tokens); verified stable across 3 generations. See CLAUDE.md.
if xai_signature(image_path):
signals.append(Signal("xai_signature", "EXIF Signature blob + UUID Artist", "high"))
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)"
# ── Open invisible watermark (SD / SDXL / FLUX, dwtDct) ──────────
# Public decoder, no key -- a definitive embedded signal on pristine files.
@@ -244,10 +493,30 @@ 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)
ai_from_metadata = bool((has_c2pa and (c2pa_is_ai or synthid)) or iptc or local_keys or invisible_wm or exif_gen)
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 iptc_ai
or aigc
or local_keys
or invisible_wm
or exif_gen
or xai_sig
)
# ── Visible Gemini sparkle (fallback for stripped-metadata case) ─
if check_visible and (conf := _visible_sparkle(image_path)) is not None and conf >= _SPARKLE_THRESHOLD:
@@ -257,17 +526,21 @@ 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
if ai_from_metadata:
is_ai: bool | None = True
confidence = "high"
elif visible_only:
elif visible_only or hf_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))
@@ -280,4 +553,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
+46 -29
View File
@@ -42,6 +42,24 @@ def is_available() -> bool:
return False
def _target_size(width: int, height: int, max_resolution: int) -> tuple[int, int] | None:
"""Compute the downscaled (width, height) for a long-side cap, or None for native.
Returns None when no pre-downscale is needed: ``max_resolution <= 0`` (native
resolution, the default that matches the raiw.cc backend -- see issue #10) or
the long side already fits the cap. Otherwise scales the long side down to
``max_resolution`` preserving aspect ratio (integer-truncated, matching the
PIL ``resize`` call site). Pure function so the native-vs-downscale decision
is unit-testable without loading the diffusion model.
"""
if max_resolution > 0 and max(width, height) > max_resolution:
ratio = max_resolution / max(width, height)
# Clamp the short side to >=1: extreme aspect ratios (e.g. 5000x3 capped
# at 1024) would otherwise truncate it to 0 and crash image.resize().
return (max(1, int(width * ratio)), max(1, int(height * ratio)))
return None
class InvisibleEngine:
"""Remove invisible AI watermarks using diffusion model regeneration.
@@ -107,6 +125,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.
@@ -120,6 +139,9 @@ class InvisibleEngine:
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
@@ -142,37 +164,26 @@ class InvisibleEngine:
image = Image.open(image_path)
image = ImageOps.exif_transpose(image)
orig_size = image.size # (width, height)
_tmp_path = None
if max_resolution > 0 and max(image.width, image.height) > max_resolution:
ratio = max_resolution / max(image.width, image.height)
new_size = (int(image.width * ratio), int(image.height * ratio))
# Optional long-side downscale; native resolution by default (issue #10).
target = _target_size(image.width, image.height, max_resolution)
if target is not None:
if self._progress_callback:
self._progress_callback(
f"Downscaling {image.width}x{image.height} "
f"to {new_size[0]}x{new_size[1]} "
f"to {target[0]}x{target[1]} "
f"(max-resolution cap {max_resolution}px)..."
)
image = image.resize(new_size, Image.Resampling.LANCZOS)
image = image.resize(target, Image.Resampling.LANCZOS)
# Save to a temp file instead of overwriting the original
_tmp_fd, _tmp_str = tempfile.mkstemp(suffix=image_path.suffix)
_tmp_path = Path(_tmp_str)
image.save(_tmp_path)
import os as _os
_os.close(_tmp_fd)
image_path = _tmp_path
else:
# We must save the transposed image back to a tmp file if it was rotated
# otherwise WatermarkRemover will reload it without EXIF rotation!
_tmp_fd, _tmp_str = tempfile.mkstemp(suffix=image_path.suffix)
_tmp_path = Path(_tmp_str)
image.save(_tmp_path)
import os as _os
_os.close(_tmp_fd)
image_path = _tmp_path
# Always persist to a temp file, even without downscaling: WatermarkRemover
# reloads by path, so the EXIF-transposed pixels must be saved or rotation
# is lost. Cleaned up in the finally block via _tmp_path.
_tmp_fd, _tmp_str = tempfile.mkstemp(suffix=image_path.suffix)
_tmp_path = Path(_tmp_str)
image.save(_tmp_path)
os.close(_tmp_fd)
image_path = _tmp_path
try:
# Optional: Face protection (Phase 1 - Extraction)
@@ -203,6 +214,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)
@@ -210,7 +222,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:
@@ -236,24 +250,27 @@ 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:
if _tmp_path is not None and _tmp_path.exists():
# _tmp_path is always set above (we persist the image unconditionally).
if _tmp_path.exists():
_tmp_path.unlink()
def remove_watermark_batch(
@@ -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
+380 -17
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import contextlib
import logging
import re
from typing import TYPE_CHECKING
if TYPE_CHECKING:
@@ -73,6 +74,61 @@ 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
# this; the same standard is mandatory for Jimeng, Kling, Qwen, Ernie, etc.,
# so the marker covers the whole China-AIGC-labeled ecosystem. Container-
# agnostic (XMP is text), so a raw-byte scan catches it in PNG/JPEG/etc.
AIGC_MARKERS: tuple[bytes, ...] = (
b"tc260.org.cn/ns/AIGC",
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",
@@ -97,6 +153,28 @@ def _is_ai_key(key: str) -> bool:
return any(kw in key_lower for kw in AI_KEYWORDS)
def scan_head(image_path: Path, size: int = 1024 * 1024) -> bytes:
"""First ``size`` bytes of the file, plus -- for ISOBMFF containers -- the
payloads of any provenance (``uuid`` / ``jumb``) boxes found beyond that
window by seeking past large boxes like ``mdat``.
This is the shared input for every C2PA / AIGC / IPTC byte scan. The
ISOBMFF extension catches a manifest placed AFTER the media data in a
streaming / non-faststart MP4, which a fixed first-MB read would miss. For
non-ISOBMFF inputs 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
return head
def has_ai_metadata(image_path: Path) -> bool:
"""Check if an image contains AI-generation metadata.
@@ -132,14 +210,122 @@ 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)
# and IPTC AI markers in XMP. First 512KB (plus late ISOBMFF provenance boxes).
data = scan_head(image_path, 512 * 1024)
if b"c2pa" in data.lower() or b"C2PA" in data:
return True
if C2PA_UUID in data:
return True
return any(marker in data for marker in IPTC_AI_MARKERS)
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 AI-labeling block, if present.
Two serializations are recognized:
- a PNG ``tEXt``/``iTXt`` chunk keyed ``AIGC`` carrying the raw JSON object
(as written by Doubao / ByteDance), read via PIL; and
- an XMP ``<TC260:AIGC>{...}</TC260:AIGC>`` block (HTML-entity encoded text),
found by a container-agnostic raw-byte scan (PNG/JPEG/WebP alike).
Returns the decoded JSON (e.g. ``{"Label": "1", "ContentProducer": ...}``)
or None. The PNG-chunk key ``AIGC`` is generic, so a JSON object there is
accepted only if it carries at least one known TC260 field (``_TC260_FIELDS``);
the namespaced XMP element is unambiguous, so any JSON object is accepted.
"""
import html
import json
from typing import cast
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:
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>{...}</TC260:AIGC> block (namespaced element, unambiguous).
data = scan_head(image_path)
match = re.search(rb"<TC260:AIGC>(.*?)</TC260:AIGC>", data, re.DOTALL)
if not match:
return None
return _parse(html.unescape(match.group(1).decode("utf-8", "replace")), require_tc260_field=False)
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
if isinstance(value, str) and value.strip():
return value.strip()
return 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:
@@ -172,8 +358,7 @@ 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)
data = scan_head(image_path)
has_c2pa = b"c2pa" in data.lower() or C2PA_UUID in data
# Matches both "trainedAlgorithmicMedia" and "compositeWithTrainedAlgorithmicMedia".
ai_source = b"trainedAlgorithmicMedia" in data or b"TrainedAlgorithmicMedia" in data
@@ -224,8 +409,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:
@@ -237,6 +421,97 @@ def exif_generator(image_path: Path) -> str | None:
return None
# xAI / Grok EXIF signature scheme. A 64+ char base64 blob after "Signature:"
# is far beyond any incidental description text, and the UUID Artist makes the
# pair xAI-specific -- both required keeps the false-positive rate near zero.
_XAI_SIGNATURE_RE = re.compile(r"Signature:\s*[A-Za-z0-9+/=]{64,}")
_UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE)
def _is_xai_signature_pair(description: str, artist: str) -> bool:
"""True if an EXIF (ImageDescription, Artist) pair is xAI/Grok's scheme."""
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:
"""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 ""
def xai_signature(image_path: Path) -> bool:
"""Detect xAI / Grok's EXIF provenance signature scheme.
Grok image downloads (Aurora model) carry no C2PA, XMP, SynthID, or IPTC --
their only provenance signal is a private EXIF pair: ``ImageDescription`` =
``"Signature: <base64>"`` together with ``Artist`` = the image UUID. Verified
stable across three independent generations (2026-05-26; see CLAUDE.md). The
signature is xAI's and is not locally verifiable (no public key); detection
keys on this distinctive, low-false-positive shape, not on the signature's
validity. It survives only on the *original* JPEG download -- the web-UI
image is a re-encoded WebP that drops EXIF.
"""
try:
import piexif
from PIL import Image
with Image.open(image_path) as img:
exif_bytes = img.info.get("exif")
if not exif_bytes:
return False
tags = piexif.load(exif_bytes).get("0th", {})
except Exception as exc: # unopenable format / malformed EXIF
logger.debug("xAI-signature EXIF read failed for %s: %s", image_path, exc)
return False
return _is_xai_signature_pair(
_exif_text(tags, piexif.ImageIFD.ImageDescription), _exif_text(tags, piexif.ImageIFD.Artist)
)
def _scrub_ai_exif(exif_dict: dict) -> list[str]:
"""Delete AI-provenance tags from a piexif dict's ``0th`` IFD, in place.
Removes (a) the xAI/Grok signature pair (``ImageDescription`` "Signature: ..."
+ UUID ``Artist``) and (b) any ``Software`` / ``Make`` / ``Artist`` /
``ImageDescription`` tag whose value carries an ``AI_GENERATOR_TOKENS`` token
(Ideogram's ``Make``, Firefly's ``Software``, etc.). Mirrors the detection in
``xai_signature`` / ``exif_generator`` so removal scrubs exactly what
``identify`` flags, while leaving genuine camera/editor EXIF intact. Returns
the names of the removed tags (for logging).
"""
import piexif
from remove_ai_watermarks.noai.constants import AI_GENERATOR_TOKENS
ifd = exif_dict.get("0th")
if not ifd:
return []
drop: dict[int, str] = {}
# (a) xAI / Grok: the Signature blob and the UUID Artist go together.
if _is_xai_signature_pair(
_exif_text(ifd, piexif.ImageIFD.ImageDescription), _exif_text(ifd, piexif.ImageIFD.Artist)
):
drop[piexif.ImageIFD.ImageDescription] = "ImageDescription"
drop[piexif.ImageIFD.Artist] = "Artist"
# (b) Known AI generator token in any of the text tags.
for tag, name in (
(piexif.ImageIFD.Software, "Software"),
(piexif.ImageIFD.Make, "Make"),
(piexif.ImageIFD.Artist, "Artist"),
(piexif.ImageIFD.ImageDescription, "ImageDescription"),
):
if any(token in _exif_text(ifd, tag).lower() for token in AI_GENERATOR_TOKENS):
drop[tag] = name
for tag in drop:
ifd.pop(tag, None)
return list(drop.values())
def get_ai_metadata(image_path: Path) -> dict[str, str]:
"""Extract AI-related metadata from an image.
@@ -248,7 +523,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] = {}
@@ -278,17 +553,81 @@ 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):
producer = aigc.get("ContentProducer", "")
result["aigc_label"] = f"China AIGC label (TC260){f'; producer {producer}' if producer else ''}"
# 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})")
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,
@@ -314,19 +653,39 @@ 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()
@@ -365,6 +724,10 @@ def remove_ai_metadata(
save_kwargs["pnginfo"] = pnginfo
if exif_data and save_kwargs["format"] == "JPEG":
# Scrub AI-provenance EXIF tags (xAI/Grok signature, generator tokens)
# while keeping genuine camera/editor EXIF; PNG output drops EXIF entirely.
if removed := _scrub_ai_exif(exif_data):
logger.info("Scrubbed AI EXIF tag(s): %s", ", ".join(removed))
with contextlib.suppress(Exception):
save_kwargs["exif"] = piexif.dump(exif_data)
+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:
"""
@@ -122,6 +122,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
@@ -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
@@ -229,6 +229,13 @@ def get_device() -> str:
# 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.
@@ -271,6 +278,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 +387,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 +398,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.
@@ -437,7 +450,21 @@ class WatermarkRemover:
guidance_scale,
generator,
)
elif protect_text and self._can_protect_text():
cleaned_image = self._run_differential(
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 +547,99 @@ 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"}
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()
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(
+179
View File
@@ -0,0 +1,179 @@
"""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.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, 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:
"""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,
mask: NDArray,
*,
method: Literal["telea", "ns"] = "telea",
radius: int = 6,
) -> NDArray:
"""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)."""
try:
import onnxruntime # noqa: F401
return True
except ImportError:
return False
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, mask: NDArray) -> NDArray:
"""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,
*,
boxes: list[tuple[int, int, int, int]] | None = None,
mask: NDArray | None = None,
backend: Backend = "cv2",
dilate: int = 3,
cv2_method: Literal["telea", "ns"] = "telea",
cv2_radius: int = 6,
) -> NDArray:
"""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)
+167
View File
@@ -0,0 +1,167 @@
"""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: long side scaled to this, rounded to a multiple of 32.
_DET_INPUT_LONG_SIDE = 736
# 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 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
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]
scale = _DET_INPUT_LONG_SIDE / max(height, width)
in_w = max((round(width * scale) // 32) * 32, 32)
in_h = max((round(height * scale) // 32) * 32, 32)
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,72 @@
"""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
def detect_trustmark(image_path: Path) -> str | None:
"""Return a TrustMark scheme note if a TrustMark watermark is decoded, else None.
Returns e.g. ``"Adobe TrustMark (variant P, schema 0)"`` when the decoder
reports the watermark present, or None if it is absent, the optional
``trustmark`` package is not installed, or the image cannot be read/decoded.
"""
if not is_available():
return None
try:
from PIL import Image
with Image.open(image_path) as img:
cover = img.convert("RGB")
_wm_secret, wm_present, wm_schema = _decoder().decode(cover)
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})" if wm_present else None
+17 -4
View File
@@ -2,10 +2,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
from pathlib import Path
import cv2
import numpy as np
@@ -13,6 +10,22 @@ import pytest
from PIL import Image
from PIL.PngImagePlugin import PngInfo
CORPUS_NEG_DIR = Path(__file__).resolve().parent.parent / "data" / "synthid_corpus" / "images" / "neg"
@pytest.fixture
def clean_photo() -> Path:
"""A verified-negative real photo from the corpus neg/ set.
Used by the "non-AI image" assertions (no SynthID, verdict unknown). These
are real photos with no AI provenance, the ground truth for "must not false-
positive". Skips if the corpus is not checked out.
"""
files = sorted(CORPUS_NEG_DIR.glob("*")) if CORPUS_NEG_DIR.exists() else []
if not files:
pytest.skip("no corpus neg/ images present")
return files[0]
@pytest.fixture
def tmp_image_path(tmp_path: Path) -> Path:
+25 -3
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"
@@ -505,3 +509,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
+98
View File
@@ -0,0 +1,98 @@
"""Tests for the Doubao visible-watermark engine."""
from __future__ import annotations
from pathlib import Path
import cv2
import numpy as np
import pytest
from remove_ai_watermarks.doubao_engine import DoubaoEngine, load_image_bgr
SAMPLE = Path(__file__).resolve().parents[1] / "data" / "samples" / "doubao-1.png"
# ── Locate ──────────────────────────────────────────────────────────
class TestLocate:
def test_box_anchored_bottom_right(self):
eng = DoubaoEngine()
img = np.zeros((2048, 2048, 3), np.uint8)
loc = eng.locate(img)
# right and bottom edges sit close to the image corner (within margins)
assert 2048 - (loc.x + loc.w) < int(2048 * 0.03)
assert 2048 - (loc.y + loc.h) < int(2048 * 0.03)
assert loc.is_fallback # geometry anchor, no bundled template yet
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))
# width-relative geometry: 2x wider image -> ~2x wider box
assert large.w == pytest.approx(small.w * 2, rel=0.1)
# ── Detect + remove on the real sample ──────────────────────────────
@pytest.mark.skipif(not SAMPLE.exists(), reason="sample image not present")
class TestRealSample:
def test_detects_watermark(self):
eng = DoubaoEngine()
det = eng.detect(load_image_bgr(SAMPLE))
assert det.detected
assert det.confidence > 0.0
assert det.coverage > 0.04
def test_remove_reduces_glyph_coverage(self):
eng = DoubaoEngine()
img = load_image_bgr(SAMPLE)
before = eng.detect(img).coverage
out = eng.remove_watermark(img)
after = eng.detect(out).coverage
# the inpaint should clear most glyph pixels from the corner box
assert after < before * 0.5
def test_pixels_outside_box_untouched(self):
eng = DoubaoEngine()
img = load_image_bgr(SAMPLE)
out = eng.remove_watermark(img)
# top-left quadrant is far from the bottom-right mark: must be identical
h, w = img.shape[:2]
assert np.array_equal(img[: h // 2, : w // 2], out[: h // 2, : w // 2])
# ── Negative + safety guard ─────────────────────────────────────────
class TestNegativeAndGuard:
def test_clean_image_not_detected(self):
eng = DoubaoEngine()
# smooth gradient, no watermark
ramp = np.tile(np.linspace(0, 255, 1024, dtype=np.uint8), (1024, 1))
img = cv2.cvtColor(ramp, cv2.COLOR_GRAY2BGR)
det = eng.detect(img)
assert not det.detected
def test_clean_image_returned_unchanged(self):
eng = DoubaoEngine()
ramp = np.tile(np.linspace(0, 255, 1024, dtype=np.uint8), (1024, 1))
img = cv2.cvtColor(ramp, cv2.COLOR_GRAY2BGR)
out = eng.remove_watermark(img)
assert np.array_equal(img, out)
def test_document_background_guard(self):
"""A dense high-frequency corner (document-like) trips the coverage
guard, so the image is left untouched rather than smeared."""
eng = DoubaoEngine()
rng = np.random.default_rng(0)
img = np.full((1024, 1024, 3), 255, np.uint8)
# fill the bottom-right box area with random grayish text-like noise
loc = eng.locate(img)
x, y, bw, bh = loc.bbox
noise = rng.integers(150, 246, size=(bh, bw), dtype=np.uint8)
img[y : y + bh, x : x + bw] = noise[:, :, None]
out = eng.remove_watermark(img)
assert np.array_equal(img, out)
+306 -4
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,
)
@@ -156,15 +158,15 @@ class TestIdentifyRealSamples:
assert r.is_ai_generated is True
assert any("IPTC" in w for w in r.watermarks)
def test_clean_photo_is_unknown_not_clean(self):
r = identify(SAMPLES_DIR / "not-ai-1.jpeg", check_visible=False)
def test_clean_photo_is_unknown_not_clean(self, clean_photo: Path):
r = identify(clean_photo, check_visible=False)
assert r.is_ai_generated is None # never asserted False
assert r.platform is None
assert r.confidence == "none"
assert r.watermarks == []
def test_strip_caveat_always_present(self):
r = identify(SAMPLES_DIR / "not-ai-1.jpeg", check_visible=False)
def test_strip_caveat_always_present(self, clean_photo: Path):
r = identify(clean_photo, check_visible=False)
assert any("not proof" in c for c in r.caveats)
def test_returns_report_dataclass(self):
@@ -199,6 +201,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) ──────────────────────
@@ -284,6 +358,104 @@ class TestIdentifyExifGenerator:
assert any("generator tag" in w for w in r.watermarks)
class TestIdentifyXaiSignature:
"""xAI / Grok's EXIF Signature + UUID-Artist drives an xAI verdict."""
def test_grok_signature_attributed(self, tmp_path: Path):
import piexif
from PIL import Image
exif = piexif.dump(
{
"0th": {
piexif.ImageIFD.ImageDescription: b"Signature: " + b"A" * 120,
piexif.ImageIFD.Artist: b"12345678-1234-1234-1234-123456789abc",
},
"Exif": {},
"GPS": {},
"1st": {},
}
)
path = tmp_path / "grok.jpg"
Image.new("RGB", (64, 64), (70, 80, 90)).save(path, exif=exif)
r = identify(path, check_visible=False)
assert r.is_ai_generated is True
assert r.platform is not None
assert "xAI" in r.platform
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
@@ -317,3 +489,133 @@ class TestIdentifyInvisibleWatermark:
def test_check_invisible_false_skips(self, tmp_path: Path):
r = identify(self._sdxl_watermarked(tmp_path), check_visible=False, check_invisible=False)
assert not any(s.name == "invisible_watermark" for s in r.signals)
class TestIdentifyAIGC:
"""China TC260 AIGC label is detected and attributed (e.g. Doubao)."""
def _aigc_png(self, tmp_path: Path) -> Path:
from PIL import Image
p = tmp_path / "doubao.png"
Image.new("RGB", (32, 32)).save(p)
xmp = (
'<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF '
'xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">'
'<rdf:Description xmlns:TC260="http://www.tc260.org.cn/ns/AIGC/1.0/">'
"<TC260:AIGC>{&quot;Label&quot;:&quot;1&quot;,&quot;ContentProducer&quot;:&quot;BYTEDANCE001&quot;}"
"</TC260:AIGC></rdf:Description></rdf:RDF></x:xmpmeta>"
)
with open(p, "ab") as f:
f.write(xmp.encode())
return p
def test_aigc_detected(self, tmp_path: Path):
r = identify(self._aigc_png(tmp_path), check_visible=False)
assert r.is_ai_generated is True
assert r.platform is not None
assert "AIGC" in r.platform or "TC260" in r.platform
assert any("AIGC" in w for w in r.watermarks)
def test_aigc_signal_carries_producer(self, tmp_path: Path):
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
+208
View File
@@ -0,0 +1,208 @@
"""Unit tests for the MPS->CPU fallback orchestration (no GPU/model required).
``img2img_runner`` has no torch import at module top -- the pipeline is
injected as a plain callable -- so the fallback control flow is fully
mockable. This guards the exact behavior hit in production on Apple Silicon:
a native-resolution SDXL run that OOMs on MPS must transparently retry on CPU,
while any non-MPS error must propagate unchanged.
"""
from __future__ import annotations
from unittest.mock import Mock
import pytest
from remove_ai_watermarks.noai import img2img_runner
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)"
def _result(image: object) -> Mock:
"""A stand-in for a diffusers pipeline output object (has .images)."""
out = Mock()
out.images = [image]
return out
class TestMpsFallback:
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_img2img", inner)
load_pipeline = Mock(return_value="gpu_pipe")
reload_on_cpu = Mock(return_value="cpu_pipe")
img, device = run_img2img_with_mps_fallback(
load_pipeline, object(), 0.05, 50, 7.5, "gen", "mps", 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 must use the reloaded CPU pipeline, device "cpu", and drop the
# MPS generator (generator=None) so CPU runs deterministically.
retry_args = inner.call_args_list[1].args
assert retry_args[0] == "cpu_pipe"
assert retry_args[5] is None # generator
assert retry_args[6] == "cpu" # device
def test_happy_path_returns_original_device_without_reload(self, monkeypatch: pytest.MonkeyPatch):
sentinel = object()
monkeypatch.setattr(img2img_runner, "run_img2img", Mock(return_value=sentinel))
reload_on_cpu = Mock()
img, device = run_img2img_with_mps_fallback(
Mock(return_value="gpu_pipe"),
object(),
0.05,
50,
7.5,
"gen",
"mps",
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_img2img", Mock(side_effect=RuntimeError("CUDA out of memory")))
reload_on_cpu = Mock()
with pytest.raises(RuntimeError, match="CUDA"):
run_img2img_with_mps_fallback(
Mock(return_value="gpu_pipe"),
object(),
0.05,
50,
7.5,
"gen",
"mps",
lambda _m: None,
reload_on_cpu=reload_on_cpu,
)
reload_on_cpu.assert_not_called()
def test_mps_error_on_non_mps_device_propagates(self, monkeypatch: pytest.MonkeyPatch):
# An "mps"-worded error while running on cpu must NOT trigger the reload.
monkeypatch.setattr(img2img_runner, "run_img2img", Mock(side_effect=RuntimeError(_MPS_OOM)))
reload_on_cpu = Mock()
with pytest.raises(RuntimeError, match="MPS backend"):
run_img2img_with_mps_fallback(
Mock(return_value="cpu_pipe"),
object(),
0.05,
50,
7.5,
None,
"cpu",
lambda _m: None,
reload_on_cpu=reload_on_cpu,
)
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()
pipeline = Mock(return_value=_result(sentinel))
out = run_img2img(pipeline, object(), 0.05, 50, 7.5, None, "cpu", lambda _m: None)
assert out is sentinel
def test_typeerror_on_callback_retries_without_callback(self):
# Older diffusers reject the progress callback kwarg with TypeError;
# run_img2img must retry once without it rather than fail.
sentinel = object()
pipeline = Mock(side_effect=[TypeError("unexpected keyword 'callback'"), _result(sentinel)])
out = run_img2img(pipeline, object(), 0.05, 50, 7.5, None, "cpu", lambda _m: None)
assert out is sentinel
assert pipeline.call_count == 2
# First attempt passes the progress callback; the retry omits it.
assert "callback" in pipeline.call_args_list[0].kwargs
assert "callback" not in pipeline.call_args_list[1].kwargs
+43 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from remove_ai_watermarks.invisible_engine import InvisibleEngine, is_available
from remove_ai_watermarks.invisible_engine import InvisibleEngine, _target_size, is_available
class TestIsAvailable:
@@ -26,3 +26,45 @@ class TestInvisibleEngineInit:
def test_ctrlregen_model_id(self):
assert InvisibleEngine.CTRLREGEN_MODEL_ID == "yepengliu/ctrlregen"
class TestTargetSize:
"""Regression guard for the native-resolution decision (issues #10 / #15).
max_resolution=0 must NOT downscale -- the forced downscale->upscale
round-trip was the quality loss in #10, and downscaling at all let SynthID
survive in #15 (the native SDXL pass at strength ~0.05 is what defeats it).
"""
def test_native_default_no_downscale(self):
# The default (0) means native resolution: no resize, regardless of size.
assert _target_size(4096, 4096, 0) is None
assert _target_size(123, 456, 0) is None
def test_negative_cap_treated_as_native(self):
assert _target_size(4096, 4096, -1) is None
def test_cap_below_long_side_downscales(self):
# 2000x1000, cap 1024 -> long side scaled to 1024, aspect preserved.
assert _target_size(2000, 1000, 1024) == (1024, 512)
def test_cap_uses_long_side_for_portrait(self):
# Portrait: height is the long side, so it drives the ratio.
assert _target_size(1000, 2000, 1024) == (512, 1024)
def test_cap_at_or_above_long_side_no_downscale(self):
# Already within the cap (and exactly equal) -> no resize.
assert _target_size(800, 600, 1024) is None
assert _target_size(1024, 768, 1024) is None
def test_integer_truncation_matches_pil_call_site(self):
# 1254x1254 (the gpt-image sample) capped at 1000: int(1254*1000/1254)=1000.
assert _target_size(1254, 1254, 1000) == (1000, 1000)
# Non-divisible ratio truncates toward zero like int() at the call site.
assert _target_size(1000, 333, 500) == (500, 166)
def test_extreme_aspect_ratio_clamps_short_side_to_one(self):
# 5000x3 capped at 1024: int(3 * 1024/5000) = 0 would crash resize();
# the short side must clamp to 1, never 0.
assert _target_size(5000, 3, 1024) == (1024, 1)
assert _target_size(3, 5000, 1024) == (1, 1024)
+555 -2
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
import piexif
@@ -14,8 +16,10 @@ from remove_ai_watermarks.metadata import (
exif_generator,
get_ai_metadata,
has_ai_metadata,
iptc_ai_system,
remove_ai_metadata,
synthid_source,
xai_signature,
)
# Real, committed C2PA sample images used to ground the SynthID-source tests.
@@ -224,8 +228,8 @@ class TestSynthIDSource:
assert synthid_source(SAMPLES_DIR / "firefly-1.png") is None
assert "synthid_watermark" not in get_ai_metadata(SAMPLES_DIR / "firefly-1.png")
def test_non_ai_image_is_not_synthid_source(self):
assert synthid_source(SAMPLES_DIR / "not-ai-1.jpeg") is None
def test_non_ai_image_is_not_synthid_source(self, clean_photo: Path):
assert synthid_source(clean_photo) is None
class TestSynthIDSourceNonPng:
@@ -377,6 +381,24 @@ class TestExifGenerator:
Image.new("RGB", (64, 64)).save(path, exif=exif)
assert exif_generator(path) is None
def test_artist_tag_ai_tool_detected(self, tmp_path: Path):
# exif_generator also reads the EXIF Artist field for an AI token.
exif = piexif.dump({"0th": {piexif.ImageIFD.Artist: b"Midjourney"}, "Exif": {}, "GPS": {}, "1st": {}})
path = tmp_path / "artist.jpg"
Image.new("RGB", (64, 64)).save(path, exif=exif)
assert exif_generator(path) == "Midjourney"
def test_imagedescription_tag_ai_tool_detected(self, tmp_path: Path):
# ...and the EXIF ImageDescription field.
exif = piexif.dump(
{"0th": {piexif.ImageIFD.ImageDescription: b"Made with Stable Diffusion"}, "Exif": {}, "GPS": {}, "1st": {}}
)
path = tmp_path / "desc.jpg"
Image.new("RGB", (64, 64)).save(path, exif=exif)
result = exif_generator(path)
assert result is not None
assert "Stable Diffusion" in result
def test_xmp_creatortool_scan_covers_unopenable(self, tmp_path: Path):
# PIL can't open this fake HEIF; the raw XMP CreatorTool scan still works.
path = tmp_path / "fake.heic"
@@ -390,3 +412,534 @@ class TestExifGenerator:
def test_clean_image_is_none(self, tmp_clean_png: Path):
assert exif_generator(tmp_clean_png) is None
_FAKE_SIG = "A" * 120 # 64+ base64 chars; real Grok payloads are 300-1004
_FAKE_UUID = "12345678-1234-1234-1234-123456789abc"
def _grok_jpeg(tmp_path: Path, *, signature: str = _FAKE_SIG, artist: str = _FAKE_UUID) -> Path:
"""Write a synthetic Grok-style JPEG: EXIF ImageDescription "Signature: ..."
+ a UUID Artist. Synthetic on purpose -- never commit a real Grok image
(its Artist UUID + signature are user/session data; this is a public repo)."""
exif = piexif.dump(
{
"0th": {
piexif.ImageIFD.ImageDescription: f"Signature: {signature}".encode("latin1"),
piexif.ImageIFD.Artist: artist.encode("latin1"),
},
"Exif": {},
"GPS": {},
"1st": {},
}
)
path = tmp_path / "grok.jpg"
Image.new("RGB", (64, 64), (70, 80, 90)).save(path, exif=exif)
return path
class TestXaiSignature:
"""xAI / Grok's EXIF Signature + UUID-Artist provenance scheme."""
def test_signature_plus_uuid_detected(self, tmp_path: Path):
assert xai_signature(_grok_jpeg(tmp_path)) is True
def test_real_grok_sample_detected(self):
# Real committed Grok download (data/samples/grok-1.jpg); the EXIF
# Signature + UUID-Artist pair is the only AI signal it carries.
assert xai_signature(SAMPLES_DIR / "grok-1.jpg") is True
def test_signature_without_uuid_artist_not_flagged(self, tmp_path: Path):
# A "Signature:" blob but a non-UUID Artist is not the Grok pair.
assert xai_signature(_grok_jpeg(tmp_path, artist="John Doe")) is False
def test_bare_uuid_artist_not_flagged(self, tmp_path: Path):
# A UUID Artist alone (no Signature blob) must not false-positive.
exif = piexif.dump({"0th": {piexif.ImageIFD.Artist: _FAKE_UUID.encode()}, "Exif": {}, "GPS": {}, "1st": {}})
path = tmp_path / "uuid_only.jpg"
Image.new("RGB", (64, 64)).save(path, exif=exif)
assert xai_signature(path) is False
def test_short_signature_text_not_flagged(self, tmp_path: Path):
# Incidental short "Signature: ..." text is below the 64-char base64 bar.
assert xai_signature(_grok_jpeg(tmp_path, signature="ok")) is False
def test_clean_image_is_false(self, tmp_clean_png: Path):
assert xai_signature(tmp_clean_png) is False
def test_surfaced_in_get_ai_metadata(self, tmp_path: Path):
assert "xai_signature" in get_ai_metadata(_grok_jpeg(tmp_path))
def test_has_ai_metadata_true(self, tmp_path: Path):
assert has_ai_metadata(_grok_jpeg(tmp_path)) is True
class TestRemoveAiExif:
"""remove_ai_metadata scrubs AI-provenance EXIF tags but keeps genuine EXIF."""
def test_grok_signature_stripped_on_jpeg_output(self, tmp_path: Path):
src = _grok_jpeg(tmp_path)
assert xai_signature(src) is True
out = tmp_path / "clean.jpg"
remove_ai_metadata(src, out)
assert xai_signature(out) is False
assert has_ai_metadata(out) is False
def test_generator_make_token_stripped(self, tmp_path: Path):
# Ideogram's EXIF Make="Ideogram AI" must be scrubbed on removal.
exif = piexif.dump({"0th": {piexif.ImageIFD.Make: b"Ideogram AI"}, "Exif": {}, "GPS": {}, "1st": {}})
src = tmp_path / "ideogram.jpg"
Image.new("RGB", (64, 64)).save(src, exif=exif)
out = tmp_path / "clean.jpg"
remove_ai_metadata(src, out)
assert exif_generator(out) is None
def test_real_camera_exif_preserved(self, tmp_path: Path):
# A real-camera Make ("Apple") carries no AI token and must survive.
exif = piexif.dump(
{
"0th": {piexif.ImageIFD.Make: b"Apple", piexif.ImageIFD.Model: b"iPhone 15"},
"Exif": {},
"GPS": {},
"1st": {},
}
)
src = tmp_path / "photo.jpg"
Image.new("RGB", (64, 64)).save(src, exif=exif)
out = tmp_path / "out.jpg"
remove_ai_metadata(src, out)
kept = piexif.load(Image.open(out).info["exif"])["0th"]
assert kept.get(piexif.ImageIFD.Make) == b"Apple"
class TestAIGCLabel:
"""China TC260 AIGC labeling (Doubao and other China-served generators)."""
def _aigc_png(self, tmp_path: Path, label: str = "1", producer: str = "TESTPRODUCER001") -> Path:
from remove_ai_watermarks.metadata import aigc_label # noqa: F401 (import-time guard)
p = tmp_path / "doubao.png"
Image.new("RGB", (32, 32)).save(p)
# XMP is HTML-entity encoded in real files; aigc_label must unescape it.
xmp = (
'<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF '
'xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">'
'<rdf:Description rdf:about="" '
'xmlns:TC260="http://www.tc260.org.cn/ns/AIGC/1.0/"><TC260:AIGC>'
f"{{&quot;Label&quot;:&quot;{label}&quot;,&quot;ContentProducer&quot;:&quot;{producer}&quot;}}"
"</TC260:AIGC></rdf:Description></rdf:RDF></x:xmpmeta>"
)
with open(p, "ab") as f:
f.write(xmp.encode())
return p
def test_parses_label_and_producer(self, tmp_path: Path):
from remove_ai_watermarks.metadata import aigc_label
info = aigc_label(self._aigc_png(tmp_path))
assert info is not None
assert info["Label"] == "1"
assert info["ContentProducer"] == "TESTPRODUCER001"
def test_none_when_absent(self, tmp_clean_png):
from remove_ai_watermarks.metadata import aigc_label
assert aigc_label(tmp_clean_png) is None
def test_has_ai_metadata_detects_aigc(self, tmp_path: Path):
assert has_ai_metadata(self._aigc_png(tmp_path))
def test_get_ai_metadata_surfaces_aigc(self, tmp_path: Path):
meta = get_ai_metadata(self._aigc_png(tmp_path))
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)
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:
"""Real Doubao (ByteDance) sample carries the China TC260 AIGC XMP label."""
def test_doubao_aigc_label(self):
from remove_ai_watermarks.metadata import aigc_label
info = aigc_label(SAMPLES_DIR / "doubao-1.png")
assert info is not None
assert info["Label"] == "1"
assert info["ContentProducer"] # ByteDance producer code present
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
+60 -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:
@@ -259,6 +259,29 @@ class TestParseChunkGuards:
assert "OpenAI" in info["issuer"] # issuer byte-search still robust
class TestC2PADigitalSourceType:
"""The three IPTC digitalSourceType variants drive the AI verdict.
Only *trained* and *composite-with-trained* mean AI-generated (and so imply
a SynthID proxy for a SynthID vendor); plain ``algorithmicMedia`` is
procedural (not trained) and must NOT be flagged as AI.
"""
def test_plain_algorithmic_media_not_flagged_ai(self):
chunk = b"...name" + bytes([0x69]) + b"some-tool" + b" OpenAI algorithmicMedia"
info: dict = {}
_parse_c2pa_chunk(chunk, info)
assert info["source_type"] == "algorithmicMedia"
assert "synthid_watermark" not in info # procedural, not AI-generated
def test_composite_with_trained_is_ai_and_synthid(self):
chunk = b"...name" + bytes([0x69]) + b"some-tool" + b" OpenAI compositeWithTrainedAlgorithmicMedia"
info: dict = {}
_parse_c2pa_chunk(chunk, info)
assert "compositeWithTrainedAlgorithmicMedia" in info["source_type"]
assert "synthid_watermark" in info # AI-enhanced + OpenAI issuer
# ── ISOBMFF (AVIF / HEIF / JPEG-XL container stripping) ──────────────
FTYP = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avifmif1" # 24-byte ftyp box
@@ -294,3 +317,31 @@ class TestISOBMFF:
cleaned, stripped = strip_c2pa_boxes(FTYP + b"\x00\x00\x00\x04XXXX")
assert stripped == 0
assert cleaned.startswith(FTYP)
def test_size_zero_box_runs_to_eof(self):
# size32==0 means the box extends to EOF; a non-C2PA box round-trips.
box = struct.pack(">I", 0) + b"free" + b"\x00\x00\x00\x00"
cleaned, stripped = strip_c2pa_boxes(FTYP + box)
assert stripped == 0
assert cleaned == FTYP + box
def test_truncated_largesize_terminates_safely(self):
# size32==1 promises a 64-bit largesize, but the box ends after 8 bytes;
# iteration must stop rather than read the missing largesize past EOF.
cleaned, stripped = strip_c2pa_boxes(FTYP + b"\x00\x00\x00\x01uuid")
assert stripped == 0
assert cleaned == FTYP
class TestC2PAInvalidSignature:
"""A .png file that is not actually PNG-signed must read as clean, not crash."""
def test_has_c2pa_false_for_non_png_bytes(self, tmp_path: Path):
fake = tmp_path / "fake.png"
fake.write_bytes(b"\xff\xd8\xff\xe0 not a png at all, just garbage bytes")
assert has_c2pa_metadata(fake) is False
def test_extract_chunk_none_for_non_png_bytes(self, tmp_path: Path):
fake = tmp_path / "fake.png"
fake.write_bytes(b"\xff\xd8\xff\xe0 not a png at all, just garbage bytes")
assert extract_c2pa_chunk(fake) is None
+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")
+59
View File
@@ -0,0 +1,59 @@
"""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 build_change_map
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
+76
View File
@@ -0,0 +1,76 @@
"""Unit tests for the pure tiling helpers (no GPU/model required).
``tiling.py`` imports torch at module top, so skip cleanly when torch is
absent. The helpers themselves are pure numpy/PIL/math -- they decide how a
large image is split into overlapping tiles and blended back, so a regression
here would seam or crop the CtrlRegen output wrongly.
"""
from __future__ import annotations
import numpy as np
import pytest
pytest.importorskip("torch")
from PIL import Image
from remove_ai_watermarks.noai.ctrlregen.tiling import (
make_blend_weight,
resize_center_crop,
tile_positions,
)
class TestTilePositions:
def test_image_smaller_than_tile_single_position(self):
assert tile_positions(500, 512, 64) == [0]
def test_image_equal_to_tile_single_position(self):
assert tile_positions(512, 512, 64) == [0]
def test_first_is_zero_last_is_total_minus_tile(self):
# The tiles must fully cover the span: first starts at 0, last ends at
# the far edge (start == total - tile), or the image's edge is missed.
pos = tile_positions(2000, 512, 64)
assert pos[0] == 0
assert pos[-1] == 2000 - 512
def test_overlap_positions_are_monotonic_and_exact(self):
assert tile_positions(1000, 512, 64) == [0, 244, 488]
def test_zero_overlap_tiles_are_contiguous(self):
# 1024 wide, 512 tile, no overlap -> two tiles butting at 512.
assert tile_positions(1024, 512, 0) == [0, 512]
class TestMakeBlendWeight:
def test_zero_overlap_is_all_ones(self):
w = make_blend_weight(8, 8, 0)
assert w.shape == (8, 8)
assert w.dtype == np.float64
assert np.all(w == 1.0)
def test_overlap_ramps_corners_to_zero_center_to_one(self):
w = make_blend_weight(16, 16, 4)
assert w[0, 0] == 0.0 # cosine ramp starts at 0
assert w[8, 8] == 1.0 # center is unweighted
assert w.max() == 1.0
assert w.min() == 0.0
def test_weight_is_point_symmetric(self):
# Symmetric ramps on both edges -> mask equals its 180-degree rotation,
# so opposite tile seams blend identically.
w = make_blend_weight(16, 16, 4)
assert np.allclose(w, w[::-1, ::-1])
class TestResizeCenterCrop:
@pytest.mark.parametrize(("width", "height"), [(400, 800), (800, 400), (300, 300), (1000, 1001)])
def test_output_is_always_square_of_requested_size(self, width: int, height: int):
out = resize_center_crop(Image.new("RGB", (width, height)), 256)
assert out.size == (256, 256)
def test_default_size_is_512(self):
out = resize_center_crop(Image.new("RGB", (640, 480)))
assert out.size == (512, 512)
+36
View File
@@ -0,0 +1,36 @@
"""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.trustmark_detector import detect_trustmark, is_available
if TYPE_CHECKING:
from pathlib import Path
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
Generated
+1766 -866
View File
File diff suppressed because it is too large. Load diff