Files
Jungfraujoch/tests/AdaptiveSpotFinderGPUTest.cpp
T
leonarski_fandClaude Opus 4.8 9fdeed282a Add fused GPU adaptive spot finder (azint + spot finding in one pass)
AdaptiveSpotFinderGPU does the per-resolution-ring reduction once on the GPU and
drives both products from it: the azimuthal-integration profile (corrected space)
and the self-calibrating adaptive spot-detection threshold (raw counts). This
replaces the separate GPU azint pass and the host-side adaptive spot finder that
runs on the GPU path today. On a ~4.5 MP detector it does both jobs in ~1 ms/frame
versus ~40 ms for the CPU adaptive finder (~42x), with an identical spot list and
azimuthal profile.

The per-ring threshold math (Poisson tail + read-floored Gaussian, operating point
from the false-pixels-per-frame knob) is factored into AdaptiveThreshold.h so the
CPU and GPU finders share one source of truth and cannot drift.

Wired opt-in via a MXAnalysisWithoutFPGA constructor flag, default on for the rugnux
offline path and the interactive viewer, off for the online receiver (so the broker
path is unchanged). When on, Analyze() skips the separate azint pass and lifts the
profile from the fused engine. The viewer gains an "Adaptive threshold" checkbox that
greys out the signal/noise and photon-count sliders (the adaptive finder uses neither).

Dedicated tests exercise both products (spot-finding parity vs the CPU finder,
azimuthal profile vs a standalone GPU azint) plus a speed benchmark. Validated
end-to-end on lysozyme serial stills: fused == CPU-adaptive index rate and merge stats.

Docs: new section 3.2 in docs/CPU_DATA_ANALYSIS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:10:45 +02:00

198 lines
8.2 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; the only
// difference is the GPU's float atomic ring reduction, which is exact for a realistic background).
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));
}
}
}
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