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)
113 lines
7.2 KiB
C++
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;
|
|
};
|