diff --git a/docs/synthid-detector-removal-plan.md b/docs/synthid-detector-removal-plan.md index abbda33..ccb08ee 100644 --- a/docs/synthid-detector-removal-plan.md +++ b/docs/synthid-detector-removal-plan.md @@ -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: diff --git a/docs/synthid.md b/docs/synthid.md index 30cdd0b..b92cc16 100644 --- a/docs/synthid.md +++ b/docs/synthid.md @@ -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 diff --git a/scripts/synthid_periodic_tile.py b/scripts/synthid_periodic_tile.py new file mode 100644 index 0000000..1e77b42 --- /dev/null +++ b/scripts/synthid_periodic_tile.py @@ -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 diff --git a/scripts/synthid_periodic_tile_probe.py b/scripts/synthid_periodic_tile_probe.py new file mode 100644 index 0000000..7690e5d --- /dev/null +++ b/scripts/synthid_periodic_tile_probe.py @@ -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() diff --git a/scripts/synthid_tile_attack.py b/scripts/synthid_tile_attack.py index 57110ec..e2737a3 100644 --- a/scripts/synthid_tile_attack.py +++ b/scripts/synthid_tile_attack.py @@ -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: diff --git a/tests/test_synthid_periodic_tile_probe.py b/tests/test_synthid_periodic_tile_probe.py new file mode 100644 index 0000000..159b5d6 --- /dev/null +++ b/tests/test_synthid_periodic_tile_probe.py @@ -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) diff --git a/tests/test_synthid_tile_attack.py b/tests/test_synthid_tile_attack.py index 29380ad..d2560eb 100644 --- a/tests/test_synthid_tile_attack.py +++ b/tests/test_synthid_tile_attack.py @@ -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,