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>
193 lines
8.1 KiB
C++
193 lines
8.1 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <algorithm>
|
|
|
|
#include <catch2/catch_all.hpp>
|
|
|
|
#include "../common/AzimuthalIntegrationMapping.h"
|
|
#include "../image_analysis/spot_finding/AdaptiveSpotFinderCPU.h"
|
|
|
|
namespace {
|
|
|
|
SpotFindingSettings AdaptiveSettings() {
|
|
SpotFindingSettings s{};
|
|
s.adaptive_threshold = true;
|
|
s.false_pixels_per_frame = 100.0f;
|
|
s.min_pix_per_spot = 1;
|
|
s.max_pix_per_spot = 50;
|
|
s.high_resolution_limit = 0.0f;
|
|
s.low_resolution_limit = 1.0e6f;
|
|
s.high_res_gap_Q_recipA = std::nullopt;
|
|
return s;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// Raw (untransformed) geometry: the mapping is built over the raw module layout, which is smaller
|
|
// than the converted image. The finder has to walk the raw image, so a spot planted at a raw pixel
|
|
// comes back at that pixel.
|
|
TEST_CASE("AdaptiveSpotFinderCPU_RawGeometry", "[AdaptiveSpotFinder]") {
|
|
DiffractionExperiment x(DetJF4M());
|
|
x.DetectorDistance_mm(80).BeamX_pxl(1030).BeamY_pxl(1080);
|
|
x.QSpacingForAzimInt_recipA(0.05).QRangeForAzimInt_recipA(0.05, 5.0);
|
|
x.GeometryTransformation(false);
|
|
|
|
PixelMask pixel_mask(x);
|
|
AzimuthalIntegrationMapping mapping(x, pixel_mask);
|
|
|
|
const size_t w = x.GetXPixelsNum();
|
|
const size_t h = x.GetYPixelsNum();
|
|
REQUIRE(w * h == mapping.GetPixelToBin().size());
|
|
REQUIRE(w * h < static_cast<size_t>(x.GetPixelsNumConv()));
|
|
|
|
ImagePreprocessorBuffer buffer(x.GetPixelsNum());
|
|
for (size_t i = 0; i < w * h; i++)
|
|
buffer[i] = 8 + static_cast<int32_t>(i % 5); // background 8..12
|
|
|
|
// A 3x3 blob on a pixel that has a ring - with all of its neighbours on one too.
|
|
const auto &pixel_to_bin = mapping.GetPixelToBin();
|
|
size_t spot_row = 0, spot_col = 0;
|
|
for (size_t row = 100; row < h - 100 && spot_row == 0; row++) {
|
|
for (size_t col = 100; col < w - 100; col++) {
|
|
bool all_binned = true;
|
|
for (int dr = -1; dr <= 1; dr++)
|
|
for (int dc = -1; dc <= 1; dc++)
|
|
all_binned &= pixel_to_bin[(row + dr) * w + col + dc] != UINT16_MAX;
|
|
if (all_binned) {
|
|
spot_row = row;
|
|
spot_col = col;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
REQUIRE(spot_row > 0);
|
|
|
|
for (int dr = -1; dr <= 1; dr++)
|
|
for (int dc = -1; dc <= 1; dc++)
|
|
buffer[(spot_row + dr) * w + spot_col + dc] = 200;
|
|
|
|
std::vector<bool> res_mask(x.GetPixelsNum(), false);
|
|
AdaptiveSpotFinderCPU finder(mapping);
|
|
finder.SetResolutionMask(res_mask);
|
|
const auto spots = finder.Run(buffer, AdaptiveSettings());
|
|
|
|
REQUIRE(spots.size() == 1);
|
|
CHECK(std::lround(spots[0].RawCoord().x) == static_cast<long>(spot_col));
|
|
CHECK(std::lround(spots[0].RawCoord().y) == static_cast<long>(spot_row));
|
|
}
|
|
|
|
// The property the whole engine exists for: the threshold comes from the image's OWN noise, so the
|
|
// same settings behave the same way on a frame whose background is ten times higher. A frame is built
|
|
// with background spread S around a mean, one pixel planted a few S above it (must stay unfound) and
|
|
// one planted far above (must be found); then the identical frame scaled by ten must give the identical
|
|
// answer. Any threshold that does not track the background - a constant, or one that drops the sigma
|
|
// term - finds the weak pixel in the scaled frame, or loses the strong one.
|
|
TEST_CASE("AdaptiveSpotFinderCPU_ThresholdTracksBackground", "[AdaptiveSpotFinder]") {
|
|
DiffractionExperiment x(DetJF4M());
|
|
x.DetectorDistance_mm(80).BeamX_pxl(1030).BeamY_pxl(1080);
|
|
x.QSpacingForAzimInt_recipA(0.05).QRangeForAzimInt_recipA(0.05, 5.0);
|
|
x.GeometryTransformation(false);
|
|
|
|
PixelMask pixel_mask(x);
|
|
AzimuthalIntegrationMapping mapping(x, pixel_mask);
|
|
const auto &pixel_to_bin = mapping.GetPixelToBin();
|
|
|
|
const size_t w = x.GetXPixelsNum();
|
|
const size_t h = x.GetYPixelsNum();
|
|
|
|
// Two well-separated pixels that carry a ring, so both are seen by the finder.
|
|
std::vector<size_t> planted;
|
|
for (size_t row = 300; row < h - 300 && planted.size() < 2; row += 137)
|
|
for (size_t col = 300; col < w - 300; col += 149)
|
|
if (pixel_to_bin[row * w + col] != UINT16_MAX) {
|
|
planted.push_back(row * w + col);
|
|
break;
|
|
}
|
|
REQUIRE(planted.size() == 2);
|
|
|
|
// Background takes 5 evenly spaced levels one S apart, i.e. mean + 2S and sigma = sqrt(2) S. With
|
|
// ~100 expected noise pixels per frame the cut lands near mean + 4.1 sigma = mean + 5.8 S.
|
|
const auto run_at_scale = [&](int32_t scale) {
|
|
ImagePreprocessorBuffer buffer(x.GetPixelsNum());
|
|
for (size_t i = 0; i < w * h; i++)
|
|
buffer[i] = scale * (10 + static_cast<int32_t>(i % 5));
|
|
buffer[planted[0]] = scale * (10 + 5); // mean + 3 S: below the cut
|
|
buffer[planted[1]] = scale * (10 + 30); // mean + 28 S: well above it
|
|
std::vector<bool> res_mask(x.GetPixelsNum(), false);
|
|
AdaptiveSpotFinderCPU finder(mapping);
|
|
finder.SetResolutionMask(res_mask);
|
|
return finder.Run(buffer, AdaptiveSettings());
|
|
};
|
|
|
|
const auto plain = run_at_scale(1);
|
|
const auto scaled = run_at_scale(10);
|
|
|
|
REQUIRE(plain.size() == 1);
|
|
CHECK(std::lround(plain[0].RawCoord().x) == static_cast<long>(planted[1] % w));
|
|
CHECK(std::lround(plain[0].RawCoord().y) == static_cast<long>(planted[1] / w));
|
|
|
|
// Ten times the background, ten times the noise, ten times the signal - same answer.
|
|
REQUIRE(scaled.size() == plain.size());
|
|
CHECK(std::lround(scaled[0].RawCoord().x) == std::lround(plain[0].RawCoord().x));
|
|
CHECK(std::lround(scaled[0].RawCoord().y) == std::lround(plain[0].RawCoord().y));
|
|
}
|
|
|
|
// The per-ring background the finder hands out is the peak-EXCLUDED one: a handful of very bright
|
|
// pixels planted on a ring must not move it, which is the property that lets the ice score be read
|
|
// off it instead of off the plain azimuthal profile. Rings with too few pixels to be their own
|
|
// background come back as NaN rather than as a stale value from the previous frame.
|
|
TEST_CASE("AdaptiveSpotFinderCPU_RingBackgroundExcludesPeaks", "[AdaptiveSpotFinder]") {
|
|
DiffractionExperiment x(DetJF4M());
|
|
x.DetectorDistance_mm(80).BeamX_pxl(1030).BeamY_pxl(1080);
|
|
x.QSpacingForAzimInt_recipA(0.05).QRangeForAzimInt_recipA(0.05, 5.0);
|
|
x.GeometryTransformation(false);
|
|
|
|
PixelMask pixel_mask(x);
|
|
AzimuthalIntegrationMapping mapping(x, pixel_mask);
|
|
const auto &pixel_to_bin = mapping.GetPixelToBin();
|
|
|
|
const size_t w = x.GetXPixelsNum();
|
|
const size_t h = x.GetYPixelsNum();
|
|
|
|
ImagePreprocessorBuffer buffer(x.GetPixelsNum());
|
|
for (size_t i = 0; i < w * h; i++)
|
|
buffer[i] = 8 + static_cast<int32_t>(i % 5); // background 8..12
|
|
|
|
std::vector<bool> res_mask(x.GetPixelsNum(), false);
|
|
AdaptiveSpotFinderCPU finder(mapping);
|
|
finder.SetResolutionMask(res_mask);
|
|
|
|
finder.Detect(buffer, AdaptiveSettings());
|
|
const std::vector<float> clean = finder.GetRingBackground();
|
|
REQUIRE(clean.size() == mapping.GetBinNumber());
|
|
|
|
std::vector<int64_t> pixels_per_bin(mapping.GetBinNumber(), 0);
|
|
for (size_t i = 0; i < w * h; i++)
|
|
if (pixel_to_bin[i] < mapping.GetBinNumber())
|
|
pixels_per_bin[pixel_to_bin[i]]++;
|
|
|
|
// Plant 200 bright pixels spread over one well-populated ring - far more than a real spot, so a
|
|
// plain mean would move visibly.
|
|
const uint16_t ring = static_cast<uint16_t>(
|
|
std::max_element(pixels_per_bin.begin(), pixels_per_bin.end()) - pixels_per_bin.begin());
|
|
REQUIRE(pixels_per_bin[ring] > 10000);
|
|
int planted = 0;
|
|
for (size_t i = 0; i < w * h && planted < 200; i++)
|
|
if (pixel_to_bin[i] == ring) {
|
|
buffer[i] = 100000;
|
|
planted++;
|
|
}
|
|
REQUIRE(planted == 200);
|
|
|
|
finder.Detect(buffer, AdaptiveSettings());
|
|
const std::vector<float> spiked = finder.GetRingBackground();
|
|
|
|
REQUIRE(std::isfinite(clean[ring]));
|
|
REQUIRE(std::isfinite(spiked[ring]));
|
|
CHECK(spiked[ring] == Catch::Approx(clean[ring]).epsilon(0.01));
|
|
|
|
for (size_t b = 0; b < clean.size(); b++)
|
|
CHECK(std::isfinite(clean[b]) == (pixels_per_bin[b] >= 40));
|
|
}
|