Add periodic SynthID tile probing

This commit is contained in:
Victor Kuznetsov
2026-08-10 12:10:05 -07:00
parent 5e5a3976ba
commit 7091d73f2e
7 changed files with 524 additions and 36 deletions
+74 -2
View File
@@ -1073,12 +1073,84 @@ The current actionable research candidate remains a positive-only,
provider-specific expert for the supported 1536x2816 carrier epoch. Identity
and bounded translation views use the frozen phase and support thresholds;
unsupported geometry, insufficient carrier magnitude, and ambiguous phase
return `abstain`. Vendor attribution may select the expert that supplied accepted
evidence, but it must not turn an abstention into a provider label. The next
return `abstain`. Vendor attribution may select the expert that supplied
accepted evidence, but it must not turn an abstention into a provider label. The next
calibration gate still requires at least 3,000 native-support negatives,
same-provider oracle negatives, matched non-target solid outputs, and a new
temporal positive that influenced neither profile nor threshold.
### 2026-08-10: 2048 periodic-tile detector
The phase hypothesis transferred to a second native geometry when the model
and operating point were kept separate. A 256-peak 2048x2048 model learned from
111 train positives used the already frozen phase threshold of 0.43, active
support threshold of 0.40, and a translation search of plus or minus four
pixels. It accepted 31 of 35 validation positives and 27 of 30 locked-test
positives while accepting none of 49 validation or 38 test negatives.
Translation registration recovered the shifted positives without changing
those identity counts. A threshold recalibrated from only 49 validation
negatives reached 30 of 30 test positives but accepted one test negative, so it
was rejected in favor of the transferred rule.
The wider native-geometry challenge exposed the remaining uncertainty. The
frozen 2048 rule accepted two of 182 earlier external-provider images, for two
accepted source negatives among all 269 native negatives. Both cases passed at
zero translation with high phase and support, and both also passed an
independently learned HSV phase branch. They are operational false positives
under source labels, but source provenance does not establish watermark
absence. They may instead expose a shared encoder or upstream backend. Without
an independent watermark oracle they cannot be relabeled either way. The same
experiment rejected the 1024x1024 and 768x1376 experts: they accepted 9 of 26
and 4 of 9 native source negatives, respectively.
The 2048 carrier has a concrete periodic mechanism. Its 256 peaks reduced to
108 unique spatial frequencies. Translating the frequency coordinates by 128
rows preserved 56 coordinates, while the maximum overlap in each of 1,000
uniform random controls was two. The permutation estimate was 0.001, and the
128-bin spacing implies a 16x16 spatial tile. A separate detector therefore
folded a high-pass residual modulo 16x16, averaged 16,384 repetitions, and
correlated the normalized tile against a train-positive template. After
float64 serialization and validation-only threshold calibration, the fixed
tile accepted 34 of 35 validation and 29 of 30 test positives, none of the 49
calibration or 38 held-out test negatives, and the same two of 182 earlier
source negatives. This second representation supports a real periodic carrier
rather than an arbitrary set of FFT peaks, but it does not resolve the two
labels.
The fixed tile also accepted none of a preregistered 3,000-image general-image
challenge after deterministic 2048x2048 canonicalization. The maximum
normalized correlation was 0.106 against a threshold of 0.174. The zero-error
one-sided 95% upper bound is 0.0998% for that challenge. Unlike the earlier
sparse-phase result, the tile score evaluates every image without a weak
carrier-support abstention. The challenge is still not native provider data
and cannot replace the required oracle-negative calibration.
Symmetric attack evaluation established the robustness boundary. The fixed
tile accepted 29 of 30 original test positives, all 30 after a 75% downscale
round trip, 21 after JPEG-95, three after JPEG-85, and none after a 5% crop,
with no accepted held-out negatives under the identity threshold. A
validation-calibrated JPEG-95 tile threshold recovered 27 of 30 but accepted
one of 38 test negatives. Requiring both codec-conditioned tile and phase
scores reduced JPEG-95 to 16 of 30 with no held-out-negative acceptance, but
still accepted one of the 182 earlier source negatives. The corresponding
JPEG-85 consensus accepted 5 of 30 positives and none of all 269 native source
negatives. Scale-and-translation phase search recovered 15 of 35 validation
and 14 of 30 test crops with no held-out-negative acceptance, but remains
discovery-only because the test transformation had already influenced the
branch. Low-frequency peak subsets and transform-augmented phase training
improved JPEG sensitivity only by raising validation false positives to
2-10%, so both were rejected.
The reproducible implementation is `scripts/synthid_periodic_tile_probe.py`.
It stores the normalized template in float64 and calibrates only after loading
the serialized artifact; an earlier float32 experiment moved a boundary score
by approximately 2.5e-10 and demonstrated why calibration-before-serialization
is invalid. The resulting research detector is positive-only and limited to a
confirmed 2048x2048 carrier epoch. An accepted expert may suggest the encoder
family, but the two cross-source carrier matches prohibit a stronger vendor
claim until an oracle distinguishes direct provider output from shared-backend
output.
## Decision record
The program has four possible honest outcomes per provider:
+14
View File
@@ -294,6 +294,20 @@ not a universal SynthID detector. Exact measurements and remaining calibration
gates are in the
[`detector and removal research plan`](synthid-detector-removal-plan.md#2026-08-10-low-content-controls-and-registered-phase-carrier).
The next native-geometry experiment isolated a stronger mechanism. At
2048x2048, 108 selected spatial frequencies formed a 128-bin lattice, implying
a 16x16 periodic residual tile. Folding and averaging 16,384 tile repetitions
produced a spatial detector that accepted 29 of 30 test positives, none of 49
calibration negatives, and none of 38 held-out test negatives. It also accepted
the same two of 182 earlier external-source images as the independent RGB and
HSV phase branches. Those cases count against operational source-label FPR,
but may contain the same carrier through an upstream encoder; only an oracle
can distinguish the two explanations. A normalized tile challenge accepted
none of 3,000 general images, while symmetric attacks showed strong resize but
limited JPEG and crop robustness. The pickle-free research implementation is
`scripts/synthid_periodic_tile_probe.py`; exact evidence and caveats are in the
[`2048 periodic-tile experiment`](synthid-detector-removal-plan.md#2026-08-10-2048-periodic-tile-detector).
A controlled study (June 2026, clean v0.8.6 with text/face protection OFF,
native resolution on this repo's default SDXL pipeline) measured the minimum
img2img strength that removes the SynthID pixel watermark, verified per image on
+56
View File
@@ -0,0 +1,56 @@
"""Shared periodic-residual helpers for SynthID research probes."""
from __future__ import annotations
import cv2
import numpy as np
def fold_residual_template(
pixels: np.ndarray,
*,
tile_height: int,
tile_width: int,
denoise_sigma: float,
) -> np.ndarray:
"""Estimate a zero-mean periodic residual template by modulo folding."""
if pixels.ndim != 3 or pixels.shape[2] != 3:
raise ValueError("pixels must have shape (height, width, 3)")
if tile_height < 1 or tile_width < 1 or denoise_sigma <= 0.0:
raise ValueError("tile dimensions and denoise sigma must be positive")
height, width = pixels.shape[:2]
if height % tile_height != 0 or width % tile_width != 0:
raise ValueError("image geometry must be divisible by the tile geometry")
source = pixels.astype(np.float32)
denoised = cv2.GaussianBlur(
source,
(0, 0),
sigmaX=denoise_sigma,
sigmaY=denoise_sigma,
borderType=cv2.BORDER_REFLECT_101,
)
residual = source - denoised
repeats_y = height // tile_height
repeats_x = width // tile_width
folded = residual.reshape(repeats_y, tile_height, repeats_x, tile_width, 3).mean(
axis=(0, 2),
dtype=np.float64,
)
return folded - np.mean(folded, axis=(0, 1), keepdims=True)
def unit_tile(tile: np.ndarray) -> tuple[np.ndarray, float]:
"""Return TILE normalized by its L2 norm and the original norm."""
norm = float(np.linalg.norm(tile))
if norm == 0.0:
return np.zeros_like(tile, dtype=np.float64), 0.0
return np.asarray(tile, dtype=np.float64) / norm, norm
def cyclic_tile_correlations(template: np.ndarray, tile: np.ndarray) -> np.ndarray:
"""Return correlations for every cyclic spatial shift of TILE."""
if template.shape != tile.shape or template.ndim != 3:
raise ValueError("template and tile must have identical three-dimensional shapes")
template_spectrum = np.fft.fft2(template, axes=(0, 1))
tile_spectrum = np.fft.fft2(tile, axes=(0, 1))
return np.fft.ifft2(np.sum(template_spectrum * np.conj(tile_spectrum), axis=2)).real
+259
View File
@@ -0,0 +1,259 @@
"""Discover and evaluate an exact-geometry periodic residual carrier.
The model folds a high-pass residual modulo a fixed tile, averaging thousands
of spatial repetitions before normalized correlation. It is a positive-only
research signal, not a universal or certified SynthID decoder.
"""
from __future__ import annotations
import json
import logging
from dataclasses import asdict, dataclass
from pathlib import Path
import click
import numpy as np
from synthid_periodic_tile import cyclic_tile_correlations, fold_residual_template, unit_tile
from synthid_pixel_attack import load_rgb
from synthid_research_manifest import artifact_sha256
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class PeriodicTileModel:
"""One exact-geometry normalized periodic-residual template."""
height: int
width: int
tile_height: int
tile_width: int
denoise_sigma: float
template: np.ndarray
expected_norm: float
@dataclass(frozen=True)
class PeriodicTileScore:
"""Normalized tile correlation and support for one image."""
path: str
score: float
active_support: float
row_shift: int
column_shift: int
repeat_count: int
def _load_rgb(path: Path, *, height: int, width: int) -> np.ndarray:
"""Load PATH as exact-geometry uint8 RGB."""
pixels = load_rgb(path)
if pixels.shape != (height, width, 3):
raise ValueError(f"{path}: geometry {pixels.shape[1]}x{pixels.shape[0]} does not match {width}x{height}")
return pixels
def discover_model(
paths: list[Path],
*,
tile_height: int,
tile_width: int,
denoise_sigma: float = 1.0,
) -> PeriodicTileModel:
"""Learn a normalized periodic template from positive PATHS."""
if len(paths) < 3:
raise ValueError("at least three positive images are required")
first_pixels = load_rgb(paths[0])
height, width = first_pixels.shape[:2]
unit_sum = np.zeros((tile_height, tile_width, 3), dtype=np.float64)
norms: list[float] = []
for index, path in enumerate(paths):
folded = fold_residual_template(
first_pixels if index == 0 else _load_rgb(path, height=height, width=width),
tile_height=tile_height,
tile_width=tile_width,
denoise_sigma=denoise_sigma,
)
unit, norm = unit_tile(folded)
unit_sum += unit
norms.append(norm)
template, template_norm = unit_tile(unit_sum / len(paths))
if template_norm == 0.0:
raise ValueError("positive images expose no periodic residual template")
return PeriodicTileModel(
height=height,
width=width,
tile_height=tile_height,
tile_width=tile_width,
denoise_sigma=denoise_sigma,
template=template,
expected_norm=float(np.median(norms)),
)
def score_image(path: Path, model: PeriodicTileModel, *, register: bool = False) -> PeriodicTileScore:
"""Score PATH against MODEL, optionally searching cyclic tile shifts."""
folded = fold_residual_template(
_load_rgb(path, height=model.height, width=model.width),
tile_height=model.tile_height,
tile_width=model.tile_width,
denoise_sigma=model.denoise_sigma,
)
unit, norm = unit_tile(folded)
if register:
correlations = cyclic_tile_correlations(model.template, unit)
row_shift, column_shift = np.unravel_index(int(np.argmax(correlations)), correlations.shape)
score = float(correlations[row_shift, column_shift])
else:
score = float(np.sum(model.template * unit))
row_shift = column_shift = 0
return PeriodicTileScore(
path=str(path),
score=score,
active_support=min(norm / (model.expected_norm + 1e-12), 1.0),
row_shift=row_shift,
column_shift=column_shift,
repeat_count=(model.height // model.tile_height) * (model.width // model.tile_width),
)
def calibrate_threshold(paths: list[Path], model: PeriodicTileModel, *, register: bool = False) -> float:
"""Return the first float above every negative score in PATHS."""
if not paths:
raise ValueError("at least one calibration negative is required")
maximum = max(score_image(path, model, register=register).score for path in paths)
return float(np.nextafter(maximum, np.inf))
def save_model(path: Path, model: PeriodicTileModel) -> None:
"""Save MODEL as a pickle-free numeric artifact without precision loss."""
path.parent.mkdir(parents=True, exist_ok=True)
np.savez_compressed(
path,
format_version=np.asarray(1, dtype=np.int32),
height=np.asarray(model.height, dtype=np.int32),
width=np.asarray(model.width, dtype=np.int32),
tile_height=np.asarray(model.tile_height, dtype=np.int32),
tile_width=np.asarray(model.tile_width, dtype=np.int32),
denoise_sigma=np.asarray(model.denoise_sigma, dtype=np.float64),
template=model.template.astype(np.float64),
expected_norm=np.asarray(model.expected_norm, dtype=np.float64),
)
def load_model(path: Path) -> PeriodicTileModel:
"""Load and validate one numeric periodic-tile model."""
with np.load(path, allow_pickle=False) as artifact:
if int(artifact["format_version"]) != 1:
raise ValueError("unsupported periodic-tile model format version")
model = PeriodicTileModel(
height=int(artifact["height"]),
width=int(artifact["width"]),
tile_height=int(artifact["tile_height"]),
tile_width=int(artifact["tile_width"]),
denoise_sigma=float(artifact["denoise_sigma"]),
template=np.asarray(artifact["template"], dtype=np.float64),
expected_norm=float(artifact["expected_norm"]),
)
if model.height < 1 or model.width < 1 or model.tile_height < 1 or model.tile_width < 1:
raise ValueError("invalid periodic-tile geometry")
if model.height % model.tile_height or model.width % model.tile_width:
raise ValueError("image geometry is not divisible by periodic-tile geometry")
if model.template.shape != (model.tile_height, model.tile_width, 3):
raise ValueError("invalid periodic-tile template shape")
if not np.all(np.isfinite(model.template)) or not np.isclose(np.linalg.norm(model.template), 1.0):
raise ValueError("invalid periodic-tile template")
if not np.isfinite(model.denoise_sigma) or model.denoise_sigma <= 0.0:
raise ValueError("invalid periodic-tile denoise sigma")
if not np.isfinite(model.expected_norm) or model.expected_norm <= 0.0:
raise ValueError("invalid periodic-tile expected norm")
return model
@click.group()
def main() -> None:
"""Discover and evaluate an exact-geometry periodic carrier."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
@main.command()
@click.argument("positives", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--tile-height", type=click.IntRange(min=1), required=True)
@click.option("--tile-width", type=click.IntRange(min=1), required=True)
@click.option("--denoise-sigma", type=click.FloatRange(min=0.0, min_open=True), default=1.0, show_default=True)
@click.option("--model-out", type=click.Path(dir_okay=False, path_type=Path), required=True)
def discover(
positives: tuple[Path, ...],
tile_height: int,
tile_width: int,
denoise_sigma: float,
model_out: Path,
) -> None:
"""Learn a periodic tile from exact-geometry POSITIVES."""
save_model(
model_out,
discover_model(
list(positives),
tile_height=tile_height,
tile_width=tile_width,
denoise_sigma=denoise_sigma,
),
)
log.info("Wrote periodic-tile model: %s", model_out)
@main.command()
@click.argument("model_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("negatives", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--register", is_flag=True, help="Search every cyclic shift within the learned tile.")
@click.option("--report-out", type=click.Path(dir_okay=False, path_type=Path), required=True)
def calibrate(model_path: Path, negatives: tuple[Path, ...], register: bool, report_out: Path) -> None:
"""Calibrate a zero-observed-error threshold on NEGATIVES."""
model = load_model(model_path)
threshold = calibrate_threshold(list(negatives), model, register=register)
report_out.parent.mkdir(parents=True, exist_ok=True)
report_out.write_text(
json.dumps(
{
"model": str(model_path),
"model_sha256": artifact_sha256(model_path),
"register": register,
"negative_count": len(negatives),
"threshold": threshold,
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
log.info("Wrote periodic-tile calibration report: %s", report_out)
@main.command()
@click.argument("model_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--register", is_flag=True, help="Search every cyclic shift within the learned tile.")
@click.option("--report-out", type=click.Path(dir_okay=False, path_type=Path), required=True)
def score(model_path: Path, images: tuple[Path, ...], register: bool, report_out: Path) -> None:
"""Score exact-geometry IMAGES with MODEL_PATH."""
model = load_model(model_path)
report_out.parent.mkdir(parents=True, exist_ok=True)
report_out.write_text(
json.dumps(
{
"model": str(model_path),
"model_sha256": artifact_sha256(model_path),
"register": register,
"scores": [asdict(score_image(image, model, register=register)) for image in images],
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
log.info("Wrote periodic-tile score report: %s", report_out)
if __name__ == "__main__":
main()
+1 -31
View File
@@ -14,45 +14,15 @@ from dataclasses import asdict
from pathlib import Path
import click
import cv2
import numpy as np
from PIL import Image
from synthid_ensemble_detector import detect_image, load_config, load_models
from synthid_periodic_tile import fold_residual_template
from synthid_pixel_attack import load_rgb, measure
log = logging.getLogger(__name__)
def fold_residual_template(
pixels: np.ndarray,
*,
tile_height: int,
tile_width: int,
denoise_sigma: float,
) -> np.ndarray:
"""Estimate a zero-mean periodic residual template by modulo folding."""
if pixels.ndim != 3 or pixels.shape[2] != 3:
raise ValueError("pixels must have shape (height, width, 3)")
if tile_height < 1 or tile_width < 1 or denoise_sigma <= 0.0:
raise ValueError("tile dimensions and denoise sigma must be positive")
height, width = pixels.shape[:2]
if height % tile_height != 0 or width % tile_width != 0:
raise ValueError("image geometry must be divisible by the tile geometry")
source = pixels.astype(np.float64)
denoised = cv2.GaussianBlur(
source,
(0, 0),
sigmaX=denoise_sigma,
sigmaY=denoise_sigma,
borderType=cv2.BORDER_REFLECT_101,
)
residual = source - denoised
repeats_y = height // tile_height
repeats_x = width // tile_width
folded = residual.reshape(repeats_y, tile_height, repeats_x, tile_width, 3).mean(axis=(0, 2))
return folded - np.mean(folded, axis=(0, 1), keepdims=True)
def subtract_tiled_template(pixels: np.ndarray, template: np.ndarray, *, strength: float) -> np.ndarray:
"""Subtract STRENGTH times TEMPLATE repeated over PIXELS."""
if strength < 0.0:
+116
View File
@@ -0,0 +1,116 @@
"""Tests for the periodic spatial-carrier research probe."""
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().parents[1] / "scripts"))
import synthid_periodic_tile_probe as probe
from synthid_periodic_tile import cyclic_tile_correlations
def _carrier(seed: int, *, tile_height: int = 8, tile_width: int = 8) -> np.ndarray:
rng = np.random.default_rng(seed)
tile = rng.normal(size=(tile_height, tile_width, 3))
return tile - np.mean(tile, axis=(0, 1), keepdims=True)
def _write_image(path: Path, carrier: np.ndarray, *, seed: int) -> None:
rng = np.random.default_rng(seed)
repeated = np.tile(carrier, (8, 8, 1))
yy, xx = np.mgrid[:64, :64]
background = 110.0 + 0.2 * xx + 0.1 * yy
pixels = background[:, :, None] + 18.0 * repeated + rng.normal(scale=1.5, size=repeated.shape)
Image.fromarray(np.clip(np.rint(pixels), 0, 255).astype(np.uint8), mode="RGB").save(path)
def test_periodic_model_scores_matching_carrier_and_round_trips(tmp_path: Path) -> None:
carrier = _carrier(1)
positives = []
for index in range(4):
path = tmp_path / f"positive-{index}.png"
_write_image(path, carrier, seed=index)
positives.append(path)
heldout = tmp_path / "heldout.png"
negative = tmp_path / "negative.png"
_write_image(heldout, carrier, seed=10)
_write_image(negative, _carrier(2), seed=11)
model = probe.discover_model(positives, tile_height=8, tile_width=8)
matching = probe.score_image(heldout, model)
mismatching = probe.score_image(negative, model)
model_path = tmp_path / "model.npz"
probe.save_model(model_path, model)
restored = probe.load_model(model_path)
assert matching.score > 0.9
assert mismatching.score < 0.5
assert matching.active_support > 0.0
assert matching.repeat_count == 64
assert np.array_equal(restored.template, model.template)
assert probe.score_image(heldout, restored).score == matching.score
def test_registration_recovers_cyclic_tile_shift(tmp_path: Path) -> None:
carrier = _carrier(3)
positives = []
for index in range(3):
path = tmp_path / f"positive-{index}.png"
_write_image(path, carrier, seed=index)
positives.append(path)
source = tmp_path / "source.png"
shifted = tmp_path / "shifted.png"
_write_image(source, carrier, seed=20)
with Image.open(source) as image:
pixels = np.asarray(image).copy()
Image.fromarray(np.roll(pixels, shift=(1, 2), axis=(0, 1)), mode="RGB").save(shifted)
model = probe.discover_model(positives, tile_height=8, tile_width=8)
fixed = probe.score_image(shifted, model)
registered = probe.score_image(shifted, model, register=True)
assert registered.score > fixed.score
assert registered.score > 0.9
assert (registered.row_shift, registered.column_shift) == (7, 6)
def test_fft_correlations_match_explicit_cyclic_shifts() -> None:
rng = np.random.default_rng(30)
template = rng.normal(size=(4, 5, 3))
tile = rng.normal(size=(4, 5, 3))
explicit = np.asarray(
[
[np.sum(template * np.roll(tile, shift=(row, column), axis=(0, 1))) for column in range(5)]
for row in range(4)
]
)
correlations = cyclic_tile_correlations(template, tile)
assert correlations == pytest.approx(explicit)
def test_calibration_is_strictly_above_every_negative(tmp_path: Path) -> None:
carrier = _carrier(4)
positives = []
negatives = []
for index in range(3):
positive = tmp_path / f"positive-{index}.png"
negative = tmp_path / f"negative-{index}.png"
_write_image(positive, carrier, seed=index)
_write_image(negative, _carrier(10 + index), seed=20 + index)
positives.append(positive)
negatives.append(negative)
model = probe.discover_model(positives, tile_height=8, tile_width=8)
threshold = probe.calibrate_threshold(negatives, model)
assert all(probe.score_image(path, model).score < threshold for path in negatives)
with pytest.raises(ValueError, match="at least one calibration negative"):
probe.calibrate_threshold([], model)
+4 -3
View File
@@ -9,13 +9,14 @@ import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import synthid_tile_attack as attack
from synthid_periodic_tile import fold_residual_template
def test_modulo_folding_recovers_repeated_high_frequency_tile() -> None:
tile = np.fromfunction(lambda y, x, channel: ((x + y + channel) % 2) * 2.0 - 1.0, (8, 16, 3))
pixels = 100.0 + np.tile(tile, (8, 4, 1))
estimated = attack.fold_residual_template(
estimated = fold_residual_template(
pixels,
tile_height=8,
tile_width=16,
@@ -29,7 +30,7 @@ def test_modulo_folding_recovers_repeated_high_frequency_tile() -> None:
def test_subtraction_reduces_repeated_tile_energy() -> None:
tile = np.fromfunction(lambda y, x, channel: ((x + y + channel) % 2) * 2.0 - 1.0, (8, 16, 3))
pixels = np.clip(np.rint(100.0 + 4.0 * np.tile(tile, (8, 4, 1))), 0, 255).astype(np.uint8)
template = attack.fold_residual_template(
template = fold_residual_template(
pixels,
tile_height=8,
tile_width=16,
@@ -47,7 +48,7 @@ def test_folding_rejects_nondivisible_geometry() -> None:
pixels = np.zeros((63, 64, 3), dtype=np.uint8)
with pytest.raises(ValueError, match="divisible"):
attack.fold_residual_template(
fold_residual_template(
pixels,
tile_height=8,
tile_width=16,