Merge remote-tracking branch 'origin/main' into docs/arxiv-paper-review

# Conflicts:
#	.claude/settings.json
#	docs/installation.md
#	docs/supported-signals.md
#	docs/synthid.md
#	docs/verification-plan.md
#	docs/watermarking-landscape.md
#	pyproject.toml
#	src/remove_ai_watermarks/identify.py
#	uv.lock
This commit is contained in:
Victor Kuznetsov
2026-08-26 12:39:05 -07:00
84 changed files with 6888 additions and 1666 deletions
+22 -4
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import struct
import zlib
from pathlib import Path
import cv2
import numpy as np
@@ -10,9 +12,6 @@ import pytest
from PIL import Image
from PIL.PngImagePlugin import PngInfo
if TYPE_CHECKING:
from pathlib import Path
@pytest.fixture
def clean_photo(tmp_path: Path) -> Path:
@@ -76,3 +75,22 @@ def tmp_clean_png(tmp_path: Path) -> Path:
path = tmp_path / "clean.png"
img.save(path, pnginfo=pnginfo)
return path
@pytest.fixture
def tampered_chatgpt_png(tmp_path: Path) -> Path:
"""Add valid PNG metadata after signing so the C2PA asset hash no longer matches."""
source = Path(__file__).resolve().parents[1] / "data" / "fixtures" / "provenance" / "chatgpt-1.png"
data = source.read_bytes()
iend = data.rfind(b"\x00\x00\x00\x00IEND")
assert iend >= 0
kind = b"tEXt"
payload = b"c2pa-test\x00benign post-signing metadata mutation"
chunk = (
struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF)
)
target = tmp_path / "tampered-chatgpt.png"
target.write_bytes(data[:iend] + chunk + data[iend:])
assert target.read_bytes() != data
return target
+10
View File
@@ -256,6 +256,7 @@ class TestInvisibleOptionsMirrorTheEngine:
tile=True,
tile_size=768,
tile_overlap=64,
text_manifest=tmp_path / "verified-lines.json",
)
seen: dict[str, object] = {}
@@ -522,6 +523,15 @@ class TestSourceEvidenceHolder:
assert holder.visible_provenance() == api.visible_provenance(DOUBAO)
assert holder.has_invisible_target() == identify.has_invisible_target(DOUBAO)
def test_holder_preserves_invalid_c2pa_removal_hint(self, tampered_chatgpt_png):
from remove_ai_watermarks import api, identify
holder = api._SourceEvidence(tampered_chatgpt_png)
assert identify.identify(tampered_chatgpt_png, check_visible=False).ai_from_metadata is False
assert holder.has_invisible_target() is True
assert holder.has_invisible_target() == identify.has_invisible_target(tampered_chatgpt_png)
def test_extraction_failure_fails_safe_in_both_directions(self, monkeypatch, tmp_path):
"""No provenance means no relaxation; an unknown invisible target means SCRUB.
Leaving a watermark on a paid removal is worse than over-regenerating."""
+5
View File
@@ -629,11 +629,13 @@ class TestMetadataCommand:
result = runner.invoke(main, ["metadata", str(tmp_clean_png), "--check"])
assert result.exit_code == 0
assert "No AI metadata" in result.output
assert "not the same as 'clean'" in result.output
def test_metadata_check_ai(self, runner, tmp_png_with_ai_metadata):
result = runner.invoke(main, ["metadata", str(tmp_png_with_ai_metadata), "--check"])
assert result.exit_code == 0
assert "AI metadata detected" in result.output
assert "not the same as 'clean'" not in result.output
def test_metadata_remove(self, runner, tmp_png_with_ai_metadata, tmp_path):
output = tmp_path / "stripped.png"
@@ -649,6 +651,7 @@ class TestMetadataCommand:
)
assert result.exit_code == 0
assert "stripped" in result.output
assert "not the same as 'clean'" in result.output
def test_metadata_remove_reports_failure_when_the_strip_was_a_no_op(self, runner, tmp_path):
"""A file PIL cannot decode is copied through UNCHANGED by the fail-safe.
@@ -723,6 +726,8 @@ class TestIdentifyCommand:
result = runner.invoke(main, ["identify", str(sample), "--no-visible"])
assert result.exit_code == 0
assert "AI-generated (fully synthetic)" in result.output
assert "C2PA validation: integrity=valid, signature=valid" in result.output
assert "signer trust=untrusted, signer validity=expired" in result.output
def test_identify_json_is_valid(self, runner, tmp_png_with_ai_metadata):
result = runner.invoke(main, ["identify", str(tmp_png_with_ai_metadata), "--no-visible", "--json"])
+10
View File
@@ -20,3 +20,13 @@ def test_sdist_has_explicit_public_boundary() -> None:
assert _array_values(sdist_config, "include") == {"/src", "/LICENSE", "/README.md", "/pyproject.toml"}
assert {"/data", "/tmp", "/.sc"} <= _array_values(sdist_config, "exclude")
def test_release_guide_names_every_published_surface() -> None:
"""A release checklist must not silently forget a downstream surface."""
root = Path(__file__).resolve().parents[1]
guide = (root / "docs" / "release-and-distribution.md").read_text(encoding="utf-8")
for surface in ("PyPI", "Homebrew", "Hugging Face Space", "ComfyUI Registry"):
assert surface in guide
assert re.search(r"Conda is\s+not a supported publishing surface", guide)
+10 -2
View File
@@ -20,7 +20,7 @@ import pytest
_SCRIPTS = Path(__file__).resolve().parent.parent / "scripts"
def _load_assign():
def _load_module():
# fidelity_metrics is a standalone PEP723 script, not an installed module; load it by
# path with scripts/ on sys.path so its `_plain_console` shim import resolves.
sys.path.insert(0, str(_SCRIPTS))
@@ -35,7 +35,15 @@ def _load_assign():
pytest.skip(f"fidelity_metrics import deps missing: {exc}")
finally:
sys.path.remove(str(_SCRIPTS))
return mod.assign_faces_one_to_one
return mod
def _load_assign():
return _load_module().assign_faces_one_to_one
def test_cer_remains_case_sensitive() -> None:
assert _load_module()._cer("A", "a") == 1.0
def test_distinct_faces_match_nearest() -> None:
+244 -6
View File
@@ -16,6 +16,8 @@ from unittest.mock import patch
import pytest
from remove_ai_watermarks._internal.c2pa import c2pa_info_from_manifest_store
from remove_ai_watermarks._internal.constants import C2PA_AI_VENDORS, C2PA_CLAIM_GENERATOR_PLATFORMS
from remove_ai_watermarks.identify import (
ProvenanceEvidence,
ProvenanceReport,
@@ -39,6 +41,147 @@ SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "pr
class TestProvenanceEvidence:
def test_exact_ai_claim_generator_can_assert_ai_without_source_type(self, tmp_path: Path):
path = tmp_path / "firefly.png"
info = {
"has_c2pa": True,
"issuer": "Adobe",
"claim_generator": "Adobe_Firefly",
"ai_tool": "Firefly",
"c2pa_identity_ai": True,
"c2pa_validation_source": "reader",
"c2pa_validation_state": "Valid",
"c2pa_integrity": "valid",
"c2pa_signature": "valid",
"c2pa_signer_trust": "untrusted",
"c2pa_signer_validity": "valid",
"c2pa_validation_codes": ["assertion.dataHash.match", "claimSignature.validated"],
}
evidence = ProvenanceEvidence(
path=path,
c2pa_info=info,
ai_metadata={},
scan=b"jumb c2pa Adobe_Firefly",
iptc_ai_system=None,
aigc_label=None,
exif_generator=None,
xai_signature=False,
huggingface_job=None,
samsung_genai=None,
)
report = identify_from_evidence(evidence)
assert report.is_ai_generated is True
# Intact binding and signature. The signer is not anchored, which is a missing
# input here (no trust bundle ships), not a finding against the credential.
assert report.confidence == "high"
assert report.platform == "Adobe Firefly"
def test_revoked_signing_credential_is_disqualifying(self, tmp_path: Path):
"""A credential the issuer disowned cannot establish origin.
Revocation arrives on its own dimension, not as a binding or signature failure,
so a check that reads only those two returned an AI verdict off a dead cert with
an empty ``integrity_clashes`` -- quieter than a hash mismatch on the same file.
The evidence comes from :func:`c2pa_info_from_manifest_store`, not a hand-written
dict of what it is believed to emit, so the assertion follows the producer when
its contract changes.
"""
path = tmp_path / "revoked.png"
info = c2pa_info_from_manifest_store(
{
"active_manifest": "created",
"validation_results": {
"activeManifest": {
"success": [
{"code": "assertion.dataHash.match"},
{"code": "claimSignature.validated"},
],
"failure": [{"code": "signingCredential.ocsp.revoked"}],
}
},
"manifests": {
"created": {
"signature_info": {"issuer": "OpenAI"},
"assertions": [
{
"label": "c2pa.actions.v2",
"data": {
"actions": [
{
"action": "c2pa.created",
"digitalSourceType": "trainedAlgorithmicMedia",
}
]
},
}
],
}
},
}
)
assert info["c2pa_signer_validity"] == "invalid"
evidence = ProvenanceEvidence(
path=path,
c2pa_info=info,
ai_metadata={},
scan=b"jumb c2pa OpenAI trainedAlgorithmicMedia",
iptc_ai_system=None,
aigc_label=None,
exif_generator=None,
xai_signature=False,
huggingface_job=None,
samsung_genai=None,
)
report = identify_from_evidence(evidence)
assert report.is_ai_generated is None
assert report.platform is None
assert report.confidence == "none"
assert any("revoked" in clash for clash in report.integrity_clashes)
def test_anchored_signer_is_also_high_confidence(self, tmp_path: Path):
path = tmp_path / "validated.png"
info = {
"has_c2pa": True,
"issuer": "OpenAI",
"source_type": "trainedAlgorithmicMedia (AI-generated)",
"ai_source_kind": "generated",
"c2pa_validation_source": "reader",
"c2pa_validation_state": "Trusted",
"c2pa_integrity": "valid",
"c2pa_signature": "valid",
"c2pa_signer_trust": "trusted",
"c2pa_signer_validity": "valid",
"c2pa_validation_codes": [
"assertion.dataHash.match",
"claimSignature.validated",
"signingCredential.trusted",
],
}
evidence = ProvenanceEvidence(
path=path,
c2pa_info=info,
ai_metadata={},
scan=b"jumb c2pa OpenAI trainedAlgorithmicMedia",
iptc_ai_system=None,
aigc_label=None,
exif_generator=None,
xai_signature=False,
huggingface_job=None,
samsung_genai=None,
)
report = identify_from_evidence(evidence)
assert report.is_ai_generated is True
assert report.confidence == "high"
assert report.platform == "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)"
assert not any("not anchored" in caveat for caveat in report.caveats)
def test_external_metadata_record_builds_equivalent_evidence(self, tmp_path: Path):
path = tmp_path / "external.jpg"
signature = "A" * 64
@@ -428,12 +571,14 @@ class TestIdentifySamsungGalaxy:
path.write_bytes(b"\xff\xd8\xff\xe1jumbc2pa" + blob + b"\xff\xd9")
return path
def test_galaxy_trained_source_is_high_ai(self, tmp_path: Path):
def test_galaxy_trained_source_is_unverified_ai(self, tmp_path: Path):
path = self._jpeg(tmp_path, "s25.jpg", b"Samsung Galaxy Galaxy S25 c2pa-rs trainedAlgorithmicMedia")
r = identify(path, check_visible=False, check_invisible=False)
assert r.is_ai_generated is True
assert r.confidence == "high"
assert r.confidence == "medium"
assert r.platform == "Samsung Galaxy (C2PA)"
assert r.c2pa_validation is None
assert any("without cryptographic validation" in caveat for caveat in r.caveats)
assert r.integrity_clashes == [] # device cert + AI source-type is legitimate, not a clash
def test_galaxy_genai_only_is_medium_ai(self, tmp_path: Path):
@@ -546,9 +691,61 @@ class TestIdentifyRealSamples:
# both invisible/metadata targets, so the diffusion scrub should run.
assert has_invisible_target(SAMPLES_DIR / "chatgpt-1.png") is True
assert has_invisible_target(SAMPLES_DIR / "mj-1.png") is True
# ai_from_metadata mirrors confidence == "high" and backs the helper.
# ai_from_metadata records scrub intent independently of the confidence string.
assert identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False).ai_from_metadata is True
def test_untrusted_but_intact_c2pa_is_high_confidence_with_a_caveat(self):
report = identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False, check_invisible=False)
assert report.is_ai_generated is True
assert report.confidence == "high"
assert report.ai_from_metadata is True
assert report.c2pa_validation is not None
assert report.c2pa_validation["source"] == "reader"
assert report.c2pa_validation["state"] == "Invalid"
assert report.c2pa_validation["integrity"] == "valid"
assert report.c2pa_validation["signature"] == "valid"
assert report.c2pa_validation["signer_trust"] == "untrusted"
assert report.c2pa_validation["signer_validity"] == "expired"
assert "assertion.dataHash.match" in report.c2pa_validation["codes"]
# What was not established is said, not folded into the confidence string.
assert any("never checked against one" in caveat for caveat in report.caveats)
assert any("only the signing time is unproven" in caveat for caveat in report.caveats)
def test_no_committed_fixture_reports_a_trusted_signer(self):
"""The reachability guard for :func:`_c2pa_credential_level`.
The SDK ships no production trust anchors, so ``signingCredential.trusted``
appears in no default installation. Gating high confidence on it made that branch
dead in production for every vendor while a hand-built dict kept it green in the
suite. These fixtures are the producer; if one ever comes back trusted, a bundle
got configured and the confidence mapping needs re-reading, not this assertion
deleted.
"""
checked = 0
for path in sorted(SAMPLES_DIR.iterdir()):
report = identify(path, check_visible=False, check_invisible=False)
if report.c2pa_validation is None:
continue
checked += 1
assert report.c2pa_validation["signer_trust"] != "trusted"
if report.c2pa_validation["integrity"] == "valid" and report.c2pa_validation["signature"] == "valid":
assert report.confidence == "high", path.name
assert checked >= 3
def test_hash_mismatch_does_not_confirm_origin_but_keeps_scrub_fail_safe(self, tampered_chatgpt_png: Path):
report = identify(tampered_chatgpt_png, check_visible=False, check_invisible=False)
assert report.is_ai_generated is None
assert report.platform is None
assert report.confidence == "none"
assert report.ai_source_kind is None
assert report.ai_from_metadata is False
assert report.c2pa_validation is not None
assert report.c2pa_validation["integrity"] == "invalid"
assert any("dataHash.mismatch" in clash for clash in report.integrity_clashes)
assert has_invisible_target(tampered_chatgpt_png) is True
def test_has_invisible_target_false_on_clean_photo(self, clean_photo: Path):
# No detectable invisible signal -> skip the scrub (do not degrade a clean image).
assert has_invisible_target(clean_photo) is False
@@ -566,13 +763,13 @@ class TestHasInvisibleTargetFailSafe:
"""The scrub gate fails SAFE: when a detector errors, it runs the removal."""
def test_detector_error_defaults_to_run(self, tmp_path: Path):
# If identify raises (a detector crash), the gate must return True so the
# If evidence evaluation raises (a detector crash), the gate must return True so the
# caller still attempts removal -- leaving a watermark on a paid removal is
# worse than over-regenerating. (Garbage bytes do NOT raise; identify returns
# a clean None verdict there, so that path correctly skips -- see below.)
bad = tmp_path / "x.png"
bad.write_bytes(b"not image bytes")
with patch("remove_ai_watermarks.identify.identify", side_effect=RuntimeError("boom")):
with patch("remove_ai_watermarks.identify._identify_from_evidence", side_effect=RuntimeError("boom")):
assert has_invisible_target(bad) is True
def test_unreadable_bytes_are_not_a_target(self, tmp_path: Path):
@@ -1038,6 +1235,39 @@ class TestIdentifySoftBinding:
assert any("Digimarc" in w for w in r.watermarks)
assert any(s.name == "soft_binding" for s in r.signals)
def test_invismark_signal_lists_signed_watermark_id(self, tmp_path: Path):
watermark_id = "83424621-03cb-40e3-9808-a9fae837156d"
record = {
"c2pa_store": {
"active_manifest": "paint",
"manifests": {
"paint": {
"assertions": [
{
"label": "c2pa.soft-binding",
"data": {
"alg": "com.microsoft.invismark.1",
"blocks": [{"scope": "the entire image", "value": watermark_id}],
},
}
]
}
},
}
}
evidence = evidence_from_metadata_record(record, path=tmp_path / "paint.png")
report = identify_from_evidence(evidence)
assert evidence.ai_metadata["soft_binding_value"] == watermark_id
signal = next(signal for signal in report.signals if signal.name == "soft_binding")
assert "com.microsoft.invismark.1" in signal.detail
assert watermark_id in signal.detail
invismark = next(signal for signal in report.signals if signal.name == "invismark")
assert "com.microsoft.invismark.1" in invismark.detail
assert watermark_id in invismark.detail
assert any("may remain in the pixels" in caveat for caveat in report.caveats)
class TestIdentifyIptcAi:
"""IPTC 2025.1 AISystemUsed drives an AI verdict + platform attribution."""
@@ -1195,12 +1425,20 @@ class TestVendorOf:
def test_registered_vendors_normalize(self):
# Regression: these registered C2PA vendors returned None, so their claims never
# entered clash detection (a coverage hole). They now normalize to one origin.
assert _vendor_of("ByteDance (Doubao / Jimeng / Volcano Engine)") == "ByteDance"
assert _vendor_of("Microsoft (Copilot / Designer)") == "Microsoft"
assert _vendor_of("Copilot") == "Microsoft"
assert _vendor_of("ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)") == "ByteDance"
assert _vendor_of("Dreamina/1.2") == "ByteDance"
assert _vendor_of("Canva (Magic Media)") == "Canva"
assert _vendor_of("Black Forest Labs (FLUX)") == "Black Forest Labs"
assert _vendor_of("Eleven Labs Inc.") == "ElevenLabs"
def test_bytedance_issuers_share_one_platform(self):
expected = "ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)"
platforms = {vendor.platform for vendor in C2PA_AI_VENDORS if vendor.needle == "ByteDance"}
assert platforms == {expected}
assert ("dreamina", expected) in C2PA_CLAIM_GENERATOR_PLATFORMS
class TestIntegrityClashesHelper:
def test_two_ai_vendors_clash(self):
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
SCRIPT = Path(__file__).parents[1] / "scripts/infer_text_lines.py"
SPEC = importlib.util.spec_from_file_location("infer_text_lines", SCRIPT)
assert SPEC is not None
assert SPEC.loader is not None
module = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = module
SPEC.loader.exec_module(module)
def test_choose_language_prefers_observed_unicode_script() -> None:
probes = {"en": ("gibberish", 0.9), "ru": ("пример", 0.9), "ch": ("example", 0.9)}
assert module.choose_language(probes) == "ru"
probes["ch"] = ("示例", 0.9)
assert module.choose_language(probes) == "ch"
def test_stable_recognition_requires_agreement_and_confidence() -> None:
assert module.stable_recognition([("Sample text", 0.9), ("sample text", 0.95)]) == "Sample text"
assert module.stable_recognition([("Sample", 0.9), ("Simple", 0.95)]) is None
assert module.stable_recognition([("Sample", 0.8), ("Sample", 0.95)]) is None
+90
View File
@@ -43,6 +43,96 @@ class TestInvisibleEngineInit:
assert engine._preload_kwargs == {"global_only": True}
class TestVerifiedTextMode:
"""The experimental mode must fail before loading models on unmeasured inputs."""
@staticmethod
def _engine(profile: str = "qwen-zimage") -> InvisibleEngine:
engine = object.__new__(InvisibleEngine)
engine._progress_callback = None
engine._remover = SimpleNamespace(model_profile=profile)
return engine
def test_rejects_incompatible_pipeline_options(self, tmp_path):
import pytest
manifest = tmp_path / "manifest.json"
manifest.write_text("{}", encoding="utf-8")
cases = (
("sdxl-zimage", {}, "qwen-zimage"),
("qwen-zimage", {"max_resolution": 1024}, "max-resolution 0"),
("qwen-zimage", {"humanize": 1.0}, "humanize=0"),
("qwen-zimage", {"adaptive_polish": True}, "polish disabled"),
)
for profile, kwargs, message in cases:
with pytest.raises(ValueError, match=message):
self._engine(profile).remove_watermark(
tmp_path / "unused.png",
text_manifest=manifest,
**kwargs,
)
def test_rejects_fidelity_anchor_without_manifest(self, tmp_path):
import pytest
with pytest.raises(ValueError, match="fidelity_anchor requires a text manifest"):
self._engine().remove_watermark(
tmp_path / "unused.png",
fidelity_anchor=True,
)
def test_loads_and_forwards_verified_manifest(self, tmp_path, monkeypatch):
import json
from remove_ai_watermarks import region_eraser
from remove_ai_watermarks._internal.text_restoration import source_pixel_sha256
source = tmp_path / "source.png"
output = tmp_path / "output.png"
image = Image.new("RGB", (48, 32), (10, 20, 30))
image.save(source)
manifest = tmp_path / "manifest.json"
manifest.write_text(
json.dumps(
{
"schema_version": 1,
"verified": True,
"source_pixel_sha256": source_pixel_sha256(image),
"width": 48,
"height": 32,
"lines": [{"box": [8, 8, 40, 24], "text": "Exact", "script": "alphabetic"}],
}
),
encoding="utf-8",
)
seen = {}
def fake_remove(**kwargs):
seen.update(kwargs)
Image.open(kwargs["image_path"]).save(kwargs["output_path"])
return kwargs["output_path"]
engine = self._engine()
engine._remover.remove_watermark = fake_remove
monkeypatch.setattr(region_eraser, "lama_available", lambda: True)
engine.remove_watermark(source, output, text_manifest=manifest)
assert seen["text_manifest"].lines[0].text == "Exact"
# Leak-safe default since 0.27.1: the global 15% donor blend is OFF unless
# explicitly requested (measured to return detector-visible OpenAI SynthID
# on poster-scale manifests; see docs/text-protection-research.md).
assert seen["fidelity_anchor"] is False
engine.remove_watermark(source, output, text_manifest=manifest, fidelity_anchor=True)
assert seen["fidelity_anchor"] is True
engine.remove_watermark(source, output, text_manifest=manifest, tile=True)
assert seen["tile"] is True
class TestNativeOutputSize:
"""Model-side latent-grid rounding must not change the public output size."""
+41
View File
@@ -59,6 +59,47 @@ class TestHelpers:
assert _bytes_match_frac(b"abc", b"abcd") == 0.0
class TestRaveledHaarPass:
"""The precondition that makes the decoder's flat Haar pass legitimate.
`_approximation` replaces `pywt.dwt(x, "haar", axis=1)[0]` with one
`downcoef` call over `x.ravel()`. That is exact only while the last axis is
even. Neither half of this is checked anywhere else: the equivalence is a
property of pywt's implementation that an upgrade could take away, and an
odd width produces wrong bits with no exception, since the reshape still
succeeds whenever the total length is even.
"""
@pytest.mark.parametrize("shape", [(64, 64), (7, 128), (129, 2), (2, 2), (33, 400)])
def test_matches_pywt_dwt_on_even_widths(self, shape: tuple[int, int]):
import pywt
from remove_ai_watermarks.dwt_dct import _approximation
rng = np.random.default_rng(0)
# uint8 is what the FIRST production pass receives -- `decode` builds its
# plane with cvtColor, and extractChannel and transpose preserve the
# dtype -- so a divergence in how downcoef coerces integers would be
# invisible to a float-only parametrization.
for array in (
rng.random(shape),
rng.integers(0, 256, shape).astype(np.float64),
rng.integers(0, 256, shape).astype(np.uint8),
):
expected = pywt.dwt(array, "haar", axis=1)[0]
got = _approximation(array)
assert got.shape == expected.shape
assert np.array_equal(got, expected), "downcoef diverged from dwt -- a pywt upgrade may have changed it"
def test_odd_width_raises_instead_of_returning_wrong_bits(self):
from remove_ai_watermarks.dwt_dct import _approximation
# 4x6 ravels to 24, an even total, so the reshape would happily produce
# a 4x3 array of numbers that pair across row boundaries.
with pytest.raises(RuntimeError, match="odd"):
_approximation(np.zeros((4, 6))[:, :5])
class TestDetect:
def test_in_tree_decoder_matches_upstream(self, tmp_path: Path):
from imwatermark import WatermarkDecoder
+53
View File
@@ -500,6 +500,10 @@ class TestGetAiMetadataRealSample:
assert "OpenAI" in meta["issuer"]
assert "synthid_watermark" not in meta
assert "trainedAlgorithmicMedia" in meta["source_type"]
assert meta["c2pa_integrity"] == "valid"
assert meta["c2pa_signature"] == "valid"
assert meta["c2pa_signer_trust"] == "untrusted"
assert meta["c2pa_signer_validity"] == "expired"
@pytest.mark.parametrize(
@@ -592,6 +596,19 @@ class TestSynthIDSourceNonPng:
)
assert synthid_source(path) == "OpenAI"
def test_preextracted_c2pa_is_reused(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""A caller classifying another claim must not parse the manifest twice."""
from remove_ai_watermarks._internal import c2pa
path = self._c2pa_jpeg(tmp_path, "chatgpt-reused.jpg", b"OpenAI")
info = {"synthid_vendors": ["OpenAI"]}
def unexpected_extract(_path: Path):
pytest.fail("synthid_source reparsed pre-extracted C2PA")
monkeypatch.setattr(c2pa, "extract_c2pa_info", unexpected_extract)
assert synthid_source(path, c2pa_info=info) == "OpenAI"
def test_legacy_openai_c2pa_without_watermark_action_is_none(self, tmp_path: Path):
path = self._c2pa_jpeg(tmp_path, "legacy-chatgpt.jpg", b"OpenAI")
assert synthid_source(path) is None
@@ -819,6 +836,42 @@ class TestExifGenerator:
path = _img_with_software(tmp_path, "jpg", "Forever Editor 2.0")
assert exif_generator(path) is None
def test_luma_ai_png_text_chunks_detected(self, tmp_path: Path):
# Luma AI stamps tEXt Software="Uni-1" (model name, not a token) plus
# Source/Comment values carrying "Luma AI"; the Source value must match.
from PIL.PngImagePlugin import PngInfo
info = PngInfo()
info.add_text("Software", "Uni-1")
info.add_text("Source", "Luma AI")
info.add_text("Comment", "Generated by Luma AI's Uni-1 model (https://lumalabs.ai)")
path = tmp_path / "luma.png"
Image.new("RGB", (64, 64)).save(path, pnginfo=info)
assert exif_generator(path) == "Luma AI"
def test_luma_token_not_overmatched(self, tmp_path: Path):
# The token is "luma ai" with the space: a bare "luma" (e.g. a luma
# chart tool) must not fire.
path = _img_with_software(tmp_path, "jpg", "Luma Chart Export 2.0")
assert exif_generator(path) is None
def test_luma_removal_parity(self, tmp_path: Path):
from PIL.PngImagePlugin import PngInfo
from remove_ai_watermarks.metadata import remove_ai_metadata
info = PngInfo()
info.add_text("Software", "Uni-1")
info.add_text("Source", "Luma AI")
info.add_text("Comment", "Generated by Luma AI's Uni-1 model (https://lumalabs.ai)")
src = tmp_path / "luma.png"
Image.new("RGB", (64, 64)).save(src, pnginfo=info)
assert exif_generator(src) == "Luma AI"
out = tmp_path / "clean.png"
remove_ai_metadata(src, out)
assert exif_generator(out) is None
def test_aphrodite_make_detected(self, tmp_path: Path):
# Aphrodite AI writes EXIF Make="Aphrodite AI".
exif = piexif.dump({"0th": {piexif.ImageIFD.Make: b"Aphrodite AI"}, "Exif": {}, "GPS": {}, "1st": {}})
+187
View File
@@ -12,6 +12,9 @@ from PIL import Image
from remove_ai_watermarks._internal.c2pa import (
_parse_c2pa_chunk,
c2pa_info_from_manifest_store,
c2pa_info_has_invismark,
c2pa_info_has_removal_hint,
cbor_text_after,
extract_c2pa_chunk,
extract_c2pa_info,
@@ -153,6 +156,172 @@ class TestC2PA:
def test_c2pa_returns_false_for_non_png(self, tmp_jpeg_path):
assert not has_c2pa_metadata(tmp_jpeg_path)
def test_structured_extraction_ignores_unreachable_manifests(self):
store = {
"active_manifest": "active",
"manifests": {
"active": {
"signature_info": {"issuer": "Adobe"},
"assertions": [],
},
"unreachable": {
"signature_info": {"issuer": "OpenAI"},
"assertions": [
{
"label": "c2pa.actions.v2",
"data": {
"actions": [
{
"action": "c2pa.created",
"digitalSourceType": "trainedAlgorithmicMedia",
}
]
},
}
],
},
},
}
info = c2pa_info_from_manifest_store(store)
assert info["issuer"] == "Adobe"
assert "source_type" not in info
assert "ai_source_kind" not in info
assert "c2pa_identity_ai" not in info
def test_reachable_ingredient_claim_generator_can_assert_ai(self):
store = {
"active_manifest": "update",
"manifests": {
"update": {
"claim_generator": "c2pa-tool/0.1.0",
"ingredients": [{"active_manifest": "created"}],
"assertions": [],
},
"created": {
"claim_generator": "Dreamina/7.5.0",
"assertions": [],
},
},
}
info = c2pa_info_from_manifest_store(store)
assert info["ai_tool"] == "Dreamina"
assert info["c2pa_identity_ai"] is True
def test_structured_invismark_exposes_algorithm_and_watermark_id(self):
watermark_id = "83424621-03cb-40e3-9808-a9fae837156d"
store = {
"active_manifest": "paint",
"manifests": {
"paint": {
"assertions": [
{
"label": "c2pa.soft-binding",
"data": {
"alg": "com.microsoft.invismark.1",
"blocks": [
{
"scope": "the entire image",
"value": watermark_id,
}
],
},
}
]
}
},
}
info = c2pa_info_from_manifest_store(store)
assert info["soft_binding"] == "Microsoft InvisMark"
assert info["soft_binding_algorithm"] == "com.microsoft.invismark.1"
assert info["soft_binding_value"] == watermark_id
def test_soft_binding_value_requires_its_algorithm(self):
store = {
"active_manifest": "broken",
"manifests": {
"broken": {
"assertions": [
{
"label": "c2pa.soft-binding",
"data": {"blocks": [{"value": "not-attributable"}]},
}
]
}
},
}
assert "soft_binding_value" not in c2pa_info_from_manifest_store(store)
def test_soft_binding_keeps_invisible_removal_fail_safe(self):
info = {"soft_binding_vendors": ["Microsoft InvisMark"]}
assert c2pa_info_has_invismark(info) is True
assert c2pa_info_has_removal_hint(info) is True
def test_content_fingerprint_does_not_trigger_invisible_removal(self):
info = {
"soft_binding": "Adobe (content fingerprint)",
"soft_binding_vendors": ["Adobe (content fingerprint)"],
}
assert c2pa_info_has_removal_hint(info) is False
@pytest.mark.parametrize(
"ingredient_failure",
[
# One exclusion rule, reached through two different dimensions: a broken
# binding and a credential the issuer disowned. The walk classified only the
# first for a while, so a revoked child manifest stayed reachable and kept
# donating its claim generator to the parent's attribution.
"assertion.dataHash.mismatch",
"signingCredential.ocsp.revoked",
],
)
def test_invalid_ingredient_does_not_taint_active_validation_or_supply_claims(self, ingredient_failure: str):
store = {
"active_manifest": "update",
"validation_results": {
"activeManifest": {
"success": [
{"code": "assertion.dataHash.match"},
{"code": "claimSignature.validated"},
],
"failure": [{"code": "signingCredential.untrusted"}],
},
"ingredientDeltas": [{"validationDeltas": {"failure": [{"code": ingredient_failure}]}}],
},
"manifests": {
"update": {
"claim_generator": "c2pa-tool/0.1.0",
"ingredients": [
{
"active_manifest": "created",
"validation_results": {"activeManifest": {"failure": [{"code": ingredient_failure}]}},
}
],
"assertions": [],
},
"created": {
"claim_generator": "Dreamina/7.5.0",
"assertions": [],
},
},
}
info = c2pa_info_from_manifest_store(store)
assert info["c2pa_integrity"] == "valid"
assert info["c2pa_signature"] == "valid"
assert info["c2pa_signer_trust"] == "untrusted"
assert "ai_tool" not in info
assert "c2pa_identity_ai" not in info
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"
CURRENT_OPENAI_SAMPLE = (
@@ -227,6 +396,22 @@ class TestC2PARealSamples:
# Structured claim generator is exact, not a CBOR-scanned best-effort.
assert info["claim_generator"] == "ChatGPT"
def test_reader_reports_intact_but_untrusted_credentials(self):
info = extract_c2pa_info(SAMPLES_DIR / "chatgpt-1.png")
assert info["c2pa_integrity"] == "valid"
assert info["c2pa_signature"] == "valid"
assert info["c2pa_signer_trust"] == "untrusted"
assert info["c2pa_signer_validity"] == "expired"
assert "assertion.dataHash.match" in info["c2pa_validation_codes"]
def test_reader_reports_post_signing_container_mutation(self, tampered_chatgpt_png):
info = extract_c2pa_info(tampered_chatgpt_png)
assert info["c2pa_integrity"] == "invalid"
assert info["c2pa_signature"] == "valid"
assert "assertion.dataHash.mismatch" in info["c2pa_validation_codes"]
def test_fallback_to_png_parser_when_reader_unavailable(self, monkeypatch):
"""With the reader disabled, the hand-rolled PNG parser still works."""
from remove_ai_watermarks._internal import c2pa
@@ -237,6 +422,8 @@ class TestC2PARealSamples:
assert "OpenAI" in info["issuer"]
assert "trainedAlgorithmicMedia" in info["source_type"]
assert "synthid_watermark" not in info
assert info["c2pa_integrity"] == "unknown"
assert info["c2pa_validation_source"] == "fallback"
class TestC2PAInjectValidation:
+8
View File
@@ -293,6 +293,14 @@ class TestReportTransport:
assert convenience == explicit
def test_c2pa_validation_survives_the_portable_record(self, tampered_chatgpt_png: Path):
direct = identify(tampered_chatgpt_png, check_visible=False, check_invisible=False)
portable = identify_metadata_record(collect_metadata_record(tampered_chatgpt_png), path=tampered_chatgpt_png)
assert portable == direct
assert portable.c2pa_validation is not None
assert portable.c2pa_validation["integrity"] == "invalid"
def test_a_webp_record_matches(tmp_path: Path):
"""RIFF has its own walk; a chunk kept or dropped wrongly shows up here."""
+15 -2
View File
@@ -5,19 +5,27 @@ from __future__ import annotations
from importlib.metadata import metadata, requires
from packaging.requirements import Requirement
from packaging.specifiers import SpecifierSet
from packaging.utils import canonicalize_name
def _requirement_names(extra: str | None = None) -> set[str]:
def _requirement_names(extra: str | None = None, *, python_version: str | None = None) -> set[str]:
selected_extra = extra or ""
environment = {"extra": selected_extra}
if python_version is not None:
environment["python_version"] = python_version
parsed = (Requirement(value) for value in requires("remove-ai-watermarks") or [])
return {
canonicalize_name(requirement.name)
for requirement in parsed
if requirement.marker is None or requirement.marker.evaluate({"extra": selected_extra})
if requirement.marker is None or requirement.marker.evaluate(environment)
}
def test_supported_python_floor_is_published():
assert SpecifierSet(metadata("remove-ai-watermarks")["Requires-Python"]) == SpecifierSet(">=3.11,<3.15")
def test_default_install_is_metadata_focused():
default = _requirement_names()
@@ -56,6 +64,11 @@ def test_file_format_and_detector_dependencies_are_independent():
assert "openai" in _requirement_names("verify")
def test_trustmark_is_limited_to_its_numpy_compatible_python_range():
assert "trustmark" in _requirement_names("trustmark", python_version="3.12")
assert "trustmark" not in _requirement_names("trustmark", python_version="3.13")
def test_extras_use_capability_names_without_legacy_aliases():
extras = set(metadata("remove-ai-watermarks").get_all("Provides-Extra") or [])
+37 -8
View File
@@ -14,6 +14,8 @@ import pytest
from remove_ai_watermarks._internal.utils import get_image_format, is_supported_format
from remove_ai_watermarks._internal.watermark_profiles import (
PROFILE_CHOICES,
QWEN_ZIMAGE_GOOGLE_STRENGTH,
QWEN_ZIMAGE_OPENAI_STRENGTH,
REMOVAL_MODULES,
SDXL_ZIMAGE_GEMINI_STRENGTH,
SDXL_ZIMAGE_OPENAI_STRENGTH,
@@ -197,23 +199,34 @@ class TestNoReembeddedWatermark:
class TestResolveStrength:
"""resolve_strength answers for sdxl-zimage and defers for qwen-zimage."""
"""resolve_strength owns the qwen-zimage and sdxl-zimage policies."""
def test_qwen_zimage_answers_from_the_resolution_curve(self):
"""The function is total: it owns both policies rather than returning None.
qwen-zimage picks strength from image area, so it takes the size. Returning
None for it would push that branch onto every caller and leave one of the two
strength policies living outside this module. The vendor is ignored here on
purpose - the curve, not the issuer, is what was calibrated.
qwen-zimage picks unknown content's strength from image area, so it takes the
size. Returning None would push that branch onto every caller and leave one of
the two strength policies living outside this module. Measured providers take
flat corpus-derived operating points instead.
"""
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154)
assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(
QWEN_ZIMAGE_OPENAI_STRENGTH
)
assert resolve_strength(None, None, "qwen-zimage", size=(600, 500)) == pytest.approx(0.084)
assert resolve_strength(None, "google", "qwen-zimage", size=(600, 500)) == QWEN_ZIMAGE_GOOGLE_STRENGTH
# The floor holds at every size, not only above the curve's top rung.
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == QWEN_ZIMAGE_GOOGLE_STRENGTH
def test_qwen_zimage_without_a_size_fails_loudly(self):
"""A missing size must not silently fall back to some vendor value."""
with pytest.raises(ValueError, match="size is required"):
resolve_strength(None, "google", "qwen-zimage")
resolve_strength(None, "openai", "qwen-zimage")
@pytest.mark.parametrize(("vendor", "expected"), [("microsoft", 0.15), ("openai", 0.07675)])
def test_qwen_zimage_measured_vendors_use_flat_cross_source_margins(self, vendor, expected):
"""Measured providers must not fall below their operating points on small files."""
assert resolve_strength(None, vendor, "qwen-zimage", size=(600, 500)) == pytest.approx(expected)
assert resolve_strength(None, vendor, "qwen-zimage", size=(4000, 3000)) == pytest.approx(expected)
def test_sdxl_zimage_uses_its_flat_vendor_ladder(self):
@@ -247,7 +260,7 @@ class TestResolveStrength:
class TestVendorForStrength:
"""vendor_for_strength normalizes SynthID provenance to openai/google/None."""
"""Normalize supported invisible-watermark provenance to a removal profile."""
@staticmethod
def _patch(value):
@@ -265,6 +278,22 @@ class TestVendorForStrength:
with self._patch("Google"):
assert vendor_for_strength(Path("x.png")) == "google"
@pytest.mark.parametrize(("integrity", "expected"), [("valid", "microsoft"), ("invalid", None)])
def test_only_valid_microsoft_invismark_selects_the_removal_floor(self, integrity, expected):
from remove_ai_watermarks._internal.watermark_profiles import vendor_for_strength
info = {
"soft_binding_vendors": ["Microsoft InvisMark"],
"c2pa_integrity": integrity,
"c2pa_signature": "valid",
"c2pa_signer_validity": "valid",
}
with (
self._patch(None),
patch("remove_ai_watermarks._internal.c2pa.extract_c2pa_info", return_value=info),
):
assert vendor_for_strength(Path("x.png")) == expected
def test_both_issuers_google_wins(self):
# The more-robust watermark wins -> safer (higher) strength.
from remove_ai_watermarks._internal.watermark_profiles import vendor_for_strength
+132 -4
View File
@@ -579,6 +579,127 @@ def test_cli_qwen_zimage_keeps_profile_postprocess_default(tmp_image_path, monke
assert mock_engine.remove_watermark.call_args.kwargs["adaptive_polish"] is True
def test_cli_forwards_verified_text_manifest(tmp_image_path, tmp_path, monkeypatch):
from remove_ai_watermarks import cli
manifest = tmp_path / "manifest.json"
manifest.write_text("{}", encoding="utf-8")
mock_engine = MagicMock()
mock_engine.remove_watermark.return_value = tmp_image_path
monkeypatch.setattr("remove_ai_watermarks.invisible_engine.is_available", lambda: True)
monkeypatch.setattr("remove_ai_watermarks.invisible_engine.InvisibleEngine", MagicMock(return_value=mock_engine))
result = CliRunner().invoke(
cli.main,
["invisible", str(tmp_image_path), "--text-manifest", str(manifest), "--force"],
)
assert result.exit_code == 0, result.output
assert mock_engine.remove_watermark.call_args.kwargs["text_manifest"] == manifest
def test_cli_reports_verified_text_manifest_errors(tmp_image_path, tmp_path, monkeypatch):
from remove_ai_watermarks import cli
manifest = tmp_path / "manifest.json"
manifest.write_text("{}", encoding="utf-8")
mock_engine = MagicMock()
mock_engine.remove_watermark.side_effect = ValueError("manifest pixels do not match")
monkeypatch.setattr("remove_ai_watermarks.invisible_engine.is_available", lambda: True)
monkeypatch.setattr("remove_ai_watermarks.invisible_engine.InvisibleEngine", MagicMock(return_value=mock_engine))
result = CliRunner().invoke(
cli.main,
["invisible", str(tmp_image_path), "--text-manifest", str(manifest), "--force"],
)
assert result.exit_code == 1
assert "manifest pixels do not match" in result.output
def test_no_face_path_still_runs_verified_text_restoration(monkeypatch):
from remove_ai_watermarks._internal import qwen_zimage_pipeline, text_restoration
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest
pipeline = object.__new__(QwenZImagePipeline)
pipeline.device = "cuda"
pipeline.progress_callback = None
source = Image.new("RGB", (32, 32), (10, 20, 30))
donor = Image.new("RGB", (32, 32), (40, 50, 60))
global_result = Image.new("RGB", (32, 32), (70, 80, 90))
anchor = Image.new("RGB", (32, 32), (100, 110, 120))
restored = Image.new("RGB", (32, 32), (130, 140, 150))
pipeline._qwen_vae_roundtrip = MagicMock(return_value=donor)
pipeline._run_global = MagicMock(return_value=global_result)
monkeypatch.setattr(qwen_zimage_pipeline, "detect_faces", lambda _image: [])
blend = MagicMock(return_value=anchor)
restore = MagicMock(return_value=restored)
monkeypatch.setattr(text_restoration, "blend_fidelity_anchor", blend)
monkeypatch.setattr(text_restoration, "restore_verified_text", restore)
manifest = VerifiedTextManifest(
"0" * 64,
32,
32,
(VerifiedTextLine((4, 4, 20, 16), "Exact", "alphabetic"),),
)
result = pipeline.run(source, strength=0.1, seed=0, text_manifest=manifest)
assert result is restored
# Off by default since 0.27.1 (leak finding, docs/text-protection-research.md):
# no whole-frame donor blend; the raw global result feeds restoration.
blend.assert_not_called()
restore.assert_called_once_with(source, global_result, donor, manifest.lines)
result = pipeline.run(source, strength=0.1, seed=0, text_manifest=manifest, fidelity_anchor=True)
assert result is restored
blend.assert_called_once_with(global_result, donor)
restore.assert_called_with(source, anchor, donor, manifest.lines)
def test_tiled_verified_text_runs_vae_donor_per_tile(monkeypatch):
from remove_ai_watermarks._internal import qwen_zimage_pipeline, text_restoration
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest
pipeline = object.__new__(QwenZImagePipeline)
pipeline.device = "cuda"
pipeline.progress_callback = None
source = Image.new("RGB", (96, 80), (10, 20, 30))
restored = Image.new("RGB", (96, 80), (130, 140, 150))
pipeline._qwen_vae_roundtrip = MagicMock(side_effect=lambda tile: tile)
pipeline._run_global = MagicMock(side_effect=lambda tile, _strength, _seed: tile)
monkeypatch.setattr(qwen_zimage_pipeline, "detect_faces", lambda _image: [])
restore = MagicMock(return_value=restored)
monkeypatch.setattr(text_restoration, "restore_verified_text", restore)
manifest = VerifiedTextManifest(
"0" * 64,
96,
80,
(VerifiedTextLine((4, 4, 20, 16), "Exact", "alphabetic"),),
)
result = pipeline.run(
source,
strength=0.1,
seed=0,
tile=True,
tile_size=64,
tile_overlap=16,
text_manifest=manifest,
)
assert result is restored
assert pipeline._qwen_vae_roundtrip.call_count > 1
assert pipeline._run_global.call_count > 1
restore.assert_called_once()
assert restore.call_args.args[0].size == (96, 80)
assert restore.call_args.args[1].size == (96, 80)
assert restore.call_args.args[2].size == (96, 80)
def test_watermark_remover_dispatches_to_full_pipeline(tmp_path, monkeypatch):
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
@@ -601,6 +722,7 @@ def test_watermark_remover_dispatches_to_full_pipeline(tmp_path, monkeypatch):
_, kwargs = runtime.run.call_args
assert kwargs["strength"] == pytest.approx(0.084)
assert kwargs["seed"] == 0
assert kwargs["text_manifest"] is None
assert output.exists()
@@ -785,6 +907,8 @@ def test_invisible_engine_passes_the_seed_but_never_a_step_count(tmp_image_path,
def test_sdxl_zimage_strength_is_vendor_adaptive_and_leaves_other_profiles_alone():
"""An SDXL global pass needs more strength than Qwen, so it gets its own policy."""
from remove_ai_watermarks._internal.watermark_profiles import (
QWEN_ZIMAGE_GOOGLE_STRENGTH,
QWEN_ZIMAGE_OPENAI_STRENGTH,
SDXL_ZIMAGE_GEMINI_STRENGTH,
SDXL_ZIMAGE_OPENAI_STRENGTH,
resolve_strength,
@@ -794,11 +918,15 @@ def test_sdxl_zimage_strength_is_vendor_adaptive_and_leaves_other_profiles_alone
assert resolve_strength(None, "google", "sdxl-zimage") == pytest.approx(SDXL_ZIMAGE_GEMINI_STRENGTH)
# Unknown provenance takes the stricter of the two.
assert resolve_strength(None, None, "sdxl-zimage") == pytest.approx(SDXL_ZIMAGE_GEMINI_STRENGTH)
# An explicit value still wins, and qwen-zimage is untouched by this ladder: it
# defers to its resolution curve rather than to a vendor value.
# An explicit value still wins. qwen-zimage's unknown cohort keeps the curve,
# while measured providers take their flat operating points.
assert resolve_strength(0.4, "google", "sdxl-zimage") == pytest.approx(0.4)
assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154)
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154)
assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(
QWEN_ZIMAGE_OPENAI_STRENGTH
)
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == pytest.approx(
QWEN_ZIMAGE_GOOGLE_STRENGTH
)
def test_sdxl_zimage_shares_the_fixed_seed_contract():
+181
View File
@@ -0,0 +1,181 @@
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
import numpy as np
import pytest
from remove_ai_watermarks._internal import text_restoration
SCRIPT = Path(__file__).parents[1] / "scripts" / "selective_text_restoration.py"
SPEC = importlib.util.spec_from_file_location("selective_text_restoration", SCRIPT)
assert SPEC is not None
assert SPEC.loader is not None
module = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = module
SPEC.loader.exec_module(module)
def test_normalized_edit_distance_ignores_case_and_whitespace() -> None:
assert module.normalized_edit_distance(" Sample text ", "sample\ntext") == 0.0
def test_preserve_requires_source_candidate_agreement() -> None:
assert module.should_preserve_line("clean text", "clean text", 0.9, "clean text", 0.9)
assert not module.should_preserve_line("clean text", "clean text", 0.9, "damaged", 0.9)
def test_preserve_rejects_unreliable_source_recognition() -> None:
assert not module.should_preserve_line("expected", "unrelated", 0.9, "unrelated", 0.9)
assert not module.should_preserve_line("expected", "expected", 0.7, "expected", 0.9)
def test_cjk_recognition_box_excludes_overlapping_neighbor_lines() -> None:
line = module.TextLine((1281, 650, 2357, 818), "每天都是一个新的机会。", "cjk")
assert module._recognition_box(line, 2816, 1536) == (1247, 650, 2458, 818)
def test_latin_recognition_box_keeps_context_padding() -> None:
line = module.TextLine((100, 200, 300, 260), "Sample text", "latin")
assert module._recognition_box(line, 1000, 1000) == (40, 180, 360, 280)
assert module._recognition_box(line, 1000, 1000, 0.1) == (40, 192, 360, 268)
def test_verified_lines_cover_each_ground_truth_string() -> None:
root = Path(__file__).parents[1]
lines = json.loads((root / "data/evaluations/fidelity/text-lines.json").read_text(encoding="utf-8"))
ground_truth = json.loads((root / "data/evaluations/fidelity/ground-truth.json").read_text(encoding="utf-8"))
assert lines.keys() == ground_truth.keys()
for source, expected in ground_truth.items():
observed = " ".join(line["text"] for line in lines[source])
assert module.normalize_text(observed) == module.normalize_text(expected)
def test_group_word_boxes_merges_words_but_not_neighboring_lines() -> None:
boxes = [(10, 10, 30, 30), (32, 12, 60, 29), (10, 35, 50, 55)]
assert module.group_word_boxes(boxes) == [(10, 10, 60, 30), (10, 35, 50, 55)]
def test_group_word_boxes_does_not_merge_distant_columns() -> None:
boxes = [(10, 10, 60, 30), (500, 11, 560, 31)]
assert module.group_word_boxes(boxes) == boxes
def test_source_glyph_composite_keeps_masked_pixels_exact() -> None:
source = np.zeros((9, 9, 3), dtype=np.uint8)
source[:, :] = (220, 180, 40)
background = np.zeros((9, 9, 3), dtype=np.uint8)
background[:, :] = (10, 20, 30)
mask = np.zeros((9, 9), dtype=np.uint8)
mask[3:6, 3:6] = 255
result = module.composite_source_glyphs(source, background, mask, feather=0.7)
np.testing.assert_array_equal(result[3:6, 3:6], source[3:6, 3:6])
np.testing.assert_array_equal(result[0, 0], background[0, 0])
def test_fresh_silhouette_uses_new_color_instead_of_source_pixels() -> None:
background = np.zeros((9, 9, 3), dtype=np.uint8)
background[:, :] = (10, 20, 30)
mask = np.zeros((9, 9), dtype=np.uint8)
mask[3:6, 3:6] = 255
result = text_restoration.composite_fresh_silhouette(background, mask, (220, 180, 40), feather=0)
assert np.all(result[3:6, 3:6] == (220, 180, 40))
np.testing.assert_array_equal(result[0, 0], background[0, 0])
def test_fresh_silhouette_antialiasing_softens_binary_edges() -> None:
background = np.zeros((9, 9, 3), dtype=np.uint8)
background[:, :] = (10, 20, 30)
mask = np.zeros((9, 9), dtype=np.uint8)
mask[3:6, 3:6] = 255
result = text_restoration.composite_fresh_silhouette(background, mask, (220, 180, 40), feather=1.0)
assert np.all(result[3, 3] > background[3, 3])
assert np.all(result[3, 3] < (220, 180, 40))
def test_reconstructed_glyphs_keep_exact_donor_core_and_fresh_edge() -> None:
donor = np.zeros((9, 9, 3), dtype=np.uint8)
donor[:, :] = (180, 140, 60)
background = np.zeros((9, 9, 3), dtype=np.uint8)
background[:, :] = (10, 20, 30)
mask = np.zeros((9, 9), dtype=np.uint8)
mask[3:6, 3:6] = 255
fresh_edge = text_restoration.composite_fresh_silhouette(background, mask, (220, 180, 40))
result = module.composite_reconstructed_glyphs(donor, fresh_edge, mask, feather=0.5)
np.testing.assert_array_equal(result[3:6, 3:6], donor[3:6, 3:6])
assert np.any(result[2, 3] != fresh_edge[2, 3])
np.testing.assert_array_equal(result[0, 0], background[0, 0])
def test_source_silhouette_discards_foreground_amplitudes() -> None:
source = np.full((15, 15, 3), 20, dtype=np.uint8)
source[5:10, 6:9] = 230
source[6:9, 7] = 180
mask = module.source_silhouette_mask(source, (4, 4, 11, 11))
assert mask.dtype == np.uint8
assert set(np.unique(mask)) <= {0, 255}
assert mask[7, 7] == 255
assert mask[4, 4] == 0
def test_rotated_source_silhouette_excludes_axis_aligned_corners() -> None:
source = np.full((80, 160, 3), 20, dtype=np.uint8)
source[10:70, 10:150] = 230
mask = module.source_silhouette_mask(source, (0, 0, 160, 80), angle=12)
assert mask[0, 0] == 0
assert mask[79, 159] == 0
def test_source_box_mask_pads_and_clips_boxes() -> None:
mask = module.source_box_mask((20, 30), [(1, 2, 11, 10), (25, 15, 30, 20)])
assert mask.shape == (20, 30)
assert mask[0, 0] == 255
assert mask[19, 29] == 255
assert mask[0, 22] == 0
def test_detect_line_boxes_fails_closed_on_count_mismatch() -> None:
class Engine:
def predict(self, _image):
return [{"rec_scores": [0.9], "rec_boxes": [[10, 10, 30, 30]]}]
with pytest.raises(module.click.ClickException, match="detected 1 source lines; expected exactly 2"):
module.detect_line_boxes(Engine(), np.zeros((50, 50, 3), dtype=np.uint8), expected_count=2)
def test_residual_mask_is_limited_to_original_glyph_positions(monkeypatch) -> None:
from remove_ai_watermarks._internal import text_restoration
background = np.zeros((8, 8, 3), dtype=np.uint8)
original = np.zeros((8, 8), dtype=np.uint8)
original[3, 3] = 255
detected = np.zeros((8, 8), dtype=np.uint8)
detected[3, 3] = 255
detected[6, 6] = 255
monkeypatch.setattr(text_restoration, "_foreground_mask", lambda _image, _box: detected)
residual = module.residual_glyph_mask(background, original, (0, 0, 8, 8))
assert residual[3, 3] == 255
assert residual[6, 6] == 0
+158
View File
@@ -0,0 +1,158 @@
"""text_draft: proposal-only OCR for verified-text manifests (no paddle needed)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from PIL import Image
from remove_ai_watermarks.text_draft import (
TextDraft,
choose_language,
draft_available,
draft_text_lines,
group_word_boxes,
stable_recognition,
)
if TYPE_CHECKING:
from pathlib import Path
class TestPureHelpers:
def test_choose_language_follows_unicode_script(self):
assert choose_language({"ch": ("你好", 0.9), "ru": ("xxx", 0.1), "en": ("yyy", 0.1)}) == "ch"
assert choose_language({"ch": ("??", 0.1), "ru": ("привет", 0.9), "en": ("yyy", 0.1)}) == "ru"
assert choose_language({"ch": ("??", 0.1), "ru": ("xxx", 0.1), "en": ("hello", 0.9)}) == "en"
def test_stable_recognition_requires_identical_normalizations(self):
# Same text modulo spacing/case (comma kept): stable.
assert (
stable_recognition([("Hello, World", 0.97), ("hello,world", 0.96), ("HELLO, WORLD", 0.95)])
== "Hello, World"
)
# Different texts: rejected (None).
assert stable_recognition([("Hello", 0.97), ("Hallo", 0.96), ("Hello", 0.95)]) is None
# Identical text but one read under the floor: rejected.
assert stable_recognition([("Hello", 0.97), ("Hello", 0.60), ("Hello", 0.95)]) is None
assert stable_recognition([("Hello", 0.97), ("Hello", 0.96), ("Hello", 0.95)], min_score=0.99) is None
def test_group_word_boxes_merges_a_line_and_keeps_rows_apart(self):
# Two words on one baseline merge; a distant lower line stays separate.
merged = group_word_boxes([(10, 100, 60, 140), (70, 100, 120, 140)])
assert len(merged) == 1
assert merged[0] == (10, 100, 120, 140)
apart = group_word_boxes([(10, 100, 60, 140), (10, 300, 60, 340)])
assert len(apart) == 2
class TestDraftAvailable:
def test_returns_a_bool_without_importing_paddle(self):
assert isinstance(draft_available(), bool)
class _FakeDetector:
"""Emits Paddle-style pages for one synthetic poster."""
def __init__(self, boxes: list[tuple[int, int, int, int]]) -> None:
self._boxes = boxes
def predict(self, _image: Any) -> Any:
import numpy as np
yield {
"rec_boxes": np.asarray(self._boxes, dtype=np.float32),
"rec_scores": [0.99] * len(self._boxes),
}
class _FakeEngine:
"""Returns a fixed text at high score regardless of the crop."""
def __init__(self, text: str) -> None:
self._text = text
def predict(self, _crop: Any) -> Any:
yield {"rec_text": self._text, "rec_score": 0.97}
class _JitterEngine:
"""Changes its answer with the crop height - exactly what the gate rejects."""
def __init__(self, texts: list[str]) -> None:
self._texts = texts
self._calls = 0
def predict(self, crop: Any) -> Any:
text = self._texts[self._calls % len(self._texts)]
self._calls += 1
yield {"rec_text": text, "rec_score": 0.97}
class TestDraftTextLines:
@staticmethod
def _poster(tmp_path: Path) -> Path:
path = tmp_path / "poster.png"
Image.new("RGB", (400, 300), (255, 255, 255)).save(path)
return path
def test_accepts_crop_stable_and_rejects_jittered(self, tmp_path: Path):
path = self._poster(tmp_path)
stable = _FakeEngine("Invoice 42")
jitter = _JitterEngine(["Hello", "Hallo", "Hullo"])
# All three probe engines read the same string, so language = en; the
# jitter engine then serves as the en engine and flips across crops.
draft = draft_text_lines(
path,
detector=_FakeDetector([(20, 20, 200, 60), (20, 80, 200, 120)]),
engines={"en": jitter, "ru": stable, "ch": stable},
)
assert isinstance(draft, TextDraft)
assert len(draft.rejected) == 2
assert all(line.language == "en" for line in draft.rejected)
assert draft.accepted == ()
def test_accepted_line_carries_box_text_script_and_floor(self, tmp_path: Path):
path = self._poster(tmp_path)
engine = _FakeEngine("Total: 1,234.56")
draft = draft_text_lines(
path,
detector=_FakeDetector([(20, 20, 260, 60)]),
engines={"en": engine, "ru": engine, "ch": engine},
)
(line,) = draft.accepted
assert line.box == (20, 20, 260, 60)
assert line.text == "Total: 1,234.56"
assert line.script == "alphabetic"
assert line.language == "en"
assert line.min_score >= 0.85
def test_cjk_probe_switches_script_and_language(self, tmp_path: Path):
path = self._poster(tmp_path)
cjk = _FakeEngine("每天都是一个新的机会。")
latin = _FakeEngine("hello")
draft = draft_text_lines(
path,
detector=_FakeDetector([(30, 30, 300, 90)]),
engines={"en": latin, "ru": latin, "ch": cjk},
)
(line,) = draft.accepted
assert line.language == "ch"
assert line.script == "cjk"
def test_unstable_geometry_draft_accepts_a_single_high_score_read(self, tmp_path: Path):
path = self._poster(tmp_path)
jitter = _JitterEngine(["Hello", "Hallo", "Hullo"])
stable = _FakeEngine("Hello")
# Probes disagree across engines so language is en; jitter would fail
# the stable gate. Geometry mode uses the one en probe and accepts.
draft = draft_text_lines(
path,
min_score=0.75,
stable=False,
detector=_FakeDetector([(20, 20, 200, 60)]),
engines={"en": jitter, "ru": stable, "ch": stable},
)
(line,) = draft.accepted
assert line.text == "Hello"
assert line.min_score >= 0.75
+199
View File
@@ -0,0 +1,199 @@
"""Verified-text manifest and compositor tests without model downloads."""
from __future__ import annotations
import json
import numpy as np
import pytest
from PIL import Image, PngImagePlugin
from remove_ai_watermarks._internal.text_restoration import (
FIDELITY_BLEND_ALPHA,
VerifiedTextLine,
blend_fidelity_anchor,
load_verified_text_manifest,
restore_verified_text,
source_pixel_sha256,
source_silhouette_mask,
)
def _manifest(image: Image.Image) -> dict[str, object]:
return {
"schema_version": 1,
"verified": True,
"source_pixel_sha256": source_pixel_sha256(image),
"width": image.width,
"height": image.height,
"lines": [
{
"box": [8, 8, 40, 24],
"text": "Exact text",
"script": "alphabetic",
"angle": 0.0,
}
],
}
def _geometry_manifest(image: Image.Image) -> dict[str, object]:
return {
"schema_version": 2,
"verified": True,
"source_pixel_sha256": source_pixel_sha256(image),
"width": image.width,
"height": image.height,
"lines": [{"box": [8, 8, 40, 24], "angle": 0.0}],
}
def test_pixel_hash_ignores_container_metadata(tmp_path) -> None:
image = Image.new("RGB", (48, 32), (10, 20, 30))
plain = tmp_path / "plain.png"
tagged = tmp_path / "tagged.png"
image.save(plain)
metadata = PngImagePlugin.PngInfo()
metadata.add_text("note", "different container bytes")
image.save(tagged, pnginfo=metadata)
with Image.open(plain) as left, Image.open(tagged) as right:
assert plain.read_bytes() != tagged.read_bytes()
assert source_pixel_sha256(left) == source_pixel_sha256(right)
def test_verified_manifest_is_bound_to_source_pixels(tmp_path) -> None:
source = Image.new("RGB", (48, 32), (10, 20, 30))
path = tmp_path / "lines.json"
path.write_text(json.dumps(_manifest(source)), encoding="utf-8")
loaded = load_verified_text_manifest(path, source)
assert loaded.width == 48
assert loaded.height == 32
assert loaded.lines == (VerifiedTextLine((8, 8, 40, 24), "Exact text", "alphabetic", 0.0),)
def test_geometry_manifest_needs_no_transcription_or_script(tmp_path) -> None:
source = Image.new("RGB", (48, 32), (10, 20, 30))
path = tmp_path / "regions.json"
path.write_text(json.dumps(_geometry_manifest(source)), encoding="utf-8")
loaded = load_verified_text_manifest(path, source)
assert loaded.lines == (VerifiedTextLine((8, 8, 40, 24), angle=0.0),)
@pytest.mark.parametrize("field", ["text", "script"])
def test_schema_one_still_requires_text_metadata(tmp_path, field) -> None:
source = Image.new("RGB", (48, 32), (10, 20, 30))
payload = _manifest(source)
del payload["lines"][0][field]
path = tmp_path / "lines.json"
path.write_text(json.dumps(payload), encoding="utf-8")
with pytest.raises(ValueError, match=field):
load_verified_text_manifest(path, source)
@pytest.mark.parametrize("schema_version", [True, 0, 3])
def test_manifest_rejects_unsupported_schema_versions(tmp_path, schema_version) -> None:
source = Image.new("RGB", (48, 32), (10, 20, 30))
payload = _geometry_manifest(source)
payload["schema_version"] = schema_version
path = tmp_path / "lines.json"
path.write_text(json.dumps(payload), encoding="utf-8")
with pytest.raises(ValueError, match="Unsupported text manifest schema"):
load_verified_text_manifest(path, source)
@pytest.mark.parametrize(
("mutation", "message"),
[
({"verified": False}, "verified=true"),
({"source_pixel_sha256": "0" * 64}, "does not match"),
({"width": 49}, "dimensions"),
({"lines": []}, "non-empty"),
],
)
def test_manifest_rejects_unverified_or_unbound_input(tmp_path, mutation, message) -> None:
source = Image.new("RGB", (48, 32), (10, 20, 30))
payload = _manifest(source)
payload.update(mutation)
path = tmp_path / "lines.json"
path.write_text(json.dumps(payload), encoding="utf-8")
with pytest.raises(ValueError, match=message):
load_verified_text_manifest(path, source)
def test_fidelity_anchor_uses_the_calibrated_rounding() -> None:
clean = Image.fromarray(np.array([[[1, 2, 3], [100, 150, 200]]], dtype=np.uint8))
donor = Image.fromarray(np.array([[[255, 254, 253], [200, 100, 50]]], dtype=np.uint8))
result = np.asarray(blend_fidelity_anchor(clean, donor))
expected = np.rint(
np.asarray(clean, dtype=np.float32) * (1.0 - FIDELITY_BLEND_ALPHA)
+ np.asarray(donor, dtype=np.float32) * FIDELITY_BLEND_ALPHA
).astype(np.uint8)
assert np.array_equal(result, expected)
def test_restoration_uses_lama_and_qwen_vae_core(monkeypatch) -> None:
from remove_ai_watermarks import region_eraser
source = np.full((40, 64, 3), 20, dtype=np.uint8)
source[12:24, 12:44] = 235
candidate = np.full_like(source, 30)
candidate[12:24, 12:44] = 150
donor = np.full_like(source, 40)
donor[12:24, 12:44] = (210, 220, 230)
calls: list[np.ndarray] = []
def fake_erase(image_bgr, mask):
calls.append(mask.copy())
output = image_bgr.copy()
output[mask > 0] = (30, 30, 30)
return output
monkeypatch.setattr(region_eraser, "lama_available", lambda: True)
monkeypatch.setattr(region_eraser, "erase_lama", fake_erase)
result = restore_verified_text(
Image.fromarray(source),
Image.fromarray(candidate),
Image.fromarray(donor),
(VerifiedTextLine((8, 8, 48, 28)),),
)
restored = np.asarray(result)
assert calls
assert np.all(restored[16, 20] == donor[16, 20])
assert np.all(restored[0, 0] == candidate[0, 0])
def test_silhouette_includes_descender_below_the_detector_box() -> None:
source = np.full((40, 50, 3), 240, dtype=np.uint8)
source[10:22, 18:24] = 20
source[22:27, 18:22] = 20 # tail of a y / Cyrillic u, under the box
mask = source_silhouette_mask(source, (10, 10, 40, 22))
assert mask[24, 20] == 255
assert mask[16, 20] == 255
def test_silhouette_includes_glyph_edges_beside_the_detector_box() -> None:
source = np.full((40, 70, 3), 240, dtype=np.uint8)
source[10:22, 20:50] = 20
source[14:18, 10:20] = 20 # leading flourish, left of the detector box
source[14:18, 50:58] = 20 # punctuation or icon edge, right of the box
source[14:18, 2:5] = 20 # separate decoration must stay outside the crop
mask = source_silhouette_mask(source, (20, 10, 50, 22))
assert mask[16, 12] == 255
assert mask[16, 56] == 255
assert mask[16, 3] == 0
+42 -9
View File
@@ -8,15 +8,19 @@ absent/error behaviour: detect must return None, never raise.
from __future__ import annotations
from typing import TYPE_CHECKING
import hashlib
from pathlib import Path
import pytest
from remove_ai_watermarks import trustmark_detector
from remove_ai_watermarks.identify import identify
from remove_ai_watermarks.trustmark_detector import detect_trustmark, is_available
if TYPE_CHECKING:
from pathlib import Path
_OFFICIAL_FIXTURE = (
Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance" / "adobe-trustmark-p.png"
)
_OFFICIAL_FIXTURE_SHA256 = "e58c5825ed7e5d9fb04710ea541b61bd55879cad65554c0d46260aa24b3d0755"
class _FakeDecoder:
@@ -24,11 +28,12 @@ class _FakeDecoder:
``(secret, present, schema)`` tuples -- the first for the original image, the
second for the re-encoded copy used by the false-positive durability gate."""
def __init__(self, *results: tuple[bytes, bool, int]):
def __init__(self, *results: tuple[str, bool, int]):
self._results = list(results)
self.calls = 0
def decode(self, _img: object) -> tuple[bytes, bool, int]:
def decode(self, _img: object, mode: str = "binary") -> tuple[str, bool, int]:
assert mode == "binary"
result = self._results[min(self.calls, len(self._results) - 1)]
self.calls += 1
return result
@@ -64,7 +69,7 @@ class TestFalsePositiveGate:
monkeypatch.setattr(trustmark_detector, "_decoder", lambda: decoder)
def test_durable_watermark_survives_and_is_reported(self, monkeypatch, tmp_clean_png: Path):
decoder = _FakeDecoder((b"secret", True, 2), (b"secret", True, 2))
decoder = _FakeDecoder(("secret", True, 2), ("secret", True, 2))
self._patch_decoder(monkeypatch, decoder)
result = detect_trustmark(tmp_clean_png)
assert result == "Adobe TrustMark (variant P, schema 2)"
@@ -72,18 +77,46 @@ class TestFalsePositiveGate:
def test_false_positive_collapsing_on_reencode_is_dropped(self, monkeypatch, tmp_clean_png: Path):
# Present on the original, absent after re-encode -> content-noise FP.
decoder = _FakeDecoder((b"\x00\x01", True, 3), (b"", False, -1))
decoder = _FakeDecoder(("01", True, 2), ("", False, -1))
self._patch_decoder(monkeypatch, decoder)
assert detect_trustmark(tmp_clean_png) is None
def test_schema_drift_on_reencode_is_dropped(self, monkeypatch, tmp_clean_png: Path):
# Present both times but the schema changes -> not a stable watermark.
decoder = _FakeDecoder((b"\x00", True, 2), (b"\x00", True, 3))
decoder = _FakeDecoder(("01", True, 2), ("01", True, 3))
self._patch_decoder(monkeypatch, decoder)
assert detect_trustmark(tmp_clean_png) is None
def test_payload_drift_on_reencode_is_dropped(self, monkeypatch, tmp_clean_png: Path):
decoder = _FakeDecoder(("first", True, 2), ("second", True, 2))
self._patch_decoder(monkeypatch, decoder)
assert detect_trustmark(tmp_clean_png) is None
def test_weak_schema_three_is_dropped(self, monkeypatch, tmp_clean_png: Path):
decoder = _FakeDecoder(("secret", True, 3))
self._patch_decoder(monkeypatch, decoder)
assert detect_trustmark(tmp_clean_png) is None
assert decoder.calls == 1
def test_absent_skips_reencode(self, monkeypatch, tmp_clean_png: Path):
decoder = _FakeDecoder((b"", False, -1))
decoder = _FakeDecoder(("", False, -1))
self._patch_decoder(monkeypatch, decoder)
assert detect_trustmark(tmp_clean_png) is None
assert decoder.calls == 1 # no second decode when the first is absent
@pytest.mark.skipif(not is_available(), reason="trustmark not installed")
def test_official_adobe_variant_p_fixture():
assert detect_trustmark(_OFFICIAL_FIXTURE) == "Adobe TrustMark (variant P, schema 1)"
@pytest.mark.skipif(not is_available(), reason="trustmark not installed")
def test_official_adobe_variant_p_fixture_is_provenance_not_ai():
report = identify(_OFFICIAL_FIXTURE, check_visible=False)
assert report.is_ai_generated is None
assert report.ai_from_metadata is False
assert any(signal.name == "trustmark" for signal in report.signals)
def test_official_adobe_variant_p_fixture_digest():
assert hashlib.sha256(_OFFICIAL_FIXTURE.read_bytes()).hexdigest() == _OFFICIAL_FIXTURE_SHA256
+123
View File
@@ -65,6 +65,47 @@ def _video_with_tc260(path: Path, *, media_payload: bytes = _VIDEO_PAYLOAD) -> P
return path
def _hdlr(handler: bytes) -> bytes:
# version/flags + pre_defined + handler_type + reserved[3]; the TC260 walker
# never parses it, but a real-shaped hdlr keeps the fixture honest.
return _box(b"hdlr", b"\x00\x00\x00\x00" + b"\x00\x00\x00\x00" + handler + b"\x00" * 12)
def _quicktime_meta(*children: bytes) -> bytes:
# QuickTime form: no 4-byte FullBox header before the children.
return _box(b"meta", b"".join(children))
def _video_with_tc260_moov_meta(path: Path, *, media_payload: bytes = _VIDEO_PAYLOAD) -> Path:
"""Doubao's iOS export form: a QuickTime ``meta`` box as a direct ``moov`` child."""
keys = _box(
b"keys",
b"\x00\x00\x00\x00"
+ (2).to_bytes(4, "big")
+ _metadata_key(b"com.apple.quicktime.artwork")
+ _metadata_key(b"AIGC"),
)
ilst = _box(
b"ilst",
_metadata_value(1, b'{"source_type":"","data":{"product":"doubao"}}') + _metadata_value(2, _TC260_AIGC),
)
meta = _quicktime_meta(_hdlr(b"mdta"), keys, ilst)
path.write_bytes(_MP4_FTYP + _box(b"mdat", media_payload) + _box(b"moov", meta))
return path
def _video_with_tc260_mdir_list(path: Path, *, media_payload: bytes = _VIDEO_PAYLOAD) -> Path:
"""Doubao's iOS QuickTime metadata list: ``udta.meta(hdlr=mdir)/ilst`` data
items with numeric indices and no ``keys`` box at all."""
ilst = _box(
b"ilst",
_metadata_value(0, b"vid:standard-video-id") + _metadata_value(0, _TC260_AIGC),
)
meta = _quicktime_meta(_hdlr(b"mdir"), ilst)
path.write_bytes(_MP4_FTYP + _box(b"mdat", media_payload) + _box(b"moov", _box(b"udta", meta)))
return path
def _ebml_size(value: int) -> bytes:
for length in range(1, 9):
if value < (1 << (7 * length)) - 1:
@@ -612,6 +653,67 @@ class TestVideoMetadataApi:
assert b"AIGC" not in cleaned
assert _TC260_AIGC not in cleaned
@pytest.mark.parametrize("suffix", [".mp4", ".mov"])
def test_inspects_native_tc260_in_quicktime_moov_meta(self, tmp_path: Path, suffix: str):
# Doubao's iOS export stores the label in a QuickTime-form meta box
# (no FullBox header) hanging directly off moov, not under udta.
from remove_ai_watermarks.video import inspect_video_metadata
source = _video_with_tc260_moov_meta(tmp_path / f"source{suffix}")
report = inspect_video_metadata(source)
assert report.has_ai_metadata is True
assert report.markers["aigc_label"].endswith("producer 00119144030008867405X210002")
def test_removes_native_tc260_in_quicktime_moov_meta(self, tmp_path: Path):
from remove_ai_watermarks.video import remove_video_metadata
source = _video_with_tc260_moov_meta(tmp_path / "source.mov")
output = tmp_path / "clean.mov"
result = remove_video_metadata(source, output)
cleaned = output.read_bytes()
assert result.detected["aigc_label"].startswith("China AIGC label")
# The artwork item's Doubao product JSON is app-export provenance, a
# separate signal the TC260 blanker does not touch -- as on the real
# Doubao iOS export this fixture mirrors.
assert result.remaining == {"app_provenance": "App export provenance (ByteDance Doubao)"}
assert len(cleaned) == source.stat().st_size
assert _VIDEO_PAYLOAD in cleaned
assert _TC260_AIGC not in cleaned
def test_inspects_native_tc260_in_quicktime_mdir_metadata_list(self, tmp_path: Path):
# The second Doubao iOS variant: a QuickTime metadata list under
# udta.meta with hdlr=mdir and ilst data items but no keys box.
from remove_ai_watermarks.video import inspect_video_metadata
source = _video_with_tc260_mdir_list(tmp_path / "source.mov")
report = inspect_video_metadata(source)
assert report.has_ai_metadata is True
assert "aigc_label" in report.markers
def test_removes_native_tc260_in_quicktime_mdir_metadata_list(self, tmp_path: Path):
from remove_ai_watermarks.video import remove_video_metadata
source = _video_with_tc260_mdir_list(tmp_path / "source.mov")
output = tmp_path / "clean.mov"
result = remove_video_metadata(source, output)
cleaned = output.read_bytes()
assert result.detected["aigc_label"].startswith("China AIGC label")
assert result.remaining == {}
assert len(cleaned) == source.stat().st_size
assert _VIDEO_PAYLOAD in cleaned
# The keyless entry has no key name to blank; only the JSON is spaced
# out, and the neighboring standard ilst item survives untouched.
assert _TC260_AIGC not in cleaned
assert b"vid:standard-video-id" in cleaned
def test_streams_large_isobmff_without_full_file_read(
self,
tmp_path: Path,
@@ -849,6 +951,7 @@ class TestVideoMetadataCli:
assert result.exit_code == 0, result.output
assert "AI metadata detected" in result.output
assert "not the same as 'clean'" not in result.output
def test_remove_reports_output(self, tmp_path: Path):
runner = CliRunner()
@@ -859,6 +962,7 @@ class TestVideoMetadataCli:
assert result.exit_code == 0, result.output
assert "AI metadata stripped" in result.output
assert "not the same as 'clean'" in result.output
assert C2PA_UUID not in output.read_bytes()
def test_rejects_image_input(self, tmp_clean_png: Path):
@@ -1894,6 +1998,25 @@ class TestAdditionalProviderTemporalArbiter:
assert stabilize(weak) == [None] * 12
assert stabilize(strong) == [box] * 12
def test_hailuo_provenance_accepts_sub_strong_stable_run(self):
# A TC260 label naming MiniMax relaxes only the strong-frame requirement:
# the entry bar stays the measured weak floor (0.30), so a stable run
# between weak and strong (0.34) passes with provenance, fails without.
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
detections = [FrameLocalization(index, 0.31, self._HAILUO_BOX) for index in range(12)]
assert stabilize_localizations("hailuo", detections, provenance=False) == [None] * 12
assert stabilize_localizations("hailuo", detections, provenance=True) == [self._HAILUO_BOX] * 12
def test_hailuo_provenance_requires_a_minimax_producer_label(self):
from remove_ai_watermarks.video_visible import has_hailuo_video_provenance
assert has_hailuo_video_provenance({"aigc_producer": "MiniMax"})
assert not has_hailuo_video_provenance({"aigc_producer": "001191110102MACQD9K64010000"})
assert not has_hailuo_video_provenance({"aigc_label": "China AIGC label (TC260); producer MiniMax"})
assert not has_hailuo_video_provenance({"issuer": "Some other vendor"})
class TestVideoVisibleScan:
def test_auto_prepares_each_frame_once_for_every_detector(