mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-07-06 08:27:49 +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:
@@ -52,7 +52,7 @@ Compact map. The full per-module detail (design decisions, tuned thresholds, cal
|
||||
|
||||
- `noai/c2pa.py` — C2PA reading. `extract_c2pa_info(path)` uses the official **c2pa-python `Reader`** first (core dep, any container; `read_manifest_store_json` returns the WHOLE store JSON — active + ingredient manifests — so an AI marker on a parent manifest is seen), and falls back to the hand-rolled caBX/CBOR parser (`has_c2pa_metadata` / `extract_c2pa_chunk` / `_extract_c2pa_info_png`) for synthetic/partial blobs the validator rejects or a broken/absent wheel. The registry scan (issuer / source-type / SynthID / soft-binding) is shared by both paths via `_populate_registry_fields`, so the return-dict shape is identical. Do not reimplement chunk parsing; chunk reads are clamped to the remaining file size by design. `extract_c2pa_chunk`/`inject_c2pa_chunk` stay PNG-only (raw caBX bytes, test/extractor use).
|
||||
- `noai/constants.py` — the single `C2PA_AI_VENDORS` registry (+ `C2PA_SOFT_BINDINGS`) from which `C2PA_ISSUERS` / `SYNTHID_C2PA_ISSUERS` / `identify._ISSUER_PLATFORM` are all derived. Add a new vendor as one registry entry; never edit the derived dicts and never add inline.
|
||||
- `metadata.py` — `scan_head(path)` is the shared (memoized) input for every C2PA/AIGC/IPTC byte scan; use it instead of `open().read(1MB)` for any new marker scan. Also home to `synthid_source`, `xai_signature`, `iptc_ai_system`, `aigc_label`, `huggingface_job`, `samsung_genai`, and `remove_ai_metadata` (fail-safe `strip_c2pa_boxes`).
|
||||
- `metadata.py` — `scan_head(path)` is the shared (memoized) input for every C2PA/AIGC/IPTC byte scan; use it instead of `open().read(1MB)` for any new marker scan. Also home to `synthid_source`, `xai_signature`, `iptc_ai_system`, `aigc_label`, `huggingface_job`, `samsung_genai`, and `remove_ai_metadata` (fail-safe `strip_c2pa_boxes`). `exif_generator` matches a VALUE against `AI_GENERATOR_TOKENS` across EXIF `Software`/`Make`/`Artist`/`ImageDescription`, XMP `CreatorTool`, AND PNG `tEXt` chunks (`Software`/`Source`/`Title`/`Description` — NovelAI stamps there, not EXIF). **Detection and removal must stay in parity:** a generator that stamps an AI-shaped VALUE under a non-AI KEY (NovelAI's `Title`/`Source`) is dropped on removal by `_is_ai_value` (value-token match, mirrors `exif_generator`), NOT by `_is_ai_key` alone — else the cleaned file still reads as that generator. Add a new no-C2PA generator = one `AI_GENERATOR_TOKENS` entry (use a distinctive token, e.g. `reve.com` not bare `reve`); detection and removal then both follow. Regression: `tests/test_metadata.py::TestExifGenerator::{test_novelai_png_text_chunk_detected,test_novelai_removal_parity}`.
|
||||
- `identify.py` — aggregates every locally-readable signal into one `ProvenanceReport`; `is_ai_generated` is True or None, never asserted False. `ProvenanceReport.ai_source_kind` exposes the C2PA digital-source-type split — `"generated"` (trainedAlgorithmicMedia, fully AI) vs `"enhanced"` (compositeWithTrainedAlgorithmicMedia, a real photo with an AI-composited region), else None — so a caller branches full-frame scrub vs region-targeted clean (see `noai/tiling.feather_region_composite` + `WatermarkRemover.remove_watermark(region=...)`). The sparkle provenance threshold is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (imported, not a private copy) so the provenance "is there a sparkle" verdict and the removal "take the sparkle" decision can never drift. `import identify` is deliberately light (lazy `noai/__init__`, fits a 512 MB host) — keep heavy imports out (the `watermark_registry` constant import stays light: engines are lazy there). Add capture-camera tokens to `_DEVICE_C2PA_PLATFORM` only when verified against a real C2PA file; editing-app/AI-device signer tokens go to `_SIGNER_C2PA_PLATFORM`; generator/issuer platforms to `C2PA_AI_VENDORS` in `constants.py`. Integrity-clash detection is high-precision by design (only hard generator stamps feed it, source-grouped independence).
|
||||
- `watermark_registry.py` — the single catalog of known visible watermarks (gemini / doubao / jimeng / samsung), reverse-alpha based by policy. Add a new visible text mark = one `_text_mark(...)` row + a `TextMarkConfig` with a captured alpha map; do not re-add per-mark `if` branches. `cli._write_bgr_with_alpha` must NOT zero alpha in the watermark bbox (issue #30 white-box regression).
|
||||
- `gemini_engine.py` — visible Gemini-sparkle remover/detector (cv2/numpy, no GPU): top-K size-weighted fusion candidate selection (`_SELECT_TOPK`), corner-promote, over/under-subtraction guards, false-positive gate, self-verify repair. Detection scores the top-K size-weighted matches by full fusion (spatial+gradient+variance) and keeps the highest — NOT the raw-NCC argmax, which re-admits the tiny-patch FPs the size weight suppresses (the osachub 2026-06-12 sub-0.85 corner-sparkle regression; see `docs/module-internals.md`). Keep the 0.85 corner-promote NCC gate; a margin/chroma-gated lower promote was measured and REJECTED 2026-06-11 (~33% FP on non-Google content). Gate any removal candidate on a physical brightness check, not the detector alone.
|
||||
|
||||
@@ -43,7 +43,7 @@ module.
|
||||
|
||||
**Samsung's `genAIType` (in the proprietary `PhotoEditor_Re_Edit_Data` JSON) is an undocumented Galaxy-AI editing marker** (`metadata.samsung_genai`, gated on the `PhotoEditor_Re_Edit_Data` container; non-zero value = AI tool used, values {1,5} observed): medium-confidence because the field has no public spec (verified 2026-05-29: absent from C2PA spec + Samsung docs), but it co-occurred with `trainedAlgorithmicMedia` in 3/3 verified files that record a source-type and was the SOLE AI marker on a Galaxy S24 file that omits the source type. Camera C2PA marks capture authenticity, not AI (Pixel carries `computationalCapture`, not `trainedAlgorithmicMedia`), so these never set `is_ai` -- that stays driven by digital-source-type. `c2pa.cbor_text_after` (now public) is best-effort for the `generator` detail string only and can be None when the manifest keys it `claim_generator_info` (Pixel).
|
||||
|
||||
**Issuer→generator mapping is `is_ai`-gated** (`_attribute_platform(issuers, is_ai=c2pa_is_ai)`): a specific AI-generator platform is named only when the 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 capture-camera tokens to `_DEVICE_C2PA_PLATFORM`, editing-app/AI-device signer tokens to `_SIGNER_C2PA_PLATFORM`, generator/issuer platforms to the `C2PA_AI_VENDORS` registry in `constants.py` (which derives `_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.
|
||||
**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 capture-camera tokens to `_DEVICE_C2PA_PLATFORM`, editing-app/AI-device signer tokens to `_SIGNER_C2PA_PLATFORM`, generator/issuer platforms to the `C2PA_AI_VENDORS` registry in `constants.py` (which derives `_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`, XMP `CreatorTool`, and PNG `tEXt` chunks (`Software`/`Source`/`Title`/`Description` — NovelAI stamps its generator there, not EXIF) 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. Tokens mined from the retained corpus 2026-06-22: `novelai`, `reve.com` (full token, not bare `reve`), `aphrodite ai` — all no-C2PA generator stamps that previously read as no-signal (and under the P0#5 no-signal skip would have skipped the scrub).
|
||||
|
||||
**Ideogram tags its output with EXIF `Make="Ideogram AI"`** (verified on a real download 2026-05-24) — that's why `Make` is read.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Who embeds what, and whether it is locally detectable (so we know which gaps are
|
||||
**Black Forest Labs (FLUX)** API output signs C2PA: `claim_generator_info "Black Forest Labs API"` + a `c2pa.ai_generated_content` assertion + `trainedAlgorithmicMedia` (issuer `b"Black Forest Labs"` added to `C2PA_ISSUERS`, platform "Black Forest Labs (FLUX)").
|
||||
|
||||
**ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance (Doubao / Jimeng / Volcano Engine)"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. All three verified on real signed files 2026-05-29. ByteDance's **international brand (BytePlus / Seedream / Seededit)** signs the SAME content as **"Byteplus Pte. Ltd."** — the bare `volcengine` needle missed it, so real BytePlus output was mis-attributed to "Adobe Firefly" (an incidental "Adobe XMP" toolkit string in the file's XMP, picked up by the fallback byte-scan once the clean manifest issuer matched nothing). Added issuer `b"Byteplus"` → org "BytePlus (ByteDance)" (platform resolves to the shared "ByteDance (Doubao / Jimeng / Volcano Engine)" label via the common `ByteDance` needle) so the clean manifest issuer attributes it directly; found on real production traffic 2026-06-19.
|
||||
- **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).
|
||||
- **EXIF/XMP/PNG-text 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). Three more mined from the retained corpus 2026-06-22, all no-C2PA generator stamps that previously read as no-signal: **NovelAI** (anime SD) writes its stamp in PNG `tEXt` chunks `Software="NovelAI"` / `Source="NovelAI Diffusion V4.5 <hash>"` / `Title="NovelAI generated image"` — so `exif_generator` now reads PNG text chunks (`Software`/`Source`/`Title`/`Description`), not just EXIF/XMP; **Reve** (reve.com) writes EXIF `Software` / XMP `CreatorTool` = `reve.com` (token is the full `reve.com`, not bare `reve`, to avoid false-firing on "forever"/"reverie"); **Aphrodite AI** writes EXIF `Make`/`Software` = `Aphrodite AI`.
|
||||
- **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)").
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -538,6 +538,56 @@ class TestExifGenerator:
|
||||
Image.new("RGB", (64, 64)).save(path, exif=exif)
|
||||
assert exif_generator(path) == "Midjourney"
|
||||
|
||||
def test_novelai_png_text_chunk_detected(self, tmp_path: Path):
|
||||
# NovelAI (mined corpus) stamps its generator in PNG tEXt Software/Source/
|
||||
# Title chunks, not EXIF -- the PNG-text path must catch it.
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
info = PngInfo()
|
||||
info.add_text("Software", "NovelAI")
|
||||
info.add_text("Source", "NovelAI Diffusion V4.5 C02D4F98")
|
||||
info.add_text("Title", "NovelAI generated image")
|
||||
path = tmp_path / "novelai.png"
|
||||
Image.new("RGB", (64, 64)).save(path, pnginfo=info)
|
||||
assert exif_generator(path) == "NovelAI"
|
||||
|
||||
def test_reve_software_detected(self, tmp_path: Path):
|
||||
# Reve Image (mined corpus) writes EXIF Software="reve.com".
|
||||
path = _img_with_software(tmp_path, "jpg", "reve.com")
|
||||
assert exif_generator(path) == "reve.com"
|
||||
|
||||
def test_reve_token_not_overmatched(self, tmp_path: Path):
|
||||
# The "reve.com" token must not fire on incidental words like "forever".
|
||||
path = _img_with_software(tmp_path, "jpg", "Forever Editor 2.0")
|
||||
assert exif_generator(path) is None
|
||||
|
||||
def test_aphrodite_make_detected(self, tmp_path: Path):
|
||||
# Aphrodite AI (mined corpus) writes EXIF Make="Aphrodite AI".
|
||||
exif = piexif.dump({"0th": {piexif.ImageIFD.Make: b"Aphrodite AI"}, "Exif": {}, "GPS": {}, "1st": {}})
|
||||
path = tmp_path / "aphrodite.jpg"
|
||||
Image.new("RGB", (64, 64)).save(path, exif=exif)
|
||||
assert exif_generator(path) == "Aphrodite AI"
|
||||
|
||||
def test_novelai_removal_parity(self, tmp_path: Path):
|
||||
# Detection and removal must stay in parity: NovelAI stamps Title/Source
|
||||
# under non-AI keys (AI-shaped VALUE), so removal must drop them by value,
|
||||
# not only by key -- else the cleaned file still reads as NovelAI.
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
|
||||
info = PngInfo()
|
||||
info.add_text("Software", "NovelAI")
|
||||
info.add_text("Source", "NovelAI Diffusion V4.5 C02D4F98")
|
||||
info.add_text("Title", "NovelAI generated image")
|
||||
src = tmp_path / "novelai.png"
|
||||
Image.new("RGB", (64, 64)).save(src, pnginfo=info)
|
||||
assert exif_generator(src) == "NovelAI"
|
||||
|
||||
out = tmp_path / "clean.png"
|
||||
remove_ai_metadata(src, out)
|
||||
assert exif_generator(out) is None
|
||||
|
||||
def test_imagedescription_tag_ai_tool_detected(self, tmp_path: Path):
|
||||
# ...and the EXIF ImageDescription field.
|
||||
exif = piexif.dump(
|
||||
|
||||
Reference in New Issue
Block a user