Files
Jungfraujoch/image_analysis/spot_finding/AdaptiveSpotFinderCPU.cpp
T
leonarski_fandClaude Opus 4.8 b65691f312 Add fused GPU adaptive spot finder (azint + spot finding in one pass)
AdaptiveSpotFinderGPU does the per-resolution-ring reduction once on the GPU and
drives both products from it: the azimuthal-integration profile (corrected space)
and the self-calibrating adaptive spot-detection threshold (raw counts). This
replaces the separate GPU azint pass and the host-side adaptive spot finder that
runs on the GPU path today. On a ~4.5 MP detector it does both jobs in ~1 ms/frame
versus ~40 ms for the CPU adaptive finder (~42x), with an identical spot list and
azimuthal profile.

The per-ring threshold math (Poisson tail + read-floored Gaussian, operating point
from the false-pixels-per-frame knob) is factored into AdaptiveThreshold.h so the
CPU and GPU finders share one source of truth and cannot drift.

Wired opt-in via a MXAnalysisWithoutFPGA constructor flag, default on for the rugnux
offline path and the interactive viewer, off for the online receiver (so the broker
path is unchanged). When on, Analyze() skips the separate azint pass and lifts the
profile from the fused engine. The viewer gains an "Adaptive threshold" checkbox that
greys out the signal/noise and photon-count sliders (the adaptive finder uses neither).

Dedicated tests exercise both products (spot-finding parity vs the CPU finder,
azimuthal profile vs a standalone GPU azint) plus a speed benchmark. Validated
end-to-end on lysozyme serial stills: fused == CPU-adaptive index rate and merge stats.

Docs: new section 3.2 in docs/CPU_DATA_ANALYSIS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:10:45 +02:00

129 lines
5.2 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <algorithm>
#include <bitset>
#include <cmath>
#include <unordered_map>
#include "AdaptiveSpotFinderCPU.h"
#include "AdaptiveThreshold.h"
AdaptiveSpotFinderCPU::AdaptiveSpotFinderCPU(const AzimuthalIntegrationMapping &in_mapping)
: ImageSpotFinder(static_cast<int32_t>(in_mapping.GetWidth()),
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_cnt.assign(nbins, 0);
ring_mean.assign(nbins, 0.0f);
ring_sigma.assign(nbins, 0.0f);
ring_thr.assign(nbins, 0.0f);
}
// Accumulate per-ring mean/variance from the raw (photon) image. clip_k <= 0 -> use every valid
// pixel (first pass); clip_k > 0 -> keep only pixels within clip_k sigma of the current ring mean,
// which removes the Bragg peaks from the background estimate.
void AdaptiveSpotFinderCPU::AccumulateRings(const ImagePreprocessorBuffer &image, float clip_k) {
const auto &pixel_to_bin = mapping.GetPixelToBin();
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_cnt.begin(), ring_cnt.end(), 0);
for (size_t pxl = 0; pxl < npix; ++pxl) {
const int32_t v = image[pxl];
if (v == INT32_MIN || v == INT32_MAX) continue; // bad / saturated
const uint16_t b = pixel_to_bin[pxl];
if (b >= nbins) continue; // masked / out of range (UINT16_MAX)
if (clip_k > 0.0f) {
const float lo = ring_mean[b] - clip_k * ring_sigma[b];
const float hi = ring_mean[b] + clip_k * ring_sigma[b];
if (v < lo || v > hi) continue; // exclude peaks / outliers
}
ring_sum[b] += v;
ring_sum2[b] += static_cast<double>(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);
ring_mean[b] = static_cast<float>(m);
ring_sigma[b] = static_cast<float>(std::sqrt(var));
}
}
}
std::vector<DiffractionSpot> AdaptiveSpotFinderCPU::Run(const ImagePreprocessorBuffer &image,
const SpotFindingSettings &settings,
const std::vector<bool> &res_mask) {
const auto &pixel_to_bin = mapping.GetPixelToBin();
const size_t nbins = ring_sum.size();
const size_t npix = static_cast<size_t>(width) * height;
// --- Stage A: robust per-ring background (one plain pass + two sigma-clip passes) ---
AccumulateRings(image, 0.0f);
AccumulateRings(image, 3.0f);
AccumulateRings(image, 3.0f);
// --- Stage B: per-ring threshold from the single portable knob E (false pixels / frame) ---
int64_t n_total = 0;
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];
}
if (n_total == 0)
return {};
const double E = std::max(1.0f, settings.false_pixels_per_frame);
double p = E / static_cast<double>(n_total);
p = std::min(std::max(p, 1e-9), 0.1);
const float z = static_cast<float>(adaptive_threshold::NormalQuantile(1.0 - p));
// whole-frame fallback background for rings too sparse to trust on their own
const double g_mean = g_sum / n_total;
const double g_sigma = std::sqrt(std::max(0.0, g_sum2 / n_total - g_mean * g_mean));
const float g_thr = adaptive_threshold::RingThreshold(static_cast<float>(g_mean),
static_cast<float>(g_sigma), p, z);
for (size_t b = 0; b < nbins; ++b)
ring_thr[b] = (ring_cnt[b] < adaptive_threshold::MIN_RING_PIXELS)
? g_thr
: adaptive_threshold::RingThreshold(ring_mean[b], ring_sigma[b], p, z);
// --- Stage C: flag strong pixels into the bit buffer (value >= ring threshold) ---
for (size_t i = 0; i < OutputSize(); ++i)
output_buffer[i] = 0;
std::bitset<32> out = 0;
for (size_t pxl = 0; pxl < npix; ++pxl) {
const int32_t v = image[pxl];
const uint16_t b = pixel_to_bin[pxl];
bool strong = false;
if (v == INT32_MAX)
strong = true;
else if (v != INT32_MIN && b < nbins && v >= ring_thr[b])
strong = true;
const int32_t bit = pxl % 32;
if (strong)
out.set(bit);
if (bit == 31) {
output_buffer[pxl / 32] = out.to_ulong();
out.reset();
}
}
if (npix % 32 != 0)
output_buffer[OutputSize() - 1] = out.to_ulong();
// --- Stage D: connected components + resolution mask + min/max-pix (shared with classic path) ---
return ExtractSpots(image, settings, res_mask);
}