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:
Victor Kuznetsov
2026-06-20 15:34:20 -07:00
co-authored by Claude Opus 4.8
parent 373b910a60
commit 0c215b5b2f
6 changed files with 209 additions and 15 deletions
+109
View File
@@ -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."""