Add calibrated SynthID pixel detector

This commit is contained in:
Victor Kuznetsov
2026-08-11 11:09:55 -07:00
parent 7091d73f2e
commit 8a648794ad
32 changed files with 2394 additions and 146 deletions
+4
View File
@@ -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):
+22
View File
@@ -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."""
+26
View File
@@ -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):
+217
View File
@@ -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,
)
+212
View File
@@ -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,
)
+19
View File
@@ -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))
+18
View File
@@ -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):
+10 -9
View File
@@ -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