Source code for spindoctor.support.correlate

import math
from typing import Any, Literal, cast

import numpy as np
import numpy.typing as npt
from numpy.fft import fft2, fftfreq, ifft2, ifftshift
from pdslogger import PdsLogger
from scipy.ndimage import gaussian_filter, sobel

from spindoctor.config import IMAGE_LOGGER
from spindoctor.support.image import crop_center, normalize_array, pad_top_left
from spindoctor.support.misc import mad_std
from spindoctor.support.types import NDArrayBoolType, NDArrayFloatType

_NDArrayComplexType = npt.NDArray[np.complexfloating[Any, Any]]

# Floor for masked NCC denominators and weight sums (avoid divide-by-zero).
_NCC_EPS = 1e-12

# ==============================================================
# Small utilities
# ==============================================================


[docs] def gradient_magnitude(arr: NDArrayFloatType) -> NDArrayFloatType: """Sobel gradient magnitude. Emphasizes edges over flat interior regions, which is what you want when the raw-intensity NCC has a broad plateau — e.g., a lit body disc that overflows the FOV, where interior brightness is near-uniform and the only unique-alignment signal lies at the limb. Parameters: arr: 2-D input array. Returns: Same-shape gradient-magnitude array, as ``float64``. """ a = np.asarray(arr, np.float64) gx = sobel(a, axis=1, mode='constant', cval=0.0) gy = sobel(a, axis=0, mode='constant', cval=0.0) return cast(NDArrayFloatType, np.hypot(gx, gy))
[docs] def int_to_signed(idx: int, size: int) -> int: """Map ``[0 .. size-1]`` argmax index to a signed displacement coordinate. Parameters: idx: Peak index from correlation search in ``[0, size)``. size: FFT / correlation length (modulus for wrapping). Returns: ``int`` signed offset equivalent to ``idx`` when ``idx < size // 2``, else ``idx - size`` (negative branch for peaks in the second half). """ return idx if idx < size // 2 else idx - size
# ============================================================== # Fourier helpers # ==============================================================
[docs] def fourier_shift(img: NDArrayFloatType, dy: float, dx: float) -> NDArrayFloatType: """Subpixel shift via Fourier shift theorem (positive = down/right).""" fy = fftfreq(img.shape[0])[:, None] fx = fftfreq(img.shape[1])[None, :] phase = np.exp(-2j * np.pi * (dy * fy + dx * fx)) return np.real(ifft2(fft2(img) * phase))
[docs] def upsampled_dft( X: _NDArrayComplexType, up_factor: int, region_sz: tuple[int, int], offsets: tuple[int, int], ) -> _NDArrayComplexType: """Localized upsampled DFT. From Guizar-Sicairos, 2008. "Efficient subpixel image registration via cross-correlation." Optics Leters, 33(2):156-158 """ X_v_size, X_u_size = X.shape region_v, region_u = region_sz oy, ox = offsets ky = ifftshift(np.arange(X_v_size)) kx = ifftshift(np.arange(X_u_size)) a = np.arange(region_v) - oy b = np.arange(region_u) - ox j2pi = 1j * 2.0 * np.pi # Use +j sign to evaluate localized inverse DFT samples (consistent with ifft). Er = np.exp((j2pi / (X_v_size * up_factor)) * (a[:, None] @ ky[None, :])) Ec = np.exp((j2pi / (X_u_size * up_factor)) * (kx[:, None] @ b[None, :])) return cast(_NDArrayComplexType, Er @ X @ Ec)
# ============================================================== # Masked NCC (linear correlation via padding) # ============================================================== # Default overlap and variance floors for the bi-directional masked NCC # (only used when ``data_mask`` is provided). ``w_frac_min`` rejects # shifts whose effective-overlap weight w(s) is less than this fraction of # max(w); ``var_frac_min`` similarly rejects shifts where the image or # model variance under the mask is a tiny fraction of the best case, which # is where floating-point noise in the denominator would otherwise produce # spurious |NCC| >> 1. _NCC_BIDIR_W_FRAC_MIN: float = 0.3 _NCC_BIDIR_VAR_FRAC_MIN: float = 0.1
[docs] def masked_ncc( image: NDArrayFloatType, model: NDArrayFloatType, mask: NDArrayBoolType, data_mask: NDArrayBoolType | None = None, ) -> tuple[NDArrayFloatType, NDArrayFloatType]: """Masked normalized cross-correlation surface between image and model. Computes shift-wise NCC (Pearson r) using FFT-based sums. Also returns the unnormalized NCC numerator (mean-subtracted covariance) as a diagnostic. The NCC surface contains values in [-1, 1] at every shift where the mask overlaps non-constant image content, and is the primary peak-selection surface: it is invariant to the image variance under the shifted mask and therefore does not suffer from the "scale bias" that causes the numerator to prefer shifts where the fixed template mask straddles bright/dark boundaries in the image (e.g., the limb of a Lambert-shaded body disc). The numerator surface scales with sqrt(image-variance-under-mask) times the NCC, which is useful as a sanity check but must not be used for peak finding when the template is dense. Parameters: image: Padded image array. model: Padded model array (same shape as image). mask: Padded boolean mask (same shape as image). True where model pixels are valid. data_mask: Optional padded boolean mask indicating where ``image`` contains real sensor data (True) versus zero-padded margin (False). When provided, the NCC is computed in its bi-directional form so that pixel pairs where the shifted model mask straddles zero-padded image pixels do not bias the normalization. This removes the edge-ridge artifact that otherwise appears at ``|dV| = extfov_margin_v`` when the body model extends into the extfov margin. When ``None``, the standard (single-mask) NCC formula is used. Returns: Tuple of (ncc, numerator) arrays, each with the same shape as the inputs. ``ncc`` is the Pearson-r surface used for peak localization; ``numerator`` is the mean-subtracted cross-covariance (unnormalized) returned as a diagnostic. When ``data_mask`` is provided, shifts with degenerate overlap or variance are set to ``-inf`` in the NCC surface. """ ref_shape = image.shape if image.shape != model.shape or image.shape != mask.shape: raise ValueError( 'masked_ncc: image, model, and mask must have the same shape; ' f'got image {image.shape}, model {model.shape}, mask {mask.shape}.' ) if data_mask is not None and data_mask.shape != ref_shape: raise ValueError( 'masked_ncc: data_mask must match image shape ' f'{ref_shape}; got data_mask {data_mask.shape}.' ) if data_mask is not None: return _masked_ncc_bidir(image, model, mask, data_mask) image_fft = fft2(image) mask_fft = fft2(mask) model_mask_fft = fft2(model * mask) sum_w: float = float(np.sum(mask)) safe_w = sum_w + _NCC_EPS # Shift-wise sums via FFT (take real to discard floating-point imaginary noise) sum_iw = np.real(ifft2(image_fft * np.conj(mask_fft))) sum_i2w = np.real(ifft2(fft2(image**2) * np.conj(mask_fft))) sum_imw = np.real(ifft2(image_fft * np.conj(model_mask_fft))) # Model stats (constant over shifts) sum_mw: float = float(np.sum(model * mask)) mean_m: float = sum_mw / safe_w ssd_mw: float = float(np.sum(((model - mean_m) * mask) ** 2)) + _NCC_EPS # Shift-wise image mean mean_i = sum_iw / safe_w # Numerator: sum of (I - mean_I)(M - mean_M) * W over the mask num = sum_imw - mean_i * sum_mw # Denominator: sqrt( SSD_I(s) * SSD_M ) # Epsilon inside the sqrt so the floor is sqrt(_NCC_EPS) rather than # _NCC_EPS; this prevents floating-point noise in the numerator from # producing |NCC| >> 1 at shifts where the image variance is zero. ssd_iw = sum_i2w - sum_iw**2 / safe_w ssd_iw[ssd_iw < 0] = 0.0 denom = np.sqrt(ssd_iw * ssd_mw + _NCC_EPS) ncc = num / denom return ncc, num
def _masked_ncc_bidir( image: NDArrayFloatType, model: NDArrayFloatType, mask: NDArrayBoolType, data_mask: NDArrayBoolType, ) -> tuple[NDArrayFloatType, NDArrayFloatType]: """Bi-directional (data-mask aware) masked NCC; see :func:`masked_ncc`. The weight at shift s is w(s) = sum_i data_mask(i) * mask(i - s) rather than a constant sum(mask); image and model statistics under the mask are computed likewise with data_mask(i) as a per-pixel inclusion factor. This eliminates the zero-padding bias that drives a spurious peak at ``|dV| = extfov_margin_v`` when the model extends into the padded margin. Shifts with small overlap or degenerate variance are set to ``-inf`` so that peak finding does not chase floating-point noise. """ image = np.asarray(image, np.float64) model = np.asarray(model, np.float64) mask_f = mask.astype(np.float64) dmask_f = data_mask.astype(np.float64) image_fft = fft2(image) image2_fft = fft2(image * image) dmask_fft = fft2(dmask_f) mask_fft = fft2(mask_f) model_mask_fft = fft2(model * mask_f) model2_mask_fft = fft2((model * model) * mask_f) # Effective overlap weight at each shift. w_d = np.real(ifft2(dmask_fft * np.conj(mask_fft))) w_d_max = float(w_d.max()) w_floor = max(_NCC_BIDIR_W_FRAC_MIN * w_d_max, _NCC_EPS) overlap_ok = w_d >= w_floor # Shift-wise sums. ``image`` is already zero outside data_mask, so the # I*mask and I^2*mask sums are equivalent to dmask*I*mask and # dmask*I^2*mask respectively. sum_iw = np.real(ifft2(image_fft * np.conj(mask_fft))) sum_i2w = np.real(ifft2(image2_fft * np.conj(mask_fft))) sum_imw = np.real(ifft2(image_fft * np.conj(model_mask_fft))) # Model stats are shift-dependent because dmask selects which model # pixels participate at each shift. sum_mw = np.real(ifft2(dmask_fft * np.conj(model_mask_fft))) sum_m2w = np.real(ifft2(dmask_fft * np.conj(model2_mask_fft))) safe_w = np.where(overlap_ok, w_d, w_floor) mean_i = sum_iw / safe_w num = sum_imw - mean_i * sum_mw ssd_iw = np.maximum(sum_i2w - sum_iw**2 / safe_w, 0.0) ssd_mw = np.maximum(sum_m2w - sum_mw**2 / safe_w, 0.0) # Reject shifts where the image or model has near-constant content # under the effective mask; their NCC is dominated by floating-point # noise in the denominator. ssd_i_max = float(ssd_iw.max()) ssd_m_max = float(ssd_mw.max()) var_ok = (ssd_iw > _NCC_BIDIR_VAR_FRAC_MIN * ssd_i_max) & ( ssd_mw > _NCC_BIDIR_VAR_FRAC_MIN * ssd_m_max ) valid = overlap_ok & var_ok denom = np.sqrt(ssd_iw * ssd_mw) ncc = np.where(valid, num / np.maximum(denom, _NCC_EPS), 0.0) # Mathematically |NCC| <= 1; floating-point error can push it slightly # above, so clamp before invalidating. ncc = np.clip(ncc, -1.0, 1.0) ncc[~valid] = -np.inf return ncc, num # ============================================================== # Peak metrics & selection # ==============================================================
[docs] def psr_metric(corr: NDArrayFloatType, rc: tuple[int, int], guard: int = 5) -> float: corr_v, corr_u = corr.shape row, col = rc peak = corr[row, col] y, x = np.ogrid[:corr_v, :corr_u] mask = (y - row) ** 2 + (x - col) ** 2 > guard**2 bg = corr[mask] # Exclude invalidated-shift sentinels (-inf) before computing background stats; # otherwise ``bg.mean()`` collapses to -inf and PSR becomes NaN. bg = bg[np.isfinite(bg)] if bg.size == 0: return float('nan') return float((peak - bg.mean()) / (bg.std() + 1e-12))
[docs] def pmr_metric(corr: NDArrayFloatType, peak_val: float) -> float: finite = corr[np.isfinite(corr)] if finite.size == 0: return float('nan') return float(peak_val / (finite.mean() + 1e-12))
[docs] def per_metric(corr: NDArrayFloatType, peak_val: float) -> float: finite = corr[np.isfinite(corr)] if finite.size == 0: return float('nan') return float(peak_val / (np.sqrt(np.sum(finite**2)) + 1e-12))
[docs] def nms_topk( corr: NDArrayFloatType, k: int = 5, radius: int = 5, max_offset_vu: tuple[int, int] | None = None, ) -> list[tuple[int, int, float]]: """Non-maximum suppression to get top-k peaks. Parameters: corr: 2-D correlation surface (V x U). k: Maximum number of peaks to return. radius: Suppression radius around each selected peak in pixels. max_offset_vu: If given, only positions whose signed offset satisfies ``|dV| < max_offset_vu[0]`` and ``|dU| < max_offset_vu[1]`` are eligible (strict inequality: peaks at the exact window boundary are excluded because they signal a clipped search and cannot be trusted; the pyramid driver additionally marks any final result within 1 pixel of the boundary as spurious). Signed offsets are derived from the FFT-convention wrap-around used by :func:`int_to_signed`. Returns: List of ``(row, col, value)`` tuples for up to ``k`` peaks. """ corr_v, corr_u = corr.shape work = corr.copy() if max_offset_vu is not None: max_v, max_u = max_offset_vu rows = np.arange(corr_v) cols = np.arange(corr_u) # Signed offsets via FFT wrap-around convention (same as int_to_signed). signed_rows = np.where(rows < corr_v // 2, rows, rows - corr_v) signed_cols = np.where(cols < corr_u // 2, cols, cols - corr_u) work[np.abs(signed_rows) >= max_v, :] = -np.inf work[:, np.abs(signed_cols) >= max_u] = -np.inf peaks: list[tuple[int, int, float]] = [] if not np.any(np.isfinite(work)): return peaks for _ in range(k): idx = int(np.argmax(work)) v = float(work.flat[idx]) if not np.isfinite(v): break row_i, col_i = np.unravel_index(idx, work.shape) row, col = int(row_i), int(col_i) peaks.append((row, col, v)) y, x = np.ogrid[:corr_v, :corr_u] work[(y - row) ** 2 + (x - col) ** 2 <= radius**2] = -np.inf return peaks
# ============================================================== # Matched-filter (peak-curvature) covariance # ============================================================== # Lower bound on the number of statistically independent samples the # residual is allowed to represent. The residual over the template # support is spatially correlated (camera PSF, anti-alias blur, the # sub-pixel refine low-pass, and coherent silhouette / photometric model # error), so a template covering thousands of pixels carries far fewer # independent constraints than its pixel count. The correlation-area # inflation divides the effective sample count by the residual's # correlation area; this floor keeps a near-constant residual (whose # measured correlation area approaches the whole patch) from collapsing # the effective count to a value that would drive the covariance to # infinity. _MIN_EFFECTIVE_SAMPLES: float = 4.0 def _residual_correlation_area(resid: NDArrayFloatType) -> float: """Estimate the residual's spatial correlation area in pixels. The matched-filter Cramer-Rao bound assumes white (per-pixel independent) noise. A rendered-template residual is not white: the camera PSF, the anti-alias and sub-pixel-refine low-pass filters, and -- most importantly -- coherent model error (a silhouette or photometric mismatch spread across the whole body) correlate neighboring residual pixels. The number of statistically independent samples is therefore ``N_pixels / A_c`` where ``A_c`` is the correlation area returned here, and the white-noise covariance must be inflated by ``A_c`` to reflect the true information content. ``A_c`` is estimated as the product of per-axis correlation lengths, each the integral of the residual's normalized autocorrelation over its central positive lobe (``1 + 2 * sum_{k>=1} rho(k)`` truncated at the first non-positive lag). A white residual yields ``A_c ~ 1``; a residual correlated over ``L`` pixels per axis yields ``A_c ~ L**2``. Parameters: resid: 2-D residual field (image minus aligned model), any scale. Returns: Correlation area in pixels, floored at ``1.0``. """ r = np.asarray(resid, np.float64) r = r - float(r.mean()) if r.size == 0 or float(np.mean(r * r)) <= 1e-30: return 1.0 def _axis_length(axis: int) -> float: n = r.shape[axis] if n < 3: return 1.0 spec = np.fft.rfft(r, axis=axis) power = (spec * np.conj(spec)).real autocorr = np.fft.irfft(power, n=n, axis=axis) # Collapse the perpendicular axis so the 1-D autocorrelation is the # per-lag average across the other axis's lines. autocorr_1d = autocorr.mean(axis=1 - axis) zero_lag = float(autocorr_1d[0]) if zero_lag <= 0.0: return 1.0 rho = autocorr_1d / zero_lag length = 1.0 for k in range(1, n // 2): rho_k = float(rho[k]) if rho_k <= 0.0: break length += 2.0 * rho_k return max(length, 1.0) area = _axis_length(0) * _axis_length(1) # Never claim fewer than a handful of independent samples: cap the area # so the effective count N_pixels / A_c stays at or above the floor. max_area = max(r.size / _MIN_EFFECTIVE_SAMPLES, 1.0) return float(min(max(area, 1.0), max_area))
[docs] def matched_filter_covariance( model_aligned: NDArrayFloatType, sigma_n: float, *, correlation_area: float = 1.0, ) -> NDArrayFloatType: """Peak-curvature (matched-filter) covariance of a 2-D translation fit. For a template ``M`` aligned to an image ``I = M(x - s) + n`` the shift ``s`` maximum-likelihood estimate has Fisher information ``F_ab = (1 / sigma_n**2) * sum_x (dM/dx_a)(dM/dx_b)`` and covariance ``F**-1 = sigma_n**2 * inv(sum grad M grad M^T)``. Two properties are essential for the reported sigma to track the true error and are the reason this replaces the previous derivation: - **Unit consistency.** ``sigma_n`` and the gradients of ``M`` must be measured on the same intensity scale, or the covariance scales with the (arbitrary) template amplitude. The caller passes a residual noise ``sigma_n`` computed on the zero-mean, unit-std normalized image and model; this function must therefore receive the *same* normalized model so ``grad M`` shares that scale. Passing the raw template here (whose amplitude can be thousands of DN) shrinks the covariance by the template variance and is the miscalibration this derivation corrects. - **Effective sample count.** ``correlation_area`` inflates the white-noise bound by the residual's spatial correlation area so correlated model error is not counted as thousands of independent constraints (see :func:`_residual_correlation_area`). Parameters: model_aligned: The aligned template on the *same normalized scale* as the residual from which ``sigma_n`` was measured. sigma_n: Residual noise standard deviation on that normalized scale. correlation_area: Residual correlation area in pixels (``>= 1``); multiplies the covariance. Default ``1.0`` reproduces the white-noise Cramer-Rao bound. Returns: The ``2x2`` translation covariance in pixels squared. A degenerate (rank-deficient) gradient structure returns a large isotropic covariance so the result is de-weighted rather than trusted. """ sy = np.gradient(model_aligned, axis=0) sx = np.gradient(model_aligned, axis=1) Sxx = float(np.sum(sx * sx)) Syy = float(np.sum(sy * sy)) Sxy = float(np.sum(sx * sy)) var_n = float(sigma_n) ** 2 + 1e-18 area = max(float(correlation_area), 1.0) fisher = (1.0 / var_n) * np.array([[Sxx, Sxy], [Sxy, Syy]], dtype=np.float64) if np.linalg.cond(fisher) > 1e10: # Degenerate case: return large uncertainty return np.diag([1e6, 1e6]) cov_white = np.linalg.pinv(fisher + 1e-12 * np.eye(2)) return cast(NDArrayFloatType, area * cov_white)
# ============================================================== # Single-scale, K-peak evaluation (with optional prior) # ==============================================================
[docs] def evaluate_candidate( *, image_pad: NDArrayFloatType, model_pad: NDArrayFloatType, mask_pad: NDArrayBoolType, corr: NDArrayFloatType, rc: tuple[int, int], upsample_factor: int, model_shape: tuple[int, int], image_shape: tuple[int, int], logger: PdsLogger, prior_shift: tuple[float, float] | None = None, prior_weight: float = 0.0, metric: str = 'psr', refine_image_pad: NDArrayFloatType | None = None, refine_model_pad: NDArrayFloatType | None = None, refine_lowpass_sigma_px: float = 0.0, ) -> dict[str, Any]: """ Evaluate a candidate for the navigation. Parameters: image_pad: The padded image. model_pad: The padded model. mask_pad: The padded mask. corr: The correlation matrix. rc: The row and column of the candidate. upsample_factor: The upsample factor. model_shape: The shape of the model. image_shape: The shape of the image. prior_shift: The prior shift. prior_weight: The prior weight. metric: The metric to use for the navigation. refine_image_pad: Optional padded image used *only* for the sub-pixel cross-power-spectrum refinement, in place of ``image_pad``. When the coarse peak is found on gradient-magnitude surfaces (``use_gradient``), pass the raw-intensity padded image here: the Sobel *magnitude* rectifies the signal, so its cross-power peak is non-smooth at the apex and the upsampled-DFT sub-pixel estimate is biased (largest near whole-pixel offsets), whereas raw intensity reaches the ``upsample_factor`` resolution. Defaults to ``image_pad``. refine_model_pad: Optional padded model for the same refinement; defaults to ``model_pad``. Returns: A dictionary containing the navigation result. Raises: ValueError: If the model is smaller than the image in either dimension (the model must be at least image-sized; pad it with ``pad_top_left`` first). This is validated here so the failure is a clear contract error rather than an opaque ``crop_center`` error two calls deep. """ model_h, model_w = model_shape image_h, image_w = image_shape if image_h > model_h or image_w > model_w: raise ValueError( f'model shape {model_shape} must be at least as large as the image ' f'shape {image_shape} in both dimensions; a model smaller than the ' f'image is not supported (pad the model to at least image size first)' ) corr_v, corr_u = corr.shape p, q = rc dy_i, dx_i = int_to_signed(p, corr_v), int_to_signed(q, corr_u) # Subpixel refinement: local upsampled DFT of correlation spectrum numerator. # Refine on the raw-intensity surfaces when provided (see ``refine_image_pad``) # so a gradient-magnitude coarse peak does not carry its rectified-cusp # sub-pixel bias into the reported offset. refine_image = image_pad if refine_image_pad is None else refine_image_pad refine_model = model_pad if refine_model_pad is None else refine_model_pad refine_model_masked = refine_model * mask_pad # Band-limit both surfaces before the cross-power. The sub-pixel peak of two # sharp, anti-aliased (but non-PSF-blurred) surfaces with differing edge # profiles -- a rendered body / ring vs. a Lambert template -- is aliased by a # sub-pixel-phase-dependent amount (a ~0.03 px odd S-curve, zero at integer and # half-pixel offsets). A matched Gaussian low-pass removes the high-frequency # mismatch that drives it, cutting the disc / ring residual to ~0.01 px; the # upsampled DFT still localizes the smoothed peak. The low-pass is applied to # the final surfaces (the image and the already-masked model) so the model's # support edge is not re-sharpened by the mask afterwards. Default 0.0 is a # no-op. if refine_lowpass_sigma_px > 0.0: refine_image = gaussian_filter(refine_image, refine_lowpass_sigma_px) refine_model_masked = gaussian_filter(refine_model_masked, refine_lowpass_sigma_px) spec = fft2(refine_image) * np.conj(fft2(refine_model_masked)) # Center of the local upsampled DFT window should be the middle of the # evaluation region (e.g., 3x3 -> center index 1), not half the upsample factor. # Using upsample_factor here caused increasing bias for large factors. # Use a region size that scales with upsample_factor so the true peak # (which can be ~0.5*upsample_factor samples from center) is inside the window. region_v = upsample_factor + 1 region_u = upsample_factor + 1 oy = region_v // 2 ox = region_u // 2 Up = upsampled_dft( spec, upsample_factor, (region_v, region_u), (oy - dy_i * upsample_factor, ox - dx_i * upsample_factor), ) upy, upx = np.unravel_index(np.argmax(np.abs(Up)), Up.shape) dy = dy_i + (upy - oy) / upsample_factor dx = dx_i + (upx - ox) / upsample_factor # Align combined model and compute residual stats (model_h/w, image_h/w # were unpacked and validated at the top of the function). model_shift = fourier_shift(model_pad[:model_h, :model_w], dy, dx) model_crop = crop_center(model_shift, (image_h, image_w)) image_crop = image_pad[:image_h, :image_w] # Normalize both surfaces to zero-mean unit-std before the residual so # the residual noise and the template gradients that feed the # matched-filter covariance share one intensity scale (a raw-DN # template would otherwise shrink the covariance by its own variance). model_norm = normalize_array(model_crop) resid = normalize_array(image_crop) - model_norm sigma_n = mad_std(resid) if sigma_n <= 1e-12: # Near-perfect match (or a constant residual): fall back to the plain # std so the covariance stays finite rather than dividing by zero. sigma_n = max(resid.std(), 1e-6) # Inflate the white-noise bound by the residual correlation area: the # residual is spatially correlated (PSF, anti-alias / refine low-pass, # and coherent silhouette / photometric model error), so its pixels are # not independent samples. correlation_area = _residual_correlation_area(resid) cov = matched_filter_covariance(model_norm, sigma_n, correlation_area=correlation_area) # Quality metric peak_val = corr[p, q] if metric == 'psr': quality = psr_metric(corr, (p, q)) elif metric == 'pmr': quality = pmr_metric(corr, peak_val) elif metric == 'per': quality = per_metric(corr, peak_val) else: raise ValueError(f"metric must be 'psr', 'pmr', or 'per', not '{metric}'") # Prior penalty (encourage pyramid consistency or external priors) if prior_shift is not None and prior_weight > 0.0: # TODO The prior penalty subtracts prior_weight * dist from quality, but the units/scale of # quality (PSR/PMR/PER) may not be comparable to distance. This could make the penalty # disproportionately strong or weak depending on the metric choice. # Consider normalizing the distance penalty or using a separate scoring function that # combines quality and distance in a principled way (e.g., weighted sum with normalized # components). dist = np.hypot(dy - prior_shift[0], dx - prior_shift[1]) quality -= prior_weight * dist return { 'offset': (float(dy), float(dx)), 'cov': cov, 'sigma_xy': (float(np.sqrt(cov[0, 0])), float(np.sqrt(cov[1, 1]))), 'quality': float(quality.real), 'peak_val': float(peak_val.real), 'rc': (int(p), int(q)), }
# ============================================================== # Pyramid wrapper with K-peak final selection # ============================================================== _MAX_PEAK_RATIO: float = 1.0e3 """Upper bound on :func:`peak_to_runner_up_ratio`. Any ratio at or above this represents an effectively unambiguous winner; the downstream confidence sigmoid saturates far below it (``divisor=2``, ``cap_at=1`` in ``config_510_techniques.yaml``). The cap keeps the returned value -- and the diagnostic that stores it -- bounded instead of blowing up to ``~1e9`` when the runner-up quality is near zero. """
[docs] def peak_to_runner_up_ratio(top_k_peaks: list[tuple[float, float, float]]) -> float: """Return the ratio of the winning peak's quality to the runner-up's. ``top_k_peaks`` is ``[(quality, dv, du), ...]`` sorted by quality descending (the convention :func:`navigate_with_pyramid_kpeaks` uses). Returns ``1.0`` when only one peak survives non-maximum suppression -- what an unambiguous correlation looks like, so a value at or above 1.0 is the "good" tail -- and ``0.0`` when no peaks are present. When the runner-up quality is non-positive (rare; happens with the prior penalty) the result is the unambiguous-winner cap ``_MAX_PEAK_RATIO`` rather than ``winner / 1e-9`` (which scaled with the winner's magnitude and could reach ``~1e9``). The ordinary ratio is likewise clamped to ``_MAX_PEAK_RATIO``. Parameters: top_k_peaks: Peaks sorted by quality descending. Returns: Capped peak-to-runner-up quality ratio. """ if not top_k_peaks: return 0.0 if len(top_k_peaks) == 1: return 1.0 winner_q = float(top_k_peaks[0][0]) runner_q = float(top_k_peaks[1][0]) if runner_q <= 1e-9: return _MAX_PEAK_RATIO if winner_q > 0.0 else 0.0 return min(winner_q / runner_q, _MAX_PEAK_RATIO)