The existing cases plant blobs at 200 on a background of 8..12, so any threshold between 12 and 200 passes them - replacing RingThreshold with a constant leaves them all green. Two cases that do not: - the CPU threshold has to track the background: a frame and the same frame scaled ten times must give the same spots, with a pixel a few sigma above the background staying unfound in both. A constant threshold, or one that drops the sigma term, fails one scale or the other. - the GPU engine has to agree with itself across runs, which is what the ring sums being order-independent buys. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
131 lines
5.6 KiB
C++
131 lines
5.6 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#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);
|
|
const auto spots = finder.Run(buffer, AdaptiveSettings(), res_mask);
|
|
|
|
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);
|
|
return finder.Run(buffer, AdaptiveSettings(), res_mask);
|
|
};
|
|
|
|
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));
|
|
}
|