mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-31 17:50:35 +02:00
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.
1635 lines
65 KiB
Python
1635 lines
65 KiB
Python
"""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()
|