mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-07-11 18:46:32 +02:00
Track the labeled SynthID corpus; complete metadata-source test coverage
Corpus images were gitignored (local-only). The negatives were reviewed and cleared for publishing, so the labeled set is now committed (regular git, 65 MB across 25 files) -- making the removal regression set reproducible and CI-able. Corpus: - Track data/synthid_corpus/images/ (pos 9, neg 15, cleaned 1); keep only the synthetic refs/ calibration fills gitignored. - Reconcile manifest.csv to the on-disk files: 117 -> 25 rows (92 dangling rows for removed images pruned; dedup left one cleaned output, f6dd47a5). - Rewrite the corpus README layout/policy (images committed; review every image for private content before adding -- public repo, permanent history). Test fixtures: - Remove data/samples/not-ai-1/2/3 (personal iPhone photos, incl. GPS EXIF). - Add the clean_photo conftest fixture serving a verified-negative image from the corpus neg/ set; repoint the three "non-AI / clean photo" tests onto it (skips if the corpus is absent). Metadata-source coverage (close the last sub-variant gaps): - c2pa digitalSourceType: algorithmicMedia (procedural, not flagged AI) and compositeWithTrainedAlgorithmicMedia (AI + SynthID proxy). - exif_generator: EXIF Artist and ImageDescription fields (Software/Make/XMP CreatorTool were already covered). All 8 metadata-source kinds are now tested at both the unit and identify() level. 313 tests pass. CLAUDE.md updated (corpus tracked, clean_photo fixture). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+17
-4
@@ -2,10 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -13,6 +10,22 @@ import pytest
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
CORPUS_NEG_DIR = Path(__file__).resolve().parent.parent / "data" / "synthid_corpus" / "images" / "neg"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_photo() -> Path:
|
||||
"""A verified-negative real photo from the corpus neg/ set.
|
||||
|
||||
Used by the "non-AI image" assertions (no SynthID, verdict unknown). These
|
||||
are real photos with no AI provenance, the ground truth for "must not false-
|
||||
positive". Skips if the corpus is not checked out.
|
||||
"""
|
||||
files = sorted(CORPUS_NEG_DIR.glob("*")) if CORPUS_NEG_DIR.exists() else []
|
||||
if not files:
|
||||
pytest.skip("no corpus neg/ images present")
|
||||
return files[0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_image_path(tmp_path: Path) -> Path:
|
||||
|
||||
@@ -156,15 +156,15 @@ class TestIdentifyRealSamples:
|
||||
assert r.is_ai_generated is True
|
||||
assert any("IPTC" in w for w in r.watermarks)
|
||||
|
||||
def test_clean_photo_is_unknown_not_clean(self):
|
||||
r = identify(SAMPLES_DIR / "not-ai-1.jpeg", check_visible=False)
|
||||
def test_clean_photo_is_unknown_not_clean(self, clean_photo: Path):
|
||||
r = identify(clean_photo, check_visible=False)
|
||||
assert r.is_ai_generated is None # never asserted False
|
||||
assert r.platform is None
|
||||
assert r.confidence == "none"
|
||||
assert r.watermarks == []
|
||||
|
||||
def test_strip_caveat_always_present(self):
|
||||
r = identify(SAMPLES_DIR / "not-ai-1.jpeg", check_visible=False)
|
||||
def test_strip_caveat_always_present(self, clean_photo: Path):
|
||||
r = identify(clean_photo, check_visible=False)
|
||||
assert any("not proof" in c for c in r.caveats)
|
||||
|
||||
def test_returns_report_dataclass(self):
|
||||
|
||||
+20
-2
@@ -224,8 +224,8 @@ class TestSynthIDSource:
|
||||
assert synthid_source(SAMPLES_DIR / "firefly-1.png") is None
|
||||
assert "synthid_watermark" not in get_ai_metadata(SAMPLES_DIR / "firefly-1.png")
|
||||
|
||||
def test_non_ai_image_is_not_synthid_source(self):
|
||||
assert synthid_source(SAMPLES_DIR / "not-ai-1.jpeg") is None
|
||||
def test_non_ai_image_is_not_synthid_source(self, clean_photo: Path):
|
||||
assert synthid_source(clean_photo) is None
|
||||
|
||||
|
||||
class TestSynthIDSourceNonPng:
|
||||
@@ -377,6 +377,24 @@ class TestExifGenerator:
|
||||
Image.new("RGB", (64, 64)).save(path, exif=exif)
|
||||
assert exif_generator(path) is None
|
||||
|
||||
def test_artist_tag_ai_tool_detected(self, tmp_path: Path):
|
||||
# exif_generator also reads the EXIF Artist field for an AI token.
|
||||
exif = piexif.dump({"0th": {piexif.ImageIFD.Artist: b"Midjourney"}, "Exif": {}, "GPS": {}, "1st": {}})
|
||||
path = tmp_path / "artist.jpg"
|
||||
Image.new("RGB", (64, 64)).save(path, exif=exif)
|
||||
assert exif_generator(path) == "Midjourney"
|
||||
|
||||
def test_imagedescription_tag_ai_tool_detected(self, tmp_path: Path):
|
||||
# ...and the EXIF ImageDescription field.
|
||||
exif = piexif.dump(
|
||||
{"0th": {piexif.ImageIFD.ImageDescription: b"Made with Stable Diffusion"}, "Exif": {}, "GPS": {}, "1st": {}}
|
||||
)
|
||||
path = tmp_path / "desc.jpg"
|
||||
Image.new("RGB", (64, 64)).save(path, exif=exif)
|
||||
result = exif_generator(path)
|
||||
assert result is not None
|
||||
assert "Stable Diffusion" in result
|
||||
|
||||
def test_xmp_creatortool_scan_covers_unopenable(self, tmp_path: Path):
|
||||
# PIL can't open this fake HEIF; the raw XMP CreatorTool scan still works.
|
||||
path = tmp_path / "fake.heic"
|
||||
|
||||
@@ -259,6 +259,29 @@ class TestParseChunkGuards:
|
||||
assert "OpenAI" in info["issuer"] # issuer byte-search still robust
|
||||
|
||||
|
||||
class TestC2PADigitalSourceType:
|
||||
"""The three IPTC digitalSourceType variants drive the AI verdict.
|
||||
|
||||
Only *trained* and *composite-with-trained* mean AI-generated (and so imply
|
||||
a SynthID proxy for a SynthID vendor); plain ``algorithmicMedia`` is
|
||||
procedural (not trained) and must NOT be flagged as AI.
|
||||
"""
|
||||
|
||||
def test_plain_algorithmic_media_not_flagged_ai(self):
|
||||
chunk = b"...name" + bytes([0x69]) + b"some-tool" + b" OpenAI algorithmicMedia"
|
||||
info: dict = {}
|
||||
_parse_c2pa_chunk(chunk, info)
|
||||
assert info["source_type"] == "algorithmicMedia"
|
||||
assert "synthid_watermark" not in info # procedural, not AI-generated
|
||||
|
||||
def test_composite_with_trained_is_ai_and_synthid(self):
|
||||
chunk = b"...name" + bytes([0x69]) + b"some-tool" + b" OpenAI compositeWithTrainedAlgorithmicMedia"
|
||||
info: dict = {}
|
||||
_parse_c2pa_chunk(chunk, info)
|
||||
assert "compositeWithTrainedAlgorithmicMedia" in info["source_type"]
|
||||
assert "synthid_watermark" in info # AI-enhanced + OpenAI issuer
|
||||
|
||||
|
||||
# ── ISOBMFF (AVIF / HEIF / JPEG-XL container stripping) ──────────────
|
||||
|
||||
FTYP = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avifmif1" # 24-byte ftyp box
|
||||
|
||||
Reference in New Issue
Block a user