Add opt-in local-SNR spot gate and acceptance-fraction knob (serial stills)

Two opt-in tools for weak serial-stills tuning; both default-off, so the
default pipeline is bit-identical (verified: a serial-stills reference run
reproduces HEAD's 7.85% indexing rate exactly).

--local-snr <sigma> (AdaptiveSpotFinderCPU::FilterByLocalSNR): after the loose
per-ring adaptive threshold builds connected-component spots, drop any spot that
does not stand this many sigmas above its OWN LOCAL background (robust median/MAD
of a square annulus), not just the azimuthal ring mean. On structured-background
(XFEL) frames the ring mean underestimates the local diffuse level in some
sectors, so the ring threshold floods; a real Bragg peak still stands many local
sigmas proud. Validated on XFEL stills to separate real peaks from flood at the
pixel level (real median local-SNR ~70 vs flood ~2.6; SNR>=5 keeps ~99.8% of
real peaks, ~14% of flood). GPU-portable (a per-spot local reduction). NOTE: on
the current serial-stills battery it is index-rate/CC1/2 neutral -- the flood that
survives as CC clusters overlaps weak-real spots, and only lattice-fit separates
those -- but it is the correct tool for genuinely floody data (ice/jet/loosened
detector) and the right substrate for the online FPGA path.

--min-indexed-fraction <f>: exposes the previously hardcoded 0.20 minimum
indexed-spot fraction (AnalyzeIndexing) as a per-run setting. Lowering it admits
weaker/sparser crystals; on flooded XFEL data the extra lattices are spurious
(pair with --min-image-cc to gate them), on clean synchrotron data there are no
marginal frames so it is a no-op -- useful as a gating-experiment primitive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-24 11:12:22 +02:00
co-authored by Claude Opus 4.8
parent 503bd36738
commit ca7cbe206a
9 changed files with 107 additions and 3 deletions
@@ -200,11 +200,62 @@ std::vector<DiffractionSpot> AdaptiveSpotFinderCPU::Run(const ImagePreprocessorB
// --- Stage D: connected components + resolution mask + min/max-pix (shared with classic path) ---
auto spots = ExtractSpots(image, settings, res_mask);
if (settings.local_snr > 0.0f)
FilterByLocalSNR(image, spots, settings.local_snr);
if (settings.soft_weight)
ApplyWeights(spots);
return spots;
}
// Reject spots that do not stand out against their LOCAL background. The loose per-ring threshold
// keeps ~100% of real Bragg peaks but, on structured-background (XFEL) frames, also floods with
// spurious pixels: the azimuthal ring mean underestimates the diffuse level in some sectors, so a
// locally-high background pixel clears it. A real peak stands many sigmas above the background in
// its IMMEDIATE neighbourhood, a flood pixel does not. For each spot the background mean and scatter
// are measured from a square annulus around its centroid (robust median / MAD, so a neighbouring
// peak in the annulus cannot bias it), and the spot is kept only if its integrated signal exceeds
// k local sigmas. k is in sigma units -- self-calibrating, no photon threshold.
void AdaptiveSpotFinderCPU::FilterByLocalSNR(const ImagePreprocessorBuffer &image,
std::vector<DiffractionSpot> &spots, float k) const {
constexpr int RIN = 3; // half-width of the excluded core (7x7)
constexpr int ROUT = 6; // half-width of the background annulus (13x13)
std::vector<float> bg;
bg.reserve((2 * ROUT + 1) * (2 * ROUT + 1));
std::vector<DiffractionSpot> kept;
kept.reserve(spots.size());
for (const auto &s : spots) {
const Coord c = s.RawCoord();
const int col = static_cast<int>(std::lround(c.x));
const int row = static_cast<int>(std::lround(c.y));
bg.clear();
for (int dr = -ROUT; dr <= ROUT; ++dr) {
const int rr = row + dr;
if (rr < 0 || rr >= height) continue;
for (int dc = -ROUT; dc <= ROUT; ++dc) {
if (std::max(std::abs(dr), std::abs(dc)) <= RIN) continue; // skip the peak core
const int cc = col + dc;
if (cc < 0 || cc >= width) continue;
const int32_t v = image[static_cast<size_t>(rr) * width + cc];
if (v == INT32_MIN || v == INT32_MAX) continue; // masked / saturated
bg.push_back(static_cast<float>(v));
}
}
if (bg.size() < 8) { kept.push_back(s); continue; } // too few bg pixels to judge
const size_t mid = bg.size() / 2;
std::nth_element(bg.begin(), bg.begin() + mid, bg.end());
const float bg_med = bg[mid];
for (auto &v : bg) v = std::fabs(v - bg_med);
std::nth_element(bg.begin(), bg.begin() + mid, bg.end());
const float sigma = std::max(1.4826f * bg[mid], 1.0f);
const double npix = static_cast<double>(std::max<int64_t>(s.PixelCount(), 1));
const double signal = static_cast<double>(s.Count()) - bg_med * npix;
const double snr = signal / (sigma * std::sqrt(npix));
if (snr >= static_cast<double>(k))
kept.push_back(s);
}
spots.swap(kept);
}
void AdaptiveSpotFinderCPU::ApplyWeights(std::vector<DiffractionSpot> &spots) const {
const auto &pixel_to_bin = mapping.GetPixelToBin();
const size_t nbins = ring_mean.size();
@@ -338,6 +389,8 @@ std::vector<DiffractionSpot> AdaptiveSpotFinderCPU::RunPersistence(const ImagePr
std::vector<DiffractionSpot> out;
out.reserve(spots.size());
for (auto &kv : spots) out.push_back(kv.second);
if (settings.local_snr > 0.0f)
FilterByLocalSNR(image, out, settings.local_snr);
if (settings.soft_weight)
ApplyWeights(out);
return out;