The per-image ice score was read off the PLAIN azimuthal profile. That profile is a per-ring mean, so a few strong Bragg reflections landing in a ring's q bin lift it exactly as ice would. Measured over 37 rotation crystals, that did not merely add noise - it INVERTED the metric: the two highest-scoring crystals had no ice at all (4.23 and 4.06), while a clean control read 1.57. A decoy null - the identical statistic evaluated at q positions where hexagonal ice cannot be - reaches 1.51 at its 99th percentile and 2.70 at its maximum, so that metric cannot support any absolute threshold whatsoever. The adaptive spot finder already computes the right input for its own threshold: a sigma-clipped per-resolution-ring background, in the same bins. A powder ring is azimuthally smooth and survives the clip; Bragg peaks do not. On the clipped profile the clean population tightens to 1.00-1.22 and the crystals with confirmed ice sit at 2.08-2.37, against a decoy null that never exceeds 1.29. That channel is blind to one thing: ice in large crystallites diffracts as DISCRETE spots and leaves the radial profile flat. So a second channel counts found spots on the rings against the same q width of ice-free flanks beside them. The two barely overlap - the smooth-ice crystals read 2.1-2.4 / ~1.0 and the textured ones ~1.1 / 3.8-17.6, while a clean crystal reads 1.04 on both. Both are then used as a GATE (--ice-min-score 1.5, --ice-min-spot-ratio 2.0, both calibrated on the battery, 0 disables): the eleven fixed hexagonal bands cover 16-26 % of the unique reflections at typical resolutions whether or not the crystal has ice, so flagging, the exclusion from the scale fit and the merge-time CC1/2 ring mask are now all skipped when neither channel sees any. The gate is applied in the full pipeline and in --scale, which reads the stored per-image values back out of the _process.h5. Also fixes the merge-time mask's control: the shoulder now excludes reflections that are themselves on an ice ring. The rings are not evenly spaced - 1.947/1.916/1.882 A sit 0.05-0.06 apart in q - so for those three the [w,3w) shoulder landed squarely on the neighbours and the test compared ice against ice. Measured, that is the only thing this changes: it removes firings on those three rings and leaves every other firing's CC pair identical to three decimals. And the online ice half-width, which was 0.02 in the API against 0.03 offline, so the same data got a narrower band online than the measured ~0.06 ring FWHM justifies. Battery (37 rotation crystals, against the previous behaviour): space groups 34/37 in both and NO crystal's space group changes; 6 crystals gain unique reflections, 1 loses. Best of them gains 7082 unique reflections with R_meas 16.0 -> 14.3, CC1/2 95.9 -> 97.3 and ISa 13.7 -> 19.0; another goes R_meas 54.9 -> 42.9, CC1/2 84.0 -> 90.4, ISa 3.9 -> 5.5; a third reaches CC1/2 99.4 from 95.7 at an unchanged reflection count. The one crystal that loses reflections improves on both R_meas and CC1/2. Not done here: the ScanResult/API/plot-type/frontend/viewer layers for the new spot_count_ice_control (they need the OpenAPI regeneration). Message, CBOR, HDF5 write/read and the receiver plots are. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
70 lines
3.9 KiB
C++
70 lines
3.9 KiB
C++
// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#pragma once
|
|
|
|
#include <cstdint>
|
|
#include <vector>
|
|
|
|
#include "../../common/DiffractionSpot.h"
|
|
|
|
#include "../image_preprocessing/ImagePreprocessorBuffer.h"
|
|
|
|
class ImageSpotFinder {
|
|
// Flat index of every strong pixel of the current image that passed the resolution mask, and its
|
|
// value. Kept as members only to reuse the allocation from image to image.
|
|
std::vector<uint32_t> strong_pixel;
|
|
std::vector<int32_t> strong_pixel_value;
|
|
protected:
|
|
const int32_t width, height;
|
|
std::vector<uint32_t> output_buffer;
|
|
// Pixels excluded from spot finding, packed the same way as output_buffer (bit set = excluded).
|
|
// The bits past the last image pixel are set at construction, so the padding of the last word
|
|
// needs no separate guard. Default: nothing excluded.
|
|
std::vector<uint32_t> res_mask_bits;
|
|
// The connected components of the last extraction. A member so ExtractComponents can hand out a
|
|
// reference and reuse the allocation from image to image.
|
|
std::vector<DiffractionSpot> components;
|
|
|
|
// host_bit_buffer = false leaves output_buffer empty: the GPU finders extract on the device and
|
|
// never read the bit buffer on the host, so allocating and pinning 2.26 MB per engine (at 18 MP)
|
|
// would be pure waste for them.
|
|
ImageSpotFinder(int32_t width, int32_t height, bool host_bit_buffer = true);
|
|
size_t OutputSize() const;
|
|
size_t OutputByteSize() const;
|
|
// Host extraction: scan the bit buffer, gather the values, run the connected-component search.
|
|
void ExtractComponentsHost(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings);
|
|
public:
|
|
constexpr static int32_t MIN_VALID_PIXELS = 100;
|
|
constexpr static int NBX = 15;
|
|
|
|
virtual ~ImageSpotFinder() = default;
|
|
|
|
// Detect flags the image's strong pixels into the internal bit buffer - the expensive step (local
|
|
// box or per-ring background over every pixel). ExtractComponents then builds the connected
|
|
// components from those pixels.
|
|
virtual void Detect(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings) = 0;
|
|
|
|
// Peak-excluded per-ring background of the last Detect(), in the bins of the azimuthal-integration
|
|
// mapping and in raw photon counts. Only the adaptive finders build one (it is what sets their
|
|
// threshold); empty for everyone else, and for a frame with nothing valid to reduce.
|
|
[[nodiscard]] virtual const std::vector<float> &GetRingBackground() const;
|
|
|
|
// Pixels to ignore, one bool per pixel (true = ignore). Set when the resolution limits change,
|
|
// not per image: the GPU finders keep a bit-packed device copy of it, and re-uploading that for
|
|
// every image would cost more than the extraction it feeds.
|
|
virtual void SetResolutionMask(const std::vector<bool> &mask);
|
|
|
|
// Every connected component of the last Detect() with at most max-pix pixels. min-pix is NOT
|
|
// applied here on purpose - it is the only spot setting that changes between the passes of the
|
|
// per-image min-pix search, so ONE extraction serves all three of them.
|
|
virtual const std::vector<DiffractionSpot> &ExtractComponents(const ImagePreprocessorBuffer &image,
|
|
const SpotFindingSettings &settings);
|
|
// The components that also pass min-pix.
|
|
static std::vector<DiffractionSpot> Filter(const std::vector<DiffractionSpot> &in,
|
|
const SpotFindingSettings &settings);
|
|
|
|
std::vector<DiffractionSpot> ExtractSpots(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings);
|
|
std::vector<DiffractionSpot> Run(const ImagePreprocessorBuffer &image, const SpotFindingSettings &settings);
|
|
};
|