Merge rc175-metal-integration into rc175: Metal Bragg integration engine on macOS; CPU profile fit in single precision with a shared exp (result-changing at ulp level on the CPU path only); CPU partial scaling loop reads its working copy in order

The CUDA engine is untouched: on the RTX 5080 p.hkl is byte-identical to rc175 on 4nwv, lysozyme
and cytochrome C. Under --no-gpu (lysozyme, cytochrome C, myoglobin) intensities move at the ulp
level from the single-precision fit and nothing changes beyond noise in battery compare; wall
44/117/71 s -> 41/105/65 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 09:25:26 +02:00
co-authored by Claude Opus 5.5
24 changed files with 1875 additions and 378 deletions
+9 -1
View File
@@ -269,7 +269,15 @@ jobs:
shell: bash
run: |
cmake --build build-macos --target jfjoch_portable_test -j "$(sysctl -n hw.ncpu)"
cd build-macos/tests && ./jfjoch_portable_test "[portable]"
cd build-macos/tests && ./jfjoch_portable_test "[portable]~[Metal]"
# The Metal Bragg integration engine has to give the CPU engine's result bit for bit
# (BraggIntegrationEngineMetal.h): every reflection's every float, on random crowded frames
# through every integrator, overlap and background mode. Its own step, so a mismatch is named as
# such; it fails rather than skips on a machine without a Metal device, as this is the only job
# that can check it.
- name: Metal integration engine is bit-identical to the CPU engine
shell: bash
run: cd build-macos/tests && ./jfjoch_portable_test "[Metal]"
# One build, two artifacts: the viewer .dmg and rugnux as .tar.gz (CPACK_PROJECT_CONFIG_FILE
# in CMakeLists.txt gives each generator its components). The XDS plugin is Linux-only.
- name: Build viewer DMG and rugnux tgz
+11 -2
View File
@@ -21,6 +21,14 @@ INCLUDE(GNUInstallDirs)
SET(CMAKE_CXX_STANDARD 20)
SET(CMAKE_CXX_STANDARD_REQUIRED True)
# The Metal Bragg integration engine (image_analysis/bragg_integration) is Objective-C++. Enabled here,
# above every target that links it, as enable_language asks.
IF (APPLE)
ENABLE_LANGUAGE(OBJCXX)
SET(CMAKE_OBJCXX_STANDARD 20)
SET(CMAKE_OBJCXX_STANDARD_REQUIRED True)
ENDIF()
# MSVC gets C++ exception handling only from /EHsc, and CMake puts that flag in the DEFAULT value of
# CMAKE_CXX_FLAGS ("/DWIN32 /D_WINDOWS /GR /EHsc"), which is used only when the configure does not
# set CMAKE_CXX_FLAGS itself. CI does (-DCMAKE_CXX_FLAGS="/arch:AVX"), and that drops /EHsc: MSVC then
@@ -47,9 +55,10 @@ ENDIF()
# came out differently from the same source and data. Off, every x86-64 level computes the same bits
# (measured: identical p.mtz on nine battery sets, no measurable time cost), and aarch64, where FMA is
# always there and GCC contracts by default, follows the same arithmetic. MSVC does not contract under
# its default /fp:precise. Host code of .cu sources gets it through nvcc.
# its default /fp:precise. Host code of .cu sources gets it through nvcc, and the Metal kernels say so in
# their source (BraggIntegrationEngineMetal.mm).
IF (NOT MSVC)
ADD_COMPILE_OPTIONS($<$<COMPILE_LANGUAGE:C,CXX>:-ffp-contract=off>)
ADD_COMPILE_OPTIONS($<$<COMPILE_LANGUAGE:C,CXX,OBJCXX>:-ffp-contract=off>)
ADD_COMPILE_OPTIONS($<$<COMPILE_LANGUAGE:CUDA>:-Xcompiler=-ffp-contract=off>)
ENDIF()
# The same for device code. nvcc contracts by default and does it separately for every architecture
+12 -1
View File
@@ -3,6 +3,7 @@
#include "CUDAWrapper.h"
#include <atomic>
#include <condition_variable>
#include <mutex>
@@ -93,7 +94,17 @@ int32_t get_gpu_count() {
return 0;
}
void hide_gpus() {}
namespace {
std::atomic<bool> gpus_hidden{false};
}
void hide_gpus() {
gpus_hidden = true;
}
bool gpus_are_hidden() {
return gpus_hidden;
}
std::vector<std::string> get_gpu_names() {
return {};
+4
View File
@@ -29,6 +29,10 @@ void hide_gpus() {
gpus_hidden = true;
}
bool gpus_are_hidden() {
return gpus_hidden;
}
int32_t get_gpu_count() {
if (gpus_hidden)
return 0;
+3
View File
@@ -16,6 +16,9 @@ int32_t get_gpu_count();
// CUDA_VISIBLE_DEVICES= does, asked for by the program (rugnux --no-gpu). Call it before anything uses
// CUDA; there is no way back.
void hide_gpus();
// Whether hide_gpus() was called - for a GPU path that is not CUDA's (the Metal integration engine), so
// --no-gpu keeps the process off every GPU.
bool gpus_are_hidden();
// Names of the visible GPUs, in device order and one entry per device, so repeated cards repeat.
// Empty without CUDA and on a machine with no device, which is also what get_gpu_count() == 0 says.
+4 -1
View File
@@ -45,7 +45,8 @@ UDPError,
CommunicationError,
SimplonError,
CalibrationError,
IndexerError
IndexerError,
GPUMetalError
};
class JFJochException : public std::exception {
@@ -128,6 +129,8 @@ protected:
return "Detector calibration error";
case JFJochExceptionCategory::IndexerError:
return "Indexer error";
case JFJochExceptionCategory::GPUMetalError:
return "Metal (GPU) error";
default:
return "";
}
+7
View File
@@ -523,6 +523,13 @@ corrected once by the exact remainder, which returns the correctly rounded quoti
P. W. Markstein, "Computation of elementary functions on the IBM RISC System/6000 processor"
(1990), IBM J. Res. Dev. 34, 111-119.
The single-precision exp the Bragg integration's profile fit shares between the CPU and an Apple GPU
(`BraggExp.h`) is built from textbook error-free floating-point arithmetic: the argument reduction of
W. J. Cody and W. Waite, "Software Manual for the Elementary Functions" (1980), Prentice-Hall; the
exact product (with Veltkamp's split) and Fast2Sum of T. J. Dekker, "A floating-point technique for
extending the available precision" (1971), Numer. Math. 18, 224-242; and the 2Sum of D. E. Knuth,
"The Art of Computer Programming, Vol. 2: Seminumerical Algorithms", section 4.2.2.
This software uses the Viridis, Magma and Inferno colormaps from Matplotlib under its
BSD-compatible license. J. D. Hunter, "Matplotlib: A 2D graphics environment" (2007), Comput. Sci.
Eng. 9, 90-95 [doi:10.1109/MCSE.2007.55](https://doi.org/10.1109/MCSE.2007.55).
+13
View File
@@ -180,3 +180,16 @@ The placements are then refined, scored and checked against their null with the
structure factors above; the likelihood scoring runs on the host. The search is described in
[§14.10](CPU_DATA_ANALYSIS_DECISIONS.md#1410-molecular-replacement-placing-a-model-the-rigid-body-cannot), its keys and decision rules in
[Molecular replacement](RUGNUX_REPORT.md#molecular-replacement).
## Apple GPUs (Metal)
A macOS build has no CUDA, and one engine runs on the Apple GPU instead:
- **Bragg integration** (`BraggIntegrationEngineMetal.mm`, `.metal`) - the CPU engine with its
pixel work moved to the GPU: the neighbour mask, the ownership of shared pixels, the box
summation's walk over every reflection, the background clip and the single-precision profile fit.
Everything computed in double and the profile learning stay on the CPU, as Apple GPUs have no
double precision. The fit's Gaussian uses an exp of our own (`BraggExp.h`) on both, as no GPU exp
matches the CPU's. The same result as the CPU engine **to the bit**, which a macOS CI step checks on
every push; `rugnux --no-gpu` keeps it off the GPU.
[§9.2](CPU_DATA_ANALYSIS_INTEGRATION.md#92-box-summation-seed-and-fallback)
+10 -1
View File
@@ -19,6 +19,9 @@
#include "spot_finding/ImageSpotFinderCPU.h"
#include "spot_finding/AdaptiveSpotFinderCPU.h"
#include "bragg_integration/BraggIntegrationEngineCPU.h"
#ifdef __APPLE__
#include "bragg_integration/BraggIntegrationEngineMetal.h"
#endif
#ifdef JFJOCH_USE_CUDA
#include "azint/AzIntEngineGPU.h"
#include "roi/ROIIntegrationGPU.h"
@@ -53,7 +56,13 @@ MXAnalysisWithoutFPGA::MXAnalysisWithoutFPGA(const DiffractionExperiment &in_exp
auto cpu_preprocessor = std::make_unique<ImagePreprocessorCPU>(in_experiment, in_mask);
preprocessor_cpu = cpu_preprocessor.get();
preprocessor = std::move(cpu_preprocessor);
bragg_engine = std::make_unique<BraggIntegrationEngineCPU>(in_experiment, in_mask);
#ifdef __APPLE__
// The CPU engine with its integer pixel work on the Apple GPU: the same result, bit for bit.
if (BraggIntegrationEngineMetal::Available(in_experiment))
bragg_engine = std::make_unique<BraggIntegrationEngineMetal>(in_experiment, in_mask);
else
#endif
bragg_engine = std::make_unique<BraggIntegrationEngineCPU>(in_experiment, in_mask);
if (experiment.ROI().size() >= 1)
roi = std::make_unique<ROIIntegrationCPU>(experiment);
#ifdef JFJOCH_USE_CUDA
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
// exp in single precision, the same bits wherever it is compiled: the host compiler (no FMA contraction,
// see CMakeLists.txt) and the Metal compiler (fast math off) both see only IEEE +, -, * rounded to
// nearest and exact scaling by a power of two, so the profile fit's Gaussian is the same on the CPU and
// on an Apple GPU, whose exp no host function reproduces. It is closer to correctly rounded than macOS's
// expf: at most 0.53 ulp for a normal result, and away from a correctly rounded float for 0.2% of
// arguments below -1 and 0.004% in [-1, 0].
//
// exp(x) = 2^k exp(r), k = rint(x / ln2), r = x - k ln2 with ln2 split three ways, the first two parts
// of 16 bits so that k times them is exact (Cody & Waite). r is carried as rr + re, rr^2 is formed exactly
// as zh + zl (Dekker's product with Veltkamp's split) and the sums into 1 + rr + rr^2/2 are error-free
// (2Sum, Fast2Sum), so the only rounding that matters is the last one. The rest of the series is a
// degree-4 minimax fit, relative error 1.3e-10.
#ifdef __METAL_VERSION__
inline float BraggBitsToFloat(int i) { return as_type<float>(i); }
#else
#include <bit>
#include <cstdint>
inline float BraggBitsToFloat(int32_t i) { return std::bit_cast<float>(i); }
#endif
// p * 2^k, rounded once where the result is subnormal.
inline float BraggScale2(float p, int k) {
if (k < -125)
return (p * BraggBitsToFloat((k + 100 + 127) << 23)) * BraggBitsToFloat((-100 + 127) << 23);
return p * BraggBitsToFloat((k + 127) << 23);
}
// Following Cody & Waite (1980) Software Manual for the Elementary Functions; Dekker (1971) Numer. Math.
// 18, 224-242; Knuth, The Art of Computer Programming Vol. 2, 4.2.2 (2Sum).
inline float BraggExp(float x) {
if (x < -104.0f) return 0.0f; // below half the smallest subnormal
if (x > 88.72283f) return BraggBitsToFloat(0x7f800000); // overflows
const float shifter = 12582912.0f; // 1.5 * 2^23: adding it rounds to an integer
const float kf = (x * 1.44269504f + shifter) - shifter;
const int k = static_cast<int>(kf);
const float r1 = x - kf * 0.693145751953125f; // exact
const float t2 = kf * 1.4286197256296873e-06f; // exact
const float rr = r1 - t2; // r1 - t2 = rr + e2 (2Sum)
const float bv0 = rr - r1;
const float e2 = (r1 - (rr - bv0)) + (-t2 - bv0);
const float re = e2 - kf * -1.2905320041778356e-11f;
const float sp = rr * 4097.0f; // rr^2 = zh + zl
const float rh = sp - (sp - rr);
const float rl = rr - rh;
const float zh = rr * rr;
const float zl = ((rh * rh - zh) + (rh * rl + rh * rl)) + rl * rl;
const float h = 0.1666666567325592f + rr * (0.04166644066572189f + rr * (0.008333569392561913f
+ rr * (0.0013937620678916574f + rr * 0.0001974606711883098f)));
const float c = 0.5f * zl + ((re + re * rr) + (zh * rr) * h);
const float hz = 0.5f * zh; // rr + zh/2 = s1 + e1 (2Sum)
const float s1 = rr + hz;
const float bv1 = s1 - rr;
const float e1 = (rr - (s1 - bv1)) + (hz - bv1);
const float t1 = 1.0f + s1; // 1 + s1 = t1 + e3 (Fast2Sum, |s1| < 1)
const float e3 = (1.0f - t1) + s1;
return BraggScale2(t1 + (e3 + (e1 + c)), k);
}
@@ -36,13 +36,6 @@ inline void prefetch_row(const void *first, const void *last) {
prefetch(last);
}
// Identity sampler over the preprocessed int32 buffer (already in that convention).
struct BufferSampler {
const int32_t *p;
int32_t operator[](size_t i) const { return p[i]; }
const void *address(size_t i) const { return p + i; }
};
// Sampler over a raw detector image of pixel type T: masked pixels carry the type minimum, saturated
// the type maximum (the FPGA image has no lossy-codec +/-1 band). Only the pixels actually read - the
// reflection disks - are converted, so there is no whole-image pass.
@@ -65,9 +58,9 @@ struct ImageSampler {
BraggIntegrationEngineCPU::BraggIntegrationEngineCPU(const DiffractionExperiment &experiment,
const PixelMask &mask)
: BraggIntegrationEngine(experiment),
static_mask(mask.GetPackedMask()),
refl_mask(static_cast<int>(xpixel), static_cast<int>(ypixel), 0),
owner(static_cast<int>(xpixel), static_cast<int>(ypixel), BRAGG_OWNER_NONE) {}
owner(static_cast<int>(xpixel), static_cast<int>(ypixel), BRAGG_OWNER_NONE),
static_mask(mask.GetPackedMask()) {}
void BraggIntegrationEngineCPU::MarkNeighbours(const std::vector<Reflection> &predicted, size_t npredicted) {
const int W = static_cast<int>(xpixel), H = static_cast<int>(ypixel);
@@ -133,23 +126,13 @@ template <class Sampler>
std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
const std::vector<Reflection> &predicted,
size_t npredicted, int64_t image_number) {
std::vector<BraggFitResult> results(npredicted);
if (npredicted == 0)
return Finalize(predicted, npredicted, results, image_number);
return Finalize(predicted, npredicted, std::vector<BraggFitResult>(npredicted), image_number);
counts.predicted += npredicted;
const int W = static_cast<int>(xpixel), H = static_cast<int>(ypixel);
const bool do_clip = bkg_clip_nsigma > 0.0f && mode != IntegratorMode::BoxSum;
// Symmetric trimmed-mean background fraction (BraggIntegrationSettings, rugnux --background-trim):
// replaces the r2..r3 ring MEAN with an f-trimmed mean, robust to the high-side contamination
// (neighbour wings, tails) that biases the plain mean up and makes it over-subtract weak high-angle
// reflections. The base ctor has already forced it to 0 whenever the clip below is in force, which
// is the default - the two are alternatives.
const double bkg_trim_frac = bkg_trim;
auto grid_idx = [this](int dx, int dy) { return (dy + R) * G + (dx + R); };
MarkNeighbours(predicted, npredicted);
owner.Clear();
@@ -184,20 +167,6 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
};
// --- Pass A: box-sum every reflection (rough I, background, centroid, strong flag). ---
struct Rough {
double I = 0.0, sigma = NAN, bkg = 0.0, obs_x = 0.0, obs_y = 0.0;
double bkg_var = 0.0; // variance of the background ESTIMATE itself, bkg / n_bkg
double var_bkg = 0.0; // total non-signal variance carried to the merge
int64_t I_sum = 0; // kept so I can be rebuilt after the radial background correction
int n_inner = 0;
int n_disk = 0, n_own = 0; // signal-disk pixels, and how many of them are this reflection's
int r_bin = 0; // rounded distance from the beam centre, indexes the radial curve
int k_bin = 0; // which radial-background kernel this reflection's stencil needs
int cx = 0, cy = 0, shell = -1;
bool ok = false, strong = false, has_obs = false;
bool full = false; // every pixel of the signal disk was readable
bool overloaded = false; // a pixel of the signal disk was saturated
};
// A saturated pixel, or one unreadable on this frame alone - the overload marker of a detector
// that writes its error value for a pixel it could not count (EIGER), which the preprocessor turns
// into a masked pixel like a gap's. The run's mask holds the gaps, so whatever is unreadable beyond
@@ -205,26 +174,13 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
auto static_masked = [&](size_t idx) {
return idx / 32 < static_mask.size() && ((static_mask[idx / 32] >> (idx % 32)) & 1U);
};
std::vector<Rough> rough(npredicted);
double inv_d2_min = std::numeric_limits<double>::max(), inv_d2_max = 0.0;
PassA a = StartPassA(npredicted);
std::vector<int32_t> bkg_vals; // reused per reflection for the trimmed-mean background (idea 1)
// The ring pixels the background clip below reads, with their radial offsets, gathered while pass
// A walks the box anyway - in the order the clip sums them, so it sums the same values.
// A walks the box anyway.
struct ClipPixel { int32_t px; float rad; };
std::vector<ClipPixel> clip_ring;
// Radial background curve, accumulated from the annulus pixels this pass already reads. A pixel's
// radius is the reflection's radius plus the pixel's projection on the beam->reflection direction,
// so no per-pixel sqrt is needed.
const int n_rad = static_cast<int>(std::ceil(std::hypot(std::max<double>(beam_x, W - beam_x),
std::max<double>(beam_y, H - beam_y)))) + 2;
std::vector<double> rad_sum;
std::vector<int> rad_cnt;
if (bkg_radial) {
rad_sum.assign(n_rad, 0.0);
rad_cnt.assign(n_rad, 0);
}
// The box pass A reads: the OUTER ellipse, tightly - taking the largest semi-axis in both
// directions instead would read up to 60% more pixels for nothing.
struct Box { int x0, x1, y0, y1; };
@@ -247,28 +203,18 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
img.address(static_cast<size_t>(y) * W + b.x1));
}
const auto &r = predicted[i];
Rough out;
// This reflection's stencil: the r1 signal disk, and the r2..r3 ring elongated radially by
// the analytic smear.
const BraggStencil st = MakeBraggStencil(r.predicted_x, r.predicted_y, stencil);
const Box box = box_of(r, st);
const int x0 = box.x0, x1 = box.x1, y0 = box.y0, y1 = box.y1;
// Both from the stencil's own radius, so nothing downstream is derived from a second one.
out.r_bin = std::clamp(static_cast<int>(std::lround(st.r0)), 0, n_rad - 1);
out.k_bin = BraggStencilKernelIndex(st, n_kern);
int64_t I_sum = 0, I_sum_x = 0, I_sum_y = 0, n_inner = 0, n_inner_valid = 0;
int64_t x_sum = 0, y_sum = 0; // positions of the pixels behind I_sum, for the centroid
int n_disk = 0, n_own = 0; // pixels in the signal disk, and how many are this reflection's
double bkg_sum = 0.0;
int n_bkg = 0;
// Readable ring pixels, and those of them no predicted neighbour's signal region covers.
// refl_mask marks d.inner < r2_sq and the ring is d.inner >= r2_sq, so the two are
// complementary and a masked ring pixel is always someone else's - never this reflection's
// n_ring / n_clean: readable ring pixels, and those of them no predicted neighbour's signal
// region covers. refl_mask marks d.inner < r2_sq and the ring is d.inner >= r2_sq, so the two
// are complementary and a masked ring pixel is always someone else's - never this reflection's
// own core. That makes the difference an exact count of what the pattern's density took,
// separable from what the detector took.
int n_ring = 0, n_clean = 0;
BraggBoxSums s{};
bkg_vals.clear();
clip_ring.clear();
for (int y = y0; y <= y1; ++y)
@@ -282,154 +228,324 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
// mode it leaves the disk entirely - the sum, the pixel count the background is
// subtracted with, and the all-or-nothing validity gate alike. The box sum only
// counts how many were lost, which is all it can act on.
++n_disk;
++s.n_disk;
if (overlap != OverlapMode::Off) {
if (clean(x, y, i)) ++n_own;
if (clean(x, y, i)) ++s.n_own;
else if (exclude) continue;
}
++n_inner;
++s.n_inner;
if (px == INT32_MAX
|| (px == INT32_MIN && !static_masked(static_cast<size_t>(y) * W + x)))
out.overloaded = true;
s.overloaded = 1;
if (!valid(px)) continue;
I_sum += px;
I_sum_x += static_cast<int64_t>(x) * px;
I_sum_y += static_cast<int64_t>(y) * px;
s.I_sum += px;
s.I_sum_x += static_cast<int64_t>(x) * px;
s.I_sum_y += static_cast<int64_t>(y) * px;
// Position sums over the very same pixels, so the background can be taken out of
// the centroid below. It is not known yet - the ring is read in this same loop.
x_sum += x;
y_sum += y;
++n_inner_valid;
s.x_sum += x;
s.y_sum += y;
++s.n_inner_valid;
} else if (d.inner >= r2_sq && d.outer < r3_sq) {
if (!valid(px)) continue;
++n_ring;
++s.n_ring;
const uint8_t m = refl_mask.Get(x, y);
if (m == 0) ++n_clean;
if (m == 0) ++s.n_clean;
if (m == MASK_FLUX) continue;
bkg_sum += static_cast<double>(px);
if (bkg_trim_frac > 0.0) bkg_vals.push_back(px);
++n_bkg;
s.bkg_sum += px;
if (bkg_trim > 0.0f) bkg_vals.push_back(px);
++s.n_bkg;
}
}
if (use_owner && n_own < n_disk) {
++counts.overlapped;
if (n_own < overlap_min_peak * n_disk) ++counts.overlapped_major;
}
int n_bkg_used = n_bkg; // pixels behind the FINAL background value (trim/clip shrink it)
// A masked, untrusted, gapped or overloaded pixel inside the signal disk used to discard the
// reflection outright. A profile fit does not need it to: the fit is the amplitude of a
// NORMALISED profile, so leaving pixels out renormalises the estimator by construction and
// widens sigma by the information they carried - the same argument Exclude makes for a
// neighbour's pixels. Pass B keeps the reflection only while enough of the expected profile
// survived to constrain the amplitude (XDS's MINPK, dials' valid_foreground_threshold). A box
// sum has no profile to renormalise with, so there it stays all or nothing.
const bool full = n_inner_valid == n_inner;
// A ring left with five or fewer readable pixels cannot estimate a background, so the
// reflection is dropped whole - the one thing the stencil geometry does to the DATA rather
// than to a measurement. Counted here because it is the only direct evidence of a radius that has
// outgrown the pattern it is integrating (BraggIntegrationCounts).
const bool keep_partial = full || mode != IntegratorMode::BoxSum || out.overloaded;
if (keep_partial && n_bkg <= 5) ++counts.bkg_starved;
// Would every predicted neighbour, the tails included, leave the ring starved that the
// detector alone would not? That is the pattern's density, what the guard on a widened
// radius reads (BraggIntegrationCounts).
if (keep_partial && n_clean <= 5 && n_ring > 5) ++counts.bkg_starved_by_neighbour;
if (keep_partial && n_bkg > 5) {
out.bkg = bkg_sum / n_bkg;
if (bkg_trim_frac > 0.0 && bkg_vals.size() > 5
&& bkg_vals.size() <= static_cast<size_t>(bragg_engine::BKG_TRIM_MAX)) {
// Symmetric trimmed mean over the background ring (idea 1): drop the lowest and highest
// bkg_trim_frac of the pixels, average the rest. Robust to the high-side contamination
// that biases the plain ring mean and makes it over-subtract at high resolution.
std::sort(bkg_vals.begin(), bkg_vals.end());
const size_t lo = static_cast<size_t>(bkg_vals.size() * bkg_trim_frac);
const size_t hi = bkg_vals.size() - lo;
if (hi > lo) {
double s = 0.0;
for (size_t t = lo; t < hi; ++t) s += bkg_vals[t];
out.bkg = s / static_cast<double>(hi - lo);
n_bkg_used = static_cast<int>(hi - lo);
}
} else if (do_clip) {
// One high-outlier sigma-clip pass on the background ring: reject pixels above
// mean + n*sqrt(gain*mean) to strip a neighbour core or a zinger that biases the mean.
// The gain is 1 for counted photons, so this is then exactly the Poisson clip.
const double thr = out.bkg + bkg_clip_nsigma * std::sqrt(static_cast<double>(bkg_pixel_gain)
* std::max(out.bkg, 1.0));
double s = 0.0;
int n = 0;
for (const auto &[px, rad] : clip_ring)
if (static_cast<double>(px) <= thr) {
s += px;
++n;
if (bkg_radial) {
// The radial curve is binned on the TRUE detector radius, so the
// offset here is the unshrunk radial projection.
const int b = std::clamp(static_cast<int>(std::lround(st.r0 + rad)), 0, n_rad - 1);
rad_sum[b] += static_cast<double>(px);
++rad_cnt[b];
}
// One high-outlier sigma-clip pass on the background ring (see ClipThreshold), and the radial
// background curve from the pixels it keeps.
BraggClipSums clip{};
if (ClipsRing(s)) {
const int32_t thr = ClipThreshold(s);
for (const auto &[px, rad] : clip_ring)
if (px <= thr) {
clip.sum += px;
++clip.n;
if (bkg_radial) {
// The radial curve is binned on the TRUE detector radius, so the
// offset here is the unshrunk radial projection.
const int b = std::clamp(static_cast<int>(std::lround(st.r0 + rad)), 0, a.n_rad - 1);
a.rad_sum[b] += px;
++a.rad_cnt[b];
}
if (n > 5) { out.bkg = s / n; n_bkg_used = n; }
}
// The sum is over the pixels actually READ, so that is the count the background is
// subtracted with; with nothing missing it is the whole disk, exactly as before.
out.I = static_cast<double>(I_sum) - static_cast<double>(n_inner_valid) * out.bkg;
// I = I_sum - n_inner*bkg, and bkg is itself estimated from n_bkg_used pixels, so its
// error enters n_inner times over: var(I) = I_sum + n_inner^2 * bkg/n_bkg_used. Leaving
// the second term out understates sigma by sqrt(1 + n_inner/n_bkg) - 1.109x at the
// default r1=4/r2=6/r3=10 stencil, on every reflection of every dataset.
out.bkg_var = out.bkg / n_bkg_used;
out.var_bkg = static_cast<double>(n_inner_valid) * out.bkg
+ static_cast<double>(n_inner_valid) * n_inner_valid * out.bkg_var;
out.I_sum = I_sum;
out.n_inner = static_cast<int>(n_inner_valid);
out.n_disk = n_disk;
out.n_own = n_own;
const double var_bkg_term = static_cast<double>(n_inner_valid) * n_inner_valid * out.bkg_var;
out.sigma = 1.0;
if (I_sum > 0) {
out.sigma = std::max(out.sigma, std::sqrt(static_cast<double>(I_sum) + var_bkg_term));
// The centroid of the SIGNAL, not of the disk. Weighting by the raw counts weights by
// signal plus background, and the background is flat over a disk centred on the
// PREDICTION - so its own centroid is the prediction exactly, and it pulls the answer
// there. The measured displacement comes out shrunk by I/(I + n*bkg), which is worst
// where the background dominates, i.e. at high resolution. Post-refinement fits the
// beam and the distance on these centroids, so it under-corrects by that factor and
// does so resolution-dependently. Subtracting a flat pedestal from a first moment is
// exact: sum(x*(px-bkg)) = I_sum_x - bkg*x_sum over the same pixels.
const double net = static_cast<double>(I_sum) - n_inner_valid * out.bkg;
if (net > 0.0) {
out.obs_x = (static_cast<double>(I_sum_x) - out.bkg * x_sum) / net;
out.obs_y = (static_cast<double>(I_sum_y) - out.bkg * y_sum) / net;
} else {
// Nothing above background to take a centroid of; the raw one is all there is.
out.obs_x = static_cast<double>(I_sum_x) / static_cast<double>(I_sum);
out.obs_y = static_cast<double>(I_sum_y) / static_cast<double>(I_sum);
}
// A disk with a hole in it gives a centroid pulled away from the hole, and the hole
// sits at a fixed place on the detector - post-refinement would read that as geometry.
out.has_obs = full;
}
out.cx = static_cast<int>(std::lround(r.predicted_x));
out.cy = static_cast<int>(std::lround(r.predicted_y));
out.ok = true;
out.full = full;
// The profile, its resolution shells and their widths are learned from COMPLETE
// reflections: a partial one has no intensity to normalise its grid by and a hole to
// stack into it. So the learning sees exactly what it saw before this rescue existed.
out.strong = full && out.sigma > 0.0 && out.I / out.sigma >= STRONG_I_OVER_SIGMA;
if (full && r.d > 0.0f) {
const double inv_d2 = 1.0 / (static_cast<double>(r.d) * r.d);
inv_d2_min = std::min(inv_d2_min, inv_d2);
inv_d2_max = std::max(inv_d2_max, inv_d2);
}
}
rough[i] = out;
FinishPassA(a, i, r, st, s, clip, bkg_vals);
}
return ProfilePasses(img, owner, predicted, npredicted, a, image_number);
}
BraggIntegrationEngineCPU::PassA BraggIntegrationEngineCPU::StartPassA(size_t npredicted) const {
const int W = static_cast<int>(xpixel), H = static_cast<int>(ypixel);
PassA a;
a.rough.resize(npredicted);
// A pixel's radius is the reflection's radius plus the pixel's projection on the beam->reflection
// direction, so no per-pixel sqrt is needed.
a.n_rad = static_cast<int>(std::ceil(std::hypot(std::max<double>(beam_x, W - beam_x),
std::max<double>(beam_y, H - beam_y)))) + 2;
if (bkg_radial) {
a.rad_sum.assign(a.n_rad, 0);
a.rad_cnt.assign(a.n_rad, 0);
}
a.inv_d2_min = std::numeric_limits<double>::max();
a.inv_d2_max = 0.0;
return a;
}
bool BraggIntegrationEngineCPU::ClipsRing(const BraggBoxSums &s) const {
// The clip is the alternative to the trimmed mean, so where it is on the trim is off, and the
// profile modes keep every reflection with a background (FinishPassA).
return bkg_clip_nsigma > 0.0f && mode != IntegratorMode::BoxSum && s.n_bkg > 5;
}
int32_t BraggIntegrationEngineCPU::ClipThreshold(const BraggBoxSums &s) const {
// Reject pixels above mean + n*sqrt(gain*mean) to strip a neighbour core or a zinger that biases
// the mean. The gain is 1 for counted photons, so this is then exactly the Poisson clip. A pixel
// value is an integer, so it is at or below the threshold exactly when it is at or below its floor.
const double bkg = static_cast<double>(s.bkg_sum) / s.n_bkg;
const double thr = bkg + bkg_clip_nsigma * std::sqrt(static_cast<double>(bkg_pixel_gain) * std::max(bkg, 1.0));
return static_cast<int32_t>(std::clamp(std::floor(thr), static_cast<double>(INT32_MIN),
static_cast<double>(INT32_MAX)));
}
void BraggIntegrationEngineCPU::FinishPassA(PassA &a, size_t i, const Reflection &r, const BraggStencil &st,
const BraggBoxSums &s, const BraggClipSums &clip,
std::vector<int32_t> &bkg_vals) {
Rough out;
// Both from the stencil's own radius, so nothing downstream is derived from a second one.
out.r_bin = std::clamp(static_cast<int>(std::lround(st.r0)), 0, a.n_rad - 1);
out.k_bin = BraggStencilKernelIndex(st, n_kern);
out.overloaded = s.overloaded != 0;
if (overlap != OverlapMode::Off && s.n_own < s.n_disk) {
++counts.overlapped;
if (s.n_own < overlap_min_peak * s.n_disk) ++counts.overlapped_major;
}
int n_bkg_used = s.n_bkg; // pixels behind the FINAL background value (trim/clip shrink it)
// A masked, untrusted, gapped or overloaded pixel inside the signal disk used to discard the
// reflection outright. A profile fit does not need it to: the fit is the amplitude of a
// NORMALISED profile, so leaving pixels out renormalises the estimator by construction and
// widens sigma by the information they carried - the same argument Exclude makes for a
// neighbour's pixels. Pass B keeps the reflection only while enough of the expected profile
// survived to constrain the amplitude (XDS's MINPK, dials' valid_foreground_threshold). A box
// sum has no profile to renormalise with, so there it stays all or nothing.
const bool full = s.n_inner_valid == s.n_inner;
// A ring left with five or fewer readable pixels cannot estimate a background, so the
// reflection is dropped whole - the one thing the stencil geometry does to the DATA rather
// than to a measurement. Counted here because it is the only direct evidence of a radius that has
// outgrown the pattern it is integrating (BraggIntegrationCounts).
const bool keep_partial = full || mode != IntegratorMode::BoxSum || out.overloaded;
if (keep_partial && s.n_bkg <= 5) ++counts.bkg_starved;
// Would every predicted neighbour, the tails included, leave the ring starved that the
// detector alone would not? That is the pattern's density, what the guard on a widened
// radius reads (BraggIntegrationCounts).
if (keep_partial && s.n_clean <= 5 && s.n_ring > 5) ++counts.bkg_starved_by_neighbour;
if (keep_partial && s.n_bkg > 5) {
out.bkg = static_cast<double>(s.bkg_sum) / s.n_bkg;
// Symmetric trimmed-mean background fraction (BraggIntegrationSettings, rugnux --background-trim):
// replaces the r2..r3 ring MEAN with an f-trimmed mean, robust to the high-side contamination
// (neighbour wings, tails) that biases the plain mean up and makes it over-subtract weak
// high-angle reflections. The base ctor has already forced it to 0 whenever the clip is in
// force, which is the default - the two are alternatives.
const double bkg_trim_frac = bkg_trim;
if (bkg_trim_frac > 0.0 && bkg_vals.size() > 5
&& bkg_vals.size() <= static_cast<size_t>(bragg_engine::BKG_TRIM_MAX)) {
// Symmetric trimmed mean over the background ring (idea 1): drop the lowest and highest
// bkg_trim_frac of the pixels, average the rest. Robust to the high-side contamination
// that biases the plain ring mean and makes it over-subtract at high resolution.
std::sort(bkg_vals.begin(), bkg_vals.end());
const size_t lo = static_cast<size_t>(bkg_vals.size() * bkg_trim_frac);
const size_t hi = bkg_vals.size() - lo;
if (hi > lo) {
double sum = 0.0;
for (size_t t = lo; t < hi; ++t) sum += bkg_vals[t];
out.bkg = sum / static_cast<double>(hi - lo);
n_bkg_used = static_cast<int>(hi - lo);
}
} else if (ClipsRing(s)) {
if (clip.n > 5) { out.bkg = static_cast<double>(clip.sum) / clip.n; n_bkg_used = clip.n; }
}
// The sum is over the pixels actually READ, so that is the count the background is
// subtracted with; with nothing missing it is the whole disk, exactly as before.
out.I = static_cast<double>(s.I_sum) - static_cast<double>(s.n_inner_valid) * out.bkg;
// I = I_sum - n_inner*bkg, and bkg is itself estimated from n_bkg_used pixels, so its
// error enters n_inner times over: var(I) = I_sum + n_inner^2 * bkg/n_bkg_used. Leaving
// the second term out understates sigma by sqrt(1 + n_inner/n_bkg) - 1.109x at the
// default r1=4/r2=6/r3=10 stencil, on every reflection of every dataset.
out.bkg_var = out.bkg / n_bkg_used;
out.var_bkg = static_cast<double>(s.n_inner_valid) * out.bkg
+ static_cast<double>(s.n_inner_valid) * s.n_inner_valid * out.bkg_var;
out.I_sum = s.I_sum;
out.n_inner = static_cast<int>(s.n_inner_valid);
out.n_disk = s.n_disk;
out.n_own = s.n_own;
const double var_bkg_term = static_cast<double>(s.n_inner_valid) * s.n_inner_valid * out.bkg_var;
out.sigma = 1.0;
if (s.I_sum > 0) {
out.sigma = std::max(out.sigma, std::sqrt(static_cast<double>(s.I_sum) + var_bkg_term));
// The centroid of the SIGNAL, not of the disk. Weighting by the raw counts weights by
// signal plus background, and the background is flat over a disk centred on the
// PREDICTION - so its own centroid is the prediction exactly, and it pulls the answer
// there. The measured displacement comes out shrunk by I/(I + n*bkg), which is worst
// where the background dominates, i.e. at high resolution. Post-refinement fits the
// beam and the distance on these centroids, so it under-corrects by that factor and
// does so resolution-dependently. Subtracting a flat pedestal from a first moment is
// exact: sum(x*(px-bkg)) = I_sum_x - bkg*x_sum over the same pixels.
const double net = static_cast<double>(s.I_sum) - s.n_inner_valid * out.bkg;
if (net > 0.0) {
out.obs_x = (static_cast<double>(s.I_sum_x) - out.bkg * s.x_sum) / net;
out.obs_y = (static_cast<double>(s.I_sum_y) - out.bkg * s.y_sum) / net;
} else {
// Nothing above background to take a centroid of; the raw one is all there is.
out.obs_x = static_cast<double>(s.I_sum_x) / static_cast<double>(s.I_sum);
out.obs_y = static_cast<double>(s.I_sum_y) / static_cast<double>(s.I_sum);
}
// A disk with a hole in it gives a centroid pulled away from the hole, and the hole
// sits at a fixed place on the detector - post-refinement would read that as geometry.
out.has_obs = full;
}
out.cx = static_cast<int>(std::lround(r.predicted_x));
out.cy = static_cast<int>(std::lround(r.predicted_y));
out.ok = true;
out.full = full;
// The profile, its resolution shells and their widths are learned from COMPLETE
// reflections: a partial one has no intensity to normalise its grid by and a hole to
// stack into it. So the learning sees exactly what it saw before this rescue existed.
out.strong = full && out.sigma > 0.0 && out.I / out.sigma >= STRONG_I_OVER_SIGMA;
if (full && r.d > 0.0f) {
const double inv_d2 = 1.0 / (static_cast<double>(r.d) * r.d);
a.inv_d2_min = std::min(a.inv_d2_min, inv_d2);
a.inv_d2_max = std::max(a.inv_d2_max, inv_d2);
}
}
a.rough[i] = out;
}
bool BraggIntegrationEngineCPU::FitKept(const BraggFitOut &o) const {
if (o.p_valid < overlap_min_peak * o.p_grid) return false;
// A hole in the profile's PEAK is a different defect from a hole in its wings, and the mass
// fraction above cannot tell them apart - the peak of a broad spot is a few percent of the
// mass, so MINPK passes a reflection that has lost the one part of the profile its amplitude
// is determined by. See the header.
if (o.p_lost_peak > MINPK_MAX_MISSING_PEAK * o.p_peak) return false;
if (overlap == OverlapMode::Reject && o.p_own < overlap_min_peak) return false;
return true;
}
template <class Sampler, class OwnerMap>
BraggFitOut BraggIntegrationEngineCPU::FitProfile(const Sampler &img, const OwnerMap &owner_map,
const BraggFitSetup &s, size_t i,
const std::vector<float> &shell_profiles,
std::vector<float> &cells,
std::vector<FitPixel> &fit_pixels) const {
const int W = static_cast<int>(xpixel), H = static_cast<int>(ypixel);
const bool exclude = overlap == OverlapMode::Exclude;
const bool use_owner = overlap != OverlapMode::Off;
const int Rf = s.Rf, Gf = 2 * Rf + 1;
BraggFitOut o{};
const float *P;
if (s.shell >= 0)
P = shell_profiles.data() + static_cast<size_t>(s.shell) * GG;
else {
// The grid's image rows, asked for now (as in pass A): building the profile below takes long
// enough for them to arrive before the fit reads them.
const int gx0 = std::max(0, s.cx - Rf), gx1 = std::min(W - 1, s.cx + Rf);
for (int y = std::max(0, s.cy - Rf); y <= std::min(H - 1, s.cy + Rf); ++y)
prefetch_row(img.address(static_cast<size_t>(y) * W + gx0),
img.address(static_cast<size_t>(y) * W + gx1));
cells.resize(static_cast<size_t>(Gf) * Gf);
float gs = 0.0f;
for (int dy = -Rf; dy <= Rf; ++dy)
for (int dx = -Rf; dx <= Rf; ++dx) {
const float g = BraggGaussian(dx, dy, s.fx, s.fy, s.ux, s.uy, s.inv_2s2r, s.inv_2s2t);
cells[(dy + Rf) * Gf + (dx + Rf)] = g;
gs += g;
}
const float inv_gs = 1.0f / gs;
for (float &p : cells) p *= inv_gs;
P = cells.data();
}
// --- How much of the expected profile the fit can actually see. p_valid is the readable
// fraction of it - XDS's MINPK, dials' valid_foreground_threshold - measured against the
// mass that falls on the detector at all, so a reflection is judged on the pixels that
// exist and not on where the sensor ends. p_own is the same quantity over neighbour-owned
// pixels, what Reject cuts on. m_read / m_all is the fraction of the r1 disk the
// summation seed the runaway guard compares against actually summed; with nothing missing
// and nothing excluded it is 1 and the guard is untouched. ---
const BraggStencil st = MakeBraggStencil(s.px, s.py, stencil);
fit_pixels.clear();
for (int dy = -Rf; dy <= Rf; ++dy)
for (int dx = -Rf; dx <= Rf; ++dx) {
const float Pp = P[(dy + Rf) * Gf + (dx + Rf)];
if (Pp <= 0.0f) continue;
const int x = s.cx + dx, y = s.cy + dy;
if (x < 0 || y < 0 || x >= W || y >= H) continue;
// The region the summation seed was taken over (pass A), so the guard compares the two
// over the same pixels.
const bool in_disk = s.footprint
? BraggStencilInSignal(st, x - s.px, y - s.py,
(x - s.px) * (x - s.px) + (y - s.py) * (y - s.py), r1_sq)
: dx * dx + dy * dy < r1_sq;
o.p_grid += Pp;
o.p_peak = std::max(o.p_peak, Pp);
if (in_disk) o.m_all += Pp;
const int32_t v = img[y * W + x];
if (!valid(v)) {
o.p_lost_peak = std::max(o.p_lost_peak, Pp);
continue;
}
o.p_valid += Pp;
const bool own = !use_owner || BraggOwnedBy(owner_map.Get(x, y), static_cast<int>(i));
if (own) o.p_own += Pp;
if (in_disk && (own || !exclude)) o.m_read += Pp;
if (own || !exclude)
fit_pixels.push_back({Pp, static_cast<float>(v) - s.bkg});
}
if (!FitKept(o)) return o;
float If = s.I0;
for (int iter = 0; iter < 4; ++iter) {
float num = 0.0f;
o.den = 0.0f;
o.wsum = 0.0f;
for (const auto &[Pp, signal] : fit_pixels) {
const float iv = 1.0f / std::max(s.B + If * Pp, s.v_min);
num += Pp * signal * iv;
o.den += Pp * Pp * iv;
o.wsum += Pp * iv;
}
if (o.den > 0.0f) If = num / o.den; else break;
}
o.I = If;
return o;
}
template <class Sampler, class OwnerMap>
std::vector<Reflection> BraggIntegrationEngineCPU::ProfilePasses(const Sampler &img, const OwnerMap &owner_map,
const std::vector<Reflection> &predicted,
size_t npredicted, PassA &a,
int64_t image_number) {
const int W = static_cast<int>(xpixel), H = static_cast<int>(ypixel);
std::vector<BraggFitResult> results(npredicted);
std::vector<Rough> &rough = a.rough;
const bool exclude = overlap == OverlapMode::Exclude && mode != IntegratorMode::BoxSum;
const bool use_owner = overlap != OverlapMode::Off;
auto clean = [&](int x, int y, size_t i) {
return !use_owner
|| BraggOwnedBy(owner_map.Get(x, y), static_cast<int>(i));
};
auto grid_idx = [this](int dx, int dy) { return (dy + R) * G + (dx + R); };
// --- Radial background curvature correction. The annulus mean is blind to the curvature of the
// radial background (a linear background cancels between the concentric disk and annulus), so
// correct it by mean_annulus(B) - mean_disk(B) taken from the curve just accumulated. Reads no
@@ -443,8 +559,8 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
const float *kern = k_diff.data() + static_cast<size_t>(rh.k_bin) * k_len;
double corr = 0.0;
for (int k = 0; k < k_len; ++k) {
const int b = std::clamp(rh.r_bin + k - k_off, 0, n_rad - 1);
const double v = rad_cnt[b] > 0 ? rad_sum[b] / rad_cnt[b] : rh.bkg;
const int b = std::clamp(rh.r_bin + k - k_off, 0, a.n_rad - 1);
const double v = a.rad_cnt[b] > 0 ? static_cast<double>(a.rad_sum[b]) / a.rad_cnt[b] : rh.bkg;
corr += static_cast<double>(kern[k]) * v;
}
rh.bkg -= corr; // annulus mean -> mean over the signal disk
@@ -473,8 +589,8 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
}
auto shell_of = [&](float d) {
if (!(d > 0.0f) || inv_d2_max <= inv_d2_min) return 0;
const double t = (1.0 / (static_cast<double>(d) * d) - inv_d2_min) / (inv_d2_max - inv_d2_min);
if (!(d > 0.0f) || a.inv_d2_max <= a.inv_d2_min) return 0;
const double t = (1.0 / (static_cast<double>(d) * d) - a.inv_d2_min) / (a.inv_d2_max - a.inv_d2_min);
return std::clamp(static_cast<int>(t * N_SHELL), 0, N_SHELL - 1);
};
for (size_t i = 0; i < npredicted; ++i)
@@ -537,22 +653,22 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
};
// Normalised empirical profile (sum = 1), the average grid over the strong spots of a shell.
// ProfileGaussian does not use it - it rebuilds a per-reflection Gaussian in Pass B.
// Stored in float, as the fit below reads it (and as the GPU engine keeps it).
auto build_profile = [&](const std::vector<double> &grid) {
std::vector<double> P(GG, 0.0);
std::vector<float> P(GG, 0.0f);
double sum = 0.0;
for (int k = 0; k < GG; ++k) {
P[k] = std::max(0.0, grid[k]);
sum += P[k];
}
for (int k = 0; k < GG; ++k)
sum += std::max(0.0, grid[k]);
if (sum > 0.0)
for (double &p : P) p /= sum;
for (int k = 0; k < GG; ++k)
P[k] = static_cast<float>(std::max(0.0, grid[k]) / sum);
return P;
};
const std::vector<double> global_P = empirical && global_n > 0 ? build_profile(global_grid)
: std::vector<double>(GG, 0.0);
const std::vector<float> global_P = empirical && global_n > 0 ? build_profile(global_grid)
: std::vector<float>(GG, 0.0f);
const Sigma2 global_sigma2 = widths(global_mom);
std::vector<std::vector<double>> shell_P(N_SHELL, global_P);
std::vector<std::vector<float>> shell_P(N_SHELL, global_P);
std::vector<Sigma2> shell_sigma2(N_SHELL, global_sigma2);
for (int s = 0; s < N_SHELL; ++s) {
if (shell_n[s] < MIN_STRONG_PER_SHELL) continue;
@@ -562,14 +678,22 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
// --- Pass B: profile-fit each reflection (Kabsch, de-biased variance v = B + I*P; iterate). The
// reweighting is the Kabsch/Otwinowski iteration: Kabsch, Acta Cryst D66, 133-144 (2010);
// Otwinowski & Minor, Methods Enzymol 276, 307-326 (1997). ---
std::vector<double> Pbuf;
// The pixels the fit reads, gathered once while p_valid walks the grid: the profile value and the
// background-subtracted count, in the grid's order.
struct FitPixel { double Pp, signal; };
std::vector<FitPixel> fit_pixels;
// Otwinowski & Minor, Methods Enzymol 276, 307-326 (1997).
// Per reflection in three steps: what the fit needs, worked out in double (BraggFitSetup); the
// fit in single precision (FitProfile, or the Metal engine's kernel), with the GPU engine's
// arithmetic (BraggIntegrationEngineGPU.cu, fit) - the profile from exp with the widths'
// reciprocals taken once, normalised by a reciprocal, and one reciprocal of the variance for the
// three weights; and its error, the summation guard and the result, in double. ---
std::vector<float> shell_profiles; // the learned profiles, shell by shell (ProfileEmpirical)
if (empirical)
for (int s = 0; s < N_SHELL; ++s)
shell_profiles.insert(shell_profiles.end(), shell_P[s].begin(), shell_P[s].end());
std::vector<BraggFitSetup> setups(npredicted);
uint32_t cells = 0;
for (size_t i = 0; i < npredicted; ++i) {
const auto &rh = rough[i];
BraggFitSetup &fs = setups[i];
fs = {};
if (!rh.ok) continue;
// An overloaded reflection is not measured by any fit - its brightest pixels are what is
// missing. It is passed on with its box sum and the flag, for the merge to drop its rocking
@@ -582,10 +706,15 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
continue;
}
const int sh = rh.shell < 0 ? 0 : rh.shell;
int Rf = R;
const std::vector<double> *Pvec = &shell_P[sh];
const BraggStencil st = MakeBraggStencil(predicted[i].predicted_x, predicted[i].predicted_y, stencil);
fs.fit = 1;
fs.px = predicted[i].predicted_x;
fs.py = predicted[i].predicted_y;
fs.cx = rh.cx;
fs.cy = rh.cy;
fs.Rf = R;
fs.shell = sh;
fs.footprint = st.fp_s2t > 0.0f;
if (!empirical) {
const double rx = predicted[i].predicted_x - beam_x, ry = predicted[i].predicted_y - beam_y;
const double Rpx = std::hypot(rx, ry);
@@ -614,94 +743,41 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
s2r = std::max<double>(s2r, st.fp_s2r);
elong = true;
}
// Build the Gaussian per reflection, centred on the sub-pixel predicted position and (when
// The Gaussian is built per reflection, centred on the sub-pixel predicted position and (when
// needed) elongated, on a grid grown to hold the wider of its two axes.
const double fx = predicted[i].predicted_x - rh.cx, fy = predicted[i].predicted_y - rh.cy;
Rf = elong ? std::min(3 * R, static_cast<int>(std::ceil(r2 + 2.0 * std::sqrt(std::max(s2r, s2t))))) : R;
// The grid's image rows, asked for now (as in pass A): building the profile below takes
// long enough for them to arrive before the fit reads them.
const int gx0 = std::max(0, rh.cx - Rf), gx1 = std::min(W - 1, rh.cx + Rf);
for (int y = std::max(0, rh.cy - Rf); y <= std::min(H - 1, rh.cy + Rf); ++y)
prefetch_row(img.address(static_cast<size_t>(y) * W + gx0),
img.address(static_cast<size_t>(y) * W + gx1));
const int Gf = 2 * Rf + 1;
Pbuf.assign(static_cast<size_t>(Gf) * Gf, 0.0);
double gs = 0.0;
for (int dy = -Rf; dy <= Rf; ++dy)
for (int dx = -Rf; dx <= Rf; ++dx) {
const double ex = dx - fx, ey = dy - fy;
const double rad = ex * ux + ey * uy, tn = -ex * uy + ey * ux;
const double g = std::exp(-rad * rad / (2.0 * s2r) - tn * tn / (2.0 * s2t));
Pbuf[(dy + Rf) * Gf + (dx + Rf)] = g;
gs += g;
}
for (double &p : Pbuf) p /= gs;
Pvec = &Pbuf;
fs.Rf = elong ? std::min(3 * R, static_cast<int>(std::ceil(r2 + 2.0 * std::sqrt(std::max(s2r, s2t))))) : R;
fs.shell = -1;
fs.cells = cells;
cells += (2 * fs.Rf + 1) * (2 * fs.Rf + 1);
fs.fx = static_cast<float>(predicted[i].predicted_x - rh.cx);
fs.fy = static_cast<float>(predicted[i].predicted_y - rh.cy);
fs.ux = static_cast<float>(ux);
fs.uy = static_cast<float>(uy);
fs.inv_2s2r = 0.5f / static_cast<float>(s2r);
fs.inv_2s2t = 0.5f / static_cast<float>(s2t);
}
fs.bkg = static_cast<float>(rh.bkg);
fs.B = static_cast<float>(std::max(rh.bkg, PIXEL_VARIANCE_FLOOR));
fs.v_min = static_cast<float>(WEIGHT_VARIANCE_MIN_FRACTION) * fs.B;
fs.I0 = static_cast<float>(rh.I);
}
const int Gf = 2 * Rf + 1;
std::vector<BraggFitOut> fits(npredicted);
if (!FitOnDevice(setups, shell_profiles, fits)) {
std::vector<float> cells_buf;
std::vector<FitPixel> fit_pixels;
for (size_t i = 0; i < npredicted; ++i)
if (setups[i].fit)
fits[i] = FitProfile(img, owner_map, setups[i], i, shell_profiles, cells_buf, fit_pixels);
}
// --- How much of the expected profile the fit can actually see. p_valid is the readable
// fraction of it - XDS's MINPK, dials' valid_foreground_threshold - measured against the
// mass that falls on the detector at all, so a reflection is judged on the pixels that
// exist and not on where the sensor ends. p_own is the same quantity over neighbour-owned
// pixels, what Reject cuts on. m_read / m_all is the fraction of the r1 disk the
// summation seed the runaway guard compares against actually summed; with nothing missing
// and nothing excluded it is 1 and the guard is untouched. ---
double p_grid = 0.0, p_valid = 0.0, p_own = 0.0, m_all = 0.0, m_read = 0.0;
double p_peak = 0.0, p_lost_peak = 0.0;
fit_pixels.clear();
for (int dy = -Rf; dy <= Rf; ++dy)
for (int dx = -Rf; dx <= Rf; ++dx) {
const double Pp = (*Pvec)[(dy + Rf) * Gf + (dx + Rf)];
if (Pp <= 0.0) continue;
const int x = rh.cx + dx, y = rh.cy + dy;
if (x < 0 || y < 0 || x >= W || y >= H) continue;
// The region the summation seed was taken over (pass A), so the guard below
// compares the two over the same pixels.
const bool in_disk = st.fp_s2t > 0.0f
? BraggStencilInSignal(st, x - predicted[i].predicted_x, y - predicted[i].predicted_y,
(x - predicted[i].predicted_x) * (x - predicted[i].predicted_x)
+ (y - predicted[i].predicted_y) * (y - predicted[i].predicted_y),
r1_sq)
: dx * dx + dy * dy < r1_sq;
p_grid += Pp;
p_peak = std::max(p_peak, Pp);
if (in_disk) m_all += Pp;
if (!valid(img[y * W + x])) {
p_lost_peak = std::max(p_lost_peak, Pp);
continue;
}
p_valid += Pp;
const bool own = clean(x, y, i);
if (own) p_own += Pp;
if (in_disk && (own || !exclude)) m_read += Pp;
if (own || !exclude)
fit_pixels.push_back({Pp, static_cast<double>(img[y * W + x]) - rh.bkg});
}
if (p_valid < overlap_min_peak * p_grid) continue;
// A hole in the profile's PEAK is a different defect from a hole in its wings, and the mass
// fraction above cannot tell them apart - the peak of a broad spot is a few percent of the
// mass, so MINPK passes a reflection that has lost the one part of the profile its amplitude
// is determined by. See the header.
if (p_lost_peak > MINPK_MAX_MISSING_PEAK * p_peak) continue;
if (overlap == OverlapMode::Reject && p_own < overlap_min_peak) continue;
const double B = std::max(rh.bkg, PIXEL_VARIANCE_FLOOR);
double I = rh.I, den = 0.0, wsum = 0.0;
for (int iter = 0; iter < 4; ++iter) {
double num = 0.0;
den = 0.0;
wsum = 0.0;
for (const auto &[Pp, signal] : fit_pixels) {
const double v = std::max(B + I * Pp, WEIGHT_VARIANCE_MIN_FRACTION * B);
num += Pp * signal / v;
den += Pp * Pp / v;
wsum += Pp / v;
}
if (den > 0.0) I = num / den; else break;
}
if (!(den > 0.0)) continue;
for (size_t i = 0; i < npredicted; ++i) {
if (!setups[i].fit) continue;
const auto &rh = rough[i];
const BraggFitOut &fo = fits[i];
if (!FitKept(fo) || !(fo.den > 0.0f)) continue;
const float den = fo.den, wsum = fo.wsum;
double I = fo.I;
// Guard against profile-fit runaways: on a weak / near-zero reflection the reweighted Kabsch
// iteration has no real peak to lock onto and can manufacture intensity the box sum never sees.
@@ -722,7 +798,7 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
// The seed is a sum over the disk the box sum actually read, so when pixels are missing from
// both - excluded to a neighbour, or unreadable - the fit's full-profile intensity has to be
// scaled down to that same disk before the two are comparable. Nothing dropped gives 1.
const double guard_scale = m_all > 0.0 ? m_read / m_all : 1.0;
const double guard_scale = fo.m_all > 0.0f ? fo.m_read / fo.m_all : 1.0;
if (std::abs(I * guard_scale - rh.I) > PROFILE_SUMMATION_MAX_NSIGMA * rh.sigma) {
I = rh.I;
sigma = rh.sigma;
@@ -740,6 +816,10 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
return Finalize(predicted, npredicted, results, image_number);
}
template std::vector<Reflection> BraggIntegrationEngineCPU::ProfilePasses(
const BraggIntegrationEngineCPU::BufferSampler &, const BraggIntegrationEngineCPU::FlatOwnerMap &,
const std::vector<Reflection> &, size_t, BraggIntegrationEngineCPU::PassA &, int64_t);
std::vector<Reflection> BraggIntegrationEngineCPU::Run(const ImagePreprocessorBuffer &image,
const std::vector<Reflection> &predicted,
size_t npredicted, int64_t image_number) {
@@ -4,13 +4,15 @@
#pragma once
#include "BraggIntegrationEngine.h"
#include "BraggPassA.h"
#include "BraggPassB.h"
#include "../../common/PixelMask.h"
class CompressedImage;
// Plain-C++ reference/fallback engine: a faithful serial re-expression of BraggIntegrate2D (box
// sum) and ProfileIntegrate2D (Kabsch profile fit) reading the preprocessed int32 image. Also the
// numeric oracle the CUDA engine is checked against.
// numeric oracle the CUDA engine is checked against, and the engine the Metal engine extends.
class BraggIntegrationEngineCPU : public BraggIntegrationEngine {
// Core integrator, templated on a pixel sampler so it reads either the preprocessed int32 buffer
// or a raw CompressedImage of any pixel type - both presented per-pixel in the INT32_MIN(masked)/
@@ -53,9 +55,6 @@ class BraggIntegrationEngineCPU : public BraggIntegrationEngine {
};
TiledFrame<uint8_t> refl_mask;
TiledFrame<uint32_t> owner;
// The run's pixel mask, packed 32 pixels to a word (PixelMask::GetPackedMask). An unreadable pixel
// it does not explain was unreadable on this frame only - an overload (see Reflection::overloaded).
std::vector<uint32_t> static_mask;
// Marks every predicted reflection's r2 region in refl_mask (MASK_FLUX / MASK_TAIL).
void MarkNeighbours(const std::vector<Reflection> &predicted, size_t npredicted);
@@ -64,6 +63,84 @@ class BraggIntegrationEngineCPU : public BraggIntegrationEngine {
std::vector<Reflection> RunImpl(const Sampler &img, const std::vector<Reflection> &predicted,
size_t npredicted, int64_t image_number);
// Pass B's single-precision part for reflection i, as the Metal engine's kernel does it: the profile
// (the shell's learned one, or the Gaussian, built in `cells`), how much of it is readable, and the
// fit. fit_pixels is scratch: the pixels the fit reads, gathered once.
struct FitPixel { float Pp, signal; };
template <class Sampler, class OwnerMap>
BraggFitOut FitProfile(const Sampler &img, const OwnerMap &owner_map, const BraggFitSetup &s, size_t i,
const std::vector<float> &shell_profiles, std::vector<float> &cells,
std::vector<FitPixel> &fit_pixels) const;
// Whether enough of a reflection's profile is readable, and its own, for the fit to be kept.
bool FitKept(const BraggFitOut &o) const;
protected:
// The run's pixel mask, packed 32 pixels to a word (PixelMask::GetPackedMask). An unreadable pixel
// it does not explain was unreadable on this frame only - an overload (see Reflection::overloaded).
std::vector<uint32_t> static_mask;
// Identity sampler over the preprocessed int32 buffer (already in the engine's convention).
struct BufferSampler {
const int32_t *p;
int32_t operator[](size_t i) const { return p[i]; }
const void *address(size_t i) const { return p + i; }
};
// The owner map as one frame-sized array, as the Metal engine builds it (TiledFrame's Get otherwise).
struct FlatOwnerMap {
const uint32_t *p;
size_t width;
uint32_t Get(int x, int y) const { return p[static_cast<size_t>(y) * width + x]; }
};
// Pass A's result for one reflection: the box sum, its background, centroid and strong flag.
struct Rough {
double I = 0.0, sigma = NAN, bkg = 0.0, obs_x = 0.0, obs_y = 0.0;
double bkg_var = 0.0; // variance of the background ESTIMATE itself, bkg / n_bkg
double var_bkg = 0.0; // total non-signal variance carried to the merge
int64_t I_sum = 0; // kept so I can be rebuilt after the radial background correction
int n_inner = 0;
int n_disk = 0, n_own = 0; // signal-disk pixels, and how many of them are this reflection's
int r_bin = 0; // rounded distance from the beam centre, indexes the radial curve
int k_bin = 0; // which radial-background kernel this reflection's stencil needs
int cx = 0, cy = 0, shell = -1;
bool ok = false, strong = false, has_obs = false;
bool full = false; // every pixel of the signal disk was readable
bool overloaded = false; // a pixel of the signal disk was saturated
};
// Everything pass A hands the profile passes.
struct PassA {
std::vector<Rough> rough;
// Radial background curve, accumulated from the ring pixels the background clip keeps. Integer
// sums, so the order the pixels arrive in does not matter.
int n_rad = 0;
std::vector<int64_t> rad_sum;
std::vector<int> rad_cnt;
double inv_d2_min = 0.0, inv_d2_max = 0.0; // resolution range of the complete reflections
};
PassA StartPassA(size_t npredicted) const;
// Whether pass A sigma-clips this reflection's background ring, and the clip threshold: the ring
// pixels at or below it are kept. A pixel value is an integer, so the threshold is one too.
bool ClipsRing(const BraggBoxSums &s) const;
int32_t ClipThreshold(const BraggBoxSums &s) const;
// Pass A's result for reflection i from what its box walk counted, and from its clip sums where
// ClipsRing. bkg_vals holds the ring pixels for the trimmed-mean background, and is sorted here.
void FinishPassA(PassA &a, size_t i, const Reflection &r, const BraggStencil &st, const BraggBoxSums &s,
const BraggClipSums &clip, std::vector<int32_t> &bkg_vals);
// Everything after pass A: the radial background correction, then either the box-sum results or
// the profile learning and pass B. Defined for BufferSampler with either owner map, and for the
// CompressedImage samplers with the tiled one.
// Pass B's single-precision part for every reflection with setups[i].fit, on a device - the
// learned profiles side by side in shell_profiles, empty for the Gaussian. False where there is no
// device, and the CPU fits them instead.
virtual bool FitOnDevice(const std::vector<BraggFitSetup> &setups, const std::vector<float> &shell_profiles,
std::vector<BraggFitOut> &fits) { return false; }
template <class Sampler, class OwnerMap>
std::vector<Reflection> ProfilePasses(const Sampler &img, const OwnerMap &owner_map,
const std::vector<Reflection> &predicted, size_t npredicted,
PassA &a, int64_t image_number);
public:
BraggIntegrationEngineCPU(const DiffractionExperiment &experiment, const PixelMask &mask);
@@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <memory>
#include <string>
#include "BraggIntegrationEngineCPU.h"
// Apple GPU engine (macOS only): BraggIntegrationEngineCPU with its pixel work - the neighbour mask, the
// owner map, pass A's walk over every box, the background clip and pass B's single-precision profile
// fit - run as Metal kernels, one thread per reflection. Everything computed in double and the profile
// learning stay on the CPU, in the CPU engine's own code: Apple GPUs have no double precision.
//
// The result is the CPU engine's, bit for bit: the integer sums do not depend on the order the GPU
// visits pixels in, the fit's float sums are taken in the CPU's order, one reflection per thread, and
// the arithmetic deciding which pixel is which is the same source (BraggStencil.h, BraggPassB.h, and
// BraggExp.h for the Gaussian, as no GPU exp matches the host's), compiled for the GPU with fast math
// off - sqrt and division then round correctly - and, as on the host, with no a*b+c contracted into a
// fused multiply-add. The CPU and the GPU read the same memory, so the preprocessed image is not
// copied. tests/BraggIntegrationEngineMetalTest.cpp holds the engine to all of this.
class BraggIntegrationEngineMetal : public BraggIntegrationEngineCPU {
struct Device;
std::unique_ptr<Device> dev;
uint64_t frames_on_gpu = 0;
bool FitOnDevice(const std::vector<BraggFitSetup> &setups, const std::vector<float> &shell_profiles,
std::vector<BraggFitOut> &fits) override;
public:
// Whether this engine can integrate this experiment: a Metal device the process may use (rugnux
// --no-gpu hides it, see hide_gpus) and settings the kernels cover - not the trimmed-mean background,
// which only the CPU engine sorts the ring for.
static bool Available(const DiffractionExperiment &experiment);
// The Metal device's name, for the log; empty where there is none or the process may not use it.
static std::string DeviceName();
BraggIntegrationEngineMetal(const DiffractionExperiment &experiment, const PixelMask &mask);
~BraggIntegrationEngineMetal() override;
using BraggIntegrationEngineCPU::Run; // the CompressedImage overload stays the CPU engine's
std::vector<Reflection> Run(const ImagePreprocessorBuffer &image,
const std::vector<Reflection> &predicted, size_t npredicted,
int64_t image_number) override;
// The frames whose pixels the GPU walked, rather than the CPU engine this one falls back to.
[[nodiscard]] uint64_t FramesOnGPU() const { return frames_on_gpu; }
};
@@ -0,0 +1,275 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
// Kernels of BraggIntegrationEngineMetal. Compiled at run time with fast math off, after BraggStencil.h
// and BraggPassA.h (BraggIntegrationEngineMetal.mm puts the source together).
//
// Each kernel is one of BraggIntegrationEngineCPU's loops over a reflection, one thread per reflection,
// written as the CPU engine writes it. The stencil arithmetic comes from the same header and rounds as
// the CPU does, every sum is an integer, and the two masks are a maximum and a minimum - so neither the
// order a thread visits its pixels in nor the order the threads run in changes a result.
constant int32_t PX_MASKED = -2147483647 - 1; // INT32_MIN: the preprocessor's masked pixel
constant int32_t PX_SATURATED = 2147483647; // INT32_MAX: and its saturated one
constant uint8_t MASK_TAIL = 1; // bragg_engine::MASK_TAIL / MASK_FLUX
constant uint8_t MASK_FLUX = 2;
inline bool valid_px(int32_t v) { return v != PX_MASKED && v != PX_SATURATED; }
// The stencil parameters arrive as bytes: Metal takes no struct with default member initialisers as a
// buffer type, and BraggStencilParams has them.
inline BraggStencilParams stencil_params(constant char *raw) {
return *(constant const BraggStencilParams *) raw;
}
// MarkNeighbours: every reflection marks its r2 region. One dispatch per level, TAIL first, so a pixel
// two regions share ends up with the larger mark, as the CPU's max gives. want = 0 has every reflection
// unmark its region, which leaves the mask clear for the next frame.
kernel void mark_neighbours(device const float2 *xy [[buffer(0)]],
device const uint8_t *level [[buffer(1)]],
device uint8_t *mask [[buffer(2)]],
constant char *stencil_raw [[buffer(3)]],
constant BraggPassAFrame &f [[buffer(4)]],
constant uint8_t &want [[buffer(5)]],
uint i [[thread_position_in_grid]]) {
if (i >= f.n || (want != 0 && level[i] != want))
return;
const BraggStencilParams sp = stencil_params(stencil_raw);
const float px = xy[i].x, py = xy[i].y;
const BraggStencil st = MakeBraggStencil(px, py, sp);
const int x0 = max(0, (int) floor(px - st.ex_in - 1.0f));
const int x1 = min(f.width - 1, (int) ceil(px + st.ex_in + 1.0f));
const int y0 = max(0, (int) floor(py - st.ey_in - 1.0f));
const int y1 = min(f.height - 1, (int) ceil(py + st.ey_in + 1.0f));
for (int y = y0; y <= y1; ++y)
for (int x = x0; x <= x1; ++x) {
const BraggStencilDist d = BraggStencilDistances(st, x - px, y - py);
if (d.inner < f.r2_sq)
mask[y * f.width + x] = want;
}
}
// The owner map: every reflection claims the pixels within `claim` of its centre with an atomic minimum
// of its (distance, index) key, so the nearest centre wins whatever order the threads run in. release
// gives the same pixels back to nobody, which leaves the map clear for the next frame.
kernel void claim_owner(device const float2 *xy [[buffer(0)]],
device atomic_uint *owner [[buffer(1)]],
constant BraggPassAFrame &f [[buffer(2)]],
constant uint &release [[buffer(3)]],
uint i [[thread_position_in_grid]]) {
if (i >= f.n)
return;
const float px = xy[i].x, py = xy[i].y;
const float claim_sq = f.claim * f.claim;
const int x0 = max(0, (int) floor(px - f.claim));
const int x1 = min(f.width - 1, (int) ceil(px + f.claim));
const int y0 = max(0, (int) floor(py - f.claim));
const int y1 = min(f.height - 1, (int) ceil(py + f.claim));
for (int y = y0; y <= y1; ++y)
for (int x = x0; x <= x1; ++x) {
const float dx = x - px, dy = y - py;
const float d2 = dx * dx + dy * dy;
if (d2 >= claim_sq)
continue;
if (release)
atomic_store_explicit(&owner[y * f.width + x], BRAGG_OWNER_NONE, memory_order_relaxed);
else
atomic_fetch_min_explicit(&owner[y * f.width + x], BraggOwnerKey(sqrt(d2), f.inv_claim, (int) i),
memory_order_relaxed);
}
}
// Pass A's walk over the box of the outer ellipse: the signal-disk and background-ring counts.
kernel void box_sums(device const float2 *xy [[buffer(0)]],
device const int32_t *img [[buffer(1)]],
device const uint8_t *mask [[buffer(2)]],
device const uint32_t *owner [[buffer(3)]],
device const uint32_t *static_mask [[buffer(4)]],
constant char *stencil_raw [[buffer(5)]],
constant BraggPassAFrame &f [[buffer(6)]],
device BraggBoxSums *sums [[buffer(7)]],
uint i [[thread_position_in_grid]]) {
if (i >= f.n)
return;
const BraggStencilParams sp = stencil_params(stencil_raw);
const float px = xy[i].x, py = xy[i].y;
const BraggStencil st = MakeBraggStencil(px, py, sp);
const int x0 = max(0, (int) floor(px - st.ex_out - 1.0f));
const int x1 = min(f.width - 1, (int) ceil(px + st.ex_out + 1.0f));
const int y0 = max(0, (int) floor(py - st.ey_out - 1.0f));
const int y1 = min(f.height - 1, (int) ceil(py + st.ey_out + 1.0f));
BraggBoxSums s = {};
for (int y = y0; y <= y1; ++y)
for (int x = x0; x <= x1; ++x) {
const BraggStencilDist d = BraggStencilDistances(st, x - px, y - py);
const int idx = y * f.width + x;
const int32_t v = img[idx];
if (BraggStencilInSignal(st, x - px, y - py, d.signal, f.r1_sq)) {
++s.n_disk;
if (f.use_owner) {
if (BraggOwnedBy(owner[idx], (int) i)) ++s.n_own;
else if (f.exclude) continue;
}
++s.n_inner;
// Unreadable here but not in the run's mask: lost to the flux on this frame.
const bool masked = (uint) idx / 32 < f.static_mask_words
&& ((static_mask[(uint) idx / 32] >> ((uint) idx % 32)) & 1u);
if (v == PX_SATURATED || (v == PX_MASKED && !masked))
s.overloaded = 1;
if (!valid_px(v)) continue;
s.I_sum += v;
s.I_sum_x += (int64_t) x * v;
s.I_sum_y += (int64_t) y * v;
s.x_sum += x;
s.y_sum += y;
++s.n_inner_valid;
} else if (d.inner >= f.r2_sq && d.outer < f.r3_sq) {
if (!valid_px(v)) continue;
++s.n_ring;
const uint8_t m = mask[idx];
if (m == 0) ++s.n_clean;
if (m == MASK_FLUX) continue;
s.bkg_sum += v;
++s.n_bkg;
}
}
sums[i] = s;
}
// A 64-bit integer sum kept as two 32-bit words, low first: Metal has no 64-bit atomic add. The carry
// out of the low word is known to the thread that caused it, so the pair adds up exactly.
inline void atomic_add_i64(device atomic_uint *w, int64_t v) {
const uint64_t u = (uint64_t) v;
const uint lo = (uint) u, hi = (uint) (u >> 32);
const uint old = atomic_fetch_add_explicit(&w[0], lo, memory_order_relaxed);
atomic_fetch_add_explicit(&w[1], hi + (old + lo < old ? 1u : 0u), memory_order_relaxed);
}
// The background clip: the ring pixels at or below the threshold the CPU computed from box_sums, and
// the radial background curve from the same pixels.
kernel void clip_sums(device const float2 *xy [[buffer(0)]],
device const int32_t *img [[buffer(1)]],
device const uint8_t *mask [[buffer(2)]],
constant char *stencil_raw [[buffer(3)]],
constant BraggPassAFrame &f [[buffer(4)]],
device const uint8_t *clips [[buffer(5)]],
device const int32_t *thr [[buffer(6)]],
device BraggClipSums *out [[buffer(7)]],
device atomic_uint *rad_sum [[buffer(8)]],
device atomic_int *rad_cnt [[buffer(9)]],
uint i [[thread_position_in_grid]]) {
if (i >= f.n || !clips[i])
return;
const BraggStencilParams sp = stencil_params(stencil_raw);
const float px = xy[i].x, py = xy[i].y;
const BraggStencil st = MakeBraggStencil(px, py, sp);
const int x0 = max(0, (int) floor(px - st.ex_out - 1.0f));
const int x1 = min(f.width - 1, (int) ceil(px + st.ex_out + 1.0f));
const int y0 = max(0, (int) floor(py - st.ey_out - 1.0f));
const int y1 = min(f.height - 1, (int) ceil(py + st.ey_out + 1.0f));
const int32_t t = thr[i];
BraggClipSums c = {};
for (int y = y0; y <= y1; ++y)
for (int x = x0; x <= x1; ++x) {
const BraggStencilDist d = BraggStencilDistances(st, x - px, y - py);
const int idx = y * f.width + x;
const int32_t v = img[idx];
if (!(d.inner >= f.r2_sq && d.outer < f.r3_sq && valid_px(v) && mask[idx] != MASK_FLUX && v <= t))
continue;
c.sum += v;
++c.n;
if (f.n_rad > 0) {
// Binned on the true detector radius: the unshrunk radial projection.
const int b = clamp((int) round(st.r0 + d.rad), 0, f.n_rad - 1);
atomic_add_i64(&rad_sum[2 * b], v);
atomic_fetch_add_explicit(&rad_cnt[b], 1, memory_order_relaxed);
}
}
out[i] = c;
}
// BraggIntegrationEngineCPU::FitProfile. The CPU gathers the pixels the fit reads once; here every
// iteration reads them again, the same pixels in the same order.
kernel void profile_fit(device const BraggFitSetup *setups [[buffer(0)]],
device const int32_t *img [[buffer(1)]],
device const uint32_t *owner [[buffer(2)]],
device const float *shell_profiles [[buffer(3)]],
device float *cells [[buffer(4)]],
constant char *stencil_raw [[buffer(5)]],
constant BraggPassBFrame &f [[buffer(6)]],
device BraggFitOut *out [[buffer(7)]],
uint i [[thread_position_in_grid]]) {
if (i >= f.n || !setups[i].fit)
return;
const BraggFitSetup s = setups[i];
const int Rf = s.Rf, Gf = 2 * Rf + 1;
BraggFitOut o = {};
device const float *P;
if (s.shell >= 0)
P = shell_profiles + s.shell * (2 * f.R + 1) * (2 * f.R + 1);
else {
device float *g = cells + s.cells;
float gs = 0.0f;
for (int dy = -Rf; dy <= Rf; ++dy)
for (int dx = -Rf; dx <= Rf; ++dx) {
const float v = BraggGaussian(dx, dy, s.fx, s.fy, s.ux, s.uy, s.inv_2s2r, s.inv_2s2t);
g[(dy + Rf) * Gf + (dx + Rf)] = v;
gs += v;
}
const float inv_gs = 1.0f / gs;
for (int k = 0; k < Gf * Gf; ++k) g[k] *= inv_gs;
P = g;
}
const BraggStencil st = MakeBraggStencil(s.px, s.py, stencil_params(stencil_raw));
for (int dy = -Rf; dy <= Rf; ++dy)
for (int dx = -Rf; dx <= Rf; ++dx) {
const float Pp = P[(dy + Rf) * Gf + (dx + Rf)];
if (Pp <= 0.0f) continue;
const int x = s.cx + dx, y = s.cy + dy;
if (x < 0 || y < 0 || x >= f.width || y >= f.height) continue;
const bool in_disk = s.footprint
? BraggStencilInSignal(st, x - s.px, y - s.py,
(x - s.px) * (x - s.px) + (y - s.py) * (y - s.py), f.r1_sq)
: dx * dx + dy * dy < f.r1_sq;
o.p_grid += Pp;
o.p_peak = o.p_peak < Pp ? Pp : o.p_peak; // std::max
if (in_disk) o.m_all += Pp;
if (!valid_px(img[y * f.width + x])) {
o.p_lost_peak = o.p_lost_peak < Pp ? Pp : o.p_lost_peak;
continue;
}
o.p_valid += Pp;
const bool own = !f.use_owner || BraggOwnedBy(owner[y * f.width + x], (int) i);
if (own) o.p_own += Pp;
if (in_disk && (own || !f.exclude)) o.m_read += Pp;
}
// FitKept is double arithmetic: the CPU decides it, and the fit runs for every reflection.
float If = s.I0;
for (int iter = 0; iter < 4; ++iter) {
float num = 0.0f;
o.den = 0.0f;
o.wsum = 0.0f;
for (int dy = -Rf; dy <= Rf; ++dy)
for (int dx = -Rf; dx <= Rf; ++dx) {
const float Pp = P[(dy + Rf) * Gf + (dx + Rf)];
if (Pp <= 0.0f) continue;
const int x = s.cx + dx, y = s.cy + dy;
if (x < 0 || y < 0 || x >= f.width || y >= f.height) continue;
const int32_t v = img[y * f.width + x];
if (!valid_px(v)) continue;
if (f.exclude && f.use_owner && !BraggOwnedBy(owner[y * f.width + x], (int) i)) continue;
const float signal = (float) v - s.bkg;
const float d = s.B + If * Pp;
const float iv = 1.0f / (d < s.v_min ? s.v_min : d); // std::max
num += Pp * signal * iv;
o.den += Pp * Pp * iv;
o.wsum += Pp * iv;
}
if (o.den > 0.0f) If = num / o.den; else break;
}
o.I = If;
out[i] = o;
}
@@ -0,0 +1,405 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#import <Metal/Metal.h>
#include "BraggIntegrationEngineMetal.h"
#include <algorithm>
#include <string>
#include <unistd.h>
#include "../../common/CUDAWrapper.h"
#include "../../common/JFJochException.h"
using namespace bragg_engine;
// The kernels' source: BraggStencil.h, BraggPassA.h and BraggIntegrationEngineMetal.metal, copied in
// at configure time (CMakeLists.txt) so the binary needs no file beside it.
extern const char *const bragg_metal_source_stencil;
extern const char *const bragg_metal_source_pass_a;
extern const char *const bragg_metal_source_exp;
extern const char *const bragg_metal_source_pass_b;
extern const char *const bragg_metal_source_kernels;
namespace {
static_assert(MASK_TAIL == 1 && MASK_FLUX == 2, "BraggIntegrationEngineMetal.metal spells the two marks out");
struct Kernels {
id<MTLDevice> device = nil; // nil: no Metal device
id<MTLComputePipelineState> mark = nil, claim = nil, box = nil, clip = nil, fit = nil;
};
// Compiled once per process: a worker makes an engine per pass, and compiling takes a fraction of a
// second. The Metal compiler ships with macOS, so nothing has to be installed to build or run this.
const Kernels &GetKernels() {
static const Kernels kernels = [] {
Kernels k;
k.device = MTLCreateSystemDefaultDevice();
if (k.device == nil)
return k;
// No contraction of a*b+c into a fused multiply-add, as everywhere on the host (CMakeLists.txt).
// Metal would otherwise contract wherever its backend saw fit. BraggStencil.h calls C's math
// functions; the defines are Metal's spellings of them.
const std::string source = std::string("#include <metal_stdlib>\n"
"using namespace metal;\n"
"#pragma clang fp contract(off)\n"
"#define sqrtf(x) sqrt(x)\n"
"#define fmaxf(a, b) fmax(a, b)\n"
"#define floorf(x) floor(x)\n"
"#define lroundf(x) ((long) round(x))\n")
+ bragg_metal_source_stencil + bragg_metal_source_pass_a
+ bragg_metal_source_exp + bragg_metal_source_pass_b
+ bragg_metal_source_kernels;
MTLCompileOptions *options = [MTLCompileOptions new];
// Fast math off: sqrt and division then round correctly and nothing is reassociated, as on the
// CPU. (MTLCompileOptions.mathMode says the same from macOS 15; the build targets 13.) And the
// language of macOS 13 on every macOS, so the kernels mean the same wherever they compile.
options.fastMathEnabled = NO;
options.languageVersion = MTLLanguageVersion3_0;
NSError *error = nil;
id<MTLLibrary> library = [k.device newLibraryWithSource:@(source.c_str()) options:options error:&error];
if (library == nil)
throw JFJochException(JFJochExceptionCategory::GPUMetalError,
std::string("Bragg integration kernels: ") + error.localizedDescription.UTF8String);
auto pipeline = [&](const char *name) {
id<MTLFunction> function = [library newFunctionWithName:@(name)];
id<MTLComputePipelineState> p = [k.device newComputePipelineStateWithFunction:function error:&error];
if (p == nil)
throw JFJochException(JFJochExceptionCategory::GPUMetalError,
std::string(name) + ": " + error.localizedDescription.UTF8String);
return p;
};
k.mark = pipeline("mark_neighbours");
k.claim = pipeline("claim_owner");
k.box = pipeline("box_sums");
k.clip = pipeline("clip_sums");
k.fit = pipeline("profile_fit");
return k;
}();
return kernels;
}
void WaitCommitted(id<MTLCommandBuffer> cb) {
[cb waitUntilCompleted];
if (cb.status != MTLCommandBufferStatusCompleted)
throw JFJochException(JFJochExceptionCategory::GPUMetalError,
cb.error ? cb.error.localizedDescription.UTF8String : "command buffer failed");
}
void Wait(id<MTLCommandBuffer> cb) {
[cb commit];
WaitCommitted(cb);
}
} // namespace
struct BraggIntegrationEngineMetal::Device {
id<MTLCommandQueue> queue = nil;
id<MTLBuffer> static_mask = nil;
// Frame-sized, made on the first image: a worker's analysis is built for every pass, and most of
// them never integrate. A frame unmarks what it marked once the CPU has read them (`unmark`, which
// the next frame waits for): clearing all of both took 1.5 ms of a 16M-pixel frame, longer than
// integrating a sparse one. `clear` is true once an unmark completed; the first frame, and one
// after a frame that threw part-way through, clear the whole of both instead.
id<MTLBuffer> mask = nil, owner = nil;
id<MTLCommandBuffer> unmark = nil;
bool clear = false;
id<MTLBuffer> rad_sum = nil, rad_cnt = nil; // the radial background curve, as the clip kernel adds it up
// Per reflection, grown with the frames.
size_t capacity = 0;
id<MTLBuffer> xy = nil, level = nil, sums = nil, clips = nil, thr = nil, clip_out = nil;
id<MTLBuffer> fit_setups = nil, fit_out = nil;
// Pass B's Gaussians, grown with the frames, and the learned profiles.
size_t cells_capacity = 0, profiles_capacity = 0;
id<MTLBuffer> cells = nil, profiles = nil;
// The frame's image while Run hands pass B to FitOnDevice; nil otherwise (the CPU engine's own Run).
id<MTLBuffer> img = nil;
};
bool BraggIntegrationEngineMetal::Available(const DiffractionExperiment &experiment) {
const auto settings = experiment.GetBraggIntegrationSettings();
const bool trimmed_mean = !(settings.GetBackgroundClipNSigma() > 0.0f)
&& settings.GetBackgroundTrimFraction() > 0.0f;
return !gpus_are_hidden() && !trimmed_mean && GetKernels().device != nil;
}
std::string BraggIntegrationEngineMetal::DeviceName() {
if (gpus_are_hidden() || GetKernels().device == nil)
return {};
return GetKernels().device.name.UTF8String;
}
BraggIntegrationEngineMetal::BraggIntegrationEngineMetal(const DiffractionExperiment &experiment,
const PixelMask &mask)
: BraggIntegrationEngineCPU(experiment, mask), dev(std::make_unique<Device>()) {
const Kernels &k = GetKernels();
dev->queue = [k.device newCommandQueue];
dev->static_mask = [k.device newBufferWithBytes:static_mask.data()
length:std::max<size_t>(static_mask.size() * sizeof(uint32_t), 4)
options:MTLResourceStorageModeShared];
}
BraggIntegrationEngineMetal::~BraggIntegrationEngineMetal() = default;
std::vector<Reflection> BraggIntegrationEngineMetal::Run(const ImagePreprocessorBuffer &image,
const std::vector<Reflection> &predicted,
size_t npredicted, int64_t image_number) {
// An image of another size, and a frame with nothing predicted, the CPU engine answers as it would.
if (image.size() != npixel || npredicted == 0)
return BraggIntegrationEngineCPU::Run(image, predicted, npredicted, image_number);
dev->img = nil;
@autoreleasepool {
const Kernels &k = GetKernels();
// The GPU reads the host's own memory, so the image is read where it lies - which Metal allows
// for memory starting on a page, as macOS's allocator hands out any buffer of a frame's size.
// Should one not be, the CPU engine integrates it: the same result.
const size_t page = getpagesize(), bytes = npixel * sizeof(int32_t);
id<MTLBuffer> img = nil;
if (reinterpret_cast<uintptr_t>(image.data()) % page == 0)
img = [k.device newBufferWithBytesNoCopy:const_cast<int32_t *>(image.data())
length:(bytes + page - 1) / page * page
options:MTLResourceStorageModeShared
deallocator:nil];
if (img == nil)
return BraggIntegrationEngineCPU::Run(image, predicted, npredicted, image_number);
counts.predicted += npredicted;
const bool use_owner = overlap != OverlapMode::Off;
if (dev->unmark != nil) { // it reads xy, which is overwritten below
id<MTLCommandBuffer> unmark = dev->unmark;
dev->unmark = nil;
dev->clear = false; // unless it completed
WaitCommitted(unmark);
dev->clear = true;
}
auto buffer = [&](size_t length) {
return [k.device newBufferWithLength:std::max<size_t>(length, 4) options:MTLResourceStorageModeShared];
};
if (dev->mask == nil) {
dev->mask = buffer(npixel);
if (use_owner)
dev->owner = buffer(npixel * sizeof(uint32_t));
}
const bool was_clear = dev->clear;
dev->clear = false;
if (dev->capacity < npredicted) {
const size_t n = npredicted + npredicted / 2;
dev->xy = buffer(n * 2 * sizeof(float));
dev->level = buffer(n);
dev->sums = buffer(n * sizeof(BraggBoxSums));
dev->clips = buffer(n);
dev->thr = buffer(n * sizeof(int32_t));
dev->clip_out = buffer(n * sizeof(BraggClipSums));
dev->fit_setups = buffer(n * sizeof(BraggFitSetup));
dev->fit_out = buffer(n * sizeof(BraggFitOut));
dev->capacity = n;
}
auto *xy = static_cast<float *>(dev->xy.contents);
auto *level = static_cast<uint8_t *>(dev->level.contents);
for (size_t i = 0; i < npredicted; ++i) {
xy[2 * i] = predicted[i].predicted_x;
xy[2 * i + 1] = predicted[i].predicted_y;
level[i] = predicted[i].partiality >= NEIGHBOUR_MASK_MIN_PARTIALITY ? MASK_FLUX : MASK_TAIL;
}
PassA a = StartPassA(npredicted);
BraggPassAFrame f{};
f.width = static_cast<int32_t>(xpixel);
f.height = static_cast<int32_t>(ypixel);
f.r1_sq = r1_sq;
f.r2_sq = r2_sq;
f.r3_sq = r3_sq;
f.claim = claim;
f.inv_claim = inv_claim;
f.use_owner = use_owner;
f.exclude = overlap == OverlapMode::Exclude && mode != IntegratorMode::BoxSum;
f.n_rad = bkg_radial ? a.n_rad : 0;
f.static_mask_words = static_cast<uint32_t>(static_mask.size());
f.n = static_cast<uint32_t>(npredicted);
const MTLSize grid = MTLSizeMake(npredicted, 1, 1), group = MTLSizeMake(64, 1, 1);
// --- The neighbour mask, the owner map and pass A's box walk ---
id<MTLCommandBuffer> cb = [dev->queue commandBuffer];
id<MTLBlitCommandEncoder> blit;
if (!was_clear) {
blit = [cb blitCommandEncoder];
[blit fillBuffer:dev->mask range:NSMakeRange(0, npixel) value:0];
if (use_owner) // every byte 0xff: BRAGG_OWNER_NONE
[blit fillBuffer:dev->owner range:NSMakeRange(0, npixel * sizeof(uint32_t)) value:0xff];
[blit endEncoding];
}
const uint32_t claim_not_release = 0;
id<MTLComputeCommandEncoder> enc = [cb computeCommandEncoder];
for (const uint8_t want : {MASK_TAIL, MASK_FLUX}) {
[enc setComputePipelineState:k.mark];
[enc setBuffer:dev->xy offset:0 atIndex:0];
[enc setBuffer:dev->level offset:0 atIndex:1];
[enc setBuffer:dev->mask offset:0 atIndex:2];
[enc setBytes:&stencil length:sizeof(stencil) atIndex:3];
[enc setBytes:&f length:sizeof(f) atIndex:4];
[enc setBytes:&want length:sizeof(want) atIndex:5];
[enc dispatchThreads:grid threadsPerThreadgroup:group];
}
if (use_owner) {
[enc setComputePipelineState:k.claim];
[enc setBuffer:dev->xy offset:0 atIndex:0];
[enc setBuffer:dev->owner offset:0 atIndex:1];
[enc setBytes:&f length:sizeof(f) atIndex:2];
[enc setBytes:&claim_not_release length:sizeof(claim_not_release) atIndex:3];
[enc dispatchThreads:grid threadsPerThreadgroup:group];
}
[enc setComputePipelineState:k.box];
[enc setBuffer:dev->xy offset:0 atIndex:0];
[enc setBuffer:img offset:0 atIndex:1];
[enc setBuffer:dev->mask offset:0 atIndex:2];
[enc setBuffer:(use_owner ? dev->owner : dev->mask) offset:0 atIndex:3]; // not read without an owner map
[enc setBuffer:dev->static_mask offset:0 atIndex:4];
[enc setBytes:&stencil length:sizeof(stencil) atIndex:5];
[enc setBytes:&f length:sizeof(f) atIndex:6];
[enc setBuffer:dev->sums offset:0 atIndex:7];
[enc dispatchThreads:grid threadsPerThreadgroup:group];
[enc endEncoding];
Wait(cb);
// --- The clip thresholds, which are double arithmetic, on the CPU; the clip on the GPU ---
const auto *sums = static_cast<const BraggBoxSums *>(dev->sums.contents);
auto *clips = static_cast<uint8_t *>(dev->clips.contents);
auto *thr = static_cast<int32_t *>(dev->thr.contents);
bool any_clip = false;
for (size_t i = 0; i < npredicted; ++i) {
clips[i] = ClipsRing(sums[i]);
if (clips[i]) {
thr[i] = ClipThreshold(sums[i]);
any_clip = true;
}
}
if (any_clip) {
if (bkg_radial && dev->rad_sum == nil) {
dev->rad_sum = buffer(a.n_rad * 2 * sizeof(uint32_t));
dev->rad_cnt = buffer(a.n_rad * sizeof(int32_t));
}
cb = [dev->queue commandBuffer];
if (bkg_radial) {
blit = [cb blitCommandEncoder];
[blit fillBuffer:dev->rad_sum range:NSMakeRange(0, a.n_rad * 2 * sizeof(uint32_t)) value:0];
[blit fillBuffer:dev->rad_cnt range:NSMakeRange(0, a.n_rad * sizeof(int32_t)) value:0];
[blit endEncoding];
}
enc = [cb computeCommandEncoder];
[enc setComputePipelineState:k.clip];
[enc setBuffer:dev->xy offset:0 atIndex:0];
[enc setBuffer:img offset:0 atIndex:1];
[enc setBuffer:dev->mask offset:0 atIndex:2];
[enc setBytes:&stencil length:sizeof(stencil) atIndex:3];
[enc setBytes:&f length:sizeof(f) atIndex:4];
[enc setBuffer:dev->clips offset:0 atIndex:5];
[enc setBuffer:dev->thr offset:0 atIndex:6];
[enc setBuffer:dev->clip_out offset:0 atIndex:7];
[enc setBuffer:(bkg_radial ? dev->rad_sum : dev->clips) offset:0 atIndex:8]; // not written without the curve
[enc setBuffer:(bkg_radial ? dev->rad_cnt : dev->clips) offset:0 atIndex:9];
[enc dispatchThreads:grid threadsPerThreadgroup:group];
[enc endEncoding];
Wait(cb);
if (bkg_radial) {
const auto *w = static_cast<const uint32_t *>(dev->rad_sum.contents);
const auto *c = static_cast<const int32_t *>(dev->rad_cnt.contents);
for (int b = 0; b < a.n_rad; ++b) {
a.rad_sum[b] = static_cast<int64_t>(w[2 * b] | static_cast<uint64_t>(w[2 * b + 1]) << 32);
a.rad_cnt[b] = c[b];
}
}
}
++frames_on_gpu;
// --- The rest is the CPU engine's ---
const auto *clip_out = static_cast<const BraggClipSums *>(dev->clip_out.contents);
std::vector<int32_t> no_trim; // the trimmed mean is the CPU engine's alone (Available)
for (size_t i = 0; i < npredicted; ++i) {
const Reflection &r = predicted[i];
FinishPassA(a, i, r, MakeBraggStencil(r.predicted_x, r.predicted_y, stencil), sums[i],
clips[i] ? clip_out[i] : BraggClipSums{}, no_trim);
}
const FlatOwnerMap owner_map{use_owner ? static_cast<const uint32_t *>(dev->owner.contents) : nullptr, xpixel};
dev->img = img;
auto ret = ProfilePasses(BufferSampler{image.data()}, owner_map, predicted, npredicted, a, image_number);
dev->img = nil;
// --- Unmark this frame's regions and give back its claims, while the CPU goes on ---
dev->unmark = [dev->queue commandBuffer];
enc = [dev->unmark computeCommandEncoder];
const uint8_t unmark = 0;
[enc setComputePipelineState:k.mark];
[enc setBuffer:dev->xy offset:0 atIndex:0];
[enc setBuffer:dev->level offset:0 atIndex:1];
[enc setBuffer:dev->mask offset:0 atIndex:2];
[enc setBytes:&stencil length:sizeof(stencil) atIndex:3];
[enc setBytes:&f length:sizeof(f) atIndex:4];
[enc setBytes:&unmark length:sizeof(unmark) atIndex:5];
[enc dispatchThreads:grid threadsPerThreadgroup:group];
if (use_owner) {
const uint32_t release = 1;
[enc setComputePipelineState:k.claim];
[enc setBuffer:dev->xy offset:0 atIndex:0];
[enc setBuffer:dev->owner offset:0 atIndex:1];
[enc setBytes:&f length:sizeof(f) atIndex:2];
[enc setBytes:&release length:sizeof(release) atIndex:3];
[enc dispatchThreads:grid threadsPerThreadgroup:group];
}
[enc endEncoding];
[dev->unmark commit];
return ret;
}
}
bool BraggIntegrationEngineMetal::FitOnDevice(const std::vector<BraggFitSetup> &setups,
const std::vector<float> &shell_profiles,
std::vector<BraggFitOut> &fits) {
if (dev->img == nil)
return false;
@autoreleasepool {
const Kernels &k = GetKernels();
const size_t n = setups.size();
size_t n_cells = 0;
for (const BraggFitSetup &s : setups)
if (s.fit && s.shell < 0)
n_cells = std::max<size_t>(n_cells, s.cells + static_cast<size_t>(2 * s.Rf + 1) * (2 * s.Rf + 1));
auto grow = [&](__strong id<MTLBuffer> &b, size_t &capacity, size_t bytes) {
if (capacity < bytes) {
capacity = bytes + bytes / 2;
b = [k.device newBufferWithLength:std::max<size_t>(capacity, 4) options:MTLResourceStorageModeShared];
}
};
grow(dev->cells, dev->cells_capacity, n_cells * sizeof(float));
grow(dev->profiles, dev->profiles_capacity, shell_profiles.size() * sizeof(float));
std::copy(setups.begin(), setups.end(), static_cast<BraggFitSetup *>(dev->fit_setups.contents));
std::copy(shell_profiles.begin(), shell_profiles.end(), static_cast<float *>(dev->profiles.contents));
const bool use_owner = overlap != OverlapMode::Off;
const BraggPassBFrame f{static_cast<int32_t>(xpixel), static_cast<int32_t>(ypixel), r1_sq, R, use_owner,
overlap == OverlapMode::Exclude, static_cast<uint32_t>(n)};
id<MTLCommandBuffer> cb = [dev->queue commandBuffer];
id<MTLComputeCommandEncoder> enc = [cb computeCommandEncoder];
[enc setComputePipelineState:k.fit];
[enc setBuffer:dev->fit_setups offset:0 atIndex:0];
[enc setBuffer:dev->img offset:0 atIndex:1];
[enc setBuffer:(use_owner ? dev->owner : dev->mask) offset:0 atIndex:2]; // not read without an owner map
[enc setBuffer:dev->profiles offset:0 atIndex:3];
[enc setBuffer:dev->cells offset:0 atIndex:4];
[enc setBytes:&stencil length:sizeof(stencil) atIndex:5];
[enc setBytes:&f length:sizeof(f) atIndex:6];
[enc setBuffer:dev->fit_out offset:0 atIndex:7];
[enc dispatchThreads:MTLSizeMake(n, 1, 1) threadsPerThreadgroup:MTLSizeMake(64, 1, 1)];
[enc endEncoding];
Wait(cb);
const auto *out = static_cast<const BraggFitOut *>(dev->fit_out.contents);
std::copy(out, out + n, fits.begin());
}
return true;
}
@@ -0,0 +1,45 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
// Pass A of BraggIntegrationEngineCPU, as the data it hands on: what the walk over one reflection's box
// counts, and the clip sums of its background ring. Integers only, so the counts do not depend on the
// order the pixels are visited in: the Metal engine walks the boxes on the GPU and gets the CPU's
// counts exactly. Compiled by the host and by the Metal compiler, so the two agree on the layout -
// which is also why there are no default member initialisers (Metal does not take a struct with them
// as a buffer type); zero one with {}.
#ifndef __METAL_VERSION__
#include <cstdint>
#endif
struct BraggBoxSums {
int64_t I_sum, I_sum_x, I_sum_y; // readable signal-disk pixels, and their first moments
int64_t x_sum, y_sum; // the positions behind I_sum, to take the background out of the centroid
int64_t bkg_sum; // ring pixels kept for the background
int64_t n_inner, n_inner_valid; // signal-disk pixels kept, and how many of them were readable
int32_t n_disk, n_own; // signal-disk pixels, and how many of them are this reflection's
int32_t n_bkg; // ring pixels behind bkg_sum
int32_t n_ring, n_clean; // readable ring pixels, and those no predicted neighbour covers
int32_t overloaded; // a signal-disk pixel was saturated, or unreadable on this frame only
};
// The ring pixels at or below a reflection's clip threshold (BraggIntegrationEngineCPU::ClipThreshold).
struct BraggClipSums {
int64_t sum;
int32_t n;
int32_t unused;
};
// What the Metal kernels are told about the frame, beside the stencil (BraggStencilParams).
struct BraggPassAFrame {
int32_t width, height;
float r1_sq, r2_sq, r3_sq;
float claim, inv_claim; // how far a reflection claims pixels in the owner map
int32_t use_owner; // an overlap treatment is on, so the owner map is built and read
int32_t exclude; // ... and a pixel a neighbour owns leaves the signal disk
int32_t n_rad; // radial background bins; 0 when the curve is not accumulated
uint32_t static_mask_words; // length of the run's packed pixel mask
uint32_t n; // reflections
};
@@ -0,0 +1,55 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
// Pass B of BraggIntegrationEngineCPU, the profile fit, as the data it hands on. The CPU works out in
// double what one reflection's fit needs (BraggFitSetup), the fit itself is single precision
// (BraggFitOut), and the CPU finishes it in double. The Metal engine runs the middle on the GPU, so this
// is compiled by the host and by the Metal compiler (no default member initialisers, as in BraggPassA.h).
#ifndef __METAL_VERSION__
#include <cstdint>
#include "BraggExp.h" // the Metal engine puts it in front
#endif
struct BraggFitSetup {
float px, py; // predicted position
int32_t cx, cy; // profile grid centre
int32_t Rf; // grid half-size
int32_t shell; // the learned profile of this shell, or -1: the Gaussian below
uint32_t cells; // where the Metal engine keeps the Gaussian, Gf^2 floats
int32_t fit; // 0: not fitted (no pass A result, or overloaded)
int32_t footprint; // the stencil has a measured footprint: it decides the signal disk
float fx, fy; // sub-pixel offset of the prediction from the grid centre
float ux, uy; // radial direction
float inv_2s2r, inv_2s2t; // 1 / (2 sigma^2), radially and tangentially
float bkg; // background per pixel
float B, v_min; // the variance model's floor, and the least variance a weight takes
float I0; // the box sum, where the fit starts
};
struct BraggFitOut {
float p_grid, p_valid, p_own; // profile mass on the detector, readable, and this reflection's own
float p_peak, p_lost_peak; // the largest profile value, and the largest one unreadable
float m_all, m_read; // mass in the signal disk, and the part the box sum read
float I, den, wsum; // the fit; den = 0 when it did not run or failed
};
// What the Metal engine's fit kernel is told about the frame, beside the stencil.
struct BraggPassBFrame {
int32_t width, height;
float r1_sq;
int32_t R; // the learned profiles' grid half-size
int32_t use_owner, exclude; // as in BraggPassAFrame
uint32_t n; // reflections
};
// The Gaussian profile's (unnormalised) value at grid offset (dx, dy).
inline float BraggGaussian(int dx, int dy, float fx, float fy, float ux, float uy, float inv_2s2r,
float inv_2s2t) {
const float ex = dx - fx, ey = dy - fy;
const float rad = ex * ux + ey * uy, tn = -ex * uy + ey * ux;
return BraggExp(-rad * rad * inv_2s2r - tn * tn * inv_2s2t);
}
+33 -18
View File
@@ -49,15 +49,19 @@
// gives q = 0 and hence d2_inner == d2_outer == d2 EXACTLY, in floating point, not merely to within
// rounding. k_sigma = 0 therefore reproduces the old circular stencil bit for bit.
//
// This header is compiled by both the host compiler and nvcc, so the geometry has exactly one
// definition: the two engines classify by the same rule, built from the same inputs. They are not
// bit-identical to each other - nvcc contracts a*b+c into an FMA and the host baseline has no FMA
// instruction to contract into - so a pixel within a rounding of a boundary can still fall either
// way, which is what the parity test's tolerances are for.
// This header is compiled by the host compiler, by nvcc and by the Metal compiler, so the geometry has
// exactly one definition: the engines classify by the same rule, built from the same inputs. The CUDA
// engine is not bit-identical to the CPU one - nvcc contracts a*b+c into an FMA and the host baseline
// has no FMA instruction to contract into - so a pixel within a rounding of a boundary can still fall
// either way, which is what its parity test's tolerances are for. The Metal engine is: neither the host
// nor the Metal compiler contracts anything, and with fast math off Metal rounds sqrt and division
// correctly, as the CPU does (BraggIntegrationEngineMetal.mm).
// =============================================================================
#ifndef __METAL_VERSION__
#include <cmath>
#include <cstdint>
#endif
#ifdef __CUDACC__
#define BRAGG_STENCIL_HD __host__ __device__ inline
@@ -65,14 +69,24 @@
#define BRAGG_STENCIL_HD inline
#endif
// Metal keeps a program-scope constant in its constant address space, and a reference names the space
// it points into (here always the thread's own), so both say so.
#ifdef __METAL_VERSION__
#define BRAGG_STENCIL_CONSTANT constant
#define BRAGG_STENCIL_THREAD thread
#else
#define BRAGG_STENCIL_CONSTANT
#define BRAGG_STENCIL_THREAD
#endif
// The measured spot footprint (SpotFootprint.h): the radial and tangential standard deviations of a
// spot [px], tabulated against distance from the beam centre in bins of fp_bin_px. Held here, by value,
// because both engines build every stencil from these parameters - the GPU gets the table with the
// kernel arguments and needs no buffer of its own.
constexpr int BRAGG_FOOTPRINT_MAX_BINS = 16;
BRAGG_STENCIL_CONSTANT constexpr int BRAGG_FOOTPRINT_MAX_BINS = 16;
// A spot whose footprint reaches this many of its standard deviations past r1 has outgrown the disk, and
// gets the footprint stencil below.
constexpr float BRAGG_FOOTPRINT_NSIGMA = 3.0f;
BRAGG_STENCIL_CONSTANT constexpr float BRAGG_FOOTPRINT_NSIGMA = 3.0f;
// How far that stencil reaches, in the footprint's standard deviations: the summation ellipse ends and the
// background ring starts there, and the profile is fitted out to it. Further than the 3 sigma that decides
// whether a spot outgrew the disk, because a wide spot is not a Gaussian: mosaic streaks and diffuse halos
@@ -80,7 +94,7 @@ constexpr float BRAGG_FOOTPRINT_NSIGMA = 3.0f;
// corrections applied to both integrations, so only the integration differs, 4 sigma closed about a third
// of the gap to XDS's 3D summation on the haloed small-molecule set and was neutral on the streaked one;
// 5 sigma was no better than 4.
constexpr float BRAGG_FOOTPRINT_REACH = 4.0f;
BRAGG_STENCIL_CONSTANT constexpr float BRAGG_FOOTPRINT_REACH = 4.0f;
// Fixed per-experiment inputs to the stencil law (mirrors BraggIntegrationEngine's members).
struct BraggStencilParams {
@@ -116,7 +130,7 @@ struct BraggStencil {
// Radial growth of the ring at Rpx, in pixels: k_sigma times the bandwidth streak, capped. Shared
// so the stencil and the radial-background kernel table agree on what "grown by this much" means.
BRAGG_STENCIL_HD float BraggStencilGrow_px(float Rpx, const BraggStencilParams &p) {
BRAGG_STENCIL_HD float BraggStencilGrow_px(float Rpx, const BRAGG_STENCIL_THREAD BraggStencilParams &p) {
if (!(p.k_sigma > 0.0f)) return 0.0f;
// The cap bounds what a mis-declared bandwidth can do to the bounding box. A caller that
// forgets to set it gets the uncapped growth rather than silently no growth at all.
@@ -125,7 +139,8 @@ BRAGG_STENCIL_HD float BraggStencilGrow_px(float Rpx, const BraggStencilParams &
}
// The footprint at Rpx: linear between bin centres, flat beyond the first and the last.
BRAGG_STENCIL_HD void BraggFootprintAt(float Rpx, const BraggStencilParams &p, float &s_rad, float &s_tan) {
BRAGG_STENCIL_HD void BraggFootprintAt(float Rpx, const BRAGG_STENCIL_THREAD BraggStencilParams &p,
BRAGG_STENCIL_THREAD float &s_rad, BRAGG_STENCIL_THREAD float &s_tan) {
const float t = Rpx / p.fp_bin_px - 0.5f;
int i = (int) floorf(t);
float f = t - (float) i;
@@ -139,7 +154,7 @@ BRAGG_STENCIL_HD void BraggFootprintAt(float Rpx, const BraggStencilParams &p, f
// Growth of the ring beyond r2 that the footprint asks for along one axis: none while the spot fits
// the r1 disk - there the disk resolves the spot and the stencil stays what it always was - and
// otherwise out to BRAGG_FOOTPRINT_REACH of it, capped as the bandwidth growth is.
BRAGG_STENCIL_HD float BraggFootprintGrow_px(float sigma, const BraggStencilParams &p) {
BRAGG_STENCIL_HD float BraggFootprintGrow_px(float sigma, const BRAGG_STENCIL_THREAD BraggStencilParams &p) {
const float g = BRAGG_FOOTPRINT_REACH * sigma - p.r2;
if (!(g > 0.0f)) return 0.0f;
return (p.max_grow > 0.0f && g > p.max_grow) ? p.max_grow : g;
@@ -147,7 +162,7 @@ BRAGG_STENCIL_HD float BraggFootprintGrow_px(float sigma, const BraggStencilPara
// The largest growth any reflection on a detector reaching r_max can get, radially - what the
// buffers that hold a ring are sized by.
BRAGG_STENCIL_HD float BraggStencilMaxGrow_px(float r_max, const BraggStencilParams &p) {
BRAGG_STENCIL_HD float BraggStencilMaxGrow_px(float r_max, const BRAGG_STENCIL_THREAD BraggStencilParams &p) {
float g = BraggStencilGrow_px(r_max, p);
for (int i = 0; i < p.fp_n; ++i) {
const float s = fmaxf(p.fp_sigma_rad[i], p.fp_sigma_tan[i]);
@@ -156,7 +171,7 @@ BRAGG_STENCIL_HD float BraggStencilMaxGrow_px(float r_max, const BraggStencilPar
return g;
}
BRAGG_STENCIL_HD BraggStencil MakeBraggStencil(float px_x, float px_y, const BraggStencilParams &p) {
BRAGG_STENCIL_HD BraggStencil MakeBraggStencil(float px_x, float px_y, const BRAGG_STENCIL_THREAD BraggStencilParams &p) {
BraggStencil s;
const float rx = px_x - p.beam_x, ry = px_y - p.beam_y;
const float r0 = sqrtf(rx * rx + ry * ry);
@@ -212,7 +227,7 @@ BRAGG_STENCIL_HD BraggStencil MakeBraggStencil(float px_x, float px_y, const Bra
// Taken from the stencil, so the kernel and the ring it corrects are built from the same radius -
// deriving it from a separately computed r0 let the two engines round differently and pick
// different rows.
BRAGG_STENCIL_HD int BraggStencilKernelIndex(const BraggStencil &s, int n_kern) {
BRAGG_STENCIL_HD int BraggStencilKernelIndex(const BRAGG_STENCIL_THREAD BraggStencil &s, int n_kern) {
if (n_kern <= 1) return 0;
int idx = (int) lroundf(s.grow);
if (idx < 0) idx = 0;
@@ -234,8 +249,8 @@ struct BraggStencilDist {
// minimum, so the nearest centre wins whatever order the writes arrive in and the lowest index breaks
// a tie. Eight bits of distance over the claim radius is 1/40 px at the default r2 = 6 px, far finer
// than the prediction itself. A pixel nobody claimed is nobody's neighbour, hence the owner's.
constexpr uint32_t BRAGG_OWNER_NONE = 0xffffffffu;
constexpr uint32_t BRAGG_OWNER_IDX_MASK = 0x00ffffffu;
BRAGG_STENCIL_CONSTANT constexpr uint32_t BRAGG_OWNER_NONE = 0xffffffffu;
BRAGG_STENCIL_CONSTANT constexpr uint32_t BRAGG_OWNER_IDX_MASK = 0x00ffffffu;
BRAGG_STENCIL_HD int BraggOwnerQuant(float d, float inv_claim) {
int q = (int) (d * inv_claim * 255.0f);
@@ -258,7 +273,7 @@ BRAGG_STENCIL_HD bool BraggOwnedBy(uint32_t owner, int index) {
// intensity at 1, 2 and 3 px, and the merge fell apart with it. What the margin removes is the
// reflection's own profile, not the neighbour's tail, so the plain nearest-centre split is the rule.
BRAGG_STENCIL_HD BraggStencilDist BraggStencilDistances(const BraggStencil &s, float ddx, float ddy) {
BRAGG_STENCIL_HD BraggStencilDist BraggStencilDistances(const BRAGG_STENCIL_THREAD BraggStencil &s, float ddx, float ddy) {
BraggStencilDist d;
d.rad = ddx * s.ux + ddy * s.uy;
d.signal = ddx * ddx + ddy * ddy;
@@ -276,7 +291,7 @@ BRAGG_STENCIL_HD BraggStencilDist BraggStencilDistances(const BraggStencil &s, f
// The signal region: the r1 disk, and for a spot that outgrows it the whole footprint ellipse too, so
// the summation - the seed of the profile fit, and what the fit falls back to - holds the spot rather
// than its core. Without a footprint this is the plain disk test, bit for bit.
BRAGG_STENCIL_HD bool BraggStencilInSignal(const BraggStencil &s, float ddx, float ddy, float d2, float r1_sq) {
BRAGG_STENCIL_HD bool BraggStencilInSignal(const BRAGG_STENCIL_THREAD BraggStencil &s, float ddx, float ddy, float d2, float r1_sq) {
if (d2 < r1_sq) return true;
if (!(s.fp_s2t > 0.0f)) return false;
const float rad = ddx * s.ux + ddy * s.uy, tn = -ddx * s.uy + ddy * s.ux;
@@ -3,6 +3,9 @@ ADD_LIBRARY(JFJochBraggIntegration STATIC
BraggIntegrationEngine.h
BraggIntegrationEngineCPU.cpp
BraggIntegrationEngineCPU.h
BraggPassA.h
BraggPassB.h
BraggExp.h
SpotFootprint.cpp
SpotFootprint.h
Regression.h
@@ -21,3 +24,30 @@ IF (JFJOCH_CUDA_AVAILABLE)
TARGET_SOURCES(JFJochBraggIntegration PRIVATE ../indexing/CUDAMemHelpers.h
BraggIntegrationEngineGPU.cu BraggIntegrationEngineGPU.h)
ENDIF()
IF (APPLE)
# The Metal engine compiles its kernels when the program starts, from source carried in the binary:
# macOS has the Metal compiler built in, while the offline one is an Xcode download of its own. The
# source is the stencil, exp and pass headers the CPU engine compiles and the kernels, copied
# into a generated .cpp here; editing one of them re-runs the configure.
SET(METAL_PARTS stencil BraggStencil.h pass_a BraggPassA.h exp BraggExp.h pass_b BraggPassB.h
kernels BraggIntegrationEngineMetal.metal)
SET(METAL_SOURCE "// Generated from image_analysis/bragg_integration by its CMakeLists.txt - do not edit.\n")
WHILE (METAL_PARTS)
LIST(POP_FRONT METAL_PARTS name file)
FILE(READ ${CMAKE_CURRENT_SOURCE_DIR}/${file} text)
STRING(APPEND METAL_SOURCE "extern const char *const bragg_metal_source_${name};\n"
"const char *const bragg_metal_source_${name} = R\"BRAGG_METAL(\n// ${file}\n${text})BRAGG_METAL\";\n")
SET_PROPERTY(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${file})
ENDWHILE()
# Written only when it changed, so a configure does not rebuild the library.
FILE(WRITE ${CMAKE_CURRENT_BINARY_DIR}/BraggIntegrationEngineMetalSource.cpp.tmp "${METAL_SOURCE}")
CONFIGURE_FILE(${CMAKE_CURRENT_BINARY_DIR}/BraggIntegrationEngineMetalSource.cpp.tmp
${CMAKE_CURRENT_BINARY_DIR}/BraggIntegrationEngineMetalSource.cpp COPYONLY)
TARGET_SOURCES(JFJochBraggIntegration PRIVATE BraggIntegrationEngineMetal.mm BraggIntegrationEngineMetal.h
BraggIntegrationEngineMetal.metal ${CMAKE_CURRENT_BINARY_DIR}/BraggIntegrationEngineMetalSource.cpp)
SET_SOURCE_FILES_PROPERTIES(BraggIntegrationEngineMetal.mm PROPERTIES COMPILE_OPTIONS -fobjc-arc)
SET_SOURCE_FILES_PROPERTIES(BraggIntegrationEngineMetal.metal PROPERTIES HEADER_FILE_ONLY ON)
TARGET_LINK_LIBRARIES(JFJochBraggIntegration "-framework Metal" "-framework Foundation")
ENDIF()
@@ -275,6 +275,12 @@ namespace {
// the loop shrank every scale by 10-30% per round for thirty rounds under the robust fit and
// settled in fourteen under the plain one. With the weights the reference uses, the plain
// alternating fit is exact coordinate descent on one objective and cannot climb.
double ScaleFromSums(double num, double den, double &info) {
const double G = den > 0.0 ? num / den : NAN;
info = den;
return std::isfinite(G) ? std::max(0.0, G) : 1.0;
}
double SolveScale(const std::vector<ScaleObs> &obs, double &info) {
double num = 0.0, den = 0.0;
for (const auto &o : obs) {
@@ -282,9 +288,7 @@ namespace {
num += w2 * o.coeff * o.Iobs;
den += w2 * o.coeff * o.coeff;
}
const double G = den > 0.0 ? num / den : NAN;
info = den;
return std::isfinite(G) ? std::max(0.0, G) : 1.0;
return ScaleFromSums(num, den, info);
}
double median_of(std::vector<double> &v) {
@@ -1520,18 +1524,6 @@ int RotationScaleMerge::ComputeAsuGroups(const HKLKeyGenerator &keygen) {
return n_groups;
}
double RotationScaleMerge::ReferenceWeight(int32_t group, float corr, float partiality, float I, float sigma,
double min_partiality, float &I_corr) {
if (group < 0) return 0.0;
if (!(corr > 0.0f) || !std::isfinite(corr)) return 0.0;
if (partiality < min_partiality) return 0.0;
const float Ic = I * corr;
const float sigma_corr = sigma * corr;
if (!std::isfinite(Ic) || !std::isfinite(sigma_corr) || sigma_corr <= 0.0f) return 0.0;
I_corr = Ic;
return 1.0 / (static_cast<double>(sigma_corr) * sigma_corr);
}
void RotationScaleMerge::ReduceFullGroupMeans(int n_groups, std::vector<double> &out_mean) const {
// Inverse-variance per-group mean of I*corr = the merge reference (no cell mask: the scaling
// reference is built without a reference cell - only the final merge applies it). A group per task
@@ -1573,7 +1565,7 @@ void RotationScaleMerge::ReducePartialGroupMeans(int n_groups, std::vector<doubl
void RotationScaleMerge::ReduceScalingGroupMeans(int n_groups, const std::vector<ScalingRef> &refs,
const std::vector<float> &corr,
std::vector<double> &out_mean) const {
out_mean.assign(n_groups, NAN);
out_mean.resize(n_groups);
ParallelChunks(n_groups, ThreadsForWork(corr.size(), nthreads), [&](int glo, int ghi) {
for (int g = glo; g < ghi; ++g) {
double sw = 0.0, swI = 0.0;
@@ -1581,13 +1573,12 @@ void RotationScaleMerge::ReduceScalingGroupMeans(int n_groups, const std::vector
for (int p = lo; p < hi; ++p) {
const ScalingRef &r = refs[p];
float I_corr = 0.0f;
const double w = ReferenceWeight(g, corr[partial_group_perm[p]], r.partiality, r.I, r.sigma,
min_partiality, I_corr);
const double w = ReferenceWeight(g, corr[p], r.partiality, r.I, r.sigma, min_partiality, I_corr);
if (w <= 0.0) continue;
sw += w;
swI += w * I_corr;
}
if (sw > 0.0) out_mean[g] = swI / sw;
out_mean[g] = sw > 0.0 ? swI / sw : NAN;
}
});
}
@@ -1718,6 +1709,31 @@ void RotationScaleMerge::FitPerFrameG(const std::vector<O> &obs, const std::vect
frame_info_scratch = std::move(info);
}
void RotationScaleMerge::FitScalingFrames(const std::vector<ScalingFit> &fit, const std::vector<int32_t> &fit_start,
const std::vector<double> &group_mean_in, std::vector<double> &g) {
const int n_fr = static_cast<int>(fit_start.size()) - 1;
std::vector<uint8_t> scaled(n_fr, 0);
std::vector<double> info(n_fr, 0.0);
ParallelFor(n_fr, nthreads, [&](int f) {
double num = 0.0, den = 0.0;
size_t n = 0;
for (int i = fit_start[f]; i < fit_start[f + 1]; ++i) {
const ScalingFit &o = fit[i];
const double mean = group_mean_in[o.group];
if (!std::isfinite(mean)) continue;
const double coeff = o.coeff_per_mean * mean;
num += o.w2 * coeff * o.Iobs;
den += o.w2 * coeff * coeff;
++n;
}
if (n < MIN_REFLECTIONS) return;
g[f] = ScaleFromSums(num, den, info[f]);
scaled[f] = 1;
});
frame_scaled_scratch = std::move(scaled);
frame_info_scratch = std::move(info);
}
void RotationScaleMerge::ShrinkToRestrained(const char *what, const std::vector<double> &g_restrained,
const std::vector<uint8_t> &fitted,
const std::vector<int32_t> &frame_obs_count,
@@ -6414,28 +6430,49 @@ RotationScaleMerge::Result RotationScaleMerge::RunArm(bool scale_partials, bool
logger->Info("Per-frame scaling (partials): the same grouping and settings as an earlier merge, "
"so its {} iterations stand", partial_loop.iterations);
} else {
// The loop works on its own compact copy of the partials (ScalingObs in the header) and hands
// corr back to them when it is done.
// The loop works on its own compact copy of the partials (ScalingRef and ScalingFit in the
// header) and hands corr back to them when it is done. scaling_index[q] is the partial at
// position q of the copy: the CSR's group order, then the partials in no group.
const int n_partials = static_cast<int>(partials.size());
const size_t partial_threads = ThreadsForWork(partials.size(), nthreads);
std::vector<ScalingObs> scaling_obs(n_partials);
const int n_grouped = partial_group_start.empty()
? 0 : partial_group_start.back() + partial_group_count.back();
std::vector<int32_t> scaling_index(partial_group_perm.get(), partial_group_perm.get() + n_grouped);
for (int i = 0; i < n_partials; ++i)
if (partials[i].group < 0) scaling_index.push_back(i);
std::vector<ScalingRef> scaling_refs(n_partials);
std::vector<float> scaling_corr(n_partials);
ParallelChunks(n_partials, partial_threads, [&](int lo, int hi) {
for (int i = lo; i < hi; ++i) {
const Obs &o = partials[i];
scaling_obs[i] = {o.frame, o.group, o.I, o.sigma, o.partiality, o.prescaling_corr, o.zeta,
o.on_ice};
scaling_corr[i] = o.corr;
for (int q = lo; q < hi; ++q) {
const Obs &o = partials[scaling_index[q]];
scaling_refs[q] = {o.frame, o.I, o.sigma, o.partiality, o.prescaling_corr};
scaling_corr[q] = o.corr;
}
});
std::vector<ScalingRef> scaling_refs(partial_group_start.empty()
? 0 : partial_group_start.back() + partial_group_count.back());
ParallelChunks(n_groups, partial_threads, [&](int glo, int ghi) {
for (int g = glo; g < ghi; ++g)
for (int p = partial_group_start[g]; p < partial_group_start[g] + partial_group_count[g]; ++p) {
const ScalingObs &o = scaling_obs[partial_group_perm[p]];
scaling_refs[p] = {o.I, o.sigma, o.partiality};
}
// FitPerFrameG's filters, all but the reference mean's, frame by frame.
auto fitted_obs = [&](const Obs &o) {
return o.group >= 0 && !o.on_ice && o.partiality >= min_partiality
&& std::isfinite(o.zeta) && o.zeta > 0.0f;
};
const int n_fit_frames = static_cast<int>(frame_start.size());
std::vector<int32_t> fit_start(n_fit_frames + 1, 0);
ParallelFor(n_fit_frames, nthreads, [&](int f) {
fit_start[f + 1] = static_cast<int32_t>(std::count_if(
partials.begin() + frame_start[f], partials.begin() + frame_start[f] + frame_count[f],
fitted_obs));
});
for (int f = 0; f < n_fit_frames; ++f) fit_start[f + 1] += fit_start[f];
std::vector<ScalingFit> scaling_fit(fit_start.back());
ParallelFor(n_fit_frames, nthreads, [&](int f) {
int32_t k = fit_start[f];
for (int i = frame_start[f]; i < frame_start[f] + frame_count[f]; ++i) {
const Obs &o = partials[i];
if (!fitted_obs(o)) continue;
const double weight = SafeInv(o.sigma, 1.0);
scaling_fit[k++] = {o.group, o.partiality * SafeInv(o.prescaling_corr, 1.0),
static_cast<double>(o.I), weight * weight};
}
});
// RunScalingLoop rescales after every iteration before anything reads corr, so the iteration's
@@ -6446,14 +6483,14 @@ RotationScaleMerge::Result RotationScaleMerge::RunArm(bool scale_partials, bool
partial_loop = RunScalingLoop("partials", g_partial, frame_count, smooth_window,
[&] {
ReduceScalingGroupMeans(n_groups, scaling_refs, scaling_corr, partial_mean);
FitPerFrameG(scaling_obs, frame_start, frame_count, partial_mean, /*unity=*/false, g_partial);
FitScalingFrames(scaling_fit, fit_start, partial_mean, g_partial);
g_fitted = g_partial;
frame_fitted = frame_scaled_scratch;
}, [&](const std::vector<uint8_t> &apply, const std::vector<double> &ratio) {
ParallelChunks(n_partials, partial_threads, [&](int lo, int hi) {
for (int i = lo; i < hi; ++i) {
const ScalingObs &o = scaling_obs[i];
float &corr = scaling_corr[i];
for (int q = lo; q < hi; ++q) {
const ScalingRef &o = scaling_refs[q];
float &corr = scaling_corr[q];
if (frame_fitted[o.frame]) { // UpdateCorr
const double denom = static_cast<double>(o.partiality) * g_fitted[o.frame];
if (std::isfinite(o.prescaling_corr) && std::isfinite(denom) && denom > 0.0)
@@ -6468,7 +6505,11 @@ RotationScaleMerge::Result RotationScaleMerge::RunArm(bool scale_partials, bool
});
ParallelChunks(n_partials, partial_threads, [&](int lo, int hi) {
for (int i = lo; i < hi; ++i) partials[i].corr = scaling_corr[i];
for (int q = lo; q < hi; ++q) partials[scaling_index[q]].corr = scaling_corr[q];
});
// The memo keeps corr in the partials' own order.
ParallelChunks(n_partials, partial_threads, [&](int lo, int hi) {
for (int i = lo; i < hi; ++i) scaling_corr[i] = partials[i].corr;
});
PartialLoopMemo m{key, partial_loop, frame_scaled_scratch, g_partial, std::move(scaling_corr)};
if (partial_loop_memos.size() == MAX_PARTIAL_LOOP_MEMOS)
+28 -12
View File
@@ -619,22 +619,35 @@ private:
// The CPU partial scaling loop's working copy of the partials. It passes over them several times
// per iteration and reads a handful of fields each time, so it works on these rather than on the
// eighty-byte Obs: ScalingObs holds, in the partials' own order, what FitPerFrameG and the corr
// update read; ScalingRef holds, in the CSR's group order, what the group means read besides corr,
// so that only corr is gathered. The same values, so the same arithmetic in the same order.
struct ScalingObs {
int32_t frame, group;
float I, sigma, partiality, prescaling_corr, zeta;
uint8_t on_ice;
};
// eighty-byte Obs. ScalingRef holds what the group means and the corr update read, in the CSR's
// group order with the partials in no group after them, and corr is kept beside it in the same
// order - so neither pass gathers. ScalingFit holds, frame by frame, the observations
// FitScalingFrames fits: FitPerFrameG's filters, which do not change over the loop, applied once,
// and the factors of its sums that do not depend on the reference worked out once. The same
// values, so the same arithmetic in the same order.
struct ScalingRef {
float I, sigma, partiality;
int32_t frame;
float I, sigma, partiality, prescaling_corr;
};
struct ScalingFit {
int32_t group;
double coeff_per_mean; // partiality / prescaling_corr: FitPerFrameG's coeff for a mean of 1
double Iobs, w2;
};
// The observation's inverse-variance weight in the scaling reference, and its scaled intensity;
// 0 when the observation is not in the reference at all.
static double ReferenceWeight(int32_t group, float corr, float partiality, float I, float sigma,
double min_partiality, float &I_corr);
double min_partiality, float &I_corr) {
if (group < 0) return 0.0;
if (!(corr > 0.0f) || !std::isfinite(corr)) return 0.0;
if (partiality < min_partiality) return 0.0;
const float Ic = I * corr;
const float sigma_corr = sigma * corr;
if (!std::isfinite(Ic) || !std::isfinite(sigma_corr) || sigma_corr <= 0.0f) return 0.0;
I_corr = Ic;
return 1.0 / (static_cast<double>(sigma_corr) * sigma_corr);
}
static double ReferenceWeight(const Obs &o, double min_partiality, float &I_corr) {
return ReferenceWeight(o.group, o.corr, o.partiality, o.I, o.sigma, min_partiality, I_corr);
}
@@ -643,17 +656,20 @@ private:
void ReduceFullGroupMeans(int n_groups, std::vector<double> &out_mean) const;
// The same over the partials, using the CSR above - the same sums in the same order.
void ReducePartialGroupMeans(int n_groups, std::vector<double> &out_mean) const;
// The same again over the scaling loop's copy: refs in group order, corr in the partials' order.
// The same again over the scaling loop's copy, refs and corr both in group order.
void ReduceScalingGroupMeans(int n_groups, const std::vector<ScalingRef> &refs,
const std::vector<float> &corr, std::vector<double> &out_mean) const;
// Robust per-frame G fit (IRLS, Cauchy k=3), unity=false uses the rotation partiality, unity=true the
// scale-fulls (partiality already folded in). Reads out_mean[group] as the reference intensity.
// O is Obs or ScalingObs.
template <class O>
void FitPerFrameG(const std::vector<O> &obs, const std::vector<int32_t> &fstart,
const std::vector<int32_t> &fcount, const std::vector<double> &group_mean_in,
bool unity, std::vector<double> &g);
// FitPerFrameG(unity = false) over the scaling loop's ScalingFit, frame f's at
// [fit_start[f], fit_start[f + 1]).
void FitScalingFrames(const std::vector<ScalingFit> &fit, const std::vector<int32_t> &fit_start,
const std::vector<double> &group_mean_in, std::vector<double> &g);
// The alternating per-frame scale fit run to convergence: `iterate` fits every frame once against
// the current reference (leaving G in `g` and the frames it fitted in frame_scaled_scratch),
+12
View File
@@ -72,6 +72,9 @@
#include "../writer/WriteModel.h"
#include "ResultReport.h"
#include "RugnuxConsole.h"
#ifdef __APPLE__
#include "../image_analysis/bragg_integration/BraggIntegrationEngineMetal.h"
#endif
// Spots kept per image (the strongest ones) and handed to indexing. Offline reprocessing is not
// bound by the online spot budget, so this is rugnux's own default rather than the 250 the
@@ -868,6 +871,12 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
RunProvenance provenance;
provenance.command_line = JoinCommandLine(argc, argv);
provenance.gpu_count = get_gpu_count();
#ifdef __APPLE__
// Without CUDA, the Bragg integration's integer pixel work runs on the Apple GPU (MXAnalysisWithoutFPGA).
const std::string metal_device = BraggIntegrationEngineMetal::DeviceName();
#else
const std::string metal_device;
#endif
provenance.gpu_description = get_gpu_description();
// The terminal shows the run as steps (RugnuxConsole) and every Logger writes to the log file
@@ -909,6 +918,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
logger.Info("Command line: {}", provenance.command_line);
logger.Info("GPU: {}", no_gpu ? std::string("not used (--no-gpu) - running on the CPU")
: provenance.gpu_count > 0 ? fmt::format("{} ({} visible)", provenance.gpu_description, provenance.gpu_count)
: !metal_device.empty() ? fmt::format("{} through Metal, for the Bragg integration only - the rest runs on the CPU", metal_device)
: std::string("none visible - running on the CPU"));
RegisterHDF5Filter();
@@ -1664,6 +1674,8 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
: provenance.gpu_count > 0
? fmt::format("{}{}, {} threads", provenance.gpu_count > 1 ? fmt::format("{} x ", provenance.gpu_count) : "",
provenance.gpu_description, nthreads)
: !metal_device.empty()
? fmt::format("{} CPU threads, Bragg integration on {} (Metal)", nthreads, metal_device)
: fmt::format("no GPU visible - {} CPU threads, which is much slower", nthreads));
console->Rule('-');
+265
View File
@@ -0,0 +1,265 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <catch2/catch_all.hpp>
#ifdef __APPLE__
#include <bit>
#include <cmath>
#include <cstdint>
#include <random>
#include <vector>
#include "../common/BraggIntegrationSettings.h"
#include "../common/DetectorSetup.h"
#include "../common/DiffractionExperiment.h"
#include "../common/PixelMask.h"
#include "../common/Reflection.h"
#include "../image_analysis/bragg_integration/BraggIntegrationEngineCPU.h"
#include "../image_analysis/bragg_integration/BraggIntegrationEngineMetal.h"
// The Metal engine's whole claim is that it IS the CPU engine, bit for bit (BraggIntegrationEngineMetal.h).
// So nothing here has a tolerance: every float of every reflection is compared as a bit pattern, and
// every count as an integer. A Mac without a Metal device fails rather than skips - the macOS CI job is
// the only place this can be checked, and a skip there would check nothing.
namespace {
struct Frame {
std::vector<int32_t> image;
std::vector<Reflection> predicted;
};
// A crowded random frame: predictions at random sub-pixel positions (so the pixel classification meets
// every kind of rounding near the stencil boundaries), a quarter of them on whole and half pixels (so it
// meets exact ties with the radii too), with random partialities (rocking-curve tails and flux, which
// mask the rings differently), about a third of them with a spot, overlapping where they fall close. On
// a background that rises with the radius (the radial correction has a curve to fit) with noise that
// takes it below zero near the beam, as a pedestal-subtracted detector does (a negative pixel is what
// carries into the high word of the GPU's 64-bit radial sums), hot pixels in the rings (so the
// background clip rejects something), and unreadable pixels: some in the run's mask, some saturated,
// some unreadable on this frame alone (both read as an overload), inside spots as well as between them.
Frame RandomFrame(const DiffractionExperiment &x, uint32_t seed, const std::vector<size_t> &masked) {
const int W = static_cast<int>(x.GetXPixelsNum()), H = static_cast<int>(x.GetYPixelsNum());
const float beam_x = x.GetBeamX_pxl(), beam_y = x.GetBeamY_pxl();
std::mt19937 rng(seed);
std::uniform_real_distribution<float> U(0.0f, 1.0f);
Frame f;
f.image.resize(static_cast<size_t>(W) * H);
for (int y = 0; y < H; ++y)
for (int x_ = 0; x_ < W; ++x_) {
const float r = std::hypot(x_ - beam_x, y - beam_y);
const float mean = 0.5f + 0.01f * r;
f.image[static_cast<size_t>(y) * W + x_] = static_cast<int32_t>(std::lround(mean + (U(rng) - 0.5f) * 8.0f));
}
const int nrefl = W * H / 400;
int hkl = 1;
for (int n = 0; n < nrefl; ++n) {
Reflection r{};
r.h = hkl; r.k = -hkl; r.l = hkl % 7; ++hkl;
r.predicted_x = 2.0f + U(rng) * (W - 4);
r.predicted_y = 2.0f + U(rng) * (H - 4);
if (n % 4 == 1) {
r.predicted_x = std::round(r.predicted_x * 2.0f) / 2.0f;
r.predicted_y = std::round(r.predicted_y);
}
const float u = U(rng);
r.partiality = u < 0.3f ? 0.01f * u : u; // a third of them rocking-curve tails
r.d = 1.2f + 3.0f * U(rng);
r.prescaling_corr = 1.0f;
f.predicted.push_back(r);
if (U(rng) < 0.35f) {
const float amp = 20.0f + 3000.0f * U(rng) * U(rng);
const float s = 0.8f + 1.4f * U(rng);
for (int dy = -8; dy <= 8; ++dy)
for (int dx = -8; dx <= 8; ++dx) {
const int px = static_cast<int>(std::lround(r.predicted_x)) + dx;
const int py = static_cast<int>(std::lround(r.predicted_y)) + dy;
if (px < 0 || py < 0 || px >= W || py >= H) continue;
const float ex = px - r.predicted_x, ey = py - r.predicted_y;
f.image[static_cast<size_t>(py) * W + px] += static_cast<int32_t>(
std::lround(amp * std::exp(-(ex * ex + ey * ey) / (2.0f * s * s))));
}
}
}
std::uniform_int_distribution<size_t> P(0, f.image.size() - 1);
for (int k = 0; k < W * H / 500; ++k)
f.image[P(rng)] += 200 + static_cast<int32_t>(5000 * U(rng)); // hot pixels
for (int k = 0; k < W * H / 3000; ++k)
f.image[P(rng)] = U(rng) < 0.5f ? INT32_MIN : INT32_MAX; // unreadable on this frame
for (size_t i = 0; i < f.predicted.size(); i += 13) { // ... inside spots too
const auto &r = f.predicted[i];
const size_t idx = static_cast<size_t>(std::lround(r.predicted_y)) * W + std::lround(r.predicted_x) + i % 3;
if (idx < f.image.size()) f.image[idx] = i % 2 ? INT32_MIN : INT32_MAX;
}
for (size_t idx : masked)
f.image[idx] = INT32_MIN; // what the run's mask holds
return f;
}
struct Case {
IntegratorMode mode = IntegratorMode::ProfileGaussian;
OverlapMode overlap = OverlapMode::Exclude;
bool radial = false;
std::optional<float> bandwidth;
float stencil_k = 0.0f;
bool footprint = false;
float clip_nsigma = 4.0f;
};
DiffractionExperiment MakeExperiment(const DetectorSetup &det, const Case &c) {
DiffractionExperiment x(det);
x.DetectorDistance_mm(100.0f).IncidentEnergy_keV(WVL_1A_IN_KEV)
.BeamX_pxl(0.4f * static_cast<float>(x.GetXPixelsNum()))
.BeamY_pxl(0.55f * static_cast<float>(x.GetYPixelsNum()));
x.BandwidthFWHM(c.bandwidth);
BraggIntegrationSettings s;
s.Integrator(c.mode).Overlap(c.overlap).BackgroundRadialCorrection(c.radial).StencilKSigma(c.stencil_k)
.BackgroundClipNSigma(c.clip_nsigma);
if (c.footprint) {
// Wider than the r1 disk can hold from the middle out, so both stencils occur on one frame.
SpotFootprint fp;
fp.bin_px = 100.0f;
for (int b = 0; b < 8; ++b) {
fp.sigma_rad.push_back(0.9f + 0.35f * b);
fp.sigma_tan.push_back(0.8f + 0.3f * b);
}
s.Footprint(fp);
}
x.ImportBraggIntegrationSettings(s);
return x;
}
uint32_t Bits(float f) { return std::bit_cast<uint32_t>(f); }
void RequireSame(const std::vector<Reflection> &cpu, const std::vector<Reflection> &metal) {
REQUIRE(metal.size() == cpu.size());
for (size_t i = 0; i < cpu.size(); ++i) {
const Reflection &a = cpu[i], &b = metal[i];
INFO("reflection " << i << " hkl " << a.h << " " << a.k << " " << a.l
<< " I " << a.I << " / " << b.I << " bkg " << a.bkg << " / " << b.bkg);
REQUIRE(a.h == b.h);
REQUIRE(a.k == b.k);
REQUIRE(a.l == b.l);
REQUIRE(Bits(a.image_number) == Bits(b.image_number));
REQUIRE(Bits(a.I) == Bits(b.I));
REQUIRE(Bits(a.sigma) == Bits(b.sigma));
REQUIRE(Bits(a.bkg) == Bits(b.bkg));
REQUIRE(Bits(a.var_bkg) == Bits(b.var_bkg));
REQUIRE(Bits(a.observed_x) == Bits(b.observed_x));
REQUIRE(Bits(a.observed_y) == Bits(b.observed_y));
REQUIRE(Bits(a.image_scale_corr) == Bits(b.image_scale_corr));
REQUIRE(Bits(a.prescaling_corr) == Bits(b.prescaling_corr));
REQUIRE(a.observed == b.observed);
REQUIRE(a.overloaded == b.overloaded);
}
}
void RequireSame(const BraggIntegrationCounts &a, const BraggIntegrationCounts &b) {
CHECK(a.predicted == b.predicted);
CHECK(a.bkg_starved == b.bkg_starved);
CHECK(a.bkg_starved_by_neighbour == b.bkg_starved_by_neighbour);
CHECK(a.profile_fallback == b.profile_fallback);
CHECK(a.overlapped == b.overlapped);
CHECK(a.overlapped_major == b.overlapped_major);
}
// Three frames through one pair of engines - so whatever the Metal engine keeps between frames (its
// masks, its buffers) has to be left as the CPU engine leaves its own - each compared exactly.
// Returns how many reflections came out, so a case can show it was not vacuous.
size_t CompareFrames(const DetectorSetup &det, const Case &c) {
const DiffractionExperiment x = MakeExperiment(det, c);
REQUIRE(BraggIntegrationEngineMetal::Available(x));
const size_t W = x.GetXPixelsNum();
// A few pixels of the run's mask, under spots and between them: unreadable there, but not overloads.
PixelMask mask(x);
std::vector<uint32_t> user(x.GetPixelsNum(), 0);
std::vector<size_t> masked;
for (size_t k = 0; k < x.GetPixelsNum(); k += 7919) {
user[k] = 1;
masked.push_back(k);
}
mask.LoadUserMask(x, user);
BraggIntegrationEngineCPU cpu(x, mask);
BraggIntegrationEngineMetal metal(x, mask);
ImagePreprocessorBuffer image(x.GetPixelsNum());
size_t n_out = 0;
for (uint32_t frame = 0; frame < 3; ++frame) {
INFO("frame " << frame);
const Frame f = RandomFrame(x, 1000 * frame + static_cast<uint32_t>(W), masked);
std::copy(f.image.begin(), f.image.end(), image.data());
// The auto radial mode switches per image; here it is on for the radial cases, every image.
cpu.BackgroundRadial(c.radial);
metal.BackgroundRadial(c.radial);
const auto out_cpu = cpu.Run(image, f.predicted, f.predicted.size(), frame);
const auto out_metal = metal.Run(image, f.predicted, f.predicted.size(), frame);
RequireSame(out_cpu, out_metal);
n_out += out_cpu.size();
}
RequireSame(cpu.Counts(), metal.Counts());
// Not the CPU engine compared with itself: every frame went through the kernels.
REQUIRE(metal.FramesOnGPU() == 3);
return n_out;
}
} // namespace
TEST_CASE("BraggIntegrationEngineMetal_BitIdenticalToCPU", "[portable][Integration][Metal]") {
// A JUNGFRAU 1M, and a small odd-sized detector, where most boxes meet an edge.
const DetectorSetup large = DetJF(2);
const DetectorSetup small = DetDECTRIS(211, 197, "small", {});
std::vector<std::pair<std::string, Case>> cases;
for (IntegratorMode mode : {IntegratorMode::BoxSum, IntegratorMode::ProfileGaussian, IntegratorMode::ProfileEmpirical})
for (OverlapMode overlap : {OverlapMode::Off, OverlapMode::Reject, OverlapMode::Exclude})
for (bool radial : {false, true}) {
Case c;
c.mode = mode;
c.overlap = overlap;
c.radial = radial;
cases.emplace_back("mode " + std::to_string(static_cast<int>(mode)) + " overlap "
+ std::to_string(static_cast<int>(overlap)) + (radial ? " radial" : ""), c);
}
// Rings elongated by the bandwidth, and the measured footprint: per-reflection stencils.
Case broadband;
broadband.bandwidth = 0.01f;
broadband.stencil_k = 3.0f;
broadband.radial = true;
cases.emplace_back("broadband stencil radial", broadband);
Case footprint;
footprint.footprint = true;
footprint.radial = true;
cases.emplace_back("footprint radial", footprint);
Case footprint_box = footprint;
footprint_box.mode = IntegratorMode::BoxSum;
cases.emplace_back("footprint box sum", footprint_box);
// No clip: the plain ring mean, and no clip kernel at all.
Case no_clip;
no_clip.clip_nsigma = 0.0f;
cases.emplace_back("no clip", no_clip);
for (const auto &[name, c] : cases) {
DYNAMIC_SECTION(name) {
CHECK(CompareFrames(large, c) > 1000);
CHECK(CompareFrames(small, c) > 50);
}
}
}
// The trimmed-mean background sorts each ring, which only the CPU engine does: such a run keeps the CPU
// engine (MXAnalysisWithoutFPGA asks Available).
TEST_CASE("BraggIntegrationEngineMetal_TrimmedMeanStaysOnCPU", "[portable][Integration][Metal]") {
Case trim;
trim.clip_nsigma = 0.0f;
DiffractionExperiment x = MakeExperiment(DetJF(2), trim);
BraggIntegrationSettings s = x.GetBraggIntegrationSettings();
s.BackgroundTrimFraction(0.1f);
x.ImportBraggIntegrationSettings(s);
CHECK(!BraggIntegrationEngineMetal::Available(x));
}
#endif
+1
View File
@@ -20,6 +20,7 @@ ADD_EXECUTABLE(jfjoch_portable_test EXCLUDE_FROM_ALL
SpotFootprintTest.cpp
BraggIntegrationEngineCompressedImageTest.cpp
BraggIntegrationEngineCPUTest.cpp
BraggIntegrationEngineMetalTest.cpp
WriteReflectionsTest.cpp
LoadReferenceMtzFreeFlagsTest.cpp
LoadReferenceSfCifTest.cpp