Files
Jungfraujoch/image_analysis/beam_stop/ShadowFinder.h
T
jungfrauandClaude Opus 5 996cd20106 Make the beam-stop mask O(pixels) and parallel
GetMask() was 3.28 s of the 4.78 s pre-scan on a 16M-pixel detector, all on
one thread. Four changes, none of which alters the mask:

dilate() was a multi-source BFS. On a full rectangle with no obstacles the
8-connected graph distance IS the Chebyshev distance - a path stepping towards
the target never has to leave the frame - so the result is a dilation by the
(2r+1) square clipped to the frame, which separates into a pass along x and a
pass along y. That is O(1) per pixel whatever r is, with no queue and no
4-bytes-per-pixel distance array (72 MB, allocated and filled five times per
call). The erode() case is the one that hurt: it dilates the COMPLEMENT, so on
a detector whose shadow is under 1% of the pixels it seeded the BFS from
essentially every pixel.

fill_holes() floods the background from the border. It now floods the bounding
box of the region grown by one: everything outside that box is background and
the box's own ring is background, so the whole outside is one border-connected
component and a background pixel inside the box is border-connected exactly
when it reaches the ring.

The three baseline iterations re-binned every pixel by radius and re-took a
median each time. The iteration only ever excludes pixels whose background is
below a cut, and dividing by a positive baseline is monotone, so a ring's
excluded pixels are exactly its lowest ones and the next median is an order
statistic of the same, unchanging ring. The rings are binned and sorted once;
each iteration then picks a rank and counts a prefix. Nine full-image passes
become one.

box_sum's vertical pass walked one column at a time, striding a whole row per
step and missing on every access; it now carries a strip of columns together.
Each row's and each column's running sum keeps its terms in its order, so the
floating-point rounding is unchanged - only the traversal differs. The pooled
COUNT is a count of at most 25 pixels, so it is an exact integer box sum now
rather than a floating-point one; the background itself stays in double,
because its running sum adds and subtracts across a whole row and in float the
two roundings would not cancel.

The per-pixel passes then run on all threads, and GetMask takes a thread count.

Measured on a 16M-pixel rotation dataset: GetMask 3.28 s -> 0.99 s, whole
pre-scan 4.78 s -> 2.37 s, whole run 1m10s -> 1m03s. The mask is unchanged on
both a 16M and a 2M-pixel dataset (139126 and 22143 shadow pixels), as are the
space group, the merged reflection count and the merging statistics.

Also corrected the comment on erode(): the dilation cannot seed outside the
frame, so outside behaves as foreground, not as complement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 12:50:12 +02:00

88 lines
4.1 KiB
C++

// 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"
#include "../../common/PixelMask.h"
// Finds the beam-stop shadow - the central disk and the holder arm - from a set of images,
// mirroring the accumulate-then-finalize shape of DarkMaskAnalysis: feed frames with
// AddImage(), then read the mask once with GetMask(). The mask is in converted geometry
// and is 1 where the beam stop shadows the detector.
//
// The shadow is a place where the background is missing, so it is found by comparing each
// pixel's mean against the typical background at the same radius - the median over its ring,
// taken over the pixels not already known to be shadowed. That comparison holds wherever the
// ring still has unshadowed pixels to measure. Where it does not - a ring lying wholly inside
// the stop - there is nothing to compare against, and such a ring is shadow in its entirety.
//
// The background belongs to the beam and the shadow to the stop, and the two are not concentric:
// the stop sits off the beam by a sizeable fraction of its own radius. Only the per-ring
// comparison is used, so nothing here assumes they share a centre.
//
// Frames are chosen by the caller; the detection needs enough of them that the background
// is counted rather than guessed (see MIN_EXPECTED_COUNTS in the .cpp).
// Thread-safe: workers call AddImage concurrently, each naming a shard of its own (see
// SetShardCount) - so no two threads touch the same accumulator and nothing is locked while
// an image is added. The shards are summed when the projection is read.
class ShadowFinder {
mutable std::mutex m;
const int width;
const int height;
const float beam_x;
const float beam_y;
std::vector<uint32_t> pixel_mask; // pixels already masked carry no background to test
// Per-pixel projection over the frames added so far (converted geometry). One set per shard:
// the sums and counts are integers, so summing the shards is exact and the result does not
// depend on how the frames were spread over them.
struct Projection {
std::vector<int64_t> max_value;
std::vector<int64_t> sum_value;
std::vector<uint32_t> valid_count;
uint32_t frames = 0;
};
std::vector<Projection> shards;
template<class T> void Add(const T *ptr, Projection &p);
// Sum the shards into one projection. max_value is only taken from a shard that actually
// counted the pixel - a shard that never saw it holds 0, which would beat a genuinely
// negative maximum.
[[nodiscard]] Projection Reduce() const;
public:
ShadowFinder(const DiffractionExperiment &experiment, const PixelMask &mask);
// Give each worker a shard to accumulate into. Must be called before the first AddImage,
// and costs 20 bytes per pixel per shard.
void SetShardCount(size_t n);
// Accumulate one full converted-geometry image into shard `shard`. Gap / masked pixels
// (the pixel type's sentinel extreme) are skipped. `buffer` is scratch space for
// decompression, reused across the calls of one worker.
void AddImage(const DataMessage &data, std::vector<uint8_t> &buffer, size_t shard = 0);
// Compute the shadow mask (1 = shadow, 0 = keep), of the converted pixel count.
// Recomputed from the accumulators on each call - meant to be called once at the end.
// nthreads = 0 asks for all hardware threads. The per-pixel passes over a 16M-pixel detector
// dominate this, and they are all exactly parallel.
[[nodiscard]] std::vector<uint32_t> GetMask(size_t nthreads = 0) const;
// Mean counts per pixel over the frames added, NAN where nothing was counted. This is the
// projection GetMask() tests, so anything else that wants the background before indexing
// gets it without reading the frames a second time.
[[nodiscard]] std::vector<float> GetMeanProjection() const;
[[nodiscard]] uint32_t GetFrameCount() const;
};