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:
Victor Kuznetsov
2026-06-22 16:23:33 -07:00
co-authored by Claude Opus 4.8
parent 19f9ab0947
commit abb7be7e9b
7 changed files with 102 additions and 12 deletions
+5 -4
View File
@@ -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
+34 -5
View File
@@ -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",
}
)