mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-31 09:40:38 +02:00
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:
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user