nereids_io/pixels.rs
1//! Data-quality pixel masks and per-pixel spectrum extraction.
2//!
3//! The boolean masks exclude pixels whose data stream is broken, so they are
4//! never fitted. They are not a coverage or low-count filter: low-count
5//! pixels are alive and the counts likelihood handles them.
6//!
7//! Deadness is per acquisition, so always union the sample and open-beam
8//! masks; [`detect_bad_pixels`] does this.
9
10use ndarray::{Array1, Array2, Array3, Axis, Zip};
11
12use crate::error::IoError;
13
14fn validate_counts<D: ndarray::Dimension>(
15 counts: &ndarray::ArrayBase<impl ndarray::Data<Elem = f64>, D>,
16 field: &str,
17) -> Result<(), IoError> {
18 nereids_core::validation::first_non_finite_or_negative(counts.iter().copied()).map_err(
19 |(i, v)| {
20 IoError::InvalidParameter(format!(
21 "{field} counts at flat index {i} must be finite and >= 0, got {v}"
22 ))
23 },
24 )
25}
26
27/// Extract a single spectrum (all TOF bins) from a pixel in the 3D array.
28///
29/// # Arguments
30/// * `data` — 3D array with shape (n_tof, height, width).
31/// * `y` — Pixel row.
32/// * `x` — Pixel column.
33///
34/// # Returns
35/// 1D array of length n_tof.
36pub fn extract_spectrum(data: &Array3<f64>, y: usize, x: usize) -> Array1<f64> {
37 data.slice(ndarray::s![.., y, x]).to_owned()
38}
39
40/// Average spectra over a rectangular region of interest.
41///
42/// # Arguments
43/// * `data` — 3D array with shape (n_tof, height, width).
44/// * `y_range` — Row range (start..end).
45/// * `x_range` — Column range (start..end).
46///
47/// # Errors
48/// Returns `IoError::InvalidParameter` if the ROI is empty or exceeds the
49/// spatial dimensions of `data`.
50///
51/// # Returns
52/// Averaged 1D spectrum of length n_tof.
53pub fn average_roi(
54 data: &Array3<f64>,
55 y_range: std::ops::Range<usize>,
56 x_range: std::ops::Range<usize>,
57) -> Result<Array1<f64>, IoError> {
58 if y_range.is_empty() || x_range.is_empty() {
59 return Err(IoError::InvalidParameter(
60 "ROI ranges must be non-empty for average_roi".into(),
61 ));
62 }
63 if y_range.end > data.shape()[1] || x_range.end > data.shape()[2] {
64 return Err(IoError::InvalidParameter(format!(
65 "ROI range ({}..{}, {}..{}) exceeds data spatial dims ({}, {})",
66 y_range.start,
67 y_range.end,
68 x_range.start,
69 x_range.end,
70 data.shape()[1],
71 data.shape()[2],
72 )));
73 }
74 let roi = data.slice(ndarray::s![.., y_range, x_range]);
75 // Mean over spatial dimensions (axes 1 and 2).
76 // unwrap is safe here: the ROI is guaranteed non-empty by the check above.
77 Ok(roi.mean_axis(Axis(2)).unwrap().mean_axis(Axis(1)).unwrap())
78}
79
80/// Detect dead pixels (zero counts across all TOF bins of one stack).
81///
82/// Pipeline-integrity screen only — see the module-level "Pixel masks —
83/// pipeline integrity only" section. Prefer [`detect_bad_pixels`] as the
84/// validating entry point; this function performs no input validation of its
85/// own (backward compatibility: GUI, Python ABI, persisted masks).
86///
87/// Precondition: `data` is finite and non-negative.
88/// Under that invariant the exact `== 0.0` test is
89/// intentional:
90///
91/// - Counts are validated non-negative, and `0.0 × efficiency == 0.0` holds
92/// exactly in IEEE 754, so a `<= 0.0` test would be dead code.
93/// - A NaN bin makes a pixel appear *alive* (`NaN == 0.0` is `false`). This
94/// is deliberate: corrupt input must be rejected upstream, never silently
95/// masked. [`detect_bad_pixels`] rejects such input up front.
96/// - An empty TOF axis (`shape[0] == 0`) makes the all-zero test vacuously
97/// true for *every* pixel — the whole detector would be reported dead.
98/// This non-validating function keeps that behaviour for backward
99/// compatibility; the validating entry points ([`detect_bad_pixels`],
100/// [`detect_dead_pixels_chunked`], [`detect_hot_pixels`]) reject empty
101/// stacks up front.
102///
103/// # Arguments
104/// * `data` — 3D array with shape (n_tof, height, width).
105///
106/// # Returns
107/// 2D boolean mask, shape (height, width). `true` = dead pixel.
108pub fn detect_dead_pixels(data: &Array3<f64>) -> ndarray::Array2<bool> {
109 let shape = data.shape();
110 let (height, width) = (shape[1], shape[2]);
111 let mut mask = ndarray::Array2::from_elem((height, width), false);
112
113 for y in 0..height {
114 for x in 0..width {
115 let all_zero = (0..shape[0]).all(|t| data[[t, y, x]] == 0.0);
116 mask[[y, x]] = all_zero;
117 }
118 }
119
120 mask
121}
122
123/// Default MAD multiplier for the global (stage-1) cut of
124/// [`detect_hot_pixels`].
125///
126/// The one-sided Gaussian tail at 6 robust σ is P(Z > 6) ≈ 9.9e-10, i.e.
127/// ~2.6e-4 expected false flags on a full 512×512 frame (262 144 pixels) —
128/// on a *unimodal* image the screen essentially never rejects a
129/// statistically plausible pixel, while a railed pixel sits tens of robust
130/// σ above any plausible median.
131///
132/// On a **bimodal** image the global cut alone is not trustworthy: when the
133/// darker population holds the median (a sample covering >50 % of the FOV,
134/// or an aperture-limited open beam), the MAD reflects only the dark
135/// population's internal spread, and *every* bright-region pixel lands
136/// above `med + k·MAD`. [`detect_hot_pixels`] therefore never flags on the
137/// global cut alone — the local-neighborhood confirmation
138/// ([`HOT_LOCAL_FACTOR`]) must also pass.
139pub const HOT_PIXEL_K_MAD: f64 = 6.0;
140
141/// Local-neighborhood confirmation factor (stage 2) of
142/// [`detect_hot_pixels`].
143///
144/// A pixel that passes the global cut is flagged only if its total also
145/// exceeds `HOT_LOCAL_FACTOR ×` the median total of its available live
146/// 8-neighbors. The factor separates detector *point defects* from scene
147/// structure:
148///
149/// - A railed/runaway pixel is spatially isolated and typically ≥100× its
150/// neighbors, so it clears 10× with a wide margin.
151/// - Adjacent-pixel scene gradients (beam profile, sample absorption) are
152/// ≤2–3×; even directly across a sharp sample edge only one ring of
153/// neighbors is mixed, and the neighbor *median* stays on the pixel's
154/// own side of the edge.
155/// - A fully-railed 1-px row/column still leaves each railed pixel with
156/// ≥5 normal neighbors of 8, so the neighbor median stays normal and the
157/// line IS caught in a single pass. Railed CLUSTERS ≥2 px wide are
158/// caught by the stage-2 fixpoint erosion — see the
159/// "Fixpoint erosion of railed clusters" section of
160/// [`detect_hot_pixels`].
161///
162/// **Width-1 limitation (accepted trade-off)**: a 1-px-wide bright *scene*
163/// line at ≥`HOT_LOCAL_FACTOR`× local contrast is spatially
164/// indistinguishable from a railed line and IS masked. Contiguous bright
165/// regions ≥2 px wide are safe (their boundary pixels keep a same-side
166/// neighbor median, so the erosion never seeds — see [`detect_hot_pixels`],
167/// "Why bright scene regions never erode"). Real scene features on VENUS
168/// are PSF-blurred over ≥2 px, so ≥10× single-pixel scene contrast is
169/// physically rare; masking it is the accepted price for catching railed
170/// rows/columns.
171pub const HOT_LOCAL_FACTOR: f64 = 10.0;
172
173/// Reject a stack with an empty TOF axis (`shape[0] == 0`).
174///
175/// Every-bin predicates are vacuously true over zero bins: an empty stack
176/// would mark the whole detector dead (and gives the hot screen an all-zero
177/// totals image) with no error. Called up front by the validating detector
178/// entry points; [`detect_dead_pixels`] deliberately stays non-validating
179/// (see its rustdoc).
180fn validate_n_tof(data: &Array3<f64>, field: &str) -> Result<(), IoError> {
181 if data.shape()[0] == 0 {
182 return Err(IoError::InvalidParameter(format!(
183 "{field} has an empty TOF axis (shape[0] == 0) — dead/hot detection \
184 over zero bins is vacuous and would mask every pixel"
185 )));
186 }
187 Ok(())
188}
189
190/// Detect dead pixels across acquisition chunks (dead-in-any-chunk).
191///
192/// Catches *intermittent* deadness that [`detect_dead_pixels`] on the summed
193/// stack cannot see: a pixel that was dead for one acquisition chunk but
194/// alive in another has nonzero summed counts, yet its dead-chunk data
195/// corrupts the combined spectrum. A pixel is flagged iff it is all-zero
196/// (exact `== 0.0` test, same rationale as [`detect_dead_pixels`]) in *any*
197/// chunk.
198///
199/// False-positive control: a live pixel with expected total counts λ within
200/// one chunk is all-zero in that chunk with probability P = e^(−λ) (Poisson);
201/// over m chunks, P(misflag) ≤ m·e^(−λ). Guidance: chunk the acquisition so
202/// each live pixel has λ ≥ 20 expected counts per chunk — e^(−20) ≈ 2e-9,
203/// i.e. ~5e-4·m expected false flags on a 512² detector.
204///
205/// There is deliberately **no** per-TOF-block zero-run variant. Within one
206/// TOF-summed stack, wall-clock-intermittent deadness is invisible — the
207/// pixel just shows uniformly reduced counts across all TOF bins, with no
208/// zeros to find. And any within-stack zero-run cut is a statistical screen
209/// on low-count pixels (a pixel at 0.01 counts/bin normally has ~100-bin
210/// zero runs) — exactly the banned failure mode (see the module-level
211/// "Pixel masks — pipeline integrity only" section).
212///
213/// Chunks may have differing TOF axis lengths (`n_tof`) — ragged event-mode
214/// re-histogramming is fine; deadness is spatial, so only the spatial
215/// dimensions must agree.
216///
217/// # Arguments
218/// * `chunks` — One 3D counts array per acquisition chunk, each with shape
219/// (n_tof, height, width). `n_tof` may differ between chunks; (height,
220/// width) must not.
221///
222/// # Returns
223/// 2D boolean mask, shape (height, width). `true` = dead in at least one
224/// chunk.
225///
226/// # Errors
227/// Returns `IoError::InvalidParameter` if `chunks` is empty, any chunk has
228/// an empty TOF axis (`shape[0] == 0` — its all-zero test would vacuously
229/// mark every pixel dead), or any chunk contains a non-finite or negative
230/// value, and `IoError::ShapeMismatch` if the chunks' spatial dimensions
231/// differ.
232pub fn detect_dead_pixels_chunked(chunks: &[Array3<f64>]) -> Result<Array2<bool>, IoError> {
233 if chunks.is_empty() {
234 return Err(IoError::InvalidParameter(
235 "detect_dead_pixels_chunked requires at least one chunk".into(),
236 ));
237 }
238
239 let first = chunks[0].shape();
240 let (height, width) = (first[1], first[2]);
241 for (i, chunk) in chunks.iter().enumerate() {
242 let s = chunk.shape();
243 // n_tof (s[0]) may differ between chunks; only spatial dims must agree.
244 if s[1] != height || s[2] != width {
245 return Err(IoError::ShapeMismatch(format!(
246 "chunks[{i}] spatial dims ({}, {}) != chunks[0] spatial dims ({height}, {width})",
247 s[1], s[2],
248 )));
249 }
250 validate_counts(chunk, &format!("chunks[{i}]"))?;
251 validate_n_tof(chunk, &format!("chunks[{i}]"))?;
252 }
253
254 let mut mask = Array2::from_elem((height, width), false);
255 for chunk in chunks {
256 let dead = detect_dead_pixels(chunk);
257 Zip::from(&mut mask)
258 .and(&dead)
259 .for_each(|m, &d| *m = *m || d);
260 }
261 Ok(mask)
262}
263
264/// Detect hot (railed / runaway) pixels via a two-stage criterion: a
265/// robust one-sided log-space median + k·MAD screen on per-pixel total
266/// counts (stage 1, global), confirmed by a local-neighborhood isolation
267/// test (stage 2).
268///
269/// Pipeline-integrity screen only — see the module-level "Pixel masks —
270/// pipeline integrity only" section. The algorithm:
271///
272/// 1. `totals[y, x] = Σ_t data[t, y, x]`.
273/// 2. The statistics sample is `ln(totals)` over `totals > 0` pixels *only* —
274/// dead pixels are excluded *before* the median/MAD so `ln(0)` never
275/// enters and a large dead population cannot drag the median down. If no
276/// live pixels exist, every pixel is unflagged (all-`false` mask).
277/// 3. `med = median(ln totals)`, `mad = median(|ln totals − med|)`.
278/// 4. `sigma = max(MAD_TO_SIGMA·mad, exp(−med/2))`. The second term is the
279/// delta-method Poisson floor of `ln N`: `Var[ln N] ≈ 1/N` evaluated at
280/// `N = exp(med)` (medians commute with monotone maps, so `exp(med)` is
281/// the median total), giving `σ_floor = 1/√exp(med) = exp(−med/2)`. The
282/// robust scale can never legitimately sit below counting noise; this
283/// guards `mad == 0` on quantized low-count images *without* becoming a
284/// low-count screen — it only ever raises the threshold. Worked check:
285/// an image where most totals are 1.0 and some are 2.0 has `mad = 0` and
286/// floor `= 1`, so the threshold is `e^6 ≈ 403×` the median — the 2-count
287/// pixels are NOT flagged, while a railed pixel still is.
288/// 5. Stage 1 (global): a pixel is a *candidate* iff `totals > 0 &&
289/// ln(total) > med + k_mad·sigma` — **upper tail only**. A stuck-low
290/// pixel is indistinguishable from a low-count-alive pixel and is
291/// deliberately kept (masking it would be the banned low-count screen).
292/// Railed/always-max pixels are subsumed by the upper tail: no fixed
293/// saturation value exists after efficiency correction, so a
294/// saturation-constant test would be wrong anyway.
295/// 6. Stage 2 (local confirmation), iterated to a **fixpoint**: a candidate
296/// is flagged iff its total also exceeds [`HOT_LOCAL_FACTOR`] × the
297/// median of its 8-neighborhood reference sample, where each neighbor
298/// contributes its total if live (`total > 0`) and not yet flagged,
299/// contributes `0.0` if already flagged (a known defect cannot vouch
300/// for its neighbors — see below), and is omitted if dead (a dead pixel
301/// carries no scene information). Edge pixels use whatever neighbors
302/// exist. A candidate whose reference sample is empty (every neighbor
303/// dead — isolated live pixel in a dead field) keeps the global verdict;
304/// a candidate whose neighbors are mostly flagged defects likewise stays
305/// flagged (its reference median is 0). After each full pass over the
306/// fixed stage-1 candidate list, newly confirmed flags are applied and
307/// the pass repeats until a pass adds no new flag.
308///
309/// # Fixpoint erosion of railed clusters
310///
311/// A single stage-2 pass misses the INTERIOR of a railed cluster ≥2 px
312/// wide: an interior pixel's 8-neighbors are railed too, so its neighbor
313/// median is railed and the ratio test refutes the flag. Iterating to a
314/// fixpoint erodes such clusters from the boundary inward — once the
315/// cluster's outermost pixels are flagged they stop vouching (each
316/// contributes `0.0` instead of its railed total), the reference median of
317/// the next ring drops back to the background level, and the next pass
318/// flags that ring. Contributing `0.0` (rather than omitting the flagged
319/// neighbor) is load-bearing: with omission, a 3-background + 3-railed
320/// reference sample has an even-count [`nereids_core::stats::median`]
321/// midpoint mid-gap (≈ railed/2), the ratio test reads ~2× and the erosion
322/// stalls; with the zero contribution the median stays on the background
323/// side and erosion completes. Erosion fully consumes clusters up to 3 px
324/// wide in their narrower dimension (point defects, 1-px lines, 2–3-px-wide
325/// blobs/segments — the physical shapes of railed detector defects)
326/// PROVIDED the cluster exposes at least one end cap or convex corner to
327/// normal-scene neighbors — erosion must seed somewhere. An EDGE-TO-EDGE
328/// railed band ≥2 px wide (both ends off-detector — spanning the full
329/// detector width or height) exposes neither: every interior band pixel
330/// keeps ≥5 railed of 8 neighbors, and even the on-detector-border band
331/// ends keep 3 railed of 5, so every neighbor median stays railed, no
332/// pixel ever seeds, and the band is NOT caught
333/// (`test_detect_hot_pixels_edge_to_edge_2px_band_not_flagged_by_design`).
334/// This is deliberate, not an oversight: a slit-aperture open beam
335/// produces a genuine full-width bright SCENE band that is
336/// pixel-for-pixel indistinguishable from such a defect, so a full-span
337/// row/column screen would mask it — re-introducing the exact bimodal
338/// failure stage 2 exists to prevent. Full-span detector pathologies of
339/// width ≥2 belong in a declared/file mask. (A full-span width-1 railed
340/// line IS caught — each of its pixels keeps ≥4 normal neighbors,
341/// `test_detect_hot_pixels_full_railed_column_caught`; and a ≥2-px band
342/// with even one end cap inside the detector is fully consumed from that
343/// cap, `test_detect_hot_pixels_2px_band_one_end_on_detector_fully_caught`.)
344/// A hard-edged railed rectangle ≥4 px wide keeps its interior (only its
345/// convex corners flag): it is pixel-for-pixel indistinguishable from a
346/// hard-edged bright scene region, which must survive (below).
347///
348/// **Termination bound**: flags are only ever added, and every pass except
349/// the last adds at least one, so at most `height·width` passes can do
350/// work; the loop is additionally capped at `height·width` passes to make
351/// the bound structural rather than reasoned. In practice the pass count
352/// is on the order of the defect-cluster radius (one pass for point
353/// defects and 1-px lines).
354///
355/// # Why bright scene regions never erode
356///
357/// Erosion must *seed* at a bright-region boundary pixel. A boundary
358/// pixel of a contiguous bright scene region ≥2 px wide keeps ≥4 of its
359/// 8 neighbors on its own (bright) side for any straight or diagonal
360/// edge, so its reference median stays bright and its ratio is the scene
361/// gradient (≤2–3× across real edges) — far below the ≥10×
362/// [`HOT_LOCAL_FACTOR`]. Stage 1's global cut additionally gates which
363/// pixels can seed at all. With no seed, the fixpoint is reached with
364/// zero flags in the region and it survives intact
365/// (`test_detect_hot_pixels_large_psf_bright_region_not_eroded`). Two
366/// documented, test-pinned exceptions — both physically rare on VENUS,
367/// where the detector PSF blurs real scene features over ≥2 px so ≥10×
368/// single-pixel contrast steps do not occur in scene:
369///
370/// - a **width-1 bright line** at ≥10× local contrast is spatially
371/// indistinguishable from a railed line and IS masked — the accepted
372/// trade-off for catching railed rows/columns
373/// (`test_detect_hot_pixels_1px_bright_line_flagged_by_design`);
374/// - the single pixel at a **sharp convex (90°) corner** of a hard-edged
375/// ≥10× region sees only 3 same-side neighbors, its reference median
376/// falls on the dark side, and it is flagged (this predates the
377/// fixpoint); erosion does NOT propagate past it — the adjacent edge
378/// pixels keep ≥4 bright unflagged neighbors
379/// (`test_detect_hot_pixels_hard_edged_bright_rectangle_corners_only`).
380///
381/// The two stages encode complementary definitions of "hot": stage 1 says
382/// *statistically implausible for this image*, stage 2 says *spatially
383/// isolated*, and a railed/hot pixel is a detector **point defect** — it
384/// must be both. Stage 2 exists because the global cut alone fails
385/// catastrophically on **bimodal** images: with a dark majority holding the
386/// median (a sample covering >50 % of the FOV, or an aperture-limited open
387/// beam), the MAD reflects only the dark population's internal spread and
388/// the ENTIRE bright minority exceeds `med + k·MAD`. A contiguous bright
389/// REGION is scene, not a defect — masking it would reject statistically
390/// plausible pixels, the exact failure the module rules ban. A true point
391/// defect beats its neighbor median by ≥100× and is caught by both stages
392/// even *inside* a bright region and even as part of a railed row/column
393/// or a small railed cluster (see [`HOT_LOCAL_FACTOR`]).
394///
395/// # Raw counts required
396///
397/// `data` must be **raw detected counts** (unscaled). The Poisson floor in
398/// step 4 assumes `Var[N] = N`; any prior scaling silently breaks that
399/// identity — down-scaling (proton-charge-normalized rates ≪ 1, per-pixel
400/// gain division) inflates the floor and can suppress real flags, while
401/// up-scaling (event weights > 1) deflates it below true counting noise.
402/// Run the detectors on unscaled counts and normalize afterwards; the GUI
403/// does exactly this (all three of its raw-counts paths — TIFF pair,
404/// HDF5 with open beam, HDF5 without open beam — pass the raw
405/// sample/open-beam stacks, before any normalization).
406///
407/// # Arguments
408/// * `data` — 3D raw-counts array with shape (n_tof, height, width).
409/// * `k_mad` — Robust-σ multiplier for the stage-1 upper-tail cut; use
410/// [`HOT_PIXEL_K_MAD`] unless you have a reason not to.
411///
412/// # Returns
413/// 2D boolean mask, shape (height, width). `true` = hot pixel.
414///
415/// # Errors
416/// Returns `IoError::InvalidParameter` if `data` contains a non-finite or
417/// negative value or has an empty TOF axis (`shape[0] == 0`), or if `k_mad`
418/// is not finite and positive.
419pub fn detect_hot_pixels(data: &Array3<f64>, k_mad: f64) -> Result<Array2<bool>, IoError> {
420 validate_counts(data, "data")?;
421 // An empty TOF axis would yield an all-zero totals image (empty live
422 // set → all-false mask) rather than corrupt output, but the validating
423 // entry points reject it uniformly (see validate_n_tof).
424 validate_n_tof(data, "data")?;
425 // NaN bypasses `>`, so pair the order comparison with is_finite().
426 if !(k_mad.is_finite() && k_mad > 0.0) {
427 return Err(IoError::InvalidParameter(format!(
428 "k_mad must be finite and > 0, got {k_mad}"
429 )));
430 }
431
432 let totals = data.sum_axis(Axis(0));
433 let mut mask = Array2::from_elem(totals.raw_dim(), false);
434
435 // Statistics over live (totals > 0) pixels only: ln(0) never enters, and
436 // a large dead population cannot drag the median down.
437 let log_totals: Vec<f64> = totals
438 .iter()
439 .filter(|&&t| t > 0.0)
440 .map(|&t| t.ln())
441 .collect();
442 let Some(med) = nereids_core::stats::median(&log_totals) else {
443 // No live pixels at all: nothing to compare against — flag nothing.
444 return Ok(mask);
445 };
446 // unwrap is safe here: median() returned Some, so log_totals is non-empty.
447 let mad = nereids_core::stats::median_abs_deviation(&log_totals, med).unwrap();
448
449 // Delta-method Poisson floor of ln N at the median total (see rustdoc
450 // step 4): the robust scale can never sit below counting noise.
451 let sigma = f64::max(nereids_core::stats::MAD_TO_SIGMA * mad, (-med / 2.0).exp());
452 let threshold = med + k_mad * sigma;
453
454 // Stage 1 (global robust cut), upper tail only: the candidate list is
455 // fixed for the whole stage-2 fixpoint iteration below.
456 let (height, width) = totals.dim();
457 let mut candidates: Vec<(usize, usize)> = Vec::new();
458 for y in 0..height {
459 for x in 0..width {
460 let total = totals[[y, x]];
461 if total > 0.0 && total.ln() > threshold {
462 candidates.push((y, x));
463 }
464 }
465 }
466
467 // Stage 2 (local confirmation): only a spatially ISOLATED excess is a
468 // detector point defect — a contiguous bright region is scene. This is
469 // what keeps the global cut honest on bimodal images (see rustdoc).
470 //
471 // Iterated to a FIXPOINT to erode railed clusters from their boundary
472 // inward (see rustdoc, "Fixpoint erosion of railed clusters"): each
473 // pass evaluates the not-yet-flagged candidates against the PREVIOUS
474 // pass's mask (batch update — order-independent within a pass), and
475 // the loop ends when a pass adds no new flag.
476 //
477 // Termination bound: flags are only ever added and every pass except
478 // the last adds at least one, so ≤ height·width passes can do work;
479 // the explicit cap makes that bound structural. Pass 1 sees an
480 // all-false mask and is exactly the pre-fixpoint single pass.
481 let max_passes = height * width;
482 let mut neighbor_totals: Vec<f64> = Vec::with_capacity(8);
483 for _pass in 0..max_passes {
484 let mut newly_flagged: Vec<(usize, usize)> = Vec::new();
485 for &(y, x) in &candidates {
486 if mask[[y, x]] {
487 continue;
488 }
489 let total = totals[[y, x]];
490 // 8-neighborhood reference sample; edge pixels use what exists.
491 neighbor_totals.clear();
492 for ny in y.saturating_sub(1)..=(y + 1).min(height - 1) {
493 for nx in x.saturating_sub(1)..=(x + 1).min(width - 1) {
494 if (ny, nx) == (y, x) {
495 continue;
496 }
497 if mask[[ny, nx]] {
498 // Already-flagged neighbor: a known defect cannot
499 // vouch for the candidate. It contributes the
500 // lowest possible scene value (zero total) so the
501 // reference median is not dragged up by the defect
502 // itself — load-bearing for cluster erosion (see
503 // rustdoc: an omitted neighbor leaves an even-count
504 // sample whose midpoint median stalls the erosion).
505 neighbor_totals.push(0.0);
506 } else {
507 // Live (total > 0) unflagged neighbors contribute
508 // their totals; dead neighbors carry no scene
509 // information and are omitted.
510 let t = totals[[ny, nx]];
511 if t > 0.0 {
512 neighbor_totals.push(t);
513 }
514 }
515 }
516 }
517 let flagged = match nereids_core::stats::median(&neighbor_totals) {
518 Some(local_med) => total > HOT_LOCAL_FACTOR * local_med,
519 // Empty sample: every neighbor is dead (isolated live pixel
520 // in a dead field) — nothing local can refute the global
521 // verdict. (A mostly-flagged neighborhood is NOT empty:
522 // the zeros give a reference median of 0 and the candidate
523 // stays flagged, the consistent generalization.)
524 None => true,
525 };
526 if flagged {
527 newly_flagged.push((y, x));
528 }
529 }
530 if newly_flagged.is_empty() {
531 break;
532 }
533 for &(y, x) in &newly_flagged {
534 mask[[y, x]] = true;
535 }
536 }
537 Ok(mask)
538}
539
540/// Detect all pipeline-corrupting pixels: dead ∪ hot over sample and
541/// (optionally) open beam.
542///
543/// This is the validating entry point that the GUI and Python bindings
544/// should use. Deadness/hotness is per-acquisition — a pixel dead only in
545/// the open-beam run still corrupts every transmission ratio computed from
546/// it — so the masks of both stacks are unioned:
547///
548/// `mask = dead(sample) ∪ hot(sample) [∪ dead(open_beam) ∪ hot(open_beam)]`
549///
550/// The stacks' TOF axis lengths may differ (deadness is spatial); only the
551/// spatial dimensions must agree.
552///
553/// Both stacks must be **raw detected counts** (unscaled) — see the "Raw
554/// counts required" section of [`detect_hot_pixels`]: scaling distorts the
555/// Poisson floor of the hot screen. The GUI satisfies this: all three of
556/// its raw-counts paths (TIFF pair, HDF5 with open beam, HDF5 without
557/// open beam) call this function on the raw sample/open-beam stacks,
558/// before any normalization.
559///
560/// # Arguments
561/// * `sample` — Raw sample counts, shape (n_tof, height, width).
562/// * `open_beam` — Optional raw open-beam counts, shape
563/// (n_tof', height, width).
564/// * `hot_k_mad` — `Some(k)` to include the [`detect_hot_pixels`] screen
565/// with multiplier `k` (use [`HOT_PIXEL_K_MAD`]); `None` for dead-only
566/// detection.
567///
568/// # Returns
569/// 2D boolean mask, shape (height, width). `true` = exclude pixel.
570///
571/// # Errors
572/// Returns `IoError::InvalidParameter` if either stack contains a
573/// non-finite or negative value or has an empty TOF axis (`shape[0] == 0`
574/// — the dead test over zero bins would vacuously mask every pixel) or
575/// `hot_k_mad` is `Some` of a non-finite/non-positive value, and
576/// `IoError::ShapeMismatch` if the spatial dimensions differ.
577pub fn detect_bad_pixels(
578 sample: &Array3<f64>,
579 open_beam: Option<&Array3<f64>>,
580 hot_k_mad: Option<f64>,
581) -> Result<Array2<bool>, IoError> {
582 // Validate everything up front (house rule), before any detector runs.
583 // The public detectors called below re-validate; that duplication is one
584 // O(n) sweep and keeps each entry point independently safe.
585 validate_counts(sample, "sample")?;
586 validate_n_tof(sample, "sample")?;
587 if let Some(ob) = open_beam {
588 validate_counts(ob, "open_beam")?;
589 validate_n_tof(ob, "open_beam")?;
590 let (ss, os) = (sample.shape(), ob.shape());
591 // n_tof may differ (deadness is spatial); spatial dims must agree.
592 if ss[1] != os[1] || ss[2] != os[2] {
593 return Err(IoError::ShapeMismatch(format!(
594 "sample spatial dims ({}, {}) != open_beam spatial dims ({}, {})",
595 ss[1], ss[2], os[1], os[2],
596 )));
597 }
598 }
599 if let Some(k) = hot_k_mad {
600 // NaN bypasses `>`, so pair the order comparison with is_finite().
601 if !(k.is_finite() && k > 0.0) {
602 return Err(IoError::InvalidParameter(format!(
603 "hot_k_mad must be finite and > 0, got {k}"
604 )));
605 }
606 }
607
608 let mut mask = detect_dead_pixels(sample);
609 if let Some(k) = hot_k_mad {
610 let hot = detect_hot_pixels(sample, k)?;
611 Zip::from(&mut mask)
612 .and(&hot)
613 .for_each(|m, &h| *m = *m || h);
614 }
615 if let Some(ob) = open_beam {
616 let ob_dead = detect_dead_pixels(ob);
617 Zip::from(&mut mask)
618 .and(&ob_dead)
619 .for_each(|m, &d| *m = *m || d);
620 if let Some(k) = hot_k_mad {
621 let ob_hot = detect_hot_pixels(ob, k)?;
622 Zip::from(&mut mask)
623 .and(&ob_hot)
624 .for_each(|m, &h| *m = *m || h);
625 }
626 }
627 Ok(mask)
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633
634 #[test]
635 fn test_extract_spectrum() {
636 // 3 TOF bins, 2×2 image
637 let mut data = Array3::<f64>::zeros((3, 2, 2));
638 data[[0, 1, 0]] = 10.0;
639 data[[1, 1, 0]] = 20.0;
640 data[[2, 1, 0]] = 30.0;
641
642 let spectrum = extract_spectrum(&data, 1, 0);
643 assert_eq!(spectrum.len(), 3);
644 assert_eq!(spectrum[0], 10.0);
645 assert_eq!(spectrum[1], 20.0);
646 assert_eq!(spectrum[2], 30.0);
647 }
648
649 #[test]
650 fn test_average_roi() {
651 // 2 TOF bins, 4×4 image. Set a 2×2 region to known values.
652 let mut data = Array3::<f64>::zeros((2, 4, 4));
653 // TOF bin 0: region [1..3, 1..3] = 100
654 for y in 1..3 {
655 for x in 1..3 {
656 data[[0, y, x]] = 100.0;
657 data[[1, y, x]] = 200.0;
658 }
659 }
660
661 let avg = average_roi(&data, 1..3, 1..3).unwrap();
662 assert_eq!(avg.len(), 2);
663 assert!((avg[0] - 100.0).abs() < 1e-10);
664 assert!((avg[1] - 200.0).abs() < 1e-10);
665 }
666
667 #[test]
668 fn test_detect_dead_pixels() {
669 let mut data = Array3::<f64>::zeros((3, 2, 2));
670 // Pixel (0,0) is dead (all zeros)
671 // Pixel (0,1) has a count in frame 1
672 data[[1, 0, 1]] = 5.0;
673 // Pixel (1,0) has counts
674 data[[0, 1, 0]] = 10.0;
675 // Pixel (1,1) is dead
676
677 let mask = detect_dead_pixels(&data);
678 assert!(mask[[0, 0]]); // dead
679 assert!(!mask[[0, 1]]); // alive
680 assert!(!mask[[1, 0]]); // alive
681 assert!(mask[[1, 1]]); // dead
682 }
683
684 #[test]
685 fn test_detect_dead_pixels_chunked_catches_intermittent() {
686 // ACCEPTANCE: pixel (0, 1) is dead throughout chunk 0 but
687 // alive in chunk 1 — intermittent deadness that corrupts the
688 // combined spectrum.
689 let mut chunk0 = Array3::from_elem((3, 2, 2), 5.0);
690 for t in 0..3 {
691 chunk0[[t, 0, 1]] = 0.0;
692 }
693 let chunk1 = Array3::from_elem((3, 2, 2), 5.0);
694
695 let mask = detect_dead_pixels_chunked(&[chunk0.clone(), chunk1.clone()]).unwrap();
696 assert!(mask[[0, 1]], "intermittently dead pixel must be flagged");
697 assert!(!mask[[0, 0]]);
698 assert!(!mask[[1, 0]]);
699 assert!(!mask[[1, 1]]);
700
701 // The gap being closed: on the element-wise summed stack the pixel
702 // has nonzero counts everywhere, so detect_dead_pixels misses it.
703 let summed = &chunk0 + &chunk1;
704 let summed_mask = detect_dead_pixels(&summed);
705 assert!(
706 !summed_mask[[0, 1]],
707 "summed-stack detection cannot see intermittent deadness — \
708 that is exactly why the chunked variant exists"
709 );
710 }
711
712 #[test]
713 fn test_detect_dead_pixels_chunked_low_count_alive_not_flagged() {
714 // ACCEPTANCE: a pixel with a single count per chunk is alive
715 // and must be kept — masks are pipeline-integrity only, never a
716 // low-count screen.
717 let mut chunk0 = Array3::from_elem((4, 2, 2), 5.0);
718 let mut chunk1 = Array3::from_elem((4, 2, 2), 5.0);
719 for t in 0..4 {
720 chunk0[[t, 1, 1]] = 0.0;
721 chunk1[[t, 1, 1]] = 0.0;
722 }
723 chunk0[[2, 1, 1]] = 1.0; // one lone count in chunk 0
724 chunk1[[0, 1, 1]] = 1.0; // one lone count in chunk 1
725
726 let mask = detect_dead_pixels_chunked(&[chunk0, chunk1]).unwrap();
727 assert!(!mask[[1, 1]], "low-count-alive pixel must not be flagged");
728 }
729
730 #[test]
731 fn test_detect_dead_pixels_chunked_empty_slice_err() {
732 let err = detect_dead_pixels_chunked(&[]).unwrap_err();
733 assert!(matches!(err, IoError::InvalidParameter(_)));
734 }
735
736 #[test]
737 fn test_detect_dead_pixels_chunked_empty_tof_chunk_err() {
738 // A zero-frame chunk would vacuously mark the whole detector dead.
739 let chunk0 = Array3::from_elem((3, 2, 2), 1.0);
740 let chunk1 = Array3::<f64>::zeros((0, 2, 2));
741 let err = detect_dead_pixels_chunked(&[chunk0, chunk1]).unwrap_err();
742 assert!(matches!(err, IoError::InvalidParameter(_)));
743 assert!(err.to_string().contains("chunks[1]"));
744 assert!(err.to_string().contains("empty TOF axis"));
745 }
746
747 #[test]
748 fn test_detect_dead_pixels_chunked_spatial_mismatch_err() {
749 let chunk0 = Array3::from_elem((3, 2, 2), 1.0);
750 let chunk1 = Array3::from_elem((3, 2, 3), 1.0);
751 let err = detect_dead_pixels_chunked(&[chunk0, chunk1]).unwrap_err();
752 assert!(matches!(err, IoError::ShapeMismatch(_)));
753 }
754
755 #[test]
756 fn test_detect_dead_pixels_chunked_nan_err() {
757 let chunk0 = Array3::from_elem((3, 2, 2), 1.0);
758 let mut chunk1 = Array3::from_elem((3, 2, 2), 1.0);
759 chunk1[[0, 0, 0]] = f64::NAN;
760 let err = detect_dead_pixels_chunked(&[chunk0, chunk1]).unwrap_err();
761 assert!(matches!(err, IoError::InvalidParameter(_)));
762 assert!(err.to_string().contains("chunks[1]"));
763 }
764
765 #[test]
766 fn test_detect_dead_pixels_chunked_ragged_n_tof_ok() {
767 // Ragged event re-histogramming: n_tof may differ between chunks.
768 let chunk0 = Array3::from_elem((3, 2, 2), 1.0);
769 let chunk1 = Array3::from_elem((7, 2, 2), 1.0);
770 let mask = detect_dead_pixels_chunked(&[chunk0, chunk1]).unwrap();
771 assert!(mask.iter().all(|&m| !m));
772 }
773
774 #[test]
775 fn test_detect_hot_pixels_catches_railed() {
776 // ACCEPTANCE: a railed pixel (65535 counts/bin) amid a
777 // realistic slightly varying background (~100/bin) is flagged;
778 // its neighbors are not.
779 let mut data = Array3::<f64>::zeros((4, 3, 3));
780 for t in 0..4 {
781 for y in 0..3 {
782 for x in 0..3 {
783 // Background 95..103 counts/bin (totals 380..412).
784 data[[t, y, x]] = 95.0 + (y * 3 + x) as f64;
785 }
786 }
787 }
788 for t in 0..4 {
789 data[[t, 1, 1]] = 65535.0;
790 }
791
792 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
793 assert!(mask[[1, 1]], "railed pixel must be flagged");
794 for y in 0..3 {
795 for x in 0..3 {
796 if (y, x) != (1, 1) {
797 assert!(!mask[[y, x]], "neighbor ({y}, {x}) must not be flagged");
798 }
799 }
800 }
801 }
802
803 #[test]
804 fn test_detect_hot_pixels_low_count_alive_not_flagged() {
805 // ACCEPTANCE — the 13%-rejection regression guard: a pixel
806 // with 1 total count amid ~300-count pixels is low-count-ALIVE and
807 // must be kept. The screen is upper-tail only.
808 let mut data = Array3::from_elem((3, 3, 3), 100.0);
809 for t in 0..3 {
810 data[[t, 0, 2]] = 0.0;
811 }
812 data[[1, 0, 2]] = 1.0; // total 1 count
813
814 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
815 assert!(!mask[[0, 2]], "low-count-alive pixel must not be flagged");
816 assert!(mask.iter().all(|&m| !m), "nothing here is hot");
817 }
818
819 #[test]
820 fn test_detect_hot_pixels_uniform_image_poisson_floor_path() {
821 // Perfectly uniform background → MAD == 0 → the delta-method
822 // Poisson floor exp(-med/2) is the active scale. The railed pixel
823 // must still be flagged, the uniform background must not.
824 let mut data = Array3::from_elem((4, 3, 3), 100.0);
825 for t in 0..4 {
826 data[[t, 2, 0]] = 65535.0;
827 }
828
829 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
830 assert!(mask[[2, 0]]);
831 assert_eq!(mask.iter().filter(|&&m| m).count(), 1);
832 }
833
834 #[test]
835 fn test_detect_hot_pixels_quantized_low_counts_none_flagged() {
836 // Worked check from the rustdoc: mostly 1-count totals with some
837 // 2-count totals → mad = 0, floor = 1, threshold = e^6 ≈ 403× the
838 // median — the 2-count pixels are NOT flagged.
839 let mut data = Array3::from_elem((1, 3, 3), 1.0);
840 data[[0, 0, 0]] = 2.0;
841 data[[0, 2, 2]] = 2.0;
842
843 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
844 assert!(
845 mask.iter().all(|&m| !m),
846 "quantized low-count image must produce no hot flags"
847 );
848 }
849
850 /// Build the 8×8 single-TOF-bin two-moat fixture for the stage-1
851 /// robust-scale tests ( P1-2): two 3×3 all-dead moats
852 /// (rows 1–3 × cols 1–3 and rows 4–6 × cols 4–6) with a `probe` at
853 /// (2,2) and a `control` at (5,5) in their centers, and the remaining
854 /// 46 pixels filled row-major with `n_a` × `a` then (46 − n_a) × `b`.
855 ///
856 /// A dead moat makes a probe's stage-2 reference sample EMPTY (every
857 /// neighbor dead → omitted), which keeps the global verdict — so a
858 /// probe's flag outcome is decided purely by the stage-1 threshold
859 /// under test, never vetoed by the local confirmation. Dead pixels
860 /// are excluded from the stage-1 statistics, so with `n_a = 24` the
861 /// live sample is 24×`a`, 22×`b`, probe, control (n = 48, both
862 /// probes above `b`): the two central order statistics are the 24th
863 /// (= ln a) and 25th (= ln b), pinning `med = (ln a + ln b)/2`
864 /// exactly; the deviation sample has 46 × |ln(b/a)|/2 below the two
865 /// probe deviations, pinning `mad = |ln(b/a)|/2` exactly.
866 fn stage1_two_moat_grid(a: f64, n_a: usize, b: f64, probe: f64, control: f64) -> Array3<f64> {
867 let mut data = Array3::<f64>::zeros((1, 8, 8));
868 data[[0, 2, 2]] = probe;
869 data[[0, 5, 5]] = control;
870 let mut filled = 0usize;
871 for y in 0..8 {
872 for x in 0..8 {
873 let in_moat1 = (1..=3).contains(&y) && (1..=3).contains(&x);
874 let in_moat2 = (4..=6).contains(&y) && (4..=6).contains(&x);
875 if in_moat1 || in_moat2 {
876 // Moat cells stay 0.0 (dead); the probe/control
877 // assignments above are inside the moats and survive.
878 continue;
879 }
880 data[[0, y, x]] = if filled < n_a { a } else { b };
881 filled += 1;
882 }
883 }
884 assert_eq!(filled, 46, "8×8 minus two 3×3 moats is 46 background px");
885 data
886 }
887
888 /// P1-2 (1/3): the robust-MAD branch of the stage-1
889 /// scale `sigma = max(MAD_TO_SIGMA·mad, exp(−med/2))` DECIDES an
890 /// outcome. Every earlier test drove sigma through the Poisson
891 /// floor (uniform/quantized backgrounds → mad = 0), so a mutation of
892 /// the MAD branch survived the suite.
893 ///
894 /// Fixture (see [`stage1_two_moat_grid`]): 24 × A = 8000,
895 /// 22 × B = 12500, probe P = 40 000, control C = 200 000.
896 /// A·B = 10⁸ and B/A = 1.25², so ln A = 8.9871968 and
897 /// ln B = 9.4334839 sit symmetrically about ln 10⁴. Worked stage-1
898 /// arithmetic over the 48 live pixels:
899 ///
900 /// med = (ln A + ln B)/2 = ln 10⁴ = 9.2103404
901 /// mad = ln 1.25 = 0.2231436
902 /// MAD term = 1.4826022 × 0.2231436 = 0.3308331
903 /// floor = exp(−med/2) = 10⁻² = 0.01
904 /// sigma = max(0.3308331, 0.01) = 0.3308331 (MAD wins)
905 /// threshold = med + 6·sigma = 11.1953391
906 ///
907 /// Probe: ln P = 10.5966347 < 11.1953391 (margin 0.60) → NOT a
908 /// candidate → kept. Under a floor-only mutation (sigma = 0.01) the
909 /// threshold collapses to 9.2703404 < ln P and the dead moat flags
910 /// the probe unconditionally — this assertion fails.
911 /// Control: ln C = 12.2060726 > 11.1953391 (margin 1.01) → flagged;
912 /// a mutation that INFLATES the MAD term (≥2× → threshold ≥ 13.18)
913 /// unflags it. The MAD value itself separates the two probes.
914 #[test]
915 fn test_detect_hot_pixels_mad_scale_decides_stage1_threshold() {
916 let data = stage1_two_moat_grid(8000.0, 24, 12500.0, 40_000.0, 200_000.0);
917 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
918 assert!(
919 !mask[[2, 2]],
920 "probe below the MAD-driven threshold must be kept"
921 );
922 assert!(
923 mask[[5, 5]],
924 "control above the MAD-driven threshold must be flagged"
925 );
926 assert_eq!(mask.iter().filter(|&&m| m).count(), 1);
927 }
928
929 /// P1-2 (2/3) — crossover pin, MAD term just ABOVE
930 /// the Poisson floor: `max()` must pick the MAD branch.
931 ///
932 /// Fixture: 24 × A = 100/1.08, 22 × B = 108 (A·B = 10⁴ →
933 /// med = (ln A + ln B)/2 = ln 100 = 4.6051702, floor =
934 /// exp(−med/2) = 10⁻¹ = 0.1), probe P = 190, control C = 10 000.
935 ///
936 /// mad = ln 1.08 = 0.0769610
937 /// MAD term = 1.4826022 × 0.0769610 = 0.1141026
938 /// → 14 % ABOVE the 0.1 floor: MAD branch wins, barely.
939 /// correct threshold = 4.6051702 + 6×0.1141026 = 5.2897858
940 /// floor-mutant thr = 4.6051702 + 6×0.1 = 5.2051702
941 ///
942 /// ln P = ln 190 = 5.2470241 sits BETWEEN the two thresholds
943 /// (margins 0.042 above the mutant's, 0.043 below the correct one):
944 /// correct code keeps the probe; a mutant that resolves the
945 /// crossover the wrong way (max→min, dropped MAD branch, deflated
946 /// MAD_TO_SIGMA) flags it via the dead moat. Control:
947 /// ln C = 9.2103404 ≫ 5.2897858 → flagged (liveness control).
948 #[test]
949 fn test_detect_hot_pixels_mad_term_just_above_poisson_floor_wins() {
950 let data = stage1_two_moat_grid(100.0 / 1.08, 24, 108.0, 190.0, 10_000.0);
951 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
952 assert!(
953 !mask[[2, 2]],
954 "probe between floor and MAD thresholds must be kept when the MAD branch wins"
955 );
956 assert!(mask[[5, 5]], "control must be flagged");
957 assert_eq!(mask.iter().filter(|&&m| m).count(), 1);
958 }
959
960 /// P1-2 (3/3) — crossover pin, MAD term just BELOW
961 /// the Poisson floor: `max()` must pick the floor.
962 ///
963 /// Fixture: 24 × A = 100/1.06, 22 × B = 106 (med = ln 100 =
964 /// 4.6051702, floor = 0.1 as above), probe P = 175, control
965 /// C = 10 000.
966 ///
967 /// mad = ln 1.06 = 0.0582689
968 /// MAD term = 1.4826022 × 0.0582689 = 0.0863896
969 /// → 14 % BELOW the 0.1 floor: the floor wins, barely.
970 /// correct threshold = 4.6051702 + 6×0.1 = 5.2051702
971 /// MAD-mutant thr = 4.6051702 + 6×0.0863896 = 5.1235079
972 ///
973 /// ln P = ln 175 = 5.1647860 sits BETWEEN the two thresholds
974 /// (margins 0.041 above the mutant's, 0.040 below the correct one):
975 /// correct code keeps the probe; a mutant that drops the floor
976 /// (sigma = MAD term unconditionally — here max→min picks the MAD
977 /// term) flags it via the dead moat. Together with the ABOVE case
978 /// this pins both branches of the crossover and the `max()` itself
979 /// from both sides.
980 #[test]
981 fn test_detect_hot_pixels_poisson_floor_just_above_mad_term_wins() {
982 let data = stage1_two_moat_grid(100.0 / 1.06, 24, 106.0, 175.0, 10_000.0);
983 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
984 assert!(
985 !mask[[2, 2]],
986 "probe between MAD and floor thresholds must be kept when the floor wins"
987 );
988 assert!(mask[[5, 5]], "control must be flagged");
989 assert_eq!(mask.iter().filter(|&&m| m).count(), 1);
990 }
991
992 #[test]
993 fn test_detect_hot_pixels_majority_dead_live_not_flagged() {
994 // A large dead population must not drag the median down and get the
995 // few live pixels flagged: the statistics sample is live-only.
996 let mut data = Array3::<f64>::zeros((1, 4, 4));
997 data[[0, 0, 0]] = 49.0;
998 data[[0, 1, 1]] = 50.0;
999 data[[0, 2, 2]] = 51.0;
1000
1001 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1002 assert!(
1003 mask.iter().all(|&m| !m),
1004 "live pixels amid a dead majority must not be flagged"
1005 );
1006 }
1007
1008 #[test]
1009 fn test_detect_hot_pixels_all_dead_all_false() {
1010 let data = Array3::<f64>::zeros((3, 2, 2));
1011 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1012 assert!(mask.iter().all(|&m| !m));
1013 }
1014
1015 #[test]
1016 fn test_detect_hot_pixels_bimodal_bright_region_not_flagged() {
1017 // THE PR- P0 regression guard. Dark-majority bimodal scene
1018 // 60 % of the FOV at 50 counts (sample), a contiguous 40 % bright
1019 // region at 5000 counts (open beam past the sample edge). The dark
1020 // population holds the median (med = ln 50) and the MAD is 0 (both
1021 // populations are internally uniform), so sigma falls to the Poisson
1022 // floor 1/√50 ≈ 0.141 and the stage-1 threshold is
1023 // ln 50 + 6·0.141 ≈ 4.76 — EVERY bright pixel (ln 5000 ≈ 8.52)
1024 // passes the global cut. The local confirmation must veto them
1025 // all: each bright pixel's neighbor median is 5000, and
1026 // 5000 > 10 × 5000 is false. Bright scene is not a defect.
1027 let mut data = Array3::from_elem((1, 10, 10), 50.0);
1028 for y in 0..10 {
1029 for x in 6..10 {
1030 data[[0, y, x]] = 5000.0;
1031 }
1032 }
1033
1034 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1035 assert!(
1036 mask.iter().all(|&m| !m),
1037 "no pixel of a bimodal scene may be flagged — the bright \
1038 minority region is scene, not a defect"
1039 );
1040 }
1041
1042 #[test]
1043 fn test_detect_hot_pixels_railed_inside_bright_region_caught() {
1044 // Same bimodal scene, but with a genuinely railed pixel INSIDE the
1045 // bright region: 1e6 ≈ 200× its bright neighbors. It must still be
1046 // caught (stage 2 compares against the LOCAL median of 5000, not
1047 // the global dark median).
1048 let mut data = Array3::from_elem((1, 10, 10), 50.0);
1049 for y in 0..10 {
1050 for x in 6..10 {
1051 data[[0, y, x]] = 5000.0;
1052 }
1053 }
1054 data[[0, 5, 8]] = 1.0e6;
1055
1056 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1057 assert!(
1058 mask[[5, 8]],
1059 "railed pixel inside bright region must be flagged"
1060 );
1061 assert_eq!(
1062 mask.iter().filter(|&&m| m).count(),
1063 1,
1064 "only the railed pixel may be flagged"
1065 );
1066 }
1067
1068 #[test]
1069 fn test_detect_hot_pixels_railed_column_segment_caught() {
1070 // Three adjacent railed pixels in a column: each has ≥5 normal
1071 // neighbors of 8 (the middle one has 6), so the neighbor MEDIAN
1072 // stays at the background level and the whole segment is caught.
1073 let mut data = Array3::from_elem((4, 7, 7), 100.0);
1074 for t in 0..4 {
1075 for y in 2..5 {
1076 data[[t, y, 3]] = 65535.0;
1077 }
1078 }
1079
1080 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1081 for y in 2..5 {
1082 assert!(mask[[y, 3]], "railed column pixel ({y}, 3) must be flagged");
1083 }
1084 assert_eq!(
1085 mask.iter().filter(|&&m| m).count(),
1086 3,
1087 "only the railed segment may be flagged"
1088 );
1089 }
1090
1091 #[test]
1092 fn test_detect_hot_pixels_2x2_railed_cluster_fully_caught() {
1093 // Fixpoint acceptance: a 2×2 railed cluster
1094 // has no interior — every pixel keeps 5 background neighbors of 8,
1095 // so the whole cluster is caught in the first pass. Regression
1096 // guard for the smallest ≥2-px-wide cluster.
1097 let mut data = Array3::from_elem((4, 7, 7), 100.0);
1098 for t in 0..4 {
1099 for y in 2..4 {
1100 for x in 2..4 {
1101 data[[t, y, x]] = 65535.0;
1102 }
1103 }
1104 }
1105
1106 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1107 for y in 2..4 {
1108 for x in 2..4 {
1109 assert!(mask[[y, x]], "2x2 cluster pixel ({y}, {x}) must be flagged");
1110 }
1111 }
1112 assert_eq!(
1113 mask.iter().filter(|&&m| m).count(),
1114 4,
1115 "only the railed cluster may be flagged"
1116 );
1117 }
1118
1119 #[test]
1120 fn test_detect_hot_pixels_3x3_railed_blob_fully_caught() {
1121 // Fixpoint acceptance — THE erosion proof.
1122 // A 3×3 railed blob: pass 1 flags only the 4 corners (5 background
1123 // neighbors each); the edge centers (3 bg + 5 railed) and the
1124 // interior (8 railed) are refuted by a single pass — the
1125 // pre-fixpoint code missed them. With flagged neighbors
1126 // contributing zero totals, pass 2 flags the edge centers
1127 // (sample [0, 0, bg, bg, bg, R, R, R] → median bg) and pass 3 the
1128 // interior (all-flagged neighborhood → median 0).
1129 let mut data = Array3::from_elem((4, 9, 9), 100.0);
1130 for t in 0..4 {
1131 for y in 3..6 {
1132 for x in 3..6 {
1133 data[[t, y, x]] = 65535.0;
1134 }
1135 }
1136 }
1137
1138 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1139 for y in 3..6 {
1140 for x in 3..6 {
1141 assert!(
1142 mask[[y, x]],
1143 "3x3 blob pixel ({y}, {x}) must be flagged (interior included)"
1144 );
1145 }
1146 }
1147 assert_eq!(
1148 mask.iter().filter(|&&m| m).count(),
1149 9,
1150 "only the railed blob may be flagged"
1151 );
1152 }
1153
1154 #[test]
1155 fn test_detect_hot_pixels_2px_wide_railed_column_fully_caught() {
1156 // Fixpoint acceptance: the interior of a
1157 // 2-px-wide railed column (3 bg + 5 railed neighbors per interior
1158 // pixel) was invisible to the single pass — only the 4 end pixels
1159 // (5 bg neighbors each) flagged. The fixpoint erodes the column
1160 // pairwise from both ends.
1161 let mut data = Array3::from_elem((4, 9, 9), 100.0);
1162 for t in 0..4 {
1163 for y in 2..7 {
1164 for x in 3..5 {
1165 data[[t, y, x]] = 65535.0;
1166 }
1167 }
1168 }
1169
1170 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1171 for y in 2..7 {
1172 for x in 3..5 {
1173 assert!(
1174 mask[[y, x]],
1175 "2-px-wide column pixel ({y}, {x}) must be flagged"
1176 );
1177 }
1178 }
1179 assert_eq!(
1180 mask.iter().filter(|&&m| m).count(),
1181 10,
1182 "only the railed column may be flagged"
1183 );
1184 }
1185
1186 #[test]
1187 fn test_detect_hot_pixels_edge_to_edge_2px_band_not_flagged_by_design() {
1188 // Documented-limitation pin: an EDGE-TO-EDGE
1189 // railed band ≥2 px wide (both ends off-detector) exposes no end
1190 // cap or convex corner, so the erosion has no seed — interior
1191 // band pixels keep 3 bg + 5 railed neighbors (median railed) and
1192 // even the on-detector-border band ends keep 2 bg + 3 railed
1193 // (median railed). Nothing flags, deliberately: a slit-aperture
1194 // open beam produces a genuine full-width bright SCENE band that
1195 // is pixel-for-pixel indistinguishable from this defect, and a
1196 // full-span row/column screen would mask it (the bimodal
1197 // failure). Such detector pathologies belong in a declared/file
1198 // mask (see rustdoc, "Fixpoint erosion of railed clusters").
1199 let mut data = Array3::from_elem((4, 9, 9), 100.0);
1200 for t in 0..4 {
1201 for y in 3..5 {
1202 for x in 0..9 {
1203 data[[t, y, x]] = 65535.0;
1204 }
1205 }
1206 }
1207
1208 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1209 assert!(
1210 mask.iter().all(|&m| !m),
1211 "an edge-to-edge ≥2-px railed band must NOT be flagged \
1212 (documented limitation — geometrically ambiguous with a \
1213 slit-aperture bright scene band)"
1214 );
1215 }
1216
1217 #[test]
1218 fn test_detect_hot_pixels_2px_band_one_end_on_detector_fully_caught() {
1219 // Companion to the edge-to-edge pin: the
1220 // same 2-row railed band, but with ONE end cap inside the
1221 // detector. The two cap pixels keep 5 bg + 3 railed neighbors
1222 // (median bg) and seed in pass 1; the fixpoint then erodes the
1223 // band column-pair by column-pair all the way to the opposite
1224 // (detector-border) end.
1225 let mut data = Array3::from_elem((4, 9, 9), 100.0);
1226 for t in 0..4 {
1227 for y in 3..5 {
1228 for x in 0..7 {
1229 data[[t, y, x]] = 65535.0;
1230 }
1231 }
1232 }
1233
1234 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1235 for y in 3..5 {
1236 for x in 0..7 {
1237 assert!(mask[[y, x]], "band pixel ({y}, {x}) must be flagged");
1238 }
1239 }
1240 assert_eq!(
1241 mask.iter().filter(|&&m| m).count(),
1242 14,
1243 "only the railed band may be flagged"
1244 );
1245 }
1246
1247 #[test]
1248 fn test_detect_hot_pixels_full_railed_column_caught() {
1249 // Regression ( F1 test 3): a full-height 1-px railed
1250 // column — every pixel keeps ≥4 background neighbors, so the whole
1251 // line is caught in pass 1, exactly as before the fixpoint.
1252 let mut data = Array3::from_elem((4, 7, 7), 100.0);
1253 for t in 0..4 {
1254 for y in 0..7 {
1255 data[[t, y, 3]] = 65535.0;
1256 }
1257 }
1258
1259 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1260 for y in 0..7 {
1261 assert!(mask[[y, 3]], "railed column pixel ({y}, 3) must be flagged");
1262 }
1263 assert_eq!(
1264 mask.iter().filter(|&&m| m).count(),
1265 7,
1266 "only the railed column may be flagged"
1267 );
1268 }
1269
1270 #[test]
1271 fn test_detect_hot_pixels_large_psf_bright_region_not_eroded() {
1272 // Fixpoint safety ( F1 test 5): a LARGE bright scene
1273 // region — 20×20 core at 100× background — with the ≥2-px PSF edge
1274 // blur that real VENUS scene features have (adjacent-pixel ratios
1275 // ≤5× through a 2-px transition ring: 100 → 400 → 2000 → 10000).
1276 // Every bright-layer pixel passes the stage-1 global cut (dark
1277 // majority: med = ln 100, mad = 0, Poisson-floor sigma = 0.1 →
1278 // threshold ≈ 5.21 < ln 400 ≈ 5.99), yet no pixel reaches 10× its
1279 // neighbor median, so the erosion never seeds and the fixpoint is
1280 // reached with ZERO flags — the region survives intact.
1281 let mut data = Array3::from_elem((1, 50, 50), 100.0);
1282 for y in 13..37 {
1283 for x in 13..37 {
1284 data[[0, y, x]] = 400.0; // outer transition ring
1285 }
1286 }
1287 for y in 14..36 {
1288 for x in 14..36 {
1289 data[[0, y, x]] = 2000.0; // inner transition ring
1290 }
1291 }
1292 for y in 15..35 {
1293 for x in 15..35 {
1294 data[[0, y, x]] = 10000.0; // 20×20 core at 100× background
1295 }
1296 }
1297
1298 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1299 assert!(
1300 mask.iter().all(|&m| !m),
1301 "a PSF-blurred bright scene region must not be eroded at all"
1302 );
1303 }
1304
1305 #[test]
1306 fn test_detect_hot_pixels_hard_edged_bright_rectangle_corners_only() {
1307 // Pins the documented convex-corner caveat AND the fixpoint
1308 // no-propagation property. A hard-edged (0-px
1309 // transition) 20×20 region at 100× background: each sharp convex
1310 // corner pixel sees only 3 same-side neighbors (median falls on
1311 // the dark side) and flags — pre-existing single-pass behavior,
1312 // physically rare in scene (PSF blurs real edges over ≥2 px).
1313 // Crucially, the erosion must NOT propagate past the corners: the
1314 // corner-adjacent edge pixels keep ≥4 bright unflagged neighbors,
1315 // so the fixpoint stops at exactly the 4 corner pixels.
1316 let mut data = Array3::from_elem((1, 50, 50), 100.0);
1317 for y in 15..35 {
1318 for x in 15..35 {
1319 data[[0, y, x]] = 10000.0;
1320 }
1321 }
1322
1323 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1324 for &(y, x) in &[(15, 15), (15, 34), (34, 15), (34, 34)] {
1325 assert!(
1326 mask[[y, x]],
1327 "sharp convex corner ({y}, {x}) flags (documented caveat)"
1328 );
1329 }
1330 assert_eq!(
1331 mask.iter().filter(|&&m| m).count(),
1332 4,
1333 "erosion must not propagate past the convex corners"
1334 );
1335 }
1336
1337 #[test]
1338 fn test_detect_hot_pixels_1px_bright_line_flagged_by_design() {
1339 // Width-1 limitation pin ( F3 — user-decided
1340 // document + pin, no connected-component machinery). A 1-px-wide
1341 // bright SCENE line at ≥10× local contrast is spatially
1342 // indistinguishable from a railed line and IS masked — the
1343 // accepted trade-off for catching railed rows/columns. Real VENUS
1344 // scene features are PSF-blurred over ≥2 px, so this contrast is
1345 // physically rare in scene (see HOT_LOCAL_FACTOR rustdoc).
1346 let mut data = Array3::from_elem((1, 9, 9), 100.0);
1347 for y in 0..9 {
1348 data[[0, y, 4]] = 5000.0; // 50× the local background
1349 }
1350
1351 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1352 for y in 0..9 {
1353 assert!(
1354 mask[[y, 4]],
1355 "1-px bright line pixel ({y}, 4) is flagged BY DESIGN"
1356 );
1357 }
1358 assert_eq!(
1359 mask.iter().filter(|&&m| m).count(),
1360 9,
1361 "only the width-1 line may be flagged"
1362 );
1363 }
1364
1365 #[test]
1366 fn test_detect_hot_pixels_isolated_live_pixel_keeps_global_verdict() {
1367 // A stage-1 candidate whose 8-neighbors are ALL dead has no live
1368 // neighbor median to refute the global verdict — it stays flagged.
1369 // Scattered far pixels at 50 counts define the global statistics
1370 // (med = ln 50, threshold ≈ 4.76); the isolated 1e6 pixel passes.
1371 let mut data = Array3::<f64>::zeros((1, 5, 5));
1372 for &(y, x) in &[(0, 0), (0, 2), (0, 4), (4, 0), (4, 2), (4, 4)] {
1373 data[[0, y, x]] = 50.0;
1374 }
1375 data[[0, 2, 2]] = 1.0e6;
1376
1377 let mask = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap();
1378 assert!(
1379 mask[[2, 2]],
1380 "isolated live candidate in a dead field keeps the global verdict"
1381 );
1382 assert_eq!(mask.iter().filter(|&&m| m).count(), 1);
1383 }
1384
1385 #[test]
1386 fn test_detect_hot_pixels_empty_tof_err() {
1387 // n_tof == 0: the totals image would be all-zero (vacuous sums) —
1388 // validating entry points must reject rather than return all-false.
1389 let data = Array3::<f64>::zeros((0, 2, 2));
1390 let err = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap_err();
1391 assert!(matches!(err, IoError::InvalidParameter(_)));
1392 assert!(err.to_string().contains("empty TOF axis"));
1393 }
1394
1395 #[test]
1396 fn test_detect_hot_pixels_nan_err() {
1397 let mut data = Array3::from_elem((2, 2, 2), 1.0);
1398 data[[1, 1, 0]] = f64::NAN;
1399 let err = detect_hot_pixels(&data, HOT_PIXEL_K_MAD).unwrap_err();
1400 assert!(matches!(err, IoError::InvalidParameter(_)));
1401 }
1402
1403 #[test]
1404 fn test_detect_hot_pixels_bad_k_err() {
1405 let data = Array3::from_elem((2, 2, 2), 1.0);
1406 for bad_k in [0.0, -1.0, f64::NAN, f64::INFINITY] {
1407 let err = detect_hot_pixels(&data, bad_k).unwrap_err();
1408 assert!(
1409 matches!(err, IoError::InvalidParameter(_)),
1410 "k_mad = {bad_k} must be rejected"
1411 );
1412 }
1413 }
1414
1415 #[test]
1416 fn test_detect_bad_pixels_union() {
1417 // sample: (0,0) dead, (1,2) low-count-alive (1 total count),
1418 // everything else 100 counts/bin.
1419 let mut sample = Array3::from_elem((3, 3, 3), 100.0);
1420 for t in 0..3 {
1421 sample[[t, 0, 0]] = 0.0;
1422 sample[[t, 1, 2]] = 0.0;
1423 }
1424 sample[[0, 1, 2]] = 1.0;
1425 // open beam: (0,1) dead, (2,2) railed, everything else 200/bin.
1426 let mut ob = Array3::from_elem((3, 3, 3), 200.0);
1427 for t in 0..3 {
1428 ob[[t, 0, 1]] = 0.0;
1429 ob[[t, 2, 2]] = 65535.0;
1430 }
1431
1432 let mask = detect_bad_pixels(&sample, Some(&ob), Some(HOT_PIXEL_K_MAD)).unwrap();
1433 assert!(mask[[0, 0]], "dead-in-sample-only must be flagged");
1434 assert!(mask[[0, 1]], "dead-in-OB-only must be flagged");
1435 assert!(mask[[2, 2]], "hot-in-OB-only must be flagged");
1436 assert!(!mask[[1, 2]], "low-count-alive must be kept");
1437 assert!(!mask[[1, 1]], "normal pixel must be kept");
1438 assert_eq!(mask.iter().filter(|&&m| m).count(), 3);
1439 }
1440
1441 #[test]
1442 fn test_detect_bad_pixels_spatial_mismatch_err() {
1443 let sample = Array3::from_elem((3, 2, 2), 1.0);
1444 let ob = Array3::from_elem((3, 2, 3), 1.0);
1445 let err = detect_bad_pixels(&sample, Some(&ob), None).unwrap_err();
1446 assert!(matches!(err, IoError::ShapeMismatch(_)));
1447 }
1448
1449 #[test]
1450 fn test_detect_bad_pixels_ragged_n_tof_ok() {
1451 // Deadness is spatial: sample and OB may have different TOF axes.
1452 let sample = Array3::from_elem((3, 2, 2), 1.0);
1453 let ob = Array3::from_elem((7, 2, 2), 1.0);
1454 let mask = detect_bad_pixels(&sample, Some(&ob), Some(HOT_PIXEL_K_MAD)).unwrap();
1455 assert!(mask.iter().all(|&m| !m));
1456 }
1457
1458 #[test]
1459 fn test_detect_bad_pixels_hot_k_mad_none_is_dead_only() {
1460 let mut sample = Array3::from_elem((3, 3, 3), 100.0);
1461 for t in 0..3 {
1462 sample[[t, 1, 1]] = 65535.0; // railed
1463 sample[[t, 0, 0]] = 0.0; // dead
1464 }
1465 let dead_only = detect_bad_pixels(&sample, None, None).unwrap();
1466 assert!(dead_only[[0, 0]]);
1467 assert!(
1468 !dead_only[[1, 1]],
1469 "hot_k_mad = None must disable the hot screen"
1470 );
1471
1472 let with_hot = detect_bad_pixels(&sample, None, Some(HOT_PIXEL_K_MAD)).unwrap();
1473 assert!(with_hot[[0, 0]]);
1474 assert!(with_hot[[1, 1]]);
1475 }
1476
1477 #[test]
1478 fn test_detect_bad_pixels_bad_k_err() {
1479 let sample = Array3::from_elem((2, 2, 2), 1.0);
1480 for bad_k in [0.0, -1.0, f64::NAN, f64::INFINITY] {
1481 let err = detect_bad_pixels(&sample, None, Some(bad_k)).unwrap_err();
1482 assert!(
1483 matches!(err, IoError::InvalidParameter(_)),
1484 "hot_k_mad = {bad_k} must be rejected"
1485 );
1486 }
1487 }
1488
1489 #[test]
1490 fn test_detect_bad_pixels_empty_tof_err() {
1491 // An empty stack's all-zero test passes vacuously — without this
1492 // guard the whole detector would be masked dead with no error.
1493 let empty = Array3::<f64>::zeros((0, 2, 2));
1494 let err = detect_bad_pixels(&empty, None, None).unwrap_err();
1495 assert!(matches!(err, IoError::InvalidParameter(_)));
1496 assert!(err.to_string().contains("sample"));
1497
1498 let sample = Array3::from_elem((3, 2, 2), 1.0);
1499 let err = detect_bad_pixels(&sample, Some(&empty), None).unwrap_err();
1500 assert!(matches!(err, IoError::InvalidParameter(_)));
1501 assert!(err.to_string().contains("open_beam"));
1502 }
1503
1504 #[test]
1505 fn test_detect_bad_pixels_nan_err() {
1506 let mut sample = Array3::from_elem((2, 2, 2), 1.0);
1507 sample[[0, 0, 1]] = f64::NAN;
1508 let err = detect_bad_pixels(&sample, None, None).unwrap_err();
1509 assert!(err.to_string().contains("sample"));
1510
1511 let sample = Array3::from_elem((2, 2, 2), 1.0);
1512 let mut ob = Array3::from_elem((2, 2, 2), 1.0);
1513 ob[[1, 0, 0]] = f64::NEG_INFINITY;
1514 let err = detect_bad_pixels(&sample, Some(&ob), None).unwrap_err();
1515 assert!(err.to_string().contains("open_beam"));
1516 }
1517}