Files
Jungfraujoch/image_analysis/hot_pixels/HotPixels.h
T
leonarski_fandClaude Opus 5.5 1bb6d59bf7 HotPixelFinder: mask dead pixels, and run on CCD sweeps too
A pixel that reads at most half of its own sector's level on average, by more
than the bound a hot pixel's excess has to clear, is now masked as dead - the
mirror of the hot rule. Nothing but a defect keeps a pixel dark frame after
frame, and every reflection whose disk covers it is integrated short.

The finder now also runs on CCD sweeps (no sensor depth): a dead CCD pixel
reads its offset, far below any ring with background, and the hot rule's
ten-fold ratio does not depend on Poisson counts either.

Found on 5jk4 (ADSC Q4, 2304x2304): the last row and the first two columns
read the offset (~20 ADU against ~60-100 around them) and were unmasked.
Reflections touching them merged to large negative intensities, and one
observation at y=2298 with sigma 10334 swamped the outer shell's half-set
CC1/2 (3.9 %) and the overall CC1/2 (0.786; 0.998 at rc.174). With the
6630 dead pixels masked the fall-off is clean: CC1/2 0.998, cut 0.96 A
(fit 1.01 A), at --prepass-fraction 0.5 and 1 alike. Counting-detector
sets with low background are untouched (myoglobin X06DA: 0 dead).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
2026-10-08 06:53:40 +02:00

162 lines
9.3 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
// =============================================================================
// HotPixelFinder - pixels the pre-scan's own frames show to be defective
// =============================================================================
//
// A pixel that reads high on frame after frame, whatever the crystal is doing, is not recording
// diffraction. Unmasked, it is integrated as part of whichever reflection's box it falls in, and
// under rotation that is one pixel at one resolution collecting a different reflection on every
// frame that reaches it - a merged intensity hundreds or thousands of times its shell mean,
// supported by one or two observations the merge's outlier test cannot judge. The file's own
// mask misses such pixels on many detectors, and which pixels are hot moves with the threshold
// setting, so they are measured per dataset, on the pre-scan's sample of frames.
//
// On each frame a pixel is LIT when it is Poisson-significantly above the level of its own
// resolution ring - the larger of the median of the 2 px iso-2theta ring and of the ring's
// sixteenth in azimuth, so an ice or powder ring, the polarization dip and a partly shadowed ring
// all set their own level. It is PERSISTENT when it is lit on more of the sampled frames than
// 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;
// * 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.
//
// A persistent pixel is masked if it reads on average at least ten times its ring and its mean excess
// is above the Poisson bound: a weaker one cannot make an outlier. Alone or as part of a patch - a
// patch lit through the whole sweep is stationary in the lab, so it is not a reflection of the
// rotating crystal, and a reflection crossing it reads the patch. A pixel holding the
// detector's error value on most frames is masked with it: every frame already treats it as invalid,
// but the static mask did not know.
//
// A DEAD pixel is the mirror of a hot one: it reads on average at most half of its own sector's level,
// and its mean deficit is above the bound a hot pixel's excess has to clear. Nothing but a defect keeps
// a pixel dark on frame after frame - a reflection only adds counts - and every reflection whose disk
// covers it is integrated short by the deficit. The detector's outermost row and column get no rule of
// their own - a dead edge row, or a hot pixel there, is caught like any other.
//
// A CCD is read with an offset and is not Poisson, but neither test needs it to be: a dead CCD pixel
// reads its offset, far below a ring with any background on it, and a hot one ten times its ring.
// =============================================================================
#include <atomic>
#include <cstdint>
#include <memory>
#include <mutex>
#include <vector>
#include "../../common/DiffractionExperiment.h"
#include "../../common/PixelMask.h"
#ifdef JFJOCH_USE_CUDA
#include "HotPixelsGPU.h"
#endif
class HotPixelFinder {
public:
// Sixteen sectors of the ring; the level is the larger of the two medians.
static constexpr int SECTORS = 16;
// Ring width in 2theta: this many pixels where the beam meets the detector, wider further out.
static constexpr float RING_WIDTH_PX = 2.0f;
// A ring needs this many valid pixels to have a level, a sector this many to have one of its own.
static constexpr int MIN_RING_PIXELS = 16;
static constexpr int MIN_SECTOR_PIXELS = 8;
// Lit: above level + LIT_NSIGMA * noise + LIT_OFFSET (noise = the larger of sqrt(level) and the
// ring's robust spread; the offset keeps a pixel in a ring with no background from being lit by
// one or two photons).
static constexpr float LIT_NSIGMA = 3.3f;
static constexpr float LIT_OFFSET = 2.0f;
// How long one reflection can stay on a pixel, degrees of rotation beyond the oscillation: +-3
// sigma of a rocking curve 2 degrees FWHM, wider than the crystals this is meant for.
static constexpr float ROCKING_WIDTH_DEG = 5.0f;
// Pixels expected to be called persistent by chance on the whole detector.
static constexpr double FAMILY_WISE_RATE = 0.01;
// A persistent pixel is masked only where it reads this many times its ring on average: the
// strength at which the pixels behind the merged outliers were found, and well clear of a pixel
// that is merely a little over-responding.
static constexpr double STRONG_RATIO = 10.0;
// The most frames a finder can be given: its per-pixel counters are 16-bit.
static constexpr int MAX_SAMPLED_FRAMES = UINT16_MAX;
struct Result {
std::vector<uint32_t> mask; // non-zero = masked (1 hot, 2 error value, 3 dead), converted geometry
size_t hot = 0; // persistent above the ring
size_t error = 0; // holding the error value on most frames
size_t dead = 0; // reading at most half of its sector's level
uint32_t frames = 0;
};
HotPixelFinder(const DiffractionExperiment &experiment, const PixelMask &mask, size_t nthreads);
// One preprocessed frame (INT32_MIN = masked or error value, INT32_MAX = saturated). Thread
// safe; `scratch` is the caller's own, reused across its calls.
void AddImage(const int32_t *image, std::vector<int32_t> &scratch);
#ifdef JFJOCH_USE_CUDA
// The same for a frame preprocessed on the device, on `frame`'s stream - the caller's own, as
// `scratch` above. The per-pixel sums are then kept on the device and replace the host's when the
// mask is read, so a finder is fed one way or the other, not both. Thread safe.
void AddDeviceImage(const int32_t *device_image, HotPixelFinderGPU::Frame &frame);
// Build the device half now rather than with the first device frame, so the workers do not wait on
// it one behind the other.
void PrepareDevice();
#endif
// The mask, from the frames added so far. oscillation_deg is the rotation per image and
// spacing_deg the rotation between two consecutive sampled frames.
[[nodiscard]] Result GetMask(double oscillation_deg, double spacing_deg, size_t nthreads);
private:
const DiffractionGeometry geometry;
const Coord axis; // rotation axis; zero where there is none
const size_t width, height;
int nrings = 0;
// Ring * SECTORS + sector of every pixel, -1 where the pixel is already masked. The per-pixel
// arrays are hundreds of megabytes on a large detector, so they are left unwritten when allocated
// and first written by the constructor's parallel pass rather than zeroed on one thread.
std::unique_ptr<int32_t[]> key;
// Where each ring-sector's pixels start in a scratch buffer laid out by key.
std::vector<uint32_t> key_begin;
size_t unmasked = 0;
// The per-pixel sums are guarded by bands of rows, so workers adding frames at the same time
// meet only when they reach the same band.
static constexpr size_t BANDS = 64;
mutable std::mutex band_mutex[BANDS];
std::atomic<size_t> next_band{0};
mutable std::mutex m;
uint32_t frames = 0;
std::unique_ptr<uint16_t[]> n_lit, n_error;
std::unique_ptr<int64_t[]> sum_value;
// A pixel's valid frames and the sum of its ring-sector level over them, kept per ring-sector - the
// frames its ring had a level on, and the sum of the level over those - less what the pixel's own
// error frames among them took out. Integers, so exactly the per-pixel sums, at a fraction of the
// memory traffic per frame.
std::vector<uint16_t> key_frames;
std::vector<int64_t> key_level_sum;
// The same sum for the level a pixel is judged DARK against: its own sector's median, the ring's
// where the sector has too few pixels. Every pixel of a key has the same, so it needs no per-pixel
// correction - a pixel with error frames is not judged dark.
std::vector<int64_t> key_local_level_sum;
std::unique_ptr<uint16_t[]> n_error_ring_ok;
std::unique_ptr<int64_t[]> error_level_sum;
#ifdef JFJOCH_USE_CUDA
std::unique_ptr<HotPixelFinderGPU> gpu; // built by PrepareDevice or the first device frame
#endif
// Each ring-sector's level and lit threshold, from the frame's order statistics, and the frame's
// share of the per-key sums. The device does the same in HotPixelsGPU.cu, with the same roundings.
void AddLevels(const std::vector<int32_t> &sector_level, const std::vector<int32_t> &ring_level,
const std::vector<float> &ring_spread, const std::vector<char> &ring_ok,
const std::vector<int32_t> &local_level,
std::vector<int32_t> &level, std::vector<float> &threshold);
[[nodiscard]] int n_valid(size_t i) const { return key_frames[key[i]] - n_error_ring_ok[i]; }
[[nodiscard]] int64_t sum_level(size_t i) const { return key_level_sum[key[i]] - error_level_sum[i]; }
};