Files
Jungfraujoch/tests/BraggStencilTest.cpp
leonarski_f a395f358ef
Build Packages / Create release (push) Successful in 17s
Build Packages / build:viewer:macos-arm64:nocuda (push) Successful in 3m22s
Build Packages / build:rugnux:macos-arm64:nocuda (push) Successful in 2m37s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m33s
Build Packages / build:rugnux:linux-x86_64:cuda (push) Successful in 10m39s
Build Packages / build:viewer:linux-x86_64:nocuda (push) Successful in 11m4s
Build Packages / build:viewer:linux-x86_64:cuda (push) Successful in 13m19s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 17m37s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 18m49s
Build Packages / build:viewer:windows-x86_64:nocuda (push) Successful in 19m10s
Build Packages / build:viewer:windows-x86_64:cuda (push) Successful in 24m26s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m31s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 18m54s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 20m45s
Build Packages / Generate python client (push) Successful in 37s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 20m20s
Build Packages / Build documentation (push) Successful in 1m32s
Build Packages / build:rugnux:windows-x86_64:cuda (push) Successful in 14m37s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 21m6s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 19m49s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 20m29s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 17m2s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 14m27s
Build Packages / Unit tests (push) Successful in 1h18m12s
1.0.0-rc.174 (#84)
* Rugnux: Performance improvements on GPU and CPU (more of the pre-scan and of scaling on the GPU, faster CPU spot finding and crystal refinement), with unchanged results.
* Rugnux: More robust processing - patches of persistently hot pixels are masked, an inconsistent merge triggers a retry at the measured beam centre, and builds targeting different CPU levels give the same results.
* Rugnux: Improved scaling and merging - reflections with an overloaded pixel are dropped, as in XDS, sparse rotation sweeps are scaled more reliably, and French-Wilson amplitudes use an anisotropic Wilson prior.
* Rugnux: Improved space-group determination - glide planes in groups without a centre of symmetry, screw axes from short or weak axial rows kept when a higher group is adopted, and more reliable decisions on twinned and pseudo-symmetric crystals.
* Rugnux: Improved small-molecule processing - spots that grow wider than the integration disk and split spots are integrated over their measured footprint, sparse lattices are integrated on every frame, and the `.hkl` file holds unmerged scaled reflections (SHELX HKLF 4).
* Rugnux: Reads Rigaku d*TREK SMV images (Saturn CCD), including detector 2theta and encoded pixel overflows; home-source (rotating-anode) datasets were added to the validation battery.
* jfjoch_viewer: Fixed processing failing at the end with "Wrong JPEG library version" on Linux; the merge window shows the space group with proper subscripts and a checklist of crystal pathologies.

Reviewed-on: #84
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-10-06 14:03:18 +02:00

228 lines
11 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <cmath>
#include "../image_analysis/bragg_integration/BraggStencil.h"
namespace {
BraggStencilParams Params(float k_sigma, float bw_sigma = 0.002f) {
BraggStencilParams p;
p.beam_x = 400.0f;
p.beam_y = 400.0f;
p.r2 = 6.0f;
p.r3 = 10.0f;
p.bw_sigma = bw_sigma;
p.k_sigma = k_sigma;
p.max_grow = 2.0f * p.r3;
return p;
}
} // namespace
// The whole change rests on this: with no elongation asked for, the three squared distances the
// integrator tests against must be the SAME BITS as the plain circular distance used before, so
// that every pixel is classified exactly as it was, not merely nearly.
TEST_CASE("BraggStencil_ZeroElongationIsExactlyCircular", "[Integration]") {
const BraggStencilParams p = Params(0.0f); // a bandwidth, but k_sigma = 0
for (float py = 0.0f; py < 800.0f; py += 37.0f)
for (float px = 0.0f; px < 800.0f; px += 41.0f) {
const BraggStencil s = MakeBraggStencil(px, py, p);
REQUIRE(s.q_in == 0.0f);
REQUIRE(s.q_out == 0.0f);
for (int dy = -12; dy <= 12; ++dy)
for (int dx = -12; dx <= 12; ++dx) {
const auto d = BraggStencilDistances(s, static_cast<float>(dx), static_cast<float>(dy));
const float circular = static_cast<float>(dx) * dx + static_cast<float>(dy) * dy;
REQUIRE(d.signal == circular);
REQUIRE(d.inner == circular);
REQUIRE(d.outer == circular);
}
}
}
// A monochromatic beam has no streak, so nothing is elongated whatever k_sigma says - which is what
// makes the feature inert on every monochromatic dataset rather than merely small.
TEST_CASE("BraggStencil_MonochromaticIsInert", "[Integration]") {
const BraggStencilParams p = Params(4.0f, 0.0f);
for (float py = 0.0f; py < 800.0f; py += 53.0f)
for (float px = 0.0f; px < 800.0f; px += 59.0f) {
const BraggStencil s = MakeBraggStencil(px, py, p);
REQUIRE(s.grow == 0.0f);
REQUIRE(s.q_in == 0.0f);
REQUIRE(s.q_out == 0.0f);
}
}
// The elongated region really is the ellipse it claims: radial semi-axis r + grow, tangential r.
TEST_CASE("BraggStencil_ElongatedSemiAxes", "[Integration]") {
const BraggStencilParams p = Params(3.0f);
for (float py = 120.0f; py < 800.0f; py += 91.0f)
for (float px = 120.0f; px < 800.0f; px += 97.0f) {
const BraggStencil s = MakeBraggStencil(px, py, p);
const float grow = s.grow;
REQUIRE(grow > 0.0f);
REQUIRE(grow <= p.max_grow);
REQUIRE(s.grow == BraggStencilGrow_px(s.r0, p)); // the kernel table indexes on this
// On the radial axis the inner boundary sits at r2 + grow, the outer at r3 + grow.
const auto rad_in = BraggStencilDistances(s, (p.r2 + grow) * s.ux, (p.r2 + grow) * s.uy);
const auto rad_out = BraggStencilDistances(s, (p.r3 + grow) * s.ux, (p.r3 + grow) * s.uy);
CHECK_THAT(rad_in.inner, Catch::Matchers::WithinRel(p.r2 * p.r2, 1e-4f));
CHECK_THAT(rad_out.outer, Catch::Matchers::WithinRel(p.r3 * p.r3, 1e-4f));
// Across it, at the untouched tangential half-widths r2 and r3. Testing on the exact
// tangential axis would be a tautology - rad is 0 there, so q never enters - so the
// point that matters is that the SAME offset is inside the region radially and outside
// it tangentially. That is the anisotropy, and it fails if q is built from the wrong
// radius or from a constant.
const float probe = p.r2 + 0.5f * grow;
const auto radial_probe = BraggStencilDistances(s, probe * s.ux, probe * s.uy);
const auto tangent_probe = BraggStencilDistances(s, -probe * s.uy, probe * s.ux);
CHECK(radial_probe.inner < p.r2 * p.r2); // still signal, the ring starts further out
CHECK(tangent_probe.inner > p.r2 * p.r2); // already background across the streak
const auto tan_in = BraggStencilDistances(s, -p.r2 * s.uy, p.r2 * s.ux);
const auto tan_out = BraggStencilDistances(s, -p.r3 * s.uy, p.r3 * s.ux);
CHECK_THAT(tan_in.inner, Catch::Matchers::WithinRel(p.r2 * p.r2, 1e-4f));
CHECK_THAT(tan_out.outer, Catch::Matchers::WithinRel(p.r3 * p.r3, 1e-4f));
}
}
// The bounding boxes the engines scan must contain the regions they classify - a box one pixel too
// small silently drops background pixels on one side of every reflection, which no parity test
// between two engines making the same mistake would catch.
TEST_CASE("BraggStencil_BoundingBoxContainsRegion", "[Integration]") {
const BraggStencilParams p = Params(3.0f);
const float r2_sq = p.r2 * p.r2, r3_sq = p.r3 * p.r3;
for (float py = 0.0f; py < 800.0f; py += 53.0f)
for (float px = 0.0f; px < 800.0f; px += 59.0f) {
const BraggStencil s = MakeBraggStencil(px, py, p);
const int span = static_cast<int>(std::ceil(p.r3 + p.max_grow)) + 4;
for (int dy = -span; dy <= span; ++dy)
for (int dx = -span; dx <= span; ++dx) {
const auto d = BraggStencilDistances(s, static_cast<float>(dx), static_cast<float>(dy));
const float ax = std::fabs(static_cast<float>(dx)), ay = std::fabs(static_cast<float>(dy));
// No slack: the offsets are integers from an exactly centred stencil, so the
// extents bound them outright. A tolerance of a pixel here would accept a box
// one pixel too small, which is the error this exists to catch.
if (d.inner < r2_sq) {
INFO("inner region outside its box at " << dx << "," << dy);
REQUIRE(ax <= s.ex_in);
REQUIRE(ay <= s.ey_in);
}
if (d.inner >= r2_sq && d.outer < r3_sq) {
INFO("ring outside its box at " << dx << "," << dy);
REQUIRE(ax <= s.ex_out);
REQUIRE(ay <= s.ey_out);
}
}
}
}
// The growth is capped, so a mis-declared bandwidth cannot run away with the bounding box.
TEST_CASE("BraggStencil_GrowthIsCapped", "[Integration]") {
BraggStencilParams p = Params(3.0f, 0.5f); // an absurdly declared bandwidth
for (float r0 = 0.0f; r0 < 4000.0f; r0 += 17.0f)
REQUIRE(BraggStencilGrow_px(r0, p) <= p.max_grow);
const BraggStencil s = MakeBraggStencil(4000.0f, 4000.0f, p);
REQUIRE(s.ex_out <= p.r3 + p.max_grow + 1e-3f);
REQUIRE(s.ey_out <= p.r3 + p.max_grow + 1e-3f);
}
// The kernel table is indexed by the growth rounded to whole pixels, so the table has to have a row
// for every index any reflection on the detector can produce. An off-by-one here is an out-of-range
// read of k_diff - on the GPU, a device-side one.
TEST_CASE("BraggStencil_KernelIndexInRange", "[Integration][portable]") {
for (const float k : {0.0f, 0.4f, 1.0f, 2.5f, 3.0f, 6.0f}) {
const BraggStencilParams p = Params(k);
const float r_max = std::hypot(800.0f - p.beam_x, 800.0f - p.beam_y);
const int n_kern = static_cast<int>(std::lround(BraggStencilGrow_px(r_max, p))) + 1;
REQUIRE(n_kern >= 1);
for (float py = 0.0f; py <= 800.0f; py += 13.0f)
for (float px = 0.0f; px <= 800.0f; px += 17.0f) {
const BraggStencil s = MakeBraggStencil(px, py, p);
const int idx = BraggStencilKernelIndex(s, n_kern);
INFO("k " << k << " at " << px << "," << py << " grow " << s.grow);
REQUIRE(idx >= 0);
REQUIRE(idx < n_kern);
// The clamp must never be what saves it: the table is sized so the row exists.
REQUIRE(static_cast<int>(std::lround(s.grow)) == idx);
}
}
}
namespace {
// A footprint growing linearly from sigma 1 px at the beam to `edge` px at 800 px, radial and tangential
// alike unless told otherwise.
BraggStencilParams FootprintParams(float edge_rad, float edge_tan) {
BraggStencilParams p = Params(0.0f, 0.0f);
p.r1 = 4.0f;
p.fp_n = 8;
p.fp_bin_px = 100.0f;
for (int i = 0; i < p.fp_n; ++i) {
const float t = (i + 0.5f) / p.fp_n;
p.fp_sigma_rad[i] = 1.0f + t * (edge_rad - 1.0f);
p.fp_sigma_tan[i] = 1.0f + t * (edge_tan - 1.0f);
}
return p;
}
} // namespace
// Where the footprint fits the r1 disk (3 sigma <= r1) the stencil is the circular one, bit for bit:
// compact spots integrate exactly as without a footprint.
TEST_CASE("BraggStencil_FootprintInsideDiskIsInert", "[Integration]") {
const BraggStencilParams p = FootprintParams(1.3f, 1.3f); // 3 sigma < 4 everywhere
const BraggStencilParams none = Params(0.0f, 0.0f);
for (float py = 0.0f; py < 800.0f; py += 37.0f)
for (float px = 0.0f; px < 800.0f; px += 41.0f) {
const BraggStencil s = MakeBraggStencil(px, py, p);
const BraggStencil n = MakeBraggStencil(px, py, none);
REQUIRE(s.fp_s2r == 0.0f);
REQUIRE(s.grow == 0.0f);
REQUIRE(s.grow_tan == 0.0f);
for (int dy = -12; dy <= 12; ++dy)
for (int dx = -12; dx <= 12; ++dx) {
const auto d = BraggStencilDistances(s, static_cast<float>(dx), static_cast<float>(dy));
const auto e = BraggStencilDistances(n, static_cast<float>(dx), static_cast<float>(dy));
REQUIRE(d.inner == e.inner);
REQUIRE(d.outer == e.outer);
}
}
}
// A spot wider than the disk pushes the ring to BRAGG_FOOTPRINT_REACH sigma along each axis separately: a
// pixel inside that reach along the radius (or across it) is no longer background, one just beyond the grown
// ring's inner edge is.
TEST_CASE("BraggStencil_FootprintGrowsRingAlongEachAxis", "[Integration]") {
const BraggStencilParams p = FootprintParams(3.0f, 5.0f);
const float px = 400.0f + 700.0f, py = 400.0f; // on +x, 700 px out: radial = x, tangential = y
const BraggStencil s = MakeBraggStencil(px, py, p);
float sr, st;
BraggFootprintAt(700.0f, p, sr, st);
REQUIRE(s.fp_s2r == sr * sr);
REQUIRE(s.fp_s2t == st * st);
REQUIRE_THAT(s.grow, Catch::Matchers::WithinAbs(BRAGG_FOOTPRINT_REACH * sr - p.r2, 1e-4));
REQUIRE_THAT(s.grow_tan, Catch::Matchers::WithinAbs(BRAGG_FOOTPRINT_REACH * st - p.r2, 1e-4));
const float ar = p.r2 + s.grow, at = p.r2 + s.grow_tan;
// Just inside the inner ellipse along each axis: not background.
REQUIRE(BraggStencilDistances(s, 0.98f * ar, 0.0f).inner < p.r2 * p.r2);
REQUIRE(BraggStencilDistances(s, 0.0f, 0.98f * at).inner < p.r2 * p.r2);
// Just outside: background ring.
REQUIRE(BraggStencilDistances(s, 1.02f * ar, 0.0f).inner >= p.r2 * p.r2);
REQUIRE(BraggStencilDistances(s, 0.0f, 1.02f * at).inner >= p.r2 * p.r2);
// The bounding box holds the outer ellipse.
REQUIRE(s.ex_out >= p.r3 + s.grow - 1e-3f);
REQUIRE(s.ey_out >= p.r3 + s.grow_tan - 1e-3f);
REQUIRE(BraggStencilMaxGrow_px(1000.0f, p) >= std::max(s.grow, s.grow_tan));
}