CPU adaptive spot finder: sigma clips from a histogram, first pass only where read

Two whole-image passes per frame out of the CPU spot finder (the pre-scan's
finder on every build, and every image on the CPU-only build):

- AccumulateRings ran three passes over the frame - the plain ring statistics
  and two sigma clips. The plain pass now also counts each ring's valid
  values in a histogram (0..1023, the rest in a short list), and the clip
  passes sum over the distinct values: each meets the same float test its
  pixels would, and the sums are integers, so the totals are the same.
- The local test's first pass is read by DetectAt only inside a candidate's
  window. It now marks the row/32-column blocks those windows reach and
  keeps its sliding sums everywhere but skips the per-pixel test elsewhere;
  the bits it leaves unset are never read.

md5-identical output on four sets (GPU) and three (CPU-only). CPU-only
16M: 203 -> 177 s and 303 -> 263 s; GPU 16M 23.8 -> 22.8 s (the pre-scan's
finder).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
This commit is contained in:
2026-09-26 20:40:46 +02:00
co-authored by Claude Opus 5.5
parent 7ce2993ae9
commit 87cfc879b6
4 changed files with 63 additions and 11 deletions
@@ -21,6 +21,7 @@ AdaptiveSpotFinderCPU::AdaptiveSpotFinderCPU(const AzimuthalIntegrationMapping &
ring_thr.assign(nbins, 0.0f);
ring_bkg.assign(nbins, NAN);
ring_bits.assign(OutputSize(), 0);
ring_hist.assign(nbins * HIST_VALUES, 0);
}
// Per-ring background statistics with iterated peak exclusion following peakfinder8:
@@ -37,19 +38,44 @@ void AdaptiveSpotFinderCPU::AccumulateRings(const ImagePreprocessorBuffer &image
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) {
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) {
if (clip_k <= 0.0f) {
std::fill(ring_hist.begin(), ring_hist.end(), 0);
ring_overflow.clear();
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)
ring_sum[b] += v;
ring_sum2[b] += static_cast<uint64_t>(static_cast<int64_t>(v) * v);
ring_cnt[b] += 1;
if (v >= 0 && v < HIST_VALUES)
ring_hist[b * HIST_VALUES + v] += 1;
else
ring_overflow.emplace_back(b, v);
}
} else {
// A sigma-clip pass over the plain pass's values: each distinct value of a ring meets the same
// test the pixels holding it would, and its pixels are added as a count.
const auto keep = [&](uint16_t b, int32_t v) {
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
return !(v < lo || v > hi); // exclude peaks / outliers
};
for (size_t b = 0; b < nbins; ++b)
for (int32_t v = 0; v < HIST_VALUES; ++v) {
const uint32_t n = ring_hist[b * HIST_VALUES + v];
if (n == 0 || !keep(static_cast<uint16_t>(b), v)) continue;
ring_sum[b] += static_cast<int64_t>(n) * v;
ring_sum2[b] += static_cast<uint64_t>(n) * static_cast<uint64_t>(static_cast<int64_t>(v) * v);
ring_cnt[b] += n;
}
for (const auto &[b, v] : ring_overflow) {
if (!keep(b, v)) continue;
ring_sum[b] += v;
ring_sum2[b] += static_cast<uint64_t>(static_cast<int64_t>(v) * v);
ring_cnt[b] += 1;
}
ring_sum[b] += 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) {
@@ -63,6 +63,12 @@ class AdaptiveSpotFinderCPU : public ImageSpotFinderCPU {
// Pixels at or above their ring's threshold, packed like output_buffer. Intersected with the
// local-box mask that ImageSpotFinderCPU::Detect leaves in output_buffer.
std::vector<uint32_t> ring_bits;
// The plain pass's valid pixels as a per-ring histogram of their values (HIST_VALUES bins per
// ring) plus a list of the values outside it, so the two sigma-clip passes sum over distinct
// values instead of over the image again. Integer sums, so the same totals.
static constexpr int32_t HIST_VALUES = 1024;
std::vector<uint32_t> ring_hist;
std::vector<std::pair<uint16_t, int32_t>> ring_overflow; // (ring, value)
void AccumulateRings(const ImagePreprocessorBuffer &image, float clip_k);
// Fill ring_bits from the thresholds of the current frame.
@@ -50,6 +50,21 @@ bool StrongInWindow(int64_t pxl_val, int64_t sum, int64_t sum2, int64_t valid,
void ImageSpotFinderCPU::DetectAt(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings,
const std::vector<uint32_t> &candidates) {
candidate_windows.clear();
// The first pass's bits are read only inside the window of a candidate (and at the candidate), so
// it tests only the pixels of the row/column blocks such a window reaches; the bits it leaves
// unset there are never read.
const int32_t nblocks = (width + 31) / 32;
first_pass_needed.assign(static_cast<size_t>(height) * nblocks, 0);
for (size_t w = 0; w < candidates.size(); w++)
for (uint32_t bits = candidates[w]; bits; bits &= bits - 1) {
const int64_t pxl = static_cast<int64_t>(w) * 32 + std::countr_zero(bits);
if (pxl >= static_cast<int64_t>(width) * height)
break;
const int32_t line = static_cast<int32_t>(pxl / width), col = static_cast<int32_t>(pxl % width);
for (int32_t y = std::max(line - NBX, 0); y <= std::min(line + NBX, height - 1); y++)
for (int32_t b = std::max(col - NBX, 0) / 32; b <= std::min(col + NBX, width - 1) / 32; b++)
first_pass_needed[static_cast<size_t>(y) * nblocks + b] = 1;
}
DetectPass(image, settings, nullptr, first_pass_buffer, candidates.data());
std::fill(output_buffer.begin(), output_buffer.end(), 0);
@@ -196,7 +211,9 @@ void ImageSpotFinderCPU::DetectPass(const ImagePreprocessorBuffer &image,
if (candidates && (candidates[pxl / 32] & (1U << bit)))
candidate_windows.push_back({pxl, sum, sum2, valid});
if (StrongInWindow(value_at(pxl), sum, sum2, valid, settings, strong2))
const bool needed = !candidates
|| first_pass_needed[static_cast<size_t>(line) * ((width + 31) / 32) + col / 32];
if (needed && StrongInWindow(value_at(pxl), sum, sum2, valid, settings, strong2))
out.set(bit);
if (bit == 31) {
@@ -37,6 +37,9 @@ class ImageSpotFinderCPU : public ImageSpotFinder {
int64_t sum, sum2, valid;
};
std::vector<CandidateWindow> candidate_windows;
// Where the first pass's own result is read by DetectAt - within NBX of a candidate - per row and
// 32-column block. Elsewhere that pass keeps its sums but skips the test.
std::vector<uint8_t> first_pass_needed;
protected:
// Detect, but with the result wanted only at the candidate pixels (packed like output_buffer):