Files
remove-ai-watermarks/scripts/synthid_runtime/synthid_detector.py
T
Victor Kuznetsov 07b07634cf Merge the docs/arxiv-paper-review research line into main (#95)
* Add rigorous SynthID research and evaluation harness

* Record SynthID cross-epoch validation results

* Reset SynthID detector hypotheses to paired training

* Add paired wavelet and spectral SynthID probes

* Record closed-corpus SynthID detector ablations

* Add registered SynthID phase probing

* Add periodic SynthID tile probing

* Add calibrated SynthID pixel detector

* Harden scale-registered SynthID detection

* Record SynthID phase and channel rescue findings

* Add production SynthID routing and OpenAI verification

* 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.

* Record pipeline-lattice reframing and registered-v3 phase lock

Document that the pixel route detects an origin-anchored lattice rather than the watermark, and record the registered-v3 two-pixel phase-lock measurement: a 2px diagonal crop killed all 36 tested detections (28 foreign-generator, 8 Google) with recovery only at offsets that are multiples of four. Update README, CLI, Python API, supported-signals, known-limitations, module-internals, the SynthID reference, and the detector research plan.

* Split SynthID campaign docs by detector, classifiers, and removal

Keep Model 1 as a research AI-versus-camera result outside the library.
Retire the mixed oracle-ladders page to a routing hub and mark the plan
file as a chronological archive.

* Keep 2026-08-22 SynthID campaign results in research docs only

Drop Bayer, Model 1, and lattice re-check numbers from user guides and
module internals. The library surface is unchanged.

* Unplug lattice detector from package, expand research docs

* Remove lattice detector sources from the public package

* Document SynthID GitHub sweep findings

* Document general AI classifier sweep

* Document modern-negative taxonomy audit and classifier doc split

Split general AI-classifier research into ai-generated-image-classifiers.md
and record the modern-negative campaign: Unsplash/Openverse date-clean cells,
contamination controls, quarter-hard paired result, closed linear veto and
taxonomy-continuation arms.

pre-commit: 1) maintain.sh - exit 1, known uv-secure lightning advisory with no upstream fix (same triage as main); core checks separately green (ruff, format, pyright, 1564 tests); 2) /simplify - docs-only single pass, no findings; 3) docs sync - split references updated everywhere, none stale; 4) CLAUDE.md - routing line already updated, compliant

* Record wild-AI audit, Meta provider class, and stock-negative expansion

The wild vendor-flagged AI cell (300 stratified rows) puts Model 1 recall at
69.7% on unknown-renderer stock AI; the stock-negative harvest triples the
modern fashion/product cells and confirms the combined-pool veto control;
the Meta muse-image corpus doubles to 132 rows with its margin sweep; a
per-channel cv2 reference fixes the latent fold test under cv2 4.10.0.

pre-commit: 1) maintain.sh - exit 1, known uv-secure lightning advisory with no upstream fix; core checks separately green (ruff, format, pyright, 1665 tests); 2) /simplify - docs-only single pass, no findings; 3) docs sync - new run references point at the gitignored research store, none stale; 4) CLAUDE.md - compact, no changes needed

* Close representation-change campaign: both candidates falsified

Pre-registered gates (AI-test >=1772, fresh OI <=50, Kodak 0, FLUX >=288,
EvalGEN >=95) rejected DINOv2-L ridge head (1427/1847, 162/3000, 1/24, 29/300,
5/100) and the paired-reconstruction FFT tower (14/1847, 12/3000, 0/24, 0/300,
0/100; contract learned at 0.949 pairwise separation but no transfer to
production generators). Campaign verdict: general classifier beyond local
scale as pursued; product stays on documented remainder.

pre-commit: 1) maintain.sh - exit 1, known uv-secure lightning advisory with no upstream fix; core checks green minutes ago (ruff, format, pyright, 1665 tests); 2) /simplify - docs-only single pass, no findings; 3) docs sync - verdict mirrored in data/research/REPRESENTATION-CAMPAIGN.md, no stale refs; 4) CLAUDE.md - no changes

* Record full failure map, H2 router falsification, and H3 process detector closure

20 measured negative domains with batch-2 (memes, whiteboards, tickets, book
pages). H2 domain router falsified: 97.9% of AI-test looks structured, every
family 94-100%. H3 process detector on 369 contrastive pairs from three
generators (OpenAI gpt-image-1-mini, Meta muse-image-1.0, Gemini flash-image)
achieves 0.909 pairwise AUC on training pairs but transfers at AUC 0.55-0.60
to production AI; human receipts score higher than any AI family. The
generator-specific spectral trace does not generalize across three generators,
closing the process-trace family in its current form.

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (ruff, format, pyright, 1665 tests); 2) /simplify - docs + research scripts, single pass, no findings; 3) docs sync - all artifacts in data/research/, no stale refs; 4) CLAUDE.md - no changes

* Record H4-H6 research hypotheses: MLP probe, spectral invariant, TTA voting

H4 (MLP on frozen CLIP): 98.6% AI-test recall but 15.8% photo FPR — same
trade-off curve as quarter-hard. H5 (spectral invariant from CVPR 2025):
2.4% recall, the 1/f^alpha profile does not discriminate current generators.
H6 (TTA voting): fixes museum FP but not documents/UI/fashion, loses 9% recall.
All three falsified against frozen gates; ten total closed hypotheses now
document the CLIP-L representation limit from every angle.

Also records the provider cascade across all 20 domains (69.8% stray on
documents mirrors the detector failure map) and the full Y-phase campaign
PSNR data awaiting oracle rate-limit reset.

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (ruff, format, pyright, 1665 tests); 2) /simplify - docs-only single pass, no findings; 3) docs sync - all artifacts in data/research/, no stale refs; 4) CLAUDE.md - no changes

* Fix Y-phase frequency convention, record H4-H6 and campaign results

The initial Y-phase campaign targeted FFT bins 16-32 instead of spatial
periods 16-32 px (1/32 to 1/16 cycles/px). After the fix, the full-octave
Y-phase scramble reproduces the known kill (not_detected at 20.5 dB on s1
at native 1122x1402). Partial sub-bands are visually invisible (42.5 dB)
but stay detected: the mark is distributed across the full octave, not
concentrated. Point intervention is not viable within this frequency band.

H4 MLP probe: 98.6% recall, 15.8% FPR (same trade-off wall).
H5 spectral invariant: 2.4% recall (1/f^alpha does not discriminate).
H6 TTA voting: fixes museum FP but not documents/UI/fashion.

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (1665 tests, verified minutes ago); 2) /simplify - docs-only; 3) docs sync - all artifacts synced; 4) CLAUDE.md - no changes

* Add TC260 fifth class and per-class margin calibration to provider cascade

Five-head cascade (openai/google/tc260/meta/no_ai): openai 68.8%, google
76.6%, tc260 63.8%, meta 71.8% mean recall at uniform margin 0.50.
Per-class margins calibrated against 1.67% photo budget: openai 0.31,
google 0.29, tc260 0.39, meta 0.55. TC260 test recall 71.4% at calibrated
margins on 196 held-out images. Chinese-AIGC is a viable pixel class.

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (verified within the hour); 2) /simplify - research script, single pass; 3) docs sync - artifacts in data/research/; 4) CLAUDE.md - no changes

* Record universal detector v3-v8: diverse training shifts the curve

Fresh MLP on frozen CLIP embeddings with 49 negative domains (12,258 images)
and 5,043 AI positives achieves 93.0% AI-test recall with 39/49 domains at
zero false positives. Digital art FP dropped from 99.3% (Model 1) to 24.8%,
fashion from 62-71% to 12%, UI from 99.3% to 12%. Photo FPR 0.7%. The
improvement comes from training data diversity on frozen embeddings, not
representation change (v5 fine-tune proved this by undoing the gains).
Also adds .local-eval to ruff exclude so the gate covers only tracked files.

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (ruff, format, pyright, 1731 tests); 2) /simplify - docs + research; 3) docs sync - all artifacts in data/research/; 4) CLAUDE.md - no changes

* Add three-tier ensemble detector: DEFINITELY/POSSIBLY/LIKELY_HUMAN

Combines Model 1 and v8 into calibrated confidence tiers. DEFINITELY_AI
(both models agree) achieves 79.6% recall at 0.50% photo FPR with 46/49
domains at zero FP. The union reaches 93.3% recall at 1.80% photo FPR
with Kodak 0/24. First detector working across ALL content types with
per-tier calibrated error rates.

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (ruff, format, pyright, 1731 tests); 2) /simplify - docs + ensemble; 3) docs sync - artifacts in data/research/; 4) CLAUDE.md - no changes

* Add provider v2: diversity training eliminates document leakage

Same diversity-training insight applied to the provider cascade. Adding
3,769 structured negative features to training drops document/receipt
leakage from 69.8% to 5.1%, UI from 10% to 4.3%, fashion from 5-15% to
0-3%. AI recall improves: OpenAI 84% (was 75%), Google 88.8% (was 79%).
Photo leakage 1.4%. Worst regression: memes 47.9% (was 27%).

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (ruff, format, pyright, 1731 tests); 2) /simplify - provider v2; 3) docs sync - artifacts in data/research/; 4) CLAUDE.md - no changes

* Add universal v11: full taxonomy, 136 domains, 124 at zero FP

Master data plan mapped 148 visual domains. Massive collection (3,343
images, 73 domains from Openverse) plus gap-filler (724 images from
Safebooru anime/manga) expanded training to 19,768 images across 136
negative domains. Universal v11 (MLP-512 + Model 1 ensemble):
DEFINITELY 90.3% recall at 0.50% photo FPR, ANY 95.6% recall,
124/134 domains at exactly 0% FP, worst FP 13.8% (digital art).

New domains opened: anime, manga, chibi, mecha, vintage photos,
banknotes, newspapers, magazines, movie posters, antique maps,
watercolor/oil originals, patterns, cosmetics, real estate, 3D
renders, and 60+ more.

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (ruff, format, pyright, 1731 tests); 2) /simplify - docs + research; 3) docs sync - artifacts in data/research/; 4) CLAUDE.md - no changes

* Confirm representation limit: v12 fine-tune destroys structured FP despite 6x data

Fine-tuning CLIP tower on 19,518 images achieves 95.1% standalone recall
but returns to 100% FP on art/UI and 14/59 zero-FP domains (vs 124/134
for frozen v11). This is a representation property, not a data issue.
Frozen CLIP + MLP (v11) is the only working architecture. Also records
provider v3 trained on all negative domains.

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (ruff, format, pyright, 1731 tests); 2) /simplify - docs + research; 3) docs sync - artifacts in data/research/; 4) CLAUDE.md - no changes

* Document v13 plateau: 128/136 zero FP, frozen-CLIP ceiling confirmed

v13 with top-plan expansion (1,169 new images) improves domain coverage
to 128/136 at zero FP and reduces fashion FP to 5.3%, but DEFINITELY
recall plateaus at 89.6% and art FP at 15.9%. The frozen CLIP + MLP
architecture has reached its ceiling.

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (verified minutes ago, 1731 tests); 2) /simplify - docs-only; 3) docs sync - v13 recorded; 4) CLAUDE.md - no changes

* Document the 2026-08-31 photo-classifier freeze.

Record the AI-versus-camera contract, Meta v3 hold-out, Flickr UI
replacement, and that receipts stay out of the supported domain.

* Scrub Spaces catalog details from classifier research docs

* Record receipt layout-gate probe for the universal detector
2026-09-01 11:37:32 -07:00

543 lines
22 KiB
Python

"""Detect the confirmed periodic SynthID image carrier at calibrated image sizes.
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. An indeterminate result means only that the selected detector did not
find it; image sizes outside that mode's calibrated range are reported separately.
Research runtime. Not exported by ``remove_ai_watermarks``. Needs numpy and OpenCV.
"""
# 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
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING:
from numpy.typing import NDArray
SynthIDDetectionStatus = Literal["detected", "indeterminate", "unsupported"]
DETECTOR_ID = "synthid-periodic-tile-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
# resized. The supported pixel-count interval is the separately challenged domain:
# below it too few repetitions make the positive-only statistic unreliable, and
# above it resource use and specificity have not been calibrated.
MODEL_WIDTH = 2048
MODEL_HEIGHT = 2048
MIN_SUPPORTED_PIXELS = 1_000_000
MAX_SUPPORTED_PIXELS = 18_000_000
TILE_THRESHOLD = 0.17357069773071196
REGISTERED_MIN_SUPPORTED_PIXELS = 250_000
REGISTERED_MAX_SUPPORTED_PIXELS = 10_000_000
# 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.
LARGE_THRESHOLD = 1.0
LARGE_MIN_PIXELS = 10_000_000
LARGE_MAX_PIXELS = 18_000_000
LARGE_WINDOW = 2_048
LARGE_PHASE = 16
LARGE_FIXED_SCORE_MIN = 0.14
LARGE_RED_GREEN_SPATIAL_MIN = 0.90
LARGE_BLUE_YELLOW_SPATIAL_MIN = 0.70
LARGE_BLUE_YELLOW_MID_BAND_MAX = -0.15
LARGE_PORTRAIT_GEOMETRY = (3_072, 5_504)
LARGE_PORTRAIT_GREEN_MID_BAND_MAX = 0.06
INSTALL_HINT = "install numpy and opencv-python-headless"
@dataclass(frozen=True)
class SynthIDDetection:
"""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. The measurement is in
``docs/synthid-detector-research.md`` and ``docs/synthid-classifiers.md``.
"""
status: SynthIDDetectionStatus
width: int
height: int
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 | bool | None]:
"""Return a JSON-safe result without a local file path."""
return {
"status": self.status,
"width": self.width,
"height": self.height,
"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,
}
@dataclass(frozen=True)
class LargeImageComponents:
"""Auditable margins for the calibrated large-image carrier branch."""
width: int
height: int
minimum_fixed_score: float
minimum_red_green_spatial: float
minimum_blue_yellow_spatial: float
minimum_blue_yellow_mid_band: float
maximum_green_mid_band: float
@property
def decision_score(self) -> float:
"""Return the minimum normalized gate margin; one is the boundary."""
margins = [
self.minimum_fixed_score / LARGE_FIXED_SCORE_MIN,
self.minimum_red_green_spatial / LARGE_RED_GREEN_SPATIAL_MIN,
self.minimum_blue_yellow_spatial / LARGE_BLUE_YELLOW_SPATIAL_MIN,
self.minimum_blue_yellow_mid_band / LARGE_BLUE_YELLOW_MID_BAND_MAX,
]
if (self.width, self.height) == LARGE_PORTRAIT_GEOMETRY:
margins.append(1.0 + LARGE_PORTRAIT_GREEN_MID_BAND_MAX - self.maximum_green_mid_band)
return min(margins)
def is_available() -> bool:
"""True when numpy and OpenCV import."""
import importlib.util
return importlib.util.find_spec("cv2") is not None and importlib.util.find_spec("numpy") is not None
@lru_cache(maxsize=1)
def _load_template() -> tuple[NDArray[Any], float, int, int, int, int]:
"""Load and validate the bundled pickle-free detector model."""
import numpy as np
model_path = Path(__file__).resolve().parent / MODEL_FILENAME
with np.load(model_path, allow_pickle=False) as artifact:
if int(artifact["format_version"]) != 1:
raise RuntimeError("unsupported SynthID detector model format")
height = int(artifact["height"])
width = int(artifact["width"])
tile_height = int(artifact["tile_height"])
tile_width = int(artifact["tile_width"])
denoise_sigma = float(artifact["denoise_sigma"])
template = np.asarray(artifact["template"], dtype=np.float64)
if not _geometry_supported(width, height):
raise RuntimeError("bundled SynthID detector has unexpected geometry")
if template.shape != (tile_height, tile_width, 3):
raise RuntimeError("bundled SynthID detector has an invalid template shape")
if not np.all(np.isfinite(template)) or not np.isclose(np.linalg.norm(template), 1.0):
raise RuntimeError("bundled SynthID detector has an invalid template")
if not np.isfinite(denoise_sigma) or denoise_sigma <= 0.0:
raise RuntimeError("bundled SynthID detector has an invalid denoise sigma")
return template, denoise_sigma, height, width, tile_height, tile_width
def fold_residual_template(
pixels: NDArray[Any],
*,
tile_height: int,
tile_width: int,
denoise_sigma: float,
) -> NDArray[Any]:
"""Estimate a zero-mean periodic residual template by modulo folding."""
import cv2
import numpy as np
if pixels.ndim != 3 or pixels.shape[2] != 3:
raise ValueError("pixels must have shape (height, width, 3)")
if tile_height < 1 or tile_width < 1 or denoise_sigma <= 0.0:
raise ValueError("tile dimensions and denoise sigma must be positive")
height, width = pixels.shape[:2]
if height < tile_height or width < tile_width:
raise ValueError("image geometry must be at least as large as the tile geometry")
divisible = height % tile_height == 0 and width % tile_width == 0
full_height = height - height % tile_height
full_width = width - width % tile_width
repeats_y = full_height // tile_height
repeats_x = full_width // tile_width
remaining_height = height - full_height
remaining_width = width - full_width
counts = np.full((tile_height, tile_width), repeats_y * repeats_x, dtype=np.int64)
counts[:remaining_height] += repeats_x
counts[:, :remaining_width] += repeats_y
counts[:remaining_height, :remaining_width] += 1
# OpenCV filters channels independently. Processing one channel at a time
# keeps the 18 MP upper bound from requiring two full three-channel float32
# buffers in addition to the decoded image.
folded = np.empty((tile_height, tile_width, 3), dtype=np.float64)
for channel in range(3):
residual = pixels[:, :, channel].astype(np.float32)
residual -= cv2.GaussianBlur(
residual,
(0, 0),
sigmaX=denoise_sigma,
sigmaY=denoise_sigma,
borderType=cv2.BORDER_REFLECT_101,
)
if divisible:
folded[:, :, channel] = residual.reshape(
repeats_y,
tile_height,
repeats_x,
tile_width,
).mean(axis=(0, 2), dtype=np.float64)
continue
folded_sum = (
residual[:full_height, :full_width]
.reshape(
repeats_y,
tile_height,
repeats_x,
tile_width,
)
.sum(axis=(0, 2), dtype=np.float64)
)
if remaining_height:
bottom = residual[full_height:, :full_width].reshape(
remaining_height,
repeats_x,
tile_width,
)
folded_sum[:remaining_height] += bottom.sum(axis=1, dtype=np.float64)
if remaining_width:
right = residual[:full_height, full_width:].reshape(
repeats_y,
tile_height,
remaining_width,
)
folded_sum[:, :remaining_width] += right.sum(axis=0, dtype=np.float64)
if remaining_height and remaining_width:
folded_sum[:remaining_height, :remaining_width] += residual[
full_height:,
full_width:,
]
folded[:, :, channel] = folded_sum / counts
return folded - np.mean(folded, axis=(0, 1), keepdims=True)
def unit_tile(tile: NDArray[Any]) -> tuple[NDArray[Any], float]:
"""Return TILE normalized by its L2 norm and the original norm."""
import numpy as np
norm = float(np.linalg.norm(tile))
if norm == 0.0:
return np.zeros_like(tile, dtype=np.float64), 0.0
return np.asarray(tile, dtype=np.float64) / norm, norm
def _image_size(image_path: Path) -> tuple[int, int]:
from PIL import Image
with Image.open(image_path) as image:
return image.size
def _geometry_supported(width: int, height: int) -> bool:
"""Whether the image has a calibrated number of periodic-tile samples."""
pixels = width * height
return MIN_SUPPORTED_PIXELS <= pixels <= MAX_SUPPORTED_PIXELS
def _registered_geometry_supported(width: int, height: int) -> bool:
"""Whether scale registration was challenged at this decoded size."""
pixels = width * height
return (
min(width, height) >= REGISTERED_MIN_SIDE
and REGISTERED_MIN_SUPPORTED_PIXELS <= pixels <= REGISTERED_MAX_SUPPORTED_PIXELS
)
def _large_geometry_supported(width: int, height: int) -> bool:
"""Whether fixed phase-aligned windows cover the calibrated large range."""
pixels = width * height
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],
denoise_sigma: float,
) -> tuple[float, NDArray[Any]]:
"""Fold PIXELS at the model geometry and score the normalized tile."""
tile_height, tile_width = template.shape[:2]
folded = fold_residual_template(
pixels,
tile_height=tile_height,
tile_width=tile_width,
denoise_sigma=denoise_sigma,
)
normalized, _norm = unit_tile(folded)
return float((template * normalized).sum()), folded
def _large_window_starts(length: int) -> tuple[int, ...]:
"""Return phase-aligned starts that cover both edges without resampling."""
if length < LARGE_WINDOW:
raise ValueError("large-image sides must be at least 2,048 pixels")
last = ((length - LARGE_WINDOW) // LARGE_PHASE) * LARGE_PHASE
starts = list(range(0, last + 1, LARGE_WINDOW))
if starts[-1] != last:
starts.append(last)
return tuple(starts)
def _correlation(left: NDArray[Any], right: NDArray[Any]) -> float:
import numpy as np
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 _large_window_components(
folded: NDArray[Any],
template: NDArray[Any],
) -> tuple[float, float, float, float]:
"""Measure the four color-phase features used by the large branch."""
import numpy as np
folded_red_green = folded[:, :, 0] - folded[:, :, 1]
template_red_green = template[:, :, 0] - template[:, :, 1]
folded_blue_yellow = folded[:, :, 2] - 0.5 * (folded[:, :, 0] + folded[:, :, 1])
template_blue_yellow = template[:, :, 2] - 0.5 * (template[:, :, 0] + template[:, :, 1])
height, width = folded.shape[:2]
y_coordinates = np.minimum(np.arange(height), height - np.arange(height))
x_coordinates = np.minimum(np.arange(width), width - np.arange(width))
radius = np.sqrt(y_coordinates[:, None] ** 2 + x_coordinates[None, :] ** 2)
mid_band = (radius >= 4.5) & (radius < 6.5)
blue_yellow_mid = _correlation(
np.fft.fft2(folded_blue_yellow)[mid_band],
np.fft.fft2(template_blue_yellow)[mid_band],
)
green_mid = _correlation(
np.fft.fft2(folded[:, :, 1])[mid_band],
np.fft.fft2(template[:, :, 1])[mid_band],
)
return (
_correlation(folded_red_green, template_red_green),
_correlation(folded_blue_yellow, template_blue_yellow),
blue_yellow_mid,
green_mid,
)
def large_image_components(
pixels: NDArray[Any],
template: NDArray[Any],
denoise_sigma: float,
) -> LargeImageComponents:
"""Score all phase-aligned 2,048-pixel windows of one large RGB image."""
if pixels.ndim != 3 or pixels.shape[2] != 3:
raise ValueError("pixels must have shape (height, width, 3)")
height, width = pixels.shape[:2]
if not _large_geometry_supported(width, height):
raise ValueError("image geometry is outside the calibrated large-image range")
minimum_fixed = float("inf")
minimum_red_green = float("inf")
minimum_blue_yellow = float("inf")
minimum_blue_yellow_mid = float("inf")
maximum_green_mid = -float("inf")
for y in _large_window_starts(height):
for x in _large_window_starts(width):
window = pixels[y : y + LARGE_WINDOW, x : x + LARGE_WINDOW]
fixed_score, folded = folded_template_score(window, template, denoise_sigma)
red_green, blue_yellow, blue_yellow_mid, green_mid = _large_window_components(
folded,
template,
)
minimum_fixed = min(minimum_fixed, fixed_score)
minimum_red_green = min(minimum_red_green, red_green)
minimum_blue_yellow = min(minimum_blue_yellow, blue_yellow)
minimum_blue_yellow_mid = min(minimum_blue_yellow_mid, blue_yellow_mid)
maximum_green_mid = max(maximum_green_mid, green_mid)
return LargeImageComponents(
width=width,
height=height,
minimum_fixed_score=minimum_fixed,
minimum_red_green_spatial=minimum_red_green,
minimum_blue_yellow_spatial=minimum_blue_yellow,
minimum_blue_yellow_mid_band=minimum_blue_yellow_mid,
maximum_green_mid_band=maximum_green_mid,
)
def detect_synthid(
image_path: str | Path,
*,
image: NDArray[Any] | None = None,
register_scale: bool | None = None,
) -> SynthIDDetection:
"""Detect the supported periodic carrier in IMAGE_PATH.
``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:
width, height = _image_size(path)
else:
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 = 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",
width=width,
height=height,
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}")
import numpy as np
from PIL import Image
template, sigma, *_model = _load_template()
if image is None:
with Image.open(path) as source:
pixels = np.asarray(source.convert("RGB"), dtype=np.uint8)
else:
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 registered_mode:
from synthid_runtime._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 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",
)