Build Packages / Unit tests (push) Successful in 1h22m15s
Build Packages / build:windows:nocuda (push) Successful in 18m0s
Build Packages / build:windows:cuda (push) Successful in 20m30s
Build Packages / build:viewer-tgz:cpu (push) Successful in 10m32s
Build Packages / build:viewer-tgz:cuda (push) Successful in 11m39s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 8m55s
Build Packages / build:rugnux:windows (push) Successful in 11m25s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 20m6s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m27s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 20m19s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 15m34s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 20m25s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m36s
Build Packages / build:rpm (rocky8) (push) Successful in 17m43s
Build Packages / build:rpm (rocky9) (push) Successful in 13m34s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 21m28s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 18m19s
Build Packages / DIALS test (push) Successful in 12m36s
Build Packages / XDS test (durin plugin) (push) Successful in 6m56s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 6m48s
Build Packages / XDS test (neggia plugin) (push) Successful in 6m7s
Build Packages / Generate python client (push) Successful in 11s
Build Packages / Build documentation (push) Successful in 36s
Build Packages / Create release (push) Skipped
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 5m11s
* `rugnux --mode calibration` writes `<prefix>.json` beside the `.poni`, whose `dataset_settings` member is a `jfjoch_broker` `dataset_settings` body as it stands. * `rugnux` and `jfjoch_viewer` read PILATUS miniCBF sweeps natively, without conversion. * Masters written by other facilities open, including Eiger 1.x and third-party NXmx variants. * `rugnux` measures the beam centre on every run, and indexes with it when the file's value indexes nothing. * A detector swung out on a 2theta arm is placed where the file says it stands, and the calibration can hold the tilt fixed. * `rugnux` writes the unmerged MTZ by default, and a P1 merge beside it, so a wrong space group can be re-merged without reprocessing. * Significant improvements to symmetry handling in `rugnux`: the lattice, the point group, the setting and the systematic absences. * The `rugnux` report gives the resolution the CC1/2 fit reached, beside the range the reflections were written to. * The `rugnux` report gives the twinning statistics measured before the space group was decided, beside the ones measured after. * The `rugnux` report gives the strong-direction diffraction limit, and warns when CC1/2 is not monotone with resolution. * `rugnux` ranks screw axes on the evidence their absences carry, rather than on how many control reflections a candidate happens to have. * Twinning is no longer reported when the L-test contradicts it. * The `rugnux` report gives the detector tilt, the measured tilt and the direct beam beside the beam centre, and a post-refined beam centre is judged against the run's own measurement rather than the file's. * `--no-refine-tilt` holds the detector tilt at the value in the file, instead of zeroing it, when the calibration starts from the spots. * The `jfjoch_viewer` grid scan view draws the cells in the proportion of the scan steps, so the map has the shape of the scanned area. Reviewed-on: #76 Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
109 lines
5.9 KiB
C++
109 lines
5.9 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).
|
|
inline float RingThreshold(float mean, float sigma, double p, float z) {
|
|
const float gauss = mean + z * std::sqrt(sigma * sigma + READ * READ);
|
|
const float poisson = PoissonThreshold(static_cast<double>(mean), p, static_cast<double>(z));
|
|
return std::max(gauss, poisson);
|
|
}
|
|
|
|
} // namespace adaptive_threshold
|