Bragg integration: elongate the background ring per reflection

The signal disk and the r2..r3 background ring were fixed pixel circles, identical for every
reflection at every resolution. A reflection is not round: a finite bandwidth streaks it radially by
bw_sigma*Rpx, so at high resolution the ring sits within 1.3-2.2 sigma of the reflection's own
profile and measures its tails as background.

--integration-stencil <k> makes the RING an ellipse, elongated along the beam->reflection direction
by k times that streak, capped at 2*r3. The tangential half-widths stay r2 and r3, and the r1 signal
disk stays a circle: r1 drives the all-or-nothing n_inner_valid == n_inner gate, so growing it
rejects any reflection carrying one bad pixel along a long streak, and the flux a circular r1 loses
is a function of resolution alone, which the per-shell scale absorbs.

The geometry lives in one shared header compiled by both the host compiler and nvcc, so the seven
pixel-classification sites - the CPU mask/main/clip loops and the GPU mark_mask/main/trim/clip
kernels - cannot drift apart. Rather than evaluate an ellipse, each pixel's squared distance has its
radial part scaled down, d2 - q*rad^2 against r2^2/r3^2 with q = 1 - (r/(r+grow))^2, so grow = 0
gives q = 0 and both tests collapse onto d2 exactly in floating point.

The width is the bandwidth streak alone, not the profile's full radial variance, which also carries
the sensor parallax and weak-spot capture terms. Deriving the growth from those was implemented
first and measured on the rotation battery: at k=1 it took Thau_9's high-shell CC1/2 from 75.8 to
27.9 and Benas_3's from 14.1 to 6.0, against cytC_10 +1.2 and lyso_ref flat. On a monochromatic beam
they are the only terms there are, and C_CAPTURE is 64% of them. Keeping only the streak also makes
the option exactly inert without a bandwidth, rather than merely small.

Default 0. Measured on broadband rotation data with the bandwidth set to its spectroscopic value,
matched resolution limits: high-shell CC1/2 30.6 -> 46.4 at k=4, and better in EVERY shell in both
CC1/2 and R_meas (top shell R_meas 194.7% -> 138.7%), with completeness, multiplicity and space
group unchanged and 28 of 98833 unique reflections lost. Anomalous peak height over 18 sites
+0.107 +- 0.039 sigma (p = 0.013). The full 38-crystal rotation battery is unchanged to every
reported digit, base against k=3.

Two consequences of an elongated ring are handled rather than inherited. The neighbour exclusion
marks the inner ELLIPSE in each neighbour's own frame, or an elongated neighbour leaks its tails
into this reflection's ring. And the radial-background curvature kernel becomes a small table
indexed by the growth, because its azimuthal average makes one kernel serve every reflection only
while their stencils are identical; the GPU's radial window, previously a fixed 32 bins, is now
sized on the host from the widest ring on the detector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-10 15:19:28 +02:00
co-authored by Claude Opus 5
parent 52ea727650
commit 61d24db59f
16 changed files with 656 additions and 120 deletions
+52 -4
View File
@@ -83,31 +83,39 @@ Scene BuildScene(size_t width, size_t height, int spacing = 60) {
DiffractionExperiment MakeExperiment(IntegratorMode mode, std::optional<float> bandwidth_fwhm,
float clip_nsigma = 4.0f,
bool radial = false,
const DetectorSetup &det = DetJF(2)) {
const DetectorSetup &det = DetJF(2),
float stencil_k = 0.0f,
float r1 = 0.0f, float r2 = 0.0f, float r3 = 0.0f) {
DiffractionExperiment experiment(det); // DetJF(2) (small) keeps the correctness test fast
experiment.DetectorDistance_mm(100.0f).IncidentEnergy_keV(WVL_1A_IN_KEV)
.BeamX_pxl(400.0f).BeamY_pxl(400.0f);
experiment.BandwidthFWHM(bandwidth_fwhm);
BraggIntegrationSettings settings;
settings.Integrator(mode);
if (r1 > 0.0f)
settings.R1(r1).R2(r2).R3(r3);
if (clip_nsigma > 0.0f)
settings.BackgroundClipNSigma(clip_nsigma);
else
settings.BackgroundTrimFraction(0.10f);
settings.BackgroundRadialCorrection(radial);
settings.StencilKSigma(stencil_k);
experiment.ImportBraggIntegrationSettings(settings);
return experiment;
}
void CompareCpuVsGpu(IntegratorMode mode, std::optional<float> bandwidth_fwhm,
float clip_nsigma = 4.0f, bool radial = false) {
const DiffractionExperiment experiment = MakeExperiment(mode, bandwidth_fwhm, clip_nsigma, radial);
float clip_nsigma = 4.0f, bool radial = false, int spacing = 60,
float stencil_k = 0.0f,
float r1 = 0.0f, float r2 = 0.0f, float r3 = 0.0f) {
const DiffractionExperiment experiment =
MakeExperiment(mode, bandwidth_fwhm, clip_nsigma, radial, DetJF(2), stencil_k, r1, r2, r3);
const size_t width = experiment.GetXPixelsNum();
const size_t height = experiment.GetYPixelsNum();
const size_t npixel = experiment.GetPixelsNum();
REQUIRE(npixel == width * height);
const Scene scene = BuildScene(width, height);
const Scene scene = BuildScene(width, height, spacing);
REQUIRE(scene.image.size() == npixel);
REQUIRE(scene.predicted.size() > 60);
@@ -154,12 +162,52 @@ TEST_CASE("BraggIntegrationEngineGPU_MatchesCPU") {
SECTION("BoxSum") { CompareCpuVsGpu(IntegratorMode::BoxSum, std::nullopt); }
SECTION("ProfileGaussian mono") { CompareCpuVsGpu(IntegratorMode::ProfileGaussian, std::nullopt); }
SECTION("ProfileGaussian broadband") { CompareCpuVsGpu(IntegratorMode::ProfileGaussian, 0.03f); }
// An elongated background ring: the classification, the bounding box, the neighbour mask and the
// shared-memory radial window all become reflection-dependent, and the two engines have to agree
// on every one of them. Spots spaced wider so the grown rings stay clear of the neighbours -
// what is under test is the stencil, not the crowding.
SECTION("ProfileGaussian stencil broadband") {
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, 0.005f, 4.0f, false, 120, 3.0f);
}
// A monochromatic beam has no streak, so k_sigma changes nothing - the point of the section is
// that both engines agree that it changes nothing.
SECTION("ProfileGaussian stencil mono") {
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, std::nullopt, 4.0f, false, 120, 3.0f);
}
// Crowded: at the default spacing the grown rings DO overlap their neighbours, so the elongated
// neighbour mask, the shrinking background-pixel count and the n_bkg acceptance gate are all in
// play. That is the case the feature meets at high resolution, and the wide-spacing sections
// above deliberately avoid it.
SECTION("ProfileGaussian stencil crowded") {
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, 0.02f, 4.0f, false, 60, 4.0f);
}
SECTION("BoxSum stencil") {
CompareCpuVsGpu(IntegratorMode::BoxSum, 0.005f, 4.0f, false, 120, 3.0f);
}
// The trimmed-mean ring is sorted in a fixed-size shared buffer on the GPU; an elongated ring
// holds more pixels, so both engines have to fall back to the plain mean at the same place.
SECTION("ProfileGaussian stencil trim") {
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, 0.005f, 0.0f, false, 120, 3.0f);
}
// A ring wide enough to overflow the GPU's fixed trimmed-mean buffer, so the fallback to the
// plain ring mean is exercised - and has to happen in both engines at the same reflection. The
// growth cap keeps the default 6/10 ring under the buffer at any bandwidth, so this needs the
// wider stills radii to be reachable at all.
SECTION("ProfileGaussian stencil trim overflow") {
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, 0.04f, 0.0f, false, 120, 4.0f, 6.0f, 8.0f, 12.0f);
}
SECTION("ProfileEmpirical") { CompareCpuVsGpu(IntegratorMode::ProfileEmpirical, std::nullopt); }
SECTION("ProfileGaussian mono trim") { CompareCpuVsGpu(IntegratorMode::ProfileGaussian, std::nullopt, 0.0f); }
// The radial background curvature correction is computed independently in the two engines
// (host loop vs radial_correct kernel), so it needs its own parity coverage.
SECTION("BoxSum radial") { CompareCpuVsGpu(IntegratorMode::BoxSum, std::nullopt, 4.0f, true); }
SECTION("ProfileGaussian radial") { CompareCpuVsGpu(IntegratorMode::ProfileGaussian, std::nullopt, 4.0f, true); }
// With an elongated ring the radial-curvature kernel is a table indexed per reflection, and the
// shared window boxsum accumulates the curve in is sized from the widest aperture on the
// detector. Both are computed independently in the two engines.
SECTION("ProfileGaussian radial stencil") {
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, 0.005f, 4.0f, true, 120, 3.0f);
}
}
// Hidden ([.]) benchmark: the raison d'etre of the GPU port is < 2 ms/frame (vs ~142 ms on the CPU