Files
Jungfraujoch/image_analysis/geom_refinement/RingOptimizer.cpp
T
leonarski_fandClaude Opus 5 01ad1e1743 calibration: only report a tilt the fit actually measured
The detector tilt is refined by default, and on a pattern that cannot separate
it from the beam centre the fit returns one anyway - there was nothing to stop
it. Both displace a ring's radius as cos(phi), and only how that amplitude grows
with the ring's radius tells them apart, which takes two well-sampled rings. At
500 mm on the LaB6 series only two rings reach the detector and the outer one is
barely there: the tilt came out at the opposite sign to every shorter distance,
dragged the PONI 28 px, and bought a residual of 0.960 px against 0.962 pinned.
The covariance says so plainly - 0.1 sigma, and a beam centre quoted to +-180 px.

So ask it. A tilt is kept only where the fit had it free AND it stands at least
three times its own uncertainty; otherwise rot1/rot2 go back to the header's
values and the beam centre and distance are refitted around them. Over the
series the tilt stands at 50, 33, 15 and 8 sigma at 110 to 300 mm and 0.1 at
500 mm, so any threshold between 2 and 5 gives the same verdict on all five -
this says which regime a fit is in, not where a line was drawn. The declined
500 mm fit lands on a direct beam of 773.53 px, against 773.56 for the pinned
fit measured independently.

It is a rejection criterion and nothing more. Clearing it does not certify a
tilt: that estimator is limited by systematics rather than by this sigma, and a
coherent half-pixel error in the ring positions fakes a tilt of the usual size
while leaving sigma small. The report says "refined", never "verified".

Writing the gate turned up a related fault in the pass loop. RingOptimizer pins
the tilt by itself when every point it is given lies on one ring, and on a
barely-sampled pattern a later pass lands in exactly that state - which froze
the tilt at whatever the FIRST pass had produced and returned it with sigma
zero, an unmeasured tilt wearing the appearance of a fixed one. The gate reads
that as "not measured" and refits pinned, which is why it is stated over the
geometry that gets reported rather than over what the last fit happened to do.
Both paths are covered, profile and spots; the spots path was reporting a
refined tilt as declined for the same reason.

Two things measured and NOT taken:

A robust loss. A Cauchy loss scaled to the previous pass's median residual
changed nothing on the series - rms 0.415 to 0.421 at 110 mm, no case improved,
every direct beam within 0.06 px. Ring points are per-sector peaks that already
had to stand 3 sigma clear of their own background, so there are no gross
outliers left to reject. Recorded at the call site rather than left as an unused
option.

A quality gate that refuses a bad calibration. Three candidate signals, all
measured against naming the wrong standard on LaB6 data: sigma(PONI) does not
see it at all (0.52-0.65 px, indistinguishable from healthy); the residual only
half sees it (3.2-3.6 px wrong against 0.4-1.0 right, but a correct run from a
wrong header sits at 1.0-2.4 and would be caught too); and the seed's match
score is dominated by how many rings the calibrant lists, scoring 0.29 for a
perfect LaB6 fit against 0.21 for a wrongly named silicon. None of the three
separates, so no gate is shipped. What the run does say is the recovered
distance against the header, and a wrong standard moves that to 446 mm on a
110 mm exposure - unmissable, and the operator's call rather than a threshold's.

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

230 lines
9.7 KiB
C++

// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <cmath>
#include <algorithm>
#include "../../common/DetectorOrientation.h"
#include "../../common/JFJochMath.h"
#include "RingOptimizer.h"
#include "ceres/ceres.h"
struct RingResidual {
RingResidual(double x, double y, double lambda,
double pixel_size,
double expected_q,
const DetectorOrientation &orientation)
: obs_x(x), obs_y(y),
lambda(lambda),
pixel_size(pixel_size),
expected_len_recip_sq(expected_q * expected_q / (4.0 * PI * PI)) {
const RotMatrix delta = orientation.Matrix();
det_m00 = delta.Column(0).x;
det_m01 = delta.Column(1).x;
det_m10 = delta.Column(0).y;
det_m11 = delta.Column(1).y;
}
template<typename T>
bool operator()(const T* const center_x, const T* const center_y,
const T* const distance, const T* const rot1,
const T* const rot2, T* residual) const {
// Calculate lab coordinates from observed pixel coordinates
T u_lab = (T(obs_x) - center_x[0]) * T(pixel_size); // convert to mm
T v_lab = (T(obs_y) - center_y[0]) * T(pixel_size);
// The discrete image orientation, which turns the offset from the PONI before the tilt acts.
// Identity unless a detector says otherwise. It cannot change a ring's radius, but it does
// change which way the tilt tips the ring, which is exactly what this fits.
T x_lab = det_m00 * u_lab + det_m01 * v_lab;
T y_lab = det_m10 * u_lab + det_m11 * v_lab;
T z_lab = distance[0];
// Apply rotations around y and x axes
T c1 = ceres::cos(rot1[0]);
T c2 = ceres::cos(rot2[0]);
T s1 = ceres::sin(rot1[0]);
T s2 = ceres::sin(rot2[0]);
T x = x_lab * c1 + z_lab * s1;
T y = y_lab * c2 + (-x_lab * s1 + z_lab * c1) * s2;
T z = -y_lab * s2 + (-x_lab * s1 + z_lab * c1) * c2;
// convert to recip space
T lab_norm = ceres::sqrt(x*x + y*y + z*z);
T R_x = x / (lab_norm * T(lambda));
T R_y = y / (lab_norm * T(lambda));
T R_z = (z / lab_norm - T(1.0)) / T(lambda);
T predicted_len_recip_sq = R_x * R_x + R_y * R_y + R_z * R_z;
residual[0] = predicted_len_recip_sq - T(expected_len_recip_sq);
return true;
}
const double obs_x, obs_y;
const double lambda;
const double pixel_size;
const double expected_len_recip_sq;
double det_m00, det_m01, det_m10, det_m11;
};
RingOptimizer::RingOptimizer(const DiffractionGeometry& geom, bool refine_tilt)
: reference(geom), refine_tilt(refine_tilt) {}
namespace {
// Covariance of the converged fit, scaled to the residual scatter this fit actually left. Ceres hands
// back the bare (J^T J)^-1 of an unweighted problem, which carries the shape of the correlations but
// not their size; multiplying by chi2 per degree of freedom is what turns it into a sigma. The
// residual is in q^2, so the scale is in q^2 too - and it cancels, because (J^T J)^-1 is in
// parameter^2 per residual^2. The sigmas therefore come out in pixels, mm and radians whatever the
// residual is measured in, which is why this does not need to wait on the residual being restated.
void ComputeUncertainty(ceres::Problem &problem, const ceres::Solver::Summary &summary,
const std::vector<const double *> &free_blocks,
double *const centre_x, double *const centre_y, double *const distance,
double *const rot1, double *const rot2,
RingFitUncertainty &unc) {
const int p = static_cast<int>(free_blocks.size());
const int n = static_cast<int>(summary.num_residuals);
unc.free_parameters = p;
if (n <= p)
return;
// DENSE_SVD rather than the SPARSE_QR default: five parameters is not a sparse problem, and SVD is
// the one that survives a near-degenerate normal matrix long enough to report how degenerate it is.
ceres::Covariance::Options options;
options.algorithm_type = ceres::DENSE_SVD;
options.num_threads = 1;
ceres::Covariance covariance(options);
std::vector<std::pair<const double *, const double *>> pairs;
for (const double *a : free_blocks)
for (const double *b : free_blocks)
pairs.emplace_back(a, b);
// A failure here is not an error to report upwards: it means the problem is rank-deficient at the
// solution, i.e. some direction in parameter space costs the fit nothing at all. valid = false
// carries that, and it is the strongest statement this struct can make.
if (!covariance.Compute(pairs, &problem))
return;
std::vector<double> cov(static_cast<size_t>(p) * p);
if (!covariance.GetCovarianceMatrix(free_blocks, cov.data()))
return;
// 2 * final_cost is sum of squared residuals - Ceres' cost is half of it.
unc.chi2_per_dof = 2.0 * summary.final_cost / static_cast<double>(n - p);
for (auto &v : cov)
v *= unc.chi2_per_dof;
const auto index_of = [&](const double *block) {
for (int i = 0; i < p; ++i)
if (free_blocks[i] == block) return i;
return -1;
};
const auto sigma = [&](const double *block) {
const int i = index_of(block);
return i < 0 ? 0.0 : std::sqrt(std::max(0.0, cov[i * p + i]));
};
const auto corr = [&](const double *a, const double *b) {
const int i = index_of(a), j = index_of(b);
if (i < 0 || j < 0) return 0.0;
const double d = std::sqrt(cov[i * p + i] * cov[j * p + j]);
return d > 0.0 ? cov[i * p + j] / d : 0.0;
};
unc.sigma_beam_x_pxl = sigma(centre_x);
unc.sigma_beam_y_pxl = sigma(centre_y);
unc.sigma_distance_mm = sigma(distance);
unc.sigma_rot1_rad = sigma(rot1);
unc.sigma_rot2_rad = sigma(rot2);
unc.corr_beam_x_rot1 = corr(centre_x, rot1);
unc.corr_beam_y_rot2 = corr(centre_y, rot2);
unc.valid = true;
}
} // namespace
DiffractionGeometry RingOptimizer::Run(const std::vector<RingOptimizerInput> &input,
RingFitUncertainty *unc) {
// Initial guess for the parameters
double center_x = reference.GetBeamX_pxl();
double center_y = reference.GetBeamY_pxl();
double distance = reference.GetDetectorDistance_mm();
double rot1 = reference.GetPoniRot1_rad();
double rot2 = reference.GetPoniRot2_rad();
ceres::Problem problem;
// Add residuals for each point
for (const auto& pt : input) {
problem.AddResidualBlock(
new ceres::AutoDiffCostFunction<RingResidual, 1, 1, 1, 1, 1, 1>(
new RingResidual(pt.x, pt.y,
reference.GetWavelength_A(),
reference.GetPixelSize_mm(),
pt.q_expected,
reference.GetOrientation())),
// No robust loss. Tried and measured on the LaB6 distance series: a Cauchy loss scaled
// to the previous pass's median residual moved nothing (rms 0.415 -> 0.421 at 110 mm, no
// case improved, every direct beam within 0.06 px) - the ring points are per-sector
// peaks that already had to stand 3 sigma clear of their own background, so there are no
// gross outliers left for it to reject.
nullptr,
&center_x,
&center_y,
&distance,
&rot1,
&rot2
);
}
// A single ring cannot tell the beam centre from the detector tilt: both displace its radius as
// cos(phi), and what separates them is only how that amplitude scales with the ring's radius, which
// takes two rings. Hold the tilt where it was given, so the one thing a single ring does fix - where
// its centre lies - comes out rather than being traded away against an unconstrained tilt.
const bool single_ring = !input.empty()
&& std::all_of(input.begin(), input.end(), [&](const RingOptimizerInput &p) {
return p.q_expected == input.front().q_expected;
});
const bool tilt_free = !(single_ring || !refine_tilt);
if (!tilt_free) {
problem.SetParameterBlockConstant(&rot1);
problem.SetParameterBlockConstant(&rot2);
}
// Configure solver
ceres::Solver::Options options;
options.linear_solver_type = ceres::DENSE_QR;
options.minimizer_progress_to_stdout = false;
options.logging_type = ceres::LoggingType::SILENT;
options.num_threads = 1;
ceres::Solver::Summary summary;
// Run optimization
ceres::Solve(options, &problem, &summary);
if (unc && summary.IsSolutionUsable()) {
std::vector<const double *> free_blocks = {&center_x, &center_y, &distance};
if (tilt_free) {
free_blocks.push_back(&rot1);
free_blocks.push_back(&rot2);
}
ComputeUncertainty(problem, summary, free_blocks,
&center_x, &center_y, &distance, &rot1, &rot2, *unc);
}
// A failed fit must not move the detector geometry. Both callers assign the result straight
// back over the geometry they passed in, so handing back the reference leaves the calibration
// where it was instead of committing a diverged beam centre and distance.
if (!summary.IsSolutionUsable())
return reference;
DiffractionGeometry refined_geom(reference);
refined_geom.BeamX_pxl(center_x).BeamY_pxl(center_y).DetectorDistance_mm(distance)
.PoniRot1_rad(rot1).PoniRot2_rad(rot2);
return refined_geom;
}