Bragg integration: stop rectifying the fitted intensity into its own variance

The profile fit weights each pixel by 1/v with v = max(bkg, floor) + max(0, I)*P, where
I is the fit's own current estimate. Rectifying it means that at true zero the plug-in
is E[max(0,I)] = 0.4*sigma rather than 0, and with sum(P^3)/sum(P^2)^2 = 4/3 for a
Gaussian the reported sigma comes out about 0.2 counts too large - always, additively.
That is nothing at sigma ~ 7 counts and 11% at sigma ~ 2, so it only shows on data
measured against roughly one background count.

Clamp the whole weight instead of the intensity: v = max(bkg + I*P, bkg/2). Simulation
of the real integrator gives claimed/true sigma 0.92-1.01 at zero intensity across
backgrounds 0.02-2.0 ct/px and 1.000-1.007 above I = 30, where the clamp never binds.
Dropping the signal term entirely instead (v = max(bkg, floor)) is exact at zero and
wrong everywhere else - 1.91 at I = 5, 4.29 at I = 30, 13.3 at I = 300 - and a test
built on systematically absent reflections cannot see that, because it only measures
zero. Removing the clamp altogether overshoots and biases the intensity, since a
downward fluctuation shrinks v at the peak and over-weights it.

The pixel variance floor was 1/12, documented as the rounding of a continuous energy.
That does not describe a photon counter: measured on raw frames at 0.065-0.082 ct/px,
var/mean is 1.042-1.045, i.e. Poisson with no digitisation term, and a digitisation
term would be additive rather than a floor. What the floor really protects is the
background estimate, which a small ring can read as exactly zero, so it belongs at the
resolution of that estimate, ~1/n_bkg. At 1/12 it multiplied the reported variance by
floor/bkg below 0.083 ct/px - a factor of two at 0.04. Set to 0.01.

Measured on systematically absent reflections, whose true intensity is zero, as
std(I)/rms(sigma) binned by background - not std(I/sigma), which is deflated by the
correlation between the plug-in sigma and the reflection's own fluctuation. On 2.78 M
absent observations at 0.16-3 ct/px the ratio goes 1.04-1.07 to 0.99-1.00. On 2.58 M at
0.005-0.6 ct/px, decomposed: the clamp carries it above 0.08 ct/px, the floor below it.
Intensities move 0.4%; this changes sigma, not I.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 21:08:29 +02:00
co-authored by Claude Opus 5
parent f60768d49c
commit 3d3fb0e58b
3 changed files with 25 additions and 12 deletions
@@ -284,13 +284,15 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
}
// Isotropic width (2nd moment) of a learned grid: over the r1 disk (monochromatic) or the full
// grid (broadband); <r^2> = 2 sigma^2 in 2D.
// grid (broadband); <r^2> = 2 sigma^2 in 2D. The cells are signed: away from the peak a learned
// cell is pure background noise centred on zero, and clamping it at zero turns that noise into a
// positive pedestal spread over the whole domain, which the r^2 weight then reads as extra width.
auto measure_sigma2 = [&](const std::vector<double> &grid) {
double m2 = 0.0, m2w = 0.0;
for (int dy = -R; dy <= R; ++dy)
for (int dx = -R; dx <= R; ++dx) {
if (!broadband && dx * dx + dy * dy >= r1_sq) continue;
const double g = std::max(0.0, grid[grid_idx(dx, dy)]);
const double g = grid[grid_idx(dx, dy)];
m2 += g * (dx * dx + dy * dy);
m2w += g;
}
@@ -396,7 +398,7 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
if (x < 0 || y < 0 || x >= W || y >= H) continue;
const int32_t px = img[y * W + x];
if (!valid(px)) continue;
const double v = B + std::max(0.0, I) * Pp;
const double v = std::max(B + I * Pp, WEIGHT_VARIANCE_MIN_FRACTION * B);
num += Pp * (static_cast<double>(px) - rh.bkg) / v;
den += Pp * Pp / v;
wsum += Pp / v;