Files
Jungfraujoch/image_analysis/spot_finding/AdaptiveThreshold.h
T
leonarski_f b00c668e3a
Build Packages / Create release (push) Successful in 8s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m11s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 10m32s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 18m26s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 22m11s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 23m32s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 25m36s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 27m28s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 28m10s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 28m10s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 29m24s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 21m45s
Build Packages / Generate python client (push) Successful in 31s
Build Packages / Build documentation (push) Successful in 1m21s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 24m2s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 29m25s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 30m27s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 27m12s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 32m20s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 33m36s
Build Packages / Unit tests (push) Successful in 2h7m18s
Build Packages / Validation Battery (push) Successful in 33m19s
v1.0.0-rc.179 (#90)
* Rugnux: Stills processing needs only the unit cell and space group beyond what the file records - detector distance, beam centre, tilt, panel placement, saturation level, gain and polarisation plane are found from the images - and is up to twice as fast.
* Rugnux: Stills can be indexed without a GPU, with a CPU implementation of the fast-feedback indexer (macOS, computers without a GPU).
* Rugnux: Rotation and stills data are integrated in the detector's own units with one gain model, improving data from CCD detectors.
* Web frontend: The Rugnux live page shows the placed model with its electron density and anomalous difference map in a Mol* viewer.

Reviewed-on: #90
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-10-24 14:52:29 +02:00

113 lines
6.3 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
// Per-resolution-ring detection-threshold math shared by the CPU adaptive spot finder
// (AdaptiveSpotFinderCPU) and its GPU fused counterpart (AdaptiveSpotFinderGPU). Both engines reduce
// every pixel into resolution rings, take a robust per-ring background (mean, sigma), and turn it into
// a strong-pixel threshold with the SAME formula - so keeping that formula in one place is what makes
// the GPU engine reproduce the CPU one. These are plain host functions (the threshold is computed on
// the host in both engines, once per frame, over the small per-ring arrays).
#include <algorithm>
#include <cmath>
#include <cstdint>
namespace adaptive_threshold {
// Number of background pixels a ring needs before its own statistics are trusted; sparser rings
// (detector corners, heavily masked, innermost) fall back to the whole-frame background.
constexpr int64_t MIN_RING_PIXELS = 40;
// Detector-level excess-noise floor (photons). Near-zero-background rings scatter MORE than pure
// Poisson (charge sharing / read noise / occasional spurious low counts), so a per-ring sigma alone
// collapses toward zero on empty high-resolution rings and the threshold would flood. READ is a
// photon-scale constant (the same for every dataset -- NOT the per-dataset knob), so the operating
// point still self-calibrates through mean and sigma while staying physical where the background
// vanishes.
constexpr float READ = 1.0f;
// Inverse standard-normal CDF (Acklam's rational approximation, ~1e-9 accuracy). Only called once
// per frame, so accuracy over speed.
inline double NormalQuantile(double p) {
if (p <= 0.0) return -40.0;
if (p >= 1.0) return 40.0;
static const double a[] = {-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02,
1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00};
static const double b[] = {-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02,
6.680131188771972e+01, -1.328068155288572e+01};
static const double c[] = {-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00,
-2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00};
static const double d[] = {7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00,
3.754408661907416e+00};
const double plow = 0.02425, phigh = 1.0 - 0.02425;
if (p < plow) {
double q = std::sqrt(-2.0 * std::log(p));
return (((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) /
((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1.0);
} else if (p <= phigh) {
double q = p - 0.5, r = q*q;
return (((((a[0]*r+a[1])*r+a[2])*r+a[3])*r+a[4])*r+a[5])*q /
(((((b[0]*r+b[1])*r+b[2])*r+b[3])*r+b[4])*r+1.0);
} else {
double q = std::sqrt(-2.0 * std::log(1.0 - p));
return -(((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) /
((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1.0);
}
}
// Smallest integer count whose Poisson(mu) upper tail P(X >= k) <= p. This is the correct
// significance floor while the background is countable (it carries the sqrt(mu) shot-noise
// implicitly, so a bright low-resolution ring gets a high threshold). It DEGENERATES at mu -> 0
// (a single photon on a zero background is "significant"), which is why it is max'd with a
// read-noise-floored Gaussian arm by the caller.
//
// Past the summation limit the quantile is taken from the Cornish-Fisher expansion
// (Cornish and Fisher (1938) Rev. Int. Stat. Inst. 5, 307-320), whose skewness
// term (z^2-1)/6 is what a plain mu + z*sqrt(mu) leaves out. At the 4-6 sigma this operating point
// works at, that term is 3-6 counts, so the Gaussian form alone stood BELOW the true Poisson
// quantile - and it did so with a step at the switch, since below it the exact quantile was used.
// Cornish-Fisher is within one count of the exact value at every mu, so the two arms now join
// smoothly.
//
// How much this is worth depends on which arm of RingThreshold wins, and on measured data it is
// often neither: where the ring background is over-dispersed (a clipped ring sigma that still
// carries the ring's own azimuthal structure, 1.2-4.9x sqrt(mu) on a strongly diffracting rotation
// set) the Gaussian arm is the larger of the two on every ring above mu = 50 and this correction
// changes no threshold at all. It is the right value to return regardless: a caller that ever sees
// the Poisson arm win up there would otherwise get a bar that jumps at mu = 50.
inline float PoissonThreshold(double mu, double p, double z) {
// The summation below needs k up to about mu + z*sqrt(mu), and exp(-mu) has to stay normal.
constexpr double SUM_LIMIT = 200.0;
if (mu > SUM_LIMIT)
return static_cast<float>(mu + z * std::sqrt(mu) + (z * z - 1.0) / 6.0);
if (mu < 1e-6) mu = 1e-6;
const double target = 1.0 - p;
double pmf = std::exp(-mu);
double cdf = pmf;
int k = 0;
while (cdf < target && k < 1000) {
++k;
pmf *= mu / k;
cdf += pmf;
}
return static_cast<float>(k + 1);
}
// A ring's threshold is background mean + z sigmas, computed two ways and max'd: Poisson significance
// (correct where the background is countable) floored by a read-noise-aware Gaussian arm (which alone
// survives mean -> 0, where Poisson degenerates to "one photon is significant" and would flood the
// empty high-resolution rings). p, z are the frame-wide operating point (p = E / N_pixels). mean and
// sigma are in the frame's own units, gain of them to a photon (SpotFindingSettings::adu_per_photon):
// the Poisson arm counts photons and the floor is a photon, so both are taken in photons and handed
// back in the frame's units. A gain of 1 is exactly the photon-counting threshold.
inline float RingThreshold(float mean, float sigma, double p, float z, float gain) {
const float read = READ * gain;
const float gauss = mean + z * std::sqrt(sigma * sigma + read * read);
const float poisson = gain * PoissonThreshold(static_cast<double>(mean / gain), p, static_cast<double>(z));
return std::max(gauss, poisson);
}
} // namespace adaptive_threshold