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:
@@ -85,6 +85,37 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Signal-region ownership: a pixel inside two reflections' signal regions belongs to the
|
||||
// NEARER predicted centre. The union mask above cannot answer that - it also marks a
|
||||
// reflection's own core - so ownership gets its own map, one (distance, reflection) key per
|
||||
// pixel (BraggStencil.h). Built only when an overlap treatment is asked for. ---
|
||||
// A box sum has no profile to renormalise a disk it has taken pixels out of, so it never excludes.
|
||||
const bool exclude = overlap == OverlapMode::Exclude && mode != IntegratorMode::BoxSum;
|
||||
std::vector<uint32_t> owner;
|
||||
if (overlap != OverlapMode::Off) {
|
||||
owner.assign(npixel, BRAGG_OWNER_NONE);
|
||||
const float claim_sq = claim * claim;
|
||||
for (size_t i = 0; i < npredicted; ++i) {
|
||||
const auto &r = predicted[i];
|
||||
const int x0 = std::max(0, static_cast<int>(std::floor(r.predicted_x - claim)));
|
||||
const int x1 = std::min(W - 1, static_cast<int>(std::ceil(r.predicted_x + claim)));
|
||||
const int y0 = std::max(0, static_cast<int>(std::floor(r.predicted_y - claim)));
|
||||
const int y1 = std::min(H - 1, static_cast<int>(std::ceil(r.predicted_y + claim)));
|
||||
for (int y = y0; y <= y1; ++y)
|
||||
for (int x = x0; x <= x1; ++x) {
|
||||
const float dx = x - r.predicted_x, dy = y - r.predicted_y;
|
||||
const float d2 = dx * dx + dy * dy;
|
||||
if (d2 >= claim_sq) continue;
|
||||
uint32_t &o = owner[y * W + x];
|
||||
o = std::min(o, BraggOwnerKey(std::sqrt(d2), inv_claim, static_cast<int>(i)));
|
||||
}
|
||||
}
|
||||
}
|
||||
auto clean = [&](int x, int y, size_t i) {
|
||||
return owner.empty()
|
||||
|| BraggOwnedBy(owner[static_cast<size_t>(y) * W + x], static_cast<int>(i));
|
||||
};
|
||||
|
||||
// --- Pass A: box-sum every reflection (rough I, background, centroid, strong flag). ---
|
||||
struct Rough {
|
||||
double I = 0.0, sigma = NAN, bkg = 0.0, obs_x = 0.0, obs_y = 0.0;
|
||||
@@ -92,6 +123,7 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
|
||||
double var_bkg = 0.0; // total non-signal variance carried to the merge
|
||||
int64_t I_sum = 0; // kept so I can be rebuilt after the radial background correction
|
||||
int n_inner = 0;
|
||||
int n_disk = 0, n_own = 0; // signal-disk pixels, and how many of them are this reflection's
|
||||
int r_bin = 0; // rounded distance from the beam centre, indexes the radial curve
|
||||
int k_bin = 0; // which radial-background kernel this reflection's stencil needs
|
||||
int cx = 0, cy = 0, shell = -1;
|
||||
@@ -130,6 +162,7 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
|
||||
out.k_bin = BraggStencilKernelIndex(st, n_kern);
|
||||
|
||||
int64_t I_sum = 0, I_sum_x = 0, I_sum_y = 0, n_inner = 0, n_inner_valid = 0;
|
||||
int n_disk = 0, n_own = 0; // pixels in the signal disk, and how many are this reflection's
|
||||
double bkg_sum = 0.0;
|
||||
int n_bkg = 0;
|
||||
bkg_vals.clear();
|
||||
@@ -138,6 +171,15 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
|
||||
const auto d = BraggStencilDistances(st, x - r.predicted_x, y - r.predicted_y);
|
||||
const int32_t px = img[y * W + x];
|
||||
if (d.signal < r1_sq) {
|
||||
// A pixel a nearer neighbour owns carries that neighbour's flux, so in Exclude
|
||||
// mode it leaves the disk entirely - the sum, the pixel count the background is
|
||||
// subtracted with, and the all-or-nothing validity gate alike. The box sum only
|
||||
// counts how many were lost, which is all it can act on.
|
||||
++n_disk;
|
||||
if (overlap != OverlapMode::Off) {
|
||||
if (clean(x, y, i)) ++n_own;
|
||||
else if (exclude) continue;
|
||||
}
|
||||
++n_inner;
|
||||
if (!valid(px)) continue;
|
||||
I_sum += px;
|
||||
@@ -207,6 +249,8 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
|
||||
+ static_cast<double>(n_inner) * n_inner * out.bkg_var;
|
||||
out.I_sum = I_sum;
|
||||
out.n_inner = static_cast<int>(n_inner);
|
||||
out.n_disk = n_disk;
|
||||
out.n_own = n_own;
|
||||
const double var_bkg_term = static_cast<double>(n_inner) * n_inner * out.bkg_var;
|
||||
out.sigma = 1.0;
|
||||
if (I_sum > 0) {
|
||||
@@ -255,6 +299,14 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
|
||||
for (size_t i = 0; i < npredicted; ++i) {
|
||||
const auto &rh = rough[i];
|
||||
if (!rh.ok) continue;
|
||||
// A box sum measures what is in the disk with no model of what should be there, so it can
|
||||
// neither renormalise nor tell a neighbour's photon from its own: dropping the reflection
|
||||
// is the only treatment it has, and it is applied only where that is what was ASKED for.
|
||||
// Exclude means "leave the shared pixels out of the fit", and a box sum has no fit, so it
|
||||
// is a no-op here rather than a rejection the caller never requested. The fraction is by
|
||||
// AREA, not by profile mass, so the same threshold cuts harder here than in the profile
|
||||
// modes.
|
||||
if (overlap == OverlapMode::Reject && rh.n_own < overlap_min_peak * rh.n_disk) continue;
|
||||
results[i] = {static_cast<float>(rh.I), static_cast<float>(rh.sigma), static_cast<float>(rh.bkg),
|
||||
static_cast<float>(rh.obs_x), static_cast<float>(rh.obs_y),
|
||||
static_cast<float>(rh.var_bkg), true, rh.has_obs};
|
||||
@@ -296,6 +348,7 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) continue;
|
||||
const int32_t px = img[y * W + x];
|
||||
if (!valid(px)) continue;
|
||||
if (exclude && !clean(x, y, i)) continue;
|
||||
const double v = (static_cast<double>(px) - rh.bkg) / rh.I;
|
||||
shell_grid[rh.shell][grid_idx(dx, dy)] += v;
|
||||
global_grid[grid_idx(dx, dy)] += v;
|
||||
@@ -398,6 +451,32 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
|
||||
}
|
||||
|
||||
const int Gf = 2 * Rf + 1;
|
||||
|
||||
// --- How much of the expected profile is cleanly this reflection's own. p_own is the whole
|
||||
// grid's clean mass, i.e. XDS's MINPK quantity, and it is what Reject cuts on. m_own /
|
||||
// m_all is the same fraction over the r1 disk alone, which is what the summation seed the
|
||||
// runaway guard below compares against actually saw; with nothing excluded it is 1 and
|
||||
// the guard is untouched. ---
|
||||
double p_own = 1.0, m_all = 0.0, m_own = 0.0;
|
||||
if (overlap != OverlapMode::Off) {
|
||||
p_own = 0.0;
|
||||
for (int dy = -Rf; dy <= Rf; ++dy)
|
||||
for (int dx = -Rf; dx <= Rf; ++dx) {
|
||||
const double Pp = (*Pvec)[(dy + Rf) * Gf + (dx + Rf)];
|
||||
if (Pp <= 0.0) continue;
|
||||
const int x = rh.cx + dx, y = rh.cy + dy;
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) continue;
|
||||
if (!valid(img[y * W + x])) continue;
|
||||
const bool own = clean(x, y, i);
|
||||
if (own) p_own += Pp;
|
||||
if (dx * dx + dy * dy < r1_sq) {
|
||||
m_all += Pp;
|
||||
if (own) m_own += Pp;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (overlap == OverlapMode::Reject && p_own < overlap_min_peak) continue;
|
||||
|
||||
const double B = std::max(rh.bkg, PIXEL_VARIANCE_FLOOR);
|
||||
double I = rh.I, den = 0.0, wsum = 0.0;
|
||||
for (int iter = 0; iter < 4; ++iter) {
|
||||
@@ -412,6 +491,7 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) continue;
|
||||
const int32_t px = img[y * W + x];
|
||||
if (!valid(px)) continue;
|
||||
if (exclude && !clean(x, y, i)) continue;
|
||||
const double v = std::max(B + I * Pp, WEIGHT_VARIANCE_MIN_FRACTION * B);
|
||||
num += Pp * (static_cast<double>(px) - rh.bkg) / v;
|
||||
den += Pp * Pp / v;
|
||||
@@ -431,7 +511,11 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
|
||||
double sigma = std::sqrt(1.0 / den + (wsum / den) * (wsum / den) * rh.bkg_var);
|
||||
double var_bkg = std::max(0.0, 1.0 / den - std::max(0.0, I)
|
||||
+ (wsum / den) * (wsum / den) * rh.bkg_var);
|
||||
if (std::abs(I - rh.I) > PROFILE_SUMMATION_MAX_NSIGMA * rh.sigma) {
|
||||
// The seed is a sum over the disk the box sum actually read, so when Exclude has taken pixels
|
||||
// out of both, the fit's full-profile intensity has to be scaled down to that same disk
|
||||
// before the two are comparable. Nothing excluded gives exactly 1.
|
||||
const double guard_scale = exclude && m_all > 0.0 ? m_own / m_all : 1.0;
|
||||
if (std::abs(I * guard_scale - rh.I) > PROFILE_SUMMATION_MAX_NSIGMA * rh.sigma) {
|
||||
I = rh.I;
|
||||
sigma = rh.sigma;
|
||||
var_bkg = rh.var_bkg;
|
||||
|
||||
Reference in New Issue
Block a user