ShadowFinder: add beam-stop holder arms that let part of the beam through

After the existing mask is complete, a second step takes the pixels it left out that are
significantly dimmer than their ring, joins them through a 6 px bridge and across module gaps of
any width, and adds a piece whole when it holds >= 2000 dim pixels of which >= 200 are deep. The
existing mask is never touched, so a sweep with no such piece keeps its mask bit for bit.

Quick subset tests (battery --only, no model check) against the rc173 a0518abe6 full battery:
152 of 233 masks identical; 10 of 10 controls (including the sets where earlier shadow changes
regressed through marginal decisions) give byte-identical merges. On the 21 sets that gain a
piece the space group never changes, merge outlier rejections fall on 19, R_meas falls and ISa
rises on 17, and the shell-scaled model R improves on 16 of 18; a transmitting arm with an
over-subtracted background strip is recovered (R_meas 13.3 -> 12.0 %, ISa 13.4 -> 15.5).
Known costs: on one set with background bumps at ring radii the low-resolution agreement with
the model falls (CC 0.89 -> 0.85) while its internal statistics improve, and two sets cut
slightly coarser (1.50 -> 1.55 A, 1.72 -> 1.80 A) with a better model R.

Squashed from branch hq-beamstop (cfa080a59..60b6c6822), whose history records the variants
tried and dropped: a straight port of the earlier arm finder moved every mask, a two-shape
construction filled almost a whole sweep through hole filling, and a deep-fraction floor
rejected a real arm that is dim along its whole length.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
This commit is contained in:
2026-09-26 14:01:17 +02:00
co-authored by Claude Opus 5.5
parent 56b82b2948
commit a1816e905e
4 changed files with 145 additions and 2 deletions
+4
View File
@@ -1,6 +1,10 @@
# Changelog
## 1.0.0
### 1.0.0-rc.174
* Rugnux adds beam-stop holder arms that let part of the beam through to the beam-stop mask, without changing the mask of a sweep that has none.
### 1.0.0-rc.173
* Rugnux corrects the background of reflections on a smooth powder or ice ring for the ring's radial profile by default (`--background-radial=auto`); `--background-radial=off` restores the flat background ring.
+14
View File
@@ -93,6 +93,19 @@ own radius**, not to a global threshold.
the disk interior.
10. **Carve reflections last.** Remove any lit pixel from the final mask (no fill afterwards) so a
spot the geometry still covered is given back rather than re-enclosed.
11. **Arms that let part of the beam through.** A thin holder arm, or one blurred by its distance
from the detector, sits at 40-70 % of the background along most of its length and crosses
`shadow_ratio` only in places, so step 6 keeps only deep fragments, each below
`min_shadow_pixels`; under a bright background step 10 also gives back its attenuated pixels,
which reach `min_reflection`. So, after step 10, the **dim** pixels the mask left out
(`ratio < penumbra_ratio && sigma > min_deficit_sigma`) are joined through a `bridge_px`-dilated
copy and across any module gap whose two sides both belong to them, however wide the gap, and a
piece with at least `min_shadow_pixels` dim pixels of which `min_core_pixels` are core is added
whole. Nothing is given back inside it: the reflections behind transmitting hardware are
recorded attenuated. The step only ever adds such pieces - the dim edge of an ordinary stop is
left as steps 6-10 drew it, so a sweep without transmitting hardware keeps its mask bit for bit
(an earlier version redrew every stop's edge from the dim pixels and moved every mask in the
corpus by 0.01-0.7 % of the detector).
Three findings are baked in: the **mean** ratio (not the max) does the detecting — the max
projection is a great *picture* but a noisier detector; **size and counting statistics**, not a
@@ -108,6 +121,7 @@ just safe headroom.
| `shadow_ratio` | 0.50 | core threshold: mean below this fraction of the radial baseline |
| `min_deficit_sigma` | 6 | … and the deficit significant to this many Poisson standard deviations |
| `min_shadow_pixels` | 2000 | smallest region the per-pixel test may return |
| `min_core_pixels` | 200 | core pixels a dim piece of step 11 must hold |
| `penumbra_ratio` | 0.75 | soft boundary grows out to this ratio … |
| `penumbra_max_px` | 30 | … but no further than this from the core |
| `bridge_px` | 6 | bridge module gaps / breaks the arm crosses |
+81 -1
View File
@@ -28,7 +28,8 @@ constexpr float SHADOW_RATIO = 0.50f;
constexpr float PENUMBRA_RATIO = 0.75f;
constexpr int PENUMBRA_MAX_PX = 30;
// Bridge module gaps and small breaks that the holder arm crosses.
// Bridge small breaks along the holder arm. A module gap wider than this is bridged separately for
// the arm search (see bridge_gaps).
constexpr int BRIDGE_PX = 6;
// A pixel whose maximum reaches this recorded a real reflection and is never masked - a
@@ -48,6 +49,12 @@ constexpr double MIN_DEFICIT_SIGMA = 6.0;
// This is what keeps the test specific now that a shadow no longer has to touch the direct beam.
constexpr int MIN_SHADOW_PIXELS = 2000;
// ... and of those, how many must be deep (below SHADOW_RATIO) for a region of merely DIM pixels -
// hardware that lets part of the beam through - to count. A thin holder arm is dim along most of
// its length and deep in places; the background drifting over a detector's edge is dim everywhere
// and deep nowhere.
constexpr int MIN_CORE_PIXELS = 200;
// Side of the box the background is pooled over before testing. Its area is how many pixels back
// a ring's countability test, which decides where an azimuthal comparison is possible at all.
constexpr int POOL_PX = 5;
@@ -146,6 +153,31 @@ std::vector<char> erode(const std::vector<char> &in, int W, int H, int r, size_t
return out;
}
// Join a region across the module gaps it crosses: every run of gap pixels along a row or a column
// whose two ends both touch the region becomes region. A gap carries no data, so a shadow that
// continues on both sides of it is one shadow - but a gap can be wider than BRIDGE_PX reaches (17 px
// between the rows of PILATUS modules), and an arm crossing one fell apart into pieces each too small
// to be believed.
std::vector<char> bridge_gaps(const std::vector<char> &region, const std::vector<char> &valid,
int W, int H) {
std::vector<char> out = region;
auto walk = [&](int n_lines, int len, auto index) {
for (int line = 0; line < n_lines; line++) {
int k = 0;
while (k < len) {
if (valid[index(line, k)]) { k++; continue; }
const int start = k;
while (k < len && !valid[index(line, k)]) k++;
if (start > 0 && k < len && region[index(line, start - 1)] && region[index(line, k)])
for (int j = start; j < k; j++) out[index(line, j)] = 1;
}
}
};
walk(H, W, [W](int y, int x) { return static_cast<size_t>(y) * W + x; });
walk(W, H, [W](int x, int y) { return static_cast<size_t>(y) * W + x; });
return out;
}
// Fill holes: background not reachable from the image border becomes region.
//
// The flood is run over the bounding box of `region` grown by one, not the whole detector. Outside
@@ -800,5 +832,53 @@ std::vector<uint32_t> ShadowFinder::GetMask(size_t nthreads) const {
for (int i = 0; i < n_pixels; i++)
mask[i] = region[i] ? 1 : 0;
// Hardware that lets part of the beam through - a thin holder arm, or one whose shadow is blurred
// by its distance from the detector - sits between PENUMBRA_RATIO and SHADOW_RATIO along most of
// its length and crosses SHADOW_RATIO only in places, so the region test above keeps only the
// fragments of it that are deep, each too small to be believed; and where the background is
// bright, the attenuated pixels it does keep reach MIN_REFLECTION and are given back as
// reflections. Such an arm is found here as what the mask above left out: the significantly
// DIM pixels, joined across small breaks and module gaps, in a piece as large as a shadow must be
// and deep in enough places. Nothing is given back inside it - the reflections behind it are
// recorded attenuated, and giving them back is what integrates them low.
//
// It only adds whole pieces. The dim pixels along the edge of an ordinary stop are left as the
// region above drew them: each such edge feeds decisions downstream that can sit at a margin, so
// a sweep with no transmitting hardware keeps its mask bit for bit.
std::vector<char> dim(n_pixels, 0);
for (int i = 0; i < n_pixels; i++)
dim[i] = valid[i] && !region[i] && ratio[i] < PENUMBRA_RATIO && deficit[i] > MIN_DEFICIT_SIGMA;
const std::vector<char> joined = bridge_gaps(dilate(dim, W, H, BRIDGE_PX, nthreads), valid, W, H);
std::vector<char> seen(n_pixels, 0);
std::vector<int> component;
std::queue<int> q;
for (int start = 0; start < n_pixels; start++) {
if (!joined[start] || seen[start])
continue;
component.clear();
int n_dim = 0, n_low = 0;
seen[start] = 1;
q.push(start);
while (!q.empty()) {
const int i = q.front(); q.pop();
component.push_back(i);
n_dim += dim[i];
n_low += dim[i] && low[i];
const int y = i / W, x = i % W;
for (int dy = -1; dy <= 1; dy++)
for (int dx = -1; dx <= 1; dx++) {
const int yy = y + dy, xx = x + dx;
if (yy < 0 || yy >= H || xx < 0 || xx >= W)
continue;
const int j = yy * W + xx;
if (joined[j] && !seen[j]) { seen[j] = 1; q.push(j); }
}
}
if (n_dim >= MIN_SHADOW_PIXELS && n_low >= MIN_CORE_PIXELS)
for (const int i : component)
if (dim[i])
mask[i] = 1;
}
return mask;
}
+46 -1
View File
@@ -4,6 +4,7 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <climits>
#include <cmath>
#include <cstring>
#include <optional>
@@ -103,10 +104,54 @@ TEST_CASE("ShadowFinder_FindsAnInjectedBeamStop", "[ShadowFinder]") {
// Pinned from the serial implementation. A rewrite of the dilation, the hole fill or the ring
// median that moves the mask by one pixel fails here, rather than in a merging statistic
// several stages downstream.
// several stages downstream. Nothing here lets part of the beam through, so the transmitting
// shape may redraw the stop's edge but must not add to the mask: the count is the opaque one.
CHECK(std::count(mask.begin(), mask.end(), 1u) == 2612);
}
// A holder arm that lets part of the beam through, on a background bright enough that every pixel
// reaches the reflection guard's count, crossing a module gap wider than the bridge. Each of the
// three hid the arm on its own: the partly transmitting stretch never reaches SHADOW_RATIO, the gap
// cut what did into pieces too small to be believed, and a count threshold read the attenuated
// background under the arm as recorded reflections and gave every pixel of it back.
TEST_CASE("ShadowFinder_FindsAnArmThatLetsPartOfTheBeamThrough", "[ShadowFinder]") {
constexpr int32_t BRIGHT = 50; // even the arm reaches MIN_REFLECTION
constexpr int ARM_HALF_WIDE = 15, GAP_X0 = 200, GAP_X1 = 216, OPAQUE_FROM_X = 232;
const DiffractionExperiment x = TestExperiment();
const PixelMask pixel_mask(x);
ShadowFinder finder(x, pixel_mask);
std::vector<std::vector<int32_t>> frames;
std::vector<uint8_t> buffer;
for (int f = 0; f < NFRAMES; f++) {
frames.emplace_back(static_cast<size_t>(W) * H, BRIGHT);
auto &frame = frames.back();
for (int y = 0; y < H; y++)
for (int xi = 0; xi < W; xi++) {
const int dx = xi - C, dy = y - C;
if (dx * dx + dy * dy <= STOP_R * STOP_R)
frame[I(xi, y)] = 0;
else if (dx >= 0 && std::abs(dy) <= ARM_HALF_WIDE)
frame[I(xi, y)] = xi >= OPAQUE_FROM_X ? 0 : BRIGHT * 6 / 10;
if (xi >= GAP_X0 && xi <= GAP_X1)
frame[I(xi, y)] = INT32_MIN; // no data
}
DataMessage msg{};
msg.image = CompressedImage(frame, W, H);
finder.AddImage(msg, buffer);
}
const auto mask = finder.GetMask();
CHECK(mask[I(C, C)] == 1); // the stop
CHECK(mask[I(GAP_X0 - 10, C)] == 1); // the arm where it transmits, before the gap
CHECK(mask[I(GAP_X1 + 10, C + ARM_HALF_WIDE - 3)] == 1); // ... and after it
CHECK(mask[I(W - 3, C)] == 1); // where it is opaque
CHECK(mask[I(GAP_X1 + 10, C + ARM_HALF_WIDE + 25)] == 0); // and nothing beside it
CHECK(mask[I(0, 0)] == 0);
CHECK(mask[I(C - 60, C)] == 0);
}
// The per-pixel passes are split across threads, so where the split falls must not be visible in the
// answer. The x pass and the y pass of the dilation and of the pooled sum are written differently -
// one a plain scan, the other blocked by column - so an x/y asymmetry is the plausible regression.