The detector plane was three PONI angles and nothing else, so the two things it
cannot express - an image mirrored in Y, and one mounted at a multiple of 90
degrees - had no home at all. They are now the DetectorOrientation carried by the
detector setup, composed with the PONI rotation into one orthogonal matrix whose
columns ARE the fast axis, the slow axis and the sample->PONI normal:
lab = R(rot1, rot2, rot3) * Delta * ( (x-bx)*p , (y-by)*p , distance )
GetFastAxis/GetSlowAxis/GetNormalAxis read those columns and DetectorAxes() sets
the plane from them, decomposing back to the angles; PoniRotMatrix and
PoniAnglesFromMatrix are the conversion in both directions, exact on the canonical
branch (rot2 in [-pi/2, pi/2]) and with a stated convention at gimbal lock. The
angles stay stored rather than re-derived, so a geometry given as angles is
written back as the same angles, to the bit.
Delta is never inferred. In particular an arbitrary rot3 is NOT decomposed into a
quarter turn plus a residual: rot3 is a fitted quantity, and a least-squares step
must not be able to turn the stored image. It is set only where something states
it - the detector setup, --detector-mirror-y / --detector-quarter-turns, or the
value a file this system wrote records - and defaults to the identity, which makes
the whole change a no-op for every existing detector and every existing file.
It is a different setting from DetectorSetup::mirror_y, which flips the MODULE
LAYOUT while an image is assembled and so decides what the stored pixels are.
Merging the two would apply the mirror twice for every modular detector, or change
the pixel content of every file written; both are ruled out. The new one earns its
keep exactly where the old one is a no-op: a detector whose image arrives already
assembled has no layout to flip.
Both generators are signed permutations of the in-plane offset, so they preserve
the distance from the PONI. That is why almost nothing downstream changes:
everything needing an azimuth already goes through LabCoord, and everything that
does not needs only a radius. The two hand-written copies of the rotation -
XtalResidual and RingOptimizer - take the discrete part as four constants next to
cos_rot3/sin_rot3, since it acts in the detector frame where rot3 acts in the
laboratory and cannot be folded into it. RingOptimizer needs it despite being a
radial fit: it fits the tilt, and the discrete part changes which way the tilt
tips a ring.
Carried as two optional CBOR keys and two detectorSpecific datasets, both
back-compatible; the NXmx module axis vectors and the translation direction stop
being hardcoded and are computed from it, reproducing today's values exactly at
the identity. GetPoniRotMatrix is renamed GetDetectorMatrix, because it is no
longer only the PONI rotation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lc5JG6kJqZoCWaoZ43JGTW
138 lines
5.4 KiB
C++
138 lines
5.4 KiB
C++
// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#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) : reference(geom) {}
|
|
|
|
DiffractionGeometry RingOptimizer::Run(const std::vector<RingOptimizerInput> &input) {
|
|
// 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())),
|
|
nullptr,
|
|
¢er_x,
|
|
¢er_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;
|
|
});
|
|
if (single_ring) {
|
|
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);
|
|
|
|
// 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;
|
|
} |