mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-19 12:07:13 +02:00
feat(identify): close 3 detector gaps found on the spaces corpus (06-05..06-11)
- AIGC: parse the bare ``AIGC{...}`` blob form (label glued to its JSON in a
JPEG APP segment near the JFIF header), and scan both raw-JSON forms in one
fall-through loop so a quoted ``"AIGC"`` later in an XMP packet no longer
shadows a real bare label earlier in the file (3 files read unknown before).
- Integrity clash rule 2: a camera device + an AI marker from the SAME C2PA
manifest (Google Pixel Magic Editor / Pixel Studio edit chain) is a legitimate
edit chain, not a contradiction. Fire only when the AI marker's source is
independent of the camera's manifest; pure cameras (Leica/Sony/Nikon) are
unaffected (2 Pixel files mis-flagged before).
- New c2pa_cloud_manifest detector: surface a C2PA 2.4 Durable Content
Credentials cloud-manifest reference (Adobe cai-manifests.adobe.com) as a
medium provenance signal when the embedded manifest is stripped. Provenance
only, never asserts is_ai (2 files read fully unknown before).
identify reuses its already-loaded scan head for the cloud check (no second
read). +7 tests; CLAUDE.md + README synced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
22bc171806
commit
ad7e4ee08b
@@ -772,6 +772,31 @@ class TestIntegrityClashesHelper:
|
||||
# must NOT raise a clash.
|
||||
assert _integrity_clashes({}, "Leica (camera, C2PA capture)", camera_has_ai_marker=False) == []
|
||||
|
||||
def test_pixel_generative_edit_same_manifest_no_clash(self):
|
||||
# A Google Pixel that BOTH captures and runs on-device generative AI
|
||||
# (Magic Editor / Pixel Studio) records the capture and the AI edit in
|
||||
# ONE C2PA manifest -- the AI vendor is named only from that same
|
||||
# manifest (c2pa / synthid), independent of nothing. That is a legitimate
|
||||
# edit chain, NOT a camera-vs-AI contradiction, so rule 2 must stay quiet.
|
||||
assert (
|
||||
_integrity_clashes(
|
||||
{"c2pa": "Google", "synthid": "Google"},
|
||||
"Google Pixel (camera, C2PA capture)",
|
||||
camera_has_ai_marker=True,
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
def test_camera_plus_independent_ai_marker_still_clashes(self):
|
||||
# But a camera capture next to an AI marker from a genuinely INDEPENDENT
|
||||
# source (EXIF/XMP generator, TC260 AIGC, ...) is still a laundering tell.
|
||||
clashes = _integrity_clashes(
|
||||
{"c2pa": "Google", "aigc": "China AIGC (TC260)"},
|
||||
"Google Pixel (camera, C2PA capture)",
|
||||
camera_has_ai_marker=True,
|
||||
)
|
||||
assert any("Camera-capture" in c for c in clashes)
|
||||
|
||||
|
||||
class TestIntegrityClashEndToEnd:
|
||||
def _c2pa_jpeg(self, tmp_path: Path, blob: bytes) -> Path:
|
||||
@@ -806,6 +831,22 @@ class TestIntegrityClashEndToEnd:
|
||||
assert r.platform == "Google Pixel (camera, C2PA capture)"
|
||||
assert any("Camera-capture C2PA credentials" in c and "AI-generation markers" in c for c in r.integrity_clashes)
|
||||
|
||||
def test_pixel_generative_edit_no_clash(self, tmp_path: Path):
|
||||
# A real Google Pixel generative edit (Magic Editor / Pixel Studio) signs
|
||||
# ONE manifest carrying both the Pixel Camera capture and a Google
|
||||
# Generative AI edit (trainedAlgorithmicMedia + "Applied imperceptible
|
||||
# SynthID watermark"). The AI marker lives in the SAME manifest as the
|
||||
# device, so it is an edit chain, not a camera-vs-AI contradiction.
|
||||
path = self._c2pa_jpeg(
|
||||
tmp_path,
|
||||
b"Pixel Camera ... Created by Pixel Camera ... computationalCapture ... "
|
||||
b"Created by Google Generative AI ... trainedAlgorithmicMedia ... "
|
||||
b"Applied imperceptible SynthID watermark",
|
||||
)
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.integrity_clashes == []
|
||||
|
||||
def test_clash_serializes_to_json(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, b"OpenAI ... trainedAlgorithmicMedia ... TC260:AIGC label")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
|
||||
@@ -790,6 +790,42 @@ class TestAIGCLabel:
|
||||
def test_has_ai_metadata_detects_raw_json_exif_form(self, tmp_path: Path):
|
||||
assert has_ai_metadata(self._aigc_exif_jpeg(tmp_path))
|
||||
|
||||
def _aigc_bare_jpeg(self, tmp_path: Path, producer: str = "00119144030008867405X210002") -> Path:
|
||||
"""Some China-served generators glue the TC260 label straight to its JSON
|
||||
as a bare ``AIGC{...}`` blob inside a JPEG APP segment (no ``"AIGC":``
|
||||
key wrapper, no PNG chunk, no namespaced XMP) -- seen near the JFIF
|
||||
header on real 2026-06 downloads."""
|
||||
p = tmp_path / "aigc_bare.jpg"
|
||||
Image.new("RGB", (32, 32)).save(p)
|
||||
raw = p.read_bytes()
|
||||
blob = b'AIGC{"Label":"1","ContentProducer":"' + producer.encode() + b'","ProduceID":"8F995586"}'
|
||||
segment = b"\xff\xe9" + (len(blob) + 2).to_bytes(2, "big") + blob # APP9
|
||||
p.write_bytes(raw[:2] + segment + raw[2:]) # splice after SOI
|
||||
return p
|
||||
|
||||
def test_parses_bare_aigc_jpeg_segment_form(self, tmp_path: Path):
|
||||
from remove_ai_watermarks.metadata import aigc_label
|
||||
|
||||
info = aigc_label(self._aigc_bare_jpeg(tmp_path))
|
||||
assert info is not None
|
||||
assert info["Label"] == "1"
|
||||
assert info["ContentProducer"] == "00119144030008867405X210002"
|
||||
|
||||
def test_has_ai_metadata_detects_bare_aigc_jpeg_form(self, tmp_path: Path):
|
||||
assert has_ai_metadata(self._aigc_bare_jpeg(tmp_path))
|
||||
|
||||
def test_bare_aigc_without_tc260_field_ignored(self, tmp_path: Path):
|
||||
"""A bare ``AIGC{...}`` blob with no TC260 field must not false-positive."""
|
||||
from remove_ai_watermarks.metadata import aigc_label
|
||||
|
||||
p = tmp_path / "bare_unrelated.jpg"
|
||||
Image.new("RGB", (32, 32)).save(p)
|
||||
raw = p.read_bytes()
|
||||
blob = b'AIGC{"unrelated":"value"}'
|
||||
segment = b"\xff\xe9" + (len(blob) + 2).to_bytes(2, "big") + blob
|
||||
p.write_bytes(raw[:2] + segment + raw[2:])
|
||||
assert aigc_label(p) is None
|
||||
|
||||
def test_raw_json_without_tc260_field_ignored(self, tmp_path: Path):
|
||||
"""A bare ``{"AIGC":{...}}`` object with no TC260 field must not fire."""
|
||||
import json
|
||||
@@ -1185,3 +1221,49 @@ class TestFfmpegMetadataStrip:
|
||||
remove_ai_metadata(src, out)
|
||||
assert out.exists()
|
||||
assert b"Suno AI generated" not in out.read_bytes() # tag stripped, audio kept
|
||||
|
||||
|
||||
class TestC2paCloudManifest:
|
||||
"""C2PA 2.4 Durable Content Credentials: an XMP dcterms:provenance pointer to
|
||||
a vendor cloud manifest store survives when the embedded manifest is stripped."""
|
||||
|
||||
def _cloud_png(self, tmp_path: Path, host: bytes = b"cai-manifests.adobe.com") -> Path:
|
||||
xmp = (
|
||||
b'<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/">'
|
||||
b'<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">'
|
||||
b'<rdf:Description rdf:about="" xmlns:dcterms="http://purl.org/dc/terms/" '
|
||||
b'dcterms:provenance="https://' + host + b'/manifests/urn-c2pa-abc123"> </rdf:Description>'
|
||||
b'</rdf:RDF></x:xmpmeta><?xpacket end="w"?>'
|
||||
)
|
||||
p = tmp_path / "cloud.png"
|
||||
img = Image.new("RGB", (16, 16))
|
||||
meta = PngInfo()
|
||||
meta.add_itxt("XML:com.adobe.xmp", xmp.decode("latin-1"))
|
||||
img.save(p, pnginfo=meta)
|
||||
return p
|
||||
|
||||
def test_detects_adobe_cloud_manifest(self, tmp_path: Path):
|
||||
from remove_ai_watermarks.metadata import c2pa_cloud_manifest
|
||||
|
||||
assert c2pa_cloud_manifest(self._cloud_png(tmp_path)) == "Adobe Content Authenticity"
|
||||
|
||||
def test_no_provenance_pointer_is_none(self, tmp_clean_png: Path):
|
||||
from remove_ai_watermarks.metadata import c2pa_cloud_manifest
|
||||
|
||||
assert c2pa_cloud_manifest(tmp_clean_png) is None
|
||||
|
||||
def test_unknown_host_is_none(self, tmp_path: Path):
|
||||
from remove_ai_watermarks.metadata import c2pa_cloud_manifest
|
||||
|
||||
# A dcterms:provenance pointer to an unrecognized host is not attributed.
|
||||
assert c2pa_cloud_manifest(self._cloud_png(tmp_path, host=b"manifests.example.com")) is None
|
||||
|
||||
def test_cloud_manifest_does_not_assert_ai(self, tmp_path: Path):
|
||||
# Provenance only -- a cloud manifest can describe a human edit, so the
|
||||
# verdict must stay 'unknown', not 'AI-generated'.
|
||||
from remove_ai_watermarks.identify import identify
|
||||
|
||||
r = identify(self._cloud_png(tmp_path), check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is None
|
||||
assert any("Durable Content Credentials" in w for w in r.watermarks)
|
||||
assert any(s.name == "c2pa_cloud" for s in r.signals)
|
||||
|
||||
Reference in New Issue
Block a user