rugnux finds the beam stop and its holder in a projection of 60 images and marks them in the pixel mask as bit 9 (--detect-beam-stop[=N|off], on by default). Reflections behind the stop are attenuated but not flagged, so they integrate low with a plausible sigma and nothing downstream catches them: the signal-box gate requires 100% valid pixels and shadow pixels are valid, the background clip is high-side only, and the |zeta| cut applies only to the space-group search merge. The detection compares each pixel's background against the typical background at the same radius on two channels. An azimuthal one (the ring median) finds the holder arm, which is a minority of its ring; a radial one (the background just outside) finds the disk, which the ring median cannot see because inside a fully blocked ring the median is the shadow itself. Pixels are pooled over a 5x5 box and tested only where the background has actually been counted, so low-background data no longer masks the whole detector. Recorded reflections are carved back out - a beam stop cannot block a reflection that was measured. Bit 9 belongs to the run that found it, not to the dataset: it is cleared when a run starts, so a mask read back from a file that carries one starts clear. The user mask (bit 8) is left alone. Scaling and merging gain a low-resolution limit, default 50 A (--scaling-low-resolution <num>, 0 removes it), applied per observation before scaling so it also protects the per-frame scale fit and the space-group search. 50 A is the value XDS configurations use; rugnux_vs_xds.py now matches both of XDS's resolution limits instead of only the high one, so the lowest shell is the same shell in the two programs. The viewer draws the detected shadow in coral with a "Show beam stop" switch in the side panel, exposes the low-resolution limit in the settings dock, and offers detection in its processing jobs. Adding an image marker meant giving the reader a MIN_REAL_PXL_VALUE, because several places classify a pixel by range rather than by equality and would otherwise read the new marker as a very negative intensity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
385 lines
16 KiB
C++
385 lines
16 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "ShadowFinder.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <limits>
|
|
#include <queue>
|
|
#include <type_traits>
|
|
|
|
#include "../../common/JFJochException.h"
|
|
|
|
// A pixel is shadow when its background is below this fraction of the background it is
|
|
// compared against.
|
|
constexpr float SHADOW_RATIO = 0.35f;
|
|
|
|
// The boundary grows outward into partially shadowed pixels down to this fraction, but no
|
|
// further than PENUMBRA_MAX_PX from the core.
|
|
constexpr float PENUMBRA_RATIO = 0.72f;
|
|
constexpr int PENUMBRA_MAX_PX = 14;
|
|
|
|
// Bridge module gaps and small breaks that the holder arm crosses.
|
|
constexpr int BRIDGE_PX = 6;
|
|
|
|
// A pixel whose maximum reaches this recorded a real reflection and is never masked - a
|
|
// beam stop cannot block a reflection that was measured.
|
|
constexpr int64_t MIN_REFLECTION = 25;
|
|
|
|
// Counts the background must have accumulated over the frames and the pooled pixels before
|
|
// a dip in it is believable. Below this a Poisson hole is indistinguishable from a shadow,
|
|
// and testing anyway masks whole detectors on low-background data.
|
|
constexpr double MIN_EXPECTED_COUNTS = 60;
|
|
|
|
// Side of the box the background is pooled over before testing, and how far out the radial
|
|
// comparison looks for unshadowed background.
|
|
constexpr int POOL_PX = 5;
|
|
constexpr float ENVELOPE_MM = 6.0f;
|
|
|
|
// Binary-image helpers on a width*height frame stored row-major as char (0/1). All run once,
|
|
// at GetMask() time; the BFS forms keep them O(pixels) rather than O(pixels * radius).
|
|
namespace {
|
|
|
|
// 8-connected dilation by `r` pixels (Chebyshev), via a multi-source BFS.
|
|
std::vector<char> dilate(const std::vector<char> &in, int W, int H, int r) {
|
|
if (r <= 0)
|
|
return in;
|
|
std::vector<int> dist(in.size(), -1);
|
|
std::queue<int> q;
|
|
for (size_t i = 0; i < in.size(); i++)
|
|
if (in[i]) { dist[i] = 0; q.push(static_cast<int>(i)); }
|
|
while (!q.empty()) {
|
|
const int i = q.front(); q.pop();
|
|
if (dist[i] >= r)
|
|
continue;
|
|
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 (dist[j] < 0) { dist[j] = dist[i] + 1; q.push(j); }
|
|
}
|
|
}
|
|
std::vector<char> out(in.size());
|
|
for (size_t i = 0; i < out.size(); i++)
|
|
out[i] = (dist[i] >= 0) ? 1 : 0;
|
|
return out;
|
|
}
|
|
|
|
// Erosion by `r` = dilation of the complement; outside the frame counts as complement.
|
|
std::vector<char> erode(const std::vector<char> &in, int W, int H, int r) {
|
|
std::vector<char> comp(in.size());
|
|
for (size_t i = 0; i < in.size(); i++)
|
|
comp[i] = !in[i];
|
|
const auto grown = dilate(comp, W, H, r);
|
|
std::vector<char> out(in.size());
|
|
for (size_t i = 0; i < out.size(); i++)
|
|
out[i] = !grown[i];
|
|
return out;
|
|
}
|
|
|
|
// Pixels of `passable` reachable from any of `seeds` (8-connected flood).
|
|
std::vector<char> flood(const std::vector<char> &passable, int W, int H, const std::vector<int> &seeds) {
|
|
std::vector<char> visited(passable.size(), 0);
|
|
std::queue<int> q;
|
|
for (const int s : seeds)
|
|
if (passable[s] && !visited[s]) { visited[s] = 1; q.push(s); }
|
|
while (!q.empty()) {
|
|
const int i = q.front(); q.pop();
|
|
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 (passable[j] && !visited[j]) { visited[j] = 1; q.push(j); }
|
|
}
|
|
}
|
|
return visited;
|
|
}
|
|
|
|
// Fill holes: background not reachable from the image border becomes region.
|
|
std::vector<char> fill_holes(const std::vector<char> ®ion, int W, int H) {
|
|
std::vector<char> bg_visited(region.size(), 0);
|
|
std::queue<int> q;
|
|
auto push = [&](int i) { if (!region[i] && !bg_visited[i]) { bg_visited[i] = 1; q.push(i); } };
|
|
for (int x = 0; x < W; x++) { push(x); push((H - 1) * W + x); }
|
|
for (int y = 0; y < H; y++) { push(y * W); push(y * W + W - 1); }
|
|
while (!q.empty()) {
|
|
const int i = q.front(); q.pop();
|
|
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 (!region[j] && !bg_visited[j]) { bg_visited[j] = 1; q.push(j); }
|
|
}
|
|
}
|
|
std::vector<char> out = region;
|
|
for (size_t i = 0; i < out.size(); i++)
|
|
if (!region[i] && !bg_visited[i])
|
|
out[i] = 1;
|
|
return out;
|
|
}
|
|
|
|
// Sum of `in` over the k x k box centred on each pixel, zero outside the frame.
|
|
std::vector<double> box_sum(const std::vector<double> &in, int W, int H, int k) {
|
|
const int half = k / 2;
|
|
std::vector<double> row(in.size(), 0.0), out(in.size(), 0.0);
|
|
for (int y = 0; y < H; y++) {
|
|
double s = 0;
|
|
for (int x = 0; x <= std::min(half, W - 1); x++)
|
|
s += in[y * W + x];
|
|
for (int x = 0; x < W; x++) {
|
|
row[y * W + x] = s;
|
|
if (x + half + 1 < W) s += in[y * W + x + half + 1];
|
|
if (x - half >= 0) s -= in[y * W + x - half];
|
|
}
|
|
}
|
|
for (int x = 0; x < W; x++) {
|
|
double s = 0;
|
|
for (int y = 0; y <= std::min(half, H - 1); y++)
|
|
s += row[y * W + x];
|
|
for (int y = 0; y < H; y++) {
|
|
out[y * W + x] = s;
|
|
if (y + half + 1 < H) s += row[(y + half + 1) * W + x];
|
|
if (y - half >= 0) s -= row[(y - half) * W + x];
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Median of `values` per integer radius, over the pixels flagged in `use`.
|
|
std::vector<float> ring_median(const std::vector<float> &values, const std::vector<char> &use,
|
|
const std::vector<int> &radius, int max_radius) {
|
|
std::vector<std::vector<float>> bins(max_radius + 1);
|
|
for (size_t i = 0; i < values.size(); i++)
|
|
if (use[i])
|
|
bins[radius[i]].push_back(values[i]);
|
|
std::vector<float> median(max_radius + 1, 0.0f);
|
|
for (int r = 0; r <= max_radius; r++) {
|
|
auto &b = bins[r];
|
|
if (!b.empty()) {
|
|
const size_t k = b.size() / 2;
|
|
std::nth_element(b.begin(), b.begin() + k, b.end());
|
|
median[r] = b[k];
|
|
}
|
|
}
|
|
return median;
|
|
}
|
|
|
|
// Largest baseline over [r, r + win] - the background just outside radius r.
|
|
std::vector<float> outer_envelope(const std::vector<float> &baseline, int win) {
|
|
const int n = static_cast<int>(baseline.size());
|
|
std::vector<float> out(n, 0.0f);
|
|
for (int r = 0; r < n; r++) {
|
|
float v = baseline[r];
|
|
for (int k = 1; k <= win; k++)
|
|
v = std::max(v, baseline[std::min(r + k, n - 1)]);
|
|
out[r] = v;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
ShadowFinder::ShadowFinder(const DiffractionExperiment &experiment, const PixelMask &mask)
|
|
: width(static_cast<int>(experiment.GetXPixelsNumConv())),
|
|
height(static_cast<int>(experiment.GetYPixelsNumConv())),
|
|
beam_x(experiment.GetBeamX_pxl()),
|
|
beam_y(experiment.GetBeamY_pxl()),
|
|
envelope_px(std::max(4, static_cast<int>(std::lround(ENVELOPE_MM / experiment.GetPixelSize_mm())))),
|
|
pixel_mask(mask.GetMask(experiment)),
|
|
max_value(static_cast<size_t>(width) * height, 0),
|
|
sum_value(static_cast<size_t>(width) * height, 0),
|
|
valid_count(static_cast<size_t>(width) * height, 0) {
|
|
if (pixel_mask.size() != max_value.size())
|
|
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
|
"ShadowFinder: pixel mask does not match the detector");
|
|
}
|
|
|
|
template<class T>
|
|
void ShadowFinder::Add(const T *ptr) {
|
|
// The pixel type's sentinel extreme marks "no data" (module gap / masked): the
|
|
// preprocessor/writer stores INT*_MIN for signed and UINT*_MAX for unsigned. For signed
|
|
// types the opposite extreme is a genuine saturated value and is kept, so a saturated
|
|
// reflection still registers as bright.
|
|
T masked;
|
|
if constexpr (std::is_signed_v<T>)
|
|
masked = std::numeric_limits<T>::min();
|
|
else
|
|
masked = std::numeric_limits<T>::max();
|
|
|
|
std::unique_lock ul(m);
|
|
for (size_t i = 0; i < max_value.size(); i++) {
|
|
const T v = ptr[i];
|
|
if (v == masked)
|
|
continue;
|
|
const int64_t vi = static_cast<int64_t>(v);
|
|
if (valid_count[i] == 0 || vi > max_value[i])
|
|
max_value[i] = vi;
|
|
sum_value[i] += vi;
|
|
valid_count[i]++;
|
|
}
|
|
frames++;
|
|
}
|
|
|
|
void ShadowFinder::AddImage(const DataMessage &data, std::vector<uint8_t> buffer) {
|
|
if (static_cast<size_t>(data.image.GetWidth()) * data.image.GetHeight() != max_value.size())
|
|
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
|
"ShadowFinder: image size does not match the detector");
|
|
|
|
const auto ptr = data.image.GetUncompressedPtr(buffer);
|
|
switch (data.image.GetMode()) {
|
|
case CompressedImageMode::Int8: Add(reinterpret_cast<const int8_t *>(ptr)); break;
|
|
case CompressedImageMode::Uint8: Add(reinterpret_cast<const uint8_t *>(ptr)); break;
|
|
case CompressedImageMode::Int16: Add(reinterpret_cast<const int16_t *>(ptr)); break;
|
|
case CompressedImageMode::Uint16: Add(reinterpret_cast<const uint16_t *>(ptr)); break;
|
|
case CompressedImageMode::Int32: Add(reinterpret_cast<const int32_t *>(ptr)); break;
|
|
case CompressedImageMode::Uint32: Add(reinterpret_cast<const uint32_t *>(ptr)); break;
|
|
default:
|
|
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
|
"ShadowFinder: unsupported image mode");
|
|
}
|
|
}
|
|
|
|
uint32_t ShadowFinder::GetFrameCount() const {
|
|
std::unique_lock ul(m);
|
|
return frames;
|
|
}
|
|
|
|
std::vector<uint32_t> ShadowFinder::GetMask() const {
|
|
std::unique_lock ul(m);
|
|
|
|
const int W = width, H = height;
|
|
const int n_pixels = W * H;
|
|
|
|
std::vector<uint32_t> mask(n_pixels, 0);
|
|
if (frames == 0)
|
|
return mask;
|
|
|
|
// mean projection, usable pixels and radius from the beam centre
|
|
std::vector<float> mean(n_pixels, 0.0f);
|
|
std::vector<char> valid(n_pixels, 0);
|
|
std::vector<int> radius(n_pixels, 0);
|
|
int max_radius = 0;
|
|
for (int y = 0; y < H; y++)
|
|
for (int x = 0; x < W; x++) {
|
|
const int i = y * W + x;
|
|
if (valid_count[i] > 0 && pixel_mask[i] == 0) {
|
|
mean[i] = static_cast<float>(static_cast<double>(sum_value[i]) / valid_count[i]);
|
|
valid[i] = 1;
|
|
}
|
|
const float dx = x - beam_x, dy = y - beam_y;
|
|
radius[i] = static_cast<int>(std::lround(std::sqrt(dx * dx + dy * dy)));
|
|
max_radius = std::max(max_radius, radius[i]);
|
|
}
|
|
|
|
// Pool the background over a small box before testing it. A background of a fraction of
|
|
// a count per pixel per frame gives no single pixel enough counts to tell a shadow from
|
|
// a Poisson hole; the stop and its arm are wider than the box, so pooling costs no
|
|
// resolution that matters and multiplies the statistics by the pixels in the box.
|
|
std::vector<double> num(n_pixels), den(n_pixels);
|
|
for (int i = 0; i < n_pixels; i++) {
|
|
num[i] = valid[i] ? mean[i] : 0.0;
|
|
den[i] = valid[i] ? 1.0 : 0.0;
|
|
}
|
|
const auto pooled_sum = box_sum(num, W, H, POOL_PX);
|
|
const auto pooled_count = box_sum(den, W, H, POOL_PX);
|
|
std::vector<float> pooled(n_pixels, 0.0f);
|
|
for (int i = 0; i < n_pixels; i++)
|
|
if (pooled_count[i] > 0)
|
|
pooled[i] = static_cast<float>(pooled_sum[i] / pooled_count[i]);
|
|
|
|
// Azimuthal comparison: the median of the ring, iterated so the shadow stays out of the
|
|
// baseline it is measured against.
|
|
std::vector<float> ratio(n_pixels, 1.0f);
|
|
std::vector<char> excluded(n_pixels, 0);
|
|
std::vector<float> baseline;
|
|
for (int iter = 0; iter < 3; iter++) {
|
|
std::vector<char> use(n_pixels);
|
|
for (int i = 0; i < n_pixels; i++)
|
|
use[i] = valid[i] && !excluded[i];
|
|
baseline = ring_median(pooled, use, radius, max_radius);
|
|
for (int i = 0; i < n_pixels; i++)
|
|
if (valid[i])
|
|
ratio[i] = pooled[i] / std::max(baseline[radius[i]], 1e-6f);
|
|
for (int i = 0; i < n_pixels; i++)
|
|
excluded[i] = valid[i] && ratio[i] < SHADOW_RATIO;
|
|
}
|
|
|
|
// Radial comparison: the background just outside this radius. The disk blocks its rings
|
|
// completely, so their median is the shadow itself and only this comparison sees it.
|
|
const auto envelope = outer_envelope(baseline, envelope_px);
|
|
std::vector<float> ratio_radial(n_pixels, 1.0f);
|
|
for (int i = 0; i < n_pixels; i++)
|
|
if (valid[i])
|
|
ratio_radial[i] = pooled[i] / std::max(envelope[radius[i]], 1e-6f);
|
|
|
|
// A dip counts only where the background it is compared against was actually counted.
|
|
std::vector<char> low(n_pixels, 0);
|
|
for (int i = 0; i < n_pixels; i++) {
|
|
if (!valid[i])
|
|
continue;
|
|
const double counted = frames * pooled_count[i];
|
|
low[i] = (ratio[i] < SHADOW_RATIO && baseline[radius[i]] * counted >= MIN_EXPECTED_COUNTS)
|
|
|| (ratio_radial[i] < SHADOW_RATIO && envelope[radius[i]] * counted >= MIN_EXPECTED_COUNTS);
|
|
}
|
|
|
|
// The shadow is the low region connected to the beam centre, bridging the gaps it crosses.
|
|
const std::vector<char> bridged = dilate(low, W, H, BRIDGE_PX);
|
|
std::vector<int> seeds;
|
|
for (int i = 0; i < n_pixels; i++)
|
|
if (radius[i] < 4)
|
|
seeds.push_back(i);
|
|
const std::vector<char> connected = flood(bridged, W, H, seeds);
|
|
std::vector<char> region(n_pixels);
|
|
for (int i = 0; i < n_pixels; i++)
|
|
region[i] = low[i] && connected[i];
|
|
|
|
// Recorded reflections. A small cluster is required so a single-frame zinger does not count.
|
|
std::vector<char> lit(n_pixels, 0);
|
|
for (int i = 0; i < n_pixels; i++)
|
|
lit[i] = (valid_count[i] > 0) && (max_value[i] >= MIN_REFLECTION);
|
|
std::vector<char> reflection(n_pixels, 0);
|
|
for (int y = 0; y < H; y++)
|
|
for (int x = 0; x < W; x++) {
|
|
const int i = y * W + x;
|
|
if (!lit[i]) continue;
|
|
int neighbours = 0;
|
|
for (int dy = -1; dy <= 1; dy++)
|
|
for (int dx = -1; dx <= 1; dx++) {
|
|
const int yy = y + dy, xx = x + dx;
|
|
if ((dx || dy) && yy >= 0 && yy < H && xx >= 0 && xx < W && lit[yy * W + xx])
|
|
neighbours++;
|
|
}
|
|
reflection[i] = (neighbours >= 2);
|
|
}
|
|
|
|
// Grow the soft boundary, round it and fill the disk interior.
|
|
const std::vector<char> penumbra = dilate(region, W, H, PENUMBRA_MAX_PX);
|
|
for (int i = 0; i < n_pixels; i++)
|
|
if (penumbra[i] && valid[i] && std::min(ratio[i], ratio_radial[i]) < PENUMBRA_RATIO)
|
|
region[i] = 1;
|
|
|
|
region = erode(dilate(region, W, H, 2), W, H, 2);
|
|
region = fill_holes(region, W, H);
|
|
|
|
// Expose recorded reflections - done last, with no fill afterwards, so a spot the shadow
|
|
// still covered is given back rather than re-enclosed.
|
|
const std::vector<char> reflection_grown = dilate(reflection, W, H, 1);
|
|
for (int i = 0; i < n_pixels; i++)
|
|
if (reflection_grown[i])
|
|
region[i] = 0;
|
|
|
|
for (int i = 0; i < n_pixels; i++)
|
|
mask[i] = region[i] ? 1 : 0;
|
|
return mask;
|
|
}
|