beam_stop: add ShadowFinder detection class (unwired)

Add image_analysis/beam_stop/ShadowFinder.{h,cpp} and SHADOW_FINDER.md: a
self-contained beam-stop shadow detector that accumulates images via AddImage()
and returns a mask from GetMask(), mirroring the shape of DarkMaskAnalysis.

It detects the beam-stop shadow (central disk + holder arm) as an azimuthal
anomaly: an iterated radial-median background baseline, a ratio threshold, a
connectivity-to-beam-centre anchor with module-gap bridging, a central low-res
disk guard capped just inside the innermost reflection, and a reflection guard
that never masks a pixel that recorded real signal.

Not yet wired: not added to image_analysis/CMakeLists.txt and no PixelMask /
Rugnux / viewer changes. SHADOW_FINDER.md documents the algorithm and the
deferred offline (Rugnux bit 9 + viewer user-mask) integration plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-18 12:05:05 +02:00
co-authored by Claude Opus 4.8
parent 1911875e1c
commit 9bfecaadf5
3 changed files with 552 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
# Beam-stop shadow finder
`ShadowFinder` detects the beam-stop shadow — the central **disk** plus its **holder arm**
from a small number of images, and returns a per-pixel mask of the shadowed region. It exists
so the pipeline can mask the beam stop **automatically** instead of relying on a hand-drawn user
mask.
It is written in the accumulate-then-finalize shape of `DarkMaskAnalysis`: feed frames with
`AddImage()`, then read the mask once with `GetMask()`. **It is not yet wired into any workflow**
— see *Wiring plan* below. The class is self-contained (`common/` only) and unit-testable.
## API
```cpp
ShadowFinder finder(experiment); // geometry (beam centre, converted size)
std::vector<uint8_t> scratch;
for (int i : first_N_frames) // ~2050 spread frames is plenty (see below)
finder.AddImage(read_frame(i), scratch); // DataMessage with .image, like DarkMaskAnalysis
std::vector<uint32_t> shadow = finder.GetMask(); // 1 = shadow, 0 = keep; converted geometry
```
- **Input** (`AddImage`): a full **converted-geometry** image inside a `DataMessage`, dispatched by
pixel type exactly like `DarkMaskAnalysis::AnalyzeImage`. A pixel equal to its type's sentinel
extreme (`INT*_MIN` for signed, `UINT*_MAX` for unsigned) is treated as *no data* (module gap /
masked) and skipped. For signed types the opposite extreme (`INT*_MAX`, a genuine saturation
clamp) is kept, so saturated reflections still register as bright. Thread-safe.
- **Output** (`GetMask`): a `0/1` vector of size `GetPixelsNumConv()`, ready to feed
`PixelMask::LoadMask(..., bit)`. Recomputed from the accumulators on each call — call it once at
the end.
- **Settings** (`ShadowFinderSettings`): plain struct with defaults (below). When wired, this can
move onto `DiffractionExperiment` the way `DarkMaskSettings` does.
## Algorithm
The shadow is a **stationary, azimuthal anomaly**: air scatter makes the background fall off
smoothly with radius but stay roughly constant around each ring, so the beam stop is a localized
dip at a fixed place. Detection therefore compares each pixel to the typical background **at its
own radius**, not to a global threshold.
1. **Projection.** Per pixel accumulate the **max** and the **mean** (sum + count) over the added
frames, skipping the no-data sentinel. The stop is stationary, so it stays dark while spots and
the air-scatter halo build up around it.
2. **Robust radial baseline.** Take the **median of the mean-projection in each 1-pixel ring**
about the beam centre. The median ignores the shadow (a minority of each ring) and the spots.
Iterate 3×, excluding the shadow it finds, so the baseline is not pulled down by the shadow.
3. **Ratio.** `ratio = mean / baseline(r)`. Shadowed pixels sit far below 1; the smooth halo and
symmetric ice rings cancel to ≈1 and are **not** flagged.
4. **Core + connectivity.** `core = ratio < shadow_ratio`, then keep only the part **connected to
the beam centre** (flood from a small central seed through a `bridge_px`-dilated copy, so the
arm crosses module gaps). The centre anchor is what makes it specific to the beam stop rather
than any random low patch.
5. **Reflection guard.** A pixel whose **max**-projection reached `min_reflection` recorded a real
reflection — a beam stop cannot block a reflection that was measured — so it is **never masked**
(a small-cluster test drops single-pixel zingers).
6. **Central disk (low-res guard).** From the azimuthal **blocked fraction** about the beam centre,
mask a full disk out to the radius where nearly every azimuth is blocked (a disk blocks all
directions; a thin arm/gap does not). Returns nothing when the centre is not behind a disk, and
is **capped just inside the innermost reflection** so a real edge spot is never swallowed. This
guards the low-resolution zone the stop makes un-measurable, including a disk the raw data has
already voided.
7. **Soft boundary.** Grow the core+disk into partially-shadowed pixels (`ratio < penumbra_ratio`)
within `penumbra_max_px`, then a morphological close rounds the contour and hole-filling closes
the disk interior.
8. **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.
Three findings from the prototype are baked in: the **mean** ratio (not the max) does the
detecting — the max projection is a great *picture* but a noisier detector; the **centre anchor**
is what distinguishes the stop from stray low patches; and **a lit pixel is never masked**.
Detection converges in **~23 frames** (the shadow cuts the halo present in every frame); 2050 is
just safe headroom.
## Parameters (`ShadowFinderSettings`)
| field | default | meaning |
|---|---|---|
| `shadow_ratio` | 0.35 | core threshold: mean below this fraction of the radial baseline |
| `penumbra_ratio` | 0.72 | soft boundary grows out to this ratio … |
| `penumbra_max_px` | 14 | … but no further than this from the core |
| `bridge_px` | 6 | bridge module gaps / breaks the arm crosses |
| `min_reflection` | 25 | max-projection value at/above which a pixel is a protected reflection |
## Complexity & memory
Per `AddImage`: `O(pixels)`. `GetMask`: a handful of `O(pixels)` sweeps (three ring-median passes
and a few BFS-based morphology ops), i.e. a couple of seconds on a full ~18 MP detector — a
one-time finalize. Accumulators are `int32 + int64 + uint32` per pixel (~0.3 GB at 18 MP); the
finalize adds a few transient `float`/`char` buffers. Fine for the offline (Rugnux/viewer)
context it targets; not intended for the online per-frame hot path.
## Wiring plan (deferred — implement later)
The detector is deliberately reader-agnostic and bit-agnostic: one algorithm, fed frames by each
caller, with the caller deciding where the resulting mask lands. Two intended consumers:
**1. Build.** Add `beam_stop/ShadowFinder.cpp` + `.h` to the `JFJochImageAnalysis` source list in
`image_analysis/CMakeLists.txt` (mirroring `dark_mask_analysis/DarkMaskAnalysis.*` at lines 2728).
**2. Mask bit + loader** (`common/PixelMask.{h,cpp}`): add `BeamStopShadowBit = 9` (bit 9 is free;
`PixelMask.h:31-36`), a `LoadBeamStopMask` mirroring `LoadDarkBadPixelMask` (`.cpp:298-315`), a
`beam_stop_pixel` counter in `PixelMaskStatistics` / `GetStatistics` (`.cpp:186-205`), and
optionally `GetBeamStopMask()` (mirroring `GetUserMask`). Leave `GetUserMask` untouched (keeps the
user-mask channel clean). Application is then automatic: the CPU/GPU preprocessor masks any pixel
with a non-zero bit (`ImagePreprocessorCPU.cpp:10`, `ImagePreprocessorGPU.cu:104`), and HDF5
write/read + CBOR already carry the full bitfield, so bit 9 persists to `_process.h5` and reloads
with no extra code.
**3. Rugnux offline processing → bit 9** (dataset-specific, per run). Add a `detect_beam_stop`
flag to `ProcessConfig` (`rugnux/Rugnux.h`) and a `--detect-beam-stop` CLI flag mirroring
`--detect-ice-rings` in `rugnux/rugnux_cli.cpp`. In `Rugnux::Run()`, after geometry/goniometer
setup and **before** the azimuthal mapping (`Rugnux.cpp:281`) and the `start_message.pixel_mask`
persist (`:296`), run a `ShadowFinder` pre-pass over a spread sample (`select_equally_spaced_image_ordinals`
+ `reader_.GetRawImage`, as `RefineStillsGeometry` does at `:96-157`) and
`pixel_mask_.LoadBeamStopMask(...)`. Everything downstream then applies and persists it for free.
**4. Viewer standalone → user mask (bit 8)** and a viewer preview. A "Find beam stop" action reads
the first N frames via the worker's `file_reader`, runs `ShadowFinder`, and holds the result as
bit 9 for a distinct-coloured, toggleable overlay; a "Transfer to user mask" action ORs it into the
user mask through the existing chokepoint `JFJochImageReadingWorker::UpdateUserMask_i`
(`.cpp:783`, same idiom as `LoadUserMaskTIFF` add-mode `:851-858`). The distinct colour needs a
`ColorScaleSpecial::BeamStop` + sentinel branch in `JFJochReaderImage.cpp:117-124`; the on/off
toggle mirrors "Show saturated pixels".
**5. Docs.** Add a "Bit 9 beam-stop shadow" row to the bit table in `docs/PIXEL_MASK.md:9-21`.
Not needed: the online broker / receiver / FPGA / REST-API / React-frontend paths — the auto
beam-stop mask is an **offline** (Rugnux + viewer) feature and never goes to the detector.
## Known limitations / future refinements
- **Boundary smoothing** uses a morphological close; the prototype used a Gaussian level-set for a
slightly smoother edge. Upgrade if the edge looks blocky.
- **Off-centre disk**: the low-res disk guard keys on the *stored* beam centre. If that is off the
physical stop (seen on one stills detector), a small already-dead disk gets no extra circular
guard — a data-driven disk centre would close it.
- **Unsigned saturation**: for unsigned images a detector-saturating reflection collides with the
no-data sentinel and is skipped; harmless in practice (such reflections do not sit under the
stop), and absent entirely on the signed/preprocessed path.
+338
View File
@@ -0,0 +1,338 @@
// 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"
// ---------------------------------------------------------------------------------
// Small 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), so a radius-14 dilation is still a single sweep.
// ---------------------------------------------------------------------------------
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 (image border counts as outside).
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 (s >= 0 && s < static_cast<int>(passable.size()) && 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> FillHoles(const std::vector<char> &region, 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;
}
// Median of `values` per integer radius, over the pixels flagged in `use`.
std::vector<float> RingMedian(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;
}
// Fraction of each integer-radius ring that is flagged in `blocked`.
std::vector<float> RingFraction(const std::vector<char> &blocked, const std::vector<int> &radius, int max_radius) {
std::vector<int64_t> num(max_radius + 1, 0), den(max_radius + 1, 0);
for (size_t i = 0; i < blocked.size(); i++) {
den[radius[i]]++;
if (blocked[i]) num[radius[i]]++;
}
std::vector<float> frac(max_radius + 1, 0.0f);
for (int r = 0; r <= max_radius; r++)
frac[r] = den[r] ? static_cast<float>(num[r]) / static_cast<float>(den[r]) : 0.0f;
return frac;
}
} // namespace
// ---------------------------------------------------------------------------------
ShadowFinder::ShadowFinder(const DiffractionExperiment &experiment, ShadowFinderSettings in_settings)
: width(static_cast<int>(experiment.GetXPixelsNumConv())),
height(static_cast<int>(experiment.GetYPixelsNumConv())),
beam_x(experiment.GetBeamX_pxl()),
beam_y(experiment.GetBeamY_pxl()),
settings(in_settings),
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) {}
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 (INT*_MAX) 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 int32_t vi = static_cast<int32_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 = W * H;
const ShadowFinderSettings &S = settings;
std::vector<uint32_t> mask(N, 0);
if (frames == 0)
return mask;
// --- mean projection, per-pixel validity and radius from the beam centre ---
std::vector<float> mean(N, 0.0f);
std::vector<char> valid(N, 0);
std::vector<int> radius(N, 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) {
mean[i] = static_cast<float>(static_cast<double>(sum_value[i]) / valid_count[i]);
valid[i] = 1;
}
const double dx = x - beam_x, dy = y - beam_y;
const int r = static_cast<int>(std::lround(std::sqrt(dx * dx + dy * dy)));
radius[i] = r;
if (r > max_radius) max_radius = r;
}
// --- robust radial baseline; iterate to keep the shadow out of its own baseline ---
std::vector<float> ratio(N, 1.0f);
std::vector<char> excluded(N, 0);
for (int iter = 0; iter < 3; iter++) {
std::vector<char> use(N);
for (int i = 0; i < N; i++)
use[i] = valid[i] && !excluded[i];
const auto baseline = RingMedian(mean, use, radius, max_radius);
for (int i = 0; i < N; i++)
if (valid[i])
ratio[i] = mean[i] / std::max(baseline[radius[i]], 1e-6f);
for (int i = 0; i < N; i++)
excluded[i] = valid[i] && ratio[i] < S.shadow_ratio;
}
// --- shadow core: low-ratio pixels connected to the beam centre (bridging gaps) ---
std::vector<char> low(N);
for (int i = 0; i < N; i++)
low[i] = valid[i] && ratio[i] < S.shadow_ratio;
const std::vector<char> grown = Dilate(low, W, H, S.bridge_px);
std::vector<int> seeds; // a small disk at the beam centre
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++) {
const double dx = x - beam_x, dy = y - beam_y;
if (dx * dx + dy * dy < 4.0 * 4.0)
seeds.push_back(y * W + x);
}
const std::vector<char> connected = Flood(grown, W, H, seeds);
std::vector<char> core(N);
for (int i = 0; i < N; i++)
core[i] = low[i] && connected[i];
// --- real reflections: any pixel that recorded signal is never masked. Require a
// small cluster so a single-frame zinger does not count as a reflection. ---
std::vector<char> lit(N, 0);
for (int i = 0; i < N; i++)
lit[i] = (valid_count[i] > 0) && (max_value[i] >= static_cast<int32_t>(S.min_reflection));
std::vector<char> reflection(N, 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);
}
// --- central low-res disk: the fully-blocked region about the beam centre. Sized by
// the azimuthal blocked fraction (a disk blocks ~every azimuth; a thin arm or
// gap does not), and capped just inside the innermost reflection. ---
std::vector<char> blocked(N);
for (int i = 0; i < N; i++)
blocked[i] = (valid_count[i] == 0) || low[i];
const auto blocked_frac = RingFraction(blocked, radius, max_radius);
int disk_radius = 0;
{
float head = 0.0f; int head_n = 0;
for (int r = 0; r <= std::min(5, max_radius); r++) { head += blocked_frac[r]; head_n++; }
if (head_n > 0 && head / head_n >= 0.65f) { // the beam centre is behind a disk
disk_radius = max_radius;
for (int r = 1; r <= max_radius; r++)
if (blocked_frac[r] < 0.55f) { disk_radius = r; break; }
}
}
int reflection_radius = max_radius + 1; // innermost reflection (ignore the very centre)
for (int i = 0; i < N; i++)
if (reflection[i] && radius[i] > 12 && radius[i] < reflection_radius)
reflection_radius = radius[i];
if (disk_radius > reflection_radius - 4)
disk_radius = reflection_radius - 4;
if (disk_radius < 0)
disk_radius = 0;
// --- assemble: core + disk, grow the soft penumbra, round, fill the disk interior ---
std::vector<char> region(N);
for (int i = 0; i < N; i++)
region[i] = core[i] || (disk_radius > 0 && radius[i] < disk_radius);
const std::vector<char> near = Dilate(region, W, H, S.penumbra_max_px);
for (int i = 0; i < N; i++)
if (near[i] && valid[i] && ratio[i] < S.penumbra_ratio)
region[i] = 1;
region = Erode(Dilate(region, W, H, 2), W, H, 2); // close: round the boundary
region = FillHoles(region, W, H);
// Expose recorded reflections - done last, with no fill afterwards, so a spot the
// geometry 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; i++)
if (reflection_grown[i])
region[i] = 0;
for (int i = 0; i < N; i++)
mask[i] = region[i] ? 1 : 0;
return mask;
}
+76
View File
@@ -0,0 +1,76 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <cstdint>
#include <mutex>
#include <vector>
#include "../../common/CompressedImage.h"
#include "../../common/DiffractionExperiment.h"
#include "../../common/JFJochMessages.h" // DataMessage
// Tunable parameters for ShadowFinder. Plain struct with sensible defaults; when the
// finder is wired into the workflow these can move onto DiffractionExperiment the way
// DarkMaskSettings does. See SHADOW_FINDER.md for what each one does.
struct ShadowFinderSettings {
// A pixel is "shadow core" when its mean is below this fraction of the typical
// (azimuthal-median) background at the same radius.
float shadow_ratio = 0.35f;
// The soft boundary grows outward into partially-shadowed pixels down to this
// fraction of the background, but no further than penumbra_max_px from the core.
float penumbra_ratio = 0.72f;
int penumbra_max_px = 14;
// Bridge module gaps / small breaks that the holder arm crosses (pixels).
int bridge_px = 6;
// A pixel whose max-projection reaches this value recorded a real reflection and is
// never masked - a beam stop cannot block a reflection that was measured. This also
// caps the central disk just inside the innermost such reflection.
float min_reflection = 25.0f;
};
// Detects the beam-stop shadow (central disk + holder arm) from a small number of
// images, mirroring the accumulate-then-finalize shape of DarkMaskAnalysis: feed frames
// with AddImage(), then read the mask once with GetMask(). The returned mask is in
// converted geometry and is 1 where the beam stop shadows the detector.
//
// The shadow is treated as an azimuthal anomaly: a per-radius background baseline is
// robust to the shadow, so a localized dip connected to the beam centre is the beam
// stop. See SHADOW_FINDER.md for the full algorithm and the (deferred) wiring plan.
// Thread-safe: AddImage may be called from several worker threads.
class ShadowFinder {
mutable std::mutex m;
const int width;
const int height;
const double beam_x;
const double beam_y;
const ShadowFinderSettings settings;
// Per-pixel projection over the frames added so far (converted geometry).
std::vector<int32_t> max_value; // maximum over frames
std::vector<int64_t> sum_value; // sum of valid values
std::vector<uint32_t> valid_count; // number of frames the pixel carried data
uint32_t frames = 0;
template<class T> void Add(const T *ptr);
public:
ShadowFinder(const DiffractionExperiment &experiment, ShadowFinderSettings settings = {});
// Accumulate one full converted-geometry image into the projection. Gap / masked
// pixels (the pixel type's sentinel extreme) are skipped. `buffer` is scratch space
// for decompression (mirrors DarkMaskAnalysis::AnalyzeImage).
void AddImage(const DataMessage &data, std::vector<uint8_t> buffer);
// Compute the beam-stop shadow mask (1 = shadow, 0 = keep). Size is the converted
// pixel count. Recomputed from the accumulators on each call - meant to be called
// once at the end; not cheap (see SHADOW_FINDER.md).
[[nodiscard]] std::vector<uint32_t> GetMask() const;
[[nodiscard]] uint32_t GetFrameCount() const;
};