mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-07-20 22:50:52 +02:00
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>
This commit is contained in:
@@ -42,6 +42,7 @@ If this tool saves you time, consider [sponsoring its development](https://githu
|
||||
| **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 | — | — | ✅ TC260 `<TC260:AIGC>` XMP label (China's mandatory AI labeling) | Metadata strip |
|
||||
@@ -87,11 +88,11 @@ 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.)
|
||||
|
||||
### Stripping C2PA, EXIF, and "Made with AI" metadata
|
||||
|
||||
@@ -312,6 +313,8 @@ Watermarking and provenance for AI-generated content is now regulated in several
|
||||
| EU | AI Act, Article 50 | Transparency duties apply from **2 August 2026**. Legacy generative systems (placed on the market before that date) get a grandfathering period to **2 December 2026** for the Article 50(2) marking duty, under the Digital Omnibus (Commission proposal Nov 2025; co-legislator political agreement 7 May 2026). Article 50 guidelines and a marking Code of Practice are being finalised through 2026. | Removing mandated provenance markers with intent to deceive may be sanctioned under national implementations. |
|
||||
| US (federal) | COPIED Act | **Reintroduced April 2025; not enacted** (pending in the Senate). | If passed, would set NIST provenance standards and prohibit tampering with / removing provenance information. The tool itself is lawful; usage may not be. |
|
||||
| US (state) | CA AB 2655, TX SB 751, similar | TX SB 751 in force; **CA AB 2655 struck down** by a federal court (Aug 2025, Section 230 / First Amendment). | Content-specific (election deepfakes, sexual deepfakes). Not tool-specific. |
|
||||
| US (state) | CA AB 853 (amends the California AI Transparency Act) | Core provider duties operative **2 August 2026** (delayed from 1 January 2026); large platforms 1 January 2027; capture devices 1 January 2028. | Covered providers (1M+ monthly users) must embed a latent disclosure that is "permanent or extraordinarily difficult to remove" and offer a free detection tool. Removing that disclosure is what this tool does. |
|
||||
| South Korea | AI Framework Act (Basic Act on AI), Article 31 | In force since **January 2026** (one-year transition after promulgation). | Art. 31(3): AI output "difficult to distinguish from reality" must be labeled so users "clearly recognize" it; the draft Enforcement Decree accepts a machine-readable (invisible-watermark) label. Artistic/creative works get a presentation exception. |
|
||||
| China | Measures for Labeling AI-Generated Content (+ GB 45438-2025) | In force since **1 September 2025**. | Mandatory explicit (visible) + implicit (metadata) labels for AI content; tampering with or removing labels is prohibited. |
|
||||
| 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. |
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 356 KiB |
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.5.4"
|
||||
version = "0.5.5"
|
||||
description = "Remove visible and invisible AI watermarks from images (Gemini / Nano Banana, ChatGPT, Stable Diffusion)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Remove-AI-Watermarks: Unified tool for removing visible and invisible AI watermarks."""
|
||||
|
||||
__version__ = "0.5.4"
|
||||
__version__ = "0.5.5"
|
||||
|
||||
@@ -30,6 +30,7 @@ from remove_ai_watermarks.metadata import (
|
||||
aigc_label,
|
||||
exif_generator,
|
||||
get_ai_metadata,
|
||||
xai_signature,
|
||||
)
|
||||
from remove_ai_watermarks.noai.c2pa import extract_c2pa_info
|
||||
from remove_ai_watermarks.noai.constants import C2PA_AI_TOOLS, C2PA_ISSUERS
|
||||
@@ -246,6 +247,16 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
if platform is None:
|
||||
platform = f"{generator_tag} (EXIF/XMP generator tag)"
|
||||
|
||||
# ── 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)"
|
||||
|
||||
# ── Open invisible watermark (SD / SDXL / FLUX, dwtDct) ──────────
|
||||
# Public decoder, no key -- a definitive embedded signal on pristine files.
|
||||
if check_invisible and (scheme := _invisible_watermark(image_path)) is not None:
|
||||
@@ -258,8 +269,9 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
# ── Verdict so far (metadata + embedded watermark) ──────────────
|
||||
invisible_wm = any(s.name == "invisible_watermark" for s in signals)
|
||||
exif_gen = any(s.name == "exif_generator" for s in signals)
|
||||
xai_sig = any(s.name == "xai_signature" for s in signals)
|
||||
ai_from_metadata = bool(
|
||||
(has_c2pa and (c2pa_is_ai or synthid)) or iptc or aigc or local_keys or invisible_wm or exif_gen
|
||||
(has_c2pa and (c2pa_is_ai or synthid)) or iptc or aigc or local_keys or invisible_wm or exif_gen or xai_sig
|
||||
)
|
||||
|
||||
# ── Visible Gemini sparkle (fallback for stripped-metadata case) ─
|
||||
|
||||
@@ -152,7 +152,10 @@ def has_ai_metadata(image_path: Path) -> bool:
|
||||
return True
|
||||
if any(marker in data for marker in AIGC_MARKERS):
|
||||
return True
|
||||
return any(marker in data for marker in IPTC_AI_MARKERS)
|
||||
if any(marker in data for marker in IPTC_AI_MARKERS):
|
||||
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:
|
||||
@@ -274,6 +277,48 @@ def exif_generator(image_path: Path) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
import re
|
||||
|
||||
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
|
||||
|
||||
def _text(tag: int) -> str:
|
||||
value = tags.get(tag)
|
||||
return value.decode("latin1", "replace").strip() if isinstance(value, bytes) else ""
|
||||
|
||||
description = _text(piexif.ImageIFD.ImageDescription)
|
||||
artist = _text(piexif.ImageIFD.Artist)
|
||||
# A 64+ char base64 blob after "Signature:" is far beyond any incidental
|
||||
# description text, and the UUID Artist makes the pair xAI-specific.
|
||||
has_signature = re.match(r"Signature:\s*[A-Za-z0-9+/=]{64,}", description) is not None
|
||||
is_uuid = (
|
||||
re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", artist, re.IGNORECASE) is not None
|
||||
)
|
||||
return has_signature and is_uuid
|
||||
|
||||
|
||||
def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
"""Extract AI-related metadata from an image.
|
||||
|
||||
@@ -328,6 +373,10 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
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)")
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -284,6 +284,33 @@ 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)
|
||||
|
||||
|
||||
# ── Open invisible watermark (SD/SDXL/FLUX) integration ─────────────
|
||||
|
||||
from remove_ai_watermarks.invisible_watermark import is_available as _wm_available # noqa: E402
|
||||
|
||||
@@ -16,6 +16,7 @@ from remove_ai_watermarks.metadata import (
|
||||
has_ai_metadata,
|
||||
remove_ai_metadata,
|
||||
synthid_source,
|
||||
xai_signature,
|
||||
)
|
||||
|
||||
# Real, committed C2PA sample images used to ground the SynthID-source tests.
|
||||
@@ -410,6 +411,66 @@ class TestExifGenerator:
|
||||
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 TestAIGCLabel:
|
||||
"""China TC260 AIGC labeling (Doubao and other China-served generators)."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user