The shadow finder kept only the low region connected to the beam centre, flooding outward from seeds within 4 px of it. That is written for the beam stop and its holder arm, an object that touches the direct beam. Hardware that stands in the beam further out - a pin, a loop - casts a shadow that begins some way out in radius, with lit detector between it and the stop, and no bridge crosses that gap. On a sweep where such a shadow covers 38 % of the detector the per-ring test found the whole of it and connectivity then discarded 950 k pixels, leaving the beam-stop disk alone; those pixels went into integration as measured-and-near-zero. What makes the test specific instead is size: keep the connected components that hold at least MIN_SHADOW_PIXELS core pixels. A shadow is cast by something physical and is correspondingly large, while the background wanders a pixel or two at a time. Measured on five clean in-house sweeps spanning 0.05 to 9.5 counts/px/frame of background, every one returns exactly one such component - the beam stop - and the largest spurious candidate anywhere is 74 pixels, a 27x margin. MIN_EXPECTED_COUNTS becomes a Poisson significance. A ratio says nothing when the background behind it is a handful of photons, so the deficit is now required to be significant against its own scatter, sqrt(2 (E - N + N ln(N/E))) over the pooled counts. This is what makes the comparison scale-free rather than tuned to one exposure: rebuilt from six frames of a low-background sweep, the same thresholds without it mask three quarters of the detector and with it mask none of it. The index of dispersion, measured over the frames, does NOT do this job - it is 1.0 inside the shadow and 1.0 outside it, because a shadowed pixel is Poisson at a low rate and a lit one is Poisson at a high rate. Only the rate relative to the ring separates them. The rest follows: the core ratio moves 0.35 -> 0.50 and the half-shadow reach 0.72 within 14 px -> 0.75 within 30 px, since a pin's penumbra is much wider than a beam stop edge's; the ring walk that finds the rings lying wholly inside the stop keeps its own threshold so the central disk does not move; and those rings join the region after the size filter rather than being asked to be large themselves. On the shadowed sweep this takes the mask from 16,587 px (0.66 %) to 818,184 px (32.32 %), against 956,705 px (37.79 %) for the reference implementation, at a precision of 0.92 against it. Clean sweeps move from 0.60 % to 0.82 %, the width of the same arm. One thing this costs: an empty mask used to be a statement about the beam centre, because the seeds were placed at it. It no longer is, and a centre wrong by a couple of hundred pixels now draws the rings across the background's own fall-off and returns a large spurious mask instead of an empty one. The log message says so. The fix is ordering - the centre is measured from this same projection a moment later - and is not attempted here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
233 lines
10 KiB
C++
233 lines
10 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <catch2/catch_all.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstring>
|
|
#include <vector>
|
|
|
|
#include "../common/DetectorSetup.h"
|
|
#include "../common/DiffractionExperiment.h"
|
|
#include "../common/JFJochMessages.h"
|
|
#include "../common/PixelMask.h"
|
|
#include "../image_analysis/beam_stop/ShadowFinder.h"
|
|
|
|
namespace {
|
|
// Odd and square, so the beam sits on a pixel and a cross-shaped scene is exactly 4-fold
|
|
// symmetric; deliberately not a multiple of 64, so the column-blocked passes meet a short
|
|
// final block.
|
|
constexpr int W = 257, H = 257, C = 128;
|
|
constexpr int NFRAMES = 12;
|
|
constexpr int32_t BACKGROUND = 2; // integer and noise-free, so every mean is exact
|
|
constexpr int STOP_R = 22, ARM_HALF = 5;
|
|
|
|
constexpr size_t I(int x, int y) { return static_cast<size_t>(y) * W + x; }
|
|
|
|
DiffractionExperiment TestExperiment() {
|
|
DiffractionExperiment x(DetDECTRIS(W, H, "Test detector", ""));
|
|
x.IncidentEnergy_keV(WVL_1A_IN_KEV).DetectorDistance_mm(150.0f);
|
|
x.BeamX_pxl(static_cast<float>(C)).BeamY_pxl(static_cast<float>(C));
|
|
return x;
|
|
}
|
|
|
|
// Flat background, an opaque disk on the beam, and an arm running off it to the edge - a beam
|
|
// stop. `cross` gives it four arms instead of one, making the scene invariant under a quarter
|
|
// turn. `reflection` puts a cluster bright enough to count as a reflection inside the disk.
|
|
std::vector<int32_t> Scene(bool cross, bool reflection) {
|
|
std::vector<int32_t> f(static_cast<size_t>(W) * H, BACKGROUND);
|
|
for (int y = 0; y < H; y++) {
|
|
for (int x = 0; x < W; x++) {
|
|
const int dx = x - C, dy = y - C;
|
|
bool blocked = dx * dx + dy * dy <= STOP_R * STOP_R;
|
|
blocked = blocked || (cross ? (std::abs(dy) <= ARM_HALF || std::abs(dx) <= ARM_HALF)
|
|
: (std::abs(dy) <= ARM_HALF && dx >= 0));
|
|
if (blocked)
|
|
f[I(x, y)] = 0;
|
|
}
|
|
}
|
|
if (reflection) {
|
|
for (int y = C - 4; y <= C; y++)
|
|
for (int x = C - 16; x <= C - 12; x++)
|
|
f[I(x, y)] = 100;
|
|
}
|
|
return f;
|
|
}
|
|
|
|
// CompressedImage does not own its pixels, so the frames have to outlive the calls.
|
|
void Feed(ShadowFinder &finder, std::vector<std::vector<int32_t>> &frames, bool cross,
|
|
bool reflection_on_first) {
|
|
std::vector<uint8_t> buffer;
|
|
for (int f = 0; f < NFRAMES; f++) {
|
|
frames.push_back(Scene(cross, reflection_on_first && (f == 0)));
|
|
DataMessage msg{};
|
|
msg.image = CompressedImage(frames.back(), W, H);
|
|
finder.AddImage(msg, buffer);
|
|
}
|
|
}
|
|
}
|
|
|
|
// The scene is a beam stop: an opaque disk on the beam with an arm running off it. What comes back
|
|
// has to be the stop and nothing else - the corners of a detector are not shadowed - and a
|
|
// reflection recorded through the penumbra is given back rather than masked.
|
|
TEST_CASE("ShadowFinder_FindsAnInjectedBeamStop", "[ShadowFinder]") {
|
|
const DiffractionExperiment x = TestExperiment();
|
|
const PixelMask pixel_mask(x);
|
|
ShadowFinder finder(x, pixel_mask);
|
|
|
|
std::vector<std::vector<int32_t>> frames;
|
|
Feed(finder, frames, /*cross=*/false, /*reflection_on_first=*/true);
|
|
REQUIRE(finder.GetFrameCount() == NFRAMES);
|
|
|
|
const auto mask = finder.GetMask();
|
|
REQUIRE(mask.size() == static_cast<size_t>(W) * H);
|
|
|
|
CHECK(mask[I(C, C)] == 1); // the stop itself
|
|
CHECK(mask[I(C + STOP_R - 3, C)] == 1);
|
|
CHECK(mask[I(W - 3, C)] == 1); // the arm, followed to the edge
|
|
CHECK(mask[I(W - 3, C + 4 * ARM_HALF)] == 0); // and nothing beside it
|
|
CHECK(mask[I(0, 0)] == 0);
|
|
CHECK(mask[I(W - 1, 0)] == 0);
|
|
CHECK(mask[I(0, H - 1)] == 0);
|
|
CHECK(mask[I(W - 1, H - 1)] == 0);
|
|
CHECK(mask[I(C - 14, C - 2)] == 0); // a recorded reflection is given back
|
|
|
|
// The mean projection is what the mask is computed from: exact here, because the scene is
|
|
// integer and noise-free.
|
|
const auto projection = finder.GetMeanProjection();
|
|
REQUIRE(projection.size() == mask.size());
|
|
CHECK(projection[I(0, 0)] == Catch::Approx(BACKGROUND));
|
|
CHECK(projection[I(C, C)] == Catch::Approx(0.0));
|
|
|
|
// 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.
|
|
CHECK(std::count(mask.begin(), mask.end(), 1u) == 2612);
|
|
}
|
|
|
|
// 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.
|
|
TEST_CASE("ShadowFinder_MaskDoesNotDependOnTheThreadCount", "[ShadowFinder]") {
|
|
const DiffractionExperiment x = TestExperiment();
|
|
const PixelMask pixel_mask(x);
|
|
ShadowFinder finder(x, pixel_mask);
|
|
|
|
std::vector<std::vector<int32_t>> frames;
|
|
Feed(finder, frames, /*cross=*/false, /*reflection_on_first=*/true);
|
|
|
|
const auto one = finder.GetMask(1);
|
|
CHECK(finder.GetMask(3) == one);
|
|
CHECK(finder.GetMask(8) == one);
|
|
}
|
|
|
|
// Workers accumulate into shards of their own and the shards are summed when the projection is read,
|
|
// so which worker saw which frame must not reach the answer - including the maximum, which only one
|
|
// shard holds when the reflection is on a single frame.
|
|
TEST_CASE("ShadowFinder_ShardingDoesNotChangeTheProjection", "[ShadowFinder]") {
|
|
const DiffractionExperiment x = TestExperiment();
|
|
const PixelMask pixel_mask(x);
|
|
|
|
ShadowFinder serial(x, pixel_mask);
|
|
ShadowFinder sharded(x, pixel_mask);
|
|
sharded.SetShardCount(4);
|
|
|
|
std::vector<std::vector<int32_t>> frames;
|
|
std::vector<uint8_t> buffer;
|
|
for (int f = 0; f < NFRAMES; f++) {
|
|
frames.push_back(Scene(/*cross=*/false, /*reflection=*/f == 0));
|
|
DataMessage msg{};
|
|
msg.image = CompressedImage(frames.back(), W, H);
|
|
serial.AddImage(msg, buffer, 0);
|
|
sharded.AddImage(msg, buffer, static_cast<size_t>(f) % 4);
|
|
}
|
|
|
|
CHECK(serial.GetFrameCount() == sharded.GetFrameCount());
|
|
|
|
const auto a = serial.GetMeanProjection();
|
|
const auto b = sharded.GetMeanProjection();
|
|
REQUIRE(a.size() == b.size());
|
|
// NAN marks a pixel nothing counted, and NAN != NAN, so compare the bits rather than the values.
|
|
CHECK(memcmp(a.data(), b.data(), a.size() * sizeof(float)) == 0);
|
|
|
|
// The reflection is on one frame, so its maximum lives in a single shard. If the fold lost it,
|
|
// the mask would swallow the reflection instead of giving it back.
|
|
CHECK(serial.GetMask(1) == sharded.GetMask(1));
|
|
CHECK(sharded.GetMask(1)[I(C - 14, C - 2)] == 0);
|
|
}
|
|
|
|
// Four opaque arms and a centred disk: the scene is invariant under a quarter turn, so the mask must
|
|
// be too, whatever the thread count.
|
|
TEST_CASE("ShadowFinder_ASymmetricSceneGivesASymmetricMask", "[ShadowFinder]") {
|
|
const DiffractionExperiment x = TestExperiment();
|
|
const PixelMask pixel_mask(x);
|
|
ShadowFinder finder(x, pixel_mask);
|
|
|
|
std::vector<std::vector<int32_t>> frames;
|
|
Feed(finder, frames, /*cross=*/true, /*reflection_on_first=*/false);
|
|
|
|
const auto mask = finder.GetMask(8);
|
|
for (int y = 0; y < H; y++) {
|
|
for (int xi = 0; xi < W; xi++) {
|
|
REQUIRE(mask[I(xi, y)] == mask[I(y, xi)]); // transpose
|
|
REQUIRE(mask[I(xi, y)] == mask[I(W - 1 - y, xi)]); // quarter turn
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hardware that shadows the detector need not touch the direct beam: a pin or a loop begins some way
|
|
// out in radius, with lit detector between it and the stop. The scene here is that - a beam stop
|
|
// with its arm, and a separate opaque patch far from both - and the patch has to come back as
|
|
// shadow. Anchoring the search at the beam centre, as an earlier version did, returned the stop and
|
|
// discarded the patch however deep it was.
|
|
TEST_CASE("ShadowFinder_FindsAShadowThatDoesNotTouchTheBeam", "[ShadowFinder]") {
|
|
const DiffractionExperiment x = TestExperiment();
|
|
const PixelMask pixel_mask(x);
|
|
ShadowFinder finder(x, pixel_mask);
|
|
|
|
// Well clear of the stop, and wide enough that the ring it sits on still has lit pixels to be
|
|
// compared against - as a real pin shadow does, covering part of a ring and not all of it.
|
|
constexpr int PATCH_X0 = 175, PATCH_X1 = 245, PATCH_Y0 = 30, PATCH_Y1 = 90;
|
|
|
|
std::vector<std::vector<int32_t>> frames;
|
|
std::vector<uint8_t> buffer;
|
|
for (int f = 0; f < NFRAMES; f++) {
|
|
frames.push_back(Scene(/*cross=*/false, /*reflection=*/false));
|
|
for (int y = PATCH_Y0; y <= PATCH_Y1; y++)
|
|
for (int xi = PATCH_X0; xi <= PATCH_X1; xi++)
|
|
frames.back()[I(xi, y)] = 0;
|
|
DataMessage msg{};
|
|
msg.image = CompressedImage(frames.back(), W, H);
|
|
finder.AddImage(msg, buffer);
|
|
}
|
|
|
|
const auto mask = finder.GetMask();
|
|
CHECK(mask[I((PATCH_X0 + PATCH_X1) / 2, (PATCH_Y0 + PATCH_Y1) / 2)] == 1);
|
|
CHECK(mask[I(PATCH_X0 + 5, PATCH_Y0 + 5)] == 1);
|
|
CHECK(mask[I(C, C)] == 1); // the stop is still found
|
|
CHECK(mask[I(PATCH_X0 - 40, PATCH_Y0)] == 0); // and the lit detector between them is kept
|
|
CHECK(mask[I(0, H - 1)] == 0);
|
|
}
|
|
|
|
// A flat, unobstructed scene has no shadow in it. The per-pixel test no longer has to reach the
|
|
// beam centre, so what keeps it from calling a wandering background a shadow is size alone - and
|
|
// that has to hold on a scene where nothing is blocked at all.
|
|
TEST_CASE("ShadowFinder_FindsNothingOnAnUnobstructedScene", "[ShadowFinder]") {
|
|
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, BACKGROUND);
|
|
DataMessage msg{};
|
|
msg.image = CompressedImage(frames.back(), W, H);
|
|
finder.AddImage(msg, buffer);
|
|
}
|
|
|
|
const auto mask = finder.GetMask();
|
|
CHECK(std::count(mask.begin(), mask.end(), 1u) == 0);
|
|
}
|