mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-07 06:28:36 +02:00
feat(identify): detect + strip NovelAI / Reve / Aphrodite generator stamps
Mined from the retained corpus 2026-06-22 (open-world EXIF/PNG-text/XMP scan, minus the registry): three AI image generators that stamp a plain generator name and no C2PA, so identify read them as no-signal -- and under the P0#5 no-signal skip would have skipped the scrub. - NovelAI (anime SD): PNG tEXt Software/Source/Title. exif_generator now reads PNG text chunks (via img.info), not only EXIF/XMP. - Reve (reve.com): EXIF Software / XMP CreatorTool. Token is the full "reve.com", not bare "reve" (would false-fire on "forever"/"reverie"). - Aphrodite AI: EXIF Make / Software. Detection/removal parity: NovelAI stamps an AI-shaped VALUE under a non-AI KEY (Title/Source), which _is_ai_key alone keeps. New _is_ai_value drops a text chunk by value-token match on removal, mirroring exif_generator -- else the cleaned file still read as NovelAI (verified on a real corpus file). Tests: TestExifGenerator gains NovelAI PNG-text, Reve, Reve-not-overmatched, Aphrodite, and a NovelAI detect/remove parity regression. Docs synced (module-internals, watermarking-landscape, CLAUDE.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
19f9ab0947
commit
abb7be7e9b
@@ -623,13 +623,14 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
if platform is None:
|
||||
platform = "Stable Diffusion / local pipeline (Automatic1111, ComfyUI, InvokeAI)"
|
||||
|
||||
# ── EXIF Software / XMP CreatorTool generator (cross-format) ─────
|
||||
# Catches a generator tag (incl. inside AVIF/HEIF/JXL) when there is no C2PA.
|
||||
# ── EXIF Software / XMP CreatorTool / PNG-text generator (cross-format) ─
|
||||
# Catches a generator tag (incl. inside AVIF/HEIF/JXL and PNG text chunks)
|
||||
# when there is no C2PA.
|
||||
if generator_tag := exif_generator(image_path):
|
||||
signals.append(Signal("exif_generator", f"EXIF/XMP generator: {generator_tag}", "high"))
|
||||
signals.append(Signal("exif_generator", f"Embedded generator tag: {generator_tag}", "high"))
|
||||
watermarks.append(f"Embedded generator tag: {generator_tag}")
|
||||
if platform is None:
|
||||
platform = f"{generator_tag} (EXIF/XMP generator tag)"
|
||||
platform = f"{generator_tag} (embedded generator tag)"
|
||||
if v := _vendor_of(generator_tag):
|
||||
ai_vendor_claims["exif_generator"] = v
|
||||
|
||||
|
||||
@@ -176,6 +176,19 @@ def _is_ai_key(key: str) -> bool:
|
||||
return any(kw in key_lower for kw in AI_KEYWORDS)
|
||||
|
||||
|
||||
def _is_ai_value(value: str) -> bool:
|
||||
"""True if a metadata VALUE carries a known AI-generator token.
|
||||
|
||||
Mirrors :func:`exif_generator`'s value match so removal stays in parity with
|
||||
detection: NovelAI stamps a generic ``Title``/``Source`` text chunk (an
|
||||
AI-shaped value under a non-AI key) that ``_is_ai_key`` alone would keep.
|
||||
"""
|
||||
from remove_ai_watermarks.noai.constants import AI_GENERATOR_TOKENS
|
||||
|
||||
value_lower = value.lower()
|
||||
return any(token in value_lower for token in AI_GENERATOR_TOKENS)
|
||||
|
||||
|
||||
# PNG ancillary chunks that can carry provenance metadata (XMP, EXIF, text).
|
||||
# Never IDAT -- that is the compressed pixel stream.
|
||||
_PNG_META_CHUNKS: frozenset[bytes] = frozenset({b"tEXt", b"iTXt", b"zTXt", b"eXIf", b"iCCP"})
|
||||
@@ -579,12 +592,15 @@ def synthid_source(image_path: Path) -> str | None:
|
||||
|
||||
def exif_generator(image_path: Path) -> str | None:
|
||||
"""Return an AI-generator name from the EXIF ``Software`` / XMP ``CreatorTool``
|
||||
field, if it matches a known generator (see ``AI_GENERATOR_TOKENS``), else None.
|
||||
field (or a PNG text chunk), if it matches a known generator (see
|
||||
``AI_GENERATOR_TOKENS``), else None.
|
||||
|
||||
Cross-format: EXIF is read via PIL + piexif for any container PIL can open
|
||||
(JPEG/WebP/AVIF/PNG); an XMP ``CreatorTool`` raw-byte scan additionally covers
|
||||
HEIF/JPEG-XL that PIL can't open without plugins. Only AI tokens match, so
|
||||
ordinary editors (plain "Adobe Photoshop", "GIMP") are not flagged.
|
||||
HEIF/JPEG-XL that PIL can't open without plugins. PNG ``tEXt`` chunks are read
|
||||
too -- NovelAI stamps its generator in ``Software``/``Source``/``Title`` text
|
||||
chunks rather than EXIF. Only AI tokens match, so ordinary editors (plain
|
||||
"Adobe Photoshop", "GIMP") are not flagged.
|
||||
"""
|
||||
import re
|
||||
|
||||
@@ -592,13 +608,21 @@ def exif_generator(image_path: Path) -> str | None:
|
||||
|
||||
candidates: list[str] = []
|
||||
|
||||
# EXIF Software / Artist / ImageDescription (0th IFD) via PIL exif bytes.
|
||||
# EXIF Software / Artist / ImageDescription (0th IFD) via PIL exif bytes,
|
||||
# plus PNG text chunks (NovelAI writes Software/Source/Title there, not EXIF).
|
||||
try:
|
||||
import piexif
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
exif_bytes = img.info.get("exif")
|
||||
info = img.info
|
||||
exif_bytes = info.get("exif")
|
||||
# PNG tEXt/iTXt chunks land in img.info too (same idiom as the other
|
||||
# PNG-text readers in this module); NovelAI stamps Software/Source/Title.
|
||||
for key in ("Software", "Source", "Title", "Description"):
|
||||
value = info.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
candidates.append(value)
|
||||
if exif_bytes:
|
||||
tags = piexif.load(exif_bytes).get("0th", {})
|
||||
# Make catches camera-style tags AI tools reuse (Ideogram writes
|
||||
@@ -941,6 +965,11 @@ def remove_ai_metadata(
|
||||
continue
|
||||
if _is_ai_key(key):
|
||||
continue
|
||||
# Drop a generic text chunk whose VALUE names an AI generator (NovelAI
|
||||
# writes its stamp into Title/Source under non-AI keys) -- keeps removal
|
||||
# in parity with exif_generator's value-based detection.
|
||||
if isinstance(value, str) and _is_ai_value(value):
|
||||
continue
|
||||
if key == "exif":
|
||||
with contextlib.suppress(Exception):
|
||||
exif_data = piexif.load(value)
|
||||
|
||||
@@ -262,6 +262,16 @@ AI_GENERATOR_TOKENS: frozenset[str] = frozenset(
|
||||
"leonardo",
|
||||
"flux",
|
||||
"dreamstudio",
|
||||
# Mined from the retained corpus 2026-06-22 (no C2PA -- a plain EXIF/PNG
|
||||
# generator stamp was the only signal and we read none of them):
|
||||
# - NovelAI (anime SD): PNG tEXt Software="NovelAI", Source="NovelAI
|
||||
# Diffusion V4.5 <hash>", Title="NovelAI generated image".
|
||||
# - Reve Image (reve.com): EXIF Software / XMP CreatorTool = "reve.com"
|
||||
# (the bare token "reve" would false-positive on "forever"/"reverie").
|
||||
# - Aphrodite AI: EXIF Make / Software = "Aphrodite AI[ v1.0]".
|
||||
"novelai",
|
||||
"reve.com",
|
||||
"aphrodite ai",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user