Files
Jungfraujoch/image_analysis/pixel_refinement/PixelRefine.h
T

134 lines
6.1 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.
//
// 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)
// 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;
};
class PixelRefine {
BraggPrediction &prediction;
const AzimuthalIntegrationMapping &mapping;
const size_t xpixel, ypixel;
const DiffractionExperiment &experiment;
const HKLKeyGenerator hkl_key_generator;
std::map<HKLKey, double> reference_data;
public:
PixelRefine(const DiffractionExperiment &experiment,
const AzimuthalIntegrationMapping &mapping,
const std::vector<MergedReflection> &reference,
BraggPrediction &prediction);
template<class T>
void Run(const T *image,
const AzimuthalIntegrationProfile &profile,
PixelRefineData &data);
};