mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-31 09:40:38 +02:00
Reframe periodic pixel route as pipeline lattice; confirm and harden detection
The frozen periodic experts read an origin-anchored generation-pipeline lattice destroyed by a crop off the tile grid, not the crop-robust SynthID mark. Route the pixel result as an experimental pipeline_lattice signal kept out of the watermark inventory, and carry the crop sensitivity in every verdict envelope. Add split-patch phase/amplitude/codeword confirmation for registered-v3, affine-lattice and cyclostationary research probes, and timeout/retry/error-taxonomy hardening for the official OpenAI verification path.
This commit is contained in:
@@ -25,6 +25,7 @@ class TestTopLevelExports:
|
||||
assert raiw.detect_synthid is synthid_detector.detect_synthid
|
||||
assert raiw.SynthIDDetection is synthid_detector.SynthIDDetection
|
||||
assert raiw.verify_openai_synthid is openai_provenance.verify_openai_synthid
|
||||
assert raiw.OpenAIProvenanceError is openai_provenance.OpenAIProvenanceError
|
||||
assert raiw.OpenAISynthIDDetection is openai_provenance.OpenAISynthIDDetection
|
||||
|
||||
def test_unknown_attribute_raises(self):
|
||||
|
||||
+2
-1
@@ -742,6 +742,7 @@ class TestDetectSynthIDCommand:
|
||||
assert result.exit_code == 0
|
||||
assert "calibrated image sizes" in result.output
|
||||
assert "--register-scale" in result.output
|
||||
assert "--fixed-period" in result.output
|
||||
|
||||
def test_unsupported_geometry_is_machine_readable(self, runner, tmp_clean_png):
|
||||
result = runner.invoke(main, ["detect-synthid", str(tmp_clean_png), "--json"])
|
||||
@@ -782,7 +783,7 @@ class TestDetectSynthIDCommand:
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Bounded spatial-scale registration was enabled" in result.output
|
||||
assert "Bounded spatial-scale registration was explicitly enabled" in result.output
|
||||
|
||||
|
||||
class TestVerifyOpenAISynthIDCommand:
|
||||
|
||||
+17
-9
@@ -886,30 +886,38 @@ class TestIdentifyVisibleTextMarks:
|
||||
# ── Caveats and serialization ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestSynthIDPixelCarrier:
|
||||
def test_positive_pixel_carrier_is_high_confidence_ai_evidence(self, tmp_clean_png: Path):
|
||||
class TestGenerationPipelineLattice:
|
||||
def test_positive_lattice_is_ai_evidence_but_never_a_watermark(self, tmp_clean_png: Path):
|
||||
"""The lattice may support an AI verdict; it may not enter the watermark list.
|
||||
|
||||
It accepts 24% of Adobe Firefly output and dies on a seven-pixel crop, so
|
||||
reporting it beside C2PA watermark assertions would misrepresent both. The
|
||||
watermark assertion is checked by absence, because that is the failure that
|
||||
actually shipped.
|
||||
"""
|
||||
with (
|
||||
patch("remove_ai_watermarks.identify._invisible_watermark", return_value=None),
|
||||
patch("remove_ai_watermarks.identify._synthid_pixel_watermark", return_value=True),
|
||||
patch("remove_ai_watermarks.identify._pipeline_lattice", return_value=True),
|
||||
patch("remove_ai_watermarks.identify._trustmark", return_value=None),
|
||||
):
|
||||
report = identify(tmp_clean_png, check_visible=False, check_invisible=True)
|
||||
|
||||
assert report.is_ai_generated is True
|
||||
assert report.confidence == "high"
|
||||
assert any(signal.name == "synthid_pixel" for signal in report.signals)
|
||||
assert any("positive-only" in caveat for caveat in report.caveats)
|
||||
assert any(signal.name == "pipeline_lattice" for signal in report.signals)
|
||||
assert not any("synthid" in watermark.lower() for watermark in report.watermarks)
|
||||
assert not any("watermark" in watermark.lower() for watermark in report.watermarks)
|
||||
assert any("not a watermark" in caveat for caveat in report.caveats)
|
||||
|
||||
def test_negative_pixel_carrier_does_not_claim_clean(self, tmp_clean_png: Path):
|
||||
def test_negative_lattice_does_not_claim_clean(self, tmp_clean_png: Path):
|
||||
with (
|
||||
patch("remove_ai_watermarks.identify._invisible_watermark", return_value=None),
|
||||
patch("remove_ai_watermarks.identify._synthid_pixel_watermark", return_value=False),
|
||||
patch("remove_ai_watermarks.identify._pipeline_lattice", return_value=False),
|
||||
patch("remove_ai_watermarks.identify._trustmark", return_value=None),
|
||||
):
|
||||
report = identify(tmp_clean_png, check_visible=False, check_invisible=True)
|
||||
|
||||
assert report.is_ai_generated is None
|
||||
assert not any(signal.name == "synthid_pixel" for signal in report.signals)
|
||||
assert not any(signal.name == "pipeline_lattice" for signal in report.signals)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/fixtures/provenance not present")
|
||||
|
||||
@@ -20,7 +20,8 @@ class _Checks:
|
||||
self.response = response
|
||||
self.calls: list[tuple[str, bytes, str]] = []
|
||||
|
||||
def create(self, *, file: tuple[str, Any, str]) -> Any:
|
||||
def create(self, *, file: tuple[str, Any, str], timeout: float) -> Any:
|
||||
assert timeout == provenance.REQUEST_TIMEOUT_SECONDS
|
||||
filename, stream, media_type = file
|
||||
self.calls.append((filename, stream.read(), media_type))
|
||||
return self.response
|
||||
@@ -94,6 +95,8 @@ def test_detected_result_uses_only_synthid_fields(tmp_clean_png: Path) -> None:
|
||||
assert result.generated_at == "2026-07-28T18:34:12Z"
|
||||
assert result.api_created_at == 1_778_000_000
|
||||
assert "c2pa" not in result.to_dict()
|
||||
assert result.to_dict()["metadata_used_for_verdict"] is False
|
||||
assert result.to_dict()["provider_scope"] == "openai"
|
||||
|
||||
|
||||
def test_sdk_model_response_is_normalized(tmp_clean_png: Path) -> None:
|
||||
@@ -109,6 +112,28 @@ def test_sdk_model_response_is_normalized(tmp_clean_png: Path) -> None:
|
||||
assert result.status == "detected"
|
||||
|
||||
|
||||
def test_unexpected_response_object_is_an_error(tmp_clean_png: Path) -> None:
|
||||
response = _response(synthid="detected")
|
||||
response["object"] = "future_response"
|
||||
client, _checks = _client(response)
|
||||
|
||||
with pytest.raises(RuntimeError, match="unexpected 'object'"):
|
||||
_verify(tmp_clean_png, client=client)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entry", [None, {"outcome": "detected"}, {"type": 3, "outcome": "detected"}])
|
||||
def test_malformed_result_entry_is_an_error(tmp_clean_png: Path, entry: Any) -> None:
|
||||
client, _checks = _client(
|
||||
{
|
||||
"object": "content_provenance_check",
|
||||
"results": [entry],
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match=r"invalid result entry|valid type"):
|
||||
_verify(tmp_clean_png, client=client)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("image_format", "suffix", "media_type"),
|
||||
[("PNG", ".png", "image/png"), ("JPEG", ".jpg", "image/jpeg"), ("WEBP", ".webp", "image/webp")],
|
||||
@@ -138,7 +163,7 @@ def test_all_documented_image_formats_preserve_decoded_pixels(
|
||||
|
||||
@pytest.mark.parametrize("results", [[], [{"type": "c2pa", "outcome": "detected"}]])
|
||||
def test_missing_synthid_result_is_an_error(tmp_clean_png: Path, results: list[dict[str, str]]) -> None:
|
||||
client, _checks = _client({"results": results})
|
||||
client, _checks = _client({"object": "content_provenance_check", "results": results})
|
||||
|
||||
with pytest.raises(RuntimeError, match="0 SynthID results"):
|
||||
_verify(tmp_clean_png, client=client)
|
||||
@@ -147,10 +172,11 @@ def test_missing_synthid_result_is_an_error(tmp_clean_png: Path, results: list[d
|
||||
def test_duplicate_synthid_results_are_an_error(tmp_clean_png: Path) -> None:
|
||||
client, _checks = _client(
|
||||
{
|
||||
"object": "content_provenance_check",
|
||||
"results": [
|
||||
{"type": "synthid", "outcome": "detected"},
|
||||
{"type": "synthid", "outcome": "not_detected"},
|
||||
]
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -223,6 +249,28 @@ def test_upload_limit_is_checked_after_sanitizing(
|
||||
assert checks.calls == []
|
||||
|
||||
|
||||
def test_upload_limit_allows_exact_boundary(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_clean_png: Path,
|
||||
) -> None:
|
||||
from remove_ai_watermarks import metadata
|
||||
|
||||
client, checks = _client(_response(synthid="not_detected"))
|
||||
|
||||
def copy_clean(source: Path, output: Path, *, keep_standard: bool) -> tuple[Path, dict[str, str]]:
|
||||
assert keep_standard is True
|
||||
output.write_bytes(source.read_bytes())
|
||||
return output, {}
|
||||
|
||||
monkeypatch.setattr(metadata, "strip_and_verify", copy_clean)
|
||||
monkeypatch.setattr(provenance, "MAX_UPLOAD_BYTES", tmp_clean_png.stat().st_size)
|
||||
|
||||
result = _verify(tmp_clean_png, client=client)
|
||||
|
||||
assert result.status == "not_detected"
|
||||
assert len(checks.calls) == 1
|
||||
|
||||
|
||||
def test_missing_optional_sdk_has_install_hint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_clean_png: Path,
|
||||
@@ -237,7 +285,7 @@ def test_client_configuration_error_is_actionable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_clean_png: Path,
|
||||
) -> None:
|
||||
def fail() -> None:
|
||||
def fail(**_kwargs: Any) -> None:
|
||||
raise ValueError("OPENAI_API_KEY is missing")
|
||||
|
||||
monkeypatch.setattr(provenance, "is_available", lambda: True)
|
||||
@@ -247,9 +295,36 @@ def test_client_configuration_error_is_actionable(
|
||||
_verify(tmp_clean_png)
|
||||
|
||||
|
||||
def test_default_client_bounds_one_acknowledged_upload(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
expected = SimpleNamespace(content_provenance_checks=object())
|
||||
|
||||
def factory(**kwargs: Any) -> Any:
|
||||
calls.append(kwargs)
|
||||
return expected
|
||||
|
||||
monkeypatch.setattr(provenance, "is_available", lambda: True)
|
||||
monkeypatch.setattr(provenance.importlib, "import_module", lambda _name: SimpleNamespace(OpenAI=factory))
|
||||
|
||||
assert provenance._default_client() is expected
|
||||
assert calls == [
|
||||
{
|
||||
"timeout": provenance.REQUEST_TIMEOUT_SECONDS,
|
||||
"max_retries": 0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "message"),
|
||||
[(400, "rejected"), (404, "does not have"), (429, "rate limit")],
|
||||
[
|
||||
(400, "rejected"),
|
||||
(401, "authentication failed"),
|
||||
(403, "not permitted"),
|
||||
(404, "does not have"),
|
||||
(429, "rate limit"),
|
||||
(500, "temporary server error"),
|
||||
],
|
||||
)
|
||||
def test_documented_api_errors_are_actionable(
|
||||
tmp_clean_png: Path,
|
||||
@@ -263,10 +338,92 @@ def test_documented_api_errors_are_actionable(
|
||||
error.status_code = status_code # type: ignore[attr-defined]
|
||||
|
||||
class FailingChecks:
|
||||
def create(self, *, file: tuple[str, Any, str]) -> None:
|
||||
def create(self, *, file: tuple[str, Any, str], timeout: float) -> None:
|
||||
assert timeout == provenance.REQUEST_TIMEOUT_SECONDS
|
||||
raise error
|
||||
|
||||
client = SimpleNamespace(content_provenance_checks=FailingChecks())
|
||||
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
_verify(tmp_clean_png, client=client)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error_name", "message"),
|
||||
[("APITimeoutError", "timed out"), ("APIConnectionError", "could not be reached")],
|
||||
)
|
||||
def test_transport_errors_are_actionable(
|
||||
tmp_clean_png: Path,
|
||||
error_name: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
error_type = type(error_name, (Exception,), {})
|
||||
|
||||
class FailingChecks:
|
||||
def create(self, *, file: tuple[str, Any, str], timeout: float) -> None:
|
||||
assert timeout == provenance.REQUEST_TIMEOUT_SECONDS
|
||||
raise error_type("details")
|
||||
|
||||
client = SimpleNamespace(content_provenance_checks=FailingChecks())
|
||||
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
_verify(tmp_clean_png, client=client)
|
||||
|
||||
|
||||
def test_rate_limit_error_preserves_retry_context(tmp_clean_png: Path) -> None:
|
||||
class RateLimitError(Exception):
|
||||
status_code = 429
|
||||
code = "rate_limit_exceeded"
|
||||
request_id = "req_test"
|
||||
response = SimpleNamespace(headers={"retry-after": "7"})
|
||||
|
||||
class FailingChecks:
|
||||
def create(self, *, file: tuple[str, Any, str], timeout: float) -> None:
|
||||
assert timeout == provenance.REQUEST_TIMEOUT_SECONDS
|
||||
raise RateLimitError("details")
|
||||
|
||||
client = SimpleNamespace(content_provenance_checks=FailingChecks())
|
||||
|
||||
with pytest.raises(provenance.OpenAIProvenanceError, match="Retry-After: 7") as raised:
|
||||
_verify(tmp_clean_png, client=client)
|
||||
assert raised.value.status_code == 429
|
||||
assert raised.value.error_code == "rate_limit_exceeded"
|
||||
assert raised.value.request_id == "req_test"
|
||||
assert raised.value.retry_after == "7"
|
||||
assert raised.value.retryable is True
|
||||
|
||||
|
||||
def test_client_error_is_not_marked_retryable(tmp_clean_png: Path) -> None:
|
||||
class BadRequestError(Exception):
|
||||
status_code = 400
|
||||
code = "invalid_image"
|
||||
|
||||
class FailingChecks:
|
||||
def create(self, *, file: tuple[str, Any, str], timeout: float) -> None:
|
||||
assert timeout == provenance.REQUEST_TIMEOUT_SECONDS
|
||||
raise BadRequestError("details")
|
||||
|
||||
client = SimpleNamespace(content_provenance_checks=FailingChecks())
|
||||
|
||||
with pytest.raises(provenance.OpenAIProvenanceError) as raised:
|
||||
_verify(tmp_clean_png, client=client)
|
||||
assert raised.value.status_code == 400
|
||||
assert raised.value.error_code == "invalid_image"
|
||||
assert raised.value.retryable is False
|
||||
|
||||
|
||||
def test_keyboard_interrupt_is_not_wrapped_or_retried(tmp_clean_png: Path) -> None:
|
||||
class InterruptingChecks:
|
||||
calls = 0
|
||||
|
||||
def create(self, *, file: tuple[str, Any, str], timeout: float) -> None:
|
||||
assert timeout == provenance.REQUEST_TIMEOUT_SECONDS
|
||||
self.calls += 1
|
||||
raise KeyboardInterrupt
|
||||
|
||||
checks = InterruptingChecks()
|
||||
client = SimpleNamespace(content_provenance_checks=checks)
|
||||
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
_verify(tmp_clean_png, client=client)
|
||||
assert checks.calls == 1
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
import synthid_affine_lattice_probe as probe
|
||||
|
||||
from remove_ai_watermarks._synthid_confirmation import RegisteredConfirmationComponents
|
||||
|
||||
|
||||
def test_webp_lossless_round_trip_preserves_pixels() -> None:
|
||||
rng = np.random.default_rng(20260817)
|
||||
pixels = rng.integers(0, 256, (64, 64, 3), dtype=np.uint8)
|
||||
|
||||
restored = probe._webp_round_trip(pixels, 101)
|
||||
|
||||
assert np.array_equal(restored, pixels)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def periodic_fixture() -> tuple[np.ndarray, np.ndarray]:
|
||||
rng = np.random.default_rng(20260814)
|
||||
template = rng.normal(0.0, 1.0, (16, 16, 3))
|
||||
template -= np.mean(template, axis=(0, 1), keepdims=True)
|
||||
template /= np.linalg.norm(template)
|
||||
coarse = rng.normal(0.0, 8.0, (16, 16, 3)).astype(np.float32)
|
||||
background = cv2.resize(coarse, (1024, 1024), interpolation=cv2.INTER_CUBIC) + 128.0
|
||||
carrier = np.tile(template, (64, 64, 1)) * 3.0
|
||||
pixels = np.clip(np.rint(background + carrier), 0, 255).astype(np.uint8)
|
||||
return pixels, template
|
||||
|
||||
|
||||
def _score(pixels: np.ndarray, template: np.ndarray) -> probe.LatticeScore:
|
||||
return probe.score_lattice(
|
||||
pixels,
|
||||
template,
|
||||
periods=np.arange(12.0, 20.01, 0.25),
|
||||
rotations_degrees=np.asarray([-1.0, 0.0, 1.0]),
|
||||
patch_size=256,
|
||||
grid_size=4,
|
||||
harmonic_count=12,
|
||||
)
|
||||
|
||||
|
||||
def test_period_alias_candidates_include_base_and_half_period_neighbors() -> None:
|
||||
periods = np.arange(7.5, 24.501, 0.1)
|
||||
rotations = np.zeros_like(periods)
|
||||
base_index = int(np.argmin(np.abs(periods - 19.2)))
|
||||
|
||||
candidates = probe._period_alias_candidate_indices(periods, rotations, [base_index])
|
||||
|
||||
assert [periods[index] for index in candidates] == pytest.approx([19.1, 19.2, 19.3, 9.5, 9.6, 9.7])
|
||||
|
||||
|
||||
def test_split_lattice_recovers_periodic_carrier(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
|
||||
pixels, template = periodic_fixture
|
||||
|
||||
result = _score(pixels, template)
|
||||
|
||||
assert result.selected_period == pytest.approx(16.0, abs=0.25)
|
||||
assert result.selected_rotation_degrees == 0.0
|
||||
assert result.confirmation_coherence > 0.9
|
||||
assert result.joint_coherence > 0.9
|
||||
assert result.joint_codeword > 0.8
|
||||
assert result.unknown_codeword_confirmation > 0.8
|
||||
assert result.unknown_codeword_fixed_confirmation > 0.8
|
||||
assert result.unknown_codeword_fixed_all > 0.8
|
||||
assert result.unknown_codeword_excess_p99 > 0.0
|
||||
assert result.joint_amplitude > 0.8
|
||||
assert result.joint_whitened_match > 0.8
|
||||
assert result.canonical_template_score > 0.8
|
||||
assert result.canonical_registered_template_score > 0.8
|
||||
assert result.confirmation_excess_p99 > 0.0
|
||||
assert result.selection_patches == result.confirmation_patches == 8
|
||||
|
||||
|
||||
def test_split_lattice_rejects_independent_noise(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
|
||||
_pixels, template = periodic_fixture
|
||||
rng = np.random.default_rng(20260815)
|
||||
noise = rng.integers(0, 256, (1024, 1024, 3), dtype=np.uint8)
|
||||
|
||||
result = _score(noise, template)
|
||||
|
||||
assert result.confirmation_coherence < 0.8
|
||||
assert result.joint_coherence < 0.8
|
||||
assert result.joint_codeword < 0.8
|
||||
assert result.unknown_codeword_confirmation < 0.2
|
||||
assert result.unknown_codeword_fixed_confirmation < 0.2
|
||||
assert result.unknown_codeword_fixed_all < 0.2
|
||||
assert result.joint_amplitude < 0.2
|
||||
assert result.joint_whitened_match < 0.2
|
||||
assert result.canonical_template_score < 0.2
|
||||
assert result.canonical_registered_template_score < 0.2
|
||||
assert result.confirmation_excess_p99 < 0.0
|
||||
|
||||
|
||||
def test_split_lattice_tracks_resized_period(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
|
||||
pixels, template = periodic_fixture
|
||||
resized = cv2.resize(pixels, (819, 819), interpolation=cv2.INTER_CUBIC)
|
||||
|
||||
result = probe.score_lattice(
|
||||
resized,
|
||||
template,
|
||||
periods=np.arange(7.5, 24.501, 0.1),
|
||||
rotations_degrees=np.asarray([0.0]),
|
||||
patch_size=192,
|
||||
grid_size=4,
|
||||
harmonic_count=12,
|
||||
)
|
||||
|
||||
assert result.selected_period == pytest.approx(12.8, abs=0.3)
|
||||
assert result.confirmation_coherence > 0.8
|
||||
assert result.joint_amplitude > 0.8
|
||||
assert result.joint_whitened_match > 0.8
|
||||
assert result.unknown_codeword_confirmation > 0.8
|
||||
assert result.unknown_codeword_fixed_confirmation > 0.8
|
||||
assert result.unknown_codeword_fixed_all > 0.8
|
||||
|
||||
|
||||
def test_split_lattice_tracks_octave_aliased_resize(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
|
||||
pixels, template = periodic_fixture
|
||||
resized = cv2.resize(pixels, (614, 614), interpolation=cv2.INTER_AREA)
|
||||
|
||||
result = probe.score_lattice(
|
||||
resized,
|
||||
template,
|
||||
periods=np.arange(7.5, 24.501, 0.1),
|
||||
rotations_degrees=np.asarray([0.0]),
|
||||
patch_size=192,
|
||||
grid_size=4,
|
||||
harmonic_count=12,
|
||||
)
|
||||
|
||||
assert result.selected_period == pytest.approx(9.6, abs=0.15)
|
||||
assert result.canonical_template_score > 0.4
|
||||
|
||||
|
||||
def test_same_image_period_null_prefers_the_carrier_period(
|
||||
periodic_fixture: tuple[np.ndarray, np.ndarray],
|
||||
) -> None:
|
||||
pixels, template = periodic_fixture
|
||||
|
||||
correct = probe.score_same_image_period_null(pixels, template, 16.0, harmonic_count=12)
|
||||
off_period = probe.score_same_image_period_null(pixels, template, 15.0, harmonic_count=12)
|
||||
|
||||
assert correct.joint_excess > 0.2
|
||||
assert correct.joint_excess > off_period.joint_excess
|
||||
assert correct.off_period_count == len(probe.SAME_IMAGE_NULL_OFFSETS)
|
||||
|
||||
|
||||
def test_patch_shift_consensus_confirms_global_carrier_phase(
|
||||
periodic_fixture: tuple[np.ndarray, np.ndarray],
|
||||
) -> None:
|
||||
pixels, template = periodic_fixture
|
||||
rng = np.random.default_rng(20260818)
|
||||
noise = rng.integers(0, 256, pixels.shape, dtype=np.uint8)
|
||||
|
||||
carrier = probe.score_patch_shift_consensus(pixels, template, 16.0)
|
||||
control = probe.score_patch_shift_consensus(noise, template, 16.0)
|
||||
|
||||
assert carrier.joint_trimmed_z > control.joint_trimmed_z
|
||||
assert carrier.joint_support_fraction == 1.0
|
||||
assert carrier.selection_patches == carrier.confirmation_patches == 8
|
||||
|
||||
|
||||
def test_patch_shift_recovery_uses_frozen_mechanism_gates() -> None:
|
||||
baseline = {
|
||||
"amplitude_margin": 0.8,
|
||||
"high_band_margin": 1.0,
|
||||
"periods_agree": True,
|
||||
"confirmation_passes": True,
|
||||
"joint_trimmed_z": 2.5,
|
||||
}
|
||||
|
||||
assert probe.patch_shift_recovery_passes(**baseline)
|
||||
for field, failed_value in (
|
||||
("amplitude_margin", 0.449),
|
||||
("high_band_margin", 0.449),
|
||||
("periods_agree", False),
|
||||
("confirmation_passes", False),
|
||||
("joint_trimmed_z", 2.499),
|
||||
):
|
||||
candidate = {**baseline, field: failed_value}
|
||||
assert not probe.patch_shift_recovery_passes(**candidate)
|
||||
assert not probe.patch_shift_recovery_passes(**{**baseline, "amplitude_margin": 0.99, "high_band_margin": 0.99})
|
||||
|
||||
|
||||
def test_opponent_registration_recovers_resampled_carrier(
|
||||
periodic_fixture: tuple[np.ndarray, np.ndarray],
|
||||
) -> None:
|
||||
pixels, template = periodic_fixture
|
||||
resized = cv2.resize(pixels, (717, 717), interpolation=cv2.INTER_AREA)
|
||||
rng = np.random.default_rng(20260819)
|
||||
noise = rng.integers(0, 256, resized.shape, dtype=np.uint8)
|
||||
periods = np.arange(10.0, 12.41, 0.05)
|
||||
|
||||
carrier = probe.score_opponent_registered(resized, template, periods=periods)
|
||||
control = probe.score_opponent_registered(noise, template, periods=periods)
|
||||
|
||||
assert carrier.selected_period == pytest.approx(11.2, abs=0.1)
|
||||
assert carrier.decision_score > 1.0
|
||||
assert control.decision_score < 1.0
|
||||
|
||||
|
||||
def test_split_lattice_aligns_cyclic_carrier_phase(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
|
||||
pixels, template = periodic_fixture
|
||||
shifted = np.roll(pixels, shift=(3, 5), axis=(0, 1))
|
||||
|
||||
result = _score(shifted, template)
|
||||
|
||||
assert (result.selected_shift_y, result.selected_shift_x) == (3, 5)
|
||||
assert (result.amplitude_shift_y, result.amplitude_shift_x) == (13, 11)
|
||||
assert result.canonical_template_score < 0.2
|
||||
assert result.canonical_registered_template_score > 0.8
|
||||
assert result.joint_whitened_match < 0.4
|
||||
assert result.unknown_codeword_confirmation > 0.8
|
||||
assert result.unknown_codeword_fixed_confirmation > 0.8
|
||||
assert result.unknown_codeword_fixed_all > 0.8
|
||||
|
||||
|
||||
def test_split_lattice_recovers_cropped_carrier_phase(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
|
||||
pixels, template = periodic_fixture
|
||||
cropped = pixels[37:, 53:]
|
||||
|
||||
result = probe.score_lattice(
|
||||
cropped,
|
||||
template,
|
||||
periods=np.asarray([16.0]),
|
||||
rotations_degrees=np.asarray([0.0]),
|
||||
patch_size=256,
|
||||
grid_size=4,
|
||||
harmonic_count=12,
|
||||
)
|
||||
|
||||
assert result.selected_period == pytest.approx(16.0, abs=0.25)
|
||||
assert result.joint_coherence > 0.8
|
||||
assert result.unknown_codeword_fixed_all > 0.8
|
||||
assert result.canonical_template_score < 0.2
|
||||
assert result.canonical_registered_template_score > 0.8
|
||||
|
||||
|
||||
def test_orientation_bank_recovers_right_angle_rotation(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
|
||||
pixels, template = periodic_fixture
|
||||
rotated_clockwise = np.rot90(pixels, k=-1)
|
||||
|
||||
result = probe.score_orientation_bank(
|
||||
rotated_clockwise,
|
||||
template,
|
||||
periods=np.asarray([16.0]),
|
||||
rotations_degrees=np.asarray([0.0]),
|
||||
patch_size=256,
|
||||
grid_size=4,
|
||||
harmonic_count=12,
|
||||
)
|
||||
|
||||
assert result.selected_orientation_degrees == 90
|
||||
assert result.joint_amplitude > 0.8
|
||||
assert result.canonical_template_score > 0.8
|
||||
|
||||
|
||||
def test_dihedral_bank_recovers_horizontal_reflection(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
|
||||
pixels, template = periodic_fixture
|
||||
|
||||
result = probe.score_dihedral_bank(
|
||||
np.fliplr(pixels),
|
||||
template,
|
||||
periods=np.asarray([16.0]),
|
||||
rotations_degrees=np.asarray([0.0]),
|
||||
patch_size=256,
|
||||
grid_size=4,
|
||||
harmonic_count=12,
|
||||
)
|
||||
|
||||
assert result.selected_orientation_degrees == 0
|
||||
assert result.selected_horizontal_reflection is True
|
||||
assert result.joint_amplitude > 0.8
|
||||
assert result.canonical_template_score > 0.8
|
||||
|
||||
|
||||
def test_deskew_bank_recovers_small_rotation(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
|
||||
pixels, template = periodic_fixture
|
||||
rotated = probe._rotate_fixed_canvas(pixels, 1.5)
|
||||
|
||||
result = probe.score_deskew_bank(
|
||||
rotated,
|
||||
template,
|
||||
periods=np.asarray([16.0]),
|
||||
deskew_degrees=np.asarray([-2.0, -1.5, -1.0]),
|
||||
patch_size=256,
|
||||
grid_size=4,
|
||||
harmonic_count=12,
|
||||
)
|
||||
|
||||
assert result.selected_deskew_degrees == -1.5
|
||||
assert result.joint_amplitude > 0.6
|
||||
assert result.canonical_template_score > 0.6
|
||||
assert result.deskew_direct_joint_match > 0.4
|
||||
|
||||
|
||||
def test_registered_period_mode_uses_runtime_selected_period(
|
||||
periodic_fixture: tuple[np.ndarray, np.ndarray],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
pixels, template = periodic_fixture
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem():
|
||||
np.savez("template.npz", template=template)
|
||||
Image.fromarray(pixels).save("image.png")
|
||||
components = probe.RegisteredComponents(
|
||||
raw_score=0.4,
|
||||
amplitude_threshold=0.2,
|
||||
selected_period=16.0,
|
||||
spectral_period=16.0,
|
||||
high_band_score=0.15,
|
||||
confirmation=RegisteredConfirmationComponents(
|
||||
period=16.0,
|
||||
joint_coherence=0.5,
|
||||
joint_amplitude=0.2,
|
||||
unknown_codeword_fixed_confirmation=0.5,
|
||||
selection_patches=8,
|
||||
confirmation_patches=8,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(probe, "registered_components", lambda *_args: components)
|
||||
|
||||
result = runner.invoke(
|
||||
probe.main,
|
||||
[
|
||||
"template.npz",
|
||||
"image.png",
|
||||
"--registered-period",
|
||||
"--same-image-null",
|
||||
"--patch-shift-consensus",
|
||||
"--opponent-registered",
|
||||
"--report-out",
|
||||
"report.json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
report = json.loads(Path("report.json").read_text(encoding="utf-8"))
|
||||
assert report["registered_period"] is True
|
||||
assert report["same_image_null"] is True
|
||||
assert report["patch_shift_consensus"] is True
|
||||
assert report["opponent_registered"] is True
|
||||
assert report["records"][0]["registered"]["selected_period"] == 16.0
|
||||
assert report["records"][0]["registered"]["decision_score"] == 2.0
|
||||
assert report["records"][0]["score"]["selected_period"] == 16.0
|
||||
assert report["records"][0]["same_image_null"]["joint_excess"] > 0.2
|
||||
assert report["records"][0]["patch_shift_consensus"]["joint_support_fraction"] == 1.0
|
||||
assert report["records"][0]["opponent_registered"]["decision_score"] > 1.0
|
||||
|
||||
|
||||
def test_registered_confirmation_uses_frozen_period_aware_gates(
|
||||
periodic_fixture: tuple[np.ndarray, np.ndarray],
|
||||
) -> None:
|
||||
pixels, template = periodic_fixture
|
||||
baseline = _score(pixels, template)
|
||||
generic = replace(
|
||||
baseline,
|
||||
selected_period=16.0,
|
||||
joint_coherence=0.30,
|
||||
joint_amplitude=0.0,
|
||||
)
|
||||
|
||||
assert probe.registered_confirmation_passes(generic)
|
||||
assert not probe.registered_confirmation_passes(replace(generic, selected_period=9.99))
|
||||
assert not probe.registered_confirmation_passes(replace(generic, joint_coherence=0.299))
|
||||
assert not probe.registered_confirmation_passes(replace(generic, joint_amplitude=-0.001))
|
||||
assert not probe.registered_confirmation_passes(
|
||||
replace(generic, selected_period=18.28, unknown_codeword_fixed_confirmation=0.129)
|
||||
)
|
||||
assert probe.registered_confirmation_passes(
|
||||
replace(generic, selected_period=18.28, unknown_codeword_fixed_confirmation=0.13)
|
||||
)
|
||||
assert not probe.registered_confirmation_passes(replace(generic, selected_period=19.14, joint_coherence=0.399))
|
||||
assert probe.registered_confirmation_passes(replace(generic, selected_period=19.14, joint_coherence=0.40))
|
||||
assert not probe.registered_confirmation_passes(
|
||||
replace(generic, selected_period=21.31, unknown_codeword_fixed_confirmation=0.019)
|
||||
)
|
||||
assert probe.registered_confirmation_passes(
|
||||
replace(generic, selected_period=21.31, unknown_codeword_fixed_confirmation=0.02)
|
||||
)
|
||||
|
||||
|
||||
def test_fixed_candidate_uses_frozen_precision_threshold() -> None:
|
||||
assert not probe.fixed_candidate_passes(0.279999)
|
||||
assert probe.fixed_candidate_passes(0.28)
|
||||
assert not probe.fixed_candidate_passes(float("nan"))
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
import synthid_affine_lattice_probe as research_probe
|
||||
|
||||
from remove_ai_watermarks._synthid_confirmation import (
|
||||
RegisteredConfirmationComponents,
|
||||
registered_confirmation_components,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def periodic_fixture() -> tuple[np.ndarray, np.ndarray]:
|
||||
rng = np.random.default_rng(20260818)
|
||||
template = rng.normal(0.0, 1.0, (16, 16, 3))
|
||||
template -= np.mean(template, axis=(0, 1), keepdims=True)
|
||||
template /= np.linalg.norm(template)
|
||||
coarse = rng.normal(0.0, 8.0, (16, 16, 3)).astype(np.float32)
|
||||
background = cv2.resize(coarse, (1024, 1024), interpolation=cv2.INTER_CUBIC) + 128.0
|
||||
carrier = np.tile(template, (64, 64, 1)) * 3.0
|
||||
pixels = np.clip(np.rint(background + carrier), 0, 255).astype(np.uint8)
|
||||
return pixels, template
|
||||
|
||||
|
||||
def test_runtime_components_match_frozen_research_seam(
|
||||
periodic_fixture: tuple[np.ndarray, np.ndarray],
|
||||
) -> None:
|
||||
pixels, template = periodic_fixture
|
||||
|
||||
runtime = registered_confirmation_components(pixels, template, 16.0, 1.0)
|
||||
research = research_probe.score_lattice(
|
||||
pixels,
|
||||
template,
|
||||
periods=np.asarray([16.0]),
|
||||
rotations_degrees=np.asarray([0.0]),
|
||||
)
|
||||
|
||||
assert runtime.period == research.selected_period
|
||||
assert runtime.joint_coherence == pytest.approx(research.joint_coherence)
|
||||
assert runtime.joint_amplitude == pytest.approx(research.joint_amplitude)
|
||||
assert runtime.unknown_codeword_fixed_confirmation == pytest.approx(research.unknown_codeword_fixed_confirmation)
|
||||
assert runtime.selection_patches == research.selection_patches
|
||||
assert runtime.confirmation_patches == research.confirmation_patches
|
||||
assert runtime.passes
|
||||
|
||||
|
||||
def test_confirmation_rejects_independent_noise(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
|
||||
_pixels, template = periodic_fixture
|
||||
pixels = np.random.default_rng(20260819).integers(0, 256, (1024, 1024, 3), dtype=np.uint8)
|
||||
|
||||
result = registered_confirmation_components(pixels, template, 16.0, 1.0)
|
||||
|
||||
assert not result.passes
|
||||
|
||||
|
||||
def test_period_aware_confirmation_boundaries() -> None:
|
||||
baseline = RegisteredConfirmationComponents(
|
||||
period=16.0,
|
||||
joint_coherence=0.30,
|
||||
joint_amplitude=0.0,
|
||||
unknown_codeword_fixed_confirmation=0.5,
|
||||
selection_patches=8,
|
||||
confirmation_patches=8,
|
||||
)
|
||||
|
||||
assert baseline.passes
|
||||
assert not replace(baseline, period=9.99).passes
|
||||
assert not replace(baseline, joint_coherence=0.299).passes
|
||||
assert not replace(baseline, joint_amplitude=-0.001).passes
|
||||
assert not replace(baseline, period=18.28, unknown_codeword_fixed_confirmation=0.129).passes
|
||||
assert replace(baseline, period=18.28, unknown_codeword_fixed_confirmation=0.13).passes
|
||||
assert not replace(baseline, period=19.14, joint_coherence=0.399).passes
|
||||
assert replace(baseline, period=19.14, joint_coherence=0.40).passes
|
||||
assert not replace(baseline, period=21.31, unknown_codeword_fixed_confirmation=0.019).passes
|
||||
assert replace(baseline, period=21.31, unknown_codeword_fixed_confirmation=0.02).passes
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
import synthid_cyclostationary_probe as probe
|
||||
|
||||
|
||||
def _template() -> np.ndarray:
|
||||
_y, x = np.indices((16, 16))
|
||||
carrier = np.cos(2.0 * np.pi * 4.0 * x / 16.0)
|
||||
template = np.stack((carrier, 0.8 * carrier, 0.6 * carrier), axis=2)
|
||||
template -= np.mean(template, axis=(0, 1), keepdims=True)
|
||||
return template / np.linalg.norm(template)
|
||||
|
||||
|
||||
def test_detects_complex_spectral_coupling() -> None:
|
||||
rng = np.random.default_rng(20260814)
|
||||
base = rng.normal(0.0, 1.0, (1024, 1024, 3))
|
||||
_y, x = np.indices(base.shape[:2])
|
||||
modulation = 1.0 + 0.8 * np.cos(2.0 * np.pi * 4.0 * x / 16.0)
|
||||
|
||||
result = probe.score_cyclostationary(
|
||||
base * modulation[:, :, None],
|
||||
_template(),
|
||||
period=16.0,
|
||||
harmonic_count=1,
|
||||
)
|
||||
|
||||
assert result.selection_contrast > 0.1
|
||||
assert result.confirmation_contrast > 0.1
|
||||
assert result.joint_contrast > 0.1
|
||||
|
||||
|
||||
def test_rejects_independent_equal_power_noise() -> None:
|
||||
rng = np.random.default_rng(20260815)
|
||||
noise = rng.normal(0.0, 1.0, (1024, 1024, 3))
|
||||
|
||||
result = probe.score_cyclostationary(
|
||||
noise,
|
||||
_template(),
|
||||
period=16.0,
|
||||
harmonic_count=1,
|
||||
)
|
||||
|
||||
assert result.joint_contrast < 0.01
|
||||
|
||||
|
||||
def test_does_not_confuse_additive_carrier_with_modulation() -> None:
|
||||
rng = np.random.default_rng(20260816)
|
||||
noise = rng.normal(0.0, 1.0, (1024, 1024, 3))
|
||||
additive = np.tile(_template(), (64, 64, 1)) * 2.0
|
||||
|
||||
result = probe.score_cyclostationary(
|
||||
noise + additive,
|
||||
_template(),
|
||||
period=16.0,
|
||||
harmonic_count=1,
|
||||
)
|
||||
|
||||
assert result.joint_contrast < 0.01
|
||||
+360
-19
@@ -47,6 +47,63 @@ def registered_scale_positive(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def opponent_registered_positive(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""Create a strong period-10 opponent-color fallback fixture."""
|
||||
import cv2
|
||||
|
||||
directory = tmp_path_factory.mktemp("synthid-opponent-registered")
|
||||
template, *_model = detector._load_template()
|
||||
scaled_tile = template / np.max(np.abs(template)) * 40.0
|
||||
source = np.tile(scaled_tile, (128, 128, 1)) + 128.0
|
||||
pixels = cv2.resize(
|
||||
np.clip(np.rint(source), 0, 255).astype(np.uint8),
|
||||
(1280, 1280),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
path = directory / "period-10-positive.png"
|
||||
Image.fromarray(pixels, "RGB").save(path)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def opponent_period8_positive(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""Create a strong period-8 fallback fixture without native JPEG block edges."""
|
||||
import cv2
|
||||
|
||||
directory = tmp_path_factory.mktemp("synthid-opponent-period8")
|
||||
template, *_model = detector._load_template()
|
||||
scaled_tile = template / np.max(np.abs(template)) * 40.0
|
||||
source = np.tile(scaled_tile, (128, 128, 1)) + 128.0
|
||||
pixels = cv2.resize(
|
||||
np.clip(np.rint(source), 0, 255).astype(np.uint8),
|
||||
(1024, 1024),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
path = directory / "period-8-positive.png"
|
||||
Image.fromarray(pixels, "RGB").save(path)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def fine_opponent_registered_positive(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""Create a strong period-7.68 carrier missed by the coarse period grid."""
|
||||
import cv2
|
||||
|
||||
directory = tmp_path_factory.mktemp("synthid-fine-opponent-registered")
|
||||
template, *_model = detector._load_template()
|
||||
scaled_tile = template / np.max(np.abs(template)) * 40.0
|
||||
source = np.tile(scaled_tile, (144, 144, 1)) + 128.0
|
||||
pixels = cv2.resize(
|
||||
np.clip(np.rint(source), 0, 255).astype(np.uint8),
|
||||
(1106, 1106),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
path = directory / "period-7.68-positive.png"
|
||||
Image.fromarray(pixels, "RGB").save(path)
|
||||
return path
|
||||
|
||||
|
||||
def test_bundled_model_is_the_frozen_calibrated_artifact() -> None:
|
||||
model = Path(detector.__file__).parent / "assets" / detector.MODEL_FILENAME
|
||||
|
||||
@@ -79,9 +136,11 @@ def test_geometry_outside_the_challenged_pixel_count_range_is_unsupported(
|
||||
[
|
||||
(500, 500, True),
|
||||
(4000, 2500, True),
|
||||
(64, 3907, True),
|
||||
(256, 977, True),
|
||||
(499, 500, False),
|
||||
(4001, 2500, False),
|
||||
(255, 981, False),
|
||||
(64, 3907, False),
|
||||
(32, 7813, False),
|
||||
],
|
||||
)
|
||||
@@ -93,6 +152,42 @@ def test_registered_geometry_uses_its_measured_pixel_count_range(
|
||||
assert detector._registered_geometry_supported(width, height) is supported
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("width", "height", "supported"),
|
||||
[
|
||||
(1000, 1000, True),
|
||||
(4000, 2500, True),
|
||||
(767, 1304, False),
|
||||
(1000, 999, False),
|
||||
(4001, 2500, False),
|
||||
],
|
||||
)
|
||||
def test_opponent_registered_geometry_uses_its_frozen_domain(
|
||||
width: int,
|
||||
height: int,
|
||||
supported: bool,
|
||||
) -> None:
|
||||
assert detector._opponent_registered_geometry_supported(width, height) is supported
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("width", "height", "supported"),
|
||||
[
|
||||
(1000, 1000, True),
|
||||
(2500, 2000, True),
|
||||
(767, 1304, False),
|
||||
(1000, 999, False),
|
||||
(2501, 2000, False),
|
||||
],
|
||||
)
|
||||
def test_fine_opponent_registered_geometry_uses_its_frozen_domain(
|
||||
width: int,
|
||||
height: int,
|
||||
supported: bool,
|
||||
) -> None:
|
||||
assert detector._fine_opponent_registered_geometry_supported(width, height) is supported
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("width", "height", "supported"),
|
||||
[
|
||||
@@ -162,7 +257,7 @@ def test_large_red_green_gate_mutation_changes_the_real_verdict(
|
||||
|
||||
assert baseline.status == "detected"
|
||||
assert baseline.detector == detector.LARGE_DETECTOR_ID
|
||||
assert mutated.status == "not_detected"
|
||||
assert mutated.status == "indeterminate"
|
||||
|
||||
|
||||
def test_uncalibrated_narrow_large_geometry_is_unsupported() -> None:
|
||||
@@ -189,7 +284,7 @@ def test_registered_mode_rejects_a_side_too_short_for_quadrants(tmp_path: Path)
|
||||
def test_detects_supported_periodic_carrier(supported_images: tuple[Path, Path]) -> None:
|
||||
positive, _negative = supported_images
|
||||
|
||||
result = detector.detect_synthid(positive)
|
||||
result = detector.detect_synthid(positive, register_scale=False)
|
||||
|
||||
assert result.status == "detected"
|
||||
assert result.detected is True
|
||||
@@ -209,7 +304,7 @@ def test_detects_unregistered_non_divisible_geometry_in_size_range(tmp_path: Pat
|
||||
path = tmp_path / "non-divisible-positive.png"
|
||||
Image.fromarray(pixels, "RGB").save(path)
|
||||
|
||||
result = detector.detect_synthid(path)
|
||||
result = detector.detect_synthid(path, register_scale=False)
|
||||
|
||||
assert result.status == "detected"
|
||||
assert (result.width, result.height) == (width, height)
|
||||
@@ -218,10 +313,12 @@ def test_detects_unregistered_non_divisible_geometry_in_size_range(tmp_path: Pat
|
||||
|
||||
|
||||
def test_registered_mode_detects_a_rescaled_carrier(registered_scale_positive: Path) -> None:
|
||||
fixed = detector.detect_synthid(registered_scale_positive, register_scale=False)
|
||||
default = detector.detect_synthid(registered_scale_positive)
|
||||
registered = detector.detect_synthid(registered_scale_positive, register_scale=True)
|
||||
|
||||
assert default.status == "unsupported"
|
||||
assert fixed.status == "unsupported"
|
||||
assert default == registered
|
||||
assert registered.status == "detected"
|
||||
assert registered.score is not None
|
||||
assert registered.score > registered.threshold
|
||||
@@ -229,6 +326,167 @@ def test_registered_mode_detects_a_rescaled_carrier(registered_scale_positive: P
|
||||
assert registered.detector == detector.REGISTERED_DETECTOR_ID
|
||||
|
||||
|
||||
def test_registered_mode_falls_back_to_the_opponent_color_expert(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
opponent_registered_positive: Path,
|
||||
) -> None:
|
||||
import remove_ai_watermarks._synthid_registered as registered_detector
|
||||
|
||||
monkeypatch.setattr(registered_detector, "registered_score", lambda *_args: 0.0)
|
||||
|
||||
result = detector.detect_synthid(opponent_registered_positive, register_scale=True)
|
||||
|
||||
assert result.status == "detected"
|
||||
assert result.detector == detector.OPPONENT_REGISTERED_DETECTOR_ID
|
||||
assert result.score is not None
|
||||
assert result.score >= result.threshold
|
||||
|
||||
|
||||
def test_opponent_registered_threshold_mutation_changes_the_real_verdict(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
opponent_registered_positive: Path,
|
||||
) -> None:
|
||||
import remove_ai_watermarks._synthid_registered as registered_detector
|
||||
|
||||
monkeypatch.setattr(registered_detector, "registered_score", lambda *_args: 0.0)
|
||||
baseline = detector.detect_synthid(opponent_registered_positive, register_scale=True)
|
||||
assert baseline.score is not None
|
||||
assert baseline.detector == detector.OPPONENT_REGISTERED_DETECTOR_ID
|
||||
monkeypatch.setattr(
|
||||
detector,
|
||||
"OPPONENT_REGISTERED_THRESHOLD",
|
||||
float(np.nextafter(baseline.score, np.inf)),
|
||||
)
|
||||
|
||||
mutated = detector.detect_synthid(opponent_registered_positive, register_scale=True)
|
||||
|
||||
assert mutated.status == "indeterminate"
|
||||
assert mutated.detector == detector.REGISTERED_DETECTOR_ID
|
||||
|
||||
|
||||
def test_opponent_fallback_recovers_period8_without_codec_grid(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
opponent_period8_positive: Path,
|
||||
) -> None:
|
||||
import remove_ai_watermarks._synthid_registered as registered_detector
|
||||
|
||||
monkeypatch.setattr(registered_detector, "registered_score", lambda *_args: 0.0)
|
||||
|
||||
result = detector.detect_synthid(opponent_period8_positive, register_scale=True)
|
||||
|
||||
assert result.status == "detected"
|
||||
assert result.detector == detector.OPPONENT_REGISTERED_DETECTOR_ID
|
||||
|
||||
|
||||
def test_fine_opponent_fallback_recovers_off_grid_period(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
fine_opponent_registered_positive: Path,
|
||||
) -> None:
|
||||
import remove_ai_watermarks._synthid_registered as registered_detector
|
||||
|
||||
monkeypatch.setattr(registered_detector, "registered_score", lambda *_args: 0.0)
|
||||
monkeypatch.setattr(registered_detector, "opponent_registered_score", lambda *_args: 0.0)
|
||||
|
||||
result = detector.detect_synthid(fine_opponent_registered_positive, register_scale=True)
|
||||
|
||||
assert result.status == "detected"
|
||||
assert result.detector == detector.FINE_OPPONENT_REGISTERED_DETECTOR_ID
|
||||
assert result.score is not None
|
||||
assert result.score >= detector.FINE_OPPONENT_REGISTERED_THRESHOLD
|
||||
|
||||
|
||||
def test_fine_opponent_threshold_mutation_changes_the_real_verdict(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
fine_opponent_registered_positive: Path,
|
||||
) -> None:
|
||||
import remove_ai_watermarks._synthid_registered as registered_detector
|
||||
|
||||
monkeypatch.setattr(registered_detector, "registered_score", lambda *_args: 0.0)
|
||||
monkeypatch.setattr(registered_detector, "opponent_registered_score", lambda *_args: 0.0)
|
||||
baseline = detector.detect_synthid(fine_opponent_registered_positive, register_scale=True)
|
||||
assert baseline.score is not None
|
||||
assert baseline.detector == detector.FINE_OPPONENT_REGISTERED_DETECTOR_ID
|
||||
monkeypatch.setattr(
|
||||
detector,
|
||||
"FINE_OPPONENT_REGISTERED_THRESHOLD",
|
||||
float(np.nextafter(baseline.score, np.inf)),
|
||||
)
|
||||
|
||||
mutated = detector.detect_synthid(fine_opponent_registered_positive, register_scale=True)
|
||||
|
||||
assert mutated.status == "indeterminate"
|
||||
assert mutated.detector == detector.REGISTERED_DETECTOR_ID
|
||||
|
||||
|
||||
def test_fine_opponent_selector_recovers_the_fractional_period(
|
||||
fine_opponent_registered_positive: Path,
|
||||
) -> None:
|
||||
import remove_ai_watermarks._synthid_registered as registered_detector
|
||||
|
||||
template, sigma, *_model = detector._load_template()
|
||||
pixels = np.asarray(Image.open(fine_opponent_registered_positive).convert("RGB"), dtype=np.uint8)
|
||||
components = registered_detector.fine_opponent_registered_components(pixels, template, sigma)
|
||||
|
||||
assert components.selected_period == pytest.approx(7.68, abs=0.01)
|
||||
assert components.fine_decision_score >= detector.FINE_OPPONENT_REGISTERED_THRESHOLD
|
||||
assert components.candidate_count >= 100
|
||||
|
||||
|
||||
def test_period8_codec_veto_threshold_mutation_changes_real_components(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
opponent_period8_positive: Path,
|
||||
) -> None:
|
||||
import remove_ai_watermarks._synthid_registered as registered_detector
|
||||
|
||||
template, sigma, *_model = detector._load_template()
|
||||
pixels = np.asarray(Image.open(opponent_period8_positive).convert("RGB"), dtype=np.uint8)
|
||||
components = registered_detector.opponent_registered_components(pixels, template, sigma)
|
||||
assert components.decision_score >= detector.OPPONENT_REGISTERED_THRESHOLD
|
||||
assert components.red_green_p8_edge_ratio is not None
|
||||
assert components.blue_yellow_p8_edge_ratio is not None
|
||||
monkeypatch.setattr(registered_detector, "OPPONENT_REGISTERED_MAX_P8_EDGE_RATIO", 0.9)
|
||||
|
||||
assert components.decision_score == 0.0
|
||||
|
||||
|
||||
def test_opponent_registered_period_band_and_codec_veto_are_required() -> None:
|
||||
from remove_ai_watermarks._synthid_registered import OpponentRegisteredComponents
|
||||
|
||||
values = {
|
||||
"spectral_score": 0.8,
|
||||
"fixed_score": 0.32,
|
||||
"red_green_spatial": 0.9,
|
||||
"blue_yellow_spatial": 0.8,
|
||||
"candidate_count": 3,
|
||||
"red_green_p8_edge_ratio": None,
|
||||
"blue_yellow_p8_edge_ratio": None,
|
||||
}
|
||||
matching = OpponentRegisteredComponents(10.0, 10.0, **values)
|
||||
period8 = OpponentRegisteredComponents(
|
||||
8.0,
|
||||
8.0,
|
||||
**{
|
||||
**values,
|
||||
"red_green_p8_edge_ratio": 1.0,
|
||||
"blue_yellow_p8_edge_ratio": 1.0,
|
||||
},
|
||||
)
|
||||
codec_alias = OpponentRegisteredComponents(
|
||||
8.0,
|
||||
8.0,
|
||||
**{
|
||||
**values,
|
||||
"red_green_p8_edge_ratio": 1.2,
|
||||
"blue_yellow_p8_edge_ratio": 1.2,
|
||||
},
|
||||
)
|
||||
|
||||
assert matching.decision_score > detector.OPPONENT_REGISTERED_THRESHOLD
|
||||
assert period8.decision_score > detector.OPPONENT_REGISTERED_THRESHOLD
|
||||
assert codec_alias.base_decision_score > detector.OPPONENT_REGISTERED_THRESHOLD
|
||||
assert codec_alias.decision_score == 0.0
|
||||
|
||||
|
||||
def test_registered_threshold_mutation_changes_the_real_verdict(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
registered_scale_positive: Path,
|
||||
@@ -240,7 +498,7 @@ def test_registered_threshold_mutation_changes_the_real_verdict(
|
||||
|
||||
mutated = detector.detect_synthid(registered_scale_positive, register_scale=True)
|
||||
|
||||
assert mutated.status == "not_detected"
|
||||
assert mutated.status == "indeterminate"
|
||||
assert mutated.threshold == mutated_threshold
|
||||
|
||||
|
||||
@@ -270,17 +528,22 @@ def test_registered_amplitude_threshold_mutation_changes_the_real_verdict(
|
||||
|
||||
mutated = detector.detect_synthid(registered_scale_positive, register_scale=True)
|
||||
|
||||
assert mutated.status == "not_detected"
|
||||
assert mutated.status == "indeterminate"
|
||||
|
||||
|
||||
def test_registered_spectral_candidate_disagreement_blocks_decision() -> None:
|
||||
from remove_ai_watermarks._synthid_confirmation import RegisteredConfirmationComponents
|
||||
from remove_ai_watermarks._synthid_registered import RegisteredComponents
|
||||
|
||||
matching = RegisteredComponents(0.5, 0.25, 12.8, 12.8, 0.15)
|
||||
mismatching = RegisteredComponents(0.5, 0.25, 12.8, 12.9, 0.15)
|
||||
confirmation = RegisteredConfirmationComponents(12.8, 0.5, 0.2, 0.5, 8, 8)
|
||||
matching = RegisteredComponents(0.5, 0.25, 12.8, 12.8, 0.15, confirmation)
|
||||
mismatching = RegisteredComponents(0.5, 0.25, 12.8, 12.9, 0.15, confirmation)
|
||||
unconfirmed = RegisteredComponents(0.5, 0.25, 12.8, 12.8, 0.15)
|
||||
|
||||
assert matching.decision_score == pytest.approx(2.0)
|
||||
assert mismatching.decision_score == pytest.approx(0.0)
|
||||
assert unconfirmed.base_decision_score == pytest.approx(2.0)
|
||||
assert unconfirmed.decision_score == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_registered_high_band_mutation_changes_the_real_verdict(
|
||||
@@ -303,15 +566,40 @@ def test_registered_high_band_mutation_changes_the_real_verdict(
|
||||
|
||||
mutated = detector.detect_synthid(registered_scale_positive, register_scale=True)
|
||||
|
||||
assert mutated.status == "not_detected"
|
||||
assert mutated.status == "indeterminate"
|
||||
|
||||
|
||||
def test_registered_confirmation_mutation_changes_the_real_verdict(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
registered_scale_positive: Path,
|
||||
) -> None:
|
||||
import remove_ai_watermarks._synthid_confirmation as confirmation_detector
|
||||
import remove_ai_watermarks._synthid_registered as registered_detector
|
||||
|
||||
components = registered_detector.registered_components(
|
||||
np.asarray(Image.open(registered_scale_positive).convert("RGB"), dtype=np.uint8),
|
||||
detector._load_template()[0],
|
||||
detector._load_template()[1],
|
||||
)
|
||||
assert components.confirmation is not None
|
||||
assert components.decision_score >= detector.REGISTERED_THRESHOLD
|
||||
monkeypatch.setattr(
|
||||
confirmation_detector,
|
||||
"MIN_COHERENCE",
|
||||
float(np.nextafter(components.confirmation.joint_coherence, np.inf)),
|
||||
)
|
||||
|
||||
mutated = detector.detect_synthid(registered_scale_positive, register_scale=True)
|
||||
|
||||
assert mutated.status == "indeterminate"
|
||||
|
||||
|
||||
def test_supported_negative_does_not_claim_clean(supported_images: tuple[Path, Path]) -> None:
|
||||
_positive, negative = supported_images
|
||||
|
||||
result = detector.detect_synthid(negative)
|
||||
result = detector.detect_synthid(negative, register_scale=False)
|
||||
|
||||
assert result.status == "not_detected"
|
||||
assert result.status == "indeterminate"
|
||||
assert result.detected is False
|
||||
assert result.score == pytest.approx(0.0)
|
||||
|
||||
@@ -321,16 +609,16 @@ def test_threshold_mutation_changes_the_real_verdict(
|
||||
supported_images: tuple[Path, Path],
|
||||
) -> None:
|
||||
positive, _negative = supported_images
|
||||
baseline = detector.detect_synthid(positive)
|
||||
baseline = detector.detect_synthid(positive, register_scale=False)
|
||||
assert baseline.score is not None
|
||||
assert baseline.status == "detected"
|
||||
mutated_threshold = float(np.nextafter(baseline.score, np.inf))
|
||||
assert mutated_threshold > baseline.score
|
||||
|
||||
monkeypatch.setattr(detector, "TILE_THRESHOLD", mutated_threshold)
|
||||
mutated = detector.detect_synthid(positive)
|
||||
mutated = detector.detect_synthid(positive, register_scale=False)
|
||||
|
||||
assert mutated.status == "not_detected"
|
||||
assert mutated.status == "indeterminate"
|
||||
assert mutated.threshold == mutated_threshold
|
||||
|
||||
|
||||
@@ -338,11 +626,14 @@ def test_unsupported_geometry_is_distinct_from_negative(tmp_path: Path) -> None:
|
||||
path = tmp_path / "small.png"
|
||||
Image.new("RGB", (64, 32), "white").save(path)
|
||||
|
||||
result = detector.detect_synthid(path)
|
||||
result = detector.detect_synthid(path, register_scale=False)
|
||||
|
||||
assert result.status == "unsupported"
|
||||
assert result.score is None
|
||||
assert (result.width, result.height) == (64, 32)
|
||||
assert result.reason is not None
|
||||
assert result.to_dict()["metadata_used_for_verdict"] is False
|
||||
assert result.to_dict()["provider_scope"] == "provider-neutral"
|
||||
|
||||
|
||||
def test_shared_bgr_decode_matches_file_decode(supported_images: tuple[Path, Path]) -> None:
|
||||
@@ -352,8 +643,8 @@ def test_shared_bgr_decode_matches_file_decode(supported_images: tuple[Path, Pat
|
||||
bgr = cv2.imread(str(positive))
|
||||
assert bgr is not None
|
||||
|
||||
from_file = detector.detect_synthid(positive)
|
||||
from_array = detector.detect_synthid(positive, image=bgr)
|
||||
from_file = detector.detect_synthid(positive, register_scale=False)
|
||||
from_array = detector.detect_synthid(positive, image=bgr, register_scale=False)
|
||||
|
||||
assert from_array == from_file
|
||||
|
||||
@@ -366,7 +657,7 @@ def test_supported_geometry_requires_pixel_dependencies(
|
||||
monkeypatch.setattr(detector, "is_available", lambda: False)
|
||||
|
||||
with pytest.raises(RuntimeError, match="pixel extra"):
|
||||
detector.detect_synthid(negative)
|
||||
detector.detect_synthid(negative, register_scale=False)
|
||||
|
||||
|
||||
def test_fold_accepts_non_divisible_geometry_without_resampling() -> None:
|
||||
@@ -435,3 +726,53 @@ def test_fold_rejects_tile_larger_than_image() -> None:
|
||||
tile_width=16,
|
||||
denoise_sigma=1.0,
|
||||
)
|
||||
|
||||
|
||||
def test_verdict_does_not_claim_the_watermark() -> None:
|
||||
"""The result must not assert SynthID, because the statistic is not SynthID.
|
||||
|
||||
This was unguarded until 2026-08-16, and the claim had been wrong for months
|
||||
without a single test noticing. The fields are pinned by value rather than by
|
||||
presence so that a rename back to a watermark claim fails here.
|
||||
"""
|
||||
result = detector.SynthIDDetection(
|
||||
status="detected",
|
||||
width=4096,
|
||||
height=2560,
|
||||
score=1.0,
|
||||
threshold=1.0,
|
||||
)
|
||||
|
||||
payload = result.to_dict()
|
||||
|
||||
assert payload["signal_family"] == "generation-pipeline-lattice"
|
||||
assert payload["identifies_watermark"] is False
|
||||
assert payload["tile_aligned_crop_required"] is True
|
||||
assert "synthid" not in str(payload["signal_family"]).lower()
|
||||
|
||||
|
||||
def test_the_statistic_is_locked_to_the_image_origin() -> None:
|
||||
"""A crop off the tile grid must destroy the score, and that must stay visible.
|
||||
|
||||
SynthID's published evaluation retains 99.97% TPR under aggressive crop and
|
||||
resize. This statistic loses everything to a seven-pixel shift, measured on
|
||||
the real runtime at 4096x2560 where aligned crops scored up to 1.069 and
|
||||
shifted ones reached -0.438. The property is asserted here so that any future
|
||||
expert claiming to read the watermark has to survive the same shift first.
|
||||
"""
|
||||
template, sigma, *_model = detector._load_template()
|
||||
tile = template / np.max(np.abs(template))
|
||||
pixels = np.full((1024, 1024, 3), 128.0)
|
||||
pixels += 6.0 * np.tile(tile, (64, 64, 1))
|
||||
aligned = np.clip(np.rint(pixels), 0, 255).astype(np.uint8)
|
||||
|
||||
aligned_score, _folded = detector.folded_template_score(aligned, template, sigma)
|
||||
# Seven is deliberately coprime with the 16-pixel tile, so no residual phase survives.
|
||||
shifted_score, _shifted_folded = detector.folded_template_score(
|
||||
aligned[7:, 7:],
|
||||
template,
|
||||
sigma,
|
||||
)
|
||||
|
||||
assert aligned_score > 0.5
|
||||
assert shifted_score < 0.1 * aligned_score
|
||||
|
||||
Reference in New Issue
Block a user