The rc178 branch continued after #88 was squash-merged; this is its content fromb9ada7914(the tree #88 squashed) to094eee31a, as one change on main. The individual commits stay on the rc178 branch: - still battery: --cold evicts each set's input from the page cache; Alvra pixel masks; manifest fixes - AGIPD: converter to one rigid panel per module, int16 photons, mask folded in; still battery reads the converted sets - DetectorModules: panel look-up by id, plane -> panel, plane bounds and a per-pixel walk; mask size checked - tests: [PanelLayout] equivalence harness, stacked vs assembled panel layout - Dense per-pixel maps through the panel table (panel layout stage 1) - Panel id on spots and the panel table in the start message - Viewer and preview show a panel detector as it lies on the detector plane - Panel layout stage 3: per-panel spot finding (CPU+GPU, CCL) and raster algorithms on the plane raster - Panel layout stage 2: prediction and Bragg integration through the panel table - XTC reader: frames handed up as stored, streams indexed in parallel - XFEL reader: integer frames stay integer, one gain, placement by panel - EXTERNAL_TEST_DATA_STILLS.md: images used vs the entry for every set; drop "Not in the battery" - still battery: EuXFEL sets read the depositors' Cheetah CXI files directly; AGIPD converter removed - float32_to_int16.py: a non-finite pixel becomes -32768, the int16 error marker, not a zero count - CrystFEL geometry: read the pixel masks and bad regions instead of ignoring them - Panel layout stage 5: the XFEL reader hands up the stored frame and its panel table - Panel table persisted in NXmx; modulation grid and pre-scan memo key on the plane - still battery: Alvra sets take their pixel mask from the CrystFEL geometry, not --mask-good - still battery: take the deposited model as mmCIF where the RCSB has no PDB-format file - still battery: dimple gets PDB-format coordinates converted from an mmCIF-only deposition - stills fixed cost: geometry refinement and pre-scan do the same work in less wall time - stills geometry sample and trial spot finding run as many workers as the image pass - Stills integration: background ring reach measured on empty apertures, radial curvature from the whole frame - StillsPartialityRefine: the partiality width is the run's, measured on the crystals, not the prediction window's - MergeOnTheFly: capture term and multiplicative outlier band for the stills merge (both opt-in) - Stills given a cell: the written cell is the mean over the frames that are merged - Stills: the radial curvature correction decided per run on the same empty apertures - StillsPartialityRefine: measure the run width only on shells with signal - Stills pre-scan: report the paired z of the radial-correction comparison - module placement: the five concurrent fits each keep every thread - still battery: score R-free against the deposited data through the same dimple protocol - still battery: the R-free target is the lower of the deposition's dimple and published values - Stills: background clip follows the radial curve; signal disk from the lattice spacing - Stills keep an overloaded observation as a lower bound instead of dropping it - Stills: drop the lattice-spacing signal disk; the correction decision back on the plain clip - battery: R-free against the deposition without refinement only, one protocol for rotation and stills - still battery: EuXFEL sets run on a 2,000-image subset for now - stills geometry refinement: fit the detector distance and the cell together - stills geometry refinement: a distance given with --detector-distance is held in the joint fit - Metal integration engine: back to its panel-s5 kernels; Mac stills keep the old clip, no saturation count - Metal integration test: the CPU comparison runs on a rotation experiment - Stills: kept overloads go back to the drop where they are not concentrated at low resolution - stills distance search: an absolute 30-3000 mm scan, a sample found without d-limits, the fit iterated - battery results: XFEL still battery 20261022-1358 on rc1780cf62452c- 12 sets, cold, R_free unrefined - battery results: still summary gains the resolution of our merge and of the deposition - Pre-scan masks a pixel at the saturation limit on most frames, as one holding the error value - Stills keep an overload as a lower bound only where it has pixels at the limit; run-level gate removed - XFEL reader: a per-frame mask is read the way the frames are, its chunk inflated outside the HDF5 lock - stills distance search: every centre scanned with a wider acceptance, a larger sample where nothing indexes - rugnux usage: --beam-center-check describes the absolute 30-3000 mm stills distance scan, not the old 60-160 % grid - stills: detector tilt refined in the geometry pre-pass, kept where held-out frames agree - CLAUDE.md: the changelog is written once, just before the merge into main - condensed one-liners of what users get - broker: serve the placed model and its maps of the last rugnux live run (/rugnux/model.cif, /rugnux/map.ccp4) - frontend: Mol* viewer of the placed model and its density on the Rugnux live page - writer: _model.cif from rugnux live keeps the input model's metadata, as the served model.cif does - rugnux live maps: one unit cell centred on the model; the viewer opens on all of it - Anomalous difference map: ANOM/PHANOM in _maps.mtz, /rugnux/map.ccp4?type=anom, a magenta mesh in the viewer - Mol* viewer: open with the maps drawn within 10 A of the centre of view - Mol* viewer: aaredb's structure-viewer setup, on a black stage - rugnux: the gain of a stills file that states none is read off its single-photon peak where the background resolves photons - Mol* viewer: in line with AareDB's StructureView at its current head (8ece2c2) - ACKNOWLEDGEMENT: the web frontend's Mol* viewer follows AareDB's setup (G. Gotthard, PSI) - stills tilt decision: the modules stay where the run placed them - still battery: no distance pins and no per-set tuning flags; gain from the data except where a file's baseline hides it - battery results: rc178 6f6f69 - full still battery and full rotation battery with REFMAC - Bragg integration: a CCD rotation sweep's sigmas at the measured gain, intensities in photons - BackgroundGain: the gain of a frame that resolves photons is the spacing of its photon peaks, so a baseline left in the frame no longer hides it - ShadowFinder: measure the background's azimuthal modulation instead of predicting it from the polarization - BackgroundGain: one gain estimate for rotation and stills; the still battery no longer gives EuXFEL 152 its gain - ShadowFinder: measure the modulation robustly under a wide one-sided shadow - ShadowFinderTest: a one-sided shadow is not fitted as part of the background's modulation - ShadowFinder: back to the predicted polarization factor, in the plane the background shows - MXAnalysisAfterFPGA: the Bragg engine's edge mask in the image's layout, so raw / untransformed FPGA runs start again - battery results: still battery on rc178 e54f55 replaces the earlier rc178 still tables - XFELReader: per-frame mask and photon conversion without a library call or a branch per pixel - stills geometry trial: the hypotheses a search asks are indexed side by side - stills tilt decision: the fits start beside the pre-scan and the geometry trial - XFELReader: a deflated frame is inflated and converted a piece at a time, while it is in the cache - XFELReader: a byte-shuffled frame is unshuffled a piece at a time, a whole pixel per step - GeometryRefiner: residuals carry only the blocks a fit moves; beam and distance one block - GeometryRefiner: a spot's residual differentiates its observed and predicted halves apart - docs: EXTERNAL_TEST_DATA lists every open-arm battery set, with corrected values and counts - battery: doc_check.py cross-checks the manifests against the dataset pages - stills module placement: the placement fit gets half the threads, each held-out fit an eighth - still battery: the EuXFEL sets run on all their frames, as the published still runs did - battery results: still battery on rc178 a0ce6c, cold on a quiet machine, replaces the earlier rc178 table - macOS build: explicit size_t in the XFEL reader's row bounds; parentheses in the CPU preprocessor's select Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
262 lines
11 KiB
C++
262 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 <bit>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <limits>
|
|
#include <stdexcept>
|
|
|
|
#include "BeamCenterFFTCPU.h"
|
|
#include "BeamCenterFFTEngine.h"
|
|
#include "../../common/AssembleToPlane.h"
|
|
#include "../../common/CUDAWrapper.h"
|
|
#include <spdlog/spdlog.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
|
|
};
|
|
|
|
// Order-preserving: unsigned comparison of the keys orders the floats as < does (-0 below +0).
|
|
uint32_t SortKey(float v) {
|
|
const uint32_t u = std::bit_cast<uint32_t>(v);
|
|
return (u & 0x80000000u) ? ~u : (u | 0x80000000u);
|
|
}
|
|
|
|
// For each k, the value nth_element would leave at position k of the finite values of x - without
|
|
// copying or reordering the image. Counting the finite values by the upper 16 bits of their key
|
|
// finds the bucket holding the k-th; counting that bucket by the lower 16 bits finds its key,
|
|
// which is the value. Two passes over x for all k together.
|
|
std::vector<float> KthFinite(const std::vector<float> &x, std::vector<size_t> k) {
|
|
std::vector<size_t> upper(1 << 16, 0);
|
|
for (const float v : x)
|
|
if (std::isfinite(v))
|
|
upper[SortKey(v) >> 16]++;
|
|
std::vector<uint32_t> bucket(k.size(), 0);
|
|
for (size_t i = 0; i < k.size(); i++)
|
|
while (k[i] >= upper[bucket[i]])
|
|
k[i] -= upper[bucket[i]++];
|
|
|
|
std::vector<std::vector<size_t>> lower(k.size(), std::vector<size_t>(1 << 16, 0));
|
|
for (const float v : x)
|
|
if (std::isfinite(v))
|
|
for (size_t i = 0; i < k.size(); i++)
|
|
if (SortKey(v) >> 16 == bucket[i])
|
|
lower[i][SortKey(v) & 0xFFFF]++;
|
|
std::vector<float> out;
|
|
for (size_t i = 0; i < k.size(); i++) {
|
|
uint32_t b = 0;
|
|
while (k[i] >= lower[i][b])
|
|
k[i] -= lower[i][b++];
|
|
const uint32_t key = (bucket[i] << 16) | b;
|
|
out.push_back(std::bit_cast<float>((key & 0x80000000u) ? (key & 0x7FFFFFFFu) : ~key));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
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);
|
|
|
|
const size_t n_valid = std::count_if(mean.begin(), mean.end(), [](float v) { return std::isfinite(v); });
|
|
if (n_valid < 2)
|
|
return out;
|
|
|
|
// Where these fall among equal values a -0 can come out for a +0, which the clipping below
|
|
// turns into the same image.
|
|
const std::vector<float> q = KthFinite(mean, {static_cast<size_t>(0.999 * (n_valid - 1)), n_valid / 2});
|
|
const float clip_hi = q[0];
|
|
const float median = q[1];
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::vector<float> BeamCenterPointScore(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 * BEAM_CENTER_VARIANCE_FLOOR * global_variance)
|
|
r[i] = static_cast<float>(num / den);
|
|
}
|
|
return r;
|
|
}
|
|
|
|
std::vector<BeamCenterFFTCandidate> BeamCenterShortlist2D(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);
|
|
// Each round takes the first maximum of the whole surface in index order. Kept per row - its
|
|
// maximum and the first index holding it - so a round reads the rows' maxima, and only the rows
|
|
// the suppression touched are scanned again. Rows in order and the first index within a row is
|
|
// the first index overall, the same pick as one scan of the whole surface.
|
|
std::vector<float> row_v(h);
|
|
std::vector<int64_t> row_i(h);
|
|
const auto scan_row = [&](int64_t y) {
|
|
float v = -std::numeric_limits<float>::infinity();
|
|
int64_t at = -1;
|
|
for (int64_t x = 0; x < w; x++)
|
|
if (surface[static_cast<size_t>(y * w + x)] > v) {
|
|
v = surface[static_cast<size_t>(y * w + x)];
|
|
at = y * w + x;
|
|
}
|
|
row_v[y] = v;
|
|
row_i[y] = at;
|
|
};
|
|
for (int64_t y = 0; y < h; y++)
|
|
scan_row(y);
|
|
for (int k = 0; k < budget; k++) {
|
|
int64_t best = -1;
|
|
float best_v = -std::numeric_limits<float>::infinity();
|
|
for (int64_t y = 0; y < h; y++)
|
|
if (row_i[y] >= 0 && row_v[y] > best_v) {
|
|
best_v = row_v[y];
|
|
best = row_i[y];
|
|
}
|
|
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();
|
|
scan_row(y);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// The default route, and the whole of the CPU path: the four convolutions come back, and the
|
|
// combination and the search run here.
|
|
std::vector<BeamCenterFFTCandidate>
|
|
BeamCenterFFTEngine::PointShortlist(const std::vector<float> &a, const std::vector<float> &m,
|
|
int64_t h, int64_t w, const BeamCenterFFTSettings &settings,
|
|
double global_variance) {
|
|
const auto conv = PointSurfaces(a, m, h, w);
|
|
auto surface = BeamCenterPointScore(conv, settings.min_pair_fraction, global_variance);
|
|
return BeamCenterShortlist2D(surface, 2 * h, 2 * w, settings.nms_radius_pxl,
|
|
settings.candidates_point);
|
|
}
|
|
|
|
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);
|
|
|
|
result.point = engine.PointShortlist(prep.a, prep.m, height, width, settings, prep.variance);
|
|
result.margin_point = Margin(result.point);
|
|
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) {
|
|
if (BeamCenterFFTGPU::FitsInDeviceMemory(width, height)) {
|
|
BeamCenterFFTGPU gpu;
|
|
return BeamCenterFFTScore(width, height, mean, settings, gpu);
|
|
}
|
|
spdlog::info("Beam centre FFT: scoring on the CPU - on the GPU it needs {:.2f} GB, over half of the card",
|
|
BeamCenterFFTGPU::DeviceMemoryNeeded(width, height) / 1e9);
|
|
}
|
|
#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).
|
|
const DetectorModules &modules = experiment.GetModules();
|
|
const int64_t width = experiment.GetXPixelsNumConv(), height = experiment.GetYPixelsNumConv();
|
|
if (modules.empty())
|
|
return BeamCenterFFTScore(width, height, mean, settings);
|
|
|
|
// Panels: the point symmetry is about the beam on the detector plane, so the projection is scored as
|
|
// the plane raster (gaps hold nothing), and the centres come back in plane coordinates.
|
|
const PlaneRaster raster = PlaneRasterOf(modules, width, height);
|
|
BeamCenterFFTResult result = BeamCenterFFTScore(raster.width, raster.height,
|
|
AssembleToPlane(modules, mean, width, height, raster, NAN),
|
|
settings);
|
|
for (auto &c: result.point) {
|
|
c.beam_x_pxl += static_cast<float>(raster.x0);
|
|
c.beam_y_pxl += static_cast<float>(raster.y0);
|
|
}
|
|
return result;
|
|
}
|