mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-09-01 10:10:38 +02:00
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:
+22
-4
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {}})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user