Bragg integration: a shared signal pixel belongs to the nearer reflection

Nothing kept a neighbour's flux out of a reflection's own signal disk. The union mask
keeps neighbour cores out of the BACKGROUND ring, but the r1 disk was read whole, so on
a dense pattern a crowded reflection measures part of its neighbour as its own.

Ownership is decided once per image into a per-pixel (quantised distance, reflection)
key written with an atomic minimum, so the nearest predicted centre wins whatever order
the writes arrive in and the lowest index breaks a tie. `--overlap exclude`, now the
default, drops the pixels a nearer neighbour owns from the profile fit. A profile fit is
the amplitude of a normalised profile, so leaving pixels out renormalises the estimator
by construction and the reflection stays unbiased rather than being discarded; the
summation-fallback guard is scaled back to the disk the box-sum seed actually read, so
it still compares like with like. `--overlap reject` is the XDS MINPK alternative - drop
the reflection when less than `--overlap-minpk` of its expected profile is cleanly its
own. A box sum has no profile to renormalise with, so `exclude` is a no-op there and
only `reject` acts on it.

Widening the split - keeping a pixel only where no other centre is within its distance
PLUS a margin - was built and measured, and it is worse monotonically: the residual bias
of the pixels that were kept grows from +0.072 to +0.209 in ln intensity at 0 to 3 px of
margin. What the margin removes is the reflection's own profile, not the neighbour's
tail, so the plain nearest-centre split is the rule.

Measured on the full 38-crystal rotation battery against the same binary with the
treatment off: ISa better 15 / worse 8, summed shortfall against XDS 39.7 -> 28.1. Three
of the losses are the two-pass loop taking its other branch - their median mosaicity
moves between the two known attractors - rather than the change under test; excluding
those it is better 15 / worse 5 and the shortfall goes 31.3 -> 14.4. The two crowded
crystals gain 38% and 52% of their ISa, one of them passing XDS. High-shell CC1/2 over
the 35 crystals that neither flipped branch nor carry a collapsed error model is better
7 / worse 7. Space groups unchanged at 35/38. The owner map is built only when a
treatment is asked for and costs 1.1% of the battery's wall clock - 23% on a genuinely
crowded crystal, nothing where no two predictions touch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 05:02:31 +02:00
co-authored by Claude Opus 5
parent 7627c282ba
commit 6f7b136ec2
11 changed files with 360 additions and 24 deletions
+56 -10
View File
@@ -38,7 +38,10 @@ Reflection MakeReflection(float x, float y, float d, int hkl) {
return r;
}
Scene BuildScene(size_t width, size_t height, int spacing = 60) {
// companion_dx > 0 puts a second spot that many pixels beside every grid spot, so their r1 signal
// disks share pixels while the background rings still see clean sky - which is what a dense pattern
// actually looks like (crowded along one reciprocal axis, sparse across it).
Scene BuildScene(size_t width, size_t height, int spacing = 60, float companion_dx = 0.0f) {
Scene s;
s.width = width;
s.height = height;
@@ -66,6 +69,19 @@ Scene BuildScene(size_t width, size_t height, int spacing = 60) {
}
const float d = 1.4f + 0.12f * static_cast<float>((gx + gy) % 12); // 1.4..2.72 A
s.predicted.push_back(MakeReflection(cx, cy, d, hkl++));
if (companion_dx > 0.0f) {
const float ccx = cx + companion_dx;
for (int dy = -6; dy <= 6; ++dy)
for (int dx = -6; dx <= 6; ++dx) {
const int x = static_cast<int>(std::lround(ccx)) + dx;
const int y = static_cast<int>(std::lround(cy)) + dy;
if (x < 0 || y < 0 || x >= static_cast<int>(width) || y >= static_cast<int>(height)) continue;
const double ex = x - ccx, ey = y - cy;
const double g = 0.6 * amp * std::exp(-(ex * ex + ey * ey) / (2.0 * sigma * sigma));
s.image[y * width + x] += static_cast<int32_t>(std::lround(g));
}
s.predicted.push_back(MakeReflection(ccx, cy, d, hkl++));
}
}
}
@@ -85,7 +101,8 @@ DiffractionExperiment MakeExperiment(IntegratorMode mode, std::optional<float> b
bool radial = false,
const DetectorSetup &det = DetJF(2),
float stencil_k = 0.0f,
float r1 = 0.0f, float r2 = 0.0f, float r3 = 0.0f) {
float r1 = 0.0f, float r2 = 0.0f, float r3 = 0.0f,
OverlapMode overlap = OverlapMode::Off) {
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);
@@ -100,6 +117,7 @@ DiffractionExperiment MakeExperiment(IntegratorMode mode, std::optional<float> b
settings.BackgroundTrimFraction(0.10f);
settings.BackgroundRadialCorrection(radial);
settings.StencilKSigma(stencil_k);
settings.Overlap(overlap);
experiment.ImportBraggIntegrationSettings(settings);
return experiment;
}
@@ -107,15 +125,17 @@ DiffractionExperiment MakeExperiment(IntegratorMode mode, std::optional<float> b
void CompareCpuVsGpu(IntegratorMode mode, std::optional<float> bandwidth_fwhm,
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) {
float r1 = 0.0f, float r2 = 0.0f, float r3 = 0.0f,
OverlapMode overlap = OverlapMode::Off, float companion_dx = 0.0f) {
const DiffractionExperiment experiment =
MakeExperiment(mode, bandwidth_fwhm, clip_nsigma, radial, DetJF(2), stencil_k, r1, r2, r3);
MakeExperiment(mode, bandwidth_fwhm, clip_nsigma, radial, DetJF(2), stencil_k, r1, r2, r3,
overlap);
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, spacing);
const Scene scene = BuildScene(width, height, spacing, companion_dx);
REQUIRE(scene.image.size() == npixel);
REQUIRE(scene.predicted.size() > 60);
@@ -197,6 +217,27 @@ TEST_CASE("BraggIntegrationEngineGPU_MatchesCPU") {
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, 0.04f, 0.0f, false, 120, 4.0f, 6.0f, 8.0f, 12.0f);
}
SECTION("ProfileEmpirical") { CompareCpuVsGpu(IntegratorMode::ProfileEmpirical, std::nullopt); }
// Overlap treatment: companions 4 px apart put each reflection's centre inside its neighbour's
// signal disk, so the owner map, the excluded pixels and the profile fraction the two modes act on
// all have to come out the same in both engines - the ownership atomic in particular is settled by
// an atomicMin on the GPU and a serial minimum on the CPU.
SECTION("ProfileGaussian overlap exclude") {
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, std::nullopt, 4.0f, false, 60, 0.0f,
0.0f, 0.0f, 0.0f, OverlapMode::Exclude, 4.0f);
}
SECTION("ProfileGaussian overlap reject") {
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, std::nullopt, 4.0f, false, 60, 0.0f,
0.0f, 0.0f, 0.0f, OverlapMode::Reject, 4.0f);
}
SECTION("BoxSum overlap reject") {
CompareCpuVsGpu(IntegratorMode::BoxSum, std::nullopt, 4.0f, false, 60, 0.0f,
0.0f, 0.0f, 0.0f, OverlapMode::Reject, 4.0f);
}
// Nothing shares a pixel at this spacing, so an overlap treatment has to leave the result alone.
SECTION("ProfileGaussian overlap inert") {
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, std::nullopt, 4.0f, false, 60, 0.0f,
0.0f, 0.0f, 0.0f, OverlapMode::Exclude);
}
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.
@@ -217,8 +258,12 @@ TEST_CASE("BraggIntegrationEngineGPU_Benchmark", "[.][bragg_bench]") {
WARN("No CUDA GPU present. Skipping benchmark");
return;
}
// The overlap treatment is priced here too: it adds an owner map over the whole frame plus one
// atomic per claimed pixel, so what it costs is a property of the frame more than of the crowding.
for (OverlapMode ovl : {OverlapMode::Off, OverlapMode::Reject, OverlapMode::Exclude}) {
const DiffractionExperiment experiment = MakeExperiment(IntegratorMode::ProfileGaussian, std::nullopt,
4.0f, false, DetJF4M());
4.0f, false, DetJF4M(), 0.0f, 0.0f, 0.0f, 0.0f,
ovl);
const size_t width = experiment.GetXPixelsNum();
const size_t height = experiment.GetYPixelsNum();
const size_t npixel = experiment.GetPixelsNum();
@@ -226,7 +271,7 @@ TEST_CASE("BraggIntegrationEngineGPU_Benchmark", "[.][bragg_bench]") {
auto stream = std::make_shared<CudaStream>();
BraggIntegrationEngineGPU gpu(experiment, stream);
for (int spacing : {28, 40, 60, 90}) {
for (int spacing : {28, 60}) {
const Scene scene = BuildScene(width, height, spacing);
const size_t nrefl = scene.predicted.size();
@@ -254,9 +299,10 @@ TEST_CASE("BraggIntegrationEngineGPU_Benchmark", "[.][bragg_bench]") {
const auto c1 = std::chrono::steady_clock::now();
const double cpu_ms = std::chrono::duration<double, std::milli>(c1 - c0).count();
WARN(width << "x" << height << " | " << nrefl << " refl (" << observed / iters
<< " obs) | GPU " << ms << " ms | CPU " << cpu_ms << " ms (" << cpu_observed
<< " obs) | speedup " << cpu_ms / ms << "x");
WARN((int) ovl << " | " << width << "x" << height << " | " << nrefl << " refl ("
<< observed / iters << " obs) | GPU " << ms << " ms | CPU " << cpu_ms << " ms ("
<< cpu_observed << " obs) | speedup " << cpu_ms / ms << "x");
}
}
}