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 +36 -0
- weightmask/background.py +449 -0
- weightmask/bad.py +184 -0
- weightmask/cli.py +416 -0
- weightmask/contract.py +330 -0
- weightmask/cosmics.py +275 -0
- weightmask/mef.py +800 -0
- weightmask/objects.py +257 -0
- weightmask/pipeline.py +66 -0
- weightmask/process.py +413 -0
- weightmask/reconstruct_sky.py +153 -0
- weightmask/satur.py +402 -0
- weightmask/streaks.py +1411 -0
- weightmask/torchfits_adapter.py +82 -0
- weightmask/utils.py +94 -0
- weightmask/variance.py +338 -0
- weightmask/weight.py +73 -0
- weightmask-0.1.0.dist-info/METADATA +83 -0
- weightmask-0.1.0.dist-info/RECORD +23 -0
- weightmask-0.1.0.dist-info/WHEEL +5 -0
- weightmask-0.1.0.dist-info/entry_points.txt +3 -0
- weightmask-0.1.0.dist-info/licenses/LICENSE +21 -0
- weightmask-0.1.0.dist-info/top_level.txt +1 -0
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}")
|
weightmask/background.py
ADDED
|
@@ -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
|