Files
Jungfraujoch/rugnux/RugnuxCalibration.cpp
T
leonarski_fandClaude Opus 5 a8d289e7cf Powder calibration: write Poni1/Poni2 in pyFAI's frame, not ours
The same frame mismatch as the rot2/rot3 fix, in the other two fields. Our pixel
coordinates are pixel-centred - 948.0 is the CENTRE of pixel 948 - while pyFAI
measures from the edge of the sensor and puts the centre of pixel i at (i + 0.5) *
pixel size. Poni1/Poni2 went out as beam * pixel size, so anything reading the
file placed the pattern half a pixel (37.5 um at 75 um pixels) off ours. The
previous commit's "Poni1/Poni2 need no such change" was right about the axis
directions and wrong about the origin.

The proof was already in the tree. The pyFAI reference values in
DiffractionGeometryTest were computed for a .poni with Poni2: 0.150 and a 75 um
pixel, which the tests translate to beam_x = 2000 - but pyFAI's numbers are
reproduced only at 1999.5. At 2000 every one of them is out by 2.6e-3 nm^-1, which
the 1e-2 tolerance hid. The tests now use the beam centre those headers actually
mean, and agree with pyFAI to 1e-6 - float precision - across untilted q, azimuth,
rot1, rot1+rot2, rot3, rot1+rot2+rot3 and the solid-angle correction. Tolerances
drop to 1e-4 (1e-5 for solid angle): ~100x the observed float noise, and 26x
tighter than the half pixel they were blind to.

The viewer's calibration window printed "PONI x = ... mm" from the un-offset value
beside the path of the file it disagreed with; it now matches the file.

Also moves the viewer's beam-centre cross half a pixel down and right, where the
spot, prediction, top-pixel and saturation markers already are. Our coordinates
are pixel-centred and the Qt scene's are pixel-cornered, so the map between them
is +0.5, and DrawBeamCenter was the one overlay missing it.

The convention itself is now written down in docs/DETECTOR_GEOMETRY.md, with the
conversions to XDS ORGX/ORGY and to the edge-of-sensor programs, this being the
second bug to come out of it.

Only exported and displayed values change; the fitted geometry, spot positions and
integration were always self-consistent. A .poni written by an earlier build is
half a pixel off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 03:55:42 +02:00

129 lines
7.1 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <cmath>
#include <fstream>
#include <spdlog/fmt/fmt.h>
#include "RugnuxCalibration.h"
#include "../common/GitInfo.h"
#include "../image_analysis/geom_refinement/AssignSpotsToRings.h"
#include "../image_analysis/geom_refinement/RingOptimizer.h"
#include "../image_analysis/geom_refinement/RingsFromProfile.h"
namespace {
// How well the ring points sit on the fitted rings, in a unit a user can judge: the radial distance in
// pixels between where a point is and where the fitted geometry puts its ring. The fit's own residual
// is in q, so it is divided by the local dq/dr - measured by stepping one pixel outward along the radius
// rather than assumed, since dq/dr varies with two-theta and with the tilt.
//
// The beam centre enters a ring's radius as r(phi) = R + dx cos(phi) + dy sin(phi), so fitting it to n
// points of scatter s leaves the textbook var = 2 s^2 / n on each of dx and dy. That is the number that
// separates a beam centre that was measured from one that was merely reported.
CalibrationResult Summarize(const DiffractionGeometry &fitted,
const std::vector<RingOptimizerInput> &points) {
CalibrationResult result;
result.geometry = fitted;
const float cx = fitted.GetBeamX_pxl();
const float cy = fitted.GetBeamY_pxl();
double sum_sq = 0.0;
for (const auto &p : points) {
const float r = std::hypot(p.x - cx, p.y - cy);
if (!(r > 1.0f))
continue;
const float q = fitted.PxlToQ(p.x, p.y);
const float dq_dr = fitted.PxlToQ(p.x + (p.x - cx) / r, p.y + (p.y - cy) / r) - q;
if (!(std::abs(dq_dr) > 0.0f))
continue;
const double dr = (q - p.q_expected) / dq_dr;
sum_sq += dr * dr;
++result.ring_points;
}
if (result.ring_points > 0) {
result.rms_radial_pxl = std::sqrt(sum_sq / static_cast<double>(result.ring_points));
result.beam_sigma_pxl = result.rms_radial_pxl
* std::sqrt(2.0 / static_cast<double>(result.ring_points));
}
return result;
}
} // namespace
CalibrationResult CalibrateFromProfile(const std::vector<float> &profile,
const AzimuthalIntegrationMapping &mapping,
const DiffractionGeometry &geom,
const std::vector<float> &calibrant_ring_q) {
const auto points = RingsFromAzimuthalProfile(profile, mapping, geom, calibrant_ring_q);
if (points.empty())
throw JFJochException(JFJochExceptionCategory::CalibrationError,
"No powder ring found in the summed azimuthal profile");
return Summarize(RingOptimizer(geom).Run(points), points);
}
CalibrationResult CalibrateFromSpots(const std::vector<SpotToSave> &spots,
const DiffractionGeometry &geom,
const std::vector<float> &calibrant_ring_q) {
DiffractionGeometry fitted = geom;
// From scratch (Hough circle centre + ring clustering), then refined: the guess pins the centre to a
// whole pixel and only sees the spots its clustering kept, so the refine re-matches every spot at
// that geometry.
GuessGeometry(fitted, spots, calibrant_ring_q);
OptimizeGeometry(fitted, spots, calibrant_ring_q);
return Summarize(fitted, AssignSpotsToRings(fitted, spots, calibrant_ring_q));
}
void WritePoniFile(const std::string &path, const DiffractionExperiment &experiment,
const DiffractionGeometry &geom) {
std::ofstream f(path);
if (!f)
throw JFJochException(JFJochExceptionCategory::FileWriteError, "Cannot write " + path);
const double pixel_m = geom.GetPixelSize_mm() * 1e-3;
// pyFAI's axis convention is the trap: Poni1 (and pixel1) is the SLOW axis - rows, our y - and
// Poni2 the FAST axis - columns, our x - both in metres from the detector origin. A transposed PONI
// file is silently wrong, so the mapping is spelled out here rather than left to the reader.
//
// DiffractionGeometry's beam_x/beam_y IS the PONI: LabCoord rotates the vector measured FROM that
// pixel, i.e. it is the point of normal incidence, so it maps straight across with no correction.
// GetDirectBeam_pxl() is a different quantity - where the direct beam lands - and parts from the
// PONI as soon as rot1/rot2 are non-zero.
//
// The half pixel is the origin convention (see docs/DETECTOR_GEOMETRY.md): our coordinates are
// pixel-centred, so beam_x = 948 means the CENTRE of pixel 948, while pyFAI measures from the edge
// of the sensor and puts the centre of pixel i at (i + 0.5) * pixel size. Without it the pattern
// pyFAI integrates sits half a pixel off ours.
const double half_pixel_m = 0.5 * pixel_m;
f << fmt::format("# Calibration done by Jungfraujoch rugnux {}\n", jfjoch_version());
f << "poni_version: 2\n";
f << "Detector: Detector\n";
f << fmt::format("Detector_config: {{\"pixel1\": {:g}, \"pixel2\": {:g}, \"max_shape\": [{}, {}]}}\n",
pixel_m, pixel_m, experiment.GetYPixelsNumConv(), experiment.GetXPixelsNumConv());
f << fmt::format("Distance: {:.9g}\n", geom.GetDetectorDistance_mm() * 1e-3);
f << fmt::format("Poni1: {:.9g}\n", geom.GetBeamY_pxl() * pixel_m + half_pixel_m);
f << fmt::format("Poni2: {:.9g}\n", geom.GetBeamX_pxl() * pixel_m + half_pixel_m);
// rot2 and rot3 change SIGN on the way out, and rot1 does not. pyFAI has the slow axis increasing
// BOTTOM to TOP; we use the MX convention, top to bottom. The two frames therefore differ by a
// reflection in y, and conjugating a rotation by a reflection gives R(n, theta) -> R(Mn, -theta).
// For rot1 the axis IS y, so the axis reverses and the sense reverses and the two cancel; for rot2
// (about x) and rot3 (about the beam) the axis lies in the mirror plane, so only the sense reverses.
// The angles mean the same thing in both frames - it is only the handedness of the frame that
// differs - and the same two flips would apply on the way IN if a PONI file were ever read.
// Poni1/Poni2 need no such change: they are distances from the corner of the sensor along each
// axis, which the direction the axis runs in does not affect.
// Verified against pyFAI on a LaB6 image: written without the flip, the rings pyFAI integrates are
// BROADER than with no tilt at all (peak 42 against 30, mean ring-position error 0.0045 1/A against
// 0.0027); with it they sharpen to 132 and 0.0005.
f << fmt::format("Rot1: {:.9g}\n", geom.GetPoniRot1_rad());
// negate() rather than a bare minus so an unrefined angle prints as 0 and not -0.
const auto negate = [](float v) { return v == 0.0f ? 0.0f : -v; };
f << fmt::format("Rot2: {:.9g}\n", negate(geom.GetPoniRot2_rad()));
f << fmt::format("Rot3: {:.9g}\n", negate(geom.GetPoniRot3_rad()));
f << fmt::format("Wavelength: {:.9g}\n", geom.GetWavelength_A() * 1e-10);
f.flush();
if (!f)
throw JFJochException(JFJochExceptionCategory::FileWriteError, "Error writing " + path);
}