mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-31 09:40:38 +02:00
Add calibrated SynthID pixel detector
This commit is contained in:
@@ -18,8 +18,12 @@ CHATGPT = SAMPLES / "chatgpt-1.png"
|
||||
|
||||
class TestTopLevelExports:
|
||||
def test_lazy_reexports_resolve(self):
|
||||
from remove_ai_watermarks import synthid_detector
|
||||
|
||||
assert raiw.remove_visible is api.remove_visible
|
||||
assert raiw.visible_provenance is api.visible_provenance
|
||||
assert raiw.detect_synthid is synthid_detector.detect_synthid
|
||||
assert raiw.SynthIDDetection is synthid_detector.SynthIDDetection
|
||||
|
||||
def test_unknown_attribute_raises(self):
|
||||
with pytest.raises(AttributeError):
|
||||
|
||||
@@ -736,6 +736,28 @@ class TestIdentifyCommand:
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
class TestDetectSynthIDCommand:
|
||||
def test_help(self, runner):
|
||||
result = runner.invoke(main, ["detect-synthid", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "calibrated image sizes" 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"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "unsupported"
|
||||
assert payload["score"] is None
|
||||
|
||||
def test_non_json_output_preserves_negative_scope(self, runner, tmp_clean_png):
|
||||
result = runner.invoke(main, ["detect-synthid", str(tmp_clean_png)])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "unsupported" in result.output
|
||||
assert "not proof that SynthID is absent" in result.output
|
||||
|
||||
|
||||
class TestBatchCommand:
|
||||
"""Tests for the 'batch' subcommand."""
|
||||
|
||||
|
||||
@@ -886,6 +886,32 @@ class TestIdentifyVisibleTextMarks:
|
||||
# ── Caveats and serialization ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestSynthIDPixelCarrier:
|
||||
def test_positive_pixel_carrier_is_high_confidence_ai_evidence(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=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)
|
||||
|
||||
def test_negative_pixel_carrier_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._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)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/fixtures/provenance not present")
|
||||
class TestIdentifyCaveats:
|
||||
def test_legacy_openai_has_no_synthid_claim(self):
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Runtime tests for the positive-only SynthID periodic carrier detector."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
import remove_ai_watermarks.synthid_detector as detector
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def supported_images(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, Path]:
|
||||
"""Create supported-geometry positive and negative synthetic fixtures."""
|
||||
directory = tmp_path_factory.mktemp("synthid-detector")
|
||||
template, *_model = detector._load_template()
|
||||
scaled_tile = np.rint(template / np.max(np.abs(template)))
|
||||
marked = np.full((detector.MODEL_HEIGHT, detector.MODEL_WIDTH, 3), 128, dtype=np.float64)
|
||||
marked += np.tile(scaled_tile, (128, 128, 1))
|
||||
|
||||
positive = directory / "positive.png"
|
||||
negative = directory / "negative.png"
|
||||
Image.fromarray(np.clip(np.rint(marked), 0, 255).astype(np.uint8), "RGB").save(positive)
|
||||
Image.new("RGB", (detector.MODEL_WIDTH, detector.MODEL_HEIGHT), (128, 128, 128)).save(negative)
|
||||
return positive, negative
|
||||
|
||||
|
||||
def test_bundled_model_is_the_frozen_calibrated_artifact() -> None:
|
||||
model = Path(detector.__file__).parent / "assets" / detector.MODEL_FILENAME
|
||||
|
||||
assert hashlib.sha256(model.read_bytes()).hexdigest() == (
|
||||
"ee7838da8542c206c3403284b68e98f0ac99429e82f262c1a438f50a638b488b"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("width", "height"),
|
||||
[(1000, 1000), (1001, 1000), (3000, 6000), (768, 1364)],
|
||||
)
|
||||
def test_supported_geometry_uses_the_challenged_pixel_count_range(width: int, height: int) -> None:
|
||||
assert detector._geometry_supported(width, height)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("width", "height"),
|
||||
[(999, 1000), (3001, 6000), (64, 32)],
|
||||
)
|
||||
def test_geometry_outside_the_challenged_pixel_count_range_is_unsupported(
|
||||
width: int,
|
||||
height: int,
|
||||
) -> None:
|
||||
assert not detector._geometry_supported(width, height)
|
||||
|
||||
|
||||
def test_detects_supported_periodic_carrier(supported_images: tuple[Path, Path]) -> None:
|
||||
positive, _negative = supported_images
|
||||
|
||||
result = detector.detect_synthid(positive)
|
||||
|
||||
assert result.status == "detected"
|
||||
assert result.detected is True
|
||||
assert result.score is not None
|
||||
assert result.score > result.threshold
|
||||
assert result.to_dict()["detector"] == detector.DETECTOR_ID
|
||||
|
||||
|
||||
def test_detects_unregistered_non_divisible_geometry_in_size_range(tmp_path: Path) -> None:
|
||||
width, height = 1001, 1000
|
||||
template, *_model = detector._load_template()
|
||||
scaled_tile = np.rint(template / np.max(np.abs(template)))
|
||||
repeats_y = (height + scaled_tile.shape[0] - 1) // scaled_tile.shape[0]
|
||||
repeats_x = (width + scaled_tile.shape[1] - 1) // scaled_tile.shape[1]
|
||||
carrier = np.tile(scaled_tile, (repeats_y, repeats_x, 1))[:height, :width]
|
||||
pixels = np.clip(np.rint(carrier + 128.0), 0, 255).astype(np.uint8)
|
||||
path = tmp_path / "non-divisible-positive.png"
|
||||
Image.fromarray(pixels, "RGB").save(path)
|
||||
|
||||
result = detector.detect_synthid(path)
|
||||
|
||||
assert result.status == "detected"
|
||||
assert (result.width, result.height) == (width, height)
|
||||
assert result.score is not None
|
||||
assert result.score > result.threshold
|
||||
|
||||
|
||||
def test_supported_negative_does_not_claim_clean(supported_images: tuple[Path, Path]) -> None:
|
||||
_positive, negative = supported_images
|
||||
|
||||
result = detector.detect_synthid(negative)
|
||||
|
||||
assert result.status == "not_detected"
|
||||
assert result.detected is False
|
||||
assert result.score == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_threshold_mutation_changes_the_real_verdict(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
supported_images: tuple[Path, Path],
|
||||
) -> None:
|
||||
positive, _negative = supported_images
|
||||
baseline = detector.detect_synthid(positive)
|
||||
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)
|
||||
|
||||
assert mutated.status == "not_detected"
|
||||
assert mutated.threshold == mutated_threshold
|
||||
|
||||
|
||||
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)
|
||||
|
||||
assert result.status == "unsupported"
|
||||
assert result.score is None
|
||||
assert (result.width, result.height) == (64, 32)
|
||||
|
||||
|
||||
def test_shared_bgr_decode_matches_file_decode(supported_images: tuple[Path, Path]) -> None:
|
||||
import cv2
|
||||
|
||||
positive, _negative = supported_images
|
||||
bgr = cv2.imread(str(positive))
|
||||
assert bgr is not None
|
||||
|
||||
from_file = detector.detect_synthid(positive)
|
||||
from_array = detector.detect_synthid(positive, image=bgr)
|
||||
|
||||
assert from_array == from_file
|
||||
|
||||
|
||||
def test_supported_geometry_requires_pixel_dependencies(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
supported_images: tuple[Path, Path],
|
||||
) -> None:
|
||||
_positive, negative = supported_images
|
||||
monkeypatch.setattr(detector, "is_available", lambda: False)
|
||||
|
||||
with pytest.raises(RuntimeError, match="pixel extra"):
|
||||
detector.detect_synthid(negative)
|
||||
|
||||
|
||||
def test_fold_accepts_non_divisible_geometry_without_resampling() -> None:
|
||||
rng = np.random.default_rng(20260810)
|
||||
tile = rng.normal(0.0, 8.0, size=(16, 16, 3))
|
||||
repeated = np.tile(tile, (19, 20, 1)) + 128.0
|
||||
|
||||
divisible = detector.fold_residual_template(
|
||||
repeated,
|
||||
tile_height=16,
|
||||
tile_width=16,
|
||||
denoise_sigma=1.0,
|
||||
)
|
||||
non_divisible = detector.fold_residual_template(
|
||||
repeated[:299, :317],
|
||||
tile_height=16,
|
||||
tile_width=16,
|
||||
denoise_sigma=1.0,
|
||||
)
|
||||
divisible_unit, _ = detector.unit_tile(divisible)
|
||||
non_divisible_unit, _ = detector.unit_tile(non_divisible)
|
||||
|
||||
assert non_divisible.shape == (16, 16, 3)
|
||||
assert float(np.sum(divisible_unit * non_divisible_unit)) > 0.999
|
||||
|
||||
|
||||
def test_non_divisible_fold_matches_modulo_cell_means() -> None:
|
||||
import cv2
|
||||
|
||||
rng = np.random.default_rng(44041)
|
||||
pixels = rng.integers(0, 256, size=(53, 71, 3), dtype=np.uint8)
|
||||
source = pixels.astype(np.float32)
|
||||
residual = source - cv2.GaussianBlur(
|
||||
source,
|
||||
(0, 0),
|
||||
sigmaX=1.25,
|
||||
sigmaY=1.25,
|
||||
borderType=cv2.BORDER_REFLECT_101,
|
||||
)
|
||||
expected = np.empty((16, 16, 3), dtype=np.float64)
|
||||
for tile_y in range(16):
|
||||
for tile_x in range(16):
|
||||
expected[tile_y, tile_x] = residual[tile_y::16, tile_x::16].mean(
|
||||
axis=(0, 1),
|
||||
dtype=np.float64,
|
||||
)
|
||||
expected -= np.mean(expected, axis=(0, 1), keepdims=True)
|
||||
|
||||
actual = detector.fold_residual_template(
|
||||
pixels,
|
||||
tile_height=16,
|
||||
tile_width=16,
|
||||
denoise_sigma=1.25,
|
||||
)
|
||||
|
||||
np.testing.assert_allclose(actual, expected, rtol=0.0, atol=0.0)
|
||||
|
||||
|
||||
def test_fold_rejects_tile_larger_than_image() -> None:
|
||||
pixels = np.zeros((15, 16, 3), dtype=np.uint8)
|
||||
|
||||
with pytest.raises(ValueError, match="at least as large"):
|
||||
detector.fold_residual_template(
|
||||
pixels,
|
||||
tile_height=16,
|
||||
tile_width=16,
|
||||
denoise_sigma=1.0,
|
||||
)
|
||||
@@ -0,0 +1,212 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
import synthid_oracle_batch as batch
|
||||
from synthid_periodic_tile import fold_residual_template, unit_tile
|
||||
from synthid_periodic_tile_probe import PeriodicTileModel
|
||||
from synthid_periodic_tile_probe import save_model as save_tile_model
|
||||
from synthid_phase_carrier import PhaseCarrierModel
|
||||
from synthid_phase_carrier import save_model as save_phase_model
|
||||
|
||||
|
||||
def _fixture(tmp_path: Path) -> tuple[Path, Path, Path]:
|
||||
rng = np.random.default_rng(23)
|
||||
raw_tile = rng.normal(size=(8, 8, 3))
|
||||
raw_tile -= np.mean(raw_tile, axis=(0, 1), keepdims=True)
|
||||
raw_tile, _ = unit_tile(raw_tile)
|
||||
source = np.clip(np.rint(128.0 + 24.0 * np.tile(raw_tile, (8, 8, 1))), 0, 255).astype(np.uint8)
|
||||
source_path = tmp_path / "source.png"
|
||||
Image.fromarray(source, mode="RGB").save(source_path)
|
||||
|
||||
folded = fold_residual_template(
|
||||
source,
|
||||
tile_height=8,
|
||||
tile_width=8,
|
||||
denoise_sigma=1.0,
|
||||
)
|
||||
template, expected_norm = unit_tile(folded)
|
||||
tile_model = PeriodicTileModel(64, 64, 8, 8, 1.0, template, expected_norm)
|
||||
tile_model_path = tmp_path / "tile-model.npz"
|
||||
save_tile_model(tile_model_path, tile_model)
|
||||
|
||||
spectra = np.stack([np.fft.rfft2(source[:, :, channel]) for channel in range(3)], axis=2)
|
||||
magnitude = np.abs(spectra)
|
||||
magnitude[0, 0, :] = 0.0
|
||||
row, column, channel = np.unravel_index(int(np.argmax(magnitude)), magnitude.shape)
|
||||
phase_model = PhaseCarrierModel(
|
||||
64,
|
||||
64,
|
||||
np.asarray([row], dtype=np.int32),
|
||||
np.asarray([column], dtype=np.int32),
|
||||
np.asarray([channel], dtype=np.int8),
|
||||
np.asarray([np.angle(spectra[row, column, channel])]),
|
||||
np.asarray([1.0]),
|
||||
np.asarray([magnitude[row, column, channel]]),
|
||||
)
|
||||
phase_model_path = tmp_path / "phase-model.npz"
|
||||
save_phase_model(phase_model_path, phase_model)
|
||||
return source_path, tile_model_path, phase_model_path
|
||||
|
||||
|
||||
def _build(tmp_path: Path) -> tuple[Path, Path]:
|
||||
source, tile_model, phase_model = _fixture(tmp_path)
|
||||
output_dir = tmp_path / "oracle-batch"
|
||||
manifest_path = batch.build_batch(
|
||||
[source],
|
||||
output_dir=output_dir,
|
||||
tile_model_path=tile_model,
|
||||
phase_model_path=phase_model,
|
||||
tile_threshold=0.5,
|
||||
phase_threshold=0.5,
|
||||
active_threshold=0.0,
|
||||
strength=2.0,
|
||||
seed=20260810,
|
||||
provider="google",
|
||||
repository_root=Path(__file__).resolve().parent.parent,
|
||||
)
|
||||
return output_dir, manifest_path
|
||||
|
||||
|
||||
def test_build_preregisters_fixed_request_order_without_copying_source(tmp_path: Path) -> None:
|
||||
output_dir, manifest_path = _build(tmp_path)
|
||||
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert manifest["status"] == "preregistered_unsubmitted"
|
||||
assert manifest["request_count"] == 5
|
||||
assert [row["role"] for row in manifest["rows"]] == list(batch.ROLE_ORDER)
|
||||
assert manifest["rows"][0]["in_batch"] is False
|
||||
assert not (output_dir / "source.png").exists()
|
||||
assert manifest["provider"] == "google"
|
||||
assert "shifted, and orthogonal_random are detected" in manifest["decision_rule"]
|
||||
assert all(row["synthid_result"] is None for row in manifest["rows"])
|
||||
assert all(row["c2pa_result"] is None for row in manifest["rows"])
|
||||
template = json.loads((output_dir / "results-template.json").read_text(encoding="utf-8"))
|
||||
assert template["manifest_sha256"] == batch.artifact_sha256(manifest_path)
|
||||
assert [row["artifact_sha256"] for row in template["rows"]] == [row["artifact_sha256"] for row in manifest["rows"]]
|
||||
assert (
|
||||
batch.verify_batch(
|
||||
manifest_path,
|
||||
repository_root=Path(__file__).resolve().parent.parent,
|
||||
)["source_count"]
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def test_verify_rejects_derivative_mutation(tmp_path: Path) -> None:
|
||||
output_dir, manifest_path = _build(tmp_path)
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
aligned_row = next(row for row in manifest["rows"] if row["role"] == "aligned")
|
||||
aligned_path = output_dir / aligned_row["path"]
|
||||
with Image.open(aligned_path) as image:
|
||||
pixels = np.asarray(image.convert("RGB"), dtype=np.uint8).copy()
|
||||
pixels[0, 0, 0] ^= 1
|
||||
Image.fromarray(pixels, mode="RGB").save(aligned_path)
|
||||
assert batch.artifact_sha256(aligned_path) != aligned_row["artifact_sha256"]
|
||||
|
||||
with pytest.raises(ValueError, match="artifact hash mismatch"):
|
||||
batch.verify_batch(
|
||||
manifest_path,
|
||||
repository_root=Path(__file__).resolve().parent.parent,
|
||||
)
|
||||
|
||||
|
||||
def test_build_rejects_output_inside_repository(tmp_path: Path) -> None:
|
||||
source, tile_model, phase_model = _fixture(tmp_path)
|
||||
repository_root = Path(__file__).resolve().parent.parent
|
||||
|
||||
with pytest.raises(ValueError, match="outside the repository"):
|
||||
batch.build_batch(
|
||||
[source],
|
||||
output_dir=repository_root / ".local-eval/oracle-batch-test",
|
||||
tile_model_path=tile_model,
|
||||
phase_model_path=phase_model,
|
||||
tile_threshold=0.5,
|
||||
phase_threshold=0.5,
|
||||
active_threshold=0.0,
|
||||
strength=2.0,
|
||||
seed=20260810,
|
||||
provider="google",
|
||||
repository_root=repository_root,
|
||||
)
|
||||
|
||||
|
||||
def test_build_rejects_nonempty_output_directory(tmp_path: Path) -> None:
|
||||
source, tile_model, phase_model = _fixture(tmp_path)
|
||||
output_dir = tmp_path / "existing"
|
||||
output_dir.mkdir()
|
||||
(output_dir / "marker.txt").write_text("occupied", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="must not already contain"):
|
||||
batch.build_batch(
|
||||
[source],
|
||||
output_dir=output_dir,
|
||||
tile_model_path=tile_model,
|
||||
phase_model_path=phase_model,
|
||||
tile_threshold=0.5,
|
||||
phase_threshold=0.5,
|
||||
active_threshold=0.0,
|
||||
strength=2.0,
|
||||
seed=20260810,
|
||||
provider="google",
|
||||
repository_root=Path(__file__).resolve().parent.parent,
|
||||
)
|
||||
|
||||
|
||||
def test_evaluate_requires_controls_and_aligned_outcome(tmp_path: Path) -> None:
|
||||
output_dir, manifest_path = _build(tmp_path)
|
||||
results = json.loads((output_dir / "results-template.json").read_text(encoding="utf-8"))
|
||||
for row in results["rows"]:
|
||||
row["synthid_result"] = "not_detected" if row["role"] == "aligned" else "detected"
|
||||
row["c2pa_result"] = "unavailable"
|
||||
row["raw_response"] = f"verbatim {row['role']} result"
|
||||
row["submitted_at"] = "2026-08-10T20:00:00Z"
|
||||
results_path = output_dir / "results.json"
|
||||
results_path.write_text(json.dumps(results, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
report = batch.evaluate_results(
|
||||
manifest_path,
|
||||
results_path,
|
||||
repository_root=Path(__file__).resolve().parent.parent,
|
||||
)
|
||||
|
||||
assert report["counts"] == {
|
||||
"causal_success": 1,
|
||||
"aligned_still_detected": 0,
|
||||
"control_failed": 0,
|
||||
"indeterminate": 0,
|
||||
}
|
||||
|
||||
shifted = next(row for row in results["rows"] if row["role"] == "shifted")
|
||||
shifted["synthid_result"] = "not_detected"
|
||||
results_path.write_text(json.dumps(results, indent=2) + "\n", encoding="utf-8")
|
||||
control_report = batch.evaluate_results(
|
||||
manifest_path,
|
||||
results_path,
|
||||
repository_root=Path(__file__).resolve().parent.parent,
|
||||
)
|
||||
assert control_report["counts"]["control_failed"] == 1
|
||||
|
||||
|
||||
def test_evaluate_rejects_incomplete_result(tmp_path: Path) -> None:
|
||||
output_dir, manifest_path = _build(tmp_path)
|
||||
results = json.loads((output_dir / "results-template.json").read_text(encoding="utf-8"))
|
||||
results["rows"].pop()
|
||||
results_path = output_dir / "incomplete-results.json"
|
||||
results_path.write_text(json.dumps(results, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="cover every"):
|
||||
batch.evaluate_results(
|
||||
manifest_path,
|
||||
results_path,
|
||||
repository_root=Path(__file__).resolve().parent.parent,
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
import synthid_periodic_tile_ablation as ablation
|
||||
from synthid_periodic_tile import fold_residual_template, unit_tile
|
||||
from synthid_periodic_tile_probe import PeriodicTileModel
|
||||
from synthid_phase_carrier import PhaseCarrierModel
|
||||
|
||||
|
||||
def test_exact_sign_test_detects_one_sided_direction() -> None:
|
||||
assert ablation.exact_sign_test(30, 0) == pytest.approx(1.862645149230957e-9)
|
||||
assert ablation.exact_sign_test(0, 0) == 1.0
|
||||
|
||||
|
||||
def test_control_templates_are_norm_matched_and_random_control_is_orthogonal() -> None:
|
||||
rng = np.random.default_rng(7)
|
||||
template, _ = unit_tile(rng.normal(size=(8, 8, 3)))
|
||||
|
||||
controls = ablation.control_templates(template, seed=11)
|
||||
|
||||
assert set(controls) == {"aligned", "shifted", "orthogonal_random"}
|
||||
assert all(np.linalg.norm(control) == pytest.approx(1.0) for control in controls.values())
|
||||
assert np.sum(controls["orthogonal_random"] * template) == pytest.approx(0.0, abs=1e-12)
|
||||
assert not np.array_equal(controls["shifted"], template)
|
||||
|
||||
|
||||
def test_aligned_subtraction_controls_both_synthetic_representations(tmp_path: Path) -> None:
|
||||
rng = np.random.default_rng(17)
|
||||
raw_tile = rng.normal(size=(8, 8, 3))
|
||||
raw_tile -= np.mean(raw_tile, axis=(0, 1), keepdims=True)
|
||||
raw_tile, _ = unit_tile(raw_tile)
|
||||
source = np.clip(np.rint(128.0 + 24.0 * np.tile(raw_tile, (8, 8, 1))), 0, 255).astype(np.uint8)
|
||||
source_path = tmp_path / "source.png"
|
||||
Image.fromarray(source, mode="RGB").save(source_path)
|
||||
|
||||
folded = fold_residual_template(
|
||||
source,
|
||||
tile_height=8,
|
||||
tile_width=8,
|
||||
denoise_sigma=1.0,
|
||||
)
|
||||
template, expected_norm = unit_tile(folded)
|
||||
tile_model = PeriodicTileModel(
|
||||
height=64,
|
||||
width=64,
|
||||
tile_height=8,
|
||||
tile_width=8,
|
||||
denoise_sigma=1.0,
|
||||
template=template,
|
||||
expected_norm=expected_norm,
|
||||
)
|
||||
|
||||
spectra = np.stack([np.fft.rfft2(source[:, :, channel]) for channel in range(3)], axis=2)
|
||||
magnitude = np.abs(spectra)
|
||||
magnitude[0, 0, :] = 0.0
|
||||
row, column, channel = np.unravel_index(int(np.argmax(magnitude)), magnitude.shape)
|
||||
phase_model = PhaseCarrierModel(
|
||||
height=64,
|
||||
width=64,
|
||||
rows=np.asarray([row], dtype=np.int32),
|
||||
columns=np.asarray([column], dtype=np.int32),
|
||||
channels=np.asarray([channel], dtype=np.int8),
|
||||
phases=np.asarray([np.angle(spectra[row, column, channel])]),
|
||||
weights=np.asarray([1.0]),
|
||||
expected_magnitudes=np.asarray([magnitude[row, column, channel]]),
|
||||
)
|
||||
|
||||
report = ablation.run_ablation(
|
||||
[source_path],
|
||||
tile_model=tile_model,
|
||||
phase_model=phase_model,
|
||||
tile_threshold=0.5,
|
||||
phase_threshold=0.5,
|
||||
active_threshold=0.0,
|
||||
strengths=(1.0, 2.0),
|
||||
phase_strength=2.0,
|
||||
seed=20260810,
|
||||
)
|
||||
|
||||
assert report["original"] == {"tile_accepted": 1, "phase_accepted": 1}
|
||||
aligned = next(row for row in report["phase_summaries"] if row["control"] == "aligned")
|
||||
assert aligned["accepted"] == 0
|
||||
comparisons = {(row["aligned_minus"], row["metric"]): row for row in report["paired_comparisons"]}
|
||||
assert comparisons[("shifted", "tile_delta")]["difference"]["median"] < 0.0
|
||||
assert comparisons[("orthogonal_random", "tile_delta")]["difference"]["median"] < 0.0
|
||||
|
||||
|
||||
def test_phase_strength_must_be_part_of_sweep() -> None:
|
||||
template = np.zeros((8, 8, 3), dtype=np.float64)
|
||||
template[0, 0, 0] = 1.0
|
||||
tile_model = PeriodicTileModel(64, 64, 8, 8, 1.0, template, 1.0)
|
||||
phase_model = PhaseCarrierModel(
|
||||
64,
|
||||
64,
|
||||
np.asarray([1]),
|
||||
np.asarray([1]),
|
||||
np.asarray([0]),
|
||||
np.asarray([0.0]),
|
||||
np.asarray([1.0]),
|
||||
np.asarray([1.0]),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="phase strength"):
|
||||
ablation.run_ablation(
|
||||
[Path("unused.png")],
|
||||
tile_model=tile_model,
|
||||
phase_model=phase_model,
|
||||
tile_threshold=0.0,
|
||||
phase_threshold=0.0,
|
||||
active_threshold=0.0,
|
||||
strengths=(1.0,),
|
||||
phase_strength=2.0,
|
||||
seed=1,
|
||||
)
|
||||
@@ -80,6 +80,25 @@ def test_registration_recovers_cyclic_tile_shift(tmp_path: Path) -> None:
|
||||
assert (registered.row_shift, registered.column_shift) == (7, 6)
|
||||
|
||||
|
||||
def test_array_scoring_matches_file_scoring(tmp_path: Path) -> None:
|
||||
carrier = _carrier(12)
|
||||
positives = []
|
||||
for index in range(3):
|
||||
path = tmp_path / f"positive-{index}.png"
|
||||
_write_image(path, carrier, seed=index)
|
||||
positives.append(path)
|
||||
model = probe.discover_model(positives, tile_height=8, tile_width=8)
|
||||
with Image.open(positives[0]) as image:
|
||||
pixels = np.asarray(image.convert("RGB"), dtype=np.uint8)
|
||||
|
||||
file_score = probe.score_image(positives[0], model)
|
||||
array_score = probe.score_pixels(pixels, model)
|
||||
|
||||
assert array_score.score == pytest.approx(file_score.score)
|
||||
assert array_score.active_support == pytest.approx(file_score.active_support)
|
||||
assert array_score.path == "<array>"
|
||||
|
||||
|
||||
def test_fft_correlations_match_explicit_cyclic_shifts() -> None:
|
||||
rng = np.random.default_rng(30)
|
||||
template = rng.normal(size=(4, 5, 3))
|
||||
|
||||
@@ -104,6 +104,24 @@ def test_scoring_rejects_geometry_mismatch(tmp_path: Path) -> None:
|
||||
carrier.score_image(mismatch, model)
|
||||
|
||||
|
||||
def test_array_scoring_matches_file_scoring(tmp_path: Path) -> None:
|
||||
positives: list[Path] = []
|
||||
for index in range(3):
|
||||
path = tmp_path / f"positive-{index}.png"
|
||||
_write_image(path, phase=0.4, seed=index)
|
||||
positives.append(path)
|
||||
model = carrier.discover_model(positives, peak_count=4, min_radius=1.0)
|
||||
with Image.open(positives[0]) as image:
|
||||
pixels = np.asarray(image.convert("RGB"), dtype=np.uint8)
|
||||
|
||||
file_score = carrier.score_image(positives[0], model)
|
||||
array_score = carrier.score_pixels(pixels, model)
|
||||
|
||||
assert array_score.score == pytest.approx(file_score.score)
|
||||
assert array_score.active_weight_fraction == pytest.approx(file_score.active_weight_fraction)
|
||||
assert array_score.path == "<array>"
|
||||
|
||||
|
||||
def test_scoring_can_canonicalize_geometry(tmp_path: Path) -> None:
|
||||
positives: list[Path] = []
|
||||
for index in range(3):
|
||||
|
||||
@@ -4,7 +4,6 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
@@ -44,13 +43,15 @@ def test_subtraction_reduces_repeated_tile_energy() -> None:
|
||||
assert after < before
|
||||
|
||||
|
||||
def test_folding_rejects_nondivisible_geometry() -> None:
|
||||
def test_folding_accepts_nondivisible_geometry() -> None:
|
||||
pixels = np.zeros((63, 64, 3), dtype=np.uint8)
|
||||
|
||||
with pytest.raises(ValueError, match="divisible"):
|
||||
fold_residual_template(
|
||||
pixels,
|
||||
tile_height=8,
|
||||
tile_width=16,
|
||||
denoise_sigma=1.0,
|
||||
)
|
||||
folded = fold_residual_template(
|
||||
pixels,
|
||||
tile_height=8,
|
||||
tile_width=16,
|
||||
denoise_sigma=1.0,
|
||||
)
|
||||
|
||||
assert folded.shape == (8, 16, 3)
|
||||
assert np.count_nonzero(folded) == 0
|
||||
|
||||
Reference in New Issue
Block a user