mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-14 13:30:55 +02:00
Extend the development-only benchmark family from images to audio and video, and close four research directions from the watermark-landscape map with local, revision-pinned evidence. Benchmark schema: media_type audio and video rows, audioseal and videoseal adapters with media-checked adapter binding, sample-domain audio fidelity (SNR with unbounded_identical), frame-domain video fidelity (mean PSNR, changed-frame fraction), and per-frame temporal evidence in videoseal detection records. The report accepts all three media types and carries SNR columns. Audio: shared pinned AudioSeal oracle, synthetic and system-voice speech cohorts with matched negatives, codec, noise, removed, and forged arms, and the provenance experiment proving visual-only video cleaning leaves the audio bitstream and watermark verdict invariant. Video: TorchScript-only VideoSeal oracle (the pip package is unusable on macOS arm64 and its loader resolves paths relative to cwd), video cohort with crf/scale/fps attacks and four-state arms, temporal evaluation measuring the crf decay curve, the frame-aggregation matrix (computed locally after finding the TorchScript build ignores the aggregation argument), and real provider clips as carriers. Forgery: double-embedding study showing the last writer wins completely under both oracles, scoped remover-trace probe, and the forged/ removed cohort arms encoding each scheme's correct expected verdict (presence rule for audioseal, message rule for videoseal). C2PA resolution: measured the five vendors' resolver contracts and the C2PA 2.2 18.10.5 privacy gradient; product decision recorded that this tool never resolves over the network. A registry guard pins the published resolution-API set. Gate: ruff and pyright src/ clean, 2114 passed / 3 skipped; maintain.sh stops only on the known unfixed lightning 2.6.5 CVEs, pre-existing.
400 lines
13 KiB
Python
400 lines
13 KiB
Python
"""Contracts for the watermark benchmark report builder."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "watermark_benchmark_report.py"
|
|
SPEC = importlib.util.spec_from_file_location("watermark_benchmark_report", 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)
|
|
BENCHMARK = sys.modules["watermark_benchmark"]
|
|
|
|
|
|
class _FakeAdapter:
|
|
name = "dwt-dct"
|
|
source_file = SCRIPT
|
|
|
|
def __init__(self, status: str) -> None:
|
|
self.status = status
|
|
|
|
@property
|
|
def available(self) -> bool:
|
|
return self.status != "unavailable"
|
|
|
|
def detect(self, _path: Path, _image: Any) -> Any:
|
|
if self.status == "error":
|
|
raise RuntimeError("failed")
|
|
return BENCHMARK.DetectorOutcome(
|
|
status=self.status,
|
|
label="mark" if self.status == "detected" else None,
|
|
)
|
|
|
|
|
|
def _image(path: Path, value: int) -> Path:
|
|
Image.fromarray(np.full((16, 20, 3), value, dtype=np.uint8), "RGB").save(path)
|
|
return path
|
|
|
|
|
|
def _row(
|
|
artifact: Path,
|
|
case_id: str,
|
|
*,
|
|
state: str = "marked",
|
|
status: str = "detected",
|
|
elapsed_ms: float | None = 2.0,
|
|
transform: str = "embed",
|
|
expected: str = "detected",
|
|
transform_parameters: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
digest = BENCHMARK.sha256_file(artifact)
|
|
case = BENCHMARK.BenchmarkCase(
|
|
case_id=case_id,
|
|
pair_id=case_id.split("--")[0],
|
|
media_type="image",
|
|
adapter="dwt-dct",
|
|
arm="positive" if state != "clean" else "matched_negative",
|
|
state=state,
|
|
path=artifact,
|
|
sha256=digest,
|
|
reference_path=artifact,
|
|
reference_sha256=digest,
|
|
source_revision="cohort@abc",
|
|
transform=BENCHMARK.Transform(
|
|
name=transform,
|
|
revision="recipe-v1",
|
|
parameters=transform_parameters or {},
|
|
),
|
|
seed=7,
|
|
expected=expected,
|
|
)
|
|
times = iter((0, round((elapsed_ms or 0.0) * 1e6)))
|
|
|
|
def clock_ns() -> int:
|
|
return next(times)
|
|
|
|
return BENCHMARK.evaluate_case(
|
|
case,
|
|
adapters={"dwt-dct": _FakeAdapter(status)},
|
|
repository={"commit": "abc123", "dirty": False},
|
|
clock_ns=clock_ns,
|
|
)
|
|
|
|
|
|
def _write(path: Path, rows: list[dict[str, Any]]) -> Path:
|
|
path.write_text("\n".join(json.dumps(row) for row in rows) + "\n", encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def _group(summary: dict[str, Any], section: str, **identity: str) -> dict[str, Any]:
|
|
return next(row for row in summary[section] if all(row[key] == value for key, value in identity.items()))
|
|
|
|
|
|
def test_summary_deduplicates_artifacts_and_separates_cold_from_warm(tmp_path: Path) -> None:
|
|
clean_path = _image(tmp_path / "clean.png", 10)
|
|
marked_path = _image(tmp_path / "marked.png", 20)
|
|
removed_path = _image(tmp_path / "removed.png", 30)
|
|
clean = _row(
|
|
clean_path,
|
|
"clean-sdxl",
|
|
state="clean",
|
|
status="not_detected",
|
|
elapsed_ms=10.0,
|
|
transform="synthetic-carrier",
|
|
expected="not_detected",
|
|
)
|
|
repeated_clean = _row(
|
|
clean_path,
|
|
"clean-flux",
|
|
state="clean",
|
|
status="not_detected",
|
|
elapsed_ms=1.0,
|
|
transform="synthetic-carrier",
|
|
expected="not_detected",
|
|
)
|
|
marked = _row(marked_path, "marked", elapsed_ms=2.0)
|
|
removed = _row(
|
|
removed_path,
|
|
"removed",
|
|
state="removed",
|
|
status="not_detected",
|
|
elapsed_ms=3.0,
|
|
transform="remove",
|
|
expected="unresolved",
|
|
)
|
|
first = _write(tmp_path / "run-1.jsonl", [clean, repeated_clean, marked, removed])
|
|
second_rows = json.loads(json.dumps([clean, repeated_clean, marked, removed]))
|
|
for row, elapsed in zip(second_rows, (20.0, 1.5, 2.5, 3.5), strict=True):
|
|
row["detection"]["adapter_elapsed_ms"] = elapsed
|
|
second = _write(tmp_path / "run-2.jsonl", second_rows)
|
|
|
|
summary = MODULE.summarize(MODULE.load_results([first, second]))
|
|
|
|
clean_group = _group(summary, "detection", adapter="dwt-dct", state="clean")
|
|
assert clean_group == {
|
|
"adapter": "dwt-dct",
|
|
"state": "clean",
|
|
"unique_artifacts": 1,
|
|
"observations": 4,
|
|
"detected": 0,
|
|
"not_detected": 1,
|
|
"unavailable": 0,
|
|
"error": 0,
|
|
"unstable": 0,
|
|
}
|
|
cold = _group(summary, "timing", adapter="dwt-dct", phase="cold")
|
|
assert cold == {"adapter": "dwt-dct", "phase": "cold", "n": 2, "p50_ms": 10.0, "p90_ms": 20.0, "max_ms": 20.0}
|
|
warm = _group(summary, "timing", adapter="dwt-dct", phase="warm")
|
|
assert warm == {"adapter": "dwt-dct", "phase": "warm", "n": 6, "p50_ms": 2.0, "p90_ms": 3.5, "max_ms": 3.5}
|
|
removal = _group(summary, "removal", adapter="dwt-dct")
|
|
assert removal["unique_artifacts"] == 1
|
|
assert removal["no_recognized_signal_after_removal"] == 1
|
|
assert summary["runs"] == 2
|
|
assert summary["observations"] == 8
|
|
|
|
|
|
def test_summary_reports_unstable_errors_unavailable_and_mismatches(tmp_path: Path) -> None:
|
|
changing_path = _image(tmp_path / "changing.png", 10)
|
|
error_path = _image(tmp_path / "error.png", 20)
|
|
unavailable_path = _image(tmp_path / "unavailable.png", 30)
|
|
first_case = _row(changing_path, "changing", status="detected")
|
|
second_case = _row(
|
|
changing_path,
|
|
"changing",
|
|
status="not_detected",
|
|
)
|
|
error = _row(error_path, "error", status="error", elapsed_ms=4.0)
|
|
unavailable = _row(unavailable_path, "unavailable", status="unavailable", elapsed_ms=None)
|
|
first = _write(tmp_path / "run-1.jsonl", [first_case, error, unavailable])
|
|
second = _write(tmp_path / "run-2.jsonl", [second_case])
|
|
|
|
summary = MODULE.summarize(MODULE.load_results([first, second]))
|
|
|
|
marked = _group(summary, "detection", adapter="dwt-dct", state="marked")
|
|
assert marked["unstable"] == 1
|
|
assert marked["error"] == 1
|
|
assert marked["unavailable"] == 1
|
|
assert summary["unstable_cases"] == [
|
|
{"case_id": "changing", "adapter": "dwt-dct", "statuses": ["detected", "not_detected"]}
|
|
]
|
|
assert summary["expected_mismatches"] == [{"case_id": "changing", "adapter": "dwt-dct", "status": "not_detected"}]
|
|
|
|
|
|
def test_summary_rejects_case_identity_drift_between_runs(tmp_path: Path) -> None:
|
|
first_artifact = _image(tmp_path / "first.png", 10)
|
|
second_artifact = _image(tmp_path / "second.png", 20)
|
|
first = _write(tmp_path / "run-1.jsonl", [_row(first_artifact, "same-case")])
|
|
second = _write(tmp_path / "run-2.jsonl", [_row(second_artifact, "same-case")])
|
|
|
|
with pytest.raises(ValueError, match="case identity drift"):
|
|
MODULE.summarize(MODULE.load_results([first, second]))
|
|
|
|
|
|
def test_summary_preserves_real_cohort_provider_and_content_strata(tmp_path: Path) -> None:
|
|
first_artifact = _image(tmp_path / "first.png", 10)
|
|
second_artifact = _image(tmp_path / "second.png", 20)
|
|
first = _row(
|
|
first_artifact,
|
|
"first",
|
|
transform_parameters={
|
|
"scheme": "sdxl",
|
|
"source": {"provider": "openai", "content_stratum": "portrait"},
|
|
},
|
|
)
|
|
second = _row(
|
|
second_artifact,
|
|
"second",
|
|
status="not_detected",
|
|
transform_parameters={
|
|
"scheme": "sdxl",
|
|
"source": {"provider": "meta", "content_stratum": "portrait"},
|
|
},
|
|
)
|
|
source = _write(tmp_path / "run.jsonl", [first, second])
|
|
|
|
summary = MODULE.summarize(MODULE.load_results([source]))
|
|
|
|
openai = _group(
|
|
summary,
|
|
"detection_by_source_provider",
|
|
adapter="dwt-dct",
|
|
state="marked",
|
|
provider="openai",
|
|
)
|
|
portrait = _group(
|
|
summary,
|
|
"detection_by_content_stratum",
|
|
adapter="dwt-dct",
|
|
state="marked",
|
|
content_stratum="portrait",
|
|
)
|
|
profile = _group(
|
|
summary,
|
|
"detection_by_profile",
|
|
adapter="dwt-dct",
|
|
state="marked",
|
|
profile="sdxl",
|
|
)
|
|
assert openai["detected"] == 1
|
|
assert portrait["unique_artifacts"] == 2
|
|
assert portrait["detected"] == 1
|
|
assert portrait["not_detected"] == 1
|
|
assert profile["unique_artifacts"] == 2
|
|
|
|
|
|
def test_summary_reports_attack_transitions_from_each_marked_pair(tmp_path: Path) -> None:
|
|
marked_artifact = _image(tmp_path / "marked.png", 10)
|
|
attacked_artifact = _image(tmp_path / "attacked.png", 20)
|
|
marked = _row(marked_artifact, "pair--marked", status="not_detected")
|
|
attacked = _row(
|
|
attacked_artifact,
|
|
"pair--jpeg",
|
|
state="attacked",
|
|
status="detected",
|
|
transform="embed-then-jpeg",
|
|
expected="unresolved",
|
|
)
|
|
source = _write(tmp_path / "run.jsonl", [marked, attacked])
|
|
|
|
summary = MODULE.summarize(MODULE.load_results([source]))
|
|
|
|
transitions = _group(
|
|
summary,
|
|
"attack_transitions",
|
|
adapter="dwt-dct",
|
|
transform="embed-then-jpeg",
|
|
)
|
|
assert transitions == {
|
|
"adapter": "dwt-dct",
|
|
"transform": "embed-then-jpeg",
|
|
"pairs": 1,
|
|
"retained": 0,
|
|
"lost": 0,
|
|
"gained": 1,
|
|
"still_not_detected": 0,
|
|
"unmeasured": 0,
|
|
}
|
|
|
|
|
|
def test_loader_names_invalid_json_location(tmp_path: Path) -> None:
|
|
path = tmp_path / "broken.jsonl"
|
|
artifact = _image(tmp_path / "valid.png", 10)
|
|
path.write_text(json.dumps(_row(artifact, "valid")) + "\n{broken}\n", encoding="utf-8")
|
|
|
|
with pytest.raises(ValueError, match=r"broken\.jsonl:2: invalid JSON"):
|
|
MODULE.load_results([path])
|
|
|
|
|
|
def test_summary_rejects_a_false_erasure_certificate(tmp_path: Path) -> None:
|
|
artifact = _image(tmp_path / "removed.png", 10)
|
|
row = _row(
|
|
artifact,
|
|
"removed",
|
|
state="removed",
|
|
status="not_detected",
|
|
expected="unresolved",
|
|
)
|
|
row["removal"]["certifies_erasure"] = True
|
|
path = _write(tmp_path / "run.jsonl", [row])
|
|
|
|
with pytest.raises(ValueError, match="certifies_erasure must be false"):
|
|
MODULE.summarize(MODULE.load_results([path]))
|
|
|
|
|
|
def test_report_refuses_overwrite_and_preserves_interpretation_caveat(tmp_path: Path) -> None:
|
|
artifact = _image(tmp_path / "marked.png", 10)
|
|
source = _write(tmp_path / "run.jsonl", [_row(artifact, "marked")])
|
|
summary = MODULE.summarize(MODULE.load_results([source]))
|
|
report = MODULE.render_markdown(summary, title="Test report")
|
|
output = tmp_path / "report.md"
|
|
|
|
MODULE.write_report(output, report)
|
|
|
|
assert output.read_text(encoding="utf-8").startswith("# Test report\n")
|
|
assert "not evidence that a watermark is absent" in report
|
|
with pytest.raises(FileExistsError, match="refusing to overwrite"):
|
|
MODULE.write_report(output, report)
|
|
|
|
|
|
def test_report_accepts_audio_rows_and_reports_snr(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
if shutil.which("ffmpeg") is None:
|
|
pytest.skip("audio rows decode through the system ffmpeg")
|
|
import sys
|
|
|
|
scripts = Path(__file__).resolve().parents[1] / "scripts"
|
|
if str(scripts) not in sys.path:
|
|
sys.path.insert(0, str(scripts))
|
|
from audioseal_experiment import wav_pcm16_bytes
|
|
|
|
samples = np.linspace(-0.5, 0.5, 16000, dtype=np.float32)
|
|
artifact = tmp_path / "marked.wav"
|
|
reference = tmp_path / "clean.wav"
|
|
artifact.write_bytes(wav_pcm16_bytes(samples, 16_000))
|
|
reference.write_bytes(wav_pcm16_bytes(samples * 0.9, 16_000))
|
|
|
|
def sha(path: Path) -> str:
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
|
manifest = tmp_path / "manifest.jsonl"
|
|
manifest.write_text(
|
|
json.dumps(
|
|
{
|
|
"schema_version": 1,
|
|
"case_id": "audio-1",
|
|
"pair_id": "audio-1",
|
|
"media_type": "audio",
|
|
"adapter": "audioseal",
|
|
"arm": "positive",
|
|
"state": "marked",
|
|
"path": artifact.name,
|
|
"sha256": sha(artifact),
|
|
"reference_path": reference.name,
|
|
"reference_sha256": sha(reference),
|
|
"source_revision": "fixture@1",
|
|
"transform": {"name": "embed", "revision": "v1", "parameters": {}},
|
|
"seed": 1,
|
|
"expected": "detected",
|
|
}
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
case = BENCHMARK.load_manifest(manifest)[0]
|
|
monkeypatch.setattr(
|
|
BENCHMARK,
|
|
"default_adapters",
|
|
lambda: {"audioseal": _FakeAdapter("detected")},
|
|
)
|
|
record = BENCHMARK.evaluate_case(
|
|
case,
|
|
adapters=BENCHMARK.default_adapters(),
|
|
repository={"commit": "abc", "dirty": False},
|
|
)
|
|
|
|
result_path = tmp_path / "run.jsonl"
|
|
BENCHMARK.write_jsonl(result_path, [record])
|
|
|
|
summary = MODULE.summarize(MODULE.load_results([result_path]))
|
|
fidelity = next(row for row in summary["fidelity"] if row["adapter"] == "audioseal")
|
|
|
|
assert fidelity["measured"] == 1
|
|
assert fidelity["finite_snr"] == 1
|
|
assert fidelity["p50_snr_db"] is not None
|
|
assert fidelity["finite_psnr"] == 0
|
|
|
|
markdown = MODULE.render_markdown(summary)
|
|
assert "p50 SNR dB" in markdown
|