weightmask 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
weightmask/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """
2
+ WeightMask: A modular tool for generating mask, weight, and confidence maps for astronomical images.
3
+
4
+ This package includes modules for detecting various effects in astronomical images:
5
+ - Saturation
6
+ - Bad pixels
7
+ - Cosmic rays
8
+ - Streaks
9
+ - Object detection
10
+ - Background estimation
11
+ - Variance calculation
12
+ """
13
+
14
+ try:
15
+ from importlib.metadata import version as _distribution_version
16
+
17
+ __version__ = _distribution_version("weightmask")
18
+ except Exception:
19
+ __version__ = "0.1.0"
20
+
21
+ # Legacy public dictionary kept stable; the contract adds INVALID_VARIANCE.
22
+ from .contract import QUALITY_BITS
23
+
24
+ MASK_BITS = QUALITY_BITS
25
+
26
+ MASK_DTYPE = "uint32" # Data type for the bitmask
27
+
28
+ __all__ = ["MASK_BITS", "MASK_DTYPE", "QUALITY_BITS", "WeightMapGenerator", "__version__"]
29
+
30
+
31
+ def __getattr__(name: str):
32
+ if name == "WeightMapGenerator":
33
+ from .pipeline import WeightMapGenerator
34
+
35
+ return WeightMapGenerator
36
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,449 @@
1
+ import warnings
2
+
3
+ import numpy as np
4
+ import sep
5
+ from astropy.stats import mad_std
6
+ from scipy import linalg
7
+ from scipy.ndimage import median_filter
8
+
9
+
10
+ def _estimate_global_sep(sci_data, mask):
11
+ """Estimate a single global background using a single SEP box."""
12
+ try:
13
+ bkg = sep.Background(sci_data, mask=mask, bw=sci_data.shape[1], bh=sci_data.shape[0])
14
+ if bkg.globalback == 0 and np.any(~mask):
15
+ return None, None
16
+ bkg_map = np.full(sci_data.shape, bkg.globalback, dtype=np.float32)
17
+ bkg_rms_map = np.full(sci_data.shape, bkg.globalrms, dtype=np.float32)
18
+ return bkg_map, bkg_rms_map
19
+ except Exception as e:
20
+ warnings.warn(
21
+ f"Global SEP fallback failed: {e}. Trying robust median.",
22
+ RuntimeWarning,
23
+ )
24
+ return None, None
25
+
26
+
27
+ def _check_and_fix_edge_artifacts(bkg_map, sci_data_shape):
28
+ """Check for edge artifacts and apply smoothing if necessary."""
29
+ edge_width = 50
30
+ h, w = sci_data_shape
31
+ if h <= 2 * edge_width or w <= 2 * edge_width:
32
+ return bkg_map
33
+ edge_regions = [
34
+ bkg_map[:edge_width, :],
35
+ bkg_map[-edge_width:, :],
36
+ bkg_map[:, :edge_width],
37
+ bkg_map[:, -edge_width:],
38
+ ]
39
+ center_median = np.median(bkg_map[edge_width:-edge_width, edge_width:-edge_width])
40
+ for edge_region in edge_regions:
41
+ edge_median = np.median(edge_region)
42
+ if abs(edge_median - center_median) > 50:
43
+ from scipy.ndimage import gaussian_filter
44
+
45
+ bkg_map = gaussian_filter(bkg_map, sigma=2.0)
46
+ print(" Applied Gaussian smoothing to reduce edge artifacts")
47
+ break
48
+ return bkg_map
49
+
50
+
51
+ def _estimate_sep_tiered(sci_data, mask, box_size, filter_size, max_box_size):
52
+ """Estimate background using SEP with tiered retries.
53
+
54
+ Returns ``(bkg_map, bkg_rms_map, box_used)``; ``box_used`` is None on failure.
55
+ """
56
+ current_box = box_size
57
+ attempt = 0
58
+ while current_box <= max_box_size:
59
+ try:
60
+ if attempt > 0:
61
+ print(f" Retrying SEP with larger box size: {current_box}")
62
+
63
+ bkg = sep.Background(
64
+ sci_data,
65
+ mask=mask,
66
+ bw=current_box,
67
+ bh=current_box,
68
+ fw=filter_size,
69
+ fh=filter_size,
70
+ maskthresh=0.0,
71
+ )
72
+ bkg_map = bkg.back()
73
+ bkg_rms_map = bkg.rms()
74
+ print(f" SEP background global RMS: {bkg.globalrms:.3f} (box={current_box})")
75
+
76
+ bkg_map = _check_and_fix_edge_artifacts(bkg_map, sci_data.shape)
77
+ return bkg_map, bkg_rms_map, int(current_box)
78
+
79
+ except Exception as e:
80
+ next_box = current_box * 2
81
+ if next_box > max_box_size:
82
+ warnings.warn(
83
+ f"All SEP background retries failed: {e}. Falling back to robust global median.",
84
+ RuntimeWarning,
85
+ )
86
+ break
87
+ current_box = next_box
88
+ attempt += 1
89
+ return None, None, None
90
+
91
+
92
+ def _estimate_robust_median(sci_data, mask, method, config):
93
+ """Estimate background using median filter or robust median fallback."""
94
+ try:
95
+ if method == "robust_median_fallback":
96
+ print(" Using robust global median fallback.")
97
+ kernel_size = 0
98
+ else:
99
+ default_size = max(15, int(min(sci_data.shape) / 20) // 2 * 2 + 1)
100
+ kernel_size = config.get("median_kernel_size", default_size)
101
+ print(f" Using median filter with kernel size: {kernel_size}")
102
+
103
+ if kernel_size > 0:
104
+ bkg_map = median_filter(sci_data, size=kernel_size)
105
+ else:
106
+ valid_data = sci_data[~mask] if np.any(~mask) else sci_data
107
+ step = max(1, valid_data.size // 100000)
108
+ bkg_val = np.median(valid_data.ravel()[::step])
109
+ bkg_map = np.full(sci_data.shape, bkg_val, dtype=np.float32)
110
+
111
+ data_sub = sci_data - bkg_map
112
+ valid_sub = data_sub[~mask] if np.any(~mask) else data_sub
113
+ step_sub = max(1, valid_sub.size // 100000)
114
+ global_rms = mad_std(valid_sub.ravel()[::step_sub], ignore_nan=True)
115
+ bkg_rms_map = np.full(sci_data.shape, global_rms, dtype=np.float32)
116
+ print(f" Final RMS (mad_std): {global_rms:.3f}")
117
+ return bkg_map, bkg_rms_map
118
+
119
+ except Exception as e:
120
+ warnings.warn(f"Final background fallback failed: {e}", RuntimeWarning)
121
+ return None, None
122
+
123
+
124
+ def _estimate_smooth_surface(sci_data, mask, config):
125
+ """Estimate a smooth low-order background surface for gradient-dominated fields."""
126
+ valid = np.isfinite(sci_data) & (~mask)
127
+ if np.count_nonzero(valid) < 100:
128
+ return None, None
129
+
130
+ y_idx, x_idx = np.indices(sci_data.shape, dtype=np.float32)
131
+ sample_y = y_idx[valid]
132
+ sample_x = x_idx[valid]
133
+ sample_v = sci_data[valid].astype(np.float32)
134
+ step = max(1, sample_v.size // 50000)
135
+ sample_y = sample_y[::step]
136
+ sample_x = sample_x[::step]
137
+ sample_v = sample_v[::step]
138
+
139
+ x_norm = (sample_x - 0.5 * (sci_data.shape[1] - 1)) / max(float(sci_data.shape[1]), 1.0)
140
+ y_norm = (sample_y - 0.5 * (sci_data.shape[0] - 1)) / max(float(sci_data.shape[0]), 1.0)
141
+ design = np.column_stack(
142
+ [
143
+ np.ones_like(x_norm),
144
+ x_norm,
145
+ y_norm,
146
+ x_norm * y_norm,
147
+ x_norm**2,
148
+ y_norm**2,
149
+ ]
150
+ )
151
+ try:
152
+ coeffs, *_ = linalg.lstsq(design, sample_v)
153
+ except Exception as e:
154
+ warnings.warn(f"Smooth-surface background fit failed: {e}", RuntimeWarning)
155
+ return None, None
156
+
157
+ full_x = (x_idx - 0.5 * (sci_data.shape[1] - 1)) / max(float(sci_data.shape[1]), 1.0)
158
+ full_y = (y_idx - 0.5 * (sci_data.shape[0] - 1)) / max(float(sci_data.shape[0]), 1.0)
159
+ full_design = np.stack(
160
+ [
161
+ np.ones_like(full_x),
162
+ full_x,
163
+ full_y,
164
+ full_x * full_y,
165
+ full_x**2,
166
+ full_y**2,
167
+ ],
168
+ axis=0,
169
+ )
170
+ bkg_map = np.tensordot(coeffs, full_design, axes=(0, 0)).astype(np.float32)
171
+ residual = sci_data - bkg_map
172
+ valid_resid = residual[valid]
173
+ step_r = max(1, valid_resid.size // 100000)
174
+ global_rms = mad_std(valid_resid[::step_r], ignore_nan=True)
175
+ if not np.isfinite(global_rms) or global_rms <= 0:
176
+ global_rms = float(np.nanstd(valid_resid))
177
+ bkg_rms_map = np.full(sci_data.shape, global_rms, dtype=np.float32)
178
+ print(f" Smooth-surface fallback RMS: {global_rms:.3f}")
179
+ return bkg_map, bkg_rms_map
180
+
181
+
182
+ def _auto_box_size(sci_data_shape, mask_fraction, config):
183
+ """Choose a background box size from image scale and crowding."""
184
+ min_dim = min(sci_data_shape)
185
+ auto = max(32, min(256, int(round(min_dim / 8.0))))
186
+ if mask_fraction > 0.5:
187
+ auto = min(512, auto * 2)
188
+ try:
189
+ cfg_box = int(config.get("box_size", auto))
190
+ except (TypeError, ValueError):
191
+ cfg_box = auto
192
+ # Config box is a ceiling: small detectors scale down to 64/32 automatically.
193
+ return max(16, min(auto, cfg_box))
194
+
195
+
196
+ def _as_box(box, default=128):
197
+ try:
198
+ return max(1, int(box))
199
+ except (TypeError, ValueError):
200
+ return default
201
+
202
+
203
+ def _mesh_node_coords(n, size, box):
204
+ """SEP node abscissae ``(k + 0.5) * box``, clipped to ``[0, size-1]``."""
205
+ if n <= 0:
206
+ return np.zeros(0, dtype=np.float64)
207
+ return np.clip(np.rint((np.arange(n) + 0.5) * box), 0, max(size - 1, 0))
208
+
209
+
210
+ def sky_to_mesh(sky_map, box):
211
+ """Downsample full-res sky to SEP mesh nodes + FITS rebuild cards.
212
+
213
+ ``n = (size - 1) // box + 1`` nodes at clipped ``(k + 0.5) * box``.
214
+ Returns ``(mesh_float32, cards_dict)``.
215
+ """
216
+ box = _as_box(box)
217
+ arr = np.ascontiguousarray(sky_map, dtype=np.float32)
218
+ h, w = arr.shape
219
+ ny = max(1, (h - 1) // box + 1) if h > 0 else 1
220
+ nx = max(1, (w - 1) // box + 1) if w > 0 else 1
221
+ yi = _mesh_node_coords(ny, h, box).astype(np.intp)
222
+ xi = _mesh_node_coords(nx, w, box).astype(np.intp)
223
+ mesh = arr[np.ix_(yi, xi)] if h > 0 and w > 0 else np.zeros((ny, nx), dtype=np.float32)
224
+ cards = {"SKYMESH": True, "MESHBW": box, "MESHBH": box, "SKYH": h, "SKYW": w}
225
+ return np.ascontiguousarray(mesh, dtype=np.float32), cards
226
+
227
+
228
+ def reconstruct_sky_mesh(mesh, shape, box):
229
+ """Rebuild full-res sky from mesh (SEP node phase + natural cubic)."""
230
+ # ponytail: scipy CubicSpline, not SEP C bicubic; sub-ADU on MegaPrime.
231
+ # Swap back to a SEP port if bit-identical back() is required.
232
+ from scipy.interpolate import CubicSpline
233
+
234
+ box = _as_box(box)
235
+ m = np.atleast_2d(np.asarray(mesh, dtype=np.float64))
236
+ h, w = int(shape[0]), int(shape[1])
237
+ if m.size == 0 or h <= 0 or w <= 0:
238
+ return np.zeros((max(h, 0), max(w, 0)), dtype=np.float32)
239
+ ny, nx = m.shape
240
+ if ny == 1 and nx == 1:
241
+ return np.full((h, w), float(m[0, 0]), dtype=np.float32)
242
+ node_y = _mesh_node_coords(ny, h, box)
243
+ node_x = _mesh_node_coords(nx, w, box)
244
+ ys = np.arange(h, dtype=np.float64)
245
+ xs = np.arange(w, dtype=np.float64)
246
+ if ny > 1:
247
+ cols = np.column_stack([CubicSpline(node_y, m[:, j], bc_type="natural")(ys) for j in range(nx)])
248
+ else:
249
+ cols = np.broadcast_to(m, (h, nx)).copy()
250
+ if nx > 1:
251
+ out = np.vstack([CubicSpline(node_x, cols[i], bc_type="natural")(xs) for i in range(h)])
252
+ else:
253
+ out = cols
254
+ return np.ascontiguousarray(out, dtype=np.float32)
255
+
256
+
257
+ def parse_sky_mesh_header(header):
258
+ """Parse SKYMESH cards into ``((h, w), box)``."""
259
+ if header is None:
260
+ raise ValueError("Header is missing SKYMESH=T (not a sky mesh product)")
261
+ try:
262
+ skymesh = header["SKYMESH"]
263
+ except Exception as e:
264
+ raise ValueError("Header is missing SKYMESH=T (not a sky mesh product)") from e
265
+ if skymesh is not True and str(skymesh).strip().upper() not in ("T", "TRUE", "1"):
266
+ raise ValueError("Header is missing SKYMESH=T (not a sky mesh product)")
267
+ try:
268
+ h, w = int(header["SKYH"]), int(header["SKYW"])
269
+ box = int(header["MESHBW"] if "MESHBW" in header else header["MESHBH"])
270
+ except Exception as e:
271
+ raise ValueError("SKYMESH header requires SKYH, SKYW, and MESHBW/MESHBH") from e
272
+ if h <= 0 or w <= 0:
273
+ raise ValueError(f"Invalid SKYH/SKYW shape ({h}, {w})")
274
+ return (h, w), max(1, box)
275
+
276
+
277
+ def reconstruct_sky_from_header(mesh, header):
278
+ """Rebuild full-res sky from a mesh array and its SKYMESH FITS header cards."""
279
+ shape, box = parse_sky_mesh_header(header)
280
+ return reconstruct_sky_mesh(mesh, shape, box)
281
+
282
+
283
+ def _repair_negative_dips(bkg_map, sci_data, bkg_rms_map, mask, config, diagnostics=None):
284
+ """Nearest-valid fill for unphysical negative-sky interpolation overshoots.
285
+
286
+ SEP's mesh interpolation can undershoot below zero next to masked bright
287
+ sources and image edges (measured: 0.2-0.5% of a MegaPrime CCD at -100s of
288
+ ADU while the data sit at +1700). A negative *level* is only repaired when
289
+ the data agree with the repaired sky: the pixel must be inconsistent with
290
+ the map (data far above it) and consistent with the fill (data near
291
+ neighbor sky). Faint regions, blanks, and rms-unknown pixels never qualify.
292
+ """
293
+ try:
294
+ enable = bool(config.get("dip_repair_enable", True))
295
+ except Exception:
296
+ enable = True
297
+ if not enable or bkg_map is None:
298
+ return bkg_map
299
+ try:
300
+ sigma = float(config.get("dip_repair_sigma", 5.0))
301
+ max_frac = float(config.get("dip_repair_max_fraction", 0.05))
302
+ except (TypeError, ValueError):
303
+ sigma, max_frac = 5.0, 0.05
304
+ try:
305
+ data = np.asarray(sci_data, dtype=np.float64)
306
+ guide = np.isfinite(np.asarray(bkg_map, dtype=np.float64))
307
+ except Exception:
308
+ return bkg_map
309
+ try:
310
+ candidate = (
311
+ guide
312
+ & (np.asarray(bkg_map) < 0)
313
+ & np.isfinite(data)
314
+ & (~np.asarray(mask, dtype=bool))
315
+ )
316
+ except Exception:
317
+ return bkg_map
318
+ n_cand = int(np.count_nonzero(candidate))
319
+ if n_cand == 0:
320
+ return bkg_map
321
+ frac = n_cand / bkg_map.size
322
+ if not np.isfinite(frac) or frac > max_frac:
323
+ print(f" WARNING: negative sky over {frac:.1%} of image exceeds repair cap; leaving map as-is.")
324
+ return bkg_map
325
+ try:
326
+ from scipy.ndimage import distance_transform_edt
327
+
328
+ rms = np.asarray(bkg_rms_map, dtype=np.float64)
329
+ finite_rms = np.isfinite(rms) & (rms > 0)
330
+ rms_ref = np.median(rms[finite_rms]) if np.any(finite_rms) else np.nan
331
+ thresh = np.where(finite_rms, sigma * rms, sigma * rms_ref)
332
+ skymap = np.asarray(bkg_map, dtype=np.float64)
333
+ skymed = np.median(skymap[np.isfinite(skymap)]) if np.any(np.isfinite(skymap)) else np.nan
334
+ invalid = candidate | ~guide
335
+ _, idx = distance_transform_edt(invalid, return_indices=True)
336
+ fill_nn = skymap[tuple(idx)]
337
+ bad_map = (data - skymap) > thresh
338
+ # Tier 1: neighbor fill where the data agree with it.
339
+ accept = (
340
+ candidate
341
+ & np.isfinite(thresh)
342
+ & np.isfinite(fill_nn)
343
+ & bad_map
344
+ & (np.abs(data - fill_nn) <= thresh)
345
+ )
346
+ # Tier 2: deep interiors whose border fill is still depressed fall back
347
+ # to the global median when the data agree with that instead.
348
+ if np.isfinite(skymed):
349
+ accept2 = (
350
+ candidate
351
+ & ~accept
352
+ & np.isfinite(thresh)
353
+ & bad_map
354
+ & (np.abs(data - skymed) <= thresh)
355
+ )
356
+ else:
357
+ accept2 = np.zeros_like(candidate, dtype=bool)
358
+ except Exception as e:
359
+ print(f" WARNING: dip repair failed ({e}); leaving map as-is.")
360
+ return bkg_map
361
+ n_bad = int(np.count_nonzero(accept)) + int(np.count_nonzero(accept2))
362
+ if n_bad == 0:
363
+ return bkg_map
364
+ out = skymap.copy()
365
+ out[accept] = fill_nn[accept]
366
+ out[accept2] = skymed
367
+ print(f" Repaired {n_bad} negative-sky pixels ({n_bad / bkg_map.size:.3%}) via nearest-valid fill.")
368
+ if diagnostics is not None:
369
+ diagnostics["dip_repaired"] = n_bad
370
+ return out.astype(np.asarray(bkg_map).dtype, copy=False)
371
+
372
+
373
+ def estimate_background(sci_data, mask, config):
374
+ """
375
+ Estimate background and background RMS using a configured method.
376
+
377
+ Args:
378
+ sci_data (ndarray): Science image data array
379
+ mask (ndarray): Boolean mask of pixels to exclude from background estimation
380
+ config (dict): Configuration dictionary for background estimation
381
+
382
+ Returns:
383
+ tuple: (background_map, background_rms_map) or (None, None) on failure.
384
+ """
385
+ method = config.get("method", "sep").lower()
386
+ diagnostics = config.get("_diagnostics")
387
+ print(f" Estimating background using method: '{method}'")
388
+
389
+ bkg_map, bkg_rms_map = None, None
390
+ # Mesh encoding needs the SEP box that actually built the map (incl. retries).
391
+ # Non-SEP / global fallbacks have no mesh-compatible box -> leave unset/None.
392
+ if diagnostics is not None:
393
+ diagnostics.pop("box_size", None)
394
+
395
+ if method == "sep":
396
+ mask_fraction = np.mean(mask)
397
+ mask_threshold = float(config.get("mask_threshold", 0.8))
398
+ if mask_fraction > mask_threshold:
399
+ print(f" WARNING: High mask coverage ({mask_fraction:.1%}). Proactively falling back to global mode.")
400
+ bkg_map, bkg_rms_map = _estimate_global_sep(sci_data, mask)
401
+ if diagnostics is not None:
402
+ diagnostics["fallback"] = "global_sep"
403
+ if bkg_map is None:
404
+ print(" Global SEP fallback failed; switching to robust median fallback.")
405
+ method = "robust_median_fallback"
406
+ else:
407
+ box_size = (
408
+ _auto_box_size(sci_data.shape, mask_fraction, config)
409
+ if config.get("auto_box_scaling", True)
410
+ else config.get("box_size", 128)
411
+ )
412
+ try:
413
+ box_size = max(1, int(box_size))
414
+ except (TypeError, ValueError):
415
+ box_size = 128
416
+ filter_size = config.get("filter_size", 3)
417
+ max_box_size = config.get("max_box_size", max(box_size, 1024))
418
+ bkg_map, bkg_rms_map, used_box = _estimate_sep_tiered(
419
+ sci_data, mask, box_size, filter_size, max_box_size
420
+ )
421
+ if diagnostics is not None and used_box is not None:
422
+ diagnostics["box_size"] = used_box
423
+ if bkg_map is None:
424
+ print(" SEP tiered retries failed; switching to robust median fallback.")
425
+ method = "robust_median_fallback"
426
+
427
+ if method in ("robust_median_fallback", "median_filter"):
428
+ bkg_map, bkg_rms_map = _estimate_robust_median(sci_data, mask, method, config)
429
+ if diagnostics is not None:
430
+ diagnostics.pop("box_size", None)
431
+ if bkg_map is None and config.get("smooth_surface_fallback", True):
432
+ bkg_map, bkg_rms_map = _estimate_smooth_surface(sci_data, mask, config)
433
+ method = "smooth_surface"
434
+ elif method != "sep":
435
+ warnings.warn(f"Unknown background estimation method: '{method}'", RuntimeWarning)
436
+ return None, None
437
+
438
+ if bkg_rms_map is not None:
439
+ invalid = ~np.isfinite(bkg_rms_map) | (bkg_rms_map <= 0)
440
+ if np.any(invalid):
441
+ bkg_rms_map = np.where(~invalid, bkg_rms_map, np.inf)
442
+
443
+ if bkg_map is not None and bkg_rms_map is not None:
444
+ bkg_map = _repair_negative_dips(bkg_map, sci_data, bkg_rms_map, mask, config, diagnostics)
445
+
446
+ if diagnostics is not None:
447
+ diagnostics["effective_method"] = method
448
+
449
+ return bkg_map, bkg_rms_map
weightmask/bad.py ADDED
@@ -0,0 +1,184 @@
1
+ import warnings
2
+
3
+ import numpy as np
4
+
5
+
6
+ def _get_global_median(flat_data):
7
+ """Calculate a robust global median of valid pixels."""
8
+ valid_flat = flat_data[flat_data > 0]
9
+ step = max(1, valid_flat.size // 100000)
10
+ global_med = np.nanmedian(valid_flat[::step]) if valid_flat.size > 0 else 1.0
11
+ if not np.isfinite(global_med):
12
+ global_med = 1.0
13
+ return global_med
14
+
15
+
16
+ def _detect_bad_pixels_local(flat_data, config, global_med):
17
+ """Detect bad pixels via local median deviation."""
18
+ pixel_mask_bool = np.zeros(flat_data.shape, dtype=bool)
19
+ print(" Detecting bad pixels via local median deviation...")
20
+ try:
21
+ from scipy.ndimage import median_filter
22
+
23
+ filter_size = config.get("local_filter_size", config.get("filter_size", 15))
24
+ local_low_thresh = config.get("local_low_thresh", config.get("low_thresh", 0.5))
25
+ local_high_thresh = config.get("local_high_thresh", config.get("high_thresh", 2.0))
26
+
27
+ with warnings.catch_warnings():
28
+ warnings.simplefilter("ignore", category=RuntimeWarning)
29
+
30
+ # Create a heavily smoothed version of the flat to serve as the "true" illumination model.
31
+ # Replace NaNs/Infs with median so they don't corrupt the filter
32
+ clean_flat = flat_data.copy()
33
+
34
+ clean_flat[~np.isfinite(clean_flat)] = global_med
35
+
36
+ smoothed_flat = median_filter(clean_flat, size=filter_size)
37
+
38
+ # Avoid division by zero
39
+ smoothed_flat[smoothed_flat <= 0] = 1e-6
40
+
41
+ # Calculate the ratio between the raw flat and the local smoothed model
42
+ ratio = flat_data / smoothed_flat
43
+
44
+ # Flag pixels where the ratio is outside the acceptable bounds
45
+ pixel_mask_bool = (
46
+ (ratio <= local_low_thresh) | (ratio >= local_high_thresh) | (flat_data <= 0) | (~np.isfinite(flat_data))
47
+ )
48
+ print(
49
+ f" Found {np.count_nonzero(pixel_mask_bool)} bad pixels (Ratio < {local_low_thresh:.2f} or > {local_high_thresh:.2f})."
50
+ )
51
+
52
+ except Exception as e:
53
+ print(f" WARNING: Local pixel thresholding failed: {e}. Skipping.")
54
+ pixel_mask_bool.fill(False)
55
+
56
+ return pixel_mask_bool
57
+
58
+
59
+ def _detect_bad_columns_derivative(flat_data, config, global_med):
60
+ """Detect bad columns via horizontal derivatives."""
61
+ column_mask_bool = np.zeros(flat_data.shape, dtype=bool)
62
+
63
+ if not config.get("col_enable", True):
64
+ print(" Bad column detection disabled in config.")
65
+ return column_mask_bool
66
+
67
+ print(" Detecting bad columns via horizontal derivatives...")
68
+ try:
69
+ # A completely dead column will have zero variance and low median,
70
+ # but a partially bad column will just cause a sharp jump.
71
+ # We take the derivative across columns (axis 1) of the column medians.
72
+
73
+ with warnings.catch_warnings():
74
+ warnings.simplefilter("ignore", category=RuntimeWarning)
75
+ column_medians = np.nanmedian(flat_data, axis=0)
76
+
77
+ # Mask entirely NaN/Inf columns immediately
78
+ invalid_cols = ~np.isfinite(column_medians)
79
+ column_mask_bool[:, invalid_cols] = True
80
+ num_invalid = np.count_nonzero(invalid_cols)
81
+
82
+ if num_invalid < len(column_medians):
83
+ # Calculate horizontal derivative (difference between adjacent columns)
84
+ valid_medians = column_medians.copy()
85
+ valid_medians[invalid_cols] = np.nanmedian(valid_medians) # Patch for diff
86
+
87
+ col_diffs = np.abs(np.diff(valid_medians, prepend=valid_medians[0]))
88
+
89
+ # Robust statistics of the differences
90
+ med_diff = np.nanmedian(col_diffs)
91
+ mad_diff = np.nanmedian(np.abs(col_diffs - med_diff)) * 1.4826
92
+ if mad_diff == 0:
93
+ mad_diff = np.nanstd(col_diffs)
94
+
95
+ sigma_thresh = config.get("col_deriv_sigma", 10.0)
96
+ thresh = med_diff + sigma_thresh * mad_diff
97
+
98
+ # Columns where the jump from the neighbor is huge
99
+ jump_cols_idx = np.where(col_diffs > thresh)[0]
100
+
101
+ # Also catch columns that are completely dead (very close to 0)
102
+ dead_thresh = config.get("col_dead_thresh", config.get("col_median_dev_factor", 0.1)) * global_med
103
+ dead_cols_idx = np.where(valid_medians < dead_thresh)[0]
104
+
105
+ bad_cols_combined = np.unique(np.concatenate([jump_cols_idx, dead_cols_idx]))
106
+
107
+ if len(bad_cols_combined) > 0:
108
+ column_mask_bool[:, bad_cols_combined] = True
109
+ print(
110
+ f" Found {len(bad_cols_combined)} bad columns (Deriv > {thresh:.3g} or Med < {dead_thresh:.3g})"
111
+ )
112
+ if num_invalid > 0:
113
+ print(f" Plus {num_invalid} columns masked due to being mostly NaNs/Infs.")
114
+ else:
115
+ print(" No bad columns detected.")
116
+
117
+ except Exception as e:
118
+ import traceback
119
+
120
+ print(f" WARNING: Bad column detection failed: {e}. Skipping.")
121
+ print(traceback.format_exc())
122
+
123
+ return column_mask_bool
124
+
125
+
126
+ def detect_bad_pixels(flat_data, config, using_unit_flat=False):
127
+ """
128
+ Detect bad pixels and columns in the flat field using local structural analysis.
129
+
130
+ Instead of global thresholds which fail on vignetted fields, this applies
131
+ a median filter to create a structural model of the flat, and flags pixels
132
+ that deviate significantly from that model. Bad columns are found using
133
+ horizontal derivatives to find sharp discontinuities.
134
+
135
+ Args:
136
+ flat_data (ndarray): Flat field data array.
137
+ config (dict): Configuration dictionary for flat masking.
138
+ using_unit_flat (bool): Whether a unit flat (all 1.0) is being used.
139
+
140
+ Returns:
141
+ ndarray: Boolean mask of bad pixels and columns (True = bad).
142
+ """
143
+ if not np.isfinite(flat_data).any():
144
+ warnings.warn("Flat data contains no finite values. Returning empty mask.", RuntimeWarning)
145
+ return np.zeros(flat_data.shape, dtype=bool)
146
+
147
+ if using_unit_flat:
148
+ print(" Skipping bad pixel/column detection (using unit flat).")
149
+ return np.zeros(flat_data.shape, dtype=bool)
150
+
151
+ global_med = _get_global_median(flat_data)
152
+
153
+ # --- 1. Local Structural Pixel Thresholding ---
154
+ pixel_mask_bool = _detect_bad_pixels_local(flat_data, config, global_med)
155
+
156
+ # --- 2. Derivative-Based Column Detection ---
157
+ column_mask_bool = _detect_bad_columns_derivative(flat_data, config, global_med)
158
+
159
+ # --- 3. Combine Masks ---
160
+ final_mask_bool = pixel_mask_bool | column_mask_bool
161
+ total_bad = np.count_nonzero(final_mask_bool)
162
+ print(f" Total BAD pixels/columns identified in flat: {total_bad}")
163
+
164
+ return final_mask_bool
165
+
166
+
167
+ def compute_flat_bad_mask(flat_data, config, tile_size=1024):
168
+ """Compute the full bad-pixel/column mask for one flat HDU, tiled.
169
+
170
+ Runs ``detect_bad_pixels`` over the same 1024x1024 tiles used by
171
+ ``process_image`` and ORs the results into a full-HDU mask. The result
172
+ depends only on the flat (and config), so callers cache it per flat and
173
+ reuse it across every exposure that shares that flat instead of
174
+ recomputing the expensive 15x15 median filter each time.
175
+ """
176
+ bad_mask = np.zeros(flat_data.shape, dtype=bool)
177
+ for y in range(0, flat_data.shape[0], tile_size):
178
+ for x in range(0, flat_data.shape[1], tile_size):
179
+ tile = (slice(y, y + tile_size), slice(x, x + tile_size))
180
+ flat_tile = flat_data[tile]
181
+ if not np.isfinite(flat_tile).any():
182
+ continue
183
+ bad_mask[tile] = detect_bad_pixels(flat_tile, config, using_unit_flat=False)
184
+ return bad_mask