// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include "HotPixels.h" #include #include #include #include #include "../common/JFJochMath.h" #include "../common/ParallelFor.h" namespace { // The lower median of v[0..n), reordering it. int32_t median_of(int32_t *v, size_t n) { std::nth_element(v, v + n / 2, v + n); return v[n / 2]; } // The smallest k with npix * P(Binomial(n, q) >= k) below the family-wise rate, n + 1 if none. int binomial_bound(int n, double q, double npix, double rate) { double tail = 0.0; int k = n + 1; for (int j = n; j >= 0; j--) { tail += std::exp(std::lgamma(n + 1.0) - std::lgamma(j + 1.0) - std::lgamma(n - j + 1.0) + j * std::log(q) + (n - j) * std::log1p(-q)); if (npix * tail >= rate) break; k = j; } return k; } } // namespace HotPixelFinder::HotPixelFinder(const DiffractionExperiment &experiment, const PixelMask &mask, size_t nthreads) : geometry(experiment.GetDiffractionGeometry()), axis(experiment.GetGoniometer() ? experiment.GetGoniometer()->GetAxis().Normalize() : Coord(0, 0, 0)), width(experiment.GetXPixelsNumConv()), height(experiment.GetYPixelsNumConv()), key(width * height, -1), n_valid(width * height, 0), n_lit(width * height, 0), n_error(width * height, 0), sum_value(width * height, 0), sum_level(width * height, 0) { const auto &m = mask.GetMask(); // Rings of equal 2theta, RING_WIDTH_PX wide where the beam meets the detector. const float ring_rad = RING_WIDTH_PX * geometry.GetPixelSize_mm() / geometry.GetDetectorDistance_mm(); ParallelChunks(static_cast(height), nthreads, [&](int y0, int y1) { for (size_t y = y0; y < static_cast(y1); y++) for (size_t x = 0; x < width; x++) { const size_t i = y * width + x; if (m[i] != 0) continue; const float fx = static_cast(x), fy = static_cast(y); const int ring = static_cast(geometry.TwoTheta_rad(fx, fy) / ring_rad); const int sector = std::min(SECTORS - 1, static_cast(geometry.Phi_rad(fx, fy) / static_cast(2.0 * PI) * SECTORS)); key[i] = ring * SECTORS + sector; } }); for (const int32_t k : key) if (k >= 0) { nrings = std::max(nrings, k / SECTORS + 1); unmasked++; } key_begin.assign(static_cast(nrings) * SECTORS + 1, 0); for (const int32_t k : key) if (k >= 0) key_begin[k + 1]++; for (size_t k = 1; k < key_begin.size(); k++) key_begin[k] += key_begin[k - 1]; } void HotPixelFinder::AddImage(const int32_t *image, std::vector &scratch) { const size_t npix = width * height; const size_t nkeys = static_cast(nrings) * SECTORS; // The frame's valid counts laid out by ring and sector. A saturated pixel has no count to add. scratch.resize(npix); std::vector count(nkeys, 0); for (size_t i = 0; i < npix; i++) { const int32_t k = key[i], v = image[i]; if (k < 0 || v == INT32_MIN || v == INT32_MAX) continue; scratch[key_begin[k] + count[k]++] = v; } std::vector sector_level(nkeys, 0); for (size_t k = 0; k < nkeys; k++) if (count[k] >= MIN_SECTOR_PIXELS) sector_level[k] = median_of(scratch.data() + key_begin[k], count[k]); // Each ring's sectors packed together - forward into the space the ring owns, so nothing is // overwritten before it is moved - then its median and its robust spread about that median. std::vector ring_level(nrings, 0); std::vector ring_spread(nrings, 0.0f); std::vector ring_ok(nrings, 0); for (int r = 0; r < nrings; r++) { int32_t *ring = scratch.data() + key_begin[r * SECTORS]; size_t n = 0; for (int s = 0; s < SECTORS; s++) { const size_t k = r * SECTORS + s; std::memmove(ring + n, scratch.data() + key_begin[k], count[k] * sizeof(int32_t)); n += count[k]; } if (n < MIN_RING_PIXELS) continue; ring_ok[r] = 1; ring_level[r] = median_of(ring, n); for (size_t j = 0; j < n; j++) ring[j] = std::abs(ring[j] - ring_level[r]); ring_spread[r] = 1.4826f * static_cast(median_of(ring, n)); } std::vector level(nkeys, 0); std::vector threshold(nkeys, 0.0f); for (size_t k = 0; k < nkeys; k++) { const int r = static_cast(k / SECTORS); level[k] = std::max(ring_level[r], sector_level[k]); const float noise = std::max(std::sqrt(static_cast(std::max(level[k], 0))), ring_spread[r]); threshold[k] = static_cast(level[k]) + LIT_NSIGMA * noise + LIT_OFFSET; } // Every sum is an integer, so the result does not depend on the order the frames arrive in. { std::lock_guard lock(m); frames++; } const size_t rows_per_band = (height + BANDS - 1) / BANDS; const size_t first = next_band.fetch_add(1); for (size_t b = 0; b < BANDS; b++) { const size_t band = (first + b) % BANDS; const size_t begin = std::min(npix, band * rows_per_band * width); const size_t end = std::min(npix, (band + 1) * rows_per_band * width); std::lock_guard lock(band_mutex[band]); for (size_t i = begin; i < end; i++) { const int32_t k = key[i], v = image[i]; if (k < 0) continue; if (v == INT32_MIN) { n_error[i]++; continue; } if (!ring_ok[k / SECTORS]) continue; n_valid[i]++; sum_value[i] += v; sum_level[i] += level[k]; if (v == INT32_MAX || static_cast(v) > threshold[k]) n_lit[i]++; } } } HotPixelFinder::Result HotPixelFinder::GetMask(double oscillation_deg, double spacing_deg, size_t nthreads) const { std::lock_guard lock(m); Result ret; ret.frames = frames; ret.mask.assign(width * height, 0); const int n = static_cast(frames); // The chance rate per ring, from the pixels lit on no more than half of their frames: whatever // lights those - reflections, zingers, noise above the bound - lights a defect-free pixel too. std::vector lit(nrings, 0.0), seen(nrings, 0.0); for (size_t i = 0; i < key.size(); i++) if (key[i] >= 0 && n_valid[i] > 0 && 2 * n_lit[i] <= n_valid[i]) { lit[key[i] / SECTORS] += n_lit[i]; seen[key[i] / SECTORS] += n_valid[i]; } std::vector k_chance(nrings, n + 1); for (int r = 0; r < nrings; r++) if (seen[r] > 0.0) k_chance[r] = binomial_bound(n, std::clamp(lit[r] / seen[r], 1e-6, 0.999), static_cast(unmasked), FAMILY_WISE_RATE); // Persistent: lit on more frames than one reflection or chance explains. const int min_valid = std::max(10, n / 2); std::vector persistent(width * height, 0); ParallelChunks(static_cast(height), nthreads, [&](int y0, int y1) { for (size_t y = y0; y < static_cast(y1); y++) for (size_t x = 0; x < width; x++) { const size_t i = y * width + x; if (key[i] < 0 || n_valid[i] < min_valid || n_lit[i] == 0 || !(spacing_deg > 0.0)) continue; // 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(x), static_cast(y)).Normalize(); const double zeta = std::max(1e-4, static_cast(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); const double k = std::min(std::max(k_bragg, k_chance[key[i] / SECTORS]), n_valid[i]); persistent[i] = n_lit[i] >= k; } }); // Masked: a persistent pixel standing alone - on its own or as one of a pair; a larger patch is // a feature of the scattering, not a defect - whose mean is STRONG_RATIO times its ring's and // whose excess is above the Poisson bound. A weaker one cannot make an outlier. const auto persistent_neighbours = [&](size_t x, size_t y) { int count = 0; for (size_t yy = (y > 0 ? y - 1 : 0); yy <= std::min(height - 1, y + 1); yy++) for (size_t xx = (x > 0 ? x - 1 : 0); xx <= std::min(width - 1, x + 1); xx++) if ((xx != x || yy != y) && persistent[yy * width + xx]) count++; return count; }; ParallelChunks(static_cast(height), nthreads, [&](int y0, int y1) { for (size_t y = y0; y < static_cast(y1); y++) for (size_t x = 0; x < width; x++) { const size_t i = y * width + x; if (key[i] < 0) continue; if (2 * n_error[i] > n) { ret.mask[i] = 2; continue; } if (!persistent[i]) continue; const int neighbours = persistent_neighbours(x, y); bool isolated = neighbours == 0; if (neighbours == 1) for (size_t yy = (y > 0 ? y - 1 : 0); yy <= std::min(height - 1, y + 1); yy++) for (size_t xx = (x > 0 ? x - 1 : 0); xx <= std::min(width - 1, x + 1); xx++) if ((xx != x || yy != y) && persistent[yy * width + xx]) isolated = persistent_neighbours(xx, yy) == 1; const double mean_level = static_cast(sum_level[i]) / n_valid[i]; const double excess = static_cast(sum_value[i] - sum_level[i]) / n_valid[i]; if (isolated && static_cast(sum_value[i]) >= STRONG_RATIO * static_cast(sum_level[i]) && excess >= LIT_NSIGMA * std::sqrt(std::max(mean_level, 0.0)) + LIT_OFFSET) ret.mask[i] = 1; } }); for (const uint32_t v : ret.mask) { ret.hot += v == 1; ret.error += v == 2; } return ret; }