Pre-scan: the hot-pixel rule applies on stills too

A pixel lit on more frames than one reflection or chance explains is a defect on stills as on rotation:
a still's reflection is on a pixel for one frame, so two frames are already more than one explains, and
the chance bound measured on the ring covers a lattice that recurs. The rule was switched off without a
rotation (spacing 0), so a live or offline stills run whose input mask was incomplete analysed every
frame with its hot pixels: on a JUNGFRAU 8M stills set with no detector mask ~350 of them made ~350
fixed false spots on every frame, junk lattices on the empty frames and a merge far below one with the
mask. The error-value, dead and unstable rules are unchanged; the GPU candidate pass no longer takes
the spacing flag it only tested.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQsU5a8AJUdJPgWevAK
This commit is contained in:
leonarski_fandClaude Fable 5.1 committed 2026-11-13 01:26:07 +01:00
1 parent 0968981658
commit 8c490db59c
6 files changed
+80 -24

No files matched your search

+11 -7
View File
@@ -367,7 +367,7 @@ HotPixelFinder::Result HotPixelFinder::GetMask(double oscillation_deg, double sp
// tests below could pass (see GetCandidates) - into the host arrays, which are zero everywhere else,
// so the tests below run on them unchanged.
if (gpu) {
const auto c = gpu->GetCandidates(frames, min_valid, spacing_deg > 0.0, k_chance);
const auto c = gpu->GetCandidates(frames, min_valid, k_chance);
std::fill(sums_known.begin(), sums_known.end(), 0);
for (size_t j = 0; j < c.index.size(); j++) {
const size_t i = c.index[j];
@@ -389,13 +389,17 @@ HotPixelFinder::Result HotPixelFinder::GetMask(double oscillation_deg, double sp
std::vector<uint8_t> persistent(width * height, 0);
ParallelChunks(static_cast<int>(height), nthreads, [&](int y0, int y1) {
ForEachPixelInRows(modules, static_cast<int64_t>(width), y0, y1, [&](int64_t, size_t i, double px, double py) {
if (key[i] < 0 || n_valid(i) < min_valid || n_lit[i] == 0 || !(spacing_deg > 0.0)) return;
if (key[i] < 0 || n_valid(i) < min_valid || n_lit[i] == 0) return;
// One reflection's stay on this pixel, in sampled frames: 1 / |zeta| turns the rocking
// width into rotation, and zeta = |axis . (s1 x s0)| with s0 along z.
const Coord s1 = geometry.LabCoord(static_cast<float>(px), static_cast<float>(py)).Normalize();
const double zeta = std::max(1e-4, static_cast<double>(std::fabs(axis * (s1 % Coord(0, 0, 1)))));
const double stay = (oscillation_deg + ROCKING_WIDTH_DEG) / zeta;
const double k_bragg = 1.0 + std::ceil(stay / spacing_deg);
// width into rotation, and zeta = |axis . (s1 x s0)| with s0 along z. On stills (no
// spacing) a reflection is on a pixel for one frame, so two frames are more than one explains.
double k_bragg = 2.0;
if (spacing_deg > 0.0) {
const Coord s1 = geometry.LabCoord(static_cast<float>(px), static_cast<float>(py)).Normalize();
const double zeta = std::max(1e-4, static_cast<double>(std::fabs(axis * (s1 % Coord(0, 0, 1)))));
const double stay = (oscillation_deg + ROCKING_WIDTH_DEG) / zeta;
k_bragg = 1.0 + std::ceil(stay / spacing_deg);
}
const double k = std::min<double>(std::max<double>(k_bragg, k_chance[key[i] / SECTORS]),
n_valid(i));
persistent[i] = n_lit[i] >= k;
+3 -1
View File
@@ -22,7 +22,9 @@
// either explanation that is not a defect allows:
//
// * one Bragg reflection: it stays on a pixel for (oscillation + rocking width) / |zeta| of
// rotation, i.e. on at most 1 + ceil(that / frame spacing) of frames sampled that far apart;
// rotation, i.e. on at most 1 + ceil(that / frame spacing) of frames sampled that far apart; on
// stills (no spacing) a reflection is on a pixel for one frame, and a lattice at the same
// orientation on frame after frame is what the chance rate below measures;
// * chance: independent reflections and noise light a pixel of this ring on a fraction q of the
// frames (measured, on the ring's pixels that are not candidates), and the number of lit frames
// that fewer than 0.01 of all the detector's pixels would reach by chance is the binomial bound.
+5 -5
View File
@@ -208,7 +208,7 @@ __global__ void chance_kernel(size_t npixels, int sectors, const int32_t *__rest
}
// Writes the candidates' indices from `out` on when `out` is given, and counts them either way.
__global__ void candidate_kernel(size_t npixels, int sectors, HotPixelLevelRules rules, uint32_t frames, int min_valid, bool spacing_ok,
__global__ void candidate_kernel(size_t npixels, int sectors, HotPixelLevelRules rules, uint32_t frames, int min_valid,
const int32_t *__restrict__ key, const uint32_t *__restrict__ key_frames,
const uint16_t *__restrict__ n_lit, const uint16_t *__restrict__ n_error,
const uint16_t *__restrict__ n_error_ring_ok, const int64_t *__restrict__ sum_value,
@@ -219,7 +219,7 @@ __global__ void candidate_kernel(size_t npixels, int sectors, HotPixelLevelRules
const int nv = valid_frames(key, key_frames, n_error_ring_ok, i);
const bool error = 2 * static_cast<uint32_t>(n_error[i]) > frames;
const int lit = n_lit[i];
const bool persistent = spacing_ok && nv >= min_valid && lit > 0
const bool persistent = nv >= min_valid && lit > 0
&& lit >= min(max(2, k_chance[key[i] / sectors]), nv);
// Can be dead (HotPixelFinder::GetMask): at most half of its sector's level, no error frames.
const bool dark = n_error[i] == 0 && nv >= min_valid && 2 * sum_value[i] <= key_local_level_sum[key[i]];
@@ -385,7 +385,7 @@ void HotPixelFinderGPU::ChanceCounts(std::vector<int64_t> &lit, std::vector<int6
cuda_err(cudaStreamSynchronize(stream));
}
HotPixelFinderGPU::Candidates HotPixelFinderGPU::GetCandidates(uint32_t frames, int min_valid, bool spacing_ok,
HotPixelFinderGPU::Candidates HotPixelFinderGPU::GetCandidates(uint32_t frames, int min_valid,
const std::vector<int> &k_chance) {
CudaStream stream;
cuda_err(cudaStreamWaitEvent(stream, last_accumulate, 0));
@@ -396,7 +396,7 @@ HotPixelFinderGPU::Candidates HotPixelFinderGPU::GetCandidates(uint32_t frames,
cuda_err(cudaMemcpyAsync(d_k_chance, k_chance.data(), k_chance.size() * sizeof(int), cudaMemcpyHostToDevice,
stream));
cuda_err(cudaMemsetAsync(d_count, 0, sizeof(uint32_t), stream));
candidate_kernel<<<blocks, THREADS, 0, stream>>>(npixels, sectors, rules, frames, min_valid, spacing_ok, key, key_frames,
candidate_kernel<<<blocks, THREADS, 0, stream>>>(npixels, sectors, rules, frames, min_valid, key, key_frames,
n_lit, n_error, n_error_ring_ok, sum_value, key_local_level_sum,
d_k_chance, n_neg, d_count, nullptr);
cuda_err(cudaGetLastError());
@@ -421,7 +421,7 @@ HotPixelFinderGPU::Candidates HotPixelFinderGPU::GetCandidates(uint32_t frames,
CudaDevicePtr<uint16_t> g_lit(n, ALLOC), g_error(n, ALLOC), g_error_ring_ok(n, ALLOC), g_neg(n, ALLOC);
CudaDevicePtr<int64_t> g_sum(n, ALLOC), g_error_level(n, ALLOC);
cuda_err(cudaMemsetAsync(d_count, 0, sizeof(uint32_t), stream));
candidate_kernel<<<blocks, THREADS, 0, stream>>>(npixels, sectors, rules, frames, min_valid, spacing_ok, key,
candidate_kernel<<<blocks, THREADS, 0, stream>>>(npixels, sectors, rules, frames, min_valid, key,
key_frames, n_lit, n_error, n_error_ring_ok, sum_value,
key_local_level_sum, d_k_chance, n_neg, d_count, index);
cuda_err(cudaGetLastError());
+4 -4
View File
@@ -96,9 +96,9 @@ public:
void ChanceCounts(std::vector<int64_t> &lit, std::vector<int64_t> &seen);
// The pixels that can be masked: those holding the error value on more than half of `frames`;
// where spacing_ok, those lit on at least min(max(2, k_chance[ring]), valid frames) of at least
// min_valid valid frames - a persistent pixel is lit on at least that many, because one reflection
// explains at least two; and those with no error frame, at least min_valid valid frames and a sum
// those lit on at least min(max(2, k_chance[ring]), valid frames) of at least min_valid valid
// frames - a persistent pixel is lit on at least that many, because one reflection explains at
// least two; and those with no error frame, at least min_valid valid frames and a sum
// at most half of their sector's level sum, which a dead pixel has. For each, its index and
// per-pixel sums; and every key's sums.
struct Candidates {
@@ -108,5 +108,5 @@ public:
std::vector<uint32_t> key_frames;
std::vector<int64_t> key_level_sum, key_local_level_sum;
};
Candidates GetCandidates(uint32_t frames, int min_valid, bool spacing_ok, const std::vector<int> &k_chance);
Candidates GetCandidates(uint32_t frames, int min_valid, const std::vector<int> &k_chance);
};
+4 -5
View File
@@ -240,9 +240,8 @@ void Rugnux::PreScan(int start_image, int images_to_process, int frame_count, Ru
// radius the first one measured rather than re-reading the frames.
const bool want_width = config_.adaptive_integration_radius && !spot_width_measured_;
// Defective pixels, for the same reason on the same pass: each pixel is compared against its
// resolution ring. Only under rotation, where a reflection moves off a pixel and a defect does not -
// except for the pixels holding the detector's error value and the dead ones, which need no
// rotation and are measured on stills too (MaskDefectivePixels).
// resolution ring, and one lit on more frames than a reflection or chance explains is masked
// (MaskDefectivePixels) - on stills as on rotation: a still's reflection is on a pixel for one frame.
const auto goniometer = experiment_.GetGoniometer();
const bool want_hot = !hot_pixels_measured_
&& (!experiment_.IsRotationIndexing() || (goniometer && goniometer->IsScanning()));
@@ -1414,8 +1413,8 @@ void Rugnux::MaskDefectivePixels(int start_image, const std::vector<int> &sample
}
});
// On stills there is no rotation to count a reflection's stay on a pixel in, so spacing stays 0 and
// the hot-pixel rule is not applied (HotPixelFinder::GetMask); the error-value and dead pixels are.
// On stills there is no rotation to count a reflection's stay on a pixel in: spacing stays 0 and a
// pixel lit on two frames is already more than one reflection explains (HotPixelFinder::GetMask).
const auto goniometer = experiment_.GetGoniometer();
const double oscillation = goniometer ? std::abs(goniometer->GetIncrement_deg()) : 0.0;
const double spacing = goniometer && sample.size() > 1
+53 -2
View File
@@ -93,6 +93,57 @@ TEST_CASE("HotPixelFinder_PersistentPixelNotBragg", "[HotPixelFinder]") {
CHECK(result.unstable == 1);
}
// Stills: no rotation, so a reflection is on a pixel for one frame and nothing but a defect lights the
// same pixel on frame after frame. The same background and ring; a pixel 60 high on every frame and a 3x3
// patch like it are masked, a pixel 20 high on every frame is not (too weak for an outlier), and a pixel
// that a strong reflection lands on on four of the sixty frames - a lattice that recurs - is not: four
// frames are within what chance allows a ring lit as often as this one. The error-value and dead rules as
// on rotation.
TEST_CASE("HotPixelFinder_StillsPersistentPixel", "[HotPixelFinder]") {
DiffractionExperiment x(DetDECTRIS(W, H, "Test detector", ""));
x.IncidentEnergy_keV(WVL_1A_IN_KEV).DetectorDistance_mm(10.0f);
x.BeamX_pxl(static_cast<float>(C)).BeamY_pxl(static_cast<float>(C));
const PixelMask pixel_mask(x);
HotPixelFinder finder(x, pixel_mask, 4);
constexpr int HOT_X = 200, HOT_Y = 200, WARM_X = 190, WARM_Y = 60, ERR_X = 60, ERR_Y = 190;
constexpr int BRAGG_X = 40, BRAGG_Y = 115, PATCH_X = 150, PATCH_Y = 225, DEAD_X = C + 82, DEAD_Y = C;
std::mt19937 rng(2);
std::vector<int32_t> frame(static_cast<size_t>(W) * H), scratch;
for (int f = 0; f < NFRAMES; f++) {
for (int y = 0; y < H; y++)
for (int x_ = 0; x_ < W; x_++) {
const double r = std::hypot(x_ - C, y - C);
const double mean = 5.0 + 45.0 * std::exp(-0.5 * (r - 82.0) * (r - 82.0) / 16.0);
frame[I(x_, y)] = std::poisson_distribution<int>(mean)(rng);
}
frame[I(HOT_X, HOT_Y)] += 60;
frame[I(WARM_X, WARM_Y)] += 20;
frame[I(ERR_X, ERR_Y)] = INT32_MIN;
for (int dy = -1; dy <= 1; dy++)
for (int dx = -1; dx <= 1; dx++)
frame[I(PATCH_X + dx, PATCH_Y + dy)] += 60;
if (f % 15 == 7)
frame[I(BRAGG_X, BRAGG_Y)] += 500;
frame[I(DEAD_X, DEAD_Y)] = 0;
finder.AddImage(frame.data(), scratch);
}
const auto result = finder.GetMask(0.0, 0.0, 4);
CHECK(result.frames == NFRAMES);
CHECK(result.mask[I(HOT_X, HOT_Y)] == 1);
for (int dy = -1; dy <= 1; dy++)
for (int dx = -1; dx <= 1; dx++)
CHECK(result.mask[I(PATCH_X + dx, PATCH_Y + dy)] == 1);
CHECK(result.mask[I(WARM_X, WARM_Y)] == 0);
CHECK(result.mask[I(BRAGG_X, BRAGG_Y)] == 0);
CHECK(result.mask[I(ERR_X, ERR_Y)] == 2);
CHECK(result.mask[I(DEAD_X, DEAD_Y)] == 3);
CHECK(result.hot == 10);
CHECK(result.error == 1);
CHECK(result.dead == 1);
}
#ifdef JFJOCH_USE_CUDA
// The device path against the host one, on frames that take every branch of both: a background bright
// enough on one annulus that the host reads its rings' statistics off the exact selection rather than
@@ -227,8 +278,8 @@ TEST_CASE("HotPixelFinderGPU_FoldedCardsMatchOneCard", "[HotPixelFinder]") {
CHECK(lit_folded == lit_one);
CHECK(seen_folded == seen_one);
const std::vector<int> k_chance(NRINGS, 5);
const auto expected = one.GetCandidates(NFRAMES, 10, true, k_chance);
const auto result = first.GetCandidates(NFRAMES, 10, true, k_chance);
const auto expected = one.GetCandidates(NFRAMES, 10, k_chance);
const auto result = first.GetCandidates(NFRAMES, 10, k_chance);
CHECK(expected.index.size() >= 40);
// The candidates are written by atomic slot, so their order is the device's; compare them by pixel.
const auto by_pixel = [](const HotPixelFinderGPU::Candidates &c) {