The gain of an input in detector units (SMV, marCCD, XTC, the XFEL formats) is measured once, before anything reads the frames (RugnuxMeasureGain: photon-peak spacing and background variance per unit mean), or given with --gain, and handed to the integration and the spot finding by one function (ApplyRugnuxPixelGain). The rotation pre-scan no longer measures it, and the stills reader no longer converts frames of a detector whose photons are not resolved: SACLA MPCCD, Rayonix and CCD frames are integrated in ADU at the gain, rotation and stills alike. Where a photon count was assumed, it is now taken at the gain (bit-identical at gain 1): - integration: the background clip's one-photon floor, the sigma floor (one photon), the profile fit's background floor (0.01 photon), and a still's overload kept as a lower bound, which was left in ADU while every other reflection left the integrator in photons - the cause of the earlier loss of the engine route on clipped CCD stills (merged low resolution inflated by the gain, the rocking-width fit's bandwidth collapsing); CPU and CUDA, Metal through the shared CPU code; - the adaptive spot threshold (Poisson arm and its one-photon floor), CPU and GPU; - the pre-scan spot-width measurement (strong-spot SNR, the ring clip) behind the stills bandwidth and the rotation signal radius; - the hot/dead pixel tests, CPU and GPU; the empty-aperture ring clip. BackgroundPixelGain is gone: one pixel_gain. A detector whose background resolves single photons (photon peaks: JUNGFRAU in keV, AGIPD) is a photon counter and its frames are still counted in whole photons as read: analysed in keV at the gain instead, a photon-counting still set lost 0.008 R-free (fewer spots, a noisier beam-centre trial) - the rounding is the detector's own discrimination. --gain sets the value, the background still says which kind. Validation, against rc178: photon-counting and g<=1 rotation p.hkl md5-identical (lyso, thau, cytc, myob, insu, a small-molecule set, 5reo, 6cee, 6i3j, 2xfw, 6s1u, 8v4j); CCD g>1 at or within noise of rc178 (5uth R_meas 0.113 -> 0.108). Stills R-free (0-cycle REFMAC): MPCCD sets 0.184 -> 0.185 and 0.199 -> 0.196, Rayonix identical, photon-resolving sets back on their rc178 numbers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu (cherry picked from commit da8dbfc8147354b63e375f2d9521dffb80938b6d)
310 lines
17 KiB
C++
310 lines
17 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "BraggIntegrationEngine.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <map>
|
|
#include <mutex>
|
|
#include <numeric>
|
|
#include <string>
|
|
#include <tuple>
|
|
|
|
#include "../../common/JFJochMath.h" // PI (M_PI is not standard, and MSVC does not define it)
|
|
#include "../SensorAbsorption.h"
|
|
#include "RingBackground.h"
|
|
|
|
namespace {
|
|
|
|
// Radial parallax broadening as the coefficient of tan^2(2theta), i.e. Var(z)/pixel^2 [px^2].
|
|
// Copied verbatim from ProfileIntegrate2D: a photon converts at a random depth z (exponential,
|
|
// attenuation length L, truncated at the sensor thickness), shifting the recorded spot radially by
|
|
// z*tan(2theta). L comes from the tabulated NIST attenuation coefficients (SensorAbsorption.h); the
|
|
// lambda^3 approximation this used before is within 0.2% for silicon above 10 keV but overstates
|
|
// the attenuation length of CdTe by up to a factor of two, and by six above the Cd K edge, which
|
|
// made this variance 1.9x too large on 750 um CdTe data.
|
|
double parallax_var_px2(const std::string &material, double thickness_um, double lambda_A, double pixel_um) {
|
|
if (!(thickness_um > 0.0) || !(pixel_um > 0.0) || !(lambda_A > 0.0))
|
|
return 0.0;
|
|
const double L = sensor_absorption::AttenuationLength_um(material, lambda_A);
|
|
return sensor_absorption::ConversionDepthVariance_um2(L, thickness_um) / (pixel_um * pixel_um);
|
|
}
|
|
|
|
// The radial-offset kernels below are a pure function of these six numbers, and one engine is built
|
|
// per worker per pass - 96 of them on a two-pass run - so the table was built 96 times over from the
|
|
// same inputs. Build it once and let the rest copy it; it is a few hundred floats. Two workers can
|
|
// still race to build the same table, which costs nothing but the second build: the values are
|
|
// identical, and emplace keeps whichever arrived first.
|
|
struct RadialKernelKey {
|
|
float r1_sq, r2, r3;
|
|
int n_kern, k_off, k_len;
|
|
|
|
bool operator<(const RadialKernelKey &o) const {
|
|
return std::tie(r1_sq, r2, r3, n_kern, k_off, k_len)
|
|
< std::tie(o.r1_sq, o.r2, o.r3, o.n_kern, o.k_off, o.k_len);
|
|
}
|
|
};
|
|
|
|
std::mutex radial_kernel_mutex;
|
|
std::map<RadialKernelKey, std::vector<float>> radial_kernel_cache;
|
|
|
|
} // namespace
|
|
|
|
BraggIntegrationEngine::BraggIntegrationEngine(const DiffractionExperiment &experiment)
|
|
: geom(experiment.GetDiffractionGeometry()), modules(experiment.GetModules()),
|
|
plane_bounds(modules.PlaneBounds(experiment.GetXPixelsNum(), experiment.GetYPixelsNum())) {
|
|
const auto settings = experiment.GetBraggIntegrationSettings();
|
|
const auto &det = experiment.GetDetectorSetup();
|
|
|
|
mode = settings.GetIntegrator();
|
|
empirical = mode == IntegratorMode::ProfileEmpirical;
|
|
|
|
// Same frame as the reflections' predicted_x/predicted_y and the ImagePreprocessorBuffer that
|
|
// feeds this engine (MXAnalysisWithoutFPGA sizes that buffer to GetPixelsNum()).
|
|
xpixel = experiment.GetXPixelsNum();
|
|
ypixel = experiment.GetYPixelsNum();
|
|
npixel = experiment.GetPixelsNum();
|
|
|
|
r1_sq = settings.GetR1() * settings.GetR1();
|
|
r2 = settings.GetR2();
|
|
r2_sq = r2 * r2;
|
|
r3 = settings.GetR3();
|
|
r3_sq = r3 * r3;
|
|
R = static_cast<int>(std::ceil(r2));
|
|
G = 2 * R + 1;
|
|
GG = G * G;
|
|
|
|
// The X-ray bandwidth enters ONE place: it smears a reflection radially by bw_sigma * Rpx, which
|
|
// the per-reflection Gaussian carries as part of its radial variance. It is not a mode switch -
|
|
// the background estimator and the parallax/capture term below are the same whatever the beam is.
|
|
bw_sigma = experiment.GetBandwidthFWHM().value_or(0.0f) / 2.3548f;
|
|
|
|
const double c_par = parallax_var_px2(det.GetSensorMaterial(), det.GetSensorThickness_um(),
|
|
geom.GetWavelength_A(), geom.GetPixelSize_mm() * 1000.0);
|
|
c_radial = c_par + bragg_engine::C_CAPTURE;
|
|
F_px = geom.GetDetectorDistance_mm() / std::max(1e-6f, geom.GetPixelSize_mm());
|
|
beam_x = geom.GetBeamX_pxl();
|
|
beam_y = geom.GetBeamY_pxl();
|
|
use_ellipse = !empirical;
|
|
|
|
// Per-reflection signal/background geometry: the ring elongated radially by k_sigma times the
|
|
// beam's own radial streak, capped. k_sigma = 0 is the fixed circular stencil, bit for bit, and
|
|
// so is any monochromatic beam, where the streak is zero.
|
|
stencil.beam_x = beam_x;
|
|
stencil.beam_y = beam_y;
|
|
stencil.r2 = r2;
|
|
stencil.r3 = r3;
|
|
stencil.bw_sigma = static_cast<float>(bw_sigma);
|
|
stencil.k_sigma = settings.GetStencilKSigma();
|
|
stencil.max_grow = bragg_engine::MAX_STENCIL_GROW_OVER_R3 * r3;
|
|
// The measured spot footprint (SpotFootprint.h). It moves the ring only where a spot outgrows the
|
|
// r1 disk, so a pattern of compact spots integrates exactly as without it.
|
|
static_assert(SpotFootprint::MAX_BINS <= BRAGG_FOOTPRINT_MAX_BINS);
|
|
stencil.r1 = settings.GetR1();
|
|
const auto &fp = settings.GetFootprint();
|
|
if (!empirical && !fp.empty()) {
|
|
stencil.fp_n = static_cast<int>(fp.sigma_rad.size());
|
|
stencil.fp_bin_px = fp.bin_px;
|
|
for (int i = 0; i < stencil.fp_n; ++i) {
|
|
stencil.fp_sigma_rad[i] = fp.sigma_rad[i];
|
|
stencil.fp_sigma_tan[i] = fp.sigma_tan[i];
|
|
}
|
|
}
|
|
|
|
// Robust background ring, one estimator or the other (see BraggIntegrationSettings): a high-side
|
|
// sigma-clip (rugnux --background-clip, the default) or, when the clip is switched off, a
|
|
// symmetric trimmed mean (rugnux --background-trim). The caller owns the choice - the engine no
|
|
// longer overrides it for broadband data.
|
|
bkg_clip_nsigma = settings.GetBackgroundClipNSigma();
|
|
pixel_gain = settings.GetPixelGain();
|
|
bkg_trim = bkg_clip_nsigma > 0.0f ? 0.0f : settings.GetBackgroundTrimFraction();
|
|
|
|
// Overlap treatment. Ownership is decided out to the fit grid's half size, which is where the
|
|
// profile fit reads pixels; beyond it a pixel that nobody claims is this reflection's own.
|
|
// Excluding the shared pixels needs a profile to renormalise, so it cannot act on a box sum -
|
|
// drop it to Off there rather than build an owner map nothing will read.
|
|
overlap = settings.GetOverlap();
|
|
if (overlap == OverlapMode::Exclude && mode == IntegratorMode::BoxSum)
|
|
overlap = OverlapMode::Off;
|
|
overlap_min_peak = settings.GetOverlapMinPeak();
|
|
keep_overloads = !experiment.IsRotationIndexing();
|
|
saturation_limit = static_cast<double>(experiment.GetSaturationLimit());
|
|
claim = static_cast<float>(R);
|
|
inv_claim = 1.0f / claim;
|
|
|
|
// Radial-offset kernels for the background curvature correction. A stencil pixel at (dx, dy)
|
|
// sits at radial offset dx*cos(phi) + dy*sin(phi) from the reflection, where phi is the
|
|
// reflection's azimuth; averaging over phi makes the kernels position-independent, which is
|
|
// exact to the extent the stencil is small against the reflection's radius (r3 = 10 px vs
|
|
// hundreds). k_diff is the annulus histogram minus the disk histogram, each normalised, so
|
|
// dot(k_diff, B) is directly mean_annulus(B) - mean_disk(B).
|
|
// Unset = auto: start off, and let the analysis raise it per image where the ice score says the
|
|
// background really is radial. An engine nobody drives therefore never applies the correction.
|
|
const auto radial = settings.GetBackgroundRadialCorrection();
|
|
bkg_radial_auto = !radial.has_value();
|
|
bkg_radial = radial.value_or(false);
|
|
|
|
// The table spans zero growth up to whatever the widest reflection on this detector reaches, one
|
|
// kernel per pixel of growth; with nothing elongated a single kernel is all there is, which is
|
|
// the layout and the values of every build before the stencil existed. It is built only when the
|
|
// correction can ever run - the rows are not cheap, and nothing may read them otherwise:
|
|
// bkg_radial is raised after construction only by the auto mode (MXAnalysisWithoutFPGA), which
|
|
// requires bkg_radial_auto, and the GPU allocates its curve buffers under the same condition.
|
|
// n_kern is the largest row BraggStencilKernelIndex can select, plus one.
|
|
r_max = std::hypot(std::max<double>(beam_x - plane_bounds.x0, plane_bounds.x1 - beam_x),
|
|
std::max<double>(beam_y - plane_bounds.y0, plane_bounds.y1 - beam_y));
|
|
bkg_radial_built = bkg_radial || bkg_radial_auto;
|
|
const float grow_max = bkg_radial_built ? BraggStencilMaxGrow_px(static_cast<float>(r_max), stencil)
|
|
: 0.0f;
|
|
n_kern = static_cast<int>(std::lround(grow_max)) + 1;
|
|
// Every row must fit: the last one is built at grow = n_kern - 1, which rounding can put just
|
|
// above grow_max.
|
|
k_off = static_cast<int>(std::ceil(r3 + std::max<double>(grow_max, n_kern - 1))) + 1;
|
|
k_len = 2 * k_off + 1;
|
|
const RadialKernelKey kernel_key{r1_sq, r2, r3, n_kern, k_off, k_len};
|
|
{
|
|
const std::lock_guard lock(radial_kernel_mutex);
|
|
if (const auto it = radial_kernel_cache.find(kernel_key); it != radial_kernel_cache.end())
|
|
k_diff = it->second;
|
|
}
|
|
if (k_diff.empty()) {
|
|
k_diff.reserve(static_cast<size_t>(n_kern) * k_len);
|
|
for (int j = 0; j < n_kern; ++j)
|
|
BuildRadialKernel(static_cast<float>(j));
|
|
const std::lock_guard lock(radial_kernel_mutex);
|
|
radial_kernel_cache.emplace(kernel_key, k_diff);
|
|
}
|
|
|
|
polarization = experiment.GetPolarizationFactor();
|
|
}
|
|
|
|
void BraggIntegrationEngine::BuildRadialKernel(float grow) {
|
|
// Histogram the stencil over radial offset, averaged over azimuth so the kernel does not depend
|
|
// on where the reflection sits. The average is over the SUB-PIXEL PHASE of the detector grid
|
|
// against the radial direction, not over the stencil's own orientation: the stencil is built in
|
|
// the reflection's frame at each azimuth, so an elongated one stays aligned with the radius, as
|
|
// it is on the detector. k_diff is the annulus histogram minus the disk histogram, each
|
|
// normalised, so dot(k_diff, B) is directly mean_annulus(B) - mean_disk(B).
|
|
// The signal disk is a circle whatever the ring does, so its histogram is the same for every
|
|
// kernel in the table - build it once.
|
|
const bool first = hist_disk.empty();
|
|
if (first)
|
|
hist_disk.assign(k_len, 0.0);
|
|
std::vector<double> hist_ann(k_len, 0.0);
|
|
constexpr int n_phi = 512;
|
|
const int span = static_cast<int>(std::ceil(r3 + grow)) + 1;
|
|
const float si = r2 / (r2 + grow), so = r3 / (r3 + grow);
|
|
const double q_in = 1.0 - static_cast<double>(si) * si;
|
|
const double q_out = 1.0 - static_cast<double>(so) * so;
|
|
for (int p = 0; p < n_phi; ++p) {
|
|
const double phi = 2.0 * PI * p / n_phi, cp = std::cos(phi), sp = std::sin(phi);
|
|
for (int dy = -span; dy <= span; ++dy)
|
|
for (int dx = -span; dx <= span; ++dx) {
|
|
const double d2 = static_cast<double>(dx) * dx + static_cast<double>(dy) * dy;
|
|
const double rad = dx * cp + dy * sp;
|
|
const int k = k_off + static_cast<int>(std::lround(rad));
|
|
if (k < 0 || k >= k_len)
|
|
continue;
|
|
const double rad2 = rad * rad;
|
|
if (d2 < r1_sq) {
|
|
if (first) hist_disk[k] += 1.0;
|
|
} else if (d2 - q_in * rad2 >= r2_sq && d2 - q_out * rad2 < r3_sq) {
|
|
hist_ann[k] += 1.0;
|
|
}
|
|
}
|
|
}
|
|
if (first) sum_disk = std::accumulate(hist_disk.begin(), hist_disk.end(), 0.0);
|
|
const double sd = sum_disk;
|
|
const double sa = std::accumulate(hist_ann.begin(), hist_ann.end(), 0.0);
|
|
for (int k = 0; k < k_len; ++k)
|
|
k_diff.push_back(static_cast<float>(hist_ann[k] / sa - hist_disk[k] / sd));
|
|
}
|
|
|
|
void BraggIntegrationEngine::RadialBackgroundFromRings(const std::vector<float> &ring_bkg, int32_t q_bins,
|
|
float low_q, float dq) {
|
|
radial_curve.clear();
|
|
const RingBackground rings(ring_bkg, q_bins, low_q, dq);
|
|
if (rings.empty())
|
|
return;
|
|
// The same bins as the engines' own curve: one per pixel of distance from the beam, out to the far
|
|
// corner.
|
|
const int n = static_cast<int>(std::ceil(r_max)) + 2;
|
|
const double two_k = 4.0 * PI / geom.GetWavelength_A();
|
|
radial_curve.resize(n);
|
|
for (int r = 0; r < n; ++r)
|
|
radial_curve[r] = static_cast<float>(rings.At(two_k * std::sin(0.5 * std::atan(r / F_px))));
|
|
}
|
|
|
|
std::vector<BraggPanel> BraggIntegrationEngine::Panels(const std::vector<Reflection> &predicted,
|
|
size_t npredicted) const {
|
|
std::vector<BraggPanel> out(npredicted);
|
|
for (size_t i = 0; i < npredicted; ++i) {
|
|
const float x = predicted[i].predicted_x, y = predicted[i].predicted_y;
|
|
out[i] = MakeBraggPanel(modules, modules.ModuleOfImagePixel(x, y), static_cast<int64_t>(xpixel),
|
|
static_cast<int64_t>(ypixel), x, y);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
std::vector<Reflection> BraggIntegrationEngine::Finalize(const std::vector<Reflection> &predicted,
|
|
size_t npredicted,
|
|
const std::vector<BraggPanel> &panels,
|
|
const std::vector<BraggFitResult> &results,
|
|
int64_t image_number) {
|
|
// Sized to what is kept, not to what was predicted: this vector is the one every image holds to the
|
|
// end of the pass, and a background ring lost to the neighbours drops a reflection here - on a
|
|
// dense pattern more than half of them, which a reserve of npredicted would carry as dead capacity.
|
|
size_t n_ok = 0;
|
|
for (size_t i = 0; i < npredicted; ++i)
|
|
n_ok += results[i].ok ? 1 : 0;
|
|
std::vector<Reflection> out;
|
|
out.reserve(n_ok);
|
|
for (size_t i = 0; i < npredicted; ++i) {
|
|
const auto &fr = results[i];
|
|
if (!fr.ok)
|
|
continue;
|
|
Reflection refl = predicted[i];
|
|
// In photons: the engine measured in ADU, with variances already taken at the gain.
|
|
refl.I = fr.I / pixel_gain;
|
|
refl.sigma = fr.sigma / pixel_gain;
|
|
refl.bkg = fr.bkg / pixel_gain;
|
|
refl.var_bkg = fr.var_bkg / (pixel_gain * pixel_gain);
|
|
if (fr.has_observed) {
|
|
refl.observed_x = fr.observed_x;
|
|
refl.observed_y = fr.observed_y;
|
|
}
|
|
// has_observed is set only for a signal disk every pixel of which was readable.
|
|
refl.clipped = !fr.has_observed;
|
|
refl.overloaded = fr.overloaded;
|
|
// A rotation merge drops the whole rocking event of an overloaded reflection. A still has no
|
|
// rocking event: its overloaded observations are the fully recorded ones of the strongest
|
|
// reflections, so dropping them leaves those reflections to their partial observations, whose
|
|
// partiality correction then sets the merged intensity - and it reads low. Measured on clipped
|
|
// CCD stills, the merged low-resolution intensities came out at 0.4-0.6x of the deposited ones.
|
|
// So a still keeps the observation as a lower bound: each saturated pixel counted at the
|
|
// saturation limit. (A fit to the unsaturated wings was tried and read lower still: the
|
|
// profile learned from unsaturated spots does not describe the wings of a clipped one.)
|
|
// That takes a pixel AT the limit: an observation overloaded only by pixels unreadable on
|
|
// this frame has nothing to count at the limit, and counting those pixels as zero is no
|
|
// lower bound of anything - it stays overloaded and is dropped. (A pixel stuck at the limit
|
|
// is no clipped spot either; the pre-scan masks it, HotPixelFinder.)
|
|
// Counted in ADU like everything else, its own variance at the gain, then in photons.
|
|
if (fr.overloaded && keep_overloads && fr.saturated > 0) {
|
|
const float I = fr.I + static_cast<float>(fr.saturated * (saturation_limit - fr.bkg));
|
|
refl.I = I / pixel_gain;
|
|
refl.sigma = std::sqrt(fr.var_bkg + pixel_gain * std::max(I, pixel_gain)) / pixel_gain;
|
|
refl.overloaded = false;
|
|
++counts.overloads_kept;
|
|
}
|
|
refl.observed = true;
|
|
if (polarization)
|
|
refl.prescaling_corr /= geom.CalcAzIntPolarizationCorr(panels[i].plane_x, panels[i].plane_y,
|
|
polarization.value());
|
|
refl.image_scale_corr = refl.prescaling_corr * refl.qe_corr * refl.flight_corr / refl.partiality;
|
|
refl.image_number = static_cast<float>(image_number);
|
|
out.push_back(refl);
|
|
}
|
|
return out;
|
|
}
|