Files
Jungfraujoch/image_analysis/pixel_refinement/PixelRefine.h
T
leonarski_f efe882f4b6
Build Packages / Unit tests (push) Failing after 1s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 25m52s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 29m5s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 29m54s
Build Packages / build:rpm (rocky8) (push) Successful in 31m55s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 32m12s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 32m48s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 35m27s
Build Packages / Generate python client (push) Successful in 25s
Build Packages / build:rpm (rocky9) (push) Successful in 31m59s
Build Packages / Create release (push) Skipped
Build Packages / Build documentation (push) Successful in 1m36s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 24m8s
Build Packages / XDS test (neggia plugin) (push) Successful in 17m46s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 21m36s
Build Packages / XDS test (durin plugin) (push) Successful in 19m40s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 19m38s
Build Packages / DIALS test (push) Successful in 26m30s
jfjoch_viewer: Better display (to be tested) of pixel refine
2026-06-09 16:28:17 +02:00

198 lines
10 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include "../bragg_prediction/BraggPrediction.h"
#include "../common/DiffractionExperiment.h"
#include "../common/AzimuthalIntegrationMapping.h"
#include "../common/AzimuthalIntegrationProfile.h"
#include "../scale_merge/HKLKey.h"
// =============================================================================
// PixelRefine — one optimization to rule geometry, integration and scaling
// =============================================================================
//
// Intent
// ------
// Classical crystallographic data processing is a one-way pipeline:
//
// spot finding -> indexing -> geometry refinement -> integration -> scaling -> merging
//
// Each stage consumes the previous stage's output and never talks back. The
// integrator trusts the refined geometry; the scaler trusts the integrated
// intensities; nothing downstream is ever allowed to correct an upstream
// parameter. Post-refinement and profile fitting were the field's partial
// answers to this: post-refinement lets merged intensities nudge per-image
// orientation/cell/mosaicity, and profile fitting lets a learned spot shape
// improve weak-reflection intensities. But both are narrow back-channels bolted
// onto a feed-forward pipeline — there is no end-to-end gradient that flows from
// the raw detector pixels all the way back to every parameter at once.
//
// PixelRefine is an experiment in doing the whole thing as a *single* least
// squares problem. We write down, for every pixel in a reflection's shoebox, the
// expected counts as an explicit forward model
//
// I_pred(pixel) = G * I_true * B_term * P_radial * P_tangential + I_bkg
//
// and let Ceres autodiff back-propagate the per-pixel residuals into ALL of:
// * detector geometry (beam centre, distance, tilt)
// * crystal orientation + unit cell
// * overall scale G and Debye-Waller B
// * the reciprocal-space spot widths R = (radial, tangential)
// simultaneously. Geometry refinement, profile-fitted integration and scaling
// then stop being separate stages: they are different parameters of one model,
// coupled through the same pixels, with full backpropagation between them. Once
// the model is differentiable end-to-end, things that used to need bespoke code
// — mosaicity refinement, profile fitting, partiality — fall out "for free" as
// extra parameters of the same forward model.
//
// How I_true enters
// -----------------
// I_true is NOT refined here. It is a *fixed hypothesis* for the duration of a
// pass: the current best merged estimate of each reflection's full intensity.
// The intended outer loop is iterative, like EM / self-consistent field:
//
// repeat over the whole dataset:
// run PixelRefine on every image with the current I_true reference
// re-merge the resulting intensities -> new I_true
// until the reference stops changing
//
// So a single PixelRefine call answers "given this intensity hypothesis, what
// geometry/scale/profile best explains these pixels, and what intensities do I
// read back out?", and the dataset-level loop refines the hypothesis itself.
//
// Inner predict<->refine loop
// ---------------------------
// Within one image we also iterate (max_iterations): Bragg prediction places the
// shoeboxes, we refine, the refined geometry/cell feed the next prediction, etc.
// Initially the model geometry equals the experiment's DiffractionGeometry, but
// as refinement proceeds it diverges, so later predictions must use the *refined*
// data.geom rather than the static experiment geometry.
//
// On shoeboxes, tails, and gatekeeping
// ------------------------------------
// We deliberately do NOT chase the full spot. A shoebox only needs to cover
// enough of a reflection for the fit to be meaningful; clipped tails are fine,
// because the partiality term already downweights whatever falls outside the
// well-modelled core. The premise - especially for serial crystallography - is
// that the problem is rarely *missing* information; it is *failing to gate out*
// information that is not meaningful. As long as the model knows a piece is
// missing (low partiality), it is safe to leave it missing. That flips the usual
// trade-off: rather than shrinking boxes to avoid contamination, we can grow them
// and let partiality decide, per pixel and per reflection, what actually carries
// signal. (For downstream integration this pixel-level gating is the point - keep
// only meaningful pixels, instead of a fixed geometric mask.) The bandwidth term
// below is part of the same idea: it tells the model where the radial tails are
// *expected* to be, so it can weight rather than blindly include them.
//
// X-ray bandwidth (optional)
// --------------------------
// A finite bandwidth thickens the Ewald shell radially and smears spots along the
// radial direction, growing like 1/d^2 (the pink-beam/DMM signature). It enters
// as a fixed, resolution-dependent addition to the radial width R0 (see
// PixelRefineData::bandwidth and PixelRefine.cpp). It is OFF by default
// (bandwidth = 0, monochromatic); set it for DMM-type data, leave it for Si.
//
// Status: experimental prototype. The forward model (esp. the still-image
// Lorentz/partiality normalization) is deliberately simple and expected to
// evolve. See PixelRefine.cpp for the physics conventions and known caveats.
// =============================================================================
struct PixelRefineData {
// --- model state (input as initial guess, output as refined result) ---
DiffractionGeometry geom;
CrystalLattice latt;
gemmi::CrystalSystem crystal_system = gemmi::CrystalSystem::Triclinic;
char centering = 'P';
double B_factor = 0.0; // Debye-Waller B (A^2)
double scale_factor = 1.0; // overall scale G
double R[2] = {0.005, 0.005}; // R[0] = radial (partiality) width, R[1] = tangential (profile) width (A^-1)
// Relative X-ray bandwidth (sigma of dlambda/lambda), e.g. ~0.004 for a 1%
// FWHM DMM, ~1e-4 for Si(111). Adds a resolution-dependent radial broadening
// to R[0]. 0 = monochromatic (the term switches off entirely).
double bandwidth = 0.0;
// Goniometer: for a still image keep angle_deg = 0. For a wedge, pass the
// angle (deg) of the slice centre relative to the reference (phi=0) frame.
double angle_deg = 0.0;
// --- what to refine ---
bool refine_orientation = true; // crystal orientation (p0)
bool refine_unit_cell = false; // cell lengths + angles
bool refine_beam_center = false;
bool refine_distance = false;
bool refine_detector_angles = false;
bool refine_rotation_axis = false;
bool refine_scale = true;
bool refine_B = false;
bool refine_R = true;
double max_time_s = 5.0;
int shoebox_radius = 3; // half-size of the per-reflection pixel box
int max_iterations = 3; // inner predict<->refine cycles (re-predict with refined geom/latt)
// --- output ---
std::vector<Reflection> reflections; // profile-fitted integration result
bool solved = false;
double final_cost = NAN;
size_t residual_count = 0;
double cc = NAN; // per-image CC of scaled intensities vs reference
int64_t cc_n = 0; // number of reflections in the CC
};
class PixelRefine {
const AzimuthalIntegrationMapping &mapping;
const size_t xpixel, ypixel;
const DiffractionExperiment &experiment;
const HKLKeyGenerator hkl_key_generator;
std::map<HKLKey, double> reference_data;
// Fills the Ceres parameter blocks (geometry + symmetry-aware lattice
// parametrization) from the current model state. Shared by Run and
// PredictImage so both walk identical geometry/lattice code.
void BuildParameterBlocks(const PixelRefineData &data,
double beam[2], double &dist_mm,
double detector_rot[2], double rot_vec[3],
double latt_vec0[3], double latt_vec1[3], double latt_vec2[3]) const;
public:
PixelRefine(const DiffractionExperiment &experiment,
const AzimuthalIntegrationMapping &mapping,
const std::vector<MergedReflection> &reference);
// The BraggPrediction is supplied per call (it is mutated): this keeps a
// single PixelRefine instance usable from several threads, each passing its
// own prediction buffer. Only `data` is written; PixelRefine state is const.
template<class T>
void Run(const T *image,
const AzimuthalIntegrationProfile &profile,
BraggPrediction &prediction,
PixelRefineData &data);
// Render the forward model as a full detector image (raw detector units, so
// it overlays directly on the original image). Uses the *same* per-pixel
// model path (PixelResidual::Model) as the optimizer, evaluated in double
// precision - slow but exact. For each reference reflection it adds the Bragg
// signal over its shoebox; with include_background it also lays down the
// azimuthal background. Diagnostic tool, not on the hot path.
std::vector<float> PredictImage(const AzimuthalIntegrationProfile &profile,
BraggPrediction &prediction,
const PixelRefineData &data,
bool include_background = true) const;
// Render the per-pixel chi-square (cost density) that the optimizer actually
// minimizes: for every shoebox pixel that enters the fit it stores the squared
// weighted residual ((I_pred - I_obs)/sigma)^2 in *corrected* units - identical
// to the Ceres residual_i^2 - accumulating where shoeboxes overlap. Pixels that
// are not part of any shoebox stay 0; masked/saturated pixels (skipped by the
// fit) also stay 0. Summing the image gives ~2*final_cost. Diagnostic tool.
template<class T>
std::vector<float> ChiSquaredImage(const T *image,
const AzimuthalIntegrationProfile &profile,
BraggPrediction &prediction,
const PixelRefineData &data) const;
};