Files
Jungfraujoch/image_analysis/geom_refinement/PowderCalibration.h
T
leonarski_fandClaude Opus 5 49dd01331b calibration: let the spots vote on the geometry in ring mode too
The rings and the spots fail in different regimes, which is the whole reason to
carry both. The header is right on a well-configured instrument and is the thing
a calibration is run to check. The profile's own estimates are exact while the
error stays small and stop meaning anything beyond that - past about half a ring
spacing each ring reaches the azimuthally averaged profile as two horns rather
than one peak, and the distance search reads a list of horns as a list of rings.
The circle through the spots reads nothing from the header at all: measured on a
110 mm LaB6 exposure, --calibration spots returns the same geometry from a header
400 px out in the centre AND eight times out in distance.

So ring mode now finds spots as well - half a second - and offers what they make
of the geometry as one more starting hypothesis, fitted like the others with the
residual left to choose. It is added whole rather than as a centre alone:
GuessGeometry votes for the circle centre, clusters the radii into rings and
takes the distance from the innermost, and those two belong together. Taking only
its centre would not have helped, because the distance candidates come from a
profile averaged about the header's centre, and a ring smeared over hundreds of
pixels cannot be un-smeared by reading its bins differently.

Measured on the 110 mm exposure, whose true PONI is 765.90 at 110.03 mm. A header
40 px out in the centre now lands within 0.6 px (it landed 41 px away before).
The cases with BOTH wrong, which failed before this and equally before the beam
centre work, now come out: 250 mm / 780 px gives 110.041 mm and 766.10 px against
42.3 mm and 782.7; 250 mm / 867 px gives 110.065 and 765.48. All five datasets
are unchanged from their correct headers and the distance still recovers from any
header between 25 and 1200 mm.

Past about a hundred pixels nothing rescues ring mode, and the reason is the
profile rather than the seeding: binned about a centre that far out it shows each
ring smeared across its own sectors, so even started from the exact answer the
fit leaves rms 4.8 px and drifts. Re-binning would fix it and would need the
images read a second time; --calibration spots, which never touches the profile,
already covers it.

That regime is now visible rather than silent. The spots' beam centre is printed
beside the fitted one as a cross-check - two methods sharing no assumption, so a
reader can see at a glance whether they agree. It costs nothing, the spots having
been found already, and it separates cleanly: 0.3-0.4 px on the good runs against
201.4 px on the exposure whose profile could not be fitted at all. Reported as a
fact and not gated on, since at long distance both methods weaken together and
the honest thing is to show the number (5.2 px at 300 mm, 12.6 at 500).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfuDvf5ipV3Hi8TiCUKD27
2026-08-31 17:58:26 +02:00

109 lines
6.9 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <string>
#include <vector>
#include "../../common/AzimuthalIntegrationMapping.h"
#include "../../common/DiffractionExperiment.h"
#include "../../common/DiffractionGeometry.h"
#include "../../common/SpotToSave.h"
#include "PowderAutoSeed.h"
#include "RingOptimizer.h" // RingFitUncertainty
// How the powder rings the detector geometry is fitted to are measured (rugnux --calibration).
enum class CalibrationMethod {
Rings, // the run-summed (q x azimuth) azimuthal profile: the ring measured at every azimuth
Spots // the pooled per-image spot lists: the ring sampled wherever the spot finder bit
};
struct CalibrationResult {
DiffractionGeometry geometry; // the fitted geometry
size_t ring_points = 0; // ring measurements the fit used
// Scatter of those measurements about the fitted rings, and the standard error it implies on the
// beam centre. Both in pixels - a calibration that has gone wrong (textured ice, one visible ring)
// says so here, and that is the only warning a user gets.
double rms_radial_pxl = 0.0;
double beam_sigma_pxl = 0.0;
// What the fit itself says about how well each parameter is determined, and how badly the tilt is
// correlated with the beam centre. rms/beam_sigma above describe the SCATTER of the measurements;
// this describes the FIT, and the two part company exactly where it matters - a two-ring tilt can
// leave a small rms while being free to move tens of pixels of beam centre with it.
RingFitUncertainty uncertainty;
// The distance the rings themselves asked for before the fit ran, and what the header said. They
// are reported rather than only used because a large gap between them is the one thing that says
// the header was wrong - which is usually why the calibration was run at all. Zero when the profile
// showed too few rings to measure a scale.
float seed_distance_mm = 0.0f;
float header_distance_mm = 0.0f;
// Whether the tilt survived. It is refined by default, but a fit that cannot separate it from the
// beam centre will still return one - spending tens of pixels of PONI on a tilt the rings do not
// support - so a tilt smaller than a few times its own uncertainty is declined and the fit repeated
// with rot1/rot2 pinned. significance is max(|rot| / sigma(rot)) over the two components, 0 where
// the tilt was never a free parameter.
bool tilt_refined = false;
float tilt_significance = 0.0f;
// How far the beam centre the SPOTS vote for is from the one the rings were fitted to, in pixels,
// and where the spots put it. An independent cross-check that costs nothing, because the spots have
// already been found: the circle through them reads nothing from the header, so it holds in exactly
// the regime the profile does not - a profile binned about a badly wrong centre shows each ring
// smeared across its sectors, and no seeding recovers that. A large disagreement is therefore not a
// close call to arbitrate but a statement that this profile could not have been fitted, whatever
// the residual says. Zero when no spots were available.
float spots_beam_x_pxl = 0.0f;
float spots_beam_y_pxl = 0.0f;
float spots_disagreement_pxl = 0.0f;
bool spots_available = false;
};
// How many of its own sigmas the fitted tilt is away from zero - the number the gate above reads.
// Zero when the tilt was held fixed, or when the covariance could not be computed at all (which is
// itself a statement that the tilt is not separable).
float TiltSignificance(const DiffractionGeometry &geom, const RingFitUncertainty &unc);
// Below this the tilt is not a measurement. A tilt the rings genuinely resolve clears it by orders of
// magnitude - measured over a LaB6 distance series, 50, 33, 15 and 8 sigma at 110 to 300 mm - while the
// two-ring fit at 500 mm, whose tilt flips sign and drags the PONI 28 px for no gain in residual,
// reaches 0.1. Any value between 2 and 5 gives the same verdict on all five, so this is a statement
// about which regime a fit is in and not a tuned number.
//
// It is a REJECTION criterion only. Passing it does not certify a tilt: the tilt estimator is
// systematics-limited rather than variance-limited, and a coherent half-pixel error in the ring
// positions fakes a tilt of the size usually seen while leaving sigma small.
constexpr float TILT_MIN_SIGNIFICANCE = 3.0f;
// Both fits take the detector tilt as a free parameter unless refine_tilt is false, which holds
// rot1/rot2 at the value `geom` came in with and fits only the beam centre and the distance. The
// tilt is real and a PONI carries it, but a program that has nowhere to put it - XDS - is better
// given a geometry measured with the tilt pinned than one measured tilted and then flattened.
// Fit the geometry to the rings found in a run-summed azimuthal profile (CalibrationMethod::Rings).
//
// spots, where they are given, add one more beam-centre hypothesis: the centre of the circle through
// them, voted for by every triple (FindCircleCenter). It reads nothing from the header at all - only
// where the spots landed - so it is the one estimate that survives a header the profile itself cannot
// correct from. The rings' own estimate stops working once the beam centre is out by more than about
// half a ring spacing, because past that the azimuthally averaged profile shows each ring as the two
// horns of a sinusoid rather than as a ring; the circle through the spots has no such limit. Measured
// on a 110 mm LaB6 exposure it returns the same geometry from a header 400 px and 8x in distance wrong.
CalibrationResult CalibrateFromProfile(const std::vector<float> &profile,
const AzimuthalIntegrationMapping &mapping,
const DiffractionGeometry &geom,
const std::vector<float> &calibrant_ring_q,
bool refine_tilt = true,
const std::vector<SpotToSave> &spots = {});
// Fit the geometry to a pooled spot list (CalibrationMethod::Spots): the beam centre from scratch off
// the Hough circle centre, then the same ring fit.
CalibrationResult CalibrateFromSpots(const std::vector<SpotToSave> &spots,
const DiffractionGeometry &geom,
const std::vector<float> &calibrant_ring_q,
bool refine_tilt = true);
// Write the geometry as a pyFAI PONI file, the interchange format every azimuthal-integration tool
// reads. Throws if the file cannot be written.
void WritePoniFile(const std::string &path, const DiffractionExperiment &experiment,
const DiffractionGeometry &geom);