Files
Jungfraujoch/image_analysis/geom_refinement/PowderAutoSeed.h
T
leonarski_fandClaude Opus 5 a5f416fcdc calibration: take the detector distance from the rings, not from the header
A powder calibration is run because nobody is sure the header is right, and the
header's distance was the one number the fit could not survive being wrong
about. The ring search is local - each ring is looked for inside a window a few
pixels of radius wide - so a distance more than a percent or two out puts every
ring outside its own window, and the fit then converges on whatever background
fluctuation each window contains. It does not fail: a 110 mm exposure told the
detector was at 150 mm reported 149.8 mm, with 146 ring points and exit 0. Only
its residual said anything, 5.3 px against 0.4 px, and nothing read it.

Measure the distance from the rings instead. The peaks of the azimuthally
averaged profile give ring RADII, and a radius does not depend on the assumed
distance at all - bin i holds the pixels at one particular radius whatever q
that radius was called - so the radii are a property of the image. Against the
calibrant's d-spacings, r = D tan(2 asin(lambda/2d)) then has one unknown. It is
scanned rather than solved because the pairing of observed rings to d-spacings
is unknown too, and the winning basin is solved in closed form. Nothing here
reads the header distance except to bin the profile; it needs only the
wavelength, the pixel size and the detector's extent.

A powder pattern has genuine distance aliases, so one answer is not enough. A
cubic primitive standard puts its rings at radii proportional to sqrt(N), and
scaling the distance by sqrt(2) maps ring N onto ring 2N - most of the comb
still lands on peaks. Measured: the 110 mm exposure with a 115 mm header scored
its best at 156.5 mm, which is 110*sqrt(2). No adjustment of the score removes an
alias the lattice really has, so the scan hands back the few best distances and
each is fitted, the header among them as one hypothesis of several. The residual
then separates them - 0.4 px against 5.2 px on that case - subject to an attempt
explaining a comparable share of the pattern first, because a start so wrong
that one ring point survives leaves a residual of exactly zero.

Each attempt re-extracts at the geometry it converged to and fits again. The
seed is measured from blended peaks and is good to about a per cent, close
enough to converge from but far enough to sit every search window a few pixels
off its ring, and an off-centre window takes its background off the ring's own
flank. Nothing is re-read from disk, so the loop is free.

Measured on the LaB6 distance series. A 110 mm dataset now recovers 110.03-110.17
mm from any header between 25 and 1200 mm, against +-2 mm before. All five
datasets recover their own distance from a fixed wrong 250 mm header. With
correct headers, four of the five are bit-identical to before and the 500 mm one
moves by a single ring point - the two-ring fit whose tilt is 0.1 sigma anyway.
Run time is unchanged at 0.62 s.

The residual is larger on a run whose header was wrong (1.1 px against 0.4 px on
the 110 mm case), because the profile was still binned at the wrong distance and
its radial sampling is correspondingly coarse. The geometry is right; only the
scatter about it is inflated. Re-running with the recovered distance recovers
the residual too.

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

85 lines
5.6 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.
std::vector<float> 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 that was binned at d_binned, if the detector is
// really at d_true.
//
// The profile cannot be re-binned without re-reading every image, so a corrected distance does not move
// the rings within it - it moves where they have to be looked for. The ring point recovered from that
// peak is still a real detector pixel, and labelling it with the calibrant's true q is what makes the
// fit exact rather than approximate: the search list only has to find the peak, the fit only uses the
// pixel and the label.
float ProfileQForRing(float q_cal, float d_true_mm, float d_binned_mm,
float wavelength_A, float pixel_mm);
// 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);