mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-19 12:07:13 +02:00
feat(identify): C2PA vendor coverage, AI-enhanced split, detect/remove threshold unify
Retained-corpus mining (2026-06-20) surfaced three provenance gaps; all are
oracle-free and regression-guarded.
- C2PA vendor coverage (roadmap): register Volcano Engine under its Chinese
legal entity 北京火山引擎科技有限公司 (the latin "volcengine" needle misses
those certs) -> normalizes to the same ByteDance platform; register ElevenLabs
("Eleven Labs Inc.", pure generative-AI) as a generator. Document the
deliberate exclusion of TikTok Inc. and PixelBin.io/"Fynd" (provenance/transform
signers, not generators) so they are not re-added.
- AI-generated vs AI-enhanced (roadmap): ProvenanceReport.ai_source_kind splits
the C2PA digital-source-type into "generated" (trainedAlgorithmicMedia) vs
"enhanced" (compositeWithTrainedAlgorithmicMedia) so a caller branches a
full-frame scrub from a region-targeted clean. Parsed once in
noai.c2pa._populate_registry_fields (PNG + any c2pa-python-readable container),
with a raw head-scan fallback in identify for the non-PNG raw-blob path. CLI
verdict reads "AI-generated (fully synthetic)" vs "AI-enhanced (real content
with an AI-composited region)"; surfaced in --json.
- Detect-vs-remove threshold desync (P0#7): identify's sparkle threshold and the
removal arbitration gate were two independent 0.5 constants. Unify them into the
single GEMINI_SPARKLE_TRUST_CONF (identify imports it) so they can never drift.
Lowering the gate to recover faint sub-0.5 sparkles was evaluated and REJECTED:
a real Doubao text mark scores ~0.40-0.42 as a gemini match with a higher
core-ring brightness margin than a genuine faint sparkle, so neither confidence
nor the brightness gate separates them in [0.35, 0.5) -- lowering would trade a
rare miss for false-positive removals on clean images. Regression-guarded by
TestSparkleDetectRemoveAlignment (real demo sparkle at borderline opacities;
identify and best_auto_mark must agree on either side of the line).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
373b910a60
commit
0c215b5b2f
@@ -24,6 +24,7 @@ from remove_ai_watermarks.identify import (
|
||||
_vendor_of,
|
||||
identify,
|
||||
)
|
||||
from remove_ai_watermarks.watermark_registry import GEMINI_SPARKLE_TRUST_CONF
|
||||
|
||||
# Where the lazy import inside identify._visible_sparkle resolves the detector.
|
||||
_SPARKLE_TARGET = "remove_ai_watermarks.gemini_engine.detect_sparkle_confidence"
|
||||
@@ -140,6 +141,23 @@ class TestIdentifyNonPng:
|
||||
assert r.is_ai_generated is True
|
||||
assert "ByteDance" in (r.platform or "")
|
||||
|
||||
def test_bytedance_chinese_legal_name_attributed(self, tmp_path: Path):
|
||||
# Some Volcano Engine certs name the signer with the Chinese legal entity
|
||||
# rather than the latin "volcengine"; the latin needle misses it, so the
|
||||
# Chinese-name registry entry is what attributes real ByteDance output.
|
||||
blob = "北京火山引擎科技有限公司".encode() + b" ... trainedAlgorithmicMedia"
|
||||
path = self._c2pa_jpeg(tmp_path, blob)
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert "ByteDance" in (r.platform or "")
|
||||
|
||||
def test_elevenlabs_attributed(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, b"Eleven Labs Inc. ... trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform == "ElevenLabs"
|
||||
assert not any("SynthID" in w for w in r.watermarks) # ElevenLabs does not use SynthID
|
||||
|
||||
def test_stability_ai_issuer_attributed_no_synthid(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, b"Stability AI ... trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False)
|
||||
@@ -148,6 +166,21 @@ class TestIdentifyNonPng:
|
||||
assert "Stability AI" in r.platform
|
||||
assert not any("SynthID" in w for w in r.watermarks) # Stability does not use SynthID
|
||||
|
||||
def test_trained_source_is_generated_kind(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, b"OpenAI ... trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.ai_source_kind == "generated"
|
||||
|
||||
def test_composite_source_is_enhanced_kind(self, tmp_path: Path):
|
||||
# compositeWithTrainedAlgorithmicMedia: a real photo with an AI-composited
|
||||
# region. Still AI (is_ai True), but the kind must read "enhanced" so a
|
||||
# caller can do region-targeted cleaning instead of a full-frame regen.
|
||||
path = self._c2pa_jpeg(tmp_path, b"Adobe ... compositeWithTrainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.ai_source_kind == "enhanced"
|
||||
|
||||
def test_c2pa_without_ai_marker_is_unknown(self, tmp_path: Path):
|
||||
# Adobe signs C2PA on plain Photoshop edits too. Without an AI digital-
|
||||
# source marker, the honest verdict is unknown -- the C2PA watermark is
|
||||
@@ -202,6 +235,16 @@ class TestIdentifySamsungGalaxy:
|
||||
assert r.platform == "ASUS Gallery (C2PA signer)"
|
||||
assert any("C2PA" in w for w in r.watermarks)
|
||||
|
||||
def test_galaxy_capture_without_ai_marker_is_not_ai(self, tmp_path: Path):
|
||||
# A genuine Galaxy phone capture carries Samsung Galaxy C2PA provenance but
|
||||
# NO AI source-type / genAIType. It must stay is_ai=None -- the device cert
|
||||
# is authenticity provenance of a real photo, not an AI-generation signal.
|
||||
path = self._jpeg(tmp_path, "s25_capture.jpg", b"Samsung Galaxy Galaxy S25 c2pa-rs no ai marker")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is None
|
||||
assert r.platform == "Samsung Galaxy (C2PA)"
|
||||
assert any("C2PA" in w for w in r.watermarks)
|
||||
|
||||
|
||||
# ── End-to-end verdicts on real fixtures ────────────────────────────
|
||||
|
||||
@@ -277,6 +320,12 @@ class TestIdentifyLocalParams:
|
||||
assert "parameters" in signal.detail
|
||||
assert signal.confidence == "high"
|
||||
|
||||
def test_local_gen_params_have_no_c2pa_source_kind(self, tmp_png_with_ai_metadata: Path):
|
||||
# AI verdict from local SD params (not C2PA) -> ai_source_kind stays None.
|
||||
r = identify(tmp_png_with_ai_metadata, check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.ai_source_kind is None
|
||||
|
||||
def test_clean_png_is_unknown(self, tmp_clean_png: Path):
|
||||
r = identify(tmp_clean_png, check_visible=False)
|
||||
assert r.is_ai_generated is None
|
||||
@@ -399,6 +448,66 @@ class TestIdentifyVisibleSparkle:
|
||||
assert r.confidence == "high"
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
_DEMO_BEFORE = REPO_ROOT / "demo_banana_before.png"
|
||||
_DEMO_AFTER = REPO_ROOT / "demo_banana_after.png"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (_DEMO_BEFORE.exists() and _DEMO_AFTER.exists()), reason="demo banana pair not present")
|
||||
class TestSparkleDetectRemoveAlignment:
|
||||
"""Detect (identify) and remove (registry.best_auto_mark) must agree on the
|
||||
same image -- the retained-corpus desync where identify reported a sparkle the
|
||||
removal arbitration declined (or vice versa). Both gate on the single shared
|
||||
GEMINI_SPARKLE_TRUST_CONF, so a sparkle just over the line is taken by BOTH
|
||||
and one just under is declined by BOTH. Fixtures composite the real captured
|
||||
sparkle (before-minus-after) back at reduced opacity to land on either side.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _faint_sparkle(tmp_path: Path, opacity: float) -> Path:
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
before = image_io.imread(_DEMO_BEFORE).astype("float32")
|
||||
after = image_io.imread(_DEMO_AFTER).astype("float32")
|
||||
faint = np.clip(after + opacity * (before - after), 0, 255).astype("uint8")
|
||||
out = tmp_path / f"sparkle_{int(opacity * 100)}.png"
|
||||
image_io.imwrite(out, faint)
|
||||
return out
|
||||
|
||||
def _detect_remove(self, path: Path) -> tuple[bool, bool, float]:
|
||||
from remove_ai_watermarks import image_io, watermark_registry
|
||||
from remove_ai_watermarks.gemini_engine import detect_sparkle_confidence
|
||||
|
||||
conf = detect_sparkle_confidence(path) or 0.0
|
||||
identify_fires = conf >= GEMINI_SPARKLE_TRUST_CONF
|
||||
best = watermark_registry.best_auto_mark(image_io.imread(path))
|
||||
remove_takes_gemini = best is not None and best.key == "gemini"
|
||||
return identify_fires, remove_takes_gemini, conf
|
||||
|
||||
def test_above_threshold_both_fire(self, tmp_path: Path):
|
||||
path = self._faint_sparkle(tmp_path, 0.7) # ~0.55 conf, just over the line
|
||||
identify_fires, remove_takes, conf = self._detect_remove(path)
|
||||
assert conf >= GEMINI_SPARKLE_TRUST_CONF
|
||||
assert identify_fires, f"identify declined a sparkle above threshold (conf={conf:.3f})"
|
||||
assert remove_takes, f"removal declined a sparkle above threshold (conf={conf:.3f})"
|
||||
|
||||
def test_below_threshold_both_decline(self, tmp_path: Path):
|
||||
path = self._faint_sparkle(tmp_path, 0.5) # ~0.37 conf, just under the line
|
||||
identify_fires, remove_takes, conf = self._detect_remove(path)
|
||||
assert conf < GEMINI_SPARKLE_TRUST_CONF
|
||||
assert not identify_fires, f"identify fired below threshold (conf={conf:.3f})"
|
||||
assert not remove_takes, f"removal fired below threshold (conf={conf:.3f})"
|
||||
|
||||
def test_full_strength_both_fire(self):
|
||||
# The shipped demo sparkle at full strength: unambiguous agreement.
|
||||
identify_fires, remove_takes, conf = self._detect_remove(_DEMO_BEFORE)
|
||||
assert conf >= GEMINI_SPARKLE_TRUST_CONF
|
||||
assert identify_fires
|
||||
assert remove_takes
|
||||
|
||||
|
||||
class TestIdentifyImportIsLight:
|
||||
"""`import identify` must stay torch-free (lazy noai/__init__): the package
|
||||
is deployed on a 512 MB host where eagerly pulling torch/diffusers OOMs."""
|
||||
|
||||
Reference in New Issue
Block a user