Files
Jungfraujoch/image_analysis/geom_refinement/PowderAutoSeed.h
T
leonarski_fandClaude Opus 5 c84b91be8a calibration: take the beam centre from the rings too
The header's beam centre was the last input the ring fit had to be roughly
right about. Each ring is looked for in a window a few pixels of radius wide,
and a centre wrong by (dx, dy) puts a ring at a different q in every sector, so
past about ten pixels the ring leaves that window over much of the turn - and
the fit then reads its cos(phi) signal off whichever sectors are left, which are
the ones where the signal is weakest. A 20 px error ended 31 px wrong.

The rings answer this without a calibrant and without a distance. A powder ring
is a conic centred on the beam, so a wrong centre makes EVERY ring's radius
oscillate once per turn by the same amount: r(phi) = R + dx cos(phi) + dy
sin(phi), solved directly and pooled over every ring the profile shows, with
each ring searched about its own measured radius rather than about where a
standard says it should be.

Using it needs the extraction to follow the rings sector by sector, which is
what ProfileRingTrack now does - exactly, and in all five parameters at once,
by walking the ring in the geometry believed true and asking the binned geometry
what q and azimuth it would have given each point. That replaces the
flat-detector distance correction it grew out of.

Following the rings is not free, and the reason is worth stating: a window that
moves with phi makes every systematic of the peak finder - where the background
line is taken, how the centroid sits in the window - vary with phi as well, and
phi is exactly the axis the beam centre is read off. Measured, it costs rms
0.415 -> 0.525 px on a good 110 mm fit, and 0.831 when the window follows the
fitted tilt too. So a second measurement is taken with a window that is the same
in every sector - the binned geometry with only its DISTANCE replaced, which is
phi-independent by construction - and both are offered to the same rule that
ranks everything else here. Acquire by following, measure by holding still.

The seeded centre is likewise a hypothesis and not a belief. It reads a
once-per-turn wobble, and a tilt puts a term of that shape there too - one that
grows as the radius squared, where a centre error does not - so pooling the
rings absorbs part of the tilt into the centre. Believed outright it made a good
110 mm fit worse; offered as an alternative start it costs one more fit and
needs no rule about when it applies. It is skipped entirely below a pixel, where
it is not a different hypothesis at all, which keeps a well-headed run at 0.71 s.

Measured on the 110 mm LaB6 exposure, whose true PONI is 765.90: a header centre
20 px out now lands within 0.5 px, where before it landed 31 px away. All five
datasets are unchanged from their correct headers, and the distance still
recovers from any header between 25 and 1200 mm.

The limit is now understood rather than merely reached. Past a few pixels the
azimuthally averaged profile stops showing rings: a ring tracing r(phi) piles up
density where that turns round, so it averages into the two HORNS of the
sinusoid, at R-|d| and R+|d|. The radius finder reports two rings where there is
one, and the gap between them is 2|d| - the search window shrinks to exactly the
offset it was meant to span. That caps recovery at roughly half the ring
spacing, about 20 px here and failing by 40. Beyond it nothing is left in an
azimuthally binned profile, and --calibration spots, which works from the spot
positions themselves, is the method that still can.

One pre-existing limit measured and NOT introduced here: a wrong distance
together with a centre more than about 5 px out fails, because the centre error
splits the radius list the distance search reads. The committed code before this
change fails identically on those cases.

Also fixed: fit_from now takes a whole geometry rather than a distance, and the
declined-tilt refit was inheriting rot1/rot2 from it - pinning the tilt at
exactly the unvalidated value the gate had just rejected. Same fault the gate
exists to catch, one level up.

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

131 lines
8.8 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/AzimuthalIntegrationMapping.h"
#include "../../common/DiffractionGeometry.h"
// Where a powder calibration should START from, measured from the rings themselves.
//
// The ring fit is a local refinement: RingsFromAzimuthalProfile looks for each ring inside a fixed
// window in q, which is only a handful of pixels of radius, and RingOptimizer then moves the geometry
// the small distance that closes the residual. Give it a starting geometry outside that window and it
// does not fail - it finds the largest background fluctuation inside each window instead, fits those,
// and returns a confident wrong answer. Measured on a 110 mm LaB6 exposure: told the detector was at
// 150 mm it reports 149.8 mm, with 146 ring points and exit 0. The only thing that separates such a run
// from a real one is its residual, roughly 3-6 px against 0.4 px.
//
// So the starting geometry cannot be taken on trust, and the header is the least trustworthy part of it
// - a calibration is run precisely because nobody is sure the header is right. What CAN be trusted is
// the wavelength, the pixel size and the detector's extent; everything below is built from those and
// from the calibrant's d-spacings, and nothing below reads the header's distance.
// Ring positions as this image actually shows them: the peaks of the azimuthally-averaged profile,
// returned as radii in pixels about the geometry's current beam centre, strongest first.
//
// No calibrant enters here. The q axis of the profile is a monotone function of pixel radius under
// whatever geometry built the mapping, so inverting it recovers where each ring sits on the detector
// whatever distance was assumed - the radii are a property of the image, not of the geometry. Radii are
// averaged over four azimuths, which cancels the first-order cos(phi) term a wrong beam centre adds.
struct ObservedRingRadius {
float radius_pxl;
float height; // peak height over the local background, as a weight
};
std::vector<ObservedRingRadius> RingRadiiFromProfile(const std::vector<float> &profile,
const AzimuthalIntegrationMapping &mapping,
const DiffractionGeometry &geom,
float min_peak_over_noise = 4.0f);
// The detector distances that put the calibrant's rings on the radii above - PLURAL, and that is the
// point.
//
// r = D tan(2 asin(lambda / 2d)) has one unknown once the radii are measured, but the PAIRING of
// observed rings to d-spacings is unknown too, so D is scanned rather than solved: every candidate
// distance implies a complete predicted comb, and a good one is where the whole comb lands on observed
// peaks at once. Scoring is symmetric - it rewards observed peaks that are explained AND predicted rings
// that are seen - because rewarding only the first would pick an absurdly short distance, where the
// predicted rings are so crowded that every peak has one nearby.
//
// A single best score is not safe, because a powder pattern has genuine aliases. A cubic primitive
// standard puts its rings at radii proportional to sqrt(N); scaling the distance by sqrt(2) therefore
// maps ring N onto ring 2N, and since most integers that are allowed have an allowed double, most of the
// comb still lands on peaks. Measured on LaB6: a 110 mm exposure whose header said 115 mm scored its
// best at 156.5 mm, which is 110 x sqrt(2). No amount of adjusting the score removes an alias that the
// lattice really has.
//
// So the scan hands back the few best distances that are not near-neighbours of one another, and the
// caller fits each and keeps whichever leaves the smaller residual - which separates them decisively
// (0.4 px against 5.2 px on that case) because only the true distance makes every ring fit at once.
// Ordered best score first. Empty when the profile shows fewer than two rings, which cannot fix a scale.
//
// The score comes back with each distance so the caller can see how the candidates ranked. It is the
// fraction of the pattern explained times the fraction of the predicted comb seen, and it is only
// meaningful RELATIVE to other candidates of the same calibrant: its second factor falls with the
// length of the ring list, so LaB6's 83 rings score 0.29 on a perfectly good 110 mm fit while a wrongly
// named silicon scores 0.21 on the same data. Measured, and the reason there is no quality gate on it -
// it ranks distances, it does not judge standards.
struct DistanceCandidate {
float distance_mm;
double score; // 0 to 1; see above
};
std::vector<DistanceCandidate> CandidateDistancesFromPowderRings(
const std::vector<ObservedRingRadius> &observed,
const std::vector<float> &calibrant_ring_q,
const DiffractionGeometry &geom,
float radius_min_pxl, float radius_max_pxl,
size_t max_candidates = 3);
// Where calibrant ring q_cal APPEARS in a profile binned with `binned`, sector by sector, if the true
// geometry is `truth`. One entry per azimuthal sector, NaN where the ring misses that sector.
//
// The profile cannot be re-binned without re-reading every image, so a corrected geometry does not move
// the rings within it - it moves where they have to be looked for. Per SECTOR, not one q for the whole
// ring, and that is what a beam-centre correction needs: a centre wrong by (dx, dy) makes a ring's
// apparent radius oscillate as dx cos(phi) + dy sin(phi), so the ring is at a different q in every
// sector and a single search window centred on one q finds it only where the oscillation happens to be
// small. That is what limited the beam centre the fit could recover from to about ten pixels.
//
// Exact, and exact in all five parameters at once: the ring is walked in `truth`, each point turned
// into a detector pixel, and that pixel asked what q and what azimuth `binned` would have given it. No
// flat-detector approximation, so a tilt is carried too.
//
// The points the caller then recovers from those peaks are real detector pixels and are labelled with
// the calibrant's true q - the track only has to find the peak, the fit only uses the pixel and label.
std::vector<float> ProfileRingTrack(float q_cal, const DiffractionGeometry &truth,
const DiffractionGeometry &binned, int32_t azim_bins);
// The beam-centre offset the rings themselves ask for, in pixels, to be ADDED to the geometry the
// profile was binned with. No calibrant and no distance enter: a powder ring is a conic centred on the
// beam, so a centre wrong by (dx, dy) makes the apparent radius of EVERY ring oscillate once per turn
// with the same amplitude - r(phi) = R + dx cos(phi) + dy sin(phi) - and that is solved for directly,
// pooled over every ring the profile shows. Each ring is searched about its OWN measured radius rather
// than about where a standard says it should be, which is what makes this work when the header centre
// is far enough out that the calibrated extraction would find nothing.
//
// The offset it can recover is bounded, and by the measurement rather than by a choice. Each ring is
// searched in a window reaching half way to its neighbour in the AZIMUTHALLY AVERAGED profile, and once
// the offset grows past a few pixels that profile stops showing rings: a ring whose radius traces
// R + dx cos(phi) + dy sin(phi) piles up density where r(phi) turns round, so it averages into the two
// HORNS of that sinusoid, at R-|d| and R+|d|. The radius finder then reports two rings where there is
// one, and the gap it measures between them is 2|d| - which is to say the window shrinks to exactly the
// offset it was meant to span. Measured on a 110 mm LaB6 exposure the whole calibration recovers a
// header centre about 20 px out and fails by 40; the limit is roughly half the spacing of the rings.
// Beyond it there is nothing left in an azimuthally binned profile to work from, and --calibration
// spots, which finds the centre from the spot positions themselves, is the method that still can.
//
// Returns nothing when no ring is sampled well enough round the turn to separate the two components.
std::optional<std::pair<float, float>> BeamCentreOffsetFromProfile(
const std::vector<float> &profile,
const AzimuthalIntegrationMapping &mapping,
const DiffractionGeometry &geom,
const std::vector<ObservedRingRadius> &observed);
// The two radii the detector spans, under the geometry that built the mapping - the bounds the scan
// above needs to know which predicted rings would have been visible at all.
std::pair<float, float> ProfileRadiusRange_pxl(const AzimuthalIntegrationMapping &mapping,
const DiffractionGeometry &geom);