beam centre: the FFT captures the whole detector, the walk refines what it captured

This commit is contained in:
2026-09-13 07:37:05 +02:00
parent 1d1d7fd686
commit d75102b9b4
3 changed files with 106 additions and 41 deletions
@@ -82,7 +82,8 @@ float median_of(std::vector<float> &v) {
std::optional<BeamCenterEstimate>
FindBeamCenterFromBackground(const DiffractionExperiment &experiment, const PixelMask &mask,
const std::vector<float> &mean, size_t nthreads) {
const std::vector<float> &mean, size_t nthreads,
std::optional<std::pair<float, float>> start) {
if (nthreads == 0)
nthreads = std::max(1u, std::thread::hardware_concurrency());
const auto W = static_cast<int>(experiment.GetXPixelsNumConv());
@@ -106,8 +107,8 @@ FindBeamCenterFromBackground(const DiffractionExperiment &experiment, const Pixe
const float pixel_size = geom.GetPixelSize_mm();
const float distance = geom.GetDetectorDistance_mm();
float beam_x = geom.GetBeamX_pxl();
float beam_y = geom.GetBeamY_pxl();
float beam_x = start ? start->first : geom.GetBeamX_pxl();
float beam_y = start ? start->second : geom.GetBeamY_pxl();
constexpr int n_cells = RADIAL_BINS * SECTORS;
std::vector<int32_t> cell_of(n_pixels);
@@ -359,3 +360,38 @@ FindBeamCenterFromBackground(const DiffractionExperiment &experiment, const Pixe
return BeamCenterEstimate{beam_x, beam_y,
std::max({sigma_x, sigma_y, std::hypot(step_x, step_y)})};
}
std::optional<BeamCenterEstimate>
FindBeamCenter(const DiffractionExperiment &experiment, const PixelMask &mask,
const std::vector<float> &mean, size_t nthreads, BeamCenterFFTResult *capture) {
// The image the capture scores: the projection with everything the mask excludes taken out.
// The beam stop is in that mask by the time this runs, and it is the part that matters - an
// opaque region on one side of the beam is centrosymmetric about ITS own centre, and where it
// is large enough that preference beats the background's.
std::vector<float> scored = mean;
const auto &pixel_mask = mask.GetMask(experiment);
if (pixel_mask.size() == scored.size())
for (size_t i = 0; i < scored.size(); i++)
if (pixel_mask[i] != 0)
scored[i] = NAN;
const BeamCenterFFTResult fft = FindBeamCenterFFT(experiment, scored);
if (capture)
*capture = fft;
if (fft.point.empty())
return FindBeamCenterFromBackground(experiment, mask, mean, nthreads);
const std::pair<float, float> seed{fft.point[0].beam_x_pxl, fft.point[0].beam_y_pxl};
if (auto refined = FindBeamCenterFromBackground(experiment, mask, mean, nthreads, seed))
return refined;
// The walk has nothing to fit AT THE CAPTURE. That is a statement about the capture as much as
// about the background: a centre far enough out leaves too few fully covered rings inside the
// fitted band for the profile to exist at all. Measured on one small-molecule set whose capture
// lands 536 px out - the walk from the file's centre answers there to 2.9 px, and taking the
// capture instead would have been the composition's only regression on the corpus. So ask the
// walk again where it used to be asked, and keep the capture only where neither start works.
if (auto from_file = FindBeamCenterFromBackground(experiment, mask, mean, nthreads))
return from_file;
return BeamCenterEstimate{seed.first, seed.second, BEAM_CENTER_CAPTURE_SIGMA_PXL};
}
@@ -4,8 +4,10 @@
#pragma once
#include <optional>
#include <utility>
#include <vector>
#include "BeamCenterFFT.h"
#include "../../common/DiffractionExperiment.h"
#include "../../common/PixelMask.h"
@@ -34,6 +36,43 @@ struct BeamCenterEstimate {
// `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.
//
// `start` is where the walk begins; the centre in the file when it is not given. The walk advances
// by a bounded distance per iteration, so where it starts decides how much of its budget is spent
// travelling and - on a surface with more than one basin - which fixed point it can reach at all.
std::optional<BeamCenterEstimate>
FindBeamCenterFromBackground(const DiffractionExperiment &experiment, const PixelMask &mask,
const std::vector<float> &mean, size_t nthreads = 0);
const std::vector<float> &mean, size_t nthreads = 0,
std::optional<std::pair<float, float>> start = {});
// The precision of a centre that is the FFT capture alone, with no walk behind it. The capture is
// a half-pixel grid position read off a surface, measured over 75 rotation datasets at a median
// 3.2 px and a 90th percentile of 11 px from the truth, so this is what it knows the centre to -
// a capture precision and not a fit precision. It is deliberately far above the ceiling the
// callers adopt a centre on: a capture is evidence about where the beam is, not a measurement of
// where it is.
constexpr float BEAM_CENTER_CAPTURE_SIGMA_PXL = 5.0f;
// The beam centre from the background, captured globally and then refined locally.
//
// The walk in FindBeamCenterFromBackground is a good local refiner and a poor searcher: it moves
// one or two pixels per iteration, it is seeded at the centre in the file, and on a background
// whose isotropy is broken it has a second basin to fall into. The FFT score is the opposite -
// it evaluates EVERY candidate centre on the detector in one transform set, at a cost that does
// not depend on how wrong the file is, but it returns a half-pixel grid position and no sigma.
// Composing them takes the reach from one and the precision from the other: the capture chooses
// the basin, the walk finishes inside it and reports what it knows the answer to.
//
// The shadow of the beam stop is blanked out of the image the capture scores. A one-sided opaque
// region imposes a centrosymmetry of its own that can beat the background's - measured, an umbra
// over 9.6 % of the detector put the capture 48 px out, and masking it put it back to 1.1 px,
// while a random mask of the same area changed nothing.
//
// Where the walk declines at the capture the walk is asked again from the centre in the file, and
// only where neither start gives it something to fit does the capture stand alone, at
// BEAM_CENTER_CAPTURE_SIGMA_PXL - which is what lets a caller that only needs a hypothesis to test
// still get one. Returns nothing only where the capture has no candidate either.
std::optional<BeamCenterEstimate>
FindBeamCenter(const DiffractionExperiment &experiment, const PixelMask &mask,
const std::vector<float> &mean, size_t nthreads = 0,
BeamCenterFFTResult *capture = nullptr);
+27 -37
View File
@@ -1215,13 +1215,33 @@ void Rugnux::PreScan(int start_image, int images_to_process, int frame_count, Ru
// second pass of a two-pass run the shadow is already in the mask, so this does not run again -
// by then the centre has been post-refined and the file's value is no longer what is in it.
if (want_shadow && config_.beam_center_check) {
background_center_ = FindBeamCenterFromBackground(experiment_, pixel_mask_,
finder.GetMeanProjection(), config_.nthreads);
const auto t0 = std::chrono::steady_clock::now();
BeamCenterFFTResult capture;
background_center_ = FindBeamCenter(experiment_, pixel_mask_, finder.GetMeanProjection(),
config_.nthreads, &capture);
measured_beam_center_ = background_center_;
const auto capture_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - t0).count();
if (!capture.point.empty())
logger.Info("Beam centre capture: the strongest point-symmetry centre on the WHOLE "
"detector is ({:.1f},{:.1f}), {:.1f} px from the file's; line mirrors put x "
"at {:.1f} and y at {:.1f}; margins {:.1f}%/{:.1f}%/{:.1f}% (point/x/y, "
"under ~3% the surface is flat). One transform set scores every candidate "
"centre, so this costs the same however wrong the file is - {} ms including "
"the walk that refines it",
capture.point[0].beam_x_pxl, capture.point[0].beam_y_pxl,
std::hypot(capture.point[0].beam_x_pxl - experiment_.GetBeamX_pxl(),
capture.point[0].beam_y_pxl - experiment_.GetBeamY_pxl()),
capture.line_x.empty() ? std::numeric_limits<float>::quiet_NaN()
: capture.line_x[0].beam_x_pxl,
capture.line_y.empty() ? std::numeric_limits<float>::quiet_NaN()
: capture.line_y[0].beam_y_pxl,
100.0f * capture.margin_point, 100.0f * capture.margin_line_x,
100.0f * capture.margin_line_y, capture_ms);
const float need = BeamCenterNeed_pxl(experiment_);
if (!background_center_) {
logger.Info("Beam centre check: the background is too flat to place the centre on this "
"run - the file's ({:.2f},{:.2f}) is neither confirmed nor contradicted",
logger.Info("Beam centre check: neither the capture nor the walk places the centre on "
"this run - the file's ({:.2f},{:.2f}) is neither confirmed nor contradicted",
experiment_.GetBeamX_pxl(), experiment_.GetBeamY_pxl());
} else {
const float dx = background_center_->beam_x_pxl - experiment_.GetBeamX_pxl();
@@ -1233,7 +1253,7 @@ void Rugnux::PreScan(int start_image, int images_to_process, int frame_count, Ru
// and what this particular geometry asks of the centre. The last is what turns a bare pixel
// count into a verdict - 3 px is nothing at 100 mm and a lost lattice at 500 mm.
logger.Info("Beam centre check: the file says ({:.2f},{:.2f}), the scattered background "
"says ({:.2f},{:.2f}) +- {:.2f} px - a difference of {:.2f} px, against the "
"puts it at ({:.2f},{:.2f}) +- {:.2f} px - a difference of {:.2f} px, against the "
"{:.2f} px this geometry asks the centre to be right to. Measured only; the "
"run keeps the centre in the file",
experiment_.GetBeamX_pxl(), experiment_.GetBeamY_pxl(),
@@ -1263,36 +1283,6 @@ void Rugnux::PreScan(int start_image, int images_to_process, int frame_count, Ru
across ? "it is mostly along the spindle, where nothing announces itself"
: "this is not a rotation sweep, so there is no spindle to resolve it on");
}
// The same projection scored globally: one FFT set evaluates the centrosymmetry of the
// background about EVERY candidate centre on the detector, so its cost is the same whether
// the header is right, 2 px out or 351 px out - where the walk above the first pass would
// otherwise pay per pixel of error. Measured on every run and committed on none: a
// shortlist and a margin, never a centre (see BeamCenterFFT.h - the surface can be locally
// flat, and a margin under a few percent means the argmax is a coin toss among the listed
// candidates, not a measurement).
const auto t0 = std::chrono::steady_clock::now();
const auto fft = FindBeamCenterFFT(experiment_, finder.GetMeanProjection());
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - t0)
.count();
if (!fft.point.empty()) {
const float fx = fft.point[0].beam_x_pxl, fy = fft.point[0].beam_y_pxl;
const float from_header = std::hypot(fx - experiment_.GetBeamX_pxl(),
fy - experiment_.GetBeamY_pxl());
logger.Info("Beam centre FFT: strongest point-symmetry centre ({:.1f},{:.1f}), {:.1f} px "
"from the file's; line mirrors put x at {:.1f} and y at {:.1f}; margins "
"{:.1f}%/{:.1f}%/{:.1f}% (point/x/y, under ~3% the surface is flat and the "
"shortlist is a set of hypotheses, not an answer). Whole detector in {} ms; "
"measured only, nothing consumes it",
fx, fy, from_header,
fft.line_x.empty() ? std::numeric_limits<float>::quiet_NaN()
: fft.line_x[0].beam_x_pxl,
fft.line_y.empty() ? std::numeric_limits<float>::quiet_NaN()
: fft.line_y[0].beam_y_pxl,
100.0f * fft.margin_point, 100.0f * fft.margin_line_x,
100.0f * fft.margin_line_y, ms);
}
}
if (!want_beam_center)
@@ -1415,8 +1405,8 @@ void Rugnux::PreScan(int start_image, int images_to_process, int frame_count, Ru
// same geometry, same projection - so this asks for it again only where it was not run.
estimate = background_center_
? background_center_
: FindBeamCenterFromBackground(experiment_, pixel_mask_,
finder.GetMeanProjection(), config_.nthreads);
: FindBeamCenter(experiment_, pixel_mask_, finder.GetMeanProjection(),
config_.nthreads);
}
if (!estimate) {
logger.Info("Beam centre: not measurable, keeping ({:.2f},{:.2f})",