From abb7be7e9bca87b75f70c719171b82099e74b9f3 Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Mon, 22 Jun 2026 16:23:33 -0700 Subject: [PATCH] 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 --- CLAUDE.md | 2 +- docs/module-internals.md | 2 +- docs/watermarking-landscape.md | 2 +- src/remove_ai_watermarks/identify.py | 9 ++-- src/remove_ai_watermarks/metadata.py | 39 ++++++++++++++--- src/remove_ai_watermarks/noai/constants.py | 10 +++++ tests/test_metadata.py | 50 ++++++++++++++++++++++ 7 files changed, 102 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5a3107f..d29bfe1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/docs/module-internals.md b/docs/module-internals.md index 5c9b558..fc28f2d 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -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. diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md index 4699864..d0b4779 100644 --- a/docs/watermarking-landscape.md +++ b/docs/watermarking-landscape.md @@ -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 "` / `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: ` (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)"). diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index 65e58e8..d54b1dd 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -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 diff --git a/src/remove_ai_watermarks/metadata.py b/src/remove_ai_watermarks/metadata.py index ac28630..03a66e2 100644 --- a/src/remove_ai_watermarks/metadata.py +++ b/src/remove_ai_watermarks/metadata.py @@ -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) diff --git a/src/remove_ai_watermarks/noai/constants.py b/src/remove_ai_watermarks/noai/constants.py index 84e5855..4ee9468 100644 --- a/src/remove_ai_watermarks/noai/constants.py +++ b/src/remove_ai_watermarks/noai/constants.py @@ -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 ", 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", } ) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index c990835..7c9b1b1 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -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(