Azimuthal integration: optional sigma clipping of the reported profile

The profile is the MEAN of each bin, so a few strong reflections landing in a bin
lift it exactly as a smooth powder ring does. That is the wrong quantity whenever
the profile is wanted as a background rather than as a measurement of what is in
the bin - the ice score being the case in point, where reading a plain profile
INVERTED the metric: over 37 rotation crystals the two highest-scoring crystals
had no ice at all.

The adaptive spot finder already computes the right thing, a sigma-clipped
per-resolution-ring background, as a byproduct of its own threshold. Where it
runs, the ice score uses that. Where it does not - --no-adaptive-spots,
--azint-only, and anything reading the profile the broker wrote - there was no way
to get it. This adds one: azim_int_settings.sigma_clip (rugnux --azim-sigma-clip),
0 = off, minimum 2 because a tighter clip rejects a large part of a clean Gaussian
bin and biases the estimate low rather than removing outliers.

Two clip passes follow the plain one, matching the finder's recipe - the first
pass's standard deviation is itself inflated by the peaks being removed, so one
pass leaves a threshold that is still too generous. A bin with fewer than eight
pixels is left alone: at the detector edge and behind the beam stop there is no
spread to clip on.

Both engines do it. On the GPU the accept range is computed by a small kernel and
stays resident, so a clip pass is one more read of the same pixels and no round
trip; the two accumulation kernels take the range as a pointer that is null on the
plain pass. Measured on a JUNGFRAU rotation dataset, non-adaptive path: azimuthal
integration 0.02 -> 0.06 ms per image, exactly the 3x the extra passes predict,
against a 0.34 ms per-image total.

Note what the result IS: the smooth background under the peaks, not the bin mean.
It should not be switched on where a ring's integrated intensity is wanted - the
powder-ring geometry fit reads ring peaks, and those are what a clip is designed
to remove. Off by default, so nothing changes unless it is asked for.

Not exposed over the REST API - that needs the generated model regenerated, which
is a separate step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 00:18:52 +02:00
co-authored by Claude Opus 5
parent 3d4209d803
commit a6be35ccdb
24 changed files with 284 additions and 432 deletions
+27 -1
View File
@@ -1,6 +1,9 @@
// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <cmath>
#include <limits>
#include "AzIntEngine.h"
AzIntEngine::AzIntEngine(const AzimuthalIntegrationMapping &integration)
@@ -9,4 +12,27 @@ azint_bins(integration.GetBinNumber()),
npixel(integration.GetPixelToBin().size()),
azint_sum(integration.GetBinNumber(), 0.0f),
azint_sum2(integration.GetBinNumber(), 0.0f),
azint_count(integration.GetBinNumber(), 0u){}
azint_count(integration.GetBinNumber(), 0u),
clip_nsigma(integration.Settings().GetSigmaClip()),
clip_lo(clip_nsigma > 0.0f ? integration.GetBinNumber() : 0),
clip_hi(clip_nsigma > 0.0f ? integration.GetBinNumber() : 0) {}
void AzIntEngine::UpdateClipLimits() {
constexpr float inf = std::numeric_limits<float>::infinity();
for (int i = 0; i < azint_bins; ++i) {
// A handful of pixels have no spread worth the name, and clipping on it would reject most of
// them. Leave such a bin alone - it is the detector edge and the beam stop, not signal.
if (azint_count[i] < 8) {
clip_lo[i] = -inf;
clip_hi[i] = inf;
continue;
}
const double n = azint_count[i];
const double mean = azint_sum[i] / n;
// sum2/n - mean^2 is the exact variance of what was accumulated; it can go slightly negative
// through float cancellation on a bin whose pixels are all but identical, hence the floor.
const double sd = std::sqrt(std::max(0.0, azint_sum2[i] / n - mean * mean));
clip_lo[i] = static_cast<float>(mean - clip_nsigma * sd);
clip_hi[i] = static_cast<float>(mean + clip_nsigma * sd);
}
}