spot_finding: accumulate the adaptive ring statistics in integers

The per-ring sums were floats reduced by atomics, so the ring sigma - and with it the
detection threshold - depended on the order the blocks happened to arrive in. Detection
compares an INTEGER pixel value against that threshold, so a threshold that drifts
across an integer flips every pixel of that value in the ring at once, which is how a
last-bit difference turned into a different spot list.

A preprocessed pixel is an exact int32 and the masked and saturated sentinels are
skipped, so v and v*v are exact in 64 bits, and integer addition is associative: the
sums no longer care about arrival order. Both engines now accumulate the same way, so
they agree exactly rather than approximately, and the GPU spot list is bit-identical
across runs. The corrected sums that feed the reported azimuthal profile stay float -
a pixel value times a float correction has no exact integer form - but they do not
enter the detection decision.

Cost: the ring reduction needs 28 bytes per bin instead of 20 in the plain pass, which
drops it from eight co-resident blocks per SM to seven and costs about 11% of that
kernel (0.582 -> 0.650 ms/frame on a 4.5 Mpx frame). End to end it does not show:
alternating runs on three rotation crystals came out the same or slightly faster, and
the battery is unchanged in every number. The CPU engine got 30% faster (32.2 -> 22.6
ms/frame), integers being cheaper than doubles.

Tests: exact CPU/GPU agreement on the spot list, and 50 repeats of bit-identical output
where there were four.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 15:19:11 +02:00
co-authored by Claude Opus 5
parent 212fbf9bab
commit abb94ca450
5 changed files with 93 additions and 49 deletions
@@ -13,8 +13,8 @@ AdaptiveSpotFinderCPU::AdaptiveSpotFinderCPU(const AzimuthalIntegrationMapping &
static_cast<int32_t>(in_mapping.GetHeight())),
mapping(in_mapping) {
const size_t nbins = mapping.GetBinNumber();
ring_sum.assign(nbins, 0.0);
ring_sum2.assign(nbins, 0.0);
ring_sum.assign(nbins, 0);
ring_sum2.assign(nbins, 0);
ring_cnt.assign(nbins, 0);
ring_mean.assign(nbins, 0.0f);
ring_sigma.assign(nbins, 0.0f);
@@ -29,8 +29,8 @@ void AdaptiveSpotFinderCPU::AccumulateRings(const ImagePreprocessorBuffer &image
const size_t nbins = ring_sum.size();
const size_t npix = static_cast<size_t>(width) * height;
std::fill(ring_sum.begin(), ring_sum.end(), 0.0);
std::fill(ring_sum2.begin(), ring_sum2.end(), 0.0);
std::fill(ring_sum.begin(), ring_sum.end(), 0);
std::fill(ring_sum2.begin(), ring_sum2.end(), 0);
std::fill(ring_cnt.begin(), ring_cnt.end(), 0);
for (size_t pxl = 0; pxl < npix; ++pxl) {
@@ -44,14 +44,14 @@ void AdaptiveSpotFinderCPU::AccumulateRings(const ImagePreprocessorBuffer &image
if (v < lo || v > hi) continue; // exclude peaks / outliers
}
ring_sum[b] += v;
ring_sum2[b] += static_cast<double>(v) * v;
ring_sum2[b] += static_cast<uint64_t>(static_cast<int64_t>(v) * v);
ring_cnt[b] += 1;
}
for (size_t b = 0; b < nbins; ++b) {
if (ring_cnt[b] > 0) {
const double m = ring_sum[b] / ring_cnt[b];
const double var = std::max(0.0, ring_sum2[b] / ring_cnt[b] - m * m);
const double m = static_cast<double>(ring_sum[b]) / ring_cnt[b];
const double var = std::max(0.0, static_cast<double>(ring_sum2[b]) / ring_cnt[b] - m * m);
ring_mean[b] = static_cast<float>(m);
ring_sigma[b] = static_cast<float>(std::sqrt(var));
}
@@ -74,8 +74,8 @@ void AdaptiveSpotFinderCPU::Detect(const ImagePreprocessorBuffer &image,
double g_sum = 0.0, g_sum2 = 0.0;
for (size_t b = 0; b < nbins; ++b) {
n_total += ring_cnt[b];
g_sum += ring_sum[b];
g_sum2 += ring_sum2[b];
g_sum += static_cast<double>(ring_sum[b]);
g_sum2 += static_cast<double>(ring_sum2[b]);
}
if (n_total == 0) {
// Nothing valid to threshold against: leave no strong pixels for ExtractSpots to build on.