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>
232 lines
9.7 KiB
C++
232 lines
9.7 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/CUDAWrapper.h"
|
|
|
|
#ifdef JFJOCH_USE_CUDA
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
|
|
#include "../common/AzimuthalIntegrationMapping.h"
|
|
#include "../common/AzimuthalIntegrationProfile.h"
|
|
#include "../image_analysis/azint/AzIntEngineGPU.h"
|
|
#include "../image_analysis/spot_finding/AdaptiveSpotFinderCPU.h"
|
|
#include "../image_analysis/spot_finding/AdaptiveSpotFinderGPU.h"
|
|
#include "../image_analysis/spot_finding/ImageSpotFinderGPU.h"
|
|
#include "../image_analysis/image_preprocessing/ImagePreprocessorBufferGPU.h"
|
|
|
|
namespace {
|
|
|
|
// Build a realistic full-detector azimuthal-integration mapping (JF4M, ~4.5 MP) whose q-range spans
|
|
// most of the detector, so the timing runs over a representative pixel count.
|
|
DiffractionExperiment MakeExperiment() {
|
|
DiffractionExperiment x(DetJF4M());
|
|
x.DetectorDistance_mm(80).BeamX_pxl(1030).BeamY_pxl(1080);
|
|
x.QSpacingForAzimInt_recipA(0.05).QRangeForAzimInt_recipA(0.05, 5.0);
|
|
return x;
|
|
}
|
|
|
|
// Deterministic image: a low, slightly rippled background (well below any adaptive threshold) plus a
|
|
// grid of bright multi-pixel blobs that both finders must recover identically.
|
|
void FillTestImage(ImagePreprocessorBuffer &buffer, const DiffractionExperiment &x) {
|
|
const size_t w = x.GetXPixelsNum();
|
|
const size_t h = x.GetYPixelsNum();
|
|
for (size_t i = 0; i < w * h; i++)
|
|
buffer[i] = 8 + static_cast<int32_t>(i % 5); // background 8..12 (mean 10)
|
|
|
|
// Bright 3x3 blobs on a coarse grid, kept clear of the edges and the beam centre.
|
|
for (size_t row = 300; row < h - 300; row += 450) {
|
|
for (size_t col = 300; col < w - 300; col += 450) {
|
|
for (int dr = -1; dr <= 1; dr++)
|
|
for (int dc = -1; dc <= 1; dc++)
|
|
buffer[(row + dr) * w + (col + dc)] = 200;
|
|
}
|
|
}
|
|
}
|
|
|
|
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; // no resolution gate for the parity test
|
|
s.low_resolution_limit = 1.0e6f;
|
|
s.high_res_gap_Q_recipA = std::nullopt;
|
|
return s;
|
|
}
|
|
|
|
std::vector<std::pair<int, int>> SortedCoords(const std::vector<DiffractionSpot> &spots) {
|
|
std::vector<std::pair<int, int>> out;
|
|
out.reserve(spots.size());
|
|
for (const auto &s : spots)
|
|
out.emplace_back(static_cast<int>(std::lround(s.RawCoord().y)),
|
|
static_cast<int>(std::lround(s.RawCoord().x)));
|
|
std::sort(out.begin(), out.end());
|
|
return out;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// Spot-finding functionality: the fused GPU engine must reproduce the reference CPU adaptive finder's
|
|
// spot list. The two share AdaptiveThreshold.h and the host connected-component extractor, and both
|
|
// sum the rings in double, so the only difference left is the order the ring sums are accumulated in.
|
|
TEST_CASE("AdaptiveSpotFinderGPU_SpotFindingParity", "[AdaptiveSpotFinderGPU]") {
|
|
if (get_gpu_count() == 0) {
|
|
WARN("No CUDA GPU present. Skipping AdaptiveSpotFinderGPU_SpotFindingParity");
|
|
return;
|
|
}
|
|
|
|
DiffractionExperiment x = MakeExperiment();
|
|
PixelMask pixel_mask(x);
|
|
AzimuthalIntegrationMapping mapping(x, pixel_mask);
|
|
|
|
ImagePreprocessorBufferGPU buffer(x.GetPixelsNum());
|
|
FillTestImage(buffer, x);
|
|
REQUIRE(cudaMemcpy(buffer.getGPUBuffer(), buffer.getBuffer().data(),
|
|
x.GetPixelsNum() * sizeof(int32_t), cudaMemcpyHostToDevice) == cudaSuccess);
|
|
|
|
std::vector<bool> res_mask(x.GetPixelsNum(), false);
|
|
const SpotFindingSettings settings = AdaptiveSettings();
|
|
|
|
AdaptiveSpotFinderCPU cpu(mapping);
|
|
auto stream = std::make_shared<CudaStream>();
|
|
AdaptiveSpotFinderGPU gpu(mapping, stream);
|
|
|
|
const auto cpu_spots = cpu.Run(buffer, settings, res_mask);
|
|
const auto gpu_spots = gpu.Run(buffer, settings, res_mask);
|
|
|
|
INFO("cpu spots=" << cpu_spots.size() << " gpu spots=" << gpu_spots.size());
|
|
REQUIRE(cpu_spots.size() > 0);
|
|
REQUIRE(cpu_spots.size() == gpu_spots.size());
|
|
CHECK(SortedCoords(cpu_spots) == SortedCoords(gpu_spots));
|
|
}
|
|
|
|
// Azimuthal-integration functionality: the profile the fused engine computes as a byproduct of the
|
|
// same pass must match a standalone GPU azimuthal integrator over the same image.
|
|
TEST_CASE("AdaptiveSpotFinderGPU_AzimuthalIntegration", "[AdaptiveSpotFinderGPU]") {
|
|
if (get_gpu_count() == 0) {
|
|
WARN("No CUDA GPU present. Skipping AdaptiveSpotFinderGPU_AzimuthalIntegration");
|
|
return;
|
|
}
|
|
|
|
DiffractionExperiment x = MakeExperiment();
|
|
PixelMask pixel_mask(x);
|
|
AzimuthalIntegrationMapping mapping(x, pixel_mask);
|
|
|
|
ImagePreprocessorBufferGPU buffer(x.GetPixelsNum());
|
|
FillTestImage(buffer, x);
|
|
REQUIRE(cudaMemcpy(buffer.getGPUBuffer(), buffer.getBuffer().data(),
|
|
x.GetPixelsNum() * sizeof(int32_t), cudaMemcpyHostToDevice) == cudaSuccess);
|
|
|
|
std::vector<bool> res_mask(x.GetPixelsNum(), false);
|
|
const SpotFindingSettings settings = AdaptiveSettings();
|
|
|
|
auto stream = std::make_shared<CudaStream>();
|
|
AdaptiveSpotFinderGPU gpu(mapping, stream);
|
|
gpu.Run(buffer, settings, res_mask);
|
|
|
|
AzIntEngineGPU azint(mapping, stream);
|
|
AzimuthalIntegrationProfile ref_profile(mapping);
|
|
azint.Run(buffer, ref_profile);
|
|
|
|
const auto ref = ref_profile.GetResult();
|
|
const auto got = gpu.GetProfile().GetResult();
|
|
const auto ref_count = ref_profile.GetPixelCount();
|
|
const auto got_count = gpu.GetProfile().GetPixelCount();
|
|
REQUIRE(ref.size() == got.size());
|
|
REQUIRE(ref_count == got_count); // identical per-ring pixel counts (same valid-pixel binning)
|
|
for (size_t b = 0; b < ref.size(); b++) {
|
|
if (std::isnan(ref[b])) {
|
|
CHECK(std::isnan(got[b]));
|
|
} else {
|
|
CHECK(got[b] == Catch::Approx(ref[b]).epsilon(0.01).margin(0.02));
|
|
}
|
|
}
|
|
}
|
|
|
|
// The ring sums are built by atomics, which arrive in an arbitrary order, so the same frame has to be
|
|
// re-run to show the engine agrees with itself: detection is a hard "value >= threshold" on integer
|
|
// counts, and a threshold that wobbles between runs flips pixels on the boundary and with them the size
|
|
// of a connected component. Two runs, same spot list.
|
|
TEST_CASE("AdaptiveSpotFinderGPU_RunToRunReproducible", "[AdaptiveSpotFinderGPU]") {
|
|
if (get_gpu_count() == 0) {
|
|
WARN("No CUDA GPU present. Skipping AdaptiveSpotFinderGPU_RunToRunReproducible");
|
|
return;
|
|
}
|
|
|
|
DiffractionExperiment x = MakeExperiment();
|
|
PixelMask pixel_mask(x);
|
|
AzimuthalIntegrationMapping mapping(x, pixel_mask);
|
|
|
|
ImagePreprocessorBufferGPU buffer(x.GetPixelsNum());
|
|
FillTestImage(buffer, x);
|
|
REQUIRE(cudaMemcpy(buffer.getGPUBuffer(), buffer.getBuffer().data(),
|
|
x.GetPixelsNum() * sizeof(int32_t), cudaMemcpyHostToDevice) == cudaSuccess);
|
|
|
|
std::vector<bool> res_mask(x.GetPixelsNum(), false);
|
|
const SpotFindingSettings settings = AdaptiveSettings();
|
|
|
|
auto stream = std::make_shared<CudaStream>();
|
|
AdaptiveSpotFinderGPU gpu(mapping, stream);
|
|
|
|
const auto first = gpu.Run(buffer, settings, res_mask);
|
|
REQUIRE(first.size() > 0);
|
|
for (int repeat = 0; repeat < 4; repeat++) {
|
|
const auto again = gpu.Run(buffer, settings, res_mask);
|
|
REQUIRE(again.size() == first.size());
|
|
REQUIRE(SortedCoords(again) == SortedCoords(first));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("AdaptiveSpotFinderGPU_Speed", "[AdaptiveSpotFinderGPU][.benchmark]") {
|
|
if (get_gpu_count() == 0) {
|
|
WARN("No CUDA GPU present. Skipping AdaptiveSpotFinderGPU_Speed");
|
|
return;
|
|
}
|
|
|
|
DiffractionExperiment x = MakeExperiment();
|
|
PixelMask pixel_mask(x);
|
|
AzimuthalIntegrationMapping mapping(x, pixel_mask);
|
|
|
|
ImagePreprocessorBufferGPU buffer(x.GetPixelsNum());
|
|
FillTestImage(buffer, x);
|
|
REQUIRE(cudaMemcpy(buffer.getGPUBuffer(), buffer.getBuffer().data(),
|
|
x.GetPixelsNum() * sizeof(int32_t), cudaMemcpyHostToDevice) == cudaSuccess);
|
|
|
|
std::vector<bool> res_mask(x.GetPixelsNum(), false);
|
|
const SpotFindingSettings settings = AdaptiveSettings();
|
|
|
|
auto stream = std::make_shared<CudaStream>();
|
|
AdaptiveSpotFinderCPU cpu(mapping);
|
|
AdaptiveSpotFinderGPU gpu_fused(mapping, stream);
|
|
ImageSpotFinderGPU gpu_classic(x.GetXPixelsNum(), x.GetYPixelsNum(), stream);
|
|
AzIntEngineGPU azint(mapping, stream);
|
|
AzimuthalIntegrationProfile profile(mapping);
|
|
|
|
const int warmup = 5, iters = 40;
|
|
auto bench = [&](const char *name, auto &&fn) {
|
|
for (int i = 0; i < warmup; i++) fn();
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
for (int i = 0; i < iters; i++) fn();
|
|
const auto t1 = std::chrono::steady_clock::now();
|
|
const double ms = std::chrono::duration<double, std::milli>(t1 - t0).count() / iters;
|
|
WARN(name << ": " << ms << " ms/frame");
|
|
return ms;
|
|
};
|
|
|
|
const double t_azint = bench("GPU azint (standalone)", [&] { azint.Run(buffer, profile); });
|
|
const double t_cpu = bench("CPU adaptive spot finding", [&] { cpu.Run(buffer, settings, res_mask); });
|
|
const double t_classic = bench("GPU classic spot finding (local-box)", [&] { gpu_classic.Run(buffer, settings, res_mask); });
|
|
const double t_fused = bench("GPU adaptive FUSED (azint + spot finding)", [&] { gpu_fused.Run(buffer, settings, res_mask); });
|
|
|
|
WARN("standard adaptive path (GPU azint + CPU adaptive) = " << (t_azint + t_cpu)
|
|
<< " ms/frame vs fused GPU = " << t_fused << " ms/frame (speedup "
|
|
<< (t_azint + t_cpu) / t_fused << "x)");
|
|
WARN("fused GPU vs GPU classic finder alone (no azint): " << t_fused << " vs " << t_classic << " ms/frame");
|
|
}
|
|
|
|
#endif
|