BeamCenterFFTScore is split the way the FFT indexer is: an engine interface with a cuFFT implementation and an fftw3f one, chosen by whether CUDA is compiled in, a device is visible and the card has room for the image. Everything that decides anything - the preparation, the masked Pearson, the shortlist and the margins - is shared, so the engines can differ only in how the four convolutions are computed, and the parity test compares two shortlists rather than two answers. The whole-detector transform was the entire added cost of the capture (4-15 s a run on CPU, all of it the transforms). On the device it is milliseconds, so the composition is now cheaper than the walk it replaced rather than dearer - which is what makes 2x2 binning, the other way out, unnecessary: full resolution is affordable and the binned capture was measured to pick a neighbouring peak on one dataset of 51. Device discipline, because the card is shared with the run's own analysis workers: the four convolution surfaces are brought back to the host and combined there, so the device holds only one real buffer and three spectra; the spectrum of the image is reused for its square; plans and buffers are created inside the call that needs them and freed when it returns; and an image that would not fit is scored on the CPU instead. Tests, none of which need a GPU or a dataset: the autoconvolution identity (an exactly symmetric image is recovered at integer and half-pixel centres, scoring exactly 1), shift equivariance, the four convolutions against brute force, the no-variance overlap that VARIANCE_FLOOR exists for, the smooth pad, GPU against CPU, and the composition - a walk seeded at the capture where the walk alone declines, the fallback to the capture alone at BEAM_CENTER_CAPTURE_SIGMA_PXL, and the beam stop being blanked out of the scored image.
258 lines
11 KiB
C++
258 lines
11 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "BeamCenterFFT.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <limits>
|
|
#include <stdexcept>
|
|
|
|
#include "BeamCenterFFTCPU.h"
|
|
#include "BeamCenterFFTEngine.h"
|
|
#include "../../common/CUDAWrapper.h"
|
|
#ifdef JFJOCH_USE_CUDA
|
|
#include "BeamCenterFFTGPU.h"
|
|
#include "../../common/JFJochException.h"
|
|
#endif
|
|
|
|
namespace {
|
|
|
|
// The image prepared for scoring: hot pixels clipped at the 99.9th percentile, the median
|
|
// background level removed, negatives clamped, masked pixels zero. The final subtraction of the
|
|
// valid-pixel mean changes nothing mathematically - the masked Pearson is exactly invariant under
|
|
// a global shift of the image (both D*C - S^2 and D*Q - S^2 cancel the shift) - but it removes
|
|
// most of the large-term cancellation that single-precision FFTs would otherwise have to survive.
|
|
struct Prepared {
|
|
std::vector<float> a; // image, 0 on masked pixels
|
|
std::vector<float> m; // 1 on valid pixels, 0 on masked
|
|
double variance = 0.0; // of a over the valid pixels, after the whole preparation
|
|
};
|
|
|
|
Prepared PrepareImage(const std::vector<float> &mean) {
|
|
Prepared out;
|
|
const size_t n = mean.size();
|
|
out.a.assign(n, 0.0f);
|
|
out.m.assign(n, 0.0f);
|
|
|
|
std::vector<float> valid;
|
|
valid.reserve(n);
|
|
for (float v : mean)
|
|
if (std::isfinite(v))
|
|
valid.push_back(v);
|
|
if (valid.size() < 2)
|
|
return out;
|
|
|
|
auto kth = [&valid](size_t k) {
|
|
std::nth_element(valid.begin(), valid.begin() + k, valid.end());
|
|
return valid[k];
|
|
};
|
|
const float clip_hi = kth(static_cast<size_t>(0.999 * (valid.size() - 1)));
|
|
const float median = kth(valid.size() / 2);
|
|
|
|
double sum = 0.0;
|
|
size_t count = 0;
|
|
for (size_t i = 0; i < n; i++) {
|
|
if (!std::isfinite(mean[i]))
|
|
continue;
|
|
const float v = std::max(0.0f, std::min(std::max(mean[i], 0.0f), clip_hi) - median);
|
|
out.a[i] = v;
|
|
out.m[i] = 1.0f;
|
|
sum += v;
|
|
count++;
|
|
}
|
|
const float shift = static_cast<float>(sum / static_cast<double>(count));
|
|
double var = 0.0;
|
|
for (size_t i = 0; i < n; i++)
|
|
if (out.m[i] != 0.0f) {
|
|
out.a[i] -= shift;
|
|
var += static_cast<double>(out.a[i]) * static_cast<double>(out.a[i]);
|
|
}
|
|
out.variance = var / static_cast<double>(count);
|
|
return out;
|
|
}
|
|
|
|
// Greedy shortlist of the 2D surface: strongest peak first, a square of half-width `nms` around
|
|
// each taken peak suppressed. The surface is indexed by t = 2c, so the centre grid is half-pixel.
|
|
std::vector<BeamCenterFFTCandidate> Shortlist2D(std::vector<float> &surface, int64_t h, int64_t w,
|
|
float nms_pxl, int budget) {
|
|
std::vector<BeamCenterFFTCandidate> out;
|
|
const int64_t d = std::lround(2.0f * nms_pxl);
|
|
for (int k = 0; k < budget; k++) {
|
|
int64_t best = -1;
|
|
float best_v = -std::numeric_limits<float>::infinity();
|
|
for (int64_t i = 0; i < h * w; i++)
|
|
if (surface[i] > best_v) {
|
|
best_v = surface[i];
|
|
best = i;
|
|
}
|
|
if (best < 0 || !std::isfinite(best_v))
|
|
break;
|
|
const int64_t iy = best / w, ix = best % w;
|
|
out.push_back({static_cast<float>(ix) / 2.0f, static_cast<float>(iy) / 2.0f, best_v});
|
|
for (int64_t y = std::max<int64_t>(0, iy - d); y <= std::min(h - 1, iy + d); y++)
|
|
for (int64_t x = std::max<int64_t>(0, ix - d); x <= std::min(w - 1, ix + d); x++)
|
|
surface[static_cast<size_t>(y * w + x)] =
|
|
-std::numeric_limits<float>::infinity();
|
|
}
|
|
return out;
|
|
}
|
|
|
|
std::vector<BeamCenterFFTCandidate> Shortlist1D(std::vector<float> &surface, float nms_pxl,
|
|
int budget, bool is_x) {
|
|
std::vector<BeamCenterFFTCandidate> out;
|
|
const int64_t n = static_cast<int64_t>(surface.size());
|
|
const int64_t d = std::lround(2.0f * nms_pxl);
|
|
for (int k = 0; k < budget; k++) {
|
|
int64_t best = -1;
|
|
float best_v = -std::numeric_limits<float>::infinity();
|
|
for (int64_t i = 0; i < n; i++)
|
|
if (surface[i] > best_v) {
|
|
best_v = surface[i];
|
|
best = i;
|
|
}
|
|
if (best < 0 || !std::isfinite(best_v))
|
|
break;
|
|
const float c = static_cast<float>(best) / 2.0f;
|
|
const float nan = std::numeric_limits<float>::quiet_NaN();
|
|
out.push_back(is_x ? BeamCenterFFTCandidate{c, nan, best_v}
|
|
: BeamCenterFFTCandidate{nan, c, best_v});
|
|
for (int64_t i = std::max<int64_t>(0, best - d); i <= std::min(n - 1, best + d); i++)
|
|
surface[i] = -std::numeric_limits<float>::infinity();
|
|
}
|
|
return out;
|
|
}
|
|
|
|
float Margin(const std::vector<BeamCenterFFTCandidate> &peaks) {
|
|
if (peaks.size() < 2 || peaks[0].score <= 0.0f)
|
|
return 0.0f;
|
|
return (peaks[0].score - peaks[1].score) / peaks[0].score;
|
|
}
|
|
|
|
// The masked Pearson r(t) = (D*C - S^2) / (D*Q - S^2) from the four convolution surfaces,
|
|
// combined elementwise in double: the numerator and denominator are cancellations of large
|
|
// near-equal terms, and the shortlist margins ride on differences of ~0.3 %.
|
|
// The denominator D*Q - S^2 equals D^2 times the image variance over the mirror overlap, so an
|
|
// overlap with (nearly) no variance - a mirrored empty corner, everything clamped to zero by the
|
|
// preparation - carries no evidence and its r is pure roundoff (measured: such a region minted
|
|
// r = 4..274 on a synthetic image, in float64 as much as float32). VARIANCE_FLOOR is the fraction
|
|
// of the whole image's variance an overlap must reach to be scored at all; on real detector
|
|
// images every overlap that passes the pair-count gate is orders of magnitude above it.
|
|
constexpr double VARIANCE_FLOOR = 1e-4;
|
|
|
|
std::vector<float> PearsonSurface(const BeamCenterConvSurfaces2D &conv, float min_pair_fraction,
|
|
double global_variance) {
|
|
const size_t n = conv.C.size();
|
|
float d_max = 0.0f;
|
|
for (size_t i = 0; i < n; i++)
|
|
d_max = std::max(d_max, conv.D[i]);
|
|
const double lim = min_pair_fraction * d_max;
|
|
std::vector<float> r(n, -std::numeric_limits<float>::infinity());
|
|
for (size_t i = 0; i < n; i++) {
|
|
const double d = conv.D[i];
|
|
if (d <= lim)
|
|
continue;
|
|
const double s = conv.S[i];
|
|
const double num = d * conv.C[i] - s * s;
|
|
const double den = d * conv.Q[i] - s * s;
|
|
if (den > d * d * VARIANCE_FLOOR * global_variance)
|
|
r[i] = static_cast<float>(num / den);
|
|
}
|
|
return r;
|
|
}
|
|
|
|
// The same score for a 1D line mirror, whose accumulators were summed over the other coordinate.
|
|
std::vector<float> PearsonLine(const BeamCenterConvSurfaces1D &conv, float min_pair_fraction,
|
|
double global_variance) {
|
|
const size_t n = conv.C.size();
|
|
double d_max = 0.0;
|
|
for (double d : conv.D)
|
|
d_max = std::max(d_max, d);
|
|
const double lim = min_pair_fraction * d_max;
|
|
std::vector<float> r(n, -std::numeric_limits<float>::infinity());
|
|
for (size_t i = 0; i < n; i++) {
|
|
const double num = conv.D[i] * conv.C[i] - conv.S[i] * conv.S[i];
|
|
const double den = conv.D[i] * conv.Q[i] - conv.S[i] * conv.S[i];
|
|
// Same no-evidence floor as PearsonSurface: see VARIANCE_FLOOR.
|
|
if (conv.D[i] > lim && den > conv.D[i] * conv.D[i] * VARIANCE_FLOOR * global_variance)
|
|
r[i] = static_cast<float>(num / den);
|
|
}
|
|
return r;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int64_t BeamCenterFFTPadSize(int64_t n) {
|
|
for (int64_t k = n;; ++k) {
|
|
int64_t v = k;
|
|
for (int p : {2, 3, 5, 7})
|
|
while (v % p == 0)
|
|
v /= p;
|
|
if (v == 1)
|
|
return k;
|
|
}
|
|
}
|
|
|
|
BeamCenterFFTResult BeamCenterFFTScore(int64_t width, int64_t height,
|
|
const std::vector<float> &mean,
|
|
const BeamCenterFFTSettings &settings,
|
|
BeamCenterFFTEngine &engine) {
|
|
if (width <= 0 || height <= 0
|
|
|| mean.size() != static_cast<size_t>(width) * static_cast<size_t>(height))
|
|
throw std::runtime_error("BeamCenterFFT: image dimensions do not match the projection");
|
|
|
|
BeamCenterFFTResult result;
|
|
const Prepared prep = PrepareImage(mean);
|
|
|
|
{
|
|
const auto conv = engine.PointSurfaces(prep.a, prep.m, height, width);
|
|
auto surface = PearsonSurface(conv, settings.min_pair_fraction, prep.variance);
|
|
result.point = Shortlist2D(surface, 2 * height, 2 * width, settings.nms_radius_pxl,
|
|
settings.candidates_point);
|
|
}
|
|
|
|
{
|
|
const auto conv_y = engine.LineSurfaces(prep.a, prep.m, height, width,
|
|
BeamCenterMirror::Rows);
|
|
auto ry = PearsonLine(conv_y, settings.min_pair_fraction, prep.variance);
|
|
result.line_y = Shortlist1D(ry, settings.nms_radius_pxl, settings.candidates_line, false);
|
|
|
|
const auto conv_x = engine.LineSurfaces(prep.a, prep.m, height, width,
|
|
BeamCenterMirror::Columns);
|
|
auto rx = PearsonLine(conv_x, settings.min_pair_fraction, prep.variance);
|
|
result.line_x = Shortlist1D(rx, settings.nms_radius_pxl, settings.candidates_line, true);
|
|
}
|
|
|
|
result.margin_point = Margin(result.point);
|
|
result.margin_line_x = Margin(result.line_x);
|
|
result.margin_line_y = Margin(result.line_y);
|
|
return result;
|
|
}
|
|
|
|
BeamCenterFFTResult BeamCenterFFTScore(int64_t width, int64_t height,
|
|
const std::vector<float> &mean,
|
|
const BeamCenterFFTSettings &settings) {
|
|
#ifdef JFJOCH_USE_CUDA
|
|
if (get_gpu_count() > 0 && BeamCenterFFTGPU::FitsInDeviceMemory(width, height)) {
|
|
BeamCenterFFTGPU gpu;
|
|
try {
|
|
return BeamCenterFFTScore(width, height, mean, settings, gpu);
|
|
} catch (const JFJochException &) {
|
|
// The card is shared with this run's analysis workers, so the memory the check above
|
|
// saw can be gone by the time it is asked for. A capture is not worth failing a run
|
|
// over: score it on the CPU instead.
|
|
}
|
|
}
|
|
#endif
|
|
BeamCenterFFTCPU cpu;
|
|
return BeamCenterFFTScore(width, height, mean, settings, cpu);
|
|
}
|
|
|
|
BeamCenterFFTResult FindBeamCenterFFT(const DiffractionExperiment &experiment,
|
|
const std::vector<float> &mean,
|
|
const BeamCenterFFTSettings &settings) {
|
|
// The pre-scan projection is in converted geometry (ShadowFinder's frame).
|
|
return BeamCenterFFTScore(experiment.GetXPixelsNumConv(), experiment.GetYPixelsNumConv(),
|
|
mean, settings);
|
|
}
|