The background ring's high-side clip, mean + 4*sqrt(mean), assumes counted photons. A CCD reports ADU: on a Rigaku Saturn a background pixel scatters 4-5x its mean, so the clip sat at ~2 sigma, cut the upper tail of every ring and added a positive offset to every reflection, compressing the intensity statistics (a false 422 on one twinned I41 crystal). No header carries a usable gain (SMV states none, every marCCD file the writer default of 1 ADU per photon), so it is measured: background_gain::MeasureFrame takes the variance per unit mean of the difference of pixels two columns apart, per level, clipped, on every fourth row of up to 16 of the pre-scan's frames; Gain is the pair-weighted median over the levels. The clip becomes mean + n*sqrt(max(g,1)*mean) (BraggIntegrationSettings::bkg_pixel_gain, CPU and GPU engines). Only for SMV/marCCD input (ProcessConfig::counts_in_adu, set from the file format); HDF5 and CBF keep g = 1, the identity. One log line reports the value. No new option. Measured g: Saturn 944+ 4.7-5.2, Saturn 944 2.7, A200 0.68, ADSC ~0.3, marCCD ~0.3 - so the ADSC/marCCD/A200 sets keep the photon clip exactly. Checks (reflection data via gemmi and p.hkl, against the rc175 battery outputs / base build): identical on 11if, 7qis, 8a1a, myob_x10sa, cytc_x10sa (photon counting), 4bwl, 5epe (ADSC, marCCD) and 6cee (A200). Saturn sets, battery baseline -> this change: 3r6o I4122 R_meas 19.3% ISa 4.4 <|L|> .321 -> I41 (correct) 12.1% 8.1 .384 3mc4 R_meas 10.2 -> 9.8 %, ISa 12.1 -> 12.7, d_min 1.79 -> 1.75 A, <|L|> .360 -> .391 5uth 12.6 -> 11.8 %, ISa 10.4 -> 10.1, <|L|> .426 -> .483, offset vs |Fc|^2 0.21 -> 0.00 5vml 10.2 -> 9.6 %, ISa 11.0 -> 11.6, <|L|> .426 -> .475, offset 0.15 -> 0.05 5cc8 8.1 -> 7.9 %, ISa 10.8 -> 11.2, <|L|> .469 -> .494 3meb 14.5 -> 14.6 %, ISa 13.2 -> 13.2, <|L|> .442 -> .457 Space groups unchanged except 3r6o. Pre-scan cost: +0.5 s on a 9 Mpx ADSC sweep, within noise on a 15 Mpx marCCD sweep, nothing on HDF5/CBF. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
96 lines
3.7 KiB
C++
96 lines
3.7 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "BackgroundGain.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <limits>
|
|
#include <utility>
|
|
|
|
namespace background_gain {
|
|
|
|
namespace {
|
|
constexpr int PAIR_DISTANCE = 2; // columns between the two pixels of a pair
|
|
constexpr size_t ROW_STEP = 4; // every fourth row: a quarter of a frame is ample for one number
|
|
constexpr double CLIP_NSIGMA = 4.0;
|
|
constexpr int CLIP_ROUNDS = 3;
|
|
const double LOG_BIN = std::log(1.25);
|
|
}
|
|
|
|
Levels MeasureFrame(const std::vector<int32_t> &image, size_t width, size_t height,
|
|
const std::vector<uint32_t> &mask, int64_t saturation) {
|
|
const auto usable = [&](size_t i) {
|
|
return mask[i] == 0 && image[i] >= 1 && image[i] < saturation;
|
|
};
|
|
// The level of every usable pair, worked out once; -1 where the pair is not usable.
|
|
std::vector<int8_t> bin(width * height, -1);
|
|
for (size_t y = 0; y < height; y += ROW_STEP)
|
|
for (size_t x = 0; x + PAIR_DISTANCE < width; x++) {
|
|
const size_t i = y * width + x;
|
|
if (!usable(i) || !usable(i + PAIR_DISTANCE))
|
|
continue;
|
|
const double m = 0.5 * (static_cast<double>(image[i]) + image[i + PAIR_DISTANCE]);
|
|
bin[i] = static_cast<int8_t>(std::min<int>(N_BINS - 1, static_cast<int>(std::log(m) / LOG_BIN)));
|
|
}
|
|
|
|
// Clip about zero at CLIP_NSIGMA of the level's own scatter, starting from no clip at all: a few
|
|
// rounds take the Bragg peaks and zingers out without needing any idea of the gain beforehand.
|
|
std::array<double, N_BINS> limit2;
|
|
limit2.fill(std::numeric_limits<double>::infinity());
|
|
Levels out;
|
|
for (int round = 0; round <= CLIP_ROUNDS; round++) {
|
|
out = Levels{};
|
|
for (size_t i = 0; i < bin.size(); i++) {
|
|
const int b = bin[i];
|
|
if (b < 0)
|
|
continue;
|
|
const double d = static_cast<double>(image[i]) - image[i + PAIR_DISTANCE];
|
|
if (d * d > limit2[b])
|
|
continue;
|
|
out.n[b]++;
|
|
out.sum_mean[b] += 0.5 * (static_cast<double>(image[i]) + image[i + PAIR_DISTANCE]);
|
|
out.sum_diff2[b] += d * d;
|
|
}
|
|
for (int b = 0; b < N_BINS; b++)
|
|
if (out.n[b] > 0)
|
|
limit2[b] = CLIP_NSIGMA * CLIP_NSIGMA * out.sum_diff2[b] / static_cast<double>(out.n[b]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
std::optional<float> Gain(const std::vector<Levels> &frames) {
|
|
// (variance/mean, pairs) per level
|
|
std::vector<std::pair<double, uint64_t>> ratio;
|
|
uint64_t total = 0;
|
|
for (int b = 0; b < N_BINS; b++) {
|
|
uint64_t n = 0;
|
|
double sum_mean = 0.0, sum_diff2 = 0.0;
|
|
for (const auto &f : frames) {
|
|
n += f.n[b];
|
|
sum_mean += f.sum_mean[b];
|
|
sum_diff2 += f.sum_diff2[b];
|
|
}
|
|
// var(a - b) = 2 var(pixel)
|
|
if (n >= MIN_PAIRS_PER_BIN && sum_mean > 0.0) {
|
|
ratio.emplace_back(0.5 * sum_diff2 / sum_mean, n);
|
|
total += n;
|
|
}
|
|
}
|
|
if (ratio.size() < MIN_BINS)
|
|
return std::nullopt;
|
|
// The median over PAIRS, not over levels: the background holds most of the pixels in a few levels,
|
|
// and the Bragg peaks spread a few pixels over many more - an unweighted median over the levels of
|
|
// a weak-background marCCD frame lands on the peaks and reads 5.7 where the background reads 0.3.
|
|
std::sort(ratio.begin(), ratio.end());
|
|
uint64_t cum = 0;
|
|
for (const auto &[r, n] : ratio) {
|
|
cum += n;
|
|
if (2 * cum >= total)
|
|
return static_cast<float>(r);
|
|
}
|
|
return static_cast<float>(ratio.back().first);
|
|
}
|
|
|
|
} // namespace background_gain
|