Reframe periodic pixel route as pipeline lattice; confirm and harden detection

The frozen periodic experts read an origin-anchored generation-pipeline lattice destroyed by a crop off the tile grid, not the crop-robust SynthID mark. Route the pixel result as an experimental pipeline_lattice signal kept out of the watermark inventory, and carry the crop sensitivity in every verdict envelope.

Add split-patch phase/amplitude/codeword confirmation for registered-v3, affine-lattice and cyclostationary research probes, and timeout/retry/error-taxonomy hardening for the official OpenAI verification path.
This commit is contained in:
Victor Kuznetsov
2026-08-16 21:53:37 -07:00
parent 2d018d32ab
commit 8eb9c06265
18 changed files with 3912 additions and 102 deletions
File diff suppressed because it is too large Load Diff
+228
View File
@@ -0,0 +1,228 @@
"""Probe complex cross-spectral coupling at preregistered carrier shifts."""
from __future__ import annotations
import json
import logging
import math
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
import click
import cv2
import numpy as np
from synthid_affine_lattice_probe import (
_canonical_pixels,
_opponent_channels,
_patch_origins,
template_harmonics,
)
from synthid_pixel_attack import load_rgb
if TYPE_CHECKING:
from numpy.typing import NDArray
log = logging.getLogger(__name__)
_OFF_CARRIER_OFFSETS = ((1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (-1, -1))
@dataclass(frozen=True)
class CyclostationaryScore:
"""Complex carrier-versus-neighbor contrast on disjoint patch groups."""
selection_carrier: float
selection_off_carrier_median: float
selection_contrast: float
confirmation_carrier: float
confirmation_off_carrier_median: float
confirmation_contrast: float
joint_contrast: float
harmonic_count: int
selection_patches: int
confirmation_patches: int
def _patch_cyclic_matrices(
pixels: NDArray[Any],
harmonics: NDArray[Any],
*,
tile_size: int,
denoise_sigma: float,
band_min: float,
band_max: float,
) -> NDArray[Any]:
"""Return normalized complex cross-channel matrices for one patch."""
patch_size = pixels.shape[0]
if pixels.shape[:2] != (patch_size, patch_size) or patch_size % tile_size:
raise ValueError("cyclostationary patches must be square multiples of the tile size")
channels = _opponent_channels(np.asarray(pixels, dtype=np.float64))
for channel in range(channels.shape[2]):
residual = channels[:, :, channel]
channels[:, :, channel] = residual - cv2.GaussianBlur(
residual,
(0, 0),
sigmaX=denoise_sigma,
sigmaY=denoise_sigma,
borderType=cv2.BORDER_REFLECT_101,
)
window_1d = np.hanning(patch_size)
window = window_1d[:, None] * window_1d[None, :]
spectrum = np.fft.fft2(channels * window[:, :, None], axes=(0, 1))
frequency_y = np.fft.fftfreq(patch_size)[:, None]
frequency_x = np.fft.fftfreq(patch_size)[None, :]
radius = np.sqrt(frequency_y * frequency_y + frequency_x * frequency_x)
base_mask = (radius >= band_min) & (radius <= band_max)
offset_values = ((0, 0), *_OFF_CARRIER_OFFSETS)
matrices = np.empty((len(harmonics), len(offset_values), 3, 3), dtype=np.complex128)
for harmonic_index, (signed_row_value, signed_column_value) in enumerate(harmonics):
alpha_y = round(float(signed_row_value) * patch_size / tile_size)
alpha_x = round(float(signed_column_value) * patch_size / tile_size)
for offset_index, (offset_y, offset_x) in enumerate(offset_values):
shift_y = alpha_y + offset_y
shift_x = alpha_x + offset_x
shifted = np.roll(spectrum, shift=(-shift_y, -shift_x), axis=(0, 1))
mask = base_mask & np.roll(base_mask, shift=(-shift_y, -shift_x), axis=(0, 1))
base_values = spectrum[mask]
shifted_values = shifted[mask]
normalizer = math.sqrt(float(np.sum(np.abs(base_values) ** 2)) * float(np.sum(np.abs(shifted_values) ** 2)))
if normalizer <= 1e-12:
matrices[harmonic_index, offset_index] = 0.0
else:
matrices[harmonic_index, offset_index] = shifted_values.T @ np.conj(base_values) / normalizer
return matrices
def _group_score(values: list[NDArray[Any]], harmonic_weights: NDArray[Any]) -> tuple[float, float, float]:
if not values:
raise ValueError("cyclostationary score needs at least one patch")
mean_matrices = np.mean(np.stack(values), axis=0)
coherence = np.linalg.norm(mean_matrices, axis=(2, 3))
carrier = float(np.sum(coherence[:, 0] * harmonic_weights))
off_scores = [
float(np.sum(coherence[:, offset_index] * harmonic_weights)) for offset_index in range(1, coherence.shape[1])
]
off_median = float(np.median(off_scores))
return carrier, off_median, carrier - off_median
def score_cyclostationary(
pixels: NDArray[Any],
template: NDArray[Any],
*,
period: float,
patch_size: int = 256,
grid_size: int = 4,
harmonic_count: int = 8,
denoise_sigma: float = 1.0,
band_min: float = 0.05,
band_max: float = 0.35,
) -> CyclostationaryScore:
"""Measure split-confirmed complex spectral coupling at one period."""
if pixels.ndim != 3 or pixels.shape[2] != 3:
raise ValueError("pixels must have shape (height, width, 3)")
if not math.isfinite(period) or period <= 0.0:
raise ValueError("period must be finite and positive")
if not (0.0 < band_min < band_max < 0.5):
raise ValueError("frequency band must satisfy 0 < min < max < 0.5")
if not math.isfinite(denoise_sigma) or denoise_sigma <= 0.0:
raise ValueError("denoise sigma must be finite and positive")
canonical = _canonical_pixels(pixels, template, period)
tile_size = template.shape[0]
harmonics, channel_weights, _coefficient_units = template_harmonics(template, harmonic_count)
harmonic_weights = np.linalg.norm(channel_weights, axis=1)
harmonic_weights /= np.sum(harmonic_weights)
grouped_values: dict[int, list[NDArray[Any]]] = {0: [], 1: []}
for origin_y, origin_x, group in _patch_origins(*canonical.shape[:2], patch_size, grid_size):
aligned_y = (origin_y // tile_size) * tile_size
aligned_x = (origin_x // tile_size) * tile_size
patch = canonical[aligned_y : aligned_y + patch_size, aligned_x : aligned_x + patch_size]
grouped_values[group].append(
_patch_cyclic_matrices(
patch,
harmonics,
tile_size=tile_size,
denoise_sigma=denoise_sigma,
band_min=band_min,
band_max=band_max,
)
)
selection_carrier, selection_null, selection_contrast = _group_score(grouped_values[0], harmonic_weights)
confirmation_carrier, confirmation_null, confirmation_contrast = _group_score(grouped_values[1], harmonic_weights)
return CyclostationaryScore(
selection_carrier=selection_carrier,
selection_off_carrier_median=selection_null,
selection_contrast=selection_contrast,
confirmation_carrier=confirmation_carrier,
confirmation_off_carrier_median=confirmation_null,
confirmation_contrast=confirmation_contrast,
joint_contrast=min(selection_contrast, confirmation_contrast),
harmonic_count=len(harmonics),
selection_patches=len(grouped_values[0]),
confirmation_patches=len(grouped_values[1]),
)
def _load_template(path: Path) -> NDArray[Any]:
with np.load(path, allow_pickle=False) as artifact:
template = np.asarray(artifact["template"], dtype=np.float64)
if template.shape != (16, 16, 3) or not np.all(np.isfinite(template)):
raise ValueError("template artifact does not contain a finite 16x16 RGB template")
return template
@click.command()
@click.argument("template_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("--period", type=click.FloatRange(min=1.0), required=True)
@click.option("--input-scale", type=click.FloatRange(min=0.01), default=1.0, show_default=True)
@click.option("--report-out", type=click.Path(dir_okay=False, path_type=Path), required=True)
def main(
template_path: Path,
images: tuple[Path, ...],
period: float,
input_scale: float,
report_out: Path,
) -> None:
"""Score IMAGES for complex cross-spectral carrier coupling."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
template = _load_template(template_path)
rows = []
for path in images:
try:
pixels = load_rgb(path)
if input_scale != 1.0:
width = max(1, round(pixels.shape[1] * input_scale))
height = max(1, round(pixels.shape[0] * input_scale))
interpolation = cv2.INTER_AREA if input_scale < 1.0 else cv2.INTER_CUBIC
pixels = cv2.resize(pixels, (width, height), interpolation=interpolation)
score = score_cyclostationary(pixels, template, period=period)
except ValueError as error:
rows.append({"path": str(path), "status": "unsupported", "reason": str(error)})
log.warning("%s: unsupported: %s", path, error)
continue
rows.append({"path": str(path), "status": "scored", "score": asdict(score)})
log.info("%s: joint contrast=%.6f", path, score.joint_contrast)
report_out.parent.mkdir(parents=True, exist_ok=True)
report_out.write_text(
json.dumps(
{
"schema_version": 1,
"template": str(template_path),
"period": period,
"input_scale": input_scale,
"records": rows,
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
log.info("Wrote %d cyclostationary records: %s", len(rows), report_out)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -61,7 +61,7 @@ def score_pixels(pixels: NDArray[np.uint8]) -> list[ExpertScore]:
if pixels.ndim != 3 or pixels.shape[2] != 3 or pixels.dtype != np.uint8: if pixels.ndim != 3 or pixels.shape[2] != 3 or pixels.dtype != np.uint8:
raise ValueError("pixels must be an RGB uint8 array") raise ValueError("pixels must be an RGB uint8 array")
bgr_pixels = np.ascontiguousarray(pixels[:, :, ::-1]) bgr_pixels = np.ascontiguousarray(pixels[:, :, ::-1])
native = synthid_detector.detect_synthid("decoded-image", image=bgr_pixels) native = synthid_detector.detect_synthid("decoded-image", image=bgr_pixels, register_scale=False)
registered = synthid_detector.detect_synthid("decoded-image", image=bgr_pixels, register_scale=True) registered = synthid_detector.detect_synthid("decoded-image", image=bgr_pixels, register_scale=True)
fixed = _observation(FIXED_EXPERT_NAME, False, None) fixed = _observation(FIXED_EXPERT_NAME, False, None)
large = _observation(LARGE_EXPERT_NAME, False, None) large = _observation(LARGE_EXPERT_NAME, False, None)
+7 -2
View File
@@ -40,6 +40,7 @@ __all__ = [
"BatchSummary", "BatchSummary",
"InvisibleOptions", "InvisibleOptions",
"MetadataStripIncomplete", "MetadataStripIncomplete",
"OpenAIProvenanceError",
"OpenAISynthIDDetection", "OpenAISynthIDDetection",
"RemoveAllResult", "RemoveAllResult",
"SynthIDDetection", "SynthIDDetection",
@@ -70,7 +71,11 @@ if TYPE_CHECKING:
remove_visible, remove_visible,
visible_provenance, visible_provenance,
) )
from remove_ai_watermarks.openai_provenance import OpenAISynthIDDetection, verify_openai_synthid from remove_ai_watermarks.openai_provenance import (
OpenAIProvenanceError,
OpenAISynthIDDetection,
verify_openai_synthid,
)
from remove_ai_watermarks.synthid_detector import SynthIDDetection, detect_synthid from remove_ai_watermarks.synthid_detector import SynthIDDetection, detect_synthid
from remove_ai_watermarks.video import ( from remove_ai_watermarks.video import (
identify_video, identify_video,
@@ -115,7 +120,7 @@ def __getattr__(name: str) -> object:
from remove_ai_watermarks import synthid_detector from remove_ai_watermarks import synthid_detector
return getattr(synthid_detector, name) return getattr(synthid_detector, name)
if name in ("OpenAISynthIDDetection", "verify_openai_synthid"): if name in ("OpenAIProvenanceError", "OpenAISynthIDDetection", "verify_openai_synthid"):
from remove_ai_watermarks import openai_provenance from remove_ai_watermarks import openai_provenance
return getattr(openai_provenance, name) return getattr(openai_provenance, name)
@@ -0,0 +1,288 @@
"""Independent split-patch confirmation for the registered SynthID carrier."""
# The optional numeric libraries do not provide complete types for this path.
# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import cv2
import numpy as np
from remove_ai_watermarks.synthid_detector import fold_residual_template, unit_tile
if TYPE_CHECKING:
from numpy.typing import NDArray
MIN_PERIOD = 10.0
MIN_COHERENCE = 0.30
MIN_AMPLITUDE = 0.0
H5_PERIOD = (18.0, 18.6)
H5_MIN = 0.13
STRONG_COHERENCE_PERIOD = (18.6, 20.0)
STRONG_COHERENCE_MIN = 0.40
WEAK_H5_PERIOD = (20.0, 22.0)
WEAK_H5_MIN = 0.02
PATCH_SIZE = 256
GRID_SIZE = 4
HARMONIC_COUNT = 16
@dataclass(frozen=True)
class RegisteredConfirmationComponents:
"""Auditable split-patch confirmation components for one fixed period."""
period: float
joint_coherence: float
joint_amplitude: float
unknown_codeword_fixed_confirmation: float
selection_patches: int
confirmation_patches: int
@property
def passes(self) -> bool:
"""Whether every frozen period-aware confirmation gate passes."""
return registered_confirmation_passes(
self.period,
self.joint_coherence,
self.joint_amplitude,
self.unknown_codeword_fixed_confirmation,
)
def registered_confirmation_passes(
period: float,
joint_coherence: float,
joint_amplitude: float,
unknown_codeword_fixed_confirmation: float,
) -> bool:
"""Apply the single frozen registered-carrier confirmation rule."""
if period < MIN_PERIOD:
return False
if joint_coherence < MIN_COHERENCE or joint_amplitude < MIN_AMPLITUDE:
return False
if H5_PERIOD[0] <= period < H5_PERIOD[1]:
return unknown_codeword_fixed_confirmation >= H5_MIN
if STRONG_COHERENCE_PERIOD[0] <= period < STRONG_COHERENCE_PERIOD[1]:
return joint_coherence >= STRONG_COHERENCE_MIN
if WEAK_H5_PERIOD[0] <= period < WEAK_H5_PERIOD[1]:
return unknown_codeword_fixed_confirmation >= WEAK_H5_MIN
return True
def _opponent_channels(values: NDArray[Any]) -> NDArray[Any]:
red = values[:, :, 0]
green = values[:, :, 1]
blue = values[:, :, 2]
return np.stack((green, red - green, blue - 0.5 * (red + green)), axis=2)
def _template_harmonics(template: NDArray[Any]) -> tuple[NDArray[Any], NDArray[Any]]:
opponent = _opponent_channels(np.asarray(template, dtype=np.float64))
spectrum = np.fft.fft2(opponent, axes=(0, 1))
height, width = template.shape[:2]
candidates: list[tuple[float, int, int]] = []
for row in range(height):
signed_row = row if row <= height // 2 else row - height
for column in range(width):
signed_column = column if column <= width // 2 else column - width
if signed_row < 0 or (signed_row == 0 and signed_column <= 0):
continue
power = float(np.sum(np.abs(spectrum[row, column]) ** 2))
candidates.append((power, signed_row, signed_column))
candidates.sort(reverse=True)
selected = candidates[:HARMONIC_COUNT]
harmonics = np.asarray([(row, column) for _power, row, column in selected], dtype=np.float64)
coefficients = np.asarray([spectrum[int(row) % height, int(column) % width] for row, column in harmonics])
weights = np.abs(coefficients)
weight_sum = float(np.sum(weights))
if weight_sum <= 0.0:
raise ValueError("template has no nonzero periodic harmonics")
return harmonics, weights / weight_sum
def _patch_origins(height: int, width: int) -> list[tuple[int, int, int]]:
if height < PATCH_SIZE or width < PATCH_SIZE:
raise ValueError("registered confirmation needs both image sides to be at least 256 pixels")
y_values = np.linspace(0, height - PATCH_SIZE, min(GRID_SIZE, height // PATCH_SIZE), dtype=np.int64)
x_values = np.linspace(0, width - PATCH_SIZE, min(GRID_SIZE, width // PATCH_SIZE), dtype=np.int64)
origins = [
(int(y), int(x), (y_index + x_index) % 2)
for y_index, y in enumerate(np.unique(y_values))
for x_index, x in enumerate(np.unique(x_values))
]
if {group for _y, _x, group in origins} != {0, 1}:
raise ValueError("registered confirmation needs two independent patch groups")
return origins
def _bilinear_sample(spectrum: NDArray[Any], y: NDArray[Any], x: NDArray[Any]) -> NDArray[Any]:
height, width = spectrum.shape
y_floor = np.floor(y)
x_floor = np.floor(x)
y0 = y_floor.astype(np.int64) % height
x0 = x_floor.astype(np.int64) % width
y1 = (y0 + 1) % height
x1 = (x0 + 1) % width
dy = y - y_floor
dx = x - x_floor
return (
spectrum[y0, x0] * (1.0 - dy) * (1.0 - dx)
+ spectrum[y1, x0] * dy * (1.0 - dx)
+ spectrum[y0, x1] * (1.0 - dy) * dx
+ spectrum[y1, x1] * dy * dx
)
def _patch_unit_values(
pixels: NDArray[Any],
origin_y: int,
origin_x: int,
period: float,
harmonics: NDArray[Any],
denoise_sigma: float,
) -> NDArray[Any]:
patch = np.asarray(
pixels[origin_y : origin_y + PATCH_SIZE, origin_x : origin_x + PATCH_SIZE],
dtype=np.float32,
)
channels = _opponent_channels(patch)
window_1d = np.hanning(PATCH_SIZE).astype(np.float32)
window = window_1d[:, None] * window_1d[None, :]
frequencies_y = harmonics[:, 0] / period
frequencies_x = harmonics[:, 1] / period
sample_y = frequencies_y * PATCH_SIZE
sample_x = frequencies_x * PATCH_SIZE
sampled = np.empty((len(harmonics), 3), dtype=np.complex128)
for channel in range(3):
residual = channels[:, :, channel]
residual -= cv2.GaussianBlur(
residual,
(0, 0),
sigmaX=denoise_sigma,
sigmaY=denoise_sigma,
borderType=cv2.BORDER_REFLECT_101,
)
sampled[:, channel] = _bilinear_sample(np.fft.fft2(residual * window), sample_y, sample_x)
sampled *= np.exp(-2j * math.pi * (frequencies_y * origin_y + frequencies_x * origin_x))[:, None]
magnitudes = np.abs(sampled)
return np.divide(sampled, magnitudes, out=np.zeros_like(sampled), where=magnitudes > 1e-12)
def _coherence(values: list[NDArray[Any]], weights: NDArray[Any]) -> float:
coherence = np.abs(np.mean(np.stack(values), axis=0))
return float(np.sum(coherence * weights))
def _unknown_codeword_fixed_confirmation(
selection_values: list[NDArray[Any]],
confirmation_values: list[NDArray[Any]],
weights: NDArray[Any],
) -> float:
cross_codeword = np.mean(np.stack(confirmation_values), axis=0) * np.conj(
np.mean(np.stack(selection_values), axis=0)
)
confirmation_mask = np.arange(len(weights)) % 2 == 1
masked_weights = weights[confirmation_mask]
return float(np.abs(np.sum(cross_codeword[confirmation_mask] * masked_weights)) / np.sum(masked_weights))
def _canonical_pixels(pixels: NDArray[Any], template: NDArray[Any], period: float) -> NDArray[Any]:
width = max(template.shape[1], round(pixels.shape[1] * template.shape[1] / period))
height = max(template.shape[0], round(pixels.shape[0] * template.shape[0] / period))
if (height, width) == pixels.shape[:2]:
return pixels
interpolation = cv2.INTER_AREA if width < pixels.shape[1] else cv2.INTER_CUBIC
return np.asarray(cv2.resize(pixels, (width, height), interpolation=interpolation))
def _cyclic_correlations(template: NDArray[Any], tile: NDArray[Any]) -> NDArray[Any]:
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
def _joint_amplitude(
pixels: NDArray[Any],
template: NDArray[Any],
period: float,
denoise_sigma: float,
) -> tuple[float, int, int]:
canonical = _canonical_pixels(pixels, template, period)
tile_height, tile_width = template.shape[:2]
grouped_units: dict[int, list[NDArray[Any]]] = {0: [], 1: []}
origins = _patch_origins(*canonical.shape[:2])
for origin_y, origin_x, group in origins:
aligned_y = (origin_y // tile_height) * tile_height
aligned_x = (origin_x // tile_width) * tile_width
folded = fold_residual_template(
canonical[aligned_y : aligned_y + PATCH_SIZE, aligned_x : aligned_x + PATCH_SIZE],
tile_height=tile_height,
tile_width=tile_width,
denoise_sigma=denoise_sigma,
)
unit, _norm = unit_tile(folded)
grouped_units[group].append(unit)
selection_tile, _selection_norm = unit_tile(np.mean(grouped_units[0], axis=0))
confirmation_tile, _confirmation_norm = unit_tile(np.mean(grouped_units[1], axis=0))
selection_correlations = _cyclic_correlations(template, selection_tile)
confirmation_correlations = _cyclic_correlations(template, confirmation_tile)
shift_y, shift_x = np.unravel_index(int(np.argmax(selection_correlations)), selection_correlations.shape)
return (
min(
float(selection_correlations[shift_y, shift_x]),
float(confirmation_correlations[shift_y, shift_x]),
),
len(grouped_units[0]),
len(grouped_units[1]),
)
def registered_confirmation_components(
pixels: NDArray[Any],
template: NDArray[Any],
period: float,
denoise_sigma: float,
) -> RegisteredConfirmationComponents:
"""Measure the frozen split-patch gates at one registered carrier period."""
if pixels.ndim != 3 or pixels.shape[2] != 3:
raise ValueError("pixels must have shape (height, width, 3)")
if not math.isfinite(period) or period <= 0.0:
raise ValueError("registered period must be finite and positive")
harmonics, weights = _template_harmonics(template)
grouped_values: dict[int, list[NDArray[Any]]] = {0: [], 1: []}
for origin_y, origin_x, group in _patch_origins(*pixels.shape[:2]):
grouped_values[group].append(
_patch_unit_values(
pixels,
origin_y,
origin_x,
period,
harmonics,
denoise_sigma,
)
)
amplitude, selection_patches, confirmation_patches = _joint_amplitude(
pixels,
template,
period,
denoise_sigma,
)
return RegisteredConfirmationComponents(
period=period,
joint_coherence=min(
_coherence(grouped_values[0], weights),
_coherence(grouped_values[1], weights),
),
joint_amplitude=amplitude,
unknown_codeword_fixed_confirmation=_unknown_codeword_fixed_confirmation(
grouped_values[0],
grouped_values[1],
weights,
),
selection_patches=selection_patches,
confirmation_patches=confirmation_patches,
)
+363 -3
View File
@@ -13,6 +13,10 @@ from typing import TYPE_CHECKING, Any
import cv2 import cv2
import numpy as np import numpy as np
from remove_ai_watermarks._synthid_confirmation import (
RegisteredConfirmationComponents,
registered_confirmation_components,
)
from remove_ai_watermarks.synthid_detector import folded_template_score from remove_ai_watermarks.synthid_detector import folded_template_score
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -21,6 +25,9 @@ if TYPE_CHECKING:
_PYRAMID_SCALES = (0.75, 1.0, 1.25) _PYRAMID_SCALES = (0.75, 1.0, 1.25)
_SEARCH_PERIODS = np.linspace(5.0, 32.0, 541, dtype=np.float64) _SEARCH_PERIODS = np.linspace(5.0, 32.0, 541, dtype=np.float64)
_CANONICAL_PERIODS = np.linspace(7.5, 24.5, 1701, dtype=np.float64) _CANONICAL_PERIODS = np.linspace(7.5, 24.5, 1701, dtype=np.float64)
_OPPONENT_SEARCH_PERIODS = np.linspace(7.5, 14.5, 141, dtype=np.float64)
_FINE_OPPONENT_COARSE_PERIODS = np.linspace(7.5, 9.0, 31, dtype=np.float64)
_FINE_OPPONENT_PROBE_SIZE = 384
_PERIOD_THRESHOLDS = ( _PERIOD_THRESHOLDS = (
(7.5, 8.5, 0.3770629524888979), (7.5, 8.5, 0.3770629524888979),
(8.5, 10.0, 0.25174716660523494), (8.5, 10.0, 0.25174716660523494),
@@ -33,6 +40,15 @@ _PERIOD_THRESHOLDS = (
(22.0, 24.5, 0.3142958338390489), (22.0, 24.5, 0.3142958338390489),
) )
REGISTERED_HIGH_BAND_THRESHOLD = 0.075 REGISTERED_HIGH_BAND_THRESHOLD = 0.075
OPPONENT_REGISTERED_MIN_PERIOD = 7.9
OPPONENT_REGISTERED_MAX_PERIOD = 12.0
OPPONENT_REGISTERED_CODEC_VETO_MAX_PERIOD = 8.1
OPPONENT_REGISTERED_MAX_P8_EDGE_RATIO = 1.05
FINE_OPPONENT_REGISTERED_MIN_PERIOD = 7.5
FINE_OPPONENT_REGISTERED_MAX_PERIOD = 9.0
OPPONENT_REGISTERED_FIXED_MIN = 0.16
OPPONENT_REGISTERED_RED_GREEN_MIN = 0.60
OPPONENT_REGISTERED_BLUE_YELLOW_MIN = 0.55
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -44,10 +60,11 @@ class RegisteredComponents:
selected_period: float selected_period: float
spectral_period: float spectral_period: float
high_band_score: float high_band_score: float
confirmation: RegisteredConfirmationComponents | None = None
@property @property
def decision_score(self) -> float: def base_decision_score(self) -> float:
"""Return a statistic that reaches one only when every gate passes.""" """Return the unchanged registered-v2 decision statistic."""
if self.selected_period != self.spectral_period: if self.selected_period != self.spectral_period:
return 0.0 return 0.0
return min( return min(
@@ -55,6 +72,62 @@ class RegisteredComponents:
self.high_band_score / REGISTERED_HIGH_BAND_THRESHOLD, self.high_band_score / REGISTERED_HIGH_BAND_THRESHOLD,
) )
@property
def decision_score(self) -> float:
"""Return the base score only after split confirmation passes."""
base_score = self.base_decision_score
if base_score < 1.0:
return base_score
if self.confirmation is None or not self.confirmation.passes:
return 0.0
return base_score
@dataclass(frozen=True)
class OpponentRegisteredComponents:
"""Auditable margins for the bounded opponent-color fallback."""
selected_period: float
spectral_period: float
spectral_score: float
fixed_score: float
red_green_spatial: float
blue_yellow_spatial: float
candidate_count: int
red_green_p8_edge_ratio: float | None
blue_yellow_p8_edge_ratio: float | None
@property
def base_decision_score(self) -> float:
"""Return the minimum normalized color-carrier margin."""
return min(
self.fixed_score / OPPONENT_REGISTERED_FIXED_MIN,
self.red_green_spatial / OPPONENT_REGISTERED_RED_GREEN_MIN,
self.blue_yellow_spatial / OPPONENT_REGISTERED_BLUE_YELLOW_MIN,
)
@property
def decision_score(self) -> float:
"""Return the margin only inside the independently challenged period band."""
if not OPPONENT_REGISTERED_MIN_PERIOD <= self.selected_period <= OPPONENT_REGISTERED_MAX_PERIOD:
return 0.0
if self.selected_period <= OPPONENT_REGISTERED_CODEC_VETO_MAX_PERIOD:
ratios = (self.red_green_p8_edge_ratio, self.blue_yellow_p8_edge_ratio)
if any(value is None or value > OPPONENT_REGISTERED_MAX_P8_EDGE_RATIO for value in ratios):
return 0.0
return self.base_decision_score
@property
def fine_decision_score(self) -> float:
"""Return the margin for the separately calibrated fine-period expert."""
if not FINE_OPPONENT_REGISTERED_MIN_PERIOD <= self.selected_period <= FINE_OPPONENT_REGISTERED_MAX_PERIOD:
return 0.0
if self.selected_period <= OPPONENT_REGISTERED_CODEC_VETO_MAX_PERIOD:
ratios = (self.red_green_p8_edge_ratio, self.blue_yellow_p8_edge_ratio)
if any(value is None or value > OPPONENT_REGISTERED_MAX_P8_EDGE_RATIO for value in ratios):
return 0.0
return self.base_decision_score
def _resize(pixels: NDArray[Any], width: int, height: int) -> NDArray[Any]: def _resize(pixels: NDArray[Any], width: int, height: int) -> NDArray[Any]:
interpolation = cv2.INTER_AREA if width < pixels.shape[1] else cv2.INTER_CUBIC interpolation = cv2.INTER_AREA if width < pixels.shape[1] else cv2.INTER_CUBIC
@@ -265,6 +338,256 @@ def _pyramid_locked_mean(
return float(np.mean(locked)) return float(np.mean(locked))
def _opponent_pair(values: NDArray[Any]) -> NDArray[Any]:
"""Return Red-minus-Green and Blue-minus-Yellow color planes."""
red = values[:, :, 0]
green = values[:, :, 1]
blue = values[:, :, 2]
return np.stack((red - green, blue - 0.5 * (red + green)), axis=2)
def _opponent_period_curve(
pixels: NDArray[Any],
template: NDArray[Any],
periods: NDArray[Any] = _OPPONENT_SEARCH_PERIODS,
) -> NDArray[Any]:
"""Return signed opponent-color coherence across the frozen search grid."""
template_opponent = _opponent_pair(np.asarray(template, dtype=np.float64))
template_spectrum = np.fft.fft2(template_opponent, axes=(0, 1))
power = np.sum(np.abs(template_spectrum) ** 2, axis=2)
power[0, 0] = 0.0
indices = np.argsort(power.ravel())[::-1][:30]
rows, columns = np.unravel_index(indices, power.shape)
height, width = template.shape[:2]
signed_rows = np.where(rows <= height // 2, rows, rows - height)
signed_columns = np.where(columns <= width // 2, columns, columns - width)
harmonics = np.column_stack((signed_rows, signed_columns)).astype(np.float64)
coefficients = template_spectrum[rows, columns]
image_height, image_width = pixels.shape[:2]
sample_y = periods[:, None] ** -1 * harmonics[None, :, 0] * image_height
sample_x = periods[:, None] ** -1 * harmonics[None, :, 1] * image_width
sampled = np.empty((len(periods), len(harmonics), 2), dtype=np.complex128)
image_opponent = _opponent_pair(np.asarray(pixels, dtype=np.float32))
for channel in range(2):
residual = image_opponent[:, :, channel]
residual -= cv2.GaussianBlur(
residual,
(0, 0),
sigmaX=1.0,
sigmaY=1.0,
borderType=cv2.BORDER_REFLECT_101,
)
sampled[:, :, channel] = _bilinear_sample(
np.fft.fft2(residual),
sample_y % image_height,
sample_x % image_width,
)
numerator = np.real(np.sum(np.conj(coefficients)[None, :, :] * sampled, axis=(1, 2)))
denominator = np.linalg.norm(coefficients) * np.linalg.norm(sampled, axis=(1, 2))
return np.divide(numerator, denominator, out=np.zeros_like(numerator), where=denominator > 0.0)
def _opponent_period_candidates(scores: NDArray[Any], count: int = 3) -> list[int]:
"""Return separated period indices in descending spectral-score order."""
candidates: list[int] = []
for index in np.argsort(scores)[::-1]:
period = float(_OPPONENT_SEARCH_PERIODS[index])
if any(abs(period - float(_OPPONENT_SEARCH_PERIODS[prior])) < 0.5 for prior in candidates):
continue
candidates.append(int(index))
if len(candidates) == count:
break
return candidates
def _canonical_at_period(
pixels: NDArray[Any],
template: NDArray[Any],
period: float,
) -> NDArray[Any]:
"""Resample PIXELS so PERIOD maps to the frozen template period."""
width = max(template.shape[1], round(pixels.shape[1] * template.shape[1] / period))
height = max(template.shape[0], round(pixels.shape[0] * template.shape[0] / period))
if (height, width) == pixels.shape[:2]:
return pixels
return _resize(pixels, width, height)
def _correlation(left: NDArray[Any], right: NDArray[Any]) -> float:
"""Return the signed real cosine between equal-shaped arrays."""
denominator = float(np.linalg.norm(left) * np.linalg.norm(right))
return float(np.real(np.vdot(right, left)) / denominator) if denominator > 0.0 else 0.0
def _period8_edge_ratio(values: NDArray[Any]) -> float:
"""Measure native 8-pixel block edges relative to non-block phases."""
phase_values = np.zeros(8, dtype=np.float64)
for axis in (0, 1):
differences = np.abs(np.diff(values, axis=axis))
indices = np.arange(differences.shape[axis])
for phase in range(8):
selected = indices[(indices + 1) % 8 == phase]
phase_values[phase] += 0.5 * float(np.take(differences, selected, axis=axis).mean())
baseline = float(np.median(phase_values[[1, 2, 3, 5, 6, 7]]))
return float(phase_values[0] / baseline) if baseline > 1e-9 else math.inf
def _period8_opponent_edge_ratios(pixels: NDArray[Any]) -> tuple[float, float]:
"""Return codec-grid ratios for the two opponent-color planes."""
opponent = _opponent_pair(np.asarray(pixels, dtype=np.float32))
return _period8_edge_ratio(opponent[:, :, 0]), _period8_edge_ratio(opponent[:, :, 1])
def _opponent_components_at_period(
pixels: NDArray[Any],
template: NDArray[Any],
sigma: float,
period: float,
*,
spectral_period: float,
spectral_score: float,
candidate_count: int,
period8_edge_ratios: tuple[float, float] | None = None,
) -> OpponentRegisteredComponents:
"""Measure one period without selecting it from the image being scored."""
canonical = _canonical_at_period(pixels, template, period)
fixed_score, folded = folded_template_score(canonical, template, sigma)
folded_opponent = _opponent_pair(folded)
template_opponent = _opponent_pair(template)
red_green_p8_edge_ratio, blue_yellow_p8_edge_ratio = period8_edge_ratios or (None, None)
return OpponentRegisteredComponents(
selected_period=period,
spectral_period=spectral_period,
spectral_score=spectral_score,
fixed_score=fixed_score,
red_green_spatial=_correlation(folded_opponent[:, :, 0], template_opponent[:, :, 0]),
blue_yellow_spatial=_correlation(folded_opponent[:, :, 1], template_opponent[:, :, 1]),
candidate_count=candidate_count,
red_green_p8_edge_ratio=red_green_p8_edge_ratio,
blue_yellow_p8_edge_ratio=blue_yellow_p8_edge_ratio,
)
def opponent_registered_components(
pixels: NDArray[Any],
template: NDArray[Any],
sigma: float,
) -> OpponentRegisteredComponents:
"""Measure the bounded lossless-resize carrier in opponent-color space."""
curve = _opponent_period_curve(pixels, template)
candidate_indices = _opponent_period_candidates(curve)
observations: list[OpponentRegisteredComponents] = []
period8_edge_ratios: tuple[float, float] | None = None
for index in candidate_indices:
period = float(_OPPONENT_SEARCH_PERIODS[index])
if period <= OPPONENT_REGISTERED_CODEC_VETO_MAX_PERIOD and period8_edge_ratios is None:
period8_edge_ratios = _period8_opponent_edge_ratios(pixels)
observations.append(
_opponent_components_at_period(
pixels,
template,
sigma,
period,
spectral_period=float(_OPPONENT_SEARCH_PERIODS[int(np.argmax(curve))]),
spectral_score=float(curve[index]),
candidate_count=len(candidate_indices),
period8_edge_ratios=period8_edge_ratios,
)
)
if not observations:
raise RuntimeError("opponent-color registration produced no candidates")
return max(observations, key=lambda observation: observation.base_decision_score)
def _fine_opponent_period_groups(curve: NDArray[Any]) -> list[list[float]]:
"""Return fine period grids around separated absolute spectral peaks."""
centers: list[float] = []
for index in np.argsort(np.abs(curve))[::-1]:
period = float(_FINE_OPPONENT_COARSE_PERIODS[index])
if any(abs(period - existing) < 0.2 for existing in centers):
continue
centers.append(period)
if len(centers) == 3:
break
return [
sorted(
{
round(float(period), 2)
for period in np.arange(center - 0.36, center + 0.361, 0.01)
if FINE_OPPONENT_REGISTERED_MIN_PERIOD <= period <= FINE_OPPONENT_REGISTERED_MAX_PERIOD
}
)
for center in centers
]
def fine_opponent_registered_components(
pixels: NDArray[Any],
template: NDArray[Any],
sigma: float,
) -> OpponentRegisteredComponents:
"""Select and score the calibrated fine-period lossless-resize expert."""
curve = _opponent_period_curve(pixels, template, _FINE_OPPONENT_COARSE_PERIODS)
spectral_index = int(np.argmax(np.abs(curve)))
spectral_period = float(_FINE_OPPONENT_COARSE_PERIODS[spectral_index])
period_groups = _fine_opponent_period_groups(curve)
probe = pixels[
: min(_FINE_OPPONENT_PROBE_SIZE, pixels.shape[0]),
: min(_FINE_OPPONENT_PROBE_SIZE, pixels.shape[1]),
]
candidate_count = sum(len(group) for group in period_groups)
unique_periods = sorted({period for group in period_groups for period in group})
probe_by_period = {
period: _opponent_components_at_period(
probe,
template,
sigma,
period,
spectral_period=spectral_period,
spectral_score=float(np.interp(period, _FINE_OPPONENT_COARSE_PERIODS, curve)),
candidate_count=candidate_count,
)
for period in unique_periods
}
probe_groups = [[probe_by_period[period] for period in group] for group in period_groups]
probe_observations = [observation for group in probe_groups for observation in group]
finalist_periods = {
observation.selected_period
for group in probe_groups
for observation in sorted(group, key=lambda value: value.base_decision_score, reverse=True)[:2]
}
finalist_periods.update(
observation.selected_period
for observation in sorted(
probe_observations,
key=lambda value: value.base_decision_score,
reverse=True,
)[:5]
)
period8_edge_ratios = (
_period8_opponent_edge_ratios(pixels)
if any(period <= OPPONENT_REGISTERED_CODEC_VETO_MAX_PERIOD for period in finalist_periods)
else None
)
observations = [
_opponent_components_at_period(
pixels,
template,
sigma,
period,
spectral_period=spectral_period,
spectral_score=float(np.interp(period, _FINE_OPPONENT_COARSE_PERIODS, curve)),
candidate_count=len(probe_observations),
period8_edge_ratios=period8_edge_ratios,
)
for period in sorted(finalist_periods)
]
if not observations:
raise RuntimeError("fine opponent-color registration produced no candidates")
return max(observations, key=lambda observation: observation.base_decision_score)
def registered_components( def registered_components(
pixels: NDArray[Any], pixels: NDArray[Any],
template: NDArray[Any], template: NDArray[Any],
@@ -286,13 +609,32 @@ def registered_components(
base_curve, base_curve,
) )
raw_score = float((baseline + quadrant + pyramid) / 3.0) raw_score = float((baseline + quadrant + pyramid) / 3.0)
return RegisteredComponents( components = RegisteredComponents(
raw_score=raw_score, raw_score=raw_score,
amplitude_threshold=_period_threshold(selected_period), amplitude_threshold=_period_threshold(selected_period),
selected_period=selected_period, selected_period=selected_period,
spectral_period=candidates[0], spectral_period=candidates[0],
high_band_score=_high_band_score(folded, template_spectrum), high_band_score=_high_band_score(folded, template_spectrum),
) )
if components.base_decision_score < 1.0:
return components
try:
confirmation = registered_confirmation_components(
pixels,
template,
selected_period,
sigma,
)
except ValueError:
return components
return RegisteredComponents(
raw_score=components.raw_score,
amplitude_threshold=components.amplitude_threshold,
selected_period=components.selected_period,
spectral_period=components.spectral_period,
high_band_score=components.high_band_score,
confirmation=confirmation,
)
def registered_score( def registered_score(
@@ -302,3 +644,21 @@ def registered_score(
) -> float: ) -> float:
"""Return the calibrated registered decision statistic.""" """Return the calibrated registered decision statistic."""
return registered_components(pixels, template, sigma).decision_score return registered_components(pixels, template, sigma).decision_score
def opponent_registered_score(
pixels: NDArray[Any],
template: NDArray[Any],
sigma: float,
) -> float:
"""Return the bounded opponent-color fallback decision statistic."""
return opponent_registered_components(pixels, template, sigma).decision_score
def fine_opponent_registered_score(
pixels: NDArray[Any],
template: NDArray[Any],
sigma: float,
) -> float:
"""Return the separately calibrated fine-period decision statistic."""
return fine_opponent_registered_components(pixels, template, sigma).fine_decision_score
+31 -14
View File
@@ -1323,15 +1323,22 @@ def cmd_video_batch(
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path)) @click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--json", "as_json", is_flag=True, help="Emit the detector result as JSON.") @click.option("--json", "as_json", is_flag=True, help="Emit the detector result as JSON.")
@click.option( @click.option(
"--register-scale", "--register-scale/--fixed-period",
is_flag=True, default=None,
help="Search the slower calibrated range of spatial carrier scales.", help="Force registered production search or the legacy fixed-period diagnostic.",
) )
def cmd_detect_synthid(source: Path, as_json: bool, register_scale: bool) -> None: def cmd_detect_synthid(source: Path, as_json: bool, register_scale: bool | None) -> None:
"""Detect the SynthID periodic pixel carrier at calibrated image sizes. """Detect a generation-pipeline pixel lattice at calibrated image sizes.
A negative result means this detector did not find its supported carrier; it EXPERIMENTAL. The supported route for SynthID is signed provenance, which
is not proof that the image contains no SynthID watermark. `identify` reads and `verify-openai-synthid` confirms against the provider.
This command does NOT detect the SynthID watermark. The statistic it reports is
destroyed by a seven-pixel crop, while SynthID's published evaluation keeps
99.97% of its detection rate under aggressive crop and resize, so what
crosses the threshold identifies the generation pipeline rather than the
mark. Read a positive as "these pixels came from a pipeline that leaves this
lattice", never as "this image is watermarked", and read an indeterminate
result as neither.
""" """
from remove_ai_watermarks.synthid_detector import detect_synthid from remove_ai_watermarks.synthid_detector import detect_synthid
@@ -1346,18 +1353,28 @@ def cmd_detect_synthid(source: Path, as_json: bool, register_scale: bool) -> Non
return return
_banner() _banner()
console.print(f"\n SynthID pixel carrier: {result.status}") console.print(f"\n Generation-pipeline lattice (experimental): {result.status}")
console.print(f" Geometry: {result.width}x{result.height}") console.print(f" Geometry: {result.width}x{result.height}")
if result.score is not None: if result.score is not None:
console.print(f" Score: {result.score:.6f} (threshold: {result.threshold:.6f})") console.print(f" Score: {result.score:.6f} (threshold: {result.threshold:.6f})")
console.print(f" Detector: {result.detector}") console.print(f" Detector: {result.detector}")
scale_scope = ( if result.reason is not None:
" Bounded spatial-scale registration was enabled. A negative or\n" console.print(f" Reason: {result.reason}")
if register_scale if register_scale is True:
else " Arbitrary spatial resampling was not registered. A negative or\n" scale_scope = " Bounded spatial-scale registration was explicitly enabled. An indeterminate or\n"
) elif register_scale is False:
scale_scope = " The legacy fixed-period diagnostic was explicitly enabled. An indeterminate or\n"
else:
scale_scope = (
" The production router selected the calibrated registered or large-image expert. An indeterminate or\n"
)
console.print( console.print(
" Scope: one confirmed periodic carrier family in a calibrated image-size range.\n" " Scope: experimental. One periodic lattice family in a calibrated image-size range,\n"
" secondary to signed provenance, which remains the supported SynthID route. This is a\n"
" generation-pipeline signature, not the SynthID watermark: it disappears when the\n"
" image is cropped off the tile grid, and it changes when the generator's pipeline\n"
" changes. A positive says the pixels came from such a pipeline. It does not say the\n"
" image carries a watermark, and it does not say it lacks one.\n"
+ scale_scope + scale_scope
+ " unsupported result is not proof that SynthID is absent." + " unsupported result is not proof that SynthID is absent."
) )
+27 -16
View File
@@ -114,10 +114,12 @@ _SYNTHID_CAVEAT = (
"covers one measured carrier family in a calibrated image-size range; confirm other cases with " "covers one measured carrier family in a calibrated image-size range; confirm other cases with "
"the provider oracle." "the provider oracle."
) )
_SYNTHID_PIXEL_CAVEAT = ( _PIPELINE_LATTICE_CAVEAT = (
"The local SynthID pixel result is a positive-only match to one measured periodic carrier family " "EXPERIMENTAL. Signed provenance is the primary route for SynthID; this pixel result is not a "
"in a calibrated image-size range, not a proprietary payload decode. A negative or unsupported " "watermark at all but a generation-pipeline lattice: it is destroyed by a "
"result is not proof of absence." "crop of seven pixels, while the published SynthID evaluation survives aggressive crop and resize. "
"It accepted 29 of 223 images from other generators, 24% of Adobe Firefly, so it does not identify "
"the provider. A negative or unsupported result is not proof of absence."
) )
_IPTC_ONLY_CAVEAT = "The IPTC 'Made with AI' tag flags AI provenance but does not identify the specific platform." _IPTC_ONLY_CAVEAT = "The IPTC 'Made with AI' tag flags AI provenance but does not identify the specific platform."
_INVISIBLE_WM_CAVEAT = ( _INVISIBLE_WM_CAVEAT = (
@@ -956,8 +958,14 @@ def _trustmark(image_path: Path) -> str | None:
return detect_trustmark(image_path) return detect_trustmark(image_path)
def _synthid_pixel_watermark(image_path: Path, decode: _SharedDecode) -> bool: def _pipeline_lattice(image_path: Path, decode: _SharedDecode) -> bool:
"""Whether the supported positive-only SynthID carrier is detected.""" """Whether the supported generation-pipeline lattice is detected.
Named for what it measures. The underlying expert is still called a SynthID
detector in its own module, but its statistic is a lattice anchored at the
image origin that a seven-pixel crop removes, so nothing here may present it
as a watermark.
"""
from remove_ai_watermarks.synthid_detector import detect_synthid, is_available from remove_ai_watermarks.synthid_detector import detect_synthid, is_available
if not is_available() or (image := decode.get()) is None: if not is_available() or (image := decode.get()) is None:
@@ -1309,16 +1317,17 @@ def _identify_from_evidence(
if platform is None: if platform is None:
platform = f"{scheme} (open DWT-DCT watermark)" platform = f"{scheme} (open DWT-DCT watermark)"
# ── Positive-only SynthID periodic carrier ────────────────────── # ── Generation-pipeline lattice, experimental ───────────────────
# This is deliberately separate from C2PA provenance. It survives lossless # Signed provenance above is the primary SynthID route; this is a secondary
# metadata stripping, but covers only one carrier family and a calibrated # pixel observation and is kept out of the watermark inventory on purpose. This reads a periodic
# image-size range. # lattice anchored at the image origin, which identifies the pipeline that
if check_invisible and pixel_path is not None and _synthid_pixel_watermark(pixel_path, decode): # produced the pixels; it is not SynthID and not any watermark, so listing
signals.append(Signal("synthid_pixel", "calibrated periodic carrier", "high")) # it beside C2PA watermark assertions would misrepresent both.
watermarks.append("SynthID periodic pixel carrier (calibrated image size)") if check_invisible and pixel_path is not None and _pipeline_lattice(pixel_path, decode):
caveats.append(_SYNTHID_PIXEL_CAVEAT) signals.append(Signal("pipeline_lattice", "generation-pipeline lattice (experimental)", "medium"))
caveats.append(_PIPELINE_LATTICE_CAVEAT)
if platform is None: if platform is None:
platform = "SynthID carrier detected (provider not attributed locally)" platform = "generation-pipeline lattice detected (provider not attributed locally)"
# ── Adobe TrustMark invisible watermark (open decoder, no key) ─── # ── Adobe TrustMark invisible watermark (open decoder, no key) ───
# The watermark behind Adobe Durable Content Credentials. Decoded locally, # The watermark behind Adobe Durable Content Credentials. Decoded locally,
@@ -1331,7 +1340,8 @@ def _identify_from_evidence(
platform = "Adobe (TrustMark / Content Credentials)" platform = "Adobe (TrustMark / Content Credentials)"
# ── Verdict so far (metadata + embedded watermark) ────────────── # ── Verdict so far (metadata + embedded watermark) ──────────────
invisible_wm = any(s.name in {"invisible_watermark", "synthid_pixel"} for s in signals) invisible_wm = any(s.name == "invisible_watermark" for s in signals)
pipeline_lattice = any(s.name == "pipeline_lattice" for s in signals)
exif_gen = any(s.name == "exif_generator" for s in signals) exif_gen = any(s.name == "exif_generator" for s in signals)
xai_sig = any(s.name == "xai_signature" for s in signals) xai_sig = any(s.name == "xai_signature" for s in signals)
ai_from_metadata = bool( ai_from_metadata = bool(
@@ -1341,6 +1351,7 @@ def _identify_from_evidence(
or aigc or aigc
or local_keys or local_keys
or invisible_wm or invisible_wm
or pipeline_lattice
or exif_gen or exif_gen
or xai_sig or xai_sig
) )
+132 -13
View File
@@ -18,6 +18,7 @@ import importlib
import json import json
import logging import logging
import tempfile import tempfile
import time
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -30,6 +31,10 @@ OpenAISynthIDStatus = Literal["detected", "not_detected"]
DETECTOR_ID = "openai-content-provenance-synthid-v1" DETECTOR_ID = "openai-content-provenance-synthid-v1"
INSTALL_HINT = "install the verification extra: uv add 'remove-ai-watermarks[verify]'" INSTALL_HINT = "install the verification extra: uv add 'remove-ai-watermarks[verify]'"
MAX_UPLOAD_BYTES = 50 * 1024 * 1024 MAX_UPLOAD_BYTES = 50 * 1024 * 1024
REQUEST_TIMEOUT_SECONDS = 120.0
# One acknowledgement authorizes one upload. The SDK otherwise retries some
# failures by default, which can transmit the same media more than once.
MAX_AUTOMATIC_RETRIES = 0
_FORMAT_DETAILS = { _FORMAT_DETAILS = {
"JPEG": ("image/jpeg", ".jpg"), "JPEG": ("image/jpeg", ".jpg"),
"PNG": ("image/png", ".png"), "PNG": ("image/png", ".png"),
@@ -48,6 +53,10 @@ class OpenAISynthIDDetection:
detector: str = DETECTOR_ID detector: str = DETECTOR_ID
ai_metadata_stripped: bool = True ai_metadata_stripped: bool = True
pixels_preserved: bool = True pixels_preserved: bool = True
signal_family: str = "synthid"
provider_scope: str = "openai"
backend: str = "official-openai-api"
metadata_used_for_verdict: bool = False
@property @property
def detected(self) -> bool: def detected(self) -> bool:
@@ -64,9 +73,34 @@ class OpenAISynthIDDetection:
"detector": self.detector, "detector": self.detector,
"ai_metadata_stripped": self.ai_metadata_stripped, "ai_metadata_stripped": self.ai_metadata_stripped,
"pixels_preserved": self.pixels_preserved, "pixels_preserved": self.pixels_preserved,
"signal_family": self.signal_family,
"provider_scope": self.provider_scope,
"backend": self.backend,
"metadata_used_for_verdict": self.metadata_used_for_verdict,
} }
class OpenAIProvenanceError(RuntimeError):
"""An official verification failure, with retry and support context."""
def __init__(
self,
message: str,
*,
status_code: int | None = None,
error_code: str | None = None,
request_id: str | None = None,
retry_after: str | None = None,
retryable: bool = False,
) -> None:
super().__init__(message)
self.status_code = status_code
self.error_code = error_code
self.request_id = request_id
self.retry_after = retry_after
self.retryable = retryable
def is_available() -> bool: def is_available() -> bool:
"""True when the optional OpenAI SDK is installed.""" """True when the optional OpenAI SDK is installed."""
from remove_ai_watermarks.optional_deps import module_available from remove_ai_watermarks.optional_deps import module_available
@@ -116,16 +150,22 @@ def _optional_string(entry: Mapping[str, Any], field: str) -> str | None:
def _parse_synthid_result(payload: Mapping[str, Any]) -> OpenAISynthIDDetection: def _parse_synthid_result(payload: Mapping[str, Any]) -> OpenAISynthIDDetection:
"""Read exactly one SynthID entry and deliberately ignore C2PA entries.""" """Read exactly one SynthID entry and deliberately ignore C2PA entries."""
if payload.get("object") != "content_provenance_check":
raise RuntimeError("OpenAI Content Provenance response has an unexpected 'object' field")
raw_results = payload.get("results") raw_results = payload.get("results")
if not isinstance(raw_results, list): if not isinstance(raw_results, list):
raise RuntimeError("OpenAI Content Provenance response has no results list") raise RuntimeError("OpenAI Content Provenance response has no results list")
results = cast("list[Any]", raw_results) results = cast("list[Any]", raw_results)
synthid_entries: list[Mapping[str, Any]] = [] synthid_entries: list[Mapping[str, Any]] = []
for raw_entry in results: for raw_entry in results:
if isinstance(raw_entry, Mapping): if not isinstance(raw_entry, Mapping):
entry = cast("Mapping[str, Any]", raw_entry) raise RuntimeError("OpenAI Content Provenance response has an invalid result entry")
if entry.get("type") == "synthid": entry = cast("Mapping[str, Any]", raw_entry)
synthid_entries.append(entry) result_type = entry.get("type")
if not isinstance(result_type, str):
raise RuntimeError("OpenAI Content Provenance response has a result without a valid type")
if result_type == "synthid":
synthid_entries.append(entry)
if len(synthid_entries) != 1: if len(synthid_entries) != 1:
raise RuntimeError(f"OpenAI Content Provenance returned {len(synthid_entries)} SynthID results; expected one") raise RuntimeError(f"OpenAI Content Provenance returned {len(synthid_entries)} SynthID results; expected one")
@@ -148,9 +188,12 @@ def _default_client() -> Any:
if not is_available(): if not is_available():
raise RuntimeError(f"OpenAI SynthID verification needs the OpenAI SDK; {INSTALL_HINT}") raise RuntimeError(f"OpenAI SynthID verification needs the OpenAI SDK; {INSTALL_HINT}")
openai_module = importlib.import_module("openai") openai_module = importlib.import_module("openai")
client_factory = cast("Callable[[], Any]", openai_module.OpenAI) client_factory = cast("Callable[..., Any]", openai_module.OpenAI)
try: try:
client = client_factory() client = client_factory(
timeout=REQUEST_TIMEOUT_SECONDS,
max_retries=MAX_AUTOMATIC_RETRIES,
)
except Exception as exc: except Exception as exc:
raise RuntimeError(f"could not initialize the OpenAI client: {exc}") from exc raise RuntimeError(f"could not initialize the OpenAI client: {exc}") from exc
if not hasattr(client, "content_provenance_checks"): if not hasattr(client, "content_provenance_checks"):
@@ -158,17 +201,74 @@ def _default_client() -> Any:
return client return client
def _request_error(exc: Exception) -> RuntimeError: def _string_attribute(value: Any) -> str | None:
status_code = getattr(exc, "status_code", None) return value if isinstance(value, str) and value else None
if status_code == 400:
def _response_header(exc: Exception, name: str) -> str | None:
response = getattr(exc, "response", None)
headers = getattr(response, "headers", None)
if headers is None:
return None
try:
return _string_attribute(headers.get(name))
except (AttributeError, TypeError):
return None
def _request_error(exc: Exception) -> OpenAIProvenanceError:
raw_status_code = getattr(exc, "status_code", None)
status_code = (
raw_status_code if isinstance(raw_status_code, int) and not isinstance(raw_status_code, bool) else None
)
error_code = _string_attribute(getattr(exc, "code", None))
if error_code is None:
body = getattr(exc, "body", None)
# The SDK types the exception body as Any, so narrowing it leaves a
# Mapping with unknown parameters. The same cast the module already uses
# for response payloads keeps the gate clean here.
if isinstance(body, Mapping):
error_code = _string_attribute(cast("Mapping[str, Any]", body).get("code"))
request_id = _string_attribute(getattr(exc, "request_id", None)) or _response_header(exc, "x-request-id")
retry_after = _response_header(exc, "retry-after")
error_name = type(exc).__name__
if error_name == "APITimeoutError":
detail = "the OpenAI Content Provenance request timed out"
elif error_name == "APIConnectionError":
detail = "the OpenAI Content Provenance API could not be reached"
elif status_code == 400:
detail = "OpenAI rejected the image as malformed, unsupported, or blocked" detail = "OpenAI rejected the image as malformed, unsupported, or blocked"
elif status_code == 401:
detail = "OpenAI Content Provenance authentication failed"
elif status_code == 403:
detail = "the OpenAI project is not permitted to use Content Provenance"
elif status_code == 404: elif status_code == 404:
detail = "the OpenAI organization does not have Content Provenance API access" detail = "the OpenAI organization does not have Content Provenance API access"
elif status_code == 429: elif status_code == 429:
detail = "the OpenAI Content Provenance API rate limit was exceeded" detail = "the OpenAI Content Provenance API rate limit was exceeded"
elif status_code is not None and status_code >= 500:
detail = "the OpenAI Content Provenance API returned a temporary server error"
else: else:
detail = f"OpenAI Content Provenance request failed: {exc}" detail = f"OpenAI Content Provenance request failed: {exc}"
return RuntimeError(detail) if retry_after is not None:
detail += f"; Retry-After: {retry_after}"
if error_code is not None:
detail += f"; error code: {error_code}"
if request_id is not None:
detail += f"; request id: {request_id}"
retryable = (
error_name in ("APITimeoutError", "APIConnectionError")
or status_code == 429
or (status_code is not None and status_code >= 500)
)
return OpenAIProvenanceError(
detail,
status_code=status_code,
error_code=error_code,
request_id=request_id,
retry_after=retry_after,
retryable=retryable,
)
def verify_openai_synthid( def verify_openai_synthid(
@@ -215,18 +315,37 @@ def verify_openai_synthid(
"filename": sanitized.name, "filename": sanitized.name,
"media_type": media_type, "media_type": media_type,
"bytes": upload_bytes, "bytes": upload_bytes,
"pixel_sha256": source_fingerprint, "automatic_retries": MAX_AUTOMATIC_RETRIES,
"timeout_seconds": REQUEST_TIMEOUT_SECONDS,
} }
log.info("OpenAI Content Provenance request: %s", json.dumps(request_context, sort_keys=True)) log.info("OpenAI Content Provenance request: %s", json.dumps(request_context, sort_keys=True))
started_at = time.monotonic()
try: try:
with stripped.open("rb") as upload: with stripped.open("rb") as upload:
response = api_client.content_provenance_checks.create( response = api_client.content_provenance_checks.create(
file=(sanitized.name, upload, media_type), file=(sanitized.name, upload, media_type),
timeout=REQUEST_TIMEOUT_SECONDS,
) )
except Exception as exc: except Exception as exc:
log.exception("OpenAI Content Provenance request failed: %s", json.dumps(request_context, sort_keys=True)) failure_context = {
**request_context,
"duration_ms": round((time.monotonic() - started_at) * 1000),
"error_code": _string_attribute(getattr(exc, "code", None)),
"request_id": _string_attribute(getattr(exc, "request_id", None))
or _response_header(exc, "x-request-id"),
"status_code": getattr(exc, "status_code", None),
}
log.exception(
"OpenAI Content Provenance request failed: %s",
json.dumps(failure_context, default=str, sort_keys=True),
)
raise _request_error(exc) from exc raise _request_error(exc) from exc
payload = _response_mapping(response) payload = _response_mapping(response)
log.info("OpenAI Content Provenance response: %s", json.dumps(payload, default=str, sort_keys=True)) response_context = {
"duration_ms": round((time.monotonic() - started_at) * 1000),
"payload": payload,
"request_id": _string_attribute(getattr(response, "_request_id", None)),
}
log.info("OpenAI Content Provenance response: %s", json.dumps(response_context, default=str, sort_keys=True))
return _parse_synthid_result(payload) return _parse_synthid_result(payload)
+110 -18
View File
@@ -2,8 +2,8 @@
This is a positive-only detector for one measured carrier epoch, not Google's This is a positive-only detector for one measured carrier epoch, not Google's
private payload decoder. A positive result is strong local evidence for the private payload decoder. A positive result is strong local evidence for the
carrier. A negative result means only that the selected detector did not find carrier. An indeterminate result means only that the selected detector did not
it; image sizes outside that mode's calibrated range are reported separately. find it; image sizes outside that mode's calibrated range are reported separately.
The numeric runtime requires the ``pixels`` extra. Imports remain lazy so the The numeric runtime requires the ``pixels`` extra. Imports remain lazy so the
package's metadata-only paths stay dependency-light. package's metadata-only paths stay dependency-light.
@@ -22,10 +22,12 @@ from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING: if TYPE_CHECKING:
from numpy.typing import NDArray from numpy.typing import NDArray
SynthIDDetectionStatus = Literal["detected", "not_detected", "unsupported"] SynthIDDetectionStatus = Literal["detected", "indeterminate", "unsupported"]
DETECTOR_ID = "synthid-periodic-tile-v2" DETECTOR_ID = "synthid-periodic-tile-v2"
REGISTERED_DETECTOR_ID = "synthid-periodic-tile-registered-v2" REGISTERED_DETECTOR_ID = "synthid-periodic-tile-registered-v3"
OPPONENT_REGISTERED_DETECTOR_ID = "synthid-periodic-tile-opponent-registered-v1"
FINE_OPPONENT_REGISTERED_DETECTOR_ID = "synthid-periodic-tile-opponent-fine-registered-v1"
LARGE_DETECTOR_ID = "synthid-periodic-tile-large-v1" LARGE_DETECTOR_ID = "synthid-periodic-tile-large-v1"
MODEL_FILENAME = "synthid_periodic_tile_2048_v1.npz" MODEL_FILENAME = "synthid_periodic_tile_2048_v1.npz"
# The template remains frozen at this model geometry. Runtime images are never # The template remains frozen at this model geometry. Runtime images are never
@@ -39,10 +41,25 @@ MAX_SUPPORTED_PIXELS = 18_000_000
TILE_THRESHOLD = 0.17357069773071196 TILE_THRESHOLD = 0.17357069773071196
REGISTERED_MIN_SUPPORTED_PIXELS = 250_000 REGISTERED_MIN_SUPPORTED_PIXELS = 250_000
REGISTERED_MAX_SUPPORTED_PIXELS = 10_000_000 REGISTERED_MAX_SUPPORTED_PIXELS = 10_000_000
REGISTERED_MIN_SIDE = 64 # Registered-v3 can confirm a positive only when both disjoint checkerboard
# The registered score is the minimum normalized margin across its amplitude, # groups contain a complete frozen 256-pixel patch. Narrower geometries need a
# spectral-candidate, and high-frequency agreement gates. # separately calibrated adaptive-patch expert and must not masquerade as misses.
REGISTERED_MIN_SIDE = 256
# The registered score preserves the minimum normalized v2 margin only after
# independent split-patch phase, amplitude, and held-out codeword confirmation.
REGISTERED_THRESHOLD = 1.0 REGISTERED_THRESHOLD = 1.0
# The opponent-color fallback is a narrower precision-first route for lossless
# scale changes. Smaller rasters retained a natural period-10 false positive.
OPPONENT_REGISTERED_THRESHOLD = 1.0
OPPONENT_REGISTERED_MIN_PIXELS = 1_000_000
OPPONENT_REGISTERED_MIN_SIDE = 768
# Fine-period registration is separately frozen for the dense 0.47-0.55
# lossless-resize challenge. Its more expensive selector is bounded to the
# geometry range covered by the locked and reserve negative sets.
FINE_OPPONENT_REGISTERED_THRESHOLD = 1.05
FINE_OPPONENT_REGISTERED_MIN_PIXELS = 1_000_000
FINE_OPPONENT_REGISTERED_MAX_PIXELS = 5_000_000
FINE_OPPONENT_REGISTERED_MIN_SIDE = 768
# The large-image score combines all-window fixed and spatial opponent gates # The large-image score combines all-window fixed and spatial opponent gates
# with an any-window signed opponent mid-band gate. The one vulnerable portrait # with an any-window signed opponent mid-band gate. The one vulnerable portrait
# geometry has an additional Green mid-band upper gate. # geometry has an additional Green mid-band upper gate.
@@ -62,7 +79,15 @@ INSTALL_HINT = "install the pixel extra: uv add 'remove-ai-watermarks[pixels]'"
@dataclass(frozen=True) @dataclass(frozen=True)
class SynthIDDetection: class SynthIDDetection:
"""One local periodic-carrier verdict.""" """One local periodic-lattice verdict.
The family this reports is NOT the watermark, and the field names say so. The
statistic is destroyed by a crop of seven pixels, while SynthID's published
evaluation retains 99.97% TPR under aggressive crop and resize, so what
crosses the threshold is a generation-pipeline lattice anchored at the image
origin. It identifies the pipeline, not the mark, and `docs/synthid.md`
carries the measurement.
"""
status: SynthIDDetectionStatus status: SynthIDDetectionStatus
width: int width: int
@@ -70,13 +95,23 @@ class SynthIDDetection:
score: float | None score: float | None
threshold: float threshold: float
detector: str = DETECTOR_ID detector: str = DETECTOR_ID
reason: str | None = None
signal_family: str = "generation-pipeline-lattice"
provider_scope: str = "provider-neutral"
backend: str = "local-pixel"
metadata_used_for_verdict: bool = False
pixels_preserved: bool = True
# Consumers cannot be expected to read a caveat in prose, so the two measured
# failure modes travel with every verdict.
tile_aligned_crop_required: bool = True
identifies_watermark: bool = False
@property @property
def detected(self) -> bool: def detected(self) -> bool:
"""Whether the supported carrier crossed its frozen threshold.""" """Whether the supported carrier crossed its frozen threshold."""
return self.status == "detected" return self.status == "detected"
def to_dict(self) -> dict[str, str | int | float | None]: def to_dict(self) -> dict[str, str | int | float | bool | None]:
"""Return a JSON-safe result without a local file path.""" """Return a JSON-safe result without a local file path."""
return { return {
"status": self.status, "status": self.status,
@@ -85,6 +120,14 @@ class SynthIDDetection:
"score": self.score, "score": self.score,
"threshold": self.threshold, "threshold": self.threshold,
"detector": self.detector, "detector": self.detector,
"reason": self.reason,
"signal_family": self.signal_family,
"provider_scope": self.provider_scope,
"backend": self.backend,
"metadata_used_for_verdict": self.metadata_used_for_verdict,
"pixels_preserved": self.pixels_preserved,
"tile_aligned_crop_required": self.tile_aligned_crop_required,
"identifies_watermark": self.identifies_watermark,
} }
@@ -269,6 +312,24 @@ def _large_geometry_supported(width: int, height: int) -> bool:
return min(width, height) >= LARGE_WINDOW and LARGE_MIN_PIXELS < pixels <= LARGE_MAX_PIXELS return min(width, height) >= LARGE_WINDOW and LARGE_MIN_PIXELS < pixels <= LARGE_MAX_PIXELS
def _opponent_registered_geometry_supported(width: int, height: int) -> bool:
"""Whether the opponent-color fallback passed its frozen geometry challenge."""
pixels = width * height
return (
min(width, height) >= OPPONENT_REGISTERED_MIN_SIDE
and OPPONENT_REGISTERED_MIN_PIXELS <= pixels <= REGISTERED_MAX_SUPPORTED_PIXELS
)
def _fine_opponent_registered_geometry_supported(width: int, height: int) -> bool:
"""Whether the fine-period selector passed its frozen geometry challenge."""
pixels = width * height
return (
min(width, height) >= FINE_OPPONENT_REGISTERED_MIN_SIDE
and FINE_OPPONENT_REGISTERED_MIN_PIXELS <= pixels <= FINE_OPPONENT_REGISTERED_MAX_PIXELS
)
def folded_template_score( def folded_template_score(
pixels: NDArray[Any], pixels: NDArray[Any],
template: NDArray[Any], template: NDArray[Any],
@@ -382,13 +443,16 @@ def detect_synthid(
image_path: str | Path, image_path: str | Path,
*, *,
image: NDArray[Any] | None = None, image: NDArray[Any] | None = None,
register_scale: bool = False, register_scale: bool | None = None,
) -> SynthIDDetection: ) -> SynthIDDetection:
"""Detect the supported periodic carrier in IMAGE_PATH. """Detect the supported periodic carrier in IMAGE_PATH.
``not_detected`` is not a clean-image guarantee. It means only that the ``indeterminate`` means that the frozen periodic carrier did not cross its
frozen periodic carrier did not cross its calibrated threshold. Set calibrated threshold; it is not a clean-image guarantee. The default
``register_scale`` for the slower, separately calibrated resize search. production router uses scale registration through 10 megapixels and the
native large-image expert above that boundary. Set ``register_scale`` to
``True`` to force registration or ``False`` to run the legacy fixed-period
diagnostic below the large-image boundary.
""" """
path = Path(image_path) path = Path(image_path)
if image is None: if image is None:
@@ -397,19 +461,28 @@ def detect_synthid(
if image.ndim != 3 or image.shape[2] != 3: if image.ndim != 3 or image.shape[2] != 3:
raise ValueError("image must be a three-channel BGR array") raise ValueError("image must be a three-channel BGR array")
height, width = image.shape[:2] height, width = image.shape[:2]
large_mode = not register_scale and width * height > LARGE_MIN_PIXELS large_mode = register_scale is not True and width * height > LARGE_MIN_PIXELS
if register_scale: registered_mode = register_scale is True or (register_scale is None and not large_mode)
if registered_mode:
geometry_supported = _registered_geometry_supported(width, height) geometry_supported = _registered_geometry_supported(width, height)
threshold = REGISTERED_THRESHOLD threshold = REGISTERED_THRESHOLD
detector_id = REGISTERED_DETECTOR_ID detector_id = REGISTERED_DETECTOR_ID
unsupported_reason = (
"registered-v3 requires 250,000-10,000,000 decoded pixels and both dimensions to be at least 256 pixels"
)
elif large_mode: elif large_mode:
geometry_supported = _large_geometry_supported(width, height) geometry_supported = _large_geometry_supported(width, height)
threshold = LARGE_THRESHOLD threshold = LARGE_THRESHOLD
detector_id = LARGE_DETECTOR_ID detector_id = LARGE_DETECTOR_ID
unsupported_reason = (
"large-v1 requires more than 10,000,000 through 18,000,000 decoded pixels "
"and at least two phase-aligned 2048-pixel windows"
)
else: else:
geometry_supported = _geometry_supported(width, height) geometry_supported = _geometry_supported(width, height)
threshold = TILE_THRESHOLD threshold = TILE_THRESHOLD
detector_id = DETECTOR_ID detector_id = DETECTOR_ID
unsupported_reason = "fixed-v2 requires 1,000,000-18,000,000 decoded pixels"
if not geometry_supported: if not geometry_supported:
return SynthIDDetection( return SynthIDDetection(
status="unsupported", status="unsupported",
@@ -418,6 +491,7 @@ def detect_synthid(
score=None, score=None,
threshold=threshold, threshold=threshold,
detector=detector_id, detector=detector_id,
reason=unsupported_reason,
) )
if not is_available(): if not is_available():
raise RuntimeError(f"SynthID pixel detection needs numpy and OpenCV; {INSTALL_HINT}") raise RuntimeError(f"SynthID pixel detection needs numpy and OpenCV; {INSTALL_HINT}")
@@ -433,19 +507,37 @@ def detect_synthid(
pixels = np.asarray(image[:, :, ::-1], dtype=np.uint8) pixels = np.asarray(image[:, :, ::-1], dtype=np.uint8)
if pixels.shape != (height, width, 3): if pixels.shape != (height, width, 3):
raise RuntimeError("decoded image geometry does not match its header") raise RuntimeError("decoded image geometry does not match its header")
if register_scale: if registered_mode:
from remove_ai_watermarks._synthid_registered import registered_score from remove_ai_watermarks._synthid_registered import (
fine_opponent_registered_score,
opponent_registered_score,
registered_score,
)
score = registered_score(pixels, template, sigma) score = registered_score(pixels, template, sigma)
if score < REGISTERED_THRESHOLD and _opponent_registered_geometry_supported(width, height):
opponent_score = opponent_registered_score(pixels, template, sigma)
if opponent_score >= OPPONENT_REGISTERED_THRESHOLD:
score = opponent_score
threshold = OPPONENT_REGISTERED_THRESHOLD
detector_id = OPPONENT_REGISTERED_DETECTOR_ID
if score < threshold and _fine_opponent_registered_geometry_supported(width, height):
fine_score = fine_opponent_registered_score(pixels, template, sigma)
if fine_score >= FINE_OPPONENT_REGISTERED_THRESHOLD:
score = fine_score
threshold = FINE_OPPONENT_REGISTERED_THRESHOLD
detector_id = FINE_OPPONENT_REGISTERED_DETECTOR_ID
elif large_mode: elif large_mode:
score = large_image_components(pixels, template, sigma).decision_score score = large_image_components(pixels, template, sigma).decision_score
else: else:
score, _folded = folded_template_score(pixels, template, sigma) score, _folded = folded_template_score(pixels, template, sigma)
detected = score >= threshold
return SynthIDDetection( return SynthIDDetection(
status="detected" if score >= threshold else "not_detected", status="detected" if detected else "indeterminate",
width=width, width=width,
height=height, height=height,
score=score, score=score,
threshold=threshold, threshold=threshold,
detector=detector_id, detector=detector_id,
reason=None if detected else "the selected carrier expert did not cross every calibrated gate",
) )
+1
View File
@@ -25,6 +25,7 @@ class TestTopLevelExports:
assert raiw.detect_synthid is synthid_detector.detect_synthid assert raiw.detect_synthid is synthid_detector.detect_synthid
assert raiw.SynthIDDetection is synthid_detector.SynthIDDetection assert raiw.SynthIDDetection is synthid_detector.SynthIDDetection
assert raiw.verify_openai_synthid is openai_provenance.verify_openai_synthid assert raiw.verify_openai_synthid is openai_provenance.verify_openai_synthid
assert raiw.OpenAIProvenanceError is openai_provenance.OpenAIProvenanceError
assert raiw.OpenAISynthIDDetection is openai_provenance.OpenAISynthIDDetection assert raiw.OpenAISynthIDDetection is openai_provenance.OpenAISynthIDDetection
def test_unknown_attribute_raises(self): def test_unknown_attribute_raises(self):
+2 -1
View File
@@ -742,6 +742,7 @@ class TestDetectSynthIDCommand:
assert result.exit_code == 0 assert result.exit_code == 0
assert "calibrated image sizes" in result.output assert "calibrated image sizes" in result.output
assert "--register-scale" in result.output assert "--register-scale" in result.output
assert "--fixed-period" in result.output
def test_unsupported_geometry_is_machine_readable(self, runner, tmp_clean_png): def test_unsupported_geometry_is_machine_readable(self, runner, tmp_clean_png):
result = runner.invoke(main, ["detect-synthid", str(tmp_clean_png), "--json"]) result = runner.invoke(main, ["detect-synthid", str(tmp_clean_png), "--json"])
@@ -782,7 +783,7 @@ class TestDetectSynthIDCommand:
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert "Bounded spatial-scale registration was enabled" in result.output assert "Bounded spatial-scale registration was explicitly enabled" in result.output
class TestVerifyOpenAISynthIDCommand: class TestVerifyOpenAISynthIDCommand:
+17 -9
View File
@@ -886,30 +886,38 @@ class TestIdentifyVisibleTextMarks:
# ── Caveats and serialization ─────────────────────────────────────── # ── Caveats and serialization ───────────────────────────────────────
class TestSynthIDPixelCarrier: class TestGenerationPipelineLattice:
def test_positive_pixel_carrier_is_high_confidence_ai_evidence(self, tmp_clean_png: Path): def test_positive_lattice_is_ai_evidence_but_never_a_watermark(self, tmp_clean_png: Path):
"""The lattice may support an AI verdict; it may not enter the watermark list.
It accepts 24% of Adobe Firefly output and dies on a seven-pixel crop, so
reporting it beside C2PA watermark assertions would misrepresent both. The
watermark assertion is checked by absence, because that is the failure that
actually shipped.
"""
with ( with (
patch("remove_ai_watermarks.identify._invisible_watermark", return_value=None), patch("remove_ai_watermarks.identify._invisible_watermark", return_value=None),
patch("remove_ai_watermarks.identify._synthid_pixel_watermark", return_value=True), patch("remove_ai_watermarks.identify._pipeline_lattice", return_value=True),
patch("remove_ai_watermarks.identify._trustmark", return_value=None), patch("remove_ai_watermarks.identify._trustmark", return_value=None),
): ):
report = identify(tmp_clean_png, check_visible=False, check_invisible=True) report = identify(tmp_clean_png, check_visible=False, check_invisible=True)
assert report.is_ai_generated is True assert report.is_ai_generated is True
assert report.confidence == "high" assert any(signal.name == "pipeline_lattice" for signal in report.signals)
assert any(signal.name == "synthid_pixel" for signal in report.signals) assert not any("synthid" in watermark.lower() for watermark in report.watermarks)
assert any("positive-only" in caveat for caveat in report.caveats) assert not any("watermark" in watermark.lower() for watermark in report.watermarks)
assert any("not a watermark" in caveat for caveat in report.caveats)
def test_negative_pixel_carrier_does_not_claim_clean(self, tmp_clean_png: Path): def test_negative_lattice_does_not_claim_clean(self, tmp_clean_png: Path):
with ( with (
patch("remove_ai_watermarks.identify._invisible_watermark", return_value=None), patch("remove_ai_watermarks.identify._invisible_watermark", return_value=None),
patch("remove_ai_watermarks.identify._synthid_pixel_watermark", return_value=False), patch("remove_ai_watermarks.identify._pipeline_lattice", return_value=False),
patch("remove_ai_watermarks.identify._trustmark", return_value=None), patch("remove_ai_watermarks.identify._trustmark", return_value=None),
): ):
report = identify(tmp_clean_png, check_visible=False, check_invisible=True) report = identify(tmp_clean_png, check_visible=False, check_invisible=True)
assert report.is_ai_generated is None assert report.is_ai_generated is None
assert not any(signal.name == "synthid_pixel" for signal in report.signals) assert not any(signal.name == "pipeline_lattice" for signal in report.signals)
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/fixtures/provenance not present") @pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/fixtures/provenance not present")
+163 -6
View File
@@ -20,7 +20,8 @@ class _Checks:
self.response = response self.response = response
self.calls: list[tuple[str, bytes, str]] = [] self.calls: list[tuple[str, bytes, str]] = []
def create(self, *, file: tuple[str, Any, str]) -> Any: def create(self, *, file: tuple[str, Any, str], timeout: float) -> Any:
assert timeout == provenance.REQUEST_TIMEOUT_SECONDS
filename, stream, media_type = file filename, stream, media_type = file
self.calls.append((filename, stream.read(), media_type)) self.calls.append((filename, stream.read(), media_type))
return self.response return self.response
@@ -94,6 +95,8 @@ def test_detected_result_uses_only_synthid_fields(tmp_clean_png: Path) -> None:
assert result.generated_at == "2026-07-28T18:34:12Z" assert result.generated_at == "2026-07-28T18:34:12Z"
assert result.api_created_at == 1_778_000_000 assert result.api_created_at == 1_778_000_000
assert "c2pa" not in result.to_dict() assert "c2pa" not in result.to_dict()
assert result.to_dict()["metadata_used_for_verdict"] is False
assert result.to_dict()["provider_scope"] == "openai"
def test_sdk_model_response_is_normalized(tmp_clean_png: Path) -> None: def test_sdk_model_response_is_normalized(tmp_clean_png: Path) -> None:
@@ -109,6 +112,28 @@ def test_sdk_model_response_is_normalized(tmp_clean_png: Path) -> None:
assert result.status == "detected" assert result.status == "detected"
def test_unexpected_response_object_is_an_error(tmp_clean_png: Path) -> None:
response = _response(synthid="detected")
response["object"] = "future_response"
client, _checks = _client(response)
with pytest.raises(RuntimeError, match="unexpected 'object'"):
_verify(tmp_clean_png, client=client)
@pytest.mark.parametrize("entry", [None, {"outcome": "detected"}, {"type": 3, "outcome": "detected"}])
def test_malformed_result_entry_is_an_error(tmp_clean_png: Path, entry: Any) -> None:
client, _checks = _client(
{
"object": "content_provenance_check",
"results": [entry],
}
)
with pytest.raises(RuntimeError, match=r"invalid result entry|valid type"):
_verify(tmp_clean_png, client=client)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("image_format", "suffix", "media_type"), ("image_format", "suffix", "media_type"),
[("PNG", ".png", "image/png"), ("JPEG", ".jpg", "image/jpeg"), ("WEBP", ".webp", "image/webp")], [("PNG", ".png", "image/png"), ("JPEG", ".jpg", "image/jpeg"), ("WEBP", ".webp", "image/webp")],
@@ -138,7 +163,7 @@ def test_all_documented_image_formats_preserve_decoded_pixels(
@pytest.mark.parametrize("results", [[], [{"type": "c2pa", "outcome": "detected"}]]) @pytest.mark.parametrize("results", [[], [{"type": "c2pa", "outcome": "detected"}]])
def test_missing_synthid_result_is_an_error(tmp_clean_png: Path, results: list[dict[str, str]]) -> None: def test_missing_synthid_result_is_an_error(tmp_clean_png: Path, results: list[dict[str, str]]) -> None:
client, _checks = _client({"results": results}) client, _checks = _client({"object": "content_provenance_check", "results": results})
with pytest.raises(RuntimeError, match="0 SynthID results"): with pytest.raises(RuntimeError, match="0 SynthID results"):
_verify(tmp_clean_png, client=client) _verify(tmp_clean_png, client=client)
@@ -147,10 +172,11 @@ def test_missing_synthid_result_is_an_error(tmp_clean_png: Path, results: list[d
def test_duplicate_synthid_results_are_an_error(tmp_clean_png: Path) -> None: def test_duplicate_synthid_results_are_an_error(tmp_clean_png: Path) -> None:
client, _checks = _client( client, _checks = _client(
{ {
"object": "content_provenance_check",
"results": [ "results": [
{"type": "synthid", "outcome": "detected"}, {"type": "synthid", "outcome": "detected"},
{"type": "synthid", "outcome": "not_detected"}, {"type": "synthid", "outcome": "not_detected"},
] ],
} }
) )
@@ -223,6 +249,28 @@ def test_upload_limit_is_checked_after_sanitizing(
assert checks.calls == [] assert checks.calls == []
def test_upload_limit_allows_exact_boundary(
monkeypatch: pytest.MonkeyPatch,
tmp_clean_png: Path,
) -> None:
from remove_ai_watermarks import metadata
client, checks = _client(_response(synthid="not_detected"))
def copy_clean(source: Path, output: Path, *, keep_standard: bool) -> tuple[Path, dict[str, str]]:
assert keep_standard is True
output.write_bytes(source.read_bytes())
return output, {}
monkeypatch.setattr(metadata, "strip_and_verify", copy_clean)
monkeypatch.setattr(provenance, "MAX_UPLOAD_BYTES", tmp_clean_png.stat().st_size)
result = _verify(tmp_clean_png, client=client)
assert result.status == "not_detected"
assert len(checks.calls) == 1
def test_missing_optional_sdk_has_install_hint( def test_missing_optional_sdk_has_install_hint(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_clean_png: Path, tmp_clean_png: Path,
@@ -237,7 +285,7 @@ def test_client_configuration_error_is_actionable(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_clean_png: Path, tmp_clean_png: Path,
) -> None: ) -> None:
def fail() -> None: def fail(**_kwargs: Any) -> None:
raise ValueError("OPENAI_API_KEY is missing") raise ValueError("OPENAI_API_KEY is missing")
monkeypatch.setattr(provenance, "is_available", lambda: True) monkeypatch.setattr(provenance, "is_available", lambda: True)
@@ -247,9 +295,36 @@ def test_client_configuration_error_is_actionable(
_verify(tmp_clean_png) _verify(tmp_clean_png)
def test_default_client_bounds_one_acknowledged_upload(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[dict[str, Any]] = []
expected = SimpleNamespace(content_provenance_checks=object())
def factory(**kwargs: Any) -> Any:
calls.append(kwargs)
return expected
monkeypatch.setattr(provenance, "is_available", lambda: True)
monkeypatch.setattr(provenance.importlib, "import_module", lambda _name: SimpleNamespace(OpenAI=factory))
assert provenance._default_client() is expected
assert calls == [
{
"timeout": provenance.REQUEST_TIMEOUT_SECONDS,
"max_retries": 0,
}
]
@pytest.mark.parametrize( @pytest.mark.parametrize(
("status_code", "message"), ("status_code", "message"),
[(400, "rejected"), (404, "does not have"), (429, "rate limit")], [
(400, "rejected"),
(401, "authentication failed"),
(403, "not permitted"),
(404, "does not have"),
(429, "rate limit"),
(500, "temporary server error"),
],
) )
def test_documented_api_errors_are_actionable( def test_documented_api_errors_are_actionable(
tmp_clean_png: Path, tmp_clean_png: Path,
@@ -263,10 +338,92 @@ def test_documented_api_errors_are_actionable(
error.status_code = status_code # type: ignore[attr-defined] error.status_code = status_code # type: ignore[attr-defined]
class FailingChecks: class FailingChecks:
def create(self, *, file: tuple[str, Any, str]) -> None: def create(self, *, file: tuple[str, Any, str], timeout: float) -> None:
assert timeout == provenance.REQUEST_TIMEOUT_SECONDS
raise error raise error
client = SimpleNamespace(content_provenance_checks=FailingChecks()) client = SimpleNamespace(content_provenance_checks=FailingChecks())
with pytest.raises(RuntimeError, match=message): with pytest.raises(RuntimeError, match=message):
_verify(tmp_clean_png, client=client) _verify(tmp_clean_png, client=client)
@pytest.mark.parametrize(
("error_name", "message"),
[("APITimeoutError", "timed out"), ("APIConnectionError", "could not be reached")],
)
def test_transport_errors_are_actionable(
tmp_clean_png: Path,
error_name: str,
message: str,
) -> None:
error_type = type(error_name, (Exception,), {})
class FailingChecks:
def create(self, *, file: tuple[str, Any, str], timeout: float) -> None:
assert timeout == provenance.REQUEST_TIMEOUT_SECONDS
raise error_type("details")
client = SimpleNamespace(content_provenance_checks=FailingChecks())
with pytest.raises(RuntimeError, match=message):
_verify(tmp_clean_png, client=client)
def test_rate_limit_error_preserves_retry_context(tmp_clean_png: Path) -> None:
class RateLimitError(Exception):
status_code = 429
code = "rate_limit_exceeded"
request_id = "req_test"
response = SimpleNamespace(headers={"retry-after": "7"})
class FailingChecks:
def create(self, *, file: tuple[str, Any, str], timeout: float) -> None:
assert timeout == provenance.REQUEST_TIMEOUT_SECONDS
raise RateLimitError("details")
client = SimpleNamespace(content_provenance_checks=FailingChecks())
with pytest.raises(provenance.OpenAIProvenanceError, match="Retry-After: 7") as raised:
_verify(tmp_clean_png, client=client)
assert raised.value.status_code == 429
assert raised.value.error_code == "rate_limit_exceeded"
assert raised.value.request_id == "req_test"
assert raised.value.retry_after == "7"
assert raised.value.retryable is True
def test_client_error_is_not_marked_retryable(tmp_clean_png: Path) -> None:
class BadRequestError(Exception):
status_code = 400
code = "invalid_image"
class FailingChecks:
def create(self, *, file: tuple[str, Any, str], timeout: float) -> None:
assert timeout == provenance.REQUEST_TIMEOUT_SECONDS
raise BadRequestError("details")
client = SimpleNamespace(content_provenance_checks=FailingChecks())
with pytest.raises(provenance.OpenAIProvenanceError) as raised:
_verify(tmp_clean_png, client=client)
assert raised.value.status_code == 400
assert raised.value.error_code == "invalid_image"
assert raised.value.retryable is False
def test_keyboard_interrupt_is_not_wrapped_or_retried(tmp_clean_png: Path) -> None:
class InterruptingChecks:
calls = 0
def create(self, *, file: tuple[str, Any, str], timeout: float) -> None:
assert timeout == provenance.REQUEST_TIMEOUT_SECONDS
self.calls += 1
raise KeyboardInterrupt
checks = InterruptingChecks()
client = SimpleNamespace(content_provenance_checks=checks)
with pytest.raises(KeyboardInterrupt):
_verify(tmp_clean_png, client=client)
assert checks.calls == 1
+399
View File
@@ -0,0 +1,399 @@
from __future__ import annotations
import json
import sys
from dataclasses import replace
from pathlib import Path
import cv2
import numpy as np
import pytest
from click.testing import CliRunner
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import synthid_affine_lattice_probe as probe
from remove_ai_watermarks._synthid_confirmation import RegisteredConfirmationComponents
def test_webp_lossless_round_trip_preserves_pixels() -> None:
rng = np.random.default_rng(20260817)
pixels = rng.integers(0, 256, (64, 64, 3), dtype=np.uint8)
restored = probe._webp_round_trip(pixels, 101)
assert np.array_equal(restored, pixels)
@pytest.fixture(scope="module")
def periodic_fixture() -> tuple[np.ndarray, np.ndarray]:
rng = np.random.default_rng(20260814)
template = rng.normal(0.0, 1.0, (16, 16, 3))
template -= np.mean(template, axis=(0, 1), keepdims=True)
template /= np.linalg.norm(template)
coarse = rng.normal(0.0, 8.0, (16, 16, 3)).astype(np.float32)
background = cv2.resize(coarse, (1024, 1024), interpolation=cv2.INTER_CUBIC) + 128.0
carrier = np.tile(template, (64, 64, 1)) * 3.0
pixels = np.clip(np.rint(background + carrier), 0, 255).astype(np.uint8)
return pixels, template
def _score(pixels: np.ndarray, template: np.ndarray) -> probe.LatticeScore:
return probe.score_lattice(
pixels,
template,
periods=np.arange(12.0, 20.01, 0.25),
rotations_degrees=np.asarray([-1.0, 0.0, 1.0]),
patch_size=256,
grid_size=4,
harmonic_count=12,
)
def test_period_alias_candidates_include_base_and_half_period_neighbors() -> None:
periods = np.arange(7.5, 24.501, 0.1)
rotations = np.zeros_like(periods)
base_index = int(np.argmin(np.abs(periods - 19.2)))
candidates = probe._period_alias_candidate_indices(periods, rotations, [base_index])
assert [periods[index] for index in candidates] == pytest.approx([19.1, 19.2, 19.3, 9.5, 9.6, 9.7])
def test_split_lattice_recovers_periodic_carrier(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
pixels, template = periodic_fixture
result = _score(pixels, template)
assert result.selected_period == pytest.approx(16.0, abs=0.25)
assert result.selected_rotation_degrees == 0.0
assert result.confirmation_coherence > 0.9
assert result.joint_coherence > 0.9
assert result.joint_codeword > 0.8
assert result.unknown_codeword_confirmation > 0.8
assert result.unknown_codeword_fixed_confirmation > 0.8
assert result.unknown_codeword_fixed_all > 0.8
assert result.unknown_codeword_excess_p99 > 0.0
assert result.joint_amplitude > 0.8
assert result.joint_whitened_match > 0.8
assert result.canonical_template_score > 0.8
assert result.canonical_registered_template_score > 0.8
assert result.confirmation_excess_p99 > 0.0
assert result.selection_patches == result.confirmation_patches == 8
def test_split_lattice_rejects_independent_noise(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
_pixels, template = periodic_fixture
rng = np.random.default_rng(20260815)
noise = rng.integers(0, 256, (1024, 1024, 3), dtype=np.uint8)
result = _score(noise, template)
assert result.confirmation_coherence < 0.8
assert result.joint_coherence < 0.8
assert result.joint_codeword < 0.8
assert result.unknown_codeword_confirmation < 0.2
assert result.unknown_codeword_fixed_confirmation < 0.2
assert result.unknown_codeword_fixed_all < 0.2
assert result.joint_amplitude < 0.2
assert result.joint_whitened_match < 0.2
assert result.canonical_template_score < 0.2
assert result.canonical_registered_template_score < 0.2
assert result.confirmation_excess_p99 < 0.0
def test_split_lattice_tracks_resized_period(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
pixels, template = periodic_fixture
resized = cv2.resize(pixels, (819, 819), interpolation=cv2.INTER_CUBIC)
result = probe.score_lattice(
resized,
template,
periods=np.arange(7.5, 24.501, 0.1),
rotations_degrees=np.asarray([0.0]),
patch_size=192,
grid_size=4,
harmonic_count=12,
)
assert result.selected_period == pytest.approx(12.8, abs=0.3)
assert result.confirmation_coherence > 0.8
assert result.joint_amplitude > 0.8
assert result.joint_whitened_match > 0.8
assert result.unknown_codeword_confirmation > 0.8
assert result.unknown_codeword_fixed_confirmation > 0.8
assert result.unknown_codeword_fixed_all > 0.8
def test_split_lattice_tracks_octave_aliased_resize(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
pixels, template = periodic_fixture
resized = cv2.resize(pixels, (614, 614), interpolation=cv2.INTER_AREA)
result = probe.score_lattice(
resized,
template,
periods=np.arange(7.5, 24.501, 0.1),
rotations_degrees=np.asarray([0.0]),
patch_size=192,
grid_size=4,
harmonic_count=12,
)
assert result.selected_period == pytest.approx(9.6, abs=0.15)
assert result.canonical_template_score > 0.4
def test_same_image_period_null_prefers_the_carrier_period(
periodic_fixture: tuple[np.ndarray, np.ndarray],
) -> None:
pixels, template = periodic_fixture
correct = probe.score_same_image_period_null(pixels, template, 16.0, harmonic_count=12)
off_period = probe.score_same_image_period_null(pixels, template, 15.0, harmonic_count=12)
assert correct.joint_excess > 0.2
assert correct.joint_excess > off_period.joint_excess
assert correct.off_period_count == len(probe.SAME_IMAGE_NULL_OFFSETS)
def test_patch_shift_consensus_confirms_global_carrier_phase(
periodic_fixture: tuple[np.ndarray, np.ndarray],
) -> None:
pixels, template = periodic_fixture
rng = np.random.default_rng(20260818)
noise = rng.integers(0, 256, pixels.shape, dtype=np.uint8)
carrier = probe.score_patch_shift_consensus(pixels, template, 16.0)
control = probe.score_patch_shift_consensus(noise, template, 16.0)
assert carrier.joint_trimmed_z > control.joint_trimmed_z
assert carrier.joint_support_fraction == 1.0
assert carrier.selection_patches == carrier.confirmation_patches == 8
def test_patch_shift_recovery_uses_frozen_mechanism_gates() -> None:
baseline = {
"amplitude_margin": 0.8,
"high_band_margin": 1.0,
"periods_agree": True,
"confirmation_passes": True,
"joint_trimmed_z": 2.5,
}
assert probe.patch_shift_recovery_passes(**baseline)
for field, failed_value in (
("amplitude_margin", 0.449),
("high_band_margin", 0.449),
("periods_agree", False),
("confirmation_passes", False),
("joint_trimmed_z", 2.499),
):
candidate = {**baseline, field: failed_value}
assert not probe.patch_shift_recovery_passes(**candidate)
assert not probe.patch_shift_recovery_passes(**{**baseline, "amplitude_margin": 0.99, "high_band_margin": 0.99})
def test_opponent_registration_recovers_resampled_carrier(
periodic_fixture: tuple[np.ndarray, np.ndarray],
) -> None:
pixels, template = periodic_fixture
resized = cv2.resize(pixels, (717, 717), interpolation=cv2.INTER_AREA)
rng = np.random.default_rng(20260819)
noise = rng.integers(0, 256, resized.shape, dtype=np.uint8)
periods = np.arange(10.0, 12.41, 0.05)
carrier = probe.score_opponent_registered(resized, template, periods=periods)
control = probe.score_opponent_registered(noise, template, periods=periods)
assert carrier.selected_period == pytest.approx(11.2, abs=0.1)
assert carrier.decision_score > 1.0
assert control.decision_score < 1.0
def test_split_lattice_aligns_cyclic_carrier_phase(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
pixels, template = periodic_fixture
shifted = np.roll(pixels, shift=(3, 5), axis=(0, 1))
result = _score(shifted, template)
assert (result.selected_shift_y, result.selected_shift_x) == (3, 5)
assert (result.amplitude_shift_y, result.amplitude_shift_x) == (13, 11)
assert result.canonical_template_score < 0.2
assert result.canonical_registered_template_score > 0.8
assert result.joint_whitened_match < 0.4
assert result.unknown_codeword_confirmation > 0.8
assert result.unknown_codeword_fixed_confirmation > 0.8
assert result.unknown_codeword_fixed_all > 0.8
def test_split_lattice_recovers_cropped_carrier_phase(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
pixels, template = periodic_fixture
cropped = pixels[37:, 53:]
result = probe.score_lattice(
cropped,
template,
periods=np.asarray([16.0]),
rotations_degrees=np.asarray([0.0]),
patch_size=256,
grid_size=4,
harmonic_count=12,
)
assert result.selected_period == pytest.approx(16.0, abs=0.25)
assert result.joint_coherence > 0.8
assert result.unknown_codeword_fixed_all > 0.8
assert result.canonical_template_score < 0.2
assert result.canonical_registered_template_score > 0.8
def test_orientation_bank_recovers_right_angle_rotation(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
pixels, template = periodic_fixture
rotated_clockwise = np.rot90(pixels, k=-1)
result = probe.score_orientation_bank(
rotated_clockwise,
template,
periods=np.asarray([16.0]),
rotations_degrees=np.asarray([0.0]),
patch_size=256,
grid_size=4,
harmonic_count=12,
)
assert result.selected_orientation_degrees == 90
assert result.joint_amplitude > 0.8
assert result.canonical_template_score > 0.8
def test_dihedral_bank_recovers_horizontal_reflection(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
pixels, template = periodic_fixture
result = probe.score_dihedral_bank(
np.fliplr(pixels),
template,
periods=np.asarray([16.0]),
rotations_degrees=np.asarray([0.0]),
patch_size=256,
grid_size=4,
harmonic_count=12,
)
assert result.selected_orientation_degrees == 0
assert result.selected_horizontal_reflection is True
assert result.joint_amplitude > 0.8
assert result.canonical_template_score > 0.8
def test_deskew_bank_recovers_small_rotation(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
pixels, template = periodic_fixture
rotated = probe._rotate_fixed_canvas(pixels, 1.5)
result = probe.score_deskew_bank(
rotated,
template,
periods=np.asarray([16.0]),
deskew_degrees=np.asarray([-2.0, -1.5, -1.0]),
patch_size=256,
grid_size=4,
harmonic_count=12,
)
assert result.selected_deskew_degrees == -1.5
assert result.joint_amplitude > 0.6
assert result.canonical_template_score > 0.6
assert result.deskew_direct_joint_match > 0.4
def test_registered_period_mode_uses_runtime_selected_period(
periodic_fixture: tuple[np.ndarray, np.ndarray],
monkeypatch: pytest.MonkeyPatch,
) -> None:
pixels, template = periodic_fixture
runner = CliRunner()
with runner.isolated_filesystem():
np.savez("template.npz", template=template)
Image.fromarray(pixels).save("image.png")
components = probe.RegisteredComponents(
raw_score=0.4,
amplitude_threshold=0.2,
selected_period=16.0,
spectral_period=16.0,
high_band_score=0.15,
confirmation=RegisteredConfirmationComponents(
period=16.0,
joint_coherence=0.5,
joint_amplitude=0.2,
unknown_codeword_fixed_confirmation=0.5,
selection_patches=8,
confirmation_patches=8,
),
)
monkeypatch.setattr(probe, "registered_components", lambda *_args: components)
result = runner.invoke(
probe.main,
[
"template.npz",
"image.png",
"--registered-period",
"--same-image-null",
"--patch-shift-consensus",
"--opponent-registered",
"--report-out",
"report.json",
],
)
assert result.exit_code == 0, result.output
report = json.loads(Path("report.json").read_text(encoding="utf-8"))
assert report["registered_period"] is True
assert report["same_image_null"] is True
assert report["patch_shift_consensus"] is True
assert report["opponent_registered"] is True
assert report["records"][0]["registered"]["selected_period"] == 16.0
assert report["records"][0]["registered"]["decision_score"] == 2.0
assert report["records"][0]["score"]["selected_period"] == 16.0
assert report["records"][0]["same_image_null"]["joint_excess"] > 0.2
assert report["records"][0]["patch_shift_consensus"]["joint_support_fraction"] == 1.0
assert report["records"][0]["opponent_registered"]["decision_score"] > 1.0
def test_registered_confirmation_uses_frozen_period_aware_gates(
periodic_fixture: tuple[np.ndarray, np.ndarray],
) -> None:
pixels, template = periodic_fixture
baseline = _score(pixels, template)
generic = replace(
baseline,
selected_period=16.0,
joint_coherence=0.30,
joint_amplitude=0.0,
)
assert probe.registered_confirmation_passes(generic)
assert not probe.registered_confirmation_passes(replace(generic, selected_period=9.99))
assert not probe.registered_confirmation_passes(replace(generic, joint_coherence=0.299))
assert not probe.registered_confirmation_passes(replace(generic, joint_amplitude=-0.001))
assert not probe.registered_confirmation_passes(
replace(generic, selected_period=18.28, unknown_codeword_fixed_confirmation=0.129)
)
assert probe.registered_confirmation_passes(
replace(generic, selected_period=18.28, unknown_codeword_fixed_confirmation=0.13)
)
assert not probe.registered_confirmation_passes(replace(generic, selected_period=19.14, joint_coherence=0.399))
assert probe.registered_confirmation_passes(replace(generic, selected_period=19.14, joint_coherence=0.40))
assert not probe.registered_confirmation_passes(
replace(generic, selected_period=21.31, unknown_codeword_fixed_confirmation=0.019)
)
assert probe.registered_confirmation_passes(
replace(generic, selected_period=21.31, unknown_codeword_fixed_confirmation=0.02)
)
def test_fixed_candidate_uses_frozen_precision_threshold() -> None:
assert not probe.fixed_candidate_passes(0.279999)
assert probe.fixed_candidate_passes(0.28)
assert not probe.fixed_candidate_passes(float("nan"))
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import sys
from dataclasses import replace
from pathlib import Path
import cv2
import numpy as np
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import synthid_affine_lattice_probe as research_probe
from remove_ai_watermarks._synthid_confirmation import (
RegisteredConfirmationComponents,
registered_confirmation_components,
)
@pytest.fixture(scope="module")
def periodic_fixture() -> tuple[np.ndarray, np.ndarray]:
rng = np.random.default_rng(20260818)
template = rng.normal(0.0, 1.0, (16, 16, 3))
template -= np.mean(template, axis=(0, 1), keepdims=True)
template /= np.linalg.norm(template)
coarse = rng.normal(0.0, 8.0, (16, 16, 3)).astype(np.float32)
background = cv2.resize(coarse, (1024, 1024), interpolation=cv2.INTER_CUBIC) + 128.0
carrier = np.tile(template, (64, 64, 1)) * 3.0
pixels = np.clip(np.rint(background + carrier), 0, 255).astype(np.uint8)
return pixels, template
def test_runtime_components_match_frozen_research_seam(
periodic_fixture: tuple[np.ndarray, np.ndarray],
) -> None:
pixels, template = periodic_fixture
runtime = registered_confirmation_components(pixels, template, 16.0, 1.0)
research = research_probe.score_lattice(
pixels,
template,
periods=np.asarray([16.0]),
rotations_degrees=np.asarray([0.0]),
)
assert runtime.period == research.selected_period
assert runtime.joint_coherence == pytest.approx(research.joint_coherence)
assert runtime.joint_amplitude == pytest.approx(research.joint_amplitude)
assert runtime.unknown_codeword_fixed_confirmation == pytest.approx(research.unknown_codeword_fixed_confirmation)
assert runtime.selection_patches == research.selection_patches
assert runtime.confirmation_patches == research.confirmation_patches
assert runtime.passes
def test_confirmation_rejects_independent_noise(periodic_fixture: tuple[np.ndarray, np.ndarray]) -> None:
_pixels, template = periodic_fixture
pixels = np.random.default_rng(20260819).integers(0, 256, (1024, 1024, 3), dtype=np.uint8)
result = registered_confirmation_components(pixels, template, 16.0, 1.0)
assert not result.passes
def test_period_aware_confirmation_boundaries() -> None:
baseline = RegisteredConfirmationComponents(
period=16.0,
joint_coherence=0.30,
joint_amplitude=0.0,
unknown_codeword_fixed_confirmation=0.5,
selection_patches=8,
confirmation_patches=8,
)
assert baseline.passes
assert not replace(baseline, period=9.99).passes
assert not replace(baseline, joint_coherence=0.299).passes
assert not replace(baseline, joint_amplitude=-0.001).passes
assert not replace(baseline, period=18.28, unknown_codeword_fixed_confirmation=0.129).passes
assert replace(baseline, period=18.28, unknown_codeword_fixed_confirmation=0.13).passes
assert not replace(baseline, period=19.14, joint_coherence=0.399).passes
assert replace(baseline, period=19.14, joint_coherence=0.40).passes
assert not replace(baseline, period=21.31, unknown_codeword_fixed_confirmation=0.019).passes
assert replace(baseline, period=21.31, unknown_codeword_fixed_confirmation=0.02).passes
@@ -0,0 +1,65 @@
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import synthid_cyclostationary_probe as probe
def _template() -> np.ndarray:
_y, x = np.indices((16, 16))
carrier = np.cos(2.0 * np.pi * 4.0 * x / 16.0)
template = np.stack((carrier, 0.8 * carrier, 0.6 * carrier), axis=2)
template -= np.mean(template, axis=(0, 1), keepdims=True)
return template / np.linalg.norm(template)
def test_detects_complex_spectral_coupling() -> None:
rng = np.random.default_rng(20260814)
base = rng.normal(0.0, 1.0, (1024, 1024, 3))
_y, x = np.indices(base.shape[:2])
modulation = 1.0 + 0.8 * np.cos(2.0 * np.pi * 4.0 * x / 16.0)
result = probe.score_cyclostationary(
base * modulation[:, :, None],
_template(),
period=16.0,
harmonic_count=1,
)
assert result.selection_contrast > 0.1
assert result.confirmation_contrast > 0.1
assert result.joint_contrast > 0.1
def test_rejects_independent_equal_power_noise() -> None:
rng = np.random.default_rng(20260815)
noise = rng.normal(0.0, 1.0, (1024, 1024, 3))
result = probe.score_cyclostationary(
noise,
_template(),
period=16.0,
harmonic_count=1,
)
assert result.joint_contrast < 0.01
def test_does_not_confuse_additive_carrier_with_modulation() -> None:
rng = np.random.default_rng(20260816)
noise = rng.normal(0.0, 1.0, (1024, 1024, 3))
additive = np.tile(_template(), (64, 64, 1)) * 2.0
result = probe.score_cyclostationary(
noise + additive,
_template(),
period=16.0,
harmonic_count=1,
)
assert result.joint_contrast < 0.01
+360 -19
View File
@@ -47,6 +47,63 @@ def registered_scale_positive(tmp_path_factory: pytest.TempPathFactory) -> Path:
return path return path
@pytest.fixture(scope="module")
def opponent_registered_positive(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Create a strong period-10 opponent-color fallback fixture."""
import cv2
directory = tmp_path_factory.mktemp("synthid-opponent-registered")
template, *_model = detector._load_template()
scaled_tile = template / np.max(np.abs(template)) * 40.0
source = np.tile(scaled_tile, (128, 128, 1)) + 128.0
pixels = cv2.resize(
np.clip(np.rint(source), 0, 255).astype(np.uint8),
(1280, 1280),
interpolation=cv2.INTER_AREA,
)
path = directory / "period-10-positive.png"
Image.fromarray(pixels, "RGB").save(path)
return path
@pytest.fixture(scope="module")
def opponent_period8_positive(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Create a strong period-8 fallback fixture without native JPEG block edges."""
import cv2
directory = tmp_path_factory.mktemp("synthid-opponent-period8")
template, *_model = detector._load_template()
scaled_tile = template / np.max(np.abs(template)) * 40.0
source = np.tile(scaled_tile, (128, 128, 1)) + 128.0
pixels = cv2.resize(
np.clip(np.rint(source), 0, 255).astype(np.uint8),
(1024, 1024),
interpolation=cv2.INTER_AREA,
)
path = directory / "period-8-positive.png"
Image.fromarray(pixels, "RGB").save(path)
return path
@pytest.fixture(scope="module")
def fine_opponent_registered_positive(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Create a strong period-7.68 carrier missed by the coarse period grid."""
import cv2
directory = tmp_path_factory.mktemp("synthid-fine-opponent-registered")
template, *_model = detector._load_template()
scaled_tile = template / np.max(np.abs(template)) * 40.0
source = np.tile(scaled_tile, (144, 144, 1)) + 128.0
pixels = cv2.resize(
np.clip(np.rint(source), 0, 255).astype(np.uint8),
(1106, 1106),
interpolation=cv2.INTER_AREA,
)
path = directory / "period-7.68-positive.png"
Image.fromarray(pixels, "RGB").save(path)
return path
def test_bundled_model_is_the_frozen_calibrated_artifact() -> None: def test_bundled_model_is_the_frozen_calibrated_artifact() -> None:
model = Path(detector.__file__).parent / "assets" / detector.MODEL_FILENAME model = Path(detector.__file__).parent / "assets" / detector.MODEL_FILENAME
@@ -79,9 +136,11 @@ def test_geometry_outside_the_challenged_pixel_count_range_is_unsupported(
[ [
(500, 500, True), (500, 500, True),
(4000, 2500, True), (4000, 2500, True),
(64, 3907, True), (256, 977, True),
(499, 500, False), (499, 500, False),
(4001, 2500, False), (4001, 2500, False),
(255, 981, False),
(64, 3907, False),
(32, 7813, False), (32, 7813, False),
], ],
) )
@@ -93,6 +152,42 @@ def test_registered_geometry_uses_its_measured_pixel_count_range(
assert detector._registered_geometry_supported(width, height) is supported assert detector._registered_geometry_supported(width, height) is supported
@pytest.mark.parametrize(
("width", "height", "supported"),
[
(1000, 1000, True),
(4000, 2500, True),
(767, 1304, False),
(1000, 999, False),
(4001, 2500, False),
],
)
def test_opponent_registered_geometry_uses_its_frozen_domain(
width: int,
height: int,
supported: bool,
) -> None:
assert detector._opponent_registered_geometry_supported(width, height) is supported
@pytest.mark.parametrize(
("width", "height", "supported"),
[
(1000, 1000, True),
(2500, 2000, True),
(767, 1304, False),
(1000, 999, False),
(2501, 2000, False),
],
)
def test_fine_opponent_registered_geometry_uses_its_frozen_domain(
width: int,
height: int,
supported: bool,
) -> None:
assert detector._fine_opponent_registered_geometry_supported(width, height) is supported
@pytest.mark.parametrize( @pytest.mark.parametrize(
("width", "height", "supported"), ("width", "height", "supported"),
[ [
@@ -162,7 +257,7 @@ def test_large_red_green_gate_mutation_changes_the_real_verdict(
assert baseline.status == "detected" assert baseline.status == "detected"
assert baseline.detector == detector.LARGE_DETECTOR_ID assert baseline.detector == detector.LARGE_DETECTOR_ID
assert mutated.status == "not_detected" assert mutated.status == "indeterminate"
def test_uncalibrated_narrow_large_geometry_is_unsupported() -> None: def test_uncalibrated_narrow_large_geometry_is_unsupported() -> None:
@@ -189,7 +284,7 @@ def test_registered_mode_rejects_a_side_too_short_for_quadrants(tmp_path: Path)
def test_detects_supported_periodic_carrier(supported_images: tuple[Path, Path]) -> None: def test_detects_supported_periodic_carrier(supported_images: tuple[Path, Path]) -> None:
positive, _negative = supported_images positive, _negative = supported_images
result = detector.detect_synthid(positive) result = detector.detect_synthid(positive, register_scale=False)
assert result.status == "detected" assert result.status == "detected"
assert result.detected is True assert result.detected is True
@@ -209,7 +304,7 @@ def test_detects_unregistered_non_divisible_geometry_in_size_range(tmp_path: Pat
path = tmp_path / "non-divisible-positive.png" path = tmp_path / "non-divisible-positive.png"
Image.fromarray(pixels, "RGB").save(path) Image.fromarray(pixels, "RGB").save(path)
result = detector.detect_synthid(path) result = detector.detect_synthid(path, register_scale=False)
assert result.status == "detected" assert result.status == "detected"
assert (result.width, result.height) == (width, height) assert (result.width, result.height) == (width, height)
@@ -218,10 +313,12 @@ def test_detects_unregistered_non_divisible_geometry_in_size_range(tmp_path: Pat
def test_registered_mode_detects_a_rescaled_carrier(registered_scale_positive: Path) -> None: def test_registered_mode_detects_a_rescaled_carrier(registered_scale_positive: Path) -> None:
fixed = detector.detect_synthid(registered_scale_positive, register_scale=False)
default = detector.detect_synthid(registered_scale_positive) default = detector.detect_synthid(registered_scale_positive)
registered = detector.detect_synthid(registered_scale_positive, register_scale=True) registered = detector.detect_synthid(registered_scale_positive, register_scale=True)
assert default.status == "unsupported" assert fixed.status == "unsupported"
assert default == registered
assert registered.status == "detected" assert registered.status == "detected"
assert registered.score is not None assert registered.score is not None
assert registered.score > registered.threshold assert registered.score > registered.threshold
@@ -229,6 +326,167 @@ def test_registered_mode_detects_a_rescaled_carrier(registered_scale_positive: P
assert registered.detector == detector.REGISTERED_DETECTOR_ID assert registered.detector == detector.REGISTERED_DETECTOR_ID
def test_registered_mode_falls_back_to_the_opponent_color_expert(
monkeypatch: pytest.MonkeyPatch,
opponent_registered_positive: Path,
) -> None:
import remove_ai_watermarks._synthid_registered as registered_detector
monkeypatch.setattr(registered_detector, "registered_score", lambda *_args: 0.0)
result = detector.detect_synthid(opponent_registered_positive, register_scale=True)
assert result.status == "detected"
assert result.detector == detector.OPPONENT_REGISTERED_DETECTOR_ID
assert result.score is not None
assert result.score >= result.threshold
def test_opponent_registered_threshold_mutation_changes_the_real_verdict(
monkeypatch: pytest.MonkeyPatch,
opponent_registered_positive: Path,
) -> None:
import remove_ai_watermarks._synthid_registered as registered_detector
monkeypatch.setattr(registered_detector, "registered_score", lambda *_args: 0.0)
baseline = detector.detect_synthid(opponent_registered_positive, register_scale=True)
assert baseline.score is not None
assert baseline.detector == detector.OPPONENT_REGISTERED_DETECTOR_ID
monkeypatch.setattr(
detector,
"OPPONENT_REGISTERED_THRESHOLD",
float(np.nextafter(baseline.score, np.inf)),
)
mutated = detector.detect_synthid(opponent_registered_positive, register_scale=True)
assert mutated.status == "indeterminate"
assert mutated.detector == detector.REGISTERED_DETECTOR_ID
def test_opponent_fallback_recovers_period8_without_codec_grid(
monkeypatch: pytest.MonkeyPatch,
opponent_period8_positive: Path,
) -> None:
import remove_ai_watermarks._synthid_registered as registered_detector
monkeypatch.setattr(registered_detector, "registered_score", lambda *_args: 0.0)
result = detector.detect_synthid(opponent_period8_positive, register_scale=True)
assert result.status == "detected"
assert result.detector == detector.OPPONENT_REGISTERED_DETECTOR_ID
def test_fine_opponent_fallback_recovers_off_grid_period(
monkeypatch: pytest.MonkeyPatch,
fine_opponent_registered_positive: Path,
) -> None:
import remove_ai_watermarks._synthid_registered as registered_detector
monkeypatch.setattr(registered_detector, "registered_score", lambda *_args: 0.0)
monkeypatch.setattr(registered_detector, "opponent_registered_score", lambda *_args: 0.0)
result = detector.detect_synthid(fine_opponent_registered_positive, register_scale=True)
assert result.status == "detected"
assert result.detector == detector.FINE_OPPONENT_REGISTERED_DETECTOR_ID
assert result.score is not None
assert result.score >= detector.FINE_OPPONENT_REGISTERED_THRESHOLD
def test_fine_opponent_threshold_mutation_changes_the_real_verdict(
monkeypatch: pytest.MonkeyPatch,
fine_opponent_registered_positive: Path,
) -> None:
import remove_ai_watermarks._synthid_registered as registered_detector
monkeypatch.setattr(registered_detector, "registered_score", lambda *_args: 0.0)
monkeypatch.setattr(registered_detector, "opponent_registered_score", lambda *_args: 0.0)
baseline = detector.detect_synthid(fine_opponent_registered_positive, register_scale=True)
assert baseline.score is not None
assert baseline.detector == detector.FINE_OPPONENT_REGISTERED_DETECTOR_ID
monkeypatch.setattr(
detector,
"FINE_OPPONENT_REGISTERED_THRESHOLD",
float(np.nextafter(baseline.score, np.inf)),
)
mutated = detector.detect_synthid(fine_opponent_registered_positive, register_scale=True)
assert mutated.status == "indeterminate"
assert mutated.detector == detector.REGISTERED_DETECTOR_ID
def test_fine_opponent_selector_recovers_the_fractional_period(
fine_opponent_registered_positive: Path,
) -> None:
import remove_ai_watermarks._synthid_registered as registered_detector
template, sigma, *_model = detector._load_template()
pixels = np.asarray(Image.open(fine_opponent_registered_positive).convert("RGB"), dtype=np.uint8)
components = registered_detector.fine_opponent_registered_components(pixels, template, sigma)
assert components.selected_period == pytest.approx(7.68, abs=0.01)
assert components.fine_decision_score >= detector.FINE_OPPONENT_REGISTERED_THRESHOLD
assert components.candidate_count >= 100
def test_period8_codec_veto_threshold_mutation_changes_real_components(
monkeypatch: pytest.MonkeyPatch,
opponent_period8_positive: Path,
) -> None:
import remove_ai_watermarks._synthid_registered as registered_detector
template, sigma, *_model = detector._load_template()
pixels = np.asarray(Image.open(opponent_period8_positive).convert("RGB"), dtype=np.uint8)
components = registered_detector.opponent_registered_components(pixels, template, sigma)
assert components.decision_score >= detector.OPPONENT_REGISTERED_THRESHOLD
assert components.red_green_p8_edge_ratio is not None
assert components.blue_yellow_p8_edge_ratio is not None
monkeypatch.setattr(registered_detector, "OPPONENT_REGISTERED_MAX_P8_EDGE_RATIO", 0.9)
assert components.decision_score == 0.0
def test_opponent_registered_period_band_and_codec_veto_are_required() -> None:
from remove_ai_watermarks._synthid_registered import OpponentRegisteredComponents
values = {
"spectral_score": 0.8,
"fixed_score": 0.32,
"red_green_spatial": 0.9,
"blue_yellow_spatial": 0.8,
"candidate_count": 3,
"red_green_p8_edge_ratio": None,
"blue_yellow_p8_edge_ratio": None,
}
matching = OpponentRegisteredComponents(10.0, 10.0, **values)
period8 = OpponentRegisteredComponents(
8.0,
8.0,
**{
**values,
"red_green_p8_edge_ratio": 1.0,
"blue_yellow_p8_edge_ratio": 1.0,
},
)
codec_alias = OpponentRegisteredComponents(
8.0,
8.0,
**{
**values,
"red_green_p8_edge_ratio": 1.2,
"blue_yellow_p8_edge_ratio": 1.2,
},
)
assert matching.decision_score > detector.OPPONENT_REGISTERED_THRESHOLD
assert period8.decision_score > detector.OPPONENT_REGISTERED_THRESHOLD
assert codec_alias.base_decision_score > detector.OPPONENT_REGISTERED_THRESHOLD
assert codec_alias.decision_score == 0.0
def test_registered_threshold_mutation_changes_the_real_verdict( def test_registered_threshold_mutation_changes_the_real_verdict(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
registered_scale_positive: Path, registered_scale_positive: Path,
@@ -240,7 +498,7 @@ def test_registered_threshold_mutation_changes_the_real_verdict(
mutated = detector.detect_synthid(registered_scale_positive, register_scale=True) mutated = detector.detect_synthid(registered_scale_positive, register_scale=True)
assert mutated.status == "not_detected" assert mutated.status == "indeterminate"
assert mutated.threshold == mutated_threshold assert mutated.threshold == mutated_threshold
@@ -270,17 +528,22 @@ def test_registered_amplitude_threshold_mutation_changes_the_real_verdict(
mutated = detector.detect_synthid(registered_scale_positive, register_scale=True) mutated = detector.detect_synthid(registered_scale_positive, register_scale=True)
assert mutated.status == "not_detected" assert mutated.status == "indeterminate"
def test_registered_spectral_candidate_disagreement_blocks_decision() -> None: def test_registered_spectral_candidate_disagreement_blocks_decision() -> None:
from remove_ai_watermarks._synthid_confirmation import RegisteredConfirmationComponents
from remove_ai_watermarks._synthid_registered import RegisteredComponents from remove_ai_watermarks._synthid_registered import RegisteredComponents
matching = RegisteredComponents(0.5, 0.25, 12.8, 12.8, 0.15) confirmation = RegisteredConfirmationComponents(12.8, 0.5, 0.2, 0.5, 8, 8)
mismatching = RegisteredComponents(0.5, 0.25, 12.8, 12.9, 0.15) matching = RegisteredComponents(0.5, 0.25, 12.8, 12.8, 0.15, confirmation)
mismatching = RegisteredComponents(0.5, 0.25, 12.8, 12.9, 0.15, confirmation)
unconfirmed = RegisteredComponents(0.5, 0.25, 12.8, 12.8, 0.15)
assert matching.decision_score == pytest.approx(2.0) assert matching.decision_score == pytest.approx(2.0)
assert mismatching.decision_score == pytest.approx(0.0) assert mismatching.decision_score == pytest.approx(0.0)
assert unconfirmed.base_decision_score == pytest.approx(2.0)
assert unconfirmed.decision_score == pytest.approx(0.0)
def test_registered_high_band_mutation_changes_the_real_verdict( def test_registered_high_band_mutation_changes_the_real_verdict(
@@ -303,15 +566,40 @@ def test_registered_high_band_mutation_changes_the_real_verdict(
mutated = detector.detect_synthid(registered_scale_positive, register_scale=True) mutated = detector.detect_synthid(registered_scale_positive, register_scale=True)
assert mutated.status == "not_detected" assert mutated.status == "indeterminate"
def test_registered_confirmation_mutation_changes_the_real_verdict(
monkeypatch: pytest.MonkeyPatch,
registered_scale_positive: Path,
) -> None:
import remove_ai_watermarks._synthid_confirmation as confirmation_detector
import remove_ai_watermarks._synthid_registered as registered_detector
components = registered_detector.registered_components(
np.asarray(Image.open(registered_scale_positive).convert("RGB"), dtype=np.uint8),
detector._load_template()[0],
detector._load_template()[1],
)
assert components.confirmation is not None
assert components.decision_score >= detector.REGISTERED_THRESHOLD
monkeypatch.setattr(
confirmation_detector,
"MIN_COHERENCE",
float(np.nextafter(components.confirmation.joint_coherence, np.inf)),
)
mutated = detector.detect_synthid(registered_scale_positive, register_scale=True)
assert mutated.status == "indeterminate"
def test_supported_negative_does_not_claim_clean(supported_images: tuple[Path, Path]) -> None: def test_supported_negative_does_not_claim_clean(supported_images: tuple[Path, Path]) -> None:
_positive, negative = supported_images _positive, negative = supported_images
result = detector.detect_synthid(negative) result = detector.detect_synthid(negative, register_scale=False)
assert result.status == "not_detected" assert result.status == "indeterminate"
assert result.detected is False assert result.detected is False
assert result.score == pytest.approx(0.0) assert result.score == pytest.approx(0.0)
@@ -321,16 +609,16 @@ def test_threshold_mutation_changes_the_real_verdict(
supported_images: tuple[Path, Path], supported_images: tuple[Path, Path],
) -> None: ) -> None:
positive, _negative = supported_images positive, _negative = supported_images
baseline = detector.detect_synthid(positive) baseline = detector.detect_synthid(positive, register_scale=False)
assert baseline.score is not None assert baseline.score is not None
assert baseline.status == "detected" assert baseline.status == "detected"
mutated_threshold = float(np.nextafter(baseline.score, np.inf)) mutated_threshold = float(np.nextafter(baseline.score, np.inf))
assert mutated_threshold > baseline.score assert mutated_threshold > baseline.score
monkeypatch.setattr(detector, "TILE_THRESHOLD", mutated_threshold) monkeypatch.setattr(detector, "TILE_THRESHOLD", mutated_threshold)
mutated = detector.detect_synthid(positive) mutated = detector.detect_synthid(positive, register_scale=False)
assert mutated.status == "not_detected" assert mutated.status == "indeterminate"
assert mutated.threshold == mutated_threshold assert mutated.threshold == mutated_threshold
@@ -338,11 +626,14 @@ def test_unsupported_geometry_is_distinct_from_negative(tmp_path: Path) -> None:
path = tmp_path / "small.png" path = tmp_path / "small.png"
Image.new("RGB", (64, 32), "white").save(path) Image.new("RGB", (64, 32), "white").save(path)
result = detector.detect_synthid(path) result = detector.detect_synthid(path, register_scale=False)
assert result.status == "unsupported" assert result.status == "unsupported"
assert result.score is None assert result.score is None
assert (result.width, result.height) == (64, 32) assert (result.width, result.height) == (64, 32)
assert result.reason is not None
assert result.to_dict()["metadata_used_for_verdict"] is False
assert result.to_dict()["provider_scope"] == "provider-neutral"
def test_shared_bgr_decode_matches_file_decode(supported_images: tuple[Path, Path]) -> None: def test_shared_bgr_decode_matches_file_decode(supported_images: tuple[Path, Path]) -> None:
@@ -352,8 +643,8 @@ def test_shared_bgr_decode_matches_file_decode(supported_images: tuple[Path, Pat
bgr = cv2.imread(str(positive)) bgr = cv2.imread(str(positive))
assert bgr is not None assert bgr is not None
from_file = detector.detect_synthid(positive) from_file = detector.detect_synthid(positive, register_scale=False)
from_array = detector.detect_synthid(positive, image=bgr) from_array = detector.detect_synthid(positive, image=bgr, register_scale=False)
assert from_array == from_file assert from_array == from_file
@@ -366,7 +657,7 @@ def test_supported_geometry_requires_pixel_dependencies(
monkeypatch.setattr(detector, "is_available", lambda: False) monkeypatch.setattr(detector, "is_available", lambda: False)
with pytest.raises(RuntimeError, match="pixel extra"): with pytest.raises(RuntimeError, match="pixel extra"):
detector.detect_synthid(negative) detector.detect_synthid(negative, register_scale=False)
def test_fold_accepts_non_divisible_geometry_without_resampling() -> None: def test_fold_accepts_non_divisible_geometry_without_resampling() -> None:
@@ -435,3 +726,53 @@ def test_fold_rejects_tile_larger_than_image() -> None:
tile_width=16, tile_width=16,
denoise_sigma=1.0, denoise_sigma=1.0,
) )
def test_verdict_does_not_claim_the_watermark() -> None:
"""The result must not assert SynthID, because the statistic is not SynthID.
This was unguarded until 2026-08-16, and the claim had been wrong for months
without a single test noticing. The fields are pinned by value rather than by
presence so that a rename back to a watermark claim fails here.
"""
result = detector.SynthIDDetection(
status="detected",
width=4096,
height=2560,
score=1.0,
threshold=1.0,
)
payload = result.to_dict()
assert payload["signal_family"] == "generation-pipeline-lattice"
assert payload["identifies_watermark"] is False
assert payload["tile_aligned_crop_required"] is True
assert "synthid" not in str(payload["signal_family"]).lower()
def test_the_statistic_is_locked_to_the_image_origin() -> None:
"""A crop off the tile grid must destroy the score, and that must stay visible.
SynthID's published evaluation retains 99.97% TPR under aggressive crop and
resize. This statistic loses everything to a seven-pixel shift, measured on
the real runtime at 4096x2560 where aligned crops scored up to 1.069 and
shifted ones reached -0.438. The property is asserted here so that any future
expert claiming to read the watermark has to survive the same shift first.
"""
template, sigma, *_model = detector._load_template()
tile = template / np.max(np.abs(template))
pixels = np.full((1024, 1024, 3), 128.0)
pixels += 6.0 * np.tile(tile, (64, 64, 1))
aligned = np.clip(np.rint(pixels), 0, 255).astype(np.uint8)
aligned_score, _folded = detector.folded_template_score(aligned, template, sigma)
# Seven is deliberately coprime with the 16-pixel tile, so no residual phase survives.
shifted_score, _shifted_folded = detector.folded_template_score(
aligned[7:, 7:],
template,
sigma,
)
assert aligned_score > 0.5
assert shifted_score < 0.1 * aligned_score