From 8eb9c06265cb4cfd1b5bd754fceed2a8866fda1d Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Sun, 16 Aug 2026 21:53:37 -0700 Subject: [PATCH] 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. --- scripts/synthid_affine_lattice_probe.py | 1634 +++++++++++++++++ scripts/synthid_cyclostationary_probe.py | 228 +++ scripts/synthid_runtime_expert_scores.py | 2 +- src/remove_ai_watermarks/__init__.py | 9 +- .../_synthid_confirmation.py | 288 +++ .../_synthid_registered.py | 366 +++- src/remove_ai_watermarks/cli.py | 45 +- src/remove_ai_watermarks/identify.py | 43 +- src/remove_ai_watermarks/openai_provenance.py | 145 +- src/remove_ai_watermarks/synthid_detector.py | 128 +- tests/test_api.py | 1 + tests/test_cli.py | 3 +- tests/test_identify.py | 26 +- tests/test_openai_provenance.py | 169 +- tests/test_synthid_affine_lattice_probe.py | 399 ++++ tests/test_synthid_confirmation.py | 84 + tests/test_synthid_cyclostationary_probe.py | 65 + tests/test_synthid_detector.py | 379 +++- 18 files changed, 3912 insertions(+), 102 deletions(-) create mode 100644 scripts/synthid_affine_lattice_probe.py create mode 100644 scripts/synthid_cyclostationary_probe.py create mode 100644 src/remove_ai_watermarks/_synthid_confirmation.py create mode 100644 tests/test_synthid_affine_lattice_probe.py create mode 100644 tests/test_synthid_confirmation.py create mode 100644 tests/test_synthid_cyclostationary_probe.py diff --git a/scripts/synthid_affine_lattice_probe.py b/scripts/synthid_affine_lattice_probe.py new file mode 100644 index 0000000..f16738b --- /dev/null +++ b/scripts/synthid_affine_lattice_probe.py @@ -0,0 +1,1634 @@ +"""Probe a periodic carrier lattice and codeword with split confirmation. + +The synchronization parameters are selected on one checkerboard of image +patches and scored on the disjoint checkerboard. The statistics measure complex +phase coherence and a content-whitened multichannel template match after +correcting every patch for its global origin. They do not use filenames, +metadata, or scene-class features. +""" + +from __future__ import annotations + +import json +import logging +import math +from dataclasses import asdict, dataclass, replace +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import click +import cv2 +import numpy as np +from synthid_pixel_attack import jpeg_round_trip, load_rgb + +from remove_ai_watermarks._synthid_confirmation import ( + registered_confirmation_passes as runtime_registered_confirmation_passes, +) +from remove_ai_watermarks._synthid_registered import RegisteredComponents, registered_components +from remove_ai_watermarks.synthid_detector import fold_residual_template, folded_template_score, unit_tile + +if TYPE_CHECKING: + from numpy.typing import NDArray + +log = logging.getLogger(__name__) + +FIXED_CANDIDATE_THRESHOLD = 0.28 +PATCH_SHIFT_MIN_MARGIN = 0.45 +PATCH_SHIFT_STRONG_MARGIN = 1.0 +PATCH_SHIFT_MIN_Z = 2.5 +OPPONENT_REGISTERED_FIXED_MIN = 0.16 +OPPONENT_REGISTERED_RED_GREEN_MIN = 0.60 +OPPONENT_REGISTERED_BLUE_YELLOW_MIN = 0.55 +SAME_IMAGE_NULL_OFFSETS = ( + -2.0, + -1.75, + -1.5, + -1.25, + -1.0, + -0.75, + -0.5, + -0.35, + 0.35, + 0.5, + 0.75, + 1.0, + 1.25, + 1.5, + 1.75, + 2.0, +) + + +def _webp_round_trip(pixels: NDArray[Any], quality: int) -> NDArray[Any]: + """Apply one in-memory WebP encode/decode while returning RGB pixels.""" + if quality < 1 or quality > 101: + raise ValueError("WebP quality must be between 1 and 101") + success, encoded = cv2.imencode( + ".webp", + cv2.cvtColor(pixels, cv2.COLOR_RGB2BGR), + [cv2.IMWRITE_WEBP_QUALITY, quality], + ) + if not success: + raise RuntimeError("WebP encoding failed") + decoded = cv2.imdecode(encoded, cv2.IMREAD_COLOR) + if decoded is None: + raise RuntimeError("WebP decoding failed") + return cv2.cvtColor(decoded, cv2.COLOR_BGR2RGB) + + +@dataclass(frozen=True) +class LatticeScore: + """One select-confirm reciprocal-lattice observation.""" + + selected_period: float + selected_rotation_degrees: float + selected_orientation_degrees: int + selected_horizontal_reflection: bool + selected_deskew_degrees: float + deskew_candidate_count: int + deskew_selection_coherence: float + deskew_direct_selection_match: float + deskew_direct_confirmation_match: float + deskew_direct_joint_match: float + peak_period: float + peak_rotation_degrees: float + peak_selection_coherence: float + selection_coherence: float + selection_candidate_p95: float + selection_candidate_p99: float + selection_excess_p95: float + selection_excess_p99: float + confirmation_coherence: float + confirmation_candidate_p95: float + confirmation_candidate_p99: float + confirmation_excess_p95: float + confirmation_excess_p99: float + joint_coherence: float + joint_excess_p99: float + selected_shift_y: int + selected_shift_x: int + selection_codeword: float + confirmation_codeword: float + confirmation_codeword_shift_p95: float + confirmation_codeword_shift_p99: float + joint_codeword: float + unknown_codeword_shift_y: int + unknown_codeword_shift_x: int + unknown_codeword_selection: float + unknown_codeword_confirmation: float + unknown_codeword_fixed_confirmation: float + unknown_codeword_fixed_all: float + unknown_codeword_confirmation_p95: float + unknown_codeword_confirmation_p99: float + unknown_codeword_excess_p99: float + amplitude_shift_y: int + amplitude_shift_x: int + selection_amplitude: float + confirmation_amplitude: float + joint_amplitude: float + selection_whitened_match: float + confirmation_whitened_match: float + joint_whitened_match: float + amplitude_candidate_count: int + amplitude_rerank_count: int + canonical_template_score: float + canonical_registered_template_score: float + selection_patches: int + confirmation_patches: int + + +@dataclass(frozen=True) +class _AmplitudeScore: + """Amplitude-aware select-confirm score for one period candidate.""" + + period: float + selection: float + confirmation: float + shift_y: int + shift_x: int + unaligned_full: float + aligned_full: float + + +@dataclass(frozen=True) +class SameImageNullScore: + """Target-period coherence relative to neighboring periods in one image.""" + + target_period: float + target_selection_coherence: float + target_confirmation_coherence: float + joint_coherence: float + selection_off_period_max: float + confirmation_off_period_max: float + joint_excess: float + off_period_count: int + + +@dataclass(frozen=True) +class PatchShiftConsensusScore: + """Robust per-patch agreement on one cyclic carrier phase.""" + + period: float + selected_shift_y: int + selected_shift_x: int + selection_trimmed_z: float + confirmation_trimmed_z: float + joint_trimmed_z: float + selection_support_fraction: float + confirmation_support_fraction: float + joint_support_fraction: float + selection_patches: int + confirmation_patches: int + + +@dataclass(frozen=True) +class OpponentRegisteredScore: + """Scale-registered opponent-color carrier observation.""" + + selected_period: float + spectral_period: float + spectral_score: float + fixed_score: float + red_green_spatial: float + blue_yellow_spatial: float + candidate_count: int + + @property + def decision_score(self) -> float: + """Return the minimum normalized research gate 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, + ) + + +def fixed_candidate_passes(score: float) -> bool: + """Return the frozen precision-first fixed-period candidate verdict.""" + return math.isfinite(score) and score >= FIXED_CANDIDATE_THRESHOLD + + +def registered_confirmation_passes(score: LatticeScore) -> bool: + """Return the frozen period-aware split-confirmation verdict.""" + return runtime_registered_confirmation_passes( + score.selected_period, + score.joint_coherence, + score.joint_amplitude, + score.unknown_codeword_fixed_confirmation, + ) + + +def patch_shift_recovery_passes( + *, + amplitude_margin: float, + high_band_margin: float, + periods_agree: bool, + confirmation_passes: bool, + joint_trimmed_z: float, +) -> bool: + """Apply the frozen research-only content-adaptive recovery rule.""" + margins = (amplitude_margin, high_band_margin) + return ( + all(math.isfinite(value) for value in (*margins, joint_trimmed_z)) + and periods_agree + and confirmation_passes + and min(margins) >= PATCH_SHIFT_MIN_MARGIN + and max(margins) >= PATCH_SHIFT_STRONG_MARGIN + and joint_trimmed_z >= PATCH_SHIFT_MIN_Z + ) + + +def _opponent_channels(values: NDArray[Any]) -> NDArray[Any]: + """Return Green, Red-minus-Green, and Blue-minus-Yellow channels.""" + red = values[:, :, 0] + green = values[:, :, 1] + blue = values[:, :, 2] + return np.stack((green, red - green, blue - 0.5 * (red + green)), axis=2) + + +def _opponent_color_pair(values: NDArray[Any]) -> NDArray[Any]: + """Return the large-carrier Red-Green and Blue-Yellow planes.""" + red = values[:, :, 0] + green = values[:, :, 1] + blue = values[:, :, 2] + return np.stack((red - green, blue - 0.5 * (red + green)), axis=2) + + +def template_harmonics(template: NDArray[Any], count: int = 16) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]: + """Return strong unique half-plane harmonics and their channel weights.""" + if template.ndim != 3 or template.shape[2] != 3: + raise ValueError("template must have shape (height, width, 3)") + if count < 1: + raise ValueError("count must be positive") + 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, 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, row, column)) + candidates.sort(reverse=True) + selected = candidates[:count] + harmonics = np.asarray([(row, column) for _power, row, column, _y, _x in selected], dtype=np.float64) + coefficients = np.asarray([spectrum[y, x] for _power, _row, _column, y, x in selected]) + weights = np.abs(coefficients) + weight_sum = float(np.sum(weights)) + if weight_sum <= 0.0: + raise ValueError("template has no nonzero periodic harmonics") + coefficient_units = np.divide( + coefficients, + np.abs(coefficients), + out=np.zeros_like(coefficients), + where=np.abs(coefficients) > 1e-12, + ) + return harmonics, weights / weight_sum, coefficient_units + + +def _patch_origins(height: int, width: int, patch_size: int, grid_size: int) -> list[tuple[int, int, int]]: + if patch_size < 32 or height < patch_size or width < patch_size: + raise ValueError("image must contain at least one patch of the requested size") + if grid_size < 2: + raise ValueError("grid size must be at least two") + 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 = [] + for y_index, y in enumerate(np.unique(y_values)): + for x_index, x in enumerate(np.unique(x_values)): + origins.append((int(y), int(x), (y_index + x_index) % 2)) + if {group for _y, _x, group in origins} != {0, 1}: + raise ValueError("image geometry does not provide two 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 _candidate_frequencies( + periods: NDArray[Any], + rotations_degrees: NDArray[Any], + harmonics: NDArray[Any], +) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any], NDArray[Any]]: + period_grid, rotation_grid = np.meshgrid(periods, rotations_degrees, indexing="ij") + flat_periods = period_grid.ravel() + flat_rotations = rotation_grid.ravel() + angles = np.deg2rad(flat_rotations) + cosine = np.cos(angles)[:, None] + sine = np.sin(angles)[:, None] + base_y = harmonics[None, :, 0] / flat_periods[:, None] + base_x = harmonics[None, :, 1] / flat_periods[:, None] + frequencies_y = sine * base_x + cosine * base_y + frequencies_x = cosine * base_x - sine * base_y + return flat_periods, flat_rotations, frequencies_y, frequencies_x + + +def _patch_unit_values( + pixels: NDArray[Any], + origin_y: int, + origin_x: int, + patch_size: int, + frequencies_y: NDArray[Any], + frequencies_x: NDArray[Any], +) -> 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, :] + sampled = np.empty((*frequencies_y.shape, 3), dtype=np.complex128) + sample_y = frequencies_y * patch_size + sample_x = frequencies_x * patch_size + for channel in range(3): + residual = channels[:, :, channel] + residual = residual - cv2.GaussianBlur( + residual, + (0, 0), + sigmaX=1.0, + sigmaY=1.0, + borderType=cv2.BORDER_REFLECT_101, + ) + spectrum = np.fft.fft2(residual * window) + sampled[:, :, channel] = _bilinear_sample(spectrum, sample_y, sample_x) + phase_correction = np.exp(-2j * math.pi * (frequencies_y * origin_y + frequencies_x * origin_x)) + sampled *= phase_correction[:, :, None] + magnitudes = np.abs(sampled) + return np.divide(sampled, magnitudes, out=np.zeros_like(sampled), where=magnitudes > 1e-12) + + +def _coherence(unit_values: list[NDArray[Any]], weights: NDArray[Any]) -> NDArray[Any]: + if not unit_values: + raise ValueError("coherence needs at least one patch") + values = np.stack(unit_values) + coherence = np.abs(np.mean(values, axis=0)) + return np.sum(coherence * weights[None, :, :], axis=(1, 2)) + + +def score_same_image_period_null( + pixels: NDArray[Any], + template: NDArray[Any], + target_period: float, + *, + period_min: float = 7.5, + period_max: float = 24.5, + patch_size: int = 256, + grid_size: int = 4, + harmonic_count: int = 16, +) -> SameImageNullScore: + """Compare a fixed target period with preregistered neighboring periods.""" + if not math.isfinite(target_period) or not period_min <= target_period <= period_max: + raise ValueError("target period must be finite and inside the null range") + off_periods = np.asarray( + [ + target_period + offset + for offset in SAME_IMAGE_NULL_OFFSETS + if period_min <= target_period + offset <= period_max + ], + dtype=np.float64, + ) + if not len(off_periods): + raise ValueError("same-image period null needs at least one neighboring period") + periods = np.concatenate((np.asarray([target_period]), off_periods)) + harmonics, weights, _coefficient_units = template_harmonics(template, harmonic_count) + _periods, _rotations, frequencies_y, frequencies_x = _candidate_frequencies( + periods, + np.asarray([0.0]), + harmonics, + ) + grouped_values: dict[int, list[NDArray[Any]]] = {0: [], 1: []} + for origin_y, origin_x, group in _patch_origins(*pixels.shape[:2], patch_size, grid_size): + grouped_values[group].append( + _patch_unit_values( + pixels, + origin_y, + origin_x, + patch_size, + frequencies_y, + frequencies_x, + ) + ) + selection = _coherence(grouped_values[0], weights) + confirmation = _coherence(grouped_values[1], weights) + selection_off_max = float(np.max(selection[1:])) + confirmation_off_max = float(np.max(confirmation[1:])) + target_selection = float(selection[0]) + target_confirmation = float(confirmation[0]) + return SameImageNullScore( + target_period=target_period, + target_selection_coherence=target_selection, + target_confirmation_coherence=target_confirmation, + joint_coherence=min(target_selection, target_confirmation), + selection_off_period_max=selection_off_max, + confirmation_off_period_max=confirmation_off_max, + joint_excess=min( + target_selection - selection_off_max, + target_confirmation - confirmation_off_max, + ), + off_period_count=len(off_periods), + ) + + +def _codeword_scores( + selection_values: list[NDArray[Any]], + confirmation_values: list[NDArray[Any]], + selected_index: int, + harmonics: NDArray[Any], + coefficient_units: NDArray[Any], + weights: NDArray[Any], + tile_size: int, +) -> tuple[int, int, float, float, float, float]: + selection_mean = np.mean(np.stack(selection_values), axis=0)[selected_index] + confirmation_mean = np.mean(np.stack(confirmation_values), axis=0)[selected_index] + shift_y, shift_x = np.meshgrid(np.arange(tile_size), np.arange(tile_size), indexing="ij") + flat_y = shift_y.ravel() + flat_x = shift_x.ravel() + phase = np.exp( + -2j * math.pi * (flat_y[:, None] * harmonics[None, :, 0] + flat_x[:, None] * harmonics[None, :, 1]) / tile_size + ) + shifted_codewords = coefficient_units[None, :, :] * phase[:, :, None] + + def scores(values: NDArray[Any]) -> NDArray[Any]: + agreement = np.real(values[None, :, :] * np.conj(shifted_codewords)) + return np.sum(agreement * weights[None, :, :], axis=(1, 2)) + + selection_scores = scores(selection_mean) + confirmation_scores = scores(confirmation_mean) + selected_shift = int(np.argmax(selection_scores)) + return ( + int(flat_y[selected_shift]), + int(flat_x[selected_shift]), + float(selection_scores[selected_shift]), + float(confirmation_scores[selected_shift]), + float(np.quantile(confirmation_scores, 0.95)), + float(np.quantile(confirmation_scores, 0.99)), + ) + + +def _unknown_codeword_scores( + selection_values: list[NDArray[Any]], + confirmation_values: list[NDArray[Any]], + selected_index: int, + harmonics: NDArray[Any], + weights: NDArray[Any], + tile_size: int, +) -> tuple[int, int, float, float, float, float, float, float]: + """Fit an unknown patch codeword and confirm it on held-out harmonics.""" + if len(harmonics) < 4: + raise ValueError("unknown-codeword confirmation needs at least four harmonics") + selection_mean = np.mean(np.stack(selection_values), axis=0)[selected_index] + confirmation_mean = np.mean(np.stack(confirmation_values), axis=0)[selected_index] + cross_codeword = confirmation_mean * np.conj(selection_mean) + + shift_y, shift_x = np.meshgrid(np.arange(tile_size), np.arange(tile_size), indexing="ij") + flat_y = shift_y.ravel() + flat_x = shift_x.ravel() + phase = np.exp( + -2j * math.pi * (flat_y[:, None] * harmonics[None, :, 0] + flat_x[:, None] * harmonics[None, :, 1]) / tile_size + ) + shifted_cross = cross_codeword[None, :, :] * phase[:, :, None] + selection_mask = np.arange(len(harmonics)) % 2 == 0 + confirmation_mask = ~selection_mask + + def scores(mask: NDArray[Any]) -> NDArray[Any]: + masked_weights = weights[mask] + normalizer = float(np.sum(masked_weights)) + if normalizer <= 0.0: + raise ValueError("unknown-codeword harmonic split has no template weight") + return np.abs(np.sum(shifted_cross[:, mask, :] * masked_weights[None, :, :], axis=(1, 2))) / normalizer + + selection_scores = scores(selection_mask) + confirmation_scores = scores(confirmation_mask) + selected_shift = int(np.argmax(selection_scores)) + fixed_all = float(np.abs(np.sum(cross_codeword * weights))) + return ( + int(flat_y[selected_shift]), + int(flat_x[selected_shift]), + float(selection_scores[selected_shift]), + float(confirmation_scores[selected_shift]), + float(confirmation_scores[0]), + fixed_all, + float(np.quantile(confirmation_scores, 0.95)), + float(np.quantile(confirmation_scores, 0.99)), + ) + + +def _period_candidate_indices( + periods: NDArray[Any], + rotations_degrees: NDArray[Any], + selection_scores: NDArray[Any], + count: int, +) -> list[int]: + """Return separated zero-rotation candidates ranked on selection patches.""" + candidates: list[int] = [] + eligible = np.flatnonzero(np.isclose(rotations_degrees, 0.0, atol=1e-12)) + for index in eligible[np.argsort(selection_scores[eligible])[::-1]]: + if any(abs(float(periods[index] - periods[prior])) < 0.5 for prior in candidates): + continue + candidates.append(int(index)) + if len(candidates) == count: + break + if not candidates: + raise ValueError("amplitude confirmation requires a zero-rotation candidate") + return candidates + + +def _period_alias_candidate_indices( + periods: NDArray[Any], + rotations_degrees: NDArray[Any], + base_indices: list[int], +) -> list[int]: + """Expand coarse periods with octave aliases and adjacent grid bins.""" + eligible = np.flatnonzero(np.isclose(rotations_degrees, 0.0, atol=1e-12)) + eligible_periods = periods[eligible] + candidates: list[int] = [] + seen: set[int] = set() + for base_index in base_indices: + for ratio in (1.0, 0.5, 2.0): + target = float(periods[base_index] * ratio) + if target < float(np.min(eligible_periods)) or target > float(np.max(eligible_periods)): + continue + center = int(np.argmin(np.abs(eligible_periods - target))) + for position in range(max(0, center - 1), min(len(eligible), center + 2)): + index = int(eligible[position]) + if index not in seen: + seen.add(index) + candidates.append(index) + return candidates + + +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 _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 _real_correlation(left: NDArray[Any], right: NDArray[Any]) -> float: + 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 _opponent_period_curve( + pixels: NDArray[Any], + template: NDArray[Any], + periods: NDArray[Any], + *, + harmonic_count: int, +) -> NDArray[Any]: + """Return signed opponent-color template coherence over PERIODS.""" + template_opponent = _opponent_color_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][:harmonic_count] + 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_color_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 score_opponent_registered( + pixels: NDArray[Any], + template: NDArray[Any], + *, + periods: NDArray[Any], + harmonic_count: int = 30, + candidate_count: int = 5, + denoise_sigma: float = 1.0, +) -> OpponentRegisteredScore: + """Search scale using the large-carrier opponent-color representation.""" + if pixels.ndim != 3 or pixels.shape[2] != 3: + raise ValueError("pixels must have shape (height, width, 3)") + if periods.ndim != 1 or not len(periods) or np.any(~np.isfinite(periods)) or np.any(periods <= 0.0): + raise ValueError("periods must be a nonempty positive finite vector") + if harmonic_count < 1 or candidate_count < 1: + raise ValueError("harmonic and candidate counts must be positive") + curve = _opponent_period_curve( + pixels, + template, + periods, + harmonic_count=harmonic_count, + ) + rotations = np.zeros_like(periods) + candidate_indices = _period_candidate_indices(periods, rotations, curve, candidate_count) + observations: list[OpponentRegisteredScore] = [] + for index in candidate_indices: + period = float(periods[index]) + canonical = _canonical_pixels(pixels, template, period) + fixed_score, folded = folded_template_score(canonical, template, denoise_sigma) + folded_opponent = _opponent_color_pair(folded) + template_opponent = _opponent_color_pair(template) + observations.append( + OpponentRegisteredScore( + selected_period=period, + spectral_period=float(periods[int(np.argmax(curve))]), + spectral_score=float(curve[index]), + fixed_score=fixed_score, + red_green_spatial=_real_correlation(folded_opponent[:, :, 0], template_opponent[:, :, 0]), + blue_yellow_spatial=_real_correlation(folded_opponent[:, :, 1], template_opponent[:, :, 1]), + candidate_count=len(candidate_indices), + ) + ) + return max(observations, key=lambda score: score.decision_score) + + +def _content_whitened_patch_score( + pixels: NDArray[Any], + template: NDArray[Any], + harmonics: NDArray[Any], + *, + denoise_sigma: float, + noise_radius: int = 4, + guard_radius: int = 1, + ridge: float = 0.1, +) -> float: + """Return a complex matched-filter cosine after local color whitening.""" + patch_height, patch_width = pixels.shape[:2] + tile_height, tile_width = template.shape[:2] + if patch_height % tile_height or patch_width % tile_width: + raise ValueError("whitened patch dimensions must be multiples of the template dimensions") + if noise_radius <= guard_radius or guard_radius < 0: + raise ValueError("noise radius must exceed the nonnegative guard radius") + if not math.isfinite(ridge) or ridge <= 0.0: + raise ValueError("whitening ridge must be finite and positive") + + 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, + ) + spectrum = np.fft.fft2(channels, axes=(0, 1)) + template_spectrum = np.fft.fft2(_opponent_channels(np.asarray(template, dtype=np.float64)), axes=(0, 1)) + + numerator = 0.0 + template_energy = 0.0 + observation_energy = 0.0 + for signed_row_value, signed_column_value in harmonics: + signed_row = round(float(signed_row_value)) + signed_column = round(float(signed_column_value)) + bin_y = (signed_row * patch_height // tile_height) % patch_height + bin_x = (signed_column * patch_width // tile_width) % patch_width + noise = np.asarray( + [ + spectrum[(bin_y + offset_y) % patch_height, (bin_x + offset_x) % patch_width] + for offset_y in range(-noise_radius, noise_radius + 1) + for offset_x in range(-noise_radius, noise_radius + 1) + if max(abs(offset_y), abs(offset_x)) > guard_radius + ] + ) + covariance = noise.T @ np.conj(noise) / len(noise) + local_power = float(np.trace(covariance).real / covariance.shape[0]) + covariance += np.eye(covariance.shape[0]) * (ridge * local_power + 1e-12) + template_value = template_spectrum[signed_row % tile_height, signed_column % tile_width] + observation = spectrum[bin_y, bin_x] + whitened_template = np.linalg.solve(covariance, template_value) + whitened_observation = np.linalg.solve(covariance, observation) + numerator += float(np.vdot(template_value, whitened_observation).real) + template_energy += float(np.vdot(template_value, whitened_template).real) + observation_energy += float(np.vdot(observation, whitened_observation).real) + denominator = math.sqrt(max(0.0, template_energy * observation_energy)) + return numerator / denominator if denominator > 1e-12 else 0.0 + + +def _content_whitened_score( + pixels: NDArray[Any], + template: NDArray[Any], + harmonics: NDArray[Any], + *, + period: float, + patch_size: int, + grid_size: int, + denoise_sigma: float, +) -> tuple[float, float]: + """Score a selected period on disjoint patch groups after local whitening.""" + canonical = _canonical_pixels(pixels, template, period) + tile_height, tile_width = template.shape[:2] + grouped_scores: dict[int, list[float]] = {0: [], 1: []} + for origin_y, origin_x, group in _patch_origins(*canonical.shape[:2], patch_size, grid_size): + aligned_y = (origin_y // tile_height) * tile_height + aligned_x = (origin_x // tile_width) * tile_width + patch = canonical[aligned_y : aligned_y + patch_size, aligned_x : aligned_x + patch_size] + grouped_scores[group].append( + _content_whitened_patch_score( + patch, + template, + harmonics, + denoise_sigma=denoise_sigma, + ) + ) + return float(np.mean(grouped_scores[0])), float(np.mean(grouped_scores[1])) + + +def _amplitude_score( + pixels: NDArray[Any], + template: NDArray[Any], + *, + period: float, + patch_size: int, + grid_size: int, + denoise_sigma: float, +) -> _AmplitudeScore: + """Select cyclic phase on one patch group and confirm it on the other.""" + canonical = _canonical_pixels(pixels, template, period) + grouped_units: dict[int, list[NDArray[Any]]] = {0: [], 1: []} + tile_height, tile_width = template.shape[:2] + for origin_y, origin_x, group in _patch_origins(*canonical.shape[:2], patch_size, grid_size): + 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) + selected_shift = int(np.argmax(selection_correlations)) + shift_y, shift_x = np.unravel_index(selected_shift, selection_correlations.shape) + unaligned_full, full_folded = folded_template_score(canonical, template, denoise_sigma) + aligned_full, _aligned_norm = unit_tile(np.roll(full_folded, shift=(shift_y, shift_x), axis=(0, 1))) + return _AmplitudeScore( + period=period, + selection=float(selection_correlations[shift_y, shift_x]), + confirmation=float(confirmation_correlations[shift_y, shift_x]), + shift_y=int(shift_y), + shift_x=int(shift_x), + unaligned_full=unaligned_full, + aligned_full=float(np.sum(template * aligned_full)), + ) + + +def _robust_shift_z(correlations: NDArray[Any]) -> NDArray[Any]: + """Standardize each patch against its own cyclic-shift null.""" + flattened = correlations.reshape(correlations.shape[0], -1) + center = np.median(flattened, axis=1, keepdims=True) + mad = np.median(np.abs(flattened - center), axis=1, keepdims=True) + scale = 1.4826 * mad + fallback = np.std(flattened, axis=1, keepdims=True) + scale = np.where(scale > 1e-12, scale, fallback) + standardized = np.divide( + flattened - center, + scale, + out=np.zeros_like(flattened), + where=scale > 1e-12, + ) + return standardized.reshape(correlations.shape) + + +def _top_half_mean(values: NDArray[Any], *, axis: int) -> NDArray[Any]: + """Average the strongest half without letting one patch dominate.""" + count = values.shape[axis] + retained = max(1, (count + 1) // 2) + partitioned = np.partition(values, count - retained, axis=axis) + indices = np.arange(count - retained, count) + return np.mean(np.take(partitioned, indices, axis=axis), axis=axis) + + +def _shift_support_fraction(correlations: NDArray[Any], shift_y: int, shift_x: int) -> float: + """Return the patch fraction placing one shift in its upper five percent.""" + selected = correlations[:, shift_y, shift_x] + flattened = correlations.reshape(correlations.shape[0], -1) + less = np.sum(flattened < selected[:, None], axis=1) + equal = np.sum(flattened == selected[:, None], axis=1) + percentile = (less + 0.5 * equal) / flattened.shape[1] + return float(np.mean(percentile >= 0.95)) + + +def score_patch_shift_consensus( + pixels: NDArray[Any], + template: NDArray[Any], + period: float, + *, + patch_size: int = 256, + grid_size: int = 4, + denoise_sigma: float = 1.0, +) -> PatchShiftConsensusScore: + """Select a robust cyclic phase and confirm it on disjoint patches. + + This probe targets a content-adaptive encoder that may place the shared + detection carrier strongly in only part of an image. Every patch is + normalized against its own 2-D cyclic-shift distribution before the + strongest half are pooled. It is a research statistic, not a detector + threshold. + """ + if not math.isfinite(period) or period <= 0.0: + raise ValueError("period must be finite and positive") + canonical = _canonical_pixels(pixels, template, period) + tile_height, tile_width = template.shape[:2] + grouped_correlations: 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_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_correlations[group].append(_cyclic_correlations(template, unit)) + + selection = np.stack(grouped_correlations[0]) + confirmation = np.stack(grouped_correlations[1]) + selection_z = _robust_shift_z(selection) + confirmation_z = _robust_shift_z(confirmation) + selection_trimmed = _top_half_mean(selection_z, axis=0) + selected_shift = int(np.argmax(selection_trimmed)) + shift_y, shift_x = np.unravel_index(selected_shift, selection_trimmed.shape) + selection_score = float(selection_trimmed[shift_y, shift_x]) + confirmation_score = float(_top_half_mean(confirmation_z[:, shift_y, shift_x], axis=0)) + selection_support = _shift_support_fraction(selection, int(shift_y), int(shift_x)) + confirmation_support = _shift_support_fraction(confirmation, int(shift_y), int(shift_x)) + return PatchShiftConsensusScore( + period=period, + selected_shift_y=int(shift_y), + selected_shift_x=int(shift_x), + selection_trimmed_z=selection_score, + confirmation_trimmed_z=confirmation_score, + joint_trimmed_z=min(selection_score, confirmation_score), + selection_support_fraction=selection_support, + confirmation_support_fraction=confirmation_support, + joint_support_fraction=min(selection_support, confirmation_support), + selection_patches=len(selection), + confirmation_patches=len(confirmation), + ) + + +def score_lattice( + pixels: NDArray[Any], + template: NDArray[Any], + *, + periods: NDArray[Any], + rotations_degrees: NDArray[Any], + patch_size: int = 256, + grid_size: int = 4, + harmonic_count: int = 16, + amplitude_candidate_count: int = 5, + denoise_sigma: float = 1.0, +) -> LatticeScore: + """Select a lattice on one patch group and confirm it on the other.""" + if pixels.ndim != 3 or pixels.shape[2] != 3: + raise ValueError("pixels must have shape (height, width, 3)") + if periods.ndim != 1 or not len(periods) or np.any(~np.isfinite(periods)) or np.any(periods <= 0.0): + raise ValueError("periods must be a nonempty positive finite vector") + if rotations_degrees.ndim != 1 or not len(rotations_degrees) or np.any(~np.isfinite(rotations_degrees)): + raise ValueError("rotations must be a nonempty finite vector") + if not math.isfinite(denoise_sigma) or denoise_sigma <= 0.0: + raise ValueError("denoise sigma must be finite and positive") + if amplitude_candidate_count < 1: + raise ValueError("amplitude candidate count must be positive") + harmonics, weights, coefficient_units = template_harmonics(template, harmonic_count) + flat_periods, flat_rotations, frequencies_y, frequencies_x = _candidate_frequencies( + periods, + rotations_degrees, + harmonics, + ) + grouped_values: dict[int, list[NDArray[Any]]] = {0: [], 1: []} + for origin_y, origin_x, group in _patch_origins(*pixels.shape[:2], patch_size, grid_size): + grouped_values[group].append( + _patch_unit_values( + pixels, + origin_y, + origin_x, + patch_size, + frequencies_y, + frequencies_x, + ) + ) + selection_scores = _coherence(grouped_values[0], weights) + confirmation_scores = _coherence(grouped_values[1], weights) + peak_index = int(np.argmax(selection_scores)) + coarse_candidate_indices = _period_candidate_indices( + flat_periods, + flat_rotations, + selection_scores, + amplitude_candidate_count, + ) + candidate_indices = _period_alias_candidate_indices( + flat_periods, + flat_rotations, + coarse_candidate_indices, + ) + whitened_scores = [ + _content_whitened_score( + pixels, + template, + harmonics, + period=float(flat_periods[index]), + patch_size=patch_size, + grid_size=grid_size, + denoise_sigma=denoise_sigma, + ) + for index in candidate_indices + ] + whitened_order = np.argsort([selection for selection, _confirmation in whitened_scores])[::-1] + rerank_candidates = [int(index) for index in whitened_order[:3]] + rerank_amplitudes = [ + _amplitude_score( + pixels, + template, + period=float(flat_periods[candidate_indices[index]]), + patch_size=patch_size, + grid_size=grid_size, + denoise_sigma=denoise_sigma, + ) + for index in rerank_candidates + ] + rerank_scores = [ + min(*whitened_scores[index], amplitude.selection, amplitude.confirmation) + for index, amplitude in zip(rerank_candidates, rerank_amplitudes, strict=True) + ] + rerank_winner = int(np.argmax(rerank_scores)) + selected_candidate = rerank_candidates[rerank_winner] + selected_index = candidate_indices[selected_candidate] + amplitude = rerank_amplitudes[rerank_winner] + selection_whitened, confirmation_whitened = whitened_scores[selected_candidate] + selection_p95 = float(np.quantile(selection_scores, 0.95)) + selection_p99 = float(np.quantile(selection_scores, 0.99)) + confirmation_p95 = float(np.quantile(confirmation_scores, 0.95)) + confirmation_p99 = float(np.quantile(confirmation_scores, 0.99)) + confirmation = float(confirmation_scores[selected_index]) + selection = float(selection_scores[selected_index]) + shift_y, shift_x, selection_codeword, confirmation_codeword, codeword_p95, codeword_p99 = _codeword_scores( + grouped_values[0], + grouped_values[1], + selected_index, + harmonics, + coefficient_units, + weights, + template.shape[0], + ) + ( + unknown_shift_y, + unknown_shift_x, + unknown_selection, + unknown_confirmation, + unknown_fixed_confirmation, + unknown_fixed_all, + unknown_p95, + unknown_p99, + ) = _unknown_codeword_scores( + grouped_values[0], + grouped_values[1], + selected_index, + harmonics, + weights, + template.shape[0], + ) + return LatticeScore( + selected_period=float(flat_periods[selected_index]), + selected_rotation_degrees=float(flat_rotations[selected_index]), + selected_orientation_degrees=0, + selected_horizontal_reflection=False, + selected_deskew_degrees=0.0, + deskew_candidate_count=0, + deskew_selection_coherence=0.0, + deskew_direct_selection_match=0.0, + deskew_direct_confirmation_match=0.0, + deskew_direct_joint_match=0.0, + peak_period=float(flat_periods[peak_index]), + peak_rotation_degrees=float(flat_rotations[peak_index]), + peak_selection_coherence=float(selection_scores[peak_index]), + selection_coherence=selection, + selection_candidate_p95=selection_p95, + selection_candidate_p99=selection_p99, + selection_excess_p95=selection - selection_p95, + selection_excess_p99=selection - selection_p99, + confirmation_coherence=confirmation, + confirmation_candidate_p95=confirmation_p95, + confirmation_candidate_p99=confirmation_p99, + confirmation_excess_p95=confirmation - confirmation_p95, + confirmation_excess_p99=confirmation - confirmation_p99, + joint_coherence=min(selection, confirmation), + joint_excess_p99=min(selection - selection_p99, confirmation - confirmation_p99), + selected_shift_y=shift_y, + selected_shift_x=shift_x, + selection_codeword=selection_codeword, + confirmation_codeword=confirmation_codeword, + confirmation_codeword_shift_p95=codeword_p95, + confirmation_codeword_shift_p99=codeword_p99, + joint_codeword=min(selection_codeword, confirmation_codeword), + unknown_codeword_shift_y=unknown_shift_y, + unknown_codeword_shift_x=unknown_shift_x, + unknown_codeword_selection=unknown_selection, + unknown_codeword_confirmation=unknown_confirmation, + unknown_codeword_fixed_confirmation=unknown_fixed_confirmation, + unknown_codeword_fixed_all=unknown_fixed_all, + unknown_codeword_confirmation_p95=unknown_p95, + unknown_codeword_confirmation_p99=unknown_p99, + unknown_codeword_excess_p99=unknown_confirmation - unknown_p99, + amplitude_shift_y=amplitude.shift_y, + amplitude_shift_x=amplitude.shift_x, + selection_amplitude=amplitude.selection, + confirmation_amplitude=amplitude.confirmation, + joint_amplitude=min(amplitude.selection, amplitude.confirmation), + selection_whitened_match=selection_whitened, + confirmation_whitened_match=confirmation_whitened, + joint_whitened_match=min(selection_whitened, confirmation_whitened), + amplitude_candidate_count=len(candidate_indices), + amplitude_rerank_count=len(rerank_candidates), + canonical_template_score=amplitude.unaligned_full, + canonical_registered_template_score=amplitude.aligned_full, + selection_patches=len(grouped_values[0]), + confirmation_patches=len(grouped_values[1]), + ) + + +def score_orientation_bank( + pixels: NDArray[Any], + template: NDArray[Any], + *, + periods: NDArray[Any], + rotations_degrees: NDArray[Any], + patch_size: int = 256, + grid_size: int = 4, + harmonic_count: int = 16, + amplitude_candidate_count: int = 5, + denoise_sigma: float = 1.0, +) -> LatticeScore: + """Select a right-angle orientation on selection-patch whitened match.""" + candidates = [ + score_lattice( + np.rot90(pixels, k=quarter_turns), + template, + periods=periods, + rotations_degrees=rotations_degrees, + patch_size=patch_size, + grid_size=grid_size, + harmonic_count=harmonic_count, + amplitude_candidate_count=amplitude_candidate_count, + denoise_sigma=denoise_sigma, + ) + for quarter_turns in range(4) + ] + selected_index = int(np.argmax([candidate.selection_whitened_match for candidate in candidates])) + return replace(candidates[selected_index], selected_orientation_degrees=selected_index * 90) + + +def score_dihedral_bank( + pixels: NDArray[Any], + template: NDArray[Any], + *, + periods: NDArray[Any], + rotations_degrees: NDArray[Any], + patch_size: int = 256, + grid_size: int = 4, + harmonic_count: int = 16, + amplitude_candidate_count: int = 5, + denoise_sigma: float = 1.0, +) -> LatticeScore: + """Select a rotation and optional reflection on selection patches.""" + candidates: list[LatticeScore] = [] + transforms: list[tuple[int, bool]] = [] + for reflected in (False, True): + reflected_pixels = np.fliplr(pixels) if reflected else pixels + for quarter_turns in range(4): + candidates.append( + score_lattice( + np.rot90(reflected_pixels, k=quarter_turns), + template, + periods=periods, + rotations_degrees=rotations_degrees, + patch_size=patch_size, + grid_size=grid_size, + harmonic_count=harmonic_count, + amplitude_candidate_count=amplitude_candidate_count, + denoise_sigma=denoise_sigma, + ) + ) + transforms.append((quarter_turns * 90, reflected)) + selected_index = int(np.argmax([candidate.selection_whitened_match for candidate in candidates])) + selected_orientation, selected_reflection = transforms[selected_index] + return replace( + candidates[selected_index], + selected_orientation_degrees=selected_orientation, + selected_horizontal_reflection=selected_reflection, + ) + + +def _rotate_fixed_canvas(pixels: NDArray[Any], angle_degrees: float) -> NDArray[Any]: + """Rotate RGB pixels around the image center without changing the canvas.""" + height, width = pixels.shape[:2] + transform = cv2.getRotationMatrix2D(((width - 1) / 2.0, (height - 1) / 2.0), angle_degrees, 1.0) + return np.asarray( + cv2.warpAffine( + pixels, + transform, + (width, height), + flags=cv2.INTER_CUBIC, + borderMode=cv2.BORDER_REFLECT_101, + ) + ) + + +def _select_deskew_angle( + pixels: NDArray[Any], + template: NDArray[Any], + periods: NDArray[Any], + deskew_degrees: NDArray[Any], + *, + patch_size: int, + grid_size: int, + harmonic_count: int, +) -> tuple[float, float, float]: + """Select one deskew angle by carrier coherence on selection patches.""" + harmonics, weights, _coefficient_units = template_harmonics(template, harmonic_count) + flat_periods, flat_rotations, frequencies_y, frequencies_x = _candidate_frequencies( + periods, + deskew_degrees, + harmonics, + ) + selection_values = [ + _patch_unit_values( + pixels, + origin_y, + origin_x, + patch_size, + frequencies_y, + frequencies_x, + ) + for origin_y, origin_x, group in _patch_origins(*pixels.shape[:2], patch_size, grid_size) + if group == 0 + ] + selection_scores = _coherence(selection_values, weights) + selected_index = int(np.argmax(selection_scores)) + return ( + float(flat_periods[selected_index]), + float(flat_rotations[selected_index]), + float(selection_scores[selected_index]), + ) + + +def _affine_whitened_patch_score( + pixels: NDArray[Any], + expected_template: NDArray[Any], + frequencies_y: NDArray[Any], + frequencies_x: NDArray[Any], + *, + origin_y: int, + origin_x: int, + denoise_sigma: float, + noise_radius: int = 4, + guard_radius: int = 1, + ridge: float = 0.1, +) -> float: + """Match an affine carrier directly, without resampling the image.""" + patch_size = pixels.shape[0] + 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) + spectrum = np.fft.fft2(channels * (window_1d[:, None] * window_1d[None, :])[:, :, None], axes=(0, 1)) + offset_y, offset_x = zip( + *( + (y, x) + for y in range(-noise_radius, noise_radius + 1) + for x in range(-noise_radius, noise_radius + 1) + if max(abs(y), abs(x)) > guard_radius + ), + strict=True, + ) + offset_y_values = np.asarray(offset_y, dtype=np.float64) + offset_x_values = np.asarray(offset_x, dtype=np.float64) + sample_y = frequencies_y * patch_size + sample_x = frequencies_x * patch_size + phase_correction = np.exp(-2j * math.pi * (frequencies_y * origin_y + frequencies_x * origin_x)) + numerator = 0.0 + template_energy = 0.0 + observation_energy = 0.0 + for harmonic_index in range(len(frequencies_y)): + y = np.asarray([sample_y[harmonic_index]]) + x = np.asarray([sample_x[harmonic_index]]) + observation = np.asarray( + [_bilinear_sample(spectrum[:, :, channel], y, x)[0] for channel in range(channels.shape[2])] + ) + observation *= phase_correction[harmonic_index] + noise = np.stack( + [ + _bilinear_sample( + spectrum[:, :, channel], + y + offset_y_values, + x + offset_x_values, + ) + for channel in range(channels.shape[2]) + ], + axis=1, + ) + covariance = noise.T @ np.conj(noise) / len(noise) + local_power = float(np.trace(covariance).real / covariance.shape[0]) + covariance += np.eye(covariance.shape[0]) * (ridge * local_power + 1e-12) + template_value = expected_template[harmonic_index] + whitened_template = np.linalg.solve(covariance, template_value) + whitened_observation = np.linalg.solve(covariance, observation) + numerator += float(np.vdot(template_value, whitened_observation).real) + template_energy += float(np.vdot(template_value, whitened_template).real) + observation_energy += float(np.vdot(observation, whitened_observation).real) + denominator = math.sqrt(max(0.0, template_energy * observation_energy)) + return numerator / denominator if denominator > 1e-12 else 0.0 + + +def _affine_whitened_score( + pixels: NDArray[Any], + template: NDArray[Any], + harmonics: NDArray[Any], + *, + period: float, + deskew_degrees: float, + patch_size: int, + grid_size: int, + denoise_sigma: float, +) -> tuple[float, float]: + """Score a rotated carrier in the original pixels on disjoint patches.""" + _periods, _rotations, frequencies_y, frequencies_x = _candidate_frequencies( + np.asarray([period]), + np.asarray([deskew_degrees]), + harmonics, + ) + height, width = pixels.shape[:2] + input_rotation = -deskew_degrees + forward = cv2.getRotationMatrix2D( + ((width - 1) / 2.0, (height - 1) / 2.0), + input_rotation, + 1.0, + ) + inverse = cv2.invertAffineTransform(forward) + origin_x, origin_y = inverse[:, 2] + phase = np.exp(2j * math.pi * (harmonics[:, 0] * origin_y / period + harmonics[:, 1] * origin_x / period)) + template_spectrum = np.fft.fft2(_opponent_channels(np.asarray(template, dtype=np.float64)), axes=(0, 1)) + expected_template = ( + np.asarray( + [ + template_spectrum[round(float(row)) % template.shape[0], round(float(column)) % template.shape[1]] + for row, column in harmonics + ] + ) + * phase[:, None] + ) + grouped_scores: dict[int, list[float]] = {0: [], 1: []} + for patch_y, patch_x, group in _patch_origins(height, width, patch_size, grid_size): + patch = pixels[patch_y : patch_y + patch_size, patch_x : patch_x + patch_size] + grouped_scores[group].append( + _affine_whitened_patch_score( + patch, + expected_template, + frequencies_y[0], + frequencies_x[0], + origin_y=patch_y, + origin_x=patch_x, + denoise_sigma=denoise_sigma, + ) + ) + return float(np.mean(grouped_scores[0])), float(np.mean(grouped_scores[1])) + + +def score_deskew_bank( + pixels: NDArray[Any], + template: NDArray[Any], + *, + periods: NDArray[Any], + deskew_degrees: NDArray[Any], + patch_size: int = 256, + grid_size: int = 4, + harmonic_count: int = 16, + amplitude_candidate_count: int = 5, + denoise_sigma: float = 1.0, +) -> LatticeScore: + """Select a small-angle deskew operation on selection patches.""" + if deskew_degrees.ndim != 1 or not len(deskew_degrees) or np.any(~np.isfinite(deskew_degrees)): + raise ValueError("deskew angles must be a nonempty finite vector") + harmonics, _weights, _coefficient_units = template_harmonics(template, harmonic_count) + selected_period, selected_angle, selection_coherence = _select_deskew_angle( + pixels, + template, + periods, + deskew_degrees, + patch_size=patch_size, + grid_size=grid_size, + harmonic_count=harmonic_count, + ) + direct_selection, direct_confirmation = _affine_whitened_score( + pixels, + template, + harmonics, + period=selected_period, + deskew_degrees=selected_angle, + patch_size=patch_size, + grid_size=grid_size, + denoise_sigma=denoise_sigma, + ) + score = score_lattice( + _rotate_fixed_canvas(pixels, selected_angle), + template, + periods=periods, + rotations_degrees=np.asarray([0.0]), + patch_size=patch_size, + grid_size=grid_size, + harmonic_count=harmonic_count, + amplitude_candidate_count=amplitude_candidate_count, + denoise_sigma=denoise_sigma, + ) + return replace( + score, + selected_deskew_degrees=selected_angle, + deskew_candidate_count=len(periods) * len(deskew_degrees), + deskew_selection_coherence=selection_coherence, + deskew_direct_selection_match=direct_selection, + deskew_direct_confirmation_match=direct_confirmation, + deskew_direct_joint_match=min(direct_selection, direct_confirmation), + ) + + +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-min", type=click.FloatRange(min=1.0), default=7.5, show_default=True) +@click.option("--period-max", type=click.FloatRange(min=1.0), default=24.5, show_default=True) +@click.option("--period-step", type=click.FloatRange(min=0.01), default=0.1, show_default=True) +@click.option("--rotation-min", type=float, default=-3.0, show_default=True) +@click.option("--rotation-max", type=float, default=3.0, show_default=True) +@click.option("--rotation-step", type=click.FloatRange(min=0.01), default=0.25, show_default=True) +@click.option("--input-angle", type=float, default=0.0, show_default=True) +@click.option("--deskew-search/--no-deskew-search", default=False, show_default=True) +@click.option("--input-scale", type=click.FloatRange(min=0.01), default=1.0, show_default=True) +@click.option("--crop-fraction", type=click.FloatRange(min=0.0, max=0.49), default=0.0, show_default=True) +@click.option("--jpeg-quality", type=click.IntRange(min=1, max=100), default=None) +@click.option("--webp-quality", type=click.IntRange(min=1, max=101), default=None) +@click.option("--input-rotation", type=click.Choice(("0", "90", "180", "270")), default="0", show_default=True) +@click.option( + "--input-flip", + type=click.Choice(("none", "horizontal", "vertical", "both")), + default="none", + show_default=True, +) +@click.option("--orientation-search/--no-orientation-search", default=False, show_default=True) +@click.option("--dihedral-search/--no-dihedral-search", default=False, show_default=True) +@click.option("--registered-period/--no-registered-period", default=False, show_default=True) +@click.option("--same-image-null/--no-same-image-null", default=False, show_default=True) +@click.option("--patch-shift-consensus/--no-patch-shift-consensus", default=False, show_default=True) +@click.option("--opponent-registered/--no-opponent-registered", default=False, show_default=True) +@click.option("--amplitude-candidate-count", type=click.IntRange(min=1), default=5, 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_min: float, + period_max: float, + period_step: float, + rotation_min: float, + rotation_max: float, + rotation_step: float, + input_angle: float, + deskew_search: bool, + input_scale: float, + crop_fraction: float, + jpeg_quality: int | None, + webp_quality: int | None, + input_rotation: str, + input_flip: str, + orientation_search: bool, + dihedral_search: bool, + registered_period: bool, + same_image_null: bool, + patch_shift_consensus: bool, + opponent_registered: bool, + amplitude_candidate_count: int, + report_out: Path, +) -> None: + """Score IMAGES with split-confirmed reciprocal-lattice coherence.""" + logging.basicConfig(level=logging.INFO, format="%(message)s") + if period_max < period_min or rotation_max < rotation_min: + raise click.BadParameter("search maximum must be at least its minimum") + if jpeg_quality is not None and webp_quality is not None: + raise click.UsageError("JPEG and WebP transforms are mutually exclusive") + enabled_geometric_searches = sum((orientation_search, dihedral_search, deskew_search)) + if enabled_geometric_searches > 1: + raise click.UsageError("orientation, dihedral, and deskew search are mutually exclusive") + if registered_period and enabled_geometric_searches: + raise click.UsageError("registered-period scoring cannot be combined with a geometric search") + if same_image_null and not registered_period: + raise click.UsageError("same-image-null scoring requires registered-period scoring") + if patch_shift_consensus and not registered_period: + raise click.UsageError("patch-shift consensus requires registered-period scoring") + periods = np.arange(period_min, period_max + period_step / 2.0, period_step) + rotations = np.arange(rotation_min, rotation_max + rotation_step / 2.0, rotation_step) + template = _load_template(template_path) + rows = [] + for path in images: + registered: RegisteredComponents | None = None + period_null: SameImageNullScore | None = None + patch_consensus: PatchShiftConsensusScore | None = None + opponent_score: OpponentRegisteredScore | None = None + try: + pixels = load_rgb(path) + if input_scale != 1.0: + scaled_width = max(1, round(pixels.shape[1] * input_scale)) + scaled_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, (scaled_width, scaled_height), interpolation=interpolation) + if crop_fraction: + crop_y = round(pixels.shape[0] * crop_fraction) + crop_x = round(pixels.shape[1] * crop_fraction) + pixels = pixels[crop_y:, crop_x:] + if jpeg_quality is not None: + pixels = jpeg_round_trip(pixels, jpeg_quality) + if webp_quality is not None: + pixels = _webp_round_trip(pixels, webp_quality) + if input_angle: + pixels = _rotate_fixed_canvas(pixels, input_angle) + quarter_turns = int(input_rotation) // 90 + if quarter_turns: + pixels = np.rot90(pixels, k=-quarter_turns) + if input_flip in {"horizontal", "both"}: + pixels = np.fliplr(pixels) + if input_flip in {"vertical", "both"}: + pixels = np.flipud(pixels) + image_periods = periods + image_rotations = rotations + if registered_period: + registered = registered_components(pixels, template, 1.0) + image_periods = np.asarray([registered.selected_period]) + image_rotations = np.asarray([0.0]) + if same_image_null: + period_null = score_same_image_period_null( + pixels, + template, + registered.selected_period, + ) + if patch_shift_consensus: + patch_consensus = score_patch_shift_consensus( + pixels, + template, + registered.selected_period, + ) + if opponent_registered: + opponent_score = score_opponent_registered( + pixels, + template, + periods=periods, + ) + if dihedral_search: + scorer = score_dihedral_bank + elif orientation_search: + scorer = score_orientation_bank + elif deskew_search: + score = score_deskew_bank( + pixels, + template, + periods=periods, + deskew_degrees=rotations, + amplitude_candidate_count=amplitude_candidate_count, + ) + scorer = None + else: + scorer = score_lattice + if scorer is not None: + score = scorer( + pixels, + template, + periods=image_periods, + rotations_degrees=image_rotations, + amplitude_candidate_count=amplitude_candidate_count, + ) + except (OSError, ValueError) as error: + rows.append({"path": str(path), "status": "unsupported", "reason": str(error)}) + log.warning("%s: unsupported: %s", path, error) + continue + row: dict[str, Any] = {"path": str(path), "status": "scored", "score": asdict(score)} + if registered is not None: + row["registered"] = {**asdict(registered), "decision_score": registered.decision_score} + row["registered_confirmation_passes"] = registered_confirmation_passes(score) + if period_null is not None: + row["same_image_null"] = asdict(period_null) + if patch_consensus is not None: + row["patch_shift_consensus"] = asdict(patch_consensus) + if opponent_score is not None: + row["opponent_registered"] = { + **asdict(opponent_score), + "decision_score": opponent_score.decision_score, + } + rows.append(row) + log.info( + "%s: period=%.3f rotation=%.3f lattice=%.6f whitened=%.6f template=%.6f", + path, + score.selected_period, + score.selected_rotation_degrees, + score.joint_coherence, + score.joint_whitened_match, + score.canonical_template_score, + ) + report_out.parent.mkdir(parents=True, exist_ok=True) + report_out.write_text( + json.dumps( + { + "schema_version": 13, + "template": str(template_path), + "periods": [float(value) for value in periods], + "rotations_degrees": [float(value) for value in rotations], + "input_angle": input_angle, + "deskew_search": deskew_search, + "input_scale": input_scale, + "crop_fraction": crop_fraction, + "jpeg_quality": jpeg_quality, + "webp_quality": webp_quality, + "input_rotation": int(input_rotation), + "input_flip": input_flip, + "orientation_search": orientation_search, + "dihedral_search": dihedral_search, + "registered_period": registered_period, + "same_image_null": same_image_null, + "patch_shift_consensus": patch_shift_consensus, + "opponent_registered": opponent_registered, + "amplitude_candidate_count": amplitude_candidate_count, + "records": rows, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + log.info("Wrote %d lattice records: %s", len(rows), report_out) + + +if __name__ == "__main__": + main() diff --git a/scripts/synthid_cyclostationary_probe.py b/scripts/synthid_cyclostationary_probe.py new file mode 100644 index 0000000..6e4e9d1 --- /dev/null +++ b/scripts/synthid_cyclostationary_probe.py @@ -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() diff --git a/scripts/synthid_runtime_expert_scores.py b/scripts/synthid_runtime_expert_scores.py index c363cf1..440ee41 100644 --- a/scripts/synthid_runtime_expert_scores.py +++ b/scripts/synthid_runtime_expert_scores.py @@ -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: raise ValueError("pixels must be an RGB uint8 array") 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) fixed = _observation(FIXED_EXPERT_NAME, False, None) large = _observation(LARGE_EXPERT_NAME, False, None) diff --git a/src/remove_ai_watermarks/__init__.py b/src/remove_ai_watermarks/__init__.py index 031a188..dea10f7 100644 --- a/src/remove_ai_watermarks/__init__.py +++ b/src/remove_ai_watermarks/__init__.py @@ -40,6 +40,7 @@ __all__ = [ "BatchSummary", "InvisibleOptions", "MetadataStripIncomplete", + "OpenAIProvenanceError", "OpenAISynthIDDetection", "RemoveAllResult", "SynthIDDetection", @@ -70,7 +71,11 @@ if TYPE_CHECKING: remove_visible, 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.video import ( identify_video, @@ -115,7 +120,7 @@ def __getattr__(name: str) -> object: from remove_ai_watermarks import synthid_detector 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 return getattr(openai_provenance, name) diff --git a/src/remove_ai_watermarks/_synthid_confirmation.py b/src/remove_ai_watermarks/_synthid_confirmation.py new file mode 100644 index 0000000..0ed6cf9 --- /dev/null +++ b/src/remove_ai_watermarks/_synthid_confirmation.py @@ -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, + ) diff --git a/src/remove_ai_watermarks/_synthid_registered.py b/src/remove_ai_watermarks/_synthid_registered.py index 58ecbe3..8b6e4e5 100644 --- a/src/remove_ai_watermarks/_synthid_registered.py +++ b/src/remove_ai_watermarks/_synthid_registered.py @@ -13,6 +13,10 @@ from typing import TYPE_CHECKING, Any import cv2 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 if TYPE_CHECKING: @@ -21,6 +25,9 @@ if TYPE_CHECKING: _PYRAMID_SCALES = (0.75, 1.0, 1.25) _SEARCH_PERIODS = np.linspace(5.0, 32.0, 541, 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 = ( (7.5, 8.5, 0.3770629524888979), (8.5, 10.0, 0.25174716660523494), @@ -33,6 +40,15 @@ _PERIOD_THRESHOLDS = ( (22.0, 24.5, 0.3142958338390489), ) 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) @@ -44,10 +60,11 @@ class RegisteredComponents: selected_period: float spectral_period: float high_band_score: float + confirmation: RegisteredConfirmationComponents | None = None @property - def decision_score(self) -> float: - """Return a statistic that reaches one only when every gate passes.""" + def base_decision_score(self) -> float: + """Return the unchanged registered-v2 decision statistic.""" if self.selected_period != self.spectral_period: return 0.0 return min( @@ -55,6 +72,62 @@ class RegisteredComponents: 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]: 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)) +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( pixels: NDArray[Any], template: NDArray[Any], @@ -286,13 +609,32 @@ def registered_components( base_curve, ) raw_score = float((baseline + quadrant + pyramid) / 3.0) - return RegisteredComponents( + components = RegisteredComponents( raw_score=raw_score, amplitude_threshold=_period_threshold(selected_period), selected_period=selected_period, spectral_period=candidates[0], 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( @@ -302,3 +644,21 @@ def registered_score( ) -> float: """Return the calibrated registered decision statistic.""" 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 diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index 2bdaad2..8420950 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -1323,15 +1323,22 @@ def cmd_video_batch( @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( - "--register-scale", - is_flag=True, - help="Search the slower calibrated range of spatial carrier scales.", + "--register-scale/--fixed-period", + default=None, + help="Force registered production search or the legacy fixed-period diagnostic.", ) -def cmd_detect_synthid(source: Path, as_json: bool, register_scale: bool) -> None: - """Detect the SynthID periodic pixel carrier at calibrated image sizes. +def cmd_detect_synthid(source: Path, as_json: bool, register_scale: bool | None) -> None: + """Detect a generation-pipeline pixel lattice at calibrated image sizes. - A negative result means this detector did not find its supported carrier; it - is not proof that the image contains no SynthID watermark. + EXPERIMENTAL. The supported route for SynthID is signed provenance, which + `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 @@ -1346,18 +1353,28 @@ def cmd_detect_synthid(source: Path, as_json: bool, register_scale: bool) -> Non return _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}") if result.score is not None: console.print(f" Score: {result.score:.6f} (threshold: {result.threshold:.6f})") console.print(f" Detector: {result.detector}") - scale_scope = ( - " Bounded spatial-scale registration was enabled. A negative or\n" - if register_scale - else " Arbitrary spatial resampling was not registered. A negative or\n" - ) + if result.reason is not None: + console.print(f" Reason: {result.reason}") + if register_scale is True: + 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( - " 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 + " unsupported result is not proof that SynthID is absent." ) diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index 2a4324a..2fe5916 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -114,10 +114,12 @@ _SYNTHID_CAVEAT = ( "covers one measured carrier family in a calibrated image-size range; confirm other cases with " "the provider oracle." ) -_SYNTHID_PIXEL_CAVEAT = ( - "The local SynthID pixel result is a positive-only match to one measured periodic carrier family " - "in a calibrated image-size range, not a proprietary payload decode. A negative or unsupported " - "result is not proof of absence." +_PIPELINE_LATTICE_CAVEAT = ( + "EXPERIMENTAL. Signed provenance is the primary route for SynthID; this pixel result is not a " + "watermark at all but a generation-pipeline lattice: it is destroyed by a " + "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." _INVISIBLE_WM_CAVEAT = ( @@ -956,8 +958,14 @@ def _trustmark(image_path: Path) -> str | None: return detect_trustmark(image_path) -def _synthid_pixel_watermark(image_path: Path, decode: _SharedDecode) -> bool: - """Whether the supported positive-only SynthID carrier is detected.""" +def _pipeline_lattice(image_path: Path, decode: _SharedDecode) -> bool: + """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 if not is_available() or (image := decode.get()) is None: @@ -1309,16 +1317,17 @@ def _identify_from_evidence( if platform is None: platform = f"{scheme} (open DWT-DCT watermark)" - # ── Positive-only SynthID periodic carrier ────────────────────── - # This is deliberately separate from C2PA provenance. It survives lossless - # metadata stripping, but covers only one carrier family and a calibrated - # image-size range. - if check_invisible and pixel_path is not None and _synthid_pixel_watermark(pixel_path, decode): - signals.append(Signal("synthid_pixel", "calibrated periodic carrier", "high")) - watermarks.append("SynthID periodic pixel carrier (calibrated image size)") - caveats.append(_SYNTHID_PIXEL_CAVEAT) + # ── Generation-pipeline lattice, experimental ─────────────────── + # Signed provenance above is the primary SynthID route; this is a secondary + # pixel observation and is kept out of the watermark inventory on purpose. This reads a periodic + # lattice anchored at the image origin, which identifies the pipeline that + # produced the pixels; it is not SynthID and not any watermark, so listing + # it beside C2PA watermark assertions would misrepresent both. + if check_invisible and pixel_path is not None and _pipeline_lattice(pixel_path, decode): + signals.append(Signal("pipeline_lattice", "generation-pipeline lattice (experimental)", "medium")) + caveats.append(_PIPELINE_LATTICE_CAVEAT) 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) ─── # The watermark behind Adobe Durable Content Credentials. Decoded locally, @@ -1331,7 +1340,8 @@ def _identify_from_evidence( platform = "Adobe (TrustMark / Content Credentials)" # ── 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) xai_sig = any(s.name == "xai_signature" for s in signals) ai_from_metadata = bool( @@ -1341,6 +1351,7 @@ def _identify_from_evidence( or aigc or local_keys or invisible_wm + or pipeline_lattice or exif_gen or xai_sig ) diff --git a/src/remove_ai_watermarks/openai_provenance.py b/src/remove_ai_watermarks/openai_provenance.py index e78a799..976ed5a 100644 --- a/src/remove_ai_watermarks/openai_provenance.py +++ b/src/remove_ai_watermarks/openai_provenance.py @@ -18,6 +18,7 @@ import importlib import json import logging import tempfile +import time from collections.abc import Callable, Mapping from dataclasses import dataclass from pathlib import Path @@ -30,6 +31,10 @@ OpenAISynthIDStatus = Literal["detected", "not_detected"] DETECTOR_ID = "openai-content-provenance-synthid-v1" INSTALL_HINT = "install the verification extra: uv add 'remove-ai-watermarks[verify]'" 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 = { "JPEG": ("image/jpeg", ".jpg"), "PNG": ("image/png", ".png"), @@ -48,6 +53,10 @@ class OpenAISynthIDDetection: detector: str = DETECTOR_ID ai_metadata_stripped: 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 def detected(self) -> bool: @@ -64,9 +73,34 @@ class OpenAISynthIDDetection: "detector": self.detector, "ai_metadata_stripped": self.ai_metadata_stripped, "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: """True when the optional OpenAI SDK is installed.""" 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: """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") if not isinstance(raw_results, list): raise RuntimeError("OpenAI Content Provenance response has no results list") results = cast("list[Any]", raw_results) synthid_entries: list[Mapping[str, Any]] = [] for raw_entry in results: - if isinstance(raw_entry, Mapping): - entry = cast("Mapping[str, Any]", raw_entry) - if entry.get("type") == "synthid": - synthid_entries.append(entry) + if not isinstance(raw_entry, Mapping): + raise RuntimeError("OpenAI Content Provenance response has an invalid result entry") + entry = cast("Mapping[str, Any]", raw_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: 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(): raise RuntimeError(f"OpenAI SynthID verification needs the OpenAI SDK; {INSTALL_HINT}") openai_module = importlib.import_module("openai") - client_factory = cast("Callable[[], Any]", openai_module.OpenAI) + client_factory = cast("Callable[..., Any]", openai_module.OpenAI) try: - client = client_factory() + client = client_factory( + timeout=REQUEST_TIMEOUT_SECONDS, + max_retries=MAX_AUTOMATIC_RETRIES, + ) except Exception as exc: raise RuntimeError(f"could not initialize the OpenAI client: {exc}") from exc if not hasattr(client, "content_provenance_checks"): @@ -158,17 +201,74 @@ def _default_client() -> Any: return client -def _request_error(exc: Exception) -> RuntimeError: - status_code = getattr(exc, "status_code", None) - if status_code == 400: +def _string_attribute(value: Any) -> str | None: + return value if isinstance(value, str) and value else None + + +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" + 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: detail = "the OpenAI organization does not have Content Provenance API access" elif status_code == 429: 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: 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( @@ -215,18 +315,37 @@ def verify_openai_synthid( "filename": sanitized.name, "media_type": media_type, "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)) + started_at = time.monotonic() try: with stripped.open("rb") as upload: response = api_client.content_provenance_checks.create( file=(sanitized.name, upload, media_type), + timeout=REQUEST_TIMEOUT_SECONDS, ) 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 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) diff --git a/src/remove_ai_watermarks/synthid_detector.py b/src/remove_ai_watermarks/synthid_detector.py index 220a80a..e3a174c 100644 --- a/src/remove_ai_watermarks/synthid_detector.py +++ b/src/remove_ai_watermarks/synthid_detector.py @@ -2,8 +2,8 @@ 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 -carrier. A negative result means only that the selected detector did not find -it; image sizes outside that mode's calibrated range are reported separately. +carrier. An indeterminate result means only that the selected detector did not +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 package's metadata-only paths stay dependency-light. @@ -22,10 +22,12 @@ from typing import TYPE_CHECKING, Any, Literal if TYPE_CHECKING: from numpy.typing import NDArray -SynthIDDetectionStatus = Literal["detected", "not_detected", "unsupported"] +SynthIDDetectionStatus = Literal["detected", "indeterminate", "unsupported"] 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" MODEL_FILENAME = "synthid_periodic_tile_2048_v1.npz" # 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 REGISTERED_MIN_SUPPORTED_PIXELS = 250_000 REGISTERED_MAX_SUPPORTED_PIXELS = 10_000_000 -REGISTERED_MIN_SIDE = 64 -# The registered score is the minimum normalized margin across its amplitude, -# spectral-candidate, and high-frequency agreement gates. +# Registered-v3 can confirm a positive only when both disjoint checkerboard +# groups contain a complete frozen 256-pixel patch. Narrower geometries need a +# 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 +# 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 # with an any-window signed opponent mid-band gate. The one vulnerable portrait # 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) 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 width: int @@ -70,13 +95,23 @@ class SynthIDDetection: score: float | None threshold: float 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 def detected(self) -> bool: """Whether the supported carrier crossed its frozen threshold.""" 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 { "status": self.status, @@ -85,6 +120,14 @@ class SynthIDDetection: "score": self.score, "threshold": self.threshold, "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 +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( pixels: NDArray[Any], template: NDArray[Any], @@ -382,13 +443,16 @@ def detect_synthid( image_path: str | Path, *, image: NDArray[Any] | None = None, - register_scale: bool = False, + register_scale: bool | None = None, ) -> SynthIDDetection: """Detect the supported periodic carrier in IMAGE_PATH. - ``not_detected`` is not a clean-image guarantee. It means only that the - frozen periodic carrier did not cross its calibrated threshold. Set - ``register_scale`` for the slower, separately calibrated resize search. + ``indeterminate`` means that the frozen periodic carrier did not cross its + calibrated threshold; it is not a clean-image guarantee. The default + 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) if image is None: @@ -397,19 +461,28 @@ def detect_synthid( if image.ndim != 3 or image.shape[2] != 3: raise ValueError("image must be a three-channel BGR array") height, width = image.shape[:2] - large_mode = not register_scale and width * height > LARGE_MIN_PIXELS - if register_scale: + large_mode = register_scale is not True and width * height > LARGE_MIN_PIXELS + 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) threshold = REGISTERED_THRESHOLD 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: geometry_supported = _large_geometry_supported(width, height) threshold = LARGE_THRESHOLD 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: geometry_supported = _geometry_supported(width, height) threshold = TILE_THRESHOLD detector_id = DETECTOR_ID + unsupported_reason = "fixed-v2 requires 1,000,000-18,000,000 decoded pixels" if not geometry_supported: return SynthIDDetection( status="unsupported", @@ -418,6 +491,7 @@ def detect_synthid( score=None, threshold=threshold, detector=detector_id, + reason=unsupported_reason, ) if not is_available(): 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) if pixels.shape != (height, width, 3): raise RuntimeError("decoded image geometry does not match its header") - if register_scale: - from remove_ai_watermarks._synthid_registered import registered_score + if registered_mode: + from remove_ai_watermarks._synthid_registered import ( + fine_opponent_registered_score, + opponent_registered_score, + registered_score, + ) 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: score = large_image_components(pixels, template, sigma).decision_score else: score, _folded = folded_template_score(pixels, template, sigma) + detected = score >= threshold return SynthIDDetection( - status="detected" if score >= threshold else "not_detected", + status="detected" if detected else "indeterminate", width=width, height=height, score=score, threshold=threshold, detector=detector_id, + reason=None if detected else "the selected carrier expert did not cross every calibrated gate", ) diff --git a/tests/test_api.py b/tests/test_api.py index 35802ef..b3a2bb4 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -25,6 +25,7 @@ class TestTopLevelExports: assert raiw.detect_synthid is synthid_detector.detect_synthid assert raiw.SynthIDDetection is synthid_detector.SynthIDDetection 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 def test_unknown_attribute_raises(self): diff --git a/tests/test_cli.py b/tests/test_cli.py index d842c88..0e36137 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -742,6 +742,7 @@ class TestDetectSynthIDCommand: assert result.exit_code == 0 assert "calibrated image sizes" 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): 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 "Bounded spatial-scale registration was enabled" in result.output + assert "Bounded spatial-scale registration was explicitly enabled" in result.output class TestVerifyOpenAISynthIDCommand: diff --git a/tests/test_identify.py b/tests/test_identify.py index 0feffe1..8d76268 100644 --- a/tests/test_identify.py +++ b/tests/test_identify.py @@ -886,30 +886,38 @@ class TestIdentifyVisibleTextMarks: # ── Caveats and serialization ─────────────────────────────────────── -class TestSynthIDPixelCarrier: - def test_positive_pixel_carrier_is_high_confidence_ai_evidence(self, tmp_clean_png: Path): +class TestGenerationPipelineLattice: + 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 ( 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), ): report = identify(tmp_clean_png, check_visible=False, check_invisible=True) assert report.is_ai_generated is True - assert report.confidence == "high" - assert any(signal.name == "synthid_pixel" for signal in report.signals) - assert any("positive-only" in caveat for caveat in report.caveats) + assert any(signal.name == "pipeline_lattice" for signal in report.signals) + assert not any("synthid" in watermark.lower() for watermark in report.watermarks) + 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 ( 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), ): report = identify(tmp_clean_png, check_visible=False, check_invisible=True) assert report.is_ai_generated is None - assert not any(signal.name == "synthid_pixel" for signal in report.signals) + 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") diff --git a/tests/test_openai_provenance.py b/tests/test_openai_provenance.py index 667e67d..77f9ee6 100644 --- a/tests/test_openai_provenance.py +++ b/tests/test_openai_provenance.py @@ -20,7 +20,8 @@ class _Checks: self.response = response 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 self.calls.append((filename, stream.read(), media_type)) 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.api_created_at == 1_778_000_000 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: @@ -109,6 +112,28 @@ def test_sdk_model_response_is_normalized(tmp_clean_png: Path) -> None: 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( ("image_format", "suffix", "media_type"), [("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"}]]) 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"): _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: client, _checks = _client( { + "object": "content_provenance_check", "results": [ {"type": "synthid", "outcome": "detected"}, {"type": "synthid", "outcome": "not_detected"}, - ] + ], } ) @@ -223,6 +249,28 @@ def test_upload_limit_is_checked_after_sanitizing( 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( monkeypatch: pytest.MonkeyPatch, tmp_clean_png: Path, @@ -237,7 +285,7 @@ def test_client_configuration_error_is_actionable( monkeypatch: pytest.MonkeyPatch, tmp_clean_png: Path, ) -> None: - def fail() -> None: + def fail(**_kwargs: Any) -> None: raise ValueError("OPENAI_API_KEY is missing") monkeypatch.setattr(provenance, "is_available", lambda: True) @@ -247,9 +295,36 @@ def test_client_configuration_error_is_actionable( _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( ("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( tmp_clean_png: Path, @@ -263,10 +338,92 @@ def test_documented_api_errors_are_actionable( error.status_code = status_code # type: ignore[attr-defined] 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 client = SimpleNamespace(content_provenance_checks=FailingChecks()) with pytest.raises(RuntimeError, match=message): _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 diff --git a/tests/test_synthid_affine_lattice_probe.py b/tests/test_synthid_affine_lattice_probe.py new file mode 100644 index 0000000..37edad6 --- /dev/null +++ b/tests/test_synthid_affine_lattice_probe.py @@ -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")) diff --git a/tests/test_synthid_confirmation.py b/tests/test_synthid_confirmation.py new file mode 100644 index 0000000..f15b641 --- /dev/null +++ b/tests/test_synthid_confirmation.py @@ -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 diff --git a/tests/test_synthid_cyclostationary_probe.py b/tests/test_synthid_cyclostationary_probe.py new file mode 100644 index 0000000..2dd1eb7 --- /dev/null +++ b/tests/test_synthid_cyclostationary_probe.py @@ -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 diff --git a/tests/test_synthid_detector.py b/tests/test_synthid_detector.py index 8dd952c..2d79b91 100644 --- a/tests/test_synthid_detector.py +++ b/tests/test_synthid_detector.py @@ -47,6 +47,63 @@ def registered_scale_positive(tmp_path_factory: pytest.TempPathFactory) -> 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: 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), (4000, 2500, True), - (64, 3907, True), + (256, 977, True), (499, 500, False), (4001, 2500, False), + (255, 981, False), + (64, 3907, 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 +@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( ("width", "height", "supported"), [ @@ -162,7 +257,7 @@ def test_large_red_green_gate_mutation_changes_the_real_verdict( assert baseline.status == "detected" 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: @@ -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: positive, _negative = supported_images - result = detector.detect_synthid(positive) + result = detector.detect_synthid(positive, register_scale=False) assert result.status == "detected" 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" 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.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: + fixed = detector.detect_synthid(registered_scale_positive, register_scale=False) default = detector.detect_synthid(registered_scale_positive) 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.score is not None 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 +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( monkeypatch: pytest.MonkeyPatch, 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) - assert mutated.status == "not_detected" + assert mutated.status == "indeterminate" 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) - assert mutated.status == "not_detected" + assert mutated.status == "indeterminate" 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 - matching = RegisteredComponents(0.5, 0.25, 12.8, 12.8, 0.15) - mismatching = RegisteredComponents(0.5, 0.25, 12.8, 12.9, 0.15) + confirmation = RegisteredConfirmationComponents(12.8, 0.5, 0.2, 0.5, 8, 8) + 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 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( @@ -303,15 +566,40 @@ def test_registered_high_band_mutation_changes_the_real_verdict( 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: _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.score == pytest.approx(0.0) @@ -321,16 +609,16 @@ def test_threshold_mutation_changes_the_real_verdict( supported_images: tuple[Path, Path], ) -> None: 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.status == "detected" mutated_threshold = float(np.nextafter(baseline.score, np.inf)) assert mutated_threshold > baseline.score monkeypatch.setattr(detector, "TILE_THRESHOLD", mutated_threshold) - mutated = detector.detect_synthid(positive) + mutated = detector.detect_synthid(positive, register_scale=False) - assert mutated.status == "not_detected" + assert mutated.status == "indeterminate" 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" 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.score is None 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: @@ -352,8 +643,8 @@ def test_shared_bgr_decode_matches_file_decode(supported_images: tuple[Path, Pat bgr = cv2.imread(str(positive)) assert bgr is not None - from_file = detector.detect_synthid(positive) - from_array = detector.detect_synthid(positive, image=bgr) + from_file = detector.detect_synthid(positive, register_scale=False) + from_array = detector.detect_synthid(positive, image=bgr, register_scale=False) assert from_array == from_file @@ -366,7 +657,7 @@ def test_supported_geometry_requires_pixel_dependencies( monkeypatch.setattr(detector, "is_available", lambda: False) 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: @@ -435,3 +726,53 @@ def test_fold_rejects_tile_larger_than_image() -> None: tile_width=16, 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