Files
Jungfraujoch/image_analysis/geom_refinement/RingsFromProfile.cpp
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

155 lines
7.7 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <algorithm>
#include <cmath>
#include "RingsFromProfile.h"
#include "AssignSpotsToRings.h" // RingMatchWindow
#include "PowderAutoSeed.h" // ProfileRingTrack
#include "../../common/JFJochMath.h"
float SectorPeakQ(const std::vector<float> &profile, int32_t q_bins, int phi_bin,
int lo_bin, int hi_bin, float low_q, float q_spacing, float min_peak_over_noise) {
const size_t row = static_cast<size_t>(phi_bin) * static_cast<size_t>(q_bins);
const auto value = [&](int i) { return profile[row + static_cast<size_t>(i)]; };
const auto q_of = [&](int i) { return low_q + (static_cast<float>(i) + 0.5f) * q_spacing; };
// A bin no pixel fell in is NaN, not zero (AzimuthalIntegrationProfile::GetResult), and the four
// background bins are where a module gap or the beam stop shows up first. Say so rather than
// relying on NaN comparisons to fail the peak test further down: a sector whose background cannot
// be measured has no measurable peak either.
for (int i : {lo_bin, lo_bin + 1, hi_bin - 1, hi_bin}) {
if (!std::isfinite(value(i)))
return NAN;
}
const float bkg_lo = 0.5f * (value(lo_bin) + value(lo_bin + 1));
const float bkg_hi = 0.5f * (value(hi_bin) + value(hi_bin - 1));
const auto bkg_at = [&](int i) {
const float t = static_cast<float>(i - lo_bin) / static_cast<float>(hi_bin - lo_bin);
return bkg_lo + t * (bkg_hi - bkg_lo);
};
int peak = -1;
float peak_height = 0.0f;
for (int i = lo_bin + 2; i <= hi_bin - 2; ++i) {
const float h = value(i) - bkg_at(i);
if (h > peak_height) { peak_height = h; peak = i; }
}
if (peak < 0)
return NAN;
// Scatter of the background shoulders, as the noise this peak has to stand clear of. A sector with
// no ring in it has a "peak" that is just the largest background fluctuation, and this is what
// rejects it - the alternative, an absolute intensity cut, would need a value per detector and beam.
float s = 0.0f;
int n = 0;
for (int i : {lo_bin, lo_bin + 1, hi_bin - 1, hi_bin}) {
const float r = value(i) - bkg_at(i);
s += r * r;
++n;
}
const float noise = std::sqrt(s / static_cast<float>(n));
if (!(peak_height > min_peak_over_noise * noise))
return NAN;
const float half = 0.5f * peak_height;
double sum_wq = 0.0, sum_w = 0.0;
for (int i = peak; i >= lo_bin && value(i) - bkg_at(i) >= half; --i) {
const double w = value(i) - bkg_at(i);
sum_wq += w * q_of(i);
sum_w += w;
}
for (int i = peak + 1; i <= hi_bin && value(i) - bkg_at(i) >= half; ++i) {
const double w = value(i) - bkg_at(i);
sum_wq += w * q_of(i);
sum_w += w;
}
if (!(sum_w > 0.0))
return NAN;
return static_cast<float>(sum_wq / sum_w);
}
std::vector<RingOptimizerInput> RingsFromAzimuthalProfile(const std::vector<float> &profile,
const AzimuthalIntegrationMapping &mapping,
const DiffractionGeometry &geom,
const std::vector<float> &calibrant_ring_q,
float q_window_recipA,
float min_peak_over_noise,
const DiffractionGeometry *seeded) {
std::vector<RingOptimizerInput> out;
const int32_t q_bins = mapping.GetQBinCount();
const int32_t azim_bins = mapping.GetAzimuthalBinCount();
// One azimuthal bin is a plain radial profile: the ring is averaged over every direction at once, so
// nothing remains to say where its centre is. This needs the run to have been integrated with
// azimuthal bins (jfjoch_broker azim_int_settings.azimuthal_bins, rugnux --azim-phi-bins).
if (azim_bins < 4 || q_bins < 8
|| profile.size() != static_cast<size_t>(q_bins) * static_cast<size_t>(azim_bins))
return out;
const auto &settings = mapping.Settings();
const float low_q = settings.GetLowQ_recipA();
const float q_spacing = settings.GetQSpacing_recipA();
const float high_q = low_q + static_cast<float>(q_bins) * q_spacing;
// Where to LOOK, ring by ring and sector by sector. Without a seed a ring is looked for at its own
// q in every sector, which is the right answer only when the geometry that binned the profile was
// already close; with one, each ring is tracked through the profile it really made.
const size_t rings = calibrant_ring_q.size();
std::vector<std::vector<float>> track(rings);
for (size_t i = 0; i < rings; ++i) {
if (seeded)
track[i] = ProfileRingTrack(calibrant_ring_q[i], *seeded, geom, azim_bins);
else
track[i].assign(azim_bins, calibrant_ring_q[i]);
}
for (size_t i = 0; i < rings; ++i) {
for (int phi_bin = 0; phi_bin < azim_bins; ++phi_bin) {
const float q_ring = track[i][phi_bin];
if (!std::isfinite(q_ring))
continue;
// Never let the window reach into the neighbouring ring. SectorPeakQ takes the background
// under the peak from the two bins at each end of the window, so a window wider than half
// the gap to the next ring measures that ring's flank as this one's background. Hexagonal
// ice has three rings within 0.06 1/A of one another, which a fixed window merges into one
// peak. Measured against the neighbours IN THIS SECTOR, since that is where they are here.
float window = q_window_recipA;
if (i > 0 && std::isfinite(track[i - 1][phi_bin]))
window = std::min(window, 0.5f * std::abs(q_ring - track[i - 1][phi_bin]));
if (i + 1 < rings && std::isfinite(track[i + 1][phi_bin]))
window = std::min(window, 0.5f * std::abs(track[i + 1][phi_bin] - q_ring));
if (!(q_ring - window > low_q) || !(q_ring + window < high_q))
continue;
const int window_bins = static_cast<int>(std::lround(window / q_spacing));
const int centre_bin = static_cast<int>((q_ring - low_q) / q_spacing);
const int lo_bin = std::max(0, centre_bin - window_bins);
const int hi_bin = std::min(q_bins - 1, centre_bin + window_bins);
// Two background bins at each end and a peak between them is the least this can work with;
// a ring whose window is narrower than that is not resolved at this q spacing.
if (hi_bin - lo_bin < 6)
continue;
const float q_obs = SectorPeakQ(profile, q_bins, phi_bin, lo_bin, hi_bin,
low_q, q_spacing, min_peak_over_noise);
if (!std::isfinite(q_obs))
continue;
// The sector's CENTRE, not its lower edge: GetBin() floors phi into the sector, so a bin
// stands for [j, j+1) and taking its edge would rotate every ring point by half a sector -
// which is exactly the cos(phi) signal the beam centre is read from.
const float phi_rad = static_cast<float>((static_cast<double>(phi_bin) + 0.5)
* 2.0 * PI / static_cast<double>(azim_bins));
const auto [x, y] = geom.ResPhiToPxl(static_cast<float>(2.0 * PI) / q_obs, phi_rad);
if (!std::isfinite(x) || !std::isfinite(y))
continue;
out.push_back({x, y, calibrant_ring_q[i]});
}
}
return out;
}