Files
Jungfraujoch/image_analysis/spot_finding/SpotFindingSettings.h
T
leonarski_f 711f902aa7 One gain mechanism: frames analysed in their own units, the gain applied where a photon count is assumed
The gain of an input in detector units (SMV, marCCD, XTC, the XFEL formats) is measured once, before
anything reads the frames (RugnuxMeasureGain: photon-peak spacing and background variance per unit
mean), or given with --gain, and handed to the integration and the spot finding by one function
(ApplyRugnuxPixelGain). The rotation pre-scan no longer measures it, and the stills reader no longer
converts frames of a detector whose photons are not resolved: SACLA MPCCD, Rayonix and CCD frames are
integrated in ADU at the gain, rotation and stills alike.

Where a photon count was assumed, it is now taken at the gain (bit-identical at gain 1):
- integration: the background clip's one-photon floor, the sigma floor (one photon), the profile fit's
  background floor (0.01 photon), and a still's overload kept as a lower bound, which was left in ADU
  while every other reflection left the integrator in photons - the cause of the earlier loss of the
  engine route on clipped CCD stills (merged low resolution inflated by the gain, the rocking-width
  fit's bandwidth collapsing); CPU and CUDA, Metal through the shared CPU code;
- the adaptive spot threshold (Poisson arm and its one-photon floor), CPU and GPU;
- the pre-scan spot-width measurement (strong-spot SNR, the ring clip) behind the stills bandwidth and
  the rotation signal radius;
- the hot/dead pixel tests, CPU and GPU; the empty-aperture ring clip.
BackgroundPixelGain is gone: one pixel_gain.

A detector whose background resolves single photons (photon peaks: JUNGFRAU in keV, AGIPD) is a photon
counter and its frames are still counted in whole photons as read: analysed in keV at the gain instead,
a photon-counting still set lost 0.008 R-free (fewer spots, a noisier beam-centre trial) - the rounding
is the detector's own discrimination. --gain sets the value, the background still says which kind.

Validation, against rc178: photon-counting and g<=1 rotation p.hkl md5-identical (lyso, thau, cytc,
myob, insu, a small-molecule set, 5reo, 6cee, 6i3j, 2xfw, 6s1u, 8v4j); CCD g>1 at or within noise of rc178 (5uth
R_meas 0.113 -> 0.108). Stills R-free (0-cycle REFMAC): MPCCD sets 0.184 -> 0.185 and 0.199 -> 0.196,
Rayonix identical, photon-resolving sets back on their rc178 numbers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit da8dbfc8147354b63e375f2d9521dffb80938b6d)
2026-10-23 17:56:51 +02:00

113 lines
7.2 KiB
C++

// SPDX-FileCopyrightText: 2024 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <cstddef>
#include <optional>
#include <cstdint>
#include <vector>
// Strong pixels above which the connected-component search gives up on a frame, unlabelled: an image
// with this much of the detector over threshold is not a diffraction pattern, and it is what the
// spot extractor's buffers are sized to. It is no longer a time limit - the search is linear in the
// strong pixels either way, and a fully lit 18-megapixel frame labels in 0.16 s.
//
// The bar has to be a FRACTION of the detector. It stood at a fixed 65535, which is one pixel in 64
// of the JUNGFRAU 4M it was written for; left fixed while the detectors grew it became one pixel in
// 276 of an 18-megapixel EIGER - a bar a strongly diffracting crystal clears on its best frames,
// which were then dropped whole, and in silence. One in 64 everywhere, and never below the value
// that used to stand here, so no smaller detector loses ground.
constexpr uint32_t StrongPixelLimit(size_t pixel_count) {
const auto limit = static_cast<uint32_t>(pixel_count / 64);
return limit > UINT16_MAX ? limit : UINT16_MAX;
}
// A connected component this size or smaller is judged on size alone. That is where the size bound
// stood before it was raised, so every component that used to be accepted still is.
constexpr int64_t SPOT_SHAPE_FREE_PIXELS = 50;
// A larger one must also be COMPACT: it has to fill at least this percent of the square its bounding
// box fits inside. A Bragg reflection is round and fills about half of that square however bright it
// is; an ice arc, a cosmic-ray track or a lit detector row fills a fifth or less, and those are what
// an upper bound on spot size was ever for.
//
// It is INERT on every dataset it has been measured on: gate on and gate off give a byte-identical
// merge on three rotation crystals including the strongly diffracting one the raised bound was written
// for. The raised bound is what did nearly all of the work there - of the components an absolute ring
// contour pushes past 50 pixels, this shape test rejects a few percent and the raise re-admits the
// rest.
//
// Both only matter while a spot's footprint can grow with its brightness, and since the adaptive
// finder intersects its ring threshold with the local-box SNR test the footprint is peak-relative and
// no component on that set reaches 50 pixels at all.
//
// The shape test is kept anyway because it is the one of the two that does not go stale: a size bound
// is a bet on how large spots are, and the detectors and the detection rule both move underneath it -
// under every peak-relative detector tried, the 200-pixel bound became unreachable while this test
// still fired. The bound is kept alongside it because the cap the local box imposes scales with the
// spot WIDTH, so a set with wider spots than the ones measured here will reach past 50 pixels again.
// A shape test rather than a size test is what XDS and DISTL bound spots with; neither uses this
// statistic, but the choice of shape over size is theirs.
// Following Kabsch (2010) Acta Cryst. D66, 125-132 and Zhang, Sauter et al. (2006) J. Appl. Cryst. 39, 112-119
constexpr int64_t SPOT_MIN_FILL_PERCENT = 20;
// Integer arithmetic throughout, so the host and the GPU extractor agree by construction.
constexpr bool SpotShapeAccepted(int64_t pixel_count, int64_t bbox_side) {
if (pixel_count <= SPOT_SHAPE_FREE_PIXELS)
return true;
return pixel_count * 100 >= SPOT_MIN_FILL_PERCENT * bbox_side * bbox_side;
}
struct SpotFindingSettings {
bool enable = true;
float signal_to_noise_threshold = 4.0; // STRONG_PIXEL in XDS
int64_t photon_count_threshold = 10; // Threshold in photon counts
// Minimum connected pixels per spot. std::nullopt = choose it per image: on the stills indexing
// path the frame is indexed at min-pix 3/2/1 and the one maximising indexed count x indexed fraction
// is kept (see MXAnalysisWithoutFPGA::Analyze); a value fixes it. Defaults to a concrete value, so
// the online receiver and the FPGA path keep the single-pass fixed behaviour unless set otherwise.
std::optional<int64_t> min_pix_per_spot = 2;
// Maximum pixels per spot. A component above SPOT_SHAPE_FREE_PIXELS must also pass
// SpotShapeAccepted, so this bounds how large a ROUND spot may be, not how bright.
int64_t max_pix_per_spot = 200;
// High-resolution limit for spot finding [A]. std::nullopt = as far as the detector reaches, i.e. no
// resolution clipping of the detection at all (DiffractionExperiment::GetDetectorMaxResolution_A
// supplies the number where one is needed, e.g. for the spot plot's shells).
std::optional<float> high_resolution_limit;
// Low-resolution limit for spot finding [A]. std::nullopt = no limit at the low-resolution end, the
// mirror of high_resolution_limit above. Optional rather than a zero sentinel because zero is not a
// natural "no limit" here: every pixel has d above it, so the plain comparison would mask the whole
// image rather than none of it. Defaults to a concrete value, which is where the detection normally
// stops - the direct beam and its halo sit beyond it.
std::optional<float> low_resolution_limit = 50.0;
float cutoff_spot_count_low_res = 5.0;
std::optional<float> high_res_gap_Q_recipA = 1.5; // 0.25 * 2 * pi
// Half-width of the ice-ring exclusion band in q (2*pi/d). Measured hexagonal-ice ring FWHM on the
// JUNGFRAU is ~0.06 q, so the band half-width is ~0.03; 0.02 under-covered the strong low-res rings.
float ice_ring_width_Q_recipA = 0.03;
// Powder rings measured from this run's own spots (ring centres in q = 2*pi/d), flagged alongside
// the fixed hexagonal-ice bands. Hexagonal ice is not the only thing that powders - a shower of
// microcrystals around the crystal, or salt out of the cryoprotectant, leaves the same textured
// rings at d-spacings no fixed list can name - and their spots are otherwise handed to the indexer
// as if they were this crystal's. Empty on a run that shows no such rings, which is nearly all of
// them; filled by the first pass only where excluding the rings is what lets the run index.
std::vector<float> measured_ring_q_recipA;
bool indexing = true;
bool quick_integration = true;
// Self-calibrating detection (offline/rugnux path): when true, the fixed photon_count_threshold is
// replaced by a per-resolution-ring threshold set from the image's own noise (see
// AdaptiveSpotFinderCPU), so the same setting adapts across datasets with no per-dataset tuning.
// false_pixels_per_frame is the one portable knob: the expected number of noise pixels tolerated
// per frame (the threshold's operating point), ~100 for a multi-megapixel detector.
bool adaptive_threshold = false;
float false_pixels_per_frame = 100.0f;
// Detector units per photon of the frames (BraggIntegrationSettings::pixel_gain, set from the same
// number): the adaptive threshold's Poisson arm and its one-photon floor are taken in photons.
// 1 for a detector that counts photons.
float adu_per_photon = 1.0f;
};