mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-06 22:18:36 +02:00
Restructure documentation, validate metadata removal, consolidate assets
This commit is contained in:
+10
-11
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -10,21 +10,20 @@ import pytest
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
CORPUS_NEG_DIR = Path(__file__).resolve().parent.parent / "data" / "synthid_corpus" / "images" / "neg"
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_photo() -> Path:
|
||||
"""A verified-negative real photo from the corpus neg/ set.
|
||||
def clean_photo(tmp_path: Path) -> Path:
|
||||
"""Create a deterministic image with no provenance metadata.
|
||||
|
||||
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.
|
||||
The assertions using this fixture exercise metadata and provenance behavior,
|
||||
so a generated fixture is sufficient and avoids committing personal photos.
|
||||
"""
|
||||
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]
|
||||
path = tmp_path / "clean-control.png"
|
||||
Image.new("RGB", (128, 96), color=(90, 140, 190)).save(path)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import pytest
|
||||
import remove_ai_watermarks as raiw
|
||||
from remove_ai_watermarks import api
|
||||
|
||||
SAMPLES = Path(__file__).resolve().parents[1] / "data" / "samples"
|
||||
SAMPLES = Path(__file__).resolve().parents[1] / "data" / "fixtures" / "provenance"
|
||||
DOUBAO = SAMPLES / "doubao-1.png"
|
||||
CHATGPT = SAMPLES / "chatgpt-1.png"
|
||||
|
||||
|
||||
@@ -75,17 +75,13 @@ class TestConfig:
|
||||
assert BaiduEngine().config.provenance_ncc_factor == 1.0
|
||||
|
||||
def test_gate_above_clean_arm_max(self):
|
||||
# Clean arm scored p99 0.314 / max 0.352 on 278 hand-labelled frames;
|
||||
# the 741-frame eval set surfaced cross-fires up to 0.426 (a 抖音
|
||||
# AI创作 mark no rival can suppress), and the full-corpus sweep put the
|
||||
# outside-cohort false arm at 0.47 max vs true carriers at 0.50-0.66,
|
||||
# so the gate sits at 0.48.
|
||||
# Compatibility testing separated true Baidu marks from unrelated
|
||||
# bottom-right text, so the gate sits at 0.48.
|
||||
assert BaiduEngine().config.detect_ncc_threshold >= 0.48
|
||||
|
||||
def test_qwen_is_a_rival(self):
|
||||
# 百度 vs 千问 are near-identical after binarization: 12 of 14 full-corpus
|
||||
# cross-fires at the 0.37 gate were Qwen marks (Qwen's template beats
|
||||
# Baidu's there by 0.17-0.35, so the margin suppresses them).
|
||||
# 百度 and 千问 are near-identical after binarization, so Qwen's template
|
||||
# must act as a rival.
|
||||
assert "qwen_alpha.png" in BaiduEngine().config.rivals
|
||||
|
||||
def test_registry_row(self):
|
||||
|
||||
+27
-4
@@ -545,6 +545,29 @@ class TestAllCommand:
|
||||
assert "remove-ai-watermarks[gpu]" in result.output
|
||||
assert output.exists() # visible + metadata still produced a file
|
||||
|
||||
def test_all_reports_metadata_that_survived_stripping(self, runner, sample_png, tmp_path):
|
||||
"""The full pipeline must verify the metadata result before reporting success.
|
||||
|
||||
``remove_ai_metadata`` is deliberately fail-safe and may copy an undecodable
|
||||
input through unchanged. Calling it directly let ``all`` print a successful
|
||||
strip even when a marker survived.
|
||||
"""
|
||||
output = tmp_path / "clean.png"
|
||||
with (
|
||||
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
|
||||
patch("remove_ai_watermarks.cli._should_skip_invisible_scrub", return_value=True),
|
||||
patch(
|
||||
"remove_ai_watermarks.metadata.strip_and_verify",
|
||||
return_value=(tmp_path / "intermediate.png", {"c2pa": True}),
|
||||
),
|
||||
):
|
||||
result = runner.invoke(main, ["all", str(sample_png), "-o", str(output)])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "metadata" in result.output.lower()
|
||||
assert "survived" in result.output.lower()
|
||||
assert "AI metadata stripped" not in result.output
|
||||
|
||||
def test_all_preserves_rgba_across_invisible_step(self, runner, tmp_path):
|
||||
"""Regression: ``all`` must keep transparency even when the invisible
|
||||
step writes a 3-channel result (as the real diffusion engine does).
|
||||
@@ -610,8 +633,8 @@ class TestMetadataCommand:
|
||||
|
||||
That is correct (never crash a worker on a partial upload) but the command used
|
||||
to print "AI metadata stripped ->" and exit 0 for it, so a caller could not tell
|
||||
a real strip from a no-op and the output still read as AI. Found on real Samsung
|
||||
Galaxy S22 C2PA PNGs during the corpus parity audit, 2026-07-19.
|
||||
a real strip from a no-op and the output still read as AI. A Samsung C2PA
|
||||
compatibility case exposed the defect.
|
||||
"""
|
||||
# PNG signature + a C2PA (caBX) chunk, then garbage: the byte scanner sees the
|
||||
# marker, PIL cannot decode it.
|
||||
@@ -672,7 +695,7 @@ class TestIdentifyCommand:
|
||||
'AI-generated (fully synthetic)' at the CLI (the ai_source_kind branch)."""
|
||||
from pathlib import Path
|
||||
|
||||
sample = Path(__file__).resolve().parent.parent / "data" / "samples" / "chatgpt-1.png"
|
||||
sample = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance" / "chatgpt-1.png"
|
||||
if not sample.exists():
|
||||
pytest.skip("chatgpt sample not present")
|
||||
result = runner.invoke(main, ["identify", str(sample), "--no-visible"])
|
||||
@@ -1009,7 +1032,7 @@ def test_visible_backend_runtime_error_exits_cleanly(runner, tmp_path, monkeypat
|
||||
|
||||
from remove_ai_watermarks import region_eraser
|
||||
|
||||
doubao = Path(__file__).resolve().parent.parent / "data" / "samples" / "doubao-1.png"
|
||||
doubao = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance" / "doubao-1.png"
|
||||
if not doubao.exists():
|
||||
pytest.skip("doubao sample not present")
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ class TestFailedWriteIsReported:
|
||||
def test_batch_counts_a_failed_write_instead_of_exiting_zero(self, tmp_path, bgr, monkeypatch):
|
||||
"""The worst shape of this bug: no output files AND a success exit code.
|
||||
|
||||
Corpus-reproduced 2026-07-20 -- `batch --mode visible` into a read-only directory
|
||||
wrote ZERO files for 2 inputs and exited 0, so a wrapping service would treat an
|
||||
Regression: `batch --mode visible` into a read-only directory wrote no files
|
||||
and exited 0, so a wrapping service would treat an
|
||||
empty output directory as a completed run. The batch loop counts per-image
|
||||
exceptions, so the write must RAISE there, never `SystemExit` (which would abort
|
||||
the whole run instead of failing one image).
|
||||
|
||||
@@ -21,7 +21,7 @@ from remove_ai_watermarks.doubao_engine import (
|
||||
load_image_bgr,
|
||||
)
|
||||
|
||||
SAMPLE = Path(__file__).resolve().parents[1] / "data" / "samples" / "doubao-1.png"
|
||||
SAMPLE = Path(__file__).resolve().parents[1] / "data" / "fixtures" / "provenance" / "doubao-1.png"
|
||||
|
||||
|
||||
def _compose(w: int, h: int, bg: float = 100.0):
|
||||
|
||||
+10
-10
@@ -1,7 +1,7 @@
|
||||
"""Tests for the provenance identifier (identify.py).
|
||||
|
||||
Pure attribution logic is unit-tested directly; end-to-end verdicts assert
|
||||
against the real committed C2PA / IPTC fixtures in data/samples/.
|
||||
against the real committed C2PA / IPTC fixtures in data/fixtures/provenance/.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -30,7 +30,7 @@ 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"
|
||||
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "samples"
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"
|
||||
|
||||
|
||||
# ── Pure attribution logic (no file IO) ─────────────────────────────
|
||||
@@ -173,7 +173,7 @@ class TestIdentifyNonPng:
|
||||
|
||||
def test_fal_ai_attributed(self, tmp_path: Path):
|
||||
# fal.ai signs as "fal - Features & Labels Inc." with a "fal-ai/<model>"
|
||||
# claim generator; corpus-measured 2026-07-23 (17 files).
|
||||
# claim generator.
|
||||
path = self._c2pa_jpeg(tmp_path, b"fal - Features & Labels Inc. fal-ai/seedvr trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
@@ -182,7 +182,7 @@ class TestIdentifyNonPng:
|
||||
def test_bria_attributed_without_source_type(self, tmp_path: Path):
|
||||
# Bria signs as "Bria Artificial Intelligence" with source type ``empty``
|
||||
# (NO trainedAlgorithmicMedia) -- a pure-generator asserts_ai vendor, so
|
||||
# the issuer/generator strings alone must flag AI. Corpus-found 2026-07-23.
|
||||
# the issuer/generator strings alone must flag AI.
|
||||
path = self._c2pa_jpeg(tmp_path, b"Bria Artificial Intelligence Bria Ai c2pa.created c2pa.edited")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
@@ -279,7 +279,7 @@ class TestIdentifySamsungGalaxy:
|
||||
# ── End-to-end verdicts on real fixtures ────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/samples not present")
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/fixtures/provenance not present")
|
||||
class TestIdentifyRealSamples:
|
||||
def test_openai_chatgpt(self):
|
||||
r = identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False)
|
||||
@@ -306,7 +306,7 @@ class TestIdentifyRealSamples:
|
||||
# Apple Photos Clean Up (Apple Intelligence object removal) marks the
|
||||
# AI edit via photoshop:Credit next to compositeWithTrainedAlgorithmicMedia
|
||||
# -- it must be attributed, not reported as a generic made-with-AI tag.
|
||||
# Corpus-measured 2026-07-23 (35 files).
|
||||
# This attribution must survive metadata consolidation.
|
||||
p = tmp_path / "apple_cleanup.jpg"
|
||||
p.write_bytes(
|
||||
b'\xff\xd8\xff\xe1<x:xmpmeta photoshop:Credit="Apple Photos Clean Up" '
|
||||
@@ -491,7 +491,7 @@ class TestIdentifyHuggingFaceJob:
|
||||
|
||||
|
||||
class TestIdentifyVisibleSparkle:
|
||||
"""The visible-sparkle signal gates on the corpus-tuned threshold (0.5)."""
|
||||
"""The visible-sparkle signal gates on the calibrated threshold (0.5)."""
|
||||
|
||||
def test_above_threshold_promotes_to_medium(self, tmp_clean_png: Path):
|
||||
with patch(_SPARKLE_TARGET, return_value=0.7):
|
||||
@@ -537,7 +537,7 @@ _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.detect_marks) must agree on the
|
||||
same image -- the retained-corpus desync where identify reported a sparkle the
|
||||
same image. A prior desync let identify report a sparkle that
|
||||
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
|
||||
@@ -666,7 +666,7 @@ class TestIdentifyVisibleTextMarks:
|
||||
# ── Caveats and serialization ───────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/samples not present")
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/fixtures/provenance not present")
|
||||
class TestIdentifyCaveats:
|
||||
def test_openai_hedge_caveat_present(self):
|
||||
r = identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False)
|
||||
@@ -1126,7 +1126,7 @@ class TestIntegrityClashEndToEnd:
|
||||
assert payload["integrity_clashes"] == r.integrity_clashes
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/samples not present")
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/fixtures/provenance not present")
|
||||
@pytest.mark.parametrize("fixture", ["chatgpt-1.png", "firefly-1.png", "doubao-1.png", "grok-1.jpg", "mj-1.png"])
|
||||
class TestRealSamplesHaveNoClash:
|
||||
"""Every real single-origin fixture must report zero clashes (false-positive guard)."""
|
||||
|
||||
@@ -65,13 +65,11 @@ class TestConfig:
|
||||
assert LibLibEngine().config.provenance_ncc_factor == 1.0
|
||||
|
||||
def test_gate_above_clean_arm_max(self):
|
||||
# With the Arial silhouette the full-corpus false arm (latin UI text)
|
||||
# tops out at 0.398 while the cohort sits at 0.43-0.59; gate 0.42.
|
||||
# The Arial silhouette separates the wordmark from generic Latin UI text.
|
||||
assert LibLibEngine().config.detect_ncc_threshold >= 0.42
|
||||
|
||||
def test_small_image_size_floor(self):
|
||||
# The one full-corpus false fire with the final template was a 200x200
|
||||
# icon on a 20px template; the engine refuses small images outright.
|
||||
# Small generic icons can resemble the wordmark, so the engine rejects them.
|
||||
eng = LibLibEngine()
|
||||
assert not eng.detect(np.full((200, 200, 3), 100, np.uint8)).detected
|
||||
wm, _ = _compose(200, 200)
|
||||
|
||||
+15
-17
@@ -27,7 +27,7 @@ from remove_ai_watermarks.metadata import (
|
||||
)
|
||||
|
||||
# Real, committed C2PA sample images used to ground the SynthID-source tests.
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "samples"
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"
|
||||
|
||||
# ── Key detection ───────────────────────────────────────────────────
|
||||
|
||||
@@ -156,7 +156,7 @@ class TestHasAiMetadata:
|
||||
path.write_bytes(b"\xff\xd8\xff\xe1" + xmp + b"\xff\xd9")
|
||||
assert has_ai_metadata(path)
|
||||
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/samples not present")
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/fixtures/provenance not present")
|
||||
def test_jpeg_metadata_strip_is_pixel_lossless(self, tmp_path: Path):
|
||||
"""A JPEG metadata strip must NOT re-encode the DCT scan: pixels stay
|
||||
bit-identical, only the AI provenance APP segments are removed. Verified on real
|
||||
@@ -181,8 +181,7 @@ class TestHasAiMetadata:
|
||||
|
||||
def test_strip_preserves_lossless_content_with_mismatched_extension(self, tmp_path: Path):
|
||||
"""F1 regression: the save format is chosen by CONTENT, not the file extension.
|
||||
A PNG served with a .jpg name (common on real uploads -- ~1% of the corpus is a
|
||||
PNG/WebP under a .jpg extension) must be stripped losslessly as PNG, NOT
|
||||
PNG content served with a .jpg name must be stripped losslessly as PNG, NOT
|
||||
re-encoded into a real JPEG. The extension-driven path silently degraded it,
|
||||
breaking the 'work with originals' invariant."""
|
||||
import numpy as np
|
||||
@@ -261,8 +260,7 @@ class TestHasAiMetadata:
|
||||
"""Regression: a truncated / corrupt image must NOT crash remove_ai_metadata (a
|
||||
direct library caller like a web worker would 500 on a partial upload). PIL raises
|
||||
OSError decoding a truncated PNG; the strip must fail SAFE -- copy the input through
|
||||
unchanged and return, mirroring strip_c2pa_boxes. Real prod corpus: ~0.2% of
|
||||
uploads are truncated."""
|
||||
unchanged and return, mirroring strip_c2pa_boxes."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
@@ -337,7 +335,7 @@ class TestSamsungGenai:
|
||||
def test_remove_strips_post_eoi_trailer(self, tmp_path: Path):
|
||||
"""Regression: Galaxy AI appends ``PhotoEditor_Re_Edit_Data`` as a trailer AFTER
|
||||
the JPEG EOI, so the verbatim scan copy in the lossless strip carried it through
|
||||
(survived 0/8 on the corpus). The strip now truncates a Samsung AI trailer at EOI;
|
||||
in compatibility testing. The strip now truncates a Samsung AI trailer at EOI;
|
||||
pixels stay bit-identical and a non-Samsung trailer is preserved."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -435,7 +433,7 @@ class TestGetAiMetadata:
|
||||
assert get_ai_metadata(path) == {}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/samples not present")
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/fixtures/provenance not present")
|
||||
class TestGetAiMetadataRealSample:
|
||||
"""get_ai_metadata surfaces the consolidated C2PA fields on real images."""
|
||||
|
||||
@@ -480,7 +478,7 @@ def test_bare_algorithmic_media_not_flagged_ai(tmp_path: Path):
|
||||
# ── SynthID-source detection (metadata proxy) ────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/samples not present")
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/fixtures/provenance not present")
|
||||
class TestSynthIDSource:
|
||||
"""SynthID detection via the C2PA companion manifest.
|
||||
|
||||
@@ -728,7 +726,7 @@ class TestExifGenerator:
|
||||
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/
|
||||
# NovelAI stamps its generator in PNG tEXt Software/Source/
|
||||
# Title chunks, not EXIF -- the PNG-text path must catch it.
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
@@ -741,7 +739,7 @@ class TestExifGenerator:
|
||||
assert exif_generator(path) == "NovelAI"
|
||||
|
||||
def test_reve_software_detected(self, tmp_path: Path):
|
||||
# Reve Image (mined corpus) writes EXIF Software="reve.com".
|
||||
# Reve Image writes EXIF Software="reve.com".
|
||||
path = _img_with_software(tmp_path, "jpg", "reve.com")
|
||||
assert exif_generator(path) == "reve.com"
|
||||
|
||||
@@ -751,7 +749,7 @@ class TestExifGenerator:
|
||||
assert exif_generator(path) is None
|
||||
|
||||
def test_aphrodite_make_detected(self, tmp_path: Path):
|
||||
# Aphrodite AI (mined corpus) writes EXIF Make="Aphrodite AI".
|
||||
# Aphrodite AI 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)
|
||||
@@ -779,7 +777,7 @@ class TestExifGenerator:
|
||||
|
||||
def test_apple_clean_up_removal_parity(self, tmp_path: Path):
|
||||
# The "Apple Photos Clean Up" credit is an AI-edit VALUE under a
|
||||
# non-AI key, so removal must drop it by value too (corpus 2026-07-23).
|
||||
# non-AI key, so removal must drop it by value too.
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
@@ -851,7 +849,7 @@ class TestXaiSignature:
|
||||
assert xai_signature(_grok_jpeg(tmp_path)) is True
|
||||
|
||||
def test_real_grok_sample_detected(self):
|
||||
# Real committed Grok download (data/samples/grok-1.jpg); the EXIF
|
||||
# Real committed Grok download (data/fixtures/provenance/grok-1.jpg); the EXIF
|
||||
# Signature + UUID-Artist pair is the only AI signal it carries.
|
||||
assert xai_signature(SAMPLES_DIR / "grok-1.jpg") is True
|
||||
|
||||
@@ -1135,7 +1133,7 @@ class TestAIGCLabel:
|
||||
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. ``marker`` selects the APP segment
|
||||
(default APP9; the real corpus also uses APP11)."""
|
||||
(default APP9; APP11 is covered by a separate regression)."""
|
||||
p = tmp_path / "aigc_bare.jpg"
|
||||
Image.new("RGB", (32, 32)).save(p)
|
||||
raw = p.read_bytes()
|
||||
@@ -1168,7 +1166,7 @@ class TestAIGCLabel:
|
||||
assert not has_ai_metadata(out)
|
||||
|
||||
def test_remove_strips_bare_aigc_in_app11(self, tmp_path: Path):
|
||||
"""Regression (real corpus, 19/27 survivors): the bare ``AIGC{...}`` blob lives
|
||||
"""Regression: the bare ``AIGC{...}`` blob lives
|
||||
in APP11 (0xEB) on many China gens. That marker's branch in _jpeg_app_carries_ai
|
||||
only checked for a C2PA/JUMBF manifest and RETURNED, so the AIGC blob slipped past
|
||||
the generic check -> survived the strip. The specific checks must fall through to
|
||||
@@ -1183,7 +1181,7 @@ class TestAIGCLabel:
|
||||
assert not has_ai_metadata(out)
|
||||
|
||||
def test_remove_strips_aigc_in_png_text_chunk(self, tmp_path: Path):
|
||||
"""Regression (real corpus, 2 survivors): the TC260 ``{"AIGC":{...}}`` block in a
|
||||
"""Regression: the TC260 ``{"AIGC":{...}}`` block in a
|
||||
STANDARD PNG text chunk (Description) -- _is_ai_key keeps that key, so removal
|
||||
must also drop it on the VALUE carrying an AIGC block."""
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
+2
-2
@@ -152,10 +152,10 @@ class TestC2PA:
|
||||
assert not has_c2pa_metadata(tmp_jpeg_path)
|
||||
|
||||
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "samples"
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/samples not present")
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/fixtures/provenance not present")
|
||||
class TestC2PARealSamples:
|
||||
"""Parser behavior on real committed C2PA images."""
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ class TestDetectAndMask:
|
||||
|
||||
def test_anchor_window_rejects_off_corner_match(self):
|
||||
"""The raw-gray front-end false-fires on text-like structure ANYWHERE in
|
||||
the box (37/42009 outside-cohort frames in the 2026-07-22 sweep); the
|
||||
the box during compatibility testing; the
|
||||
anchor window is what keeps it about THIS mark. A composed mark placed
|
||||
off the measured corner anchor must NOT be detected."""
|
||||
eng = RunningHubEngine()
|
||||
|
||||
@@ -15,9 +15,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
import synthid_corpus
|
||||
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "samples"
|
||||
CORPUS_DIR = Path(__file__).resolve().parent.parent / "data" / "synthid_corpus"
|
||||
QUALITY_SET = CORPUS_DIR / "quality_sets" / "full_pipeline_quality_2026-07-25.csv"
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"
|
||||
CORPUS_DIR = Path(__file__).resolve().parent.parent / "data" / "synthid"
|
||||
QUALITY_SET = CORPUS_DIR / "full-pipeline-quality.csv"
|
||||
|
||||
EXPECTED_QUALITY_SOURCE_FILENAMES = {
|
||||
"ChatGPT Image May 30, 2026, 10_31_08 AM.png",
|
||||
@@ -47,7 +47,15 @@ def test_reusable_quality_set_has_expected_inputs_and_valid_hashes() -> None:
|
||||
assert hashlib.sha256(corpus_path.read_bytes()).hexdigest() == row["sha256"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/samples not present")
|
||||
def test_manifest_matches_canonical_originals() -> None:
|
||||
rows = _manifest_rows(CORPUS_DIR)
|
||||
originals = {path.name for path in (CORPUS_DIR / "originals").iterdir() if path.is_file()}
|
||||
|
||||
assert {row["filename"] for row in rows} == originals
|
||||
assert len({row["sha256"] for row in rows}) == len(rows)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/fixtures/provenance not present")
|
||||
class TestIngest:
|
||||
def test_ingest_openai_flags_synthid_metadata(self, tmp_path: Path):
|
||||
runner = CliRunner()
|
||||
@@ -64,8 +72,9 @@ class TestIngest:
|
||||
assert row["synthid_metadata"] == "yes"
|
||||
assert int(row["width"]) > 0
|
||||
assert int(row["height"]) > 0
|
||||
# The copied file lands under images/pos/ with a sha-prefixed name.
|
||||
assert (tmp_path / "images" / "pos" / row["filename"]).exists()
|
||||
assert row["filename"] == "chatgpt-1.png"
|
||||
# Every label shares one canonical originals/ directory.
|
||||
assert (tmp_path / "originals" / row["filename"]).exists()
|
||||
|
||||
def test_ingest_firefly_not_flagged(self, tmp_path: Path):
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Policy-level tests for the shared text-mark engine config.
|
||||
|
||||
These assert TUNING that was set by corpus measurement, not algorithm behaviour --
|
||||
These assert calibrated TUNING, not algorithm behaviour --
|
||||
they exist so a future edit cannot silently revert a calibrated constant back to a
|
||||
value that was measured to be wrong. The measurements themselves live in
|
||||
`docs/module-internals.md` and in the comment at
|
||||
@@ -13,11 +13,9 @@ class TestRivalMargin:
|
||||
|
||||
Doubao "豆包AI生成" and Jimeng "★ 即梦AI" both sit bottom-right in near-white CJK
|
||||
and survive binarization as similar blobs, so an absolute NCC gate cannot tell
|
||||
them apart -- 33 of jimeng's 68 false additions were Doubao marks. Measured
|
||||
separability scoring both templates on the SAME blob (n=40 jimeng / 75 doubao):
|
||||
absolute ncc_jimeng 0.96, ncc_jimeng MINUS ncc_doubao 0.99. Corpus effect of the
|
||||
margin gate: jimeng precision 38% -> 63% with genuine detections unchanged at 40
|
||||
(false fires 65 -> 23).
|
||||
them apart because many Jimeng false additions were Doubao marks. Calibration
|
||||
showed that the relative template margin separates them without reducing genuine
|
||||
Jimeng detections.
|
||||
"""
|
||||
|
||||
def test_jimeng_competes_against_doubao(self):
|
||||
@@ -54,12 +52,8 @@ class TestRivalMargin:
|
||||
class TestPerMarkProvenanceRelaxation:
|
||||
"""The provenance NCC relaxation is PER MARK, not one shared multiplier.
|
||||
|
||||
Corpus-measured 2026-07-18 on the default `auto` path (4417 unique TC260
|
||||
carriers, blind hand-label, two-sided control): the single shared 0.7 ran at
|
||||
76% precision on doubao but 17% on jimeng, because jimeng's relaxed silhouette
|
||||
keys on "text in the bottom-right corner" rather than the wordmark -- 33 of its
|
||||
68 false additions were DOUBAO marks. Full table at
|
||||
`_text_mark_engine._DEFAULT_PROVENANCE_NCC_FACTOR`.
|
||||
Calibration showed that one shared relaxation factor was too permissive for
|
||||
Jimeng because its relaxed silhouette confuses other bottom-right text marks.
|
||||
"""
|
||||
|
||||
|
||||
@@ -68,12 +62,10 @@ class TestScaleBasis:
|
||||
|
||||
Every tuned fraction was calibrated on PORTRAIT captures, where width and short
|
||||
side coincide, so the basis was never exercised until landscape inputs were
|
||||
measured. Corpus-measured 2026-07-18 (2572 unique TC260 carriers): doubao
|
||||
detection was portrait 60% / square 41% / **landscape 0% of 435** -- a width-scaled
|
||||
box is inflated by the aspect ratio on a wide image and the glyph never lands in
|
||||
it. A short-side basis recovered 56% of the previously-undetected landscape set.
|
||||
The same switch broke JIMENG (labelled landscape positives 13/13 -> 0/13), whose
|
||||
wordmark tracks the width -- hence per-mark, not a house rule.
|
||||
measured. Calibration showed that a width-scaled Doubao box is inflated by the
|
||||
aspect ratio on a wide image and can miss the glyph. A short-side basis recovered
|
||||
the affected landscape cases. The same switch broke Jimeng, whose wordmark tracks
|
||||
the width, hence per-mark rather than a house rule.
|
||||
"""
|
||||
|
||||
def test_doubao_scales_with_the_short_side(self):
|
||||
@@ -89,8 +81,7 @@ class TestScaleBasis:
|
||||
assert jimeng_engine._CONFIG.scale_basis == "width"
|
||||
|
||||
def test_samsung_keeps_width_because_it_is_unmeasured(self):
|
||||
"""1 addition corpus-wide, so there is no evidence either way; an unmeasured
|
||||
change is not an improvement."""
|
||||
"""There is no calibration evidence for changing this basis."""
|
||||
from remove_ai_watermarks import samsung_engine
|
||||
|
||||
assert samsung_engine._CONFIG.scale_basis == "width"
|
||||
@@ -133,8 +124,7 @@ class TestTophatFrontend:
|
||||
the gate). The `tophat` front-end never binarizes: the saturation/luma gates become
|
||||
weights, and the response is max-normalized so the score is contrast-invariant.
|
||||
|
||||
Corpus effect on the 240-image unbiased recall sample: doubao recall 89% -> 92% at
|
||||
an unchanged 99% precision.
|
||||
Calibration showed improved Doubao recall without reducing precision.
|
||||
"""
|
||||
|
||||
def test_doubao_uses_the_continuous_frontend(self):
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"""A mark the `tophat` front-end DETECTS must also be MASKABLE.
|
||||
|
||||
Corpus-found 2026-07-20: Doubao's detection moved to the continuous `tophat` front-end
|
||||
(which does not binarize, and that is where its recall 89% -> 92% came from), but the
|
||||
Doubao's detection moved to the continuous `tophat` front-end, which does not
|
||||
binarize, but the
|
||||
removal mask still came from the BINARIZED glyph blob. A mark faint enough to be found
|
||||
only by the continuous response therefore produced an empty binary blob, `localize`
|
||||
returned mask=None, and `remove()` was a silent no-op: `identify` reported
|
||||
`visible_doubao` while `visible` said "no visible mark" on the same file. Measured on the
|
||||
full corpus parity sweep: 57 of 60 sampled still-detected Doubao marks were untouched
|
||||
no-ops, ~8% of all Doubao detections.
|
||||
`visible_doubao` while `visible` said "no visible mark" on the same file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -99,11 +97,11 @@ class TestFaintMarkIsMaskable:
|
||||
class TestFaintMaskStaysTight:
|
||||
"""The faint fallback must cover the mark WITHOUT filling the whole corner.
|
||||
|
||||
Corpus-found 2026-07-20: the first version of the fallback thresholded the continuous
|
||||
The first version of the fallback thresholded the continuous
|
||||
response at 0.5, but `tophat_response` returns uint8 0..255 -- so it selected every
|
||||
non-zero pixel, not "half the peak" as its comment claimed. On 12 of 12 real frames
|
||||
taking this path the resulting box covered 100% of the corner ROI, so removal inpainted
|
||||
the entire corner instead of the glyph. Parity could not see it: parity asks whether
|
||||
non-zero pixel, not "half the peak" as its comment claimed. On compatibility frames
|
||||
taking this path the resulting box covered the corner ROI, so removal inpainted the
|
||||
entire corner instead of the glyph. Parity could not see it: parity asks whether
|
||||
the detector is clean afterwards, and a mask that fills everything passes trivially.
|
||||
The cost, not the outcome, was the defect.
|
||||
"""
|
||||
|
||||
@@ -9,7 +9,7 @@ import pytest
|
||||
|
||||
from remove_ai_watermarks import watermark_registry as reg
|
||||
|
||||
DOUBAO_SAMPLE = Path(__file__).resolve().parents[1] / "data" / "samples" / "doubao-1.png"
|
||||
DOUBAO_SAMPLE = Path(__file__).resolve().parents[1] / "data" / "fixtures" / "provenance" / "doubao-1.png"
|
||||
|
||||
|
||||
class TestCatalog:
|
||||
@@ -383,7 +383,7 @@ class TestArbiter:
|
||||
def test_weak_pill_detection_does_not_confirm_the_jimeng_wordmark(self):
|
||||
"""The pill is too false-fire-prone (~7%) to grant a sibling `confirmed` trust.
|
||||
|
||||
Corpus-measured defect (2026-07-18): a pill false fire on clean non-ByteDance
|
||||
Regression: a pill false fire on clean non-ByteDance
|
||||
content confirmed jimeng, relaxing its NCC gate 0.45 -> 0.3825; jimeng then
|
||||
false-fired, and _keep_pill's wordmark arm removed the pill UNRESTRICTED,
|
||||
skipping the flatness guard. Closed loop on the default `auto` path.
|
||||
|
||||
Reference in New Issue
Block a user