Files
Jungfraujoch/image_analysis/bragg_integration/SpotWidth.h
T
leonarski_fandClaude Opus 5.5 d6166f6fa5 Panel layout stage 2: prediction and Bragg integration through the panel table
Everything sparse that took a radial quantity from an image coordinate now takes it from the
reflection's (or spot's) position on the detector plane, through its panel (DetectorModules).
With an empty module table every changed expression reduces to the old one in the same
floating-point order, so flat detectors - every rotation set, EIGER, CCDs, assembled JUNGFRAU -
integrate bit for bit as before.

- Prediction (CPU, GPU, rotation CPU and GPU): bounds test against PlaneBounds instead of the
  image rectangle.
- IntegrateOnImage: a prediction in a gap or off every panel is dropped (PlaneToPanel), no longer
  passed through onto an unrelated stored pixel. The panel id stays local: the engine and the way
  back find it from the image position (the panel boxes tile the image), and a prediction that
  rounding to float put on another panel's edge is dropped so the two agree. Nothing is added to
  Reflection.
- Bragg engines (CPU, CUDA, Metal) take a BraggPanel per reflection (BraggStencil.h): its plane
  position, the panel's pixel axes on the plane and its box. The stencil's radial direction is
  taken on the plane and expressed in the panel's pixel axes; r0, the radial background curve,
  the Gaussian's elongation, the learned moments, tan(2theta) and Finalize's polarisation use the
  plane position; r_max / n_rad come from PlaneBounds. The neighbour mask and the owner map are
  clamped to the reflection's own panel; inside the walked box a pixel off the reflection's panel
  is read as a gap (unreadable, statically masked - not an overload), which is what the assembled
  image has there, so a disk cut by a panel edge is partial rather than silently "full".
- Risk R4: the ProfileEmpirical grid is learned and applied in the plane's axes (the panel axes
  taken to the nearest quarter turn, BraggProfileCell), so turned panels pool one shape. Without
  it the stacked layout read up to 0.3 % off the assembled one in the harness.
- SpotWidth / SpotFootprint: aperture bounds against the spot's panel box, beam distance,
  resolution and the shape's 2theta Jacobian on the plane, moments read in the panel's axes.
- GetDetectorMaxQ_recipA, the far-corner radii (first-pass tilt report, metric-symmetry arm,
  footprint table range) from PlaneBounds; pre-scan spot resolution on the plane;
  LogHeaderBeamCenter: geometric centre of the panels' plane box, and the masked-pixel check on
  the panel the beam lands on (none when it is in a gap).

Harness: PanelLayout_BraggIntegration is now a real [PanelLayout] check, over six settings
(default, bandwidth-elongated ring, radial background, overlap exclude, box sum, empirical
profile, all with polarisation), stacked vs assembled to 1e-5; PanelLayout_BraggIntegrationGPU
runs the same on the CUDA engine. Its spots avoid exact half pixels: the grid centres on the
rounded position, and a half rounds to the other neighbour on a turned panel.

The Metal engine (.metal/.mm) mirrors the CPU change exactly but is UNCOMPILED here (no macOS):
it needs a build and BraggIntegrationEngineMetal_BitIdenticalToCPU on a Mac.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
2026-10-21 10:40:20 +02:00

228 lines
14 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
// =============================================================================
// SpotWidth - how wide the recorded spots actually are, in detector pixels
// =============================================================================
//
// The integrator's signal radius r1 is not the integration domain (the profile fit runs over a
// (2*ceil(r2)+1)^2 grid), but it IS the aperture the profile WIDTH is learned over, and a second
// moment taken over a disk of radius a saturates at a^2/4 - so at the shipped r1 = 4 the learned
// Gaussian sigma can never exceed 2 px whatever the spot is. A crystal whose spots are wider than
// that is integrated with a profile the model cannot represent, and it is exactly those crystals
// that gain from a larger r1. Measured over the rotation battery, one threshold on the recorded
// width separates the two populations cleanly.
//
// This measures that width WITHOUT asking the integrator, so the answer can be used to set r1:
// r80, the radius holding 80 % of a spot's flux, is read off the encircled-flux curve of isolated
// strong spots over a FIXED 14 px aperture normalised at 8 px. Nothing here depends on r1, r2 or
// r3, or on any quantity the integrator computes; the aperture is a constant of this file. The
// curve is built about the spot's own flux-weighted centroid, no shape is assumed, and the
// candidates are stratified by resolution - the strongest spots of a weak crystal sit at high
// angle and of a strong one at low angle, and the mosaic footprint grows as 1/d, so an unstratified
// sample compares crystals at different resolutions.
// =============================================================================
#include <array>
#include <functional>
#include <optional>
#include <vector>
#include "../../common/DetectorModules.h"
#include "../../common/DiffractionGeometry.h"
#include "../../common/DiffractionSpot.h"
#include "../image_preprocessing/ImagePreprocessorBuffer.h"
#include "SpotFootprint.h"
namespace spot_width {
// The encircled-flux curve runs to R_MAX px and is normalised at R_NORM, clear of the noisy tail.
// R_NORM = 8 also fixes what this estimator can see: a spot needs r80 <= 8 px to be measured at
// all, which is five times the width the decision below is taken at.
constexpr int R_MAX = 14;
constexpr int R_NORM = 8;
// The background under a spot is the median of the R_MAX+2 .. R_MAX+6 px ring around it.
constexpr int R_BKG_IN = R_MAX + 2;
constexpr int R_BKG_OUT = R_MAX + 6;
// Resolution the widths are compared at. Inside the measured range of essentially every crystal.
constexpr float D_REF_A = 5.0f;
// One spot's second moments along and across its own radius, and the exact geometry that turns
// them into a bandwidth (EstimateBandwidth). jr and jt are the pixels one radian of 2theta, and of
// the angle across the scattering plane, moves the spot by; cos_psi is the angle of the ray to the
// sensor normal, and par_u/par_v are tan^2(psi) times the square of the in-plane ray direction's
// component along the radius and across it - the conversion depth's lateral smear, per um^2 of depth
// variance, in the two directions.
struct SpotShape {
bool valid = false;
float two_theta = 0.0f, jr = 0.0f, jt = 0.0f;
float m_rad = 0.0f, m_tan = 0.0f; // px^2, about the flux-weighted centroid
float cos_psi = 1.0f, par_u = 0.0f, par_v = 0.0f;
};
// One spot's encircled flux C(1), C(2), ... C(R_MAX), divided by C(R_NORM), with the resolution it
// was recorded at, and its shape.
struct FluxCurve {
float d_A = 0.0f;
std::array<float, R_MAX> c{};
SpotShape shape;
};
// The fitted width law: r80(d) = c0 + c1/d over resolution bands, weighted by the spots in each, so
// crystals whose strong spots sit at different resolutions are compared like with like. The fit is
// over 1/d because the mosaic contribution to the detector footprint grows that way, so c1 is the
// crystal's own answer to how much wider its spots get at high angle - a quantity the fit has always
// computed and nothing has ever been able to read, because only the value at one resolution was
// returned.
struct R80Fit {
// Kept in double, and evaluated in double, because At() has to reproduce the single number the
// caller used to get bit for bit: rounding the coefficients to float first and adding them after
// moves the last bit, and r1 = round(2*r80) can turn on it.
double c0 = 0.0, c1 = 0.0;
// What the bands actually measured, widened the way this fit has always widened it. The law is
// HELD at these outside the measured range rather than extrapolated - it has two coefficients and
// as few as two points, so a slope read off a short lever arm must not be allowed to run away.
double lo = 0.0, hi = 0.0;
[[nodiscard]] float At(double d_A) const;
};
// The width law from a pool of curves, or nothing where too few spots were measurable.
[[nodiscard]] std::optional<R80Fit> FitR80(const std::vector<FluxCurve> &curves);
// r80 at D_REF_A, which is the law above read at one resolution.
[[nodiscard]] std::optional<float> R80AtReference(const std::vector<FluxCurve> &curves);
// The signal radius the measured width asks for: r1 = clamp(round(2*r80), 4, 6). The floor is the
// shipped default and the ceiling is set by pattern density - r2 = r1 + 2 also drives the
// neighbour-ownership radius and the inner edge of the background ring, and past 6 px a dense
// pattern starts losing reflections whose ring falls below six clean pixels.
[[nodiscard]] float R1ForWidth(float r80);
// When a growing sample has told us all it is going to about the radius, so the measurement can
// stop. R1ForWidth is a three-way choice and it moves only where 2*r80 crosses 4.5 and 5.5, i.e. at
// r80 = 2.25 and 2.75 px, so "settled" is two things and not one:
//
// * the estimate has STOPPED MOVING - this sample is within SETTLED_STEP_PX of what the smaller
// sample before it said; and
// * it is not sitting ON a switch - it is SWITCH_CLEARANCE_PX clear of both of them.
//
// Neither carries the rotation battery on its own. Clearance without the step test loses the
// crystal whose r80 is 0.07 px from a switch and whose smaller samples read across it; the step test
// without the clearance lets a sample that has settled AT a switch stop there. Both bounds are set
// inside the range that is right on 38 of 38 crystals rather than at its edge: over every phase of
// the sub-sample the answer is exact for a clearance of 0.20-0.30 px and a step of 0.20-0.60 px.
//
// A quarter of a pixel is also half the width of the whole r1 = 5 band, so a sample landing inside
// that band never stops early - which is deliberate, because that band and its edges are where a
// reduced sample gets a crystal wrong.
constexpr float SWITCH_CLEARANCE_PX = 0.25f;
constexpr float SETTLED_STEP_PX = 0.40f;
// True where this sample's r80 has settled against the smaller sample's before it.
[[nodiscard]] bool WidthSettled(float r80, float r80_before);
// Most of the predicted reflections a widened radius may leave without a background ring, measured
// (BraggIntegrationCounts::bkg_starved_by_neighbour) rather than predicted. Above this the widening
// has cost the pattern more than it can be worth and the shipped radius is restored.
//
// The bound is read off the rotation battery, not chosen. At the radius the rule picks, the eleven
// crystals whose observation count survives the widening sit between 0.000 % and 0.315 %; the one
// crystal that loses a quarter of its observations sits at 4.082 %. That is the only gap in the
// distribution, and this is its midpoint in log space - the maximum-margin split, a factor 3.6
// clear of the nearest measurement on either side.
//
// It has to be the NEIGHBOUR count and not the total: the total also carries the detector's own
// floor (module gaps, the beam stop, the resolution mask), which reaches 2.3 % on some geometries,
// does not move with r1, and would put four of the rule's six wins within a factor of two of the
// loss. Splitting the two apart takes the separation from 2x to 13x.
constexpr double BKG_STARVED_MAX_FRACTION = 0.0113;
// -----------------------------------------------------------------------------------------------
// The X-ray bandwidth, read off the same spots.
//
// A spread dlambda/lambda of sigma smears a reflection along its own radius by 2 tan(theta) sigma
// radians of 2theta and not at all across it. Per spot, with u along the radius and v across it,
//
// m_rad = 1/12 + par_u + jr^2 (s_ang^2 + 4 tan^2(theta) sigma^2)
// m_tan = 1/12 + par_v + jt^2 s_ang^2
//
// (1/12: the pixel; par: the sensor parallax, fixed from the sensor's physics; s_ang: everything
// isotropic in angle - divergence, crystal size, mosaic spread seen edge-on). So
//
// y = (m_rad - 1/12 - par_u) - (jr/jt)^2 (m_tan - 1/12 - par_v) = a + sigma^2 (2 jr tan theta)^2
//
// is a straight line whose slope is the bandwidth. It is fitted over eight equal-count bins of the
// abscissa, each a 20 %-trimmed mean, weighted by its own scatter, and the slope's error is the
// spread of 200 bootstrap re-draws of the spots, inflated by the fit's reduced chi^2 where the line
// fits worse than the scatter says. The slope is significant at BANDWIDTH_Z standard errors - over
// the whole battery that separates the multilayer beamlines from every monochromatic one.
//
// It is a LOWER bound: mosaic spread seen along the radius subtracts, and a spread of cell edges
// (strain) is exactly degenerate with it. What it measures is the effective radial broadening, and
// that is what the consumers of the bandwidth need. The run uses it where it is significant and
// neither the file nor --bandwidth states one.
//
// The width settles on fewer spots than the slope needs, so the pre-scan keeps measuring spots for the
// bandwidth alone until the pool holds BANDWIDTH_POOL_SPOTS or the sample runs out.
constexpr size_t BANDWIDTH_MIN_SPOTS = 80;
constexpr size_t BANDWIDTH_POOL_SPOTS = 1000;
constexpr double BANDWIDTH_Z = 3.0;
struct BandwidthEstimate {
double fwhm = 0.0; // relative FWHM of dlambda/lambda; negative where the slope is
double fwhm_floor = 0.0; // the FWHM one standard error of sigma^2 amounts to
double z = 0.0; // slope / its standard error
double chi2 = 0.0; // reduced chi^2 of the binned line
size_t spots = 0;
};
// The bandwidth from the shapes of a pool of spots, or nothing where too few were measured.
// attenuation_um is the sensor's attenuation length at this wavelength, thickness_um its thickness
// (0 = no depth, no parallax), pixel_um the pixel size.
[[nodiscard]] std::optional<BandwidthEstimate> EstimateBandwidth(const std::vector<FluxCurve> &curves,
double attenuation_um, double thickness_um,
double pixel_um);
} // namespace spot_width
// Every isolated, strong, fully readable spot of one image, as a normalised encircled-flux curve
// with its shape, appended to `out`. Called on one worker's own vector, so the pool is independent of the order the
// workers finish in. The spots are in the image; `modules` places its panels on the plane the geometry
// describes (empty: the image is the plane), and a spot's aperture has to lie on its own panel.
void MeasureSpotFluxCurves(const ImagePreprocessorBuffer &image, int width, int height,
const DiffractionGeometry &geometry, const DetectorModules &modules,
const std::vector<DiffractionSpot> &spots,
std::vector<spot_width::FluxCurve> &out);
// The same, with spot i's pixels read about centre_px(i) - the pixel its position rounds to - with
// rows `stride` apart: R_BKG_OUT pixels of them every way that the image itself has. The frame is
// width x height.
void MeasureSpotFluxCurves(const std::function<const int32_t *(size_t)> &centre_px, int stride, int width, int height,
const DiffractionGeometry &geometry, const DetectorModules &modules,
const std::vector<DiffractionSpot> &spots,
std::vector<spot_width::FluxCurve> &out);
// The squares MeasureSpotsOnTiles reads about each spot. The flux curves stay within
// spot_width::R_BKG_OUT of their spot; the footprint's window re-centres without a bound, so a spot
// whose window walks off its square is measured again from the start on a square of
// FOOTPRINT_TILE_HALF, and on the whole frame where even that is left. Over the rotation battery's
// pre-scans a window leaves the first square on a few spots of most frames, and the second on none.
constexpr int SPOT_TILE_HALF = 24;
constexpr int FOOTPRINT_TILE_HALF = 80;
// Spots whose footprint had to be measured again on a larger square, and on the whole frame.
struct SpotTileMisses {
size_t larger = 0;
size_t whole_frame = 0;
};
// MeasureSpotFluxCurves and MeasureFootprintSpots of one frame, reading the (2*half+1)^2 pixels about
// each spot (ImagePreprocessorBuffer::GatherTiles) rather than the frame - on the GPU only those cross
// back to the host. The answers are the whole frame's to the bit: each measurement reads the same
// pixels in the same order. The whole frame is read where the squares would be more pixels than it.
SpotTileMisses MeasureSpotsOnTiles(ImagePreprocessorBuffer &image, int width, int height,
const DiffractionGeometry &geometry, const DetectorModules &modules,
const std::vector<DiffractionSpot> &spots,
std::vector<spot_width::FluxCurve> &curves, std::vector<FootprintSpot> &footprint,
int half = SPOT_TILE_HALF, int footprint_half = FOOTPRINT_TILE_HALF);