// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include #include #include #include "../image_analysis/geom_refinement/BeamCenterFFT.h" namespace { // An isotropic scattered background about (cx, cy): a decaying continuum with a solvent-ring bump, // the mean of ~60 frames' worth of counting noise on top, NAN outside the detector's usable area. // A horizontal dead stripe stands in for a module gap. std::vector Synthesise(int64_t w, int64_t h, float cx, float cy, float shadow_sector) { std::vector mean(static_cast(w * h), NAN); std::mt19937 rng(20260912); std::normal_distribution gauss(0.0f, 1.0f); for (int64_t y = 0; y < h; y++) { if (y >= 300 && y < 320) continue; // module gap for (int64_t x = 0; x < w; x++) { const float r = std::hypot(static_cast(x) - cx, static_cast(y) - cy); const float t = (r - 260.0f) / 30.0f; float value = 120.0f * std::exp(-r / 220.0f) + 45.0f * std::exp(-0.5f * t * t) + 5.0f; const float phi = std::atan2(static_cast(y) - cy, static_cast(x) - cx); if (phi > 0.0f && phi < 1.0f) value *= shadow_sector; mean[static_cast(y * w + x)] = value + gauss(rng) * std::sqrt(value / 60.0f); } } return mean; } } // namespace TEST_CASE("BeamCenterFFT_SyntheticBackground", "[BeamCenter]") { // The centre is nowhere near the image middle - the score must not care. const int64_t w = 900, h = 700; const float cx = 297.25f, cy = 411.5f; SECTION("clean isotropic background") { const auto mean = Synthesise(w, h, cx, cy, 1.0f); const auto r = BeamCenterFFTScore(w, h, mean); REQUIRE(!r.point.empty()); REQUIRE(!r.line_x.empty()); REQUIRE(!r.line_y.empty()); // Whole-detector capture: the strongest point-symmetry centre is the true one. CHECK(std::hypot(r.point[0].beam_x_pxl - cx, r.point[0].beam_y_pxl - cy) < 2.0f); CHECK(std::abs(r.line_x[0].beam_x_pxl - cx) < 2.0f); CHECK(std::abs(r.line_y[0].beam_y_pxl - cy) < 2.0f); // A clean unimodal surface separates its argmax from the runner-up by a wide margin. CHECK(r.margin_point > 0.05f); } SECTION("a shadowed sextant keeps the true centre in the shortlist") { const auto mean = Synthesise(w, h, cx, cy, 0.5f); const auto r = BeamCenterFFTScore(w, h, mean); REQUIRE(!r.point.empty()); float best = 1e9f; for (const auto &c : r.point) best = std::min(best, std::hypot(c.beam_x_pxl - cx, c.beam_y_pxl - cy)); // The argmax is pulled by the asymmetry (measured ~9 px here - the same shadow bias the // background estimator carries); the shortlist must still hold the answer within the // 12 px a re-indexing verifier accepts. CHECK(best < 12.0f); } }