Every pixel is compared against the ring it sits on, and the rings were drawn about the beam centre in the file. Displacing that centre costs nothing for tens of pixels and a great deal beyond: on clean sweeps 50 px leaves the mask where it was, 150 px returns a tenth of the detector as shadow with nothing blocking it. Header centres are wrong by that much - one sweep in the corpus states one 351 px from where the data put it, on a run that otherwise indexes every frame and merges at CC1/2 0.999 - so a shadow search that trusts the header is a live hazard now that an empty mask is no longer the fail-safe it used to be. The centre is therefore fitted from the projection BEFORE the mask is read, and named to the finder with BeamCenter(). It costs no frame of its own: the fit reads the same per-pixel mean the mask is computed from. Measured on the phase boundary, the pre-scan grows by 0.4-0.6 s on a 2.5 Mpx detector and by 3-4 s on an 18 Mpx one. The other half of the circle is that this fit is itself biased by a shadow it has not yet masked - on a sweep with a third of the detector behind a pin it put the centre 5 px from the truth and called it 0.40 px, ten of its own sigmas out. It does not have to be unbiased here. The ring comparison does not notice tens of pixels and the bias is a few, and the centre the run REPORTS and consumes is not this one: it is the fit that already ran after the mask was loaded, unchanged, with the shadow out of the way. The order is fit, mask, fit, and only the second answer leaves the function. Two rounds are enough, measured rather than assumed: on four clean sweeps the fit before the mask and the fit after it agree to 0.03 px, so a third round would draw the same rings. The centre estimator is not made robust to a shadow either - it already robustifies across azimuthal sectors, three IRLS rounds on the per-sector shifts, and that is what returned 5 px at 0.40 px, because a third of the azimuth is a second population and not an outlier. And no guard refuses to mask when the fitted centre disagrees with the header: a header 351 px out is precisely the case this has to survive. Mask, before -> after (pixels, and per cent of the detector): clean, 2.5 Mpx 20851 (0.82%) -> 20670 (0.82%) clean, 2.5 Mpx 33308 (1.32%) -> 32730 (1.29%) clean, 18 Mpx, low bkg 173804 (0.96%) -> 170031 (0.94%) clean, 18 Mpx 250196 (1.38%) -> 249915 (1.38%) a third behind a pin 818184 (32.32%) -> 815699 (32.22%) header 351 px out 54183 (0.87%) -> 314592 (5.05%) The clean sweeps do not move; on all four the measured centre is 1-9 px from the file's and the mask shrinks by under 3 %. The pinned sweep does not move either - its header centre happens to be right, so the rings were already where they belong - and its merge is unchanged to the third decimal. The last row goes the other way and is not the ordering: with the rings finally about the beam, this geometry reaches 2 theta = 68 deg, where the polarization of the source modulates the background around a ring by a factor of three. That is four times the threshold this test cuts at, so the horizontal lobes read as shadow. The commit that follows removes that confound; with it the same row reads 39 k px (0.63 %), which is the holder arm and nothing else. Even uncorrected the run's answer is unchanged - same space group, same cell to the third decimal, 12658 unique reflections either way, R_meas 9.06 -> 9.10 %, and 1 % of the multiplicity lost. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
282 lines
13 KiB
C++
282 lines
13 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);
|
|
}
|
|
|
|
// The rings are drawn about a beam centre, and a centre in a file can be a long way from the truth.
|
|
// On a background that falls with radius, rings drawn about the wrong point cut across that
|
|
// fall-off, and pixels that are simply further out than the ring's median read as shadow: a large
|
|
// part of the detector comes back masked with nothing blocking it. The caller therefore names the
|
|
// centre it has measured, and that is the one the comparison has to use.
|
|
TEST_CASE("ShadowFinder_TheRingsFollowTheCentreTheCallerNames", "[ShadowFinder]") {
|
|
// Far enough out that the ring about the file's centre spans a wide range of true radii.
|
|
constexpr int OFFSET = 100;
|
|
// Opaque, well clear of the beam, and larger than the smallest region the test may return.
|
|
constexpr int PATCH_X0 = 175, PATCH_X1 = 245, PATCH_Y0 = 30, PATCH_Y1 = 90;
|
|
constexpr int PATCH_PX = (PATCH_X1 - PATCH_X0 + 1) * (PATCH_Y1 - PATCH_Y0 + 1);
|
|
|
|
DiffractionExperiment x = TestExperiment();
|
|
x.BeamX_pxl(static_cast<float>(C + OFFSET)); // what the file claims
|
|
const PixelMask pixel_mask(x);
|
|
ShadowFinder finder(x, pixel_mask);
|
|
|
|
// A background falling with the true radius, kept under MIN_REFLECTION so no pixel is exempt.
|
|
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, 0);
|
|
for (int y = 0; y < H; y++)
|
|
for (int xi = 0; xi < W; xi++) {
|
|
const float r = std::hypot(static_cast<float>(xi - C), static_cast<float>(y - C));
|
|
const bool blocked = xi >= PATCH_X0 && xi <= PATCH_X1 && y >= PATCH_Y0 && y <= PATCH_Y1;
|
|
frames.back()[I(xi, y)] = blocked ? 0 : std::lround(24.0f * std::exp(-r / 80.0f));
|
|
}
|
|
DataMessage msg{};
|
|
msg.image = CompressedImage(frames.back(), W, H);
|
|
finder.AddImage(msg, buffer);
|
|
}
|
|
|
|
const auto at_file = finder.GetMask();
|
|
finder.BeamCenter(static_cast<float>(C), static_cast<float>(C));
|
|
const auto at_measured = finder.GetMask();
|
|
|
|
// The patch is shadow either way - it is opaque, and no centre makes it look lit.
|
|
CHECK(at_file[I((PATCH_X0 + PATCH_X1) / 2, (PATCH_Y0 + PATCH_Y1) / 2)] == 1);
|
|
CHECK(at_measured[I((PATCH_X0 + PATCH_X1) / 2, (PATCH_Y0 + PATCH_Y1) / 2)] == 1);
|
|
|
|
// About the true centre the rings are flat and only the patch and its penumbra come back;
|
|
// about the file's they cut across the fall-off and a large part of the detector does.
|
|
const auto masked_at_file = std::count(at_file.begin(), at_file.end(), 1u);
|
|
const auto masked_at_measured = std::count(at_measured.begin(), at_measured.end(), 1u);
|
|
CHECK(masked_at_measured < 2 * PATCH_PX);
|
|
CHECK(masked_at_file > 3 * PATCH_PX);
|
|
}
|