The background ring fit is the largest single phase of an offline run on a large detector: 626 s of a 5403 s hundred-dataset battery, against 127 s for the beam-stop projection it reads. The cost is not spread over its invocations - the worst single invocation per dataset accounts for 516 s of that 626 s - and the worst one is always the second pass, at the post-refined geometry, where the fit starts essentially at the answer. Instrumented per iteration, that invocation is not a truncated walk. It reaches its answer at iteration 4 and then enters a period-2 limit cycle: the step alternates in sign every iteration at a fixed 0.30 px, and the centre oscillates by 0.08 px about a value it knows to 1.75 px, for the 96 iterations left in the budget. CONVERGED_PXL sits below the cycle amplitude, so the run can never meet it; whether a dataset costs 5 s or 55 s is decided by whether its cycle happens to fall under 0.02 px. Travel looks nothing like that - a fit forced to walk 339 px keeps its step direction through all 22 of its travelling iterations and takes steps of 75 px down to 0.1 px - so the sign of the step against the one before it separates the two without any length to compare against. Two changes, measured apart. The walk now also stops when its step reverses the previous one twice running, which is a crossing of the fixed point rather than a step toward it. MAX_ITERATIONS stays at 100, so the long-travel budget is untouched: the forced 339 px walk arrives as before, in 27 iterations instead of 29, 0.013 px away. A monotone fit is bit-identical. The two cycling invocations measured go from 100 iterations to 7 and 9, moving 0.11 and 0.16 px - under 6% of the sigma each reports, and the hundredth iteration was itself an arbitrary point on the cycle. The per-pixel passes - a rotation, a sqrt, two atan2 and two divisions each, over 18 Mpx, three times an iteration - now run on the threads the caller was given, split over a fixed 64 row blocks folded in block order, so the grouping of the sums is a property of the image and not of the machine. Serial against six threads on the same projection in the same process: 4.2-5.3x, with beam_x, beam_y and sigma identical to four decimals on all five fits measured, and a test that asserts one thread and eight give the same answer. Together, measured on the same phase boundary the battery numbers use: 57.7 s to 2.4 s and 54.5 s to 3.0 s on the two worst datasets, 0.23 s to 0.10 s on a small one. The beam-stop mask is untouched - ShadowFinder is not modified and the fit runs after it and writes nothing back - and both masks come out at exactly the pixel counts the unmodified binary logged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
40 lines
2.1 KiB
C++
40 lines
2.1 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#pragma once
|
|
|
|
#include <optional>
|
|
#include <vector>
|
|
|
|
#include "../../common/DiffractionExperiment.h"
|
|
#include "../../common/PixelMask.h"
|
|
|
|
struct BeamCenterEstimate {
|
|
float beam_x_pxl = 0.0f;
|
|
float beam_y_pxl = 0.0f;
|
|
float sigma_pxl = 0.0f; // 1 sigma on the fitted shift, the larger of the two axes
|
|
};
|
|
|
|
// Beam centre from the isotropy of the scattered background, before anything is indexed.
|
|
//
|
|
// The solvent and air scatter is isotropic in 2-theta about the beam, so a centre that is off
|
|
// shifts each azimuthal sector's radial profile by a different amount. Sector k's profile is
|
|
// m_k * g(2theta + d_k), with the shift d_k = Jx_k*dx + Jy_k*dy and m_k an amplitude that
|
|
// absorbs anything multiplicative and azimuthal - a holder arm, a cryostream shadow, a
|
|
// flat-field gradient. Fitting the amplitude alongside the shift is what makes this usable:
|
|
// a 50% shadow over one sextant otherwise reads as several tens of pixels of centre error.
|
|
//
|
|
// The leverage comes from the CURVATURE of the radial profile - the water ring - because for a
|
|
// pure exponential decay g' is proportional to g and shift and amplitude are indistinguishable.
|
|
// The sigma measures that leverage, so it grows as the curvature weakens, and the caller's gate on
|
|
// it is what keeps an ill-determined centre out. It is a precision and not an accuracy: on a
|
|
// background with NO curvature at all there is nothing to separate the two parameters, the fit
|
|
// follows the noise in g' instead, and it does so confidently.
|
|
//
|
|
// `mean` is a per-pixel projection over a few tens of frames, NAN where no frame contributed.
|
|
// nthreads = 0 asks for all hardware threads. The pixels are split into a fixed number of row
|
|
// blocks whatever that count is, so the answer does not depend on it.
|
|
std::optional<BeamCenterEstimate>
|
|
FindBeamCenterFromBackground(const DiffractionExperiment &experiment, const PixelMask &mask,
|
|
const std::vector<float> &mean, size_t nthreads = 0);
|