SpecProc 1.0.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.
- specproc/__init__.py +9 -0
- specproc/calib_data/linelists/FeAr.dat +375 -0
- specproc/calib_data/linelists/README +79 -0
- specproc/calib_data/linelists/airvac.cl +61 -0
- specproc/calib_data/linelists/argon.dat +161 -0
- specproc/calib_data/linelists/ctiohear.dat +106 -0
- specproc/calib_data/linelists/ctiohenear.dat +146 -0
- specproc/calib_data/linelists/cuar.dat +459 -0
- specproc/calib_data/linelists/henear.dat +93 -0
- specproc/calib_data/linelists/henearhres.dat +300 -0
- specproc/calib_data/linelists/idhenear.dat +127 -0
- specproc/calib_data/linelists/krypton.dat +109 -0
- specproc/calib_data/linelists/lowskylines.dat +577 -0
- specproc/calib_data/linelists/ohlines.dat +268 -0
- specproc/calib_data/linelists/skylines.dat +1385 -0
- specproc/calib_data/linelists/thar-noao.dat +4169 -0
- specproc/calib_data/linelists/thar.dat +3057 -0
- specproc/calib_data/linelists/thorium.dat +3057 -0
- specproc/calib_data/linelists/vacidhenear.dat +127 -0
- specproc/calib_data/linelists/vacthar.dat +3057 -0
- specproc/calib_data/linelists/vacthorium.dat +3057 -0
- specproc/calib_data/linelists/xenon.dat +89 -0
- specproc/calib_data/telescopes/lijiang24hrs/lijiang24_hrs_thar_lines.csv +550 -0
- specproc/calib_data/telescopes/xinglong216bfosc/BFOSC/345/256/232/346/240/207/347/201/257/350/260/261/350/256/244/350/257/201/346/211/213/345/206/214v171118.pdf +0 -0
- specproc/calib_data/telescopes/xinglong216bfosc//346/212/245/345/221/212/345/233/276.docx +0 -0
- specproc/calib_data/telescopes/xinglong216hrs/216HRS /347/201/257/350/260/261/350/257/201/350/256/244/346/226/207/344/273/266.pdf +0 -0
- specproc/calib_data/telescopes/xinglong216hrs/wlcalib_20211123011_A.png +0 -0
- specproc/calib_data/telescopes/xinglong216hrs/xinglong216_thar_lines.csv +517 -0
- specproc/config/__init__.py +1 -0
- specproc/config/config_manager.py +431 -0
- specproc/core/__init__.py +1 -0
- specproc/core/basic_reduction.py +615 -0
- specproc/core/data_structures.py +525 -0
- specproc/core/de_blazing.py +210 -0
- specproc/core/extraction.py +375 -0
- specproc/core/flat_correction.py +1255 -0
- specproc/core/line_identification.py +302 -0
- specproc/core/order_stitching.py +187 -0
- specproc/core/order_tracing.py +1587 -0
- specproc/core/processing_pipeline.py +1183 -0
- specproc/core/scattered_light.py +654 -0
- specproc/core/slit_pipeline.py +762 -0
- specproc/core/wave_calibration.py +961 -0
- specproc/gui/__init__.py +1 -0
- specproc/gui/line_identifier.py +731 -0
- specproc/gui/main_window.py +2315 -0
- specproc/gui/settings_dialog.py +946 -0
- specproc/main.py +388 -0
- specproc/plotting/__init__.py +1 -0
- specproc/plotting/spectra_plotter.py +483 -0
- specproc/utils/__init__.py +1 -0
- specproc/utils/airvac.py +123 -0
- specproc/utils/fits_io.py +269 -0
- specproc/utils/image_processing.py +426 -0
- specproc/utils/overscan.py +600 -0
- specproc-1.0.0.dist-info/METADATA +668 -0
- specproc-1.0.0.dist-info/RECORD +61 -0
- specproc-1.0.0.dist-info/WHEEL +5 -0
- specproc-1.0.0.dist-info/entry_points.txt +5 -0
- specproc-1.0.0.dist-info/licenses/LICENSE +674 -0
- specproc-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FITS file I/O utilities for reading and writing spectral data.
|
|
3
|
+
|
|
4
|
+
Provides functions for loading raw FITS images, writing intermediate
|
|
5
|
+
results, and managing FITS headers.
|
|
6
|
+
"""
|
|
7
|
+
# 负责光谱数据 FITS 文件的读取与写入。
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from astropy.io import fits
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Tuple, Dict, Optional
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# 读取 FITS 图像及其头信息。
|
|
19
|
+
def read_fits_image(filepath: str) -> Tuple[np.ndarray, dict]:
|
|
20
|
+
"""
|
|
21
|
+
Read a FITS image file.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
filepath: Path to FITS file
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Tuple of (image_data, header_dict)
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
with fits.open(filepath) as hdul:
|
|
31
|
+
if len(hdul) == 0:
|
|
32
|
+
raise ValueError(f"FITS file {filepath} has no HDUs")
|
|
33
|
+
|
|
34
|
+
# Get primary data (image)
|
|
35
|
+
image_data = hdul[0].data
|
|
36
|
+
if image_data is None:
|
|
37
|
+
# Try first non-empty HDU
|
|
38
|
+
for hdu in hdul:
|
|
39
|
+
if hdu.data is not None:
|
|
40
|
+
image_data = hdu.data
|
|
41
|
+
break
|
|
42
|
+
|
|
43
|
+
# Get header
|
|
44
|
+
header = dict(hdul[0].header)
|
|
45
|
+
|
|
46
|
+
return image_data, header
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.error(f"Error reading FITS file {filepath}: {e}")
|
|
49
|
+
raise
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# 读取 FITS 表格及其头信息。
|
|
53
|
+
def read_fits_table(filepath: str, hdu: int = 1) -> Tuple[np.ndarray, dict]:
|
|
54
|
+
"""
|
|
55
|
+
Read structured table from FITS file.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
filepath: Path to FITS file
|
|
59
|
+
hdu: HDU index (default 1 for binary table)
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Tuple of (table_data, header_dict)
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
with fits.open(filepath) as hdul:
|
|
66
|
+
if len(hdul) <= hdu:
|
|
67
|
+
raise ValueError(f"FITS file {filepath} has no HDU at index {hdu}")
|
|
68
|
+
|
|
69
|
+
table_hdu = hdul[hdu]
|
|
70
|
+
data = table_hdu.data
|
|
71
|
+
header = dict(table_hdu.header)
|
|
72
|
+
|
|
73
|
+
return data, header
|
|
74
|
+
except Exception as e:
|
|
75
|
+
logger.error(f"Error reading FITS table from {filepath}: {e}")
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# 将图像数据写入 FITS 文件。
|
|
80
|
+
def write_fits_image(filepath: str, data: np.ndarray, header: Optional[Dict] = None,
|
|
81
|
+
overwrite: bool = True, dtype: Optional[str] = None):
|
|
82
|
+
"""
|
|
83
|
+
Write image to FITS file.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
filepath: Output file path
|
|
87
|
+
data: Image array
|
|
88
|
+
header: Optional FITS header dictionary
|
|
89
|
+
overwrite: Overwrite existing file
|
|
90
|
+
dtype: Optional data type for output (e.g., 'float32', 'float64')
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
# Convert data to specified dtype if provided
|
|
94
|
+
if dtype is not None:
|
|
95
|
+
original_dtype = str(data.dtype)
|
|
96
|
+
data = data.astype(dtype)
|
|
97
|
+
logger.debug(f"Converted data from {original_dtype} to {dtype}")
|
|
98
|
+
|
|
99
|
+
# Create primary HDU
|
|
100
|
+
hdu = fits.PrimaryHDU(data=data)
|
|
101
|
+
|
|
102
|
+
# If writing to a float format, remove BZERO and BSCALE to prevent incorrect scaling
|
|
103
|
+
# by FITS readers. These keywords are for scaling integers.
|
|
104
|
+
if data.dtype.kind == 'f' and header:
|
|
105
|
+
if 'BZERO' in header: del header['BZERO']
|
|
106
|
+
if 'BSCALE' in header: del header['BSCALE']
|
|
107
|
+
|
|
108
|
+
# Add header keywords
|
|
109
|
+
if header:
|
|
110
|
+
for key, value in header.items():
|
|
111
|
+
try:
|
|
112
|
+
# Skip COMMENT and HISTORY - they often contain special formatting
|
|
113
|
+
# that causes issues with astropy, and are not critical for processing
|
|
114
|
+
if key.upper() in ('COMMENT', 'HISTORY'):
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
# astropy.io.fits considers a single dot as an illegal value.
|
|
118
|
+
# Replace it with an empty string, which is valid.
|
|
119
|
+
if isinstance(value, str) and value.strip() == '.':
|
|
120
|
+
value = ''
|
|
121
|
+
|
|
122
|
+
# Clean up non-ASCII characters for FITS header values
|
|
123
|
+
if isinstance(value, str):
|
|
124
|
+
# Remove or replace non-ASCII characters
|
|
125
|
+
cleaned_value = value.encode('ascii', 'ignore').decode('ascii')
|
|
126
|
+
# Truncate if too long (FITS headers have max 80 chars per record)
|
|
127
|
+
if len(cleaned_value) > 70:
|
|
128
|
+
cleaned_value = cleaned_value[:67] + '...'
|
|
129
|
+
hdu.header[key] = cleaned_value
|
|
130
|
+
else:
|
|
131
|
+
hdu.header[key] = value
|
|
132
|
+
except Exception as e:
|
|
133
|
+
logger.warning(f"Could not add header keyword {key}: {e}")
|
|
134
|
+
|
|
135
|
+
# Write to file
|
|
136
|
+
hdul = fits.HDUList([hdu])
|
|
137
|
+
hdul.writeto(filepath, overwrite=overwrite)
|
|
138
|
+
|
|
139
|
+
logger.info(f"Wrote image to {filepath}")
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.error(f"Error writing FITS image {filepath}: {e}")
|
|
142
|
+
raise
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# 将结构化数组写入 FITS 表格。
|
|
146
|
+
def write_fits_table(filepath: str, data: np.ndarray, header: Optional[Dict] = None,
|
|
147
|
+
overwrite: bool = True):
|
|
148
|
+
"""
|
|
149
|
+
Write structured array as binary table to FITS file.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
filepath: Output file path
|
|
153
|
+
data: Structured numpy array with named fields
|
|
154
|
+
header: Optional FITS header
|
|
155
|
+
overwrite: Overwrite existing file
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
# Create binary table HDU from structured array
|
|
159
|
+
cols = []
|
|
160
|
+
for name in data.dtype.names:
|
|
161
|
+
col_data = data[name]
|
|
162
|
+
if col_data.dtype == np.object_:
|
|
163
|
+
# Handle variable-length columns
|
|
164
|
+
format_str = f'P{len(col_data[0])}'
|
|
165
|
+
col = fits.Column(name=name, format=format_str, array=col_data)
|
|
166
|
+
else:
|
|
167
|
+
col = fits.Column(name=name, format=f'{len(col_data[0]) if col_data.ndim > 1 else 1}D',
|
|
168
|
+
array=col_data)
|
|
169
|
+
cols.append(col)
|
|
170
|
+
|
|
171
|
+
table_hdu = fits.BinTableHDU.from_columns(cols)
|
|
172
|
+
|
|
173
|
+
# Add header keywords
|
|
174
|
+
if header:
|
|
175
|
+
for key, value in header.items():
|
|
176
|
+
try:
|
|
177
|
+
table_hdu.header[key] = value
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.warning(f"Could not add header keyword {key}: {e}")
|
|
180
|
+
|
|
181
|
+
# Create HDU list with primary + table
|
|
182
|
+
primary_hdu = fits.PrimaryHDU()
|
|
183
|
+
hdul = fits.HDUList([primary_hdu, table_hdu])
|
|
184
|
+
hdul.writeto(filepath, overwrite=overwrite)
|
|
185
|
+
|
|
186
|
+
logger.info(f"Wrote table to {filepath}")
|
|
187
|
+
except Exception as e:
|
|
188
|
+
logger.error(f"Error writing FITS table {filepath}: {e}")
|
|
189
|
+
raise
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# 忽略大小写查找头关键字。
|
|
193
|
+
def get_header_value(header: dict, keyword: str, default: any = None) -> any:
|
|
194
|
+
"""
|
|
195
|
+
Get header value with case-insensitive lookup.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
header: FITS header dictionary
|
|
199
|
+
keyword: Keyword name
|
|
200
|
+
default: Default value if not found
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Header value or default
|
|
204
|
+
"""
|
|
205
|
+
# Try exact match
|
|
206
|
+
if keyword in header:
|
|
207
|
+
return header[keyword]
|
|
208
|
+
|
|
209
|
+
# Try case-insensitive
|
|
210
|
+
for key, value in header.items():
|
|
211
|
+
if key.upper() == keyword.upper():
|
|
212
|
+
return value
|
|
213
|
+
|
|
214
|
+
return default
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# 获取 FITS 图像的基本信息。
|
|
218
|
+
def get_image_info(filepath: str) -> Dict:
|
|
219
|
+
"""
|
|
220
|
+
Get basic information about a FITS image.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
filepath: Path to FITS file
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Dictionary with image info (shape, datatype, header keywords)
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
img, header = read_fits_image(filepath)
|
|
230
|
+
return {
|
|
231
|
+
'filepath': filepath,
|
|
232
|
+
'shape': img.shape,
|
|
233
|
+
'dtype': str(img.dtype),
|
|
234
|
+
'exptime': get_header_value(header, 'EXPTIME', 0.0),
|
|
235
|
+
'date_obs': get_header_value(header, 'DATE-OBS', ''),
|
|
236
|
+
'object': get_header_value(header, 'OBJECT', ''),
|
|
237
|
+
}
|
|
238
|
+
except Exception as e:
|
|
239
|
+
logger.error(f"Error getting info from {filepath}: {e}")
|
|
240
|
+
return {}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# 按指定方法合并多个 FITS 图像。
|
|
244
|
+
def combine_fits_images(*filepaths: str, method: str = 'mean') -> np.ndarray:
|
|
245
|
+
"""
|
|
246
|
+
Combine multiple FITS images.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
filepaths: Paths to FITS files
|
|
250
|
+
method: 'mean', 'median', or 'sum'
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Combined image array
|
|
254
|
+
"""
|
|
255
|
+
images = []
|
|
256
|
+
for filepath in filepaths:
|
|
257
|
+
img, _ = read_fits_image(filepath)
|
|
258
|
+
images.append(img)
|
|
259
|
+
|
|
260
|
+
images = np.array(images)
|
|
261
|
+
|
|
262
|
+
if method == 'mean':
|
|
263
|
+
return np.mean(images, axis=0)
|
|
264
|
+
elif method == 'median':
|
|
265
|
+
return np.median(images, axis=0)
|
|
266
|
+
elif method == 'sum':
|
|
267
|
+
return np.sum(images, axis=0)
|
|
268
|
+
else:
|
|
269
|
+
raise ValueError(f"Unknown combine method: {method}")
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Image processing utilities for spectral data.
|
|
3
|
+
|
|
4
|
+
Provides functions for image operations like combining, filtering,
|
|
5
|
+
and detecting bad pixels.
|
|
6
|
+
"""
|
|
7
|
+
# 提供图像合并、滤波和背景处理工具。
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from typing import Optional, Tuple
|
|
11
|
+
from scipy.ndimage import gaussian_filter
|
|
12
|
+
from scipy import signal, ndimage
|
|
13
|
+
from scipy.interpolate import SmoothBivariateSpline
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# 合并多幅图像并估计不确定度。
|
|
20
|
+
def combine_images(images: np.ndarray, method: str = 'median',
|
|
21
|
+
sigma: float = 3.0) -> Tuple[np.ndarray, np.ndarray]:
|
|
22
|
+
"""
|
|
23
|
+
Combine multiple 2D images.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
images: Array of shape (n_images, height, width)
|
|
27
|
+
method: 'mean', 'median', or 'sigma_clip'
|
|
28
|
+
sigma: Sigma clipping threshold (for sigma_clip method)
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Tuple of (combined_image, uncertainty_map)
|
|
32
|
+
"""
|
|
33
|
+
if method == 'mean':
|
|
34
|
+
combined = np.mean(images, axis=0)
|
|
35
|
+
uncertainty = np.std(images, axis=0) / np.sqrt(images.shape[0])
|
|
36
|
+
return combined, uncertainty
|
|
37
|
+
|
|
38
|
+
elif method == 'median':
|
|
39
|
+
combined = np.median(images, axis=0)
|
|
40
|
+
uncertainty = np.std(images, axis=0)
|
|
41
|
+
return combined, uncertainty
|
|
42
|
+
|
|
43
|
+
elif method == 'sigma_clip':
|
|
44
|
+
combined = np.zeros_like(images[0], dtype=float)
|
|
45
|
+
uncertainty = np.zeros_like(images[0], dtype=float)
|
|
46
|
+
|
|
47
|
+
for i in range(images.shape[1]):
|
|
48
|
+
for j in range(images.shape[2]):
|
|
49
|
+
col = images[:, i, j]
|
|
50
|
+
mean = np.mean(col)
|
|
51
|
+
std = np.std(col)
|
|
52
|
+
|
|
53
|
+
# Mask outliers
|
|
54
|
+
mask = np.abs(col - mean) < sigma * std
|
|
55
|
+
if np.sum(mask) > 0:
|
|
56
|
+
combined[i, j] = np.mean(col[mask])
|
|
57
|
+
uncertainty[i, j] = std
|
|
58
|
+
else:
|
|
59
|
+
combined[i, j] = mean
|
|
60
|
+
uncertainty[i, j] = std
|
|
61
|
+
|
|
62
|
+
return combined, uncertainty
|
|
63
|
+
|
|
64
|
+
else:
|
|
65
|
+
raise ValueError(f"Unknown combine method: {method}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# 对二维图像应用 Savitzky-Golay 滤波。
|
|
69
|
+
def savitzky_golay_2d(image: np.ndarray, window_size: int = 5,
|
|
70
|
+
order: int = 2) -> np.ndarray:
|
|
71
|
+
"""
|
|
72
|
+
Apply 2D Savitzky-Golay filter.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
image: 2D image array
|
|
76
|
+
window_size: Filter window size (must be odd)
|
|
77
|
+
order: Polynomial order
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Filtered image
|
|
81
|
+
"""
|
|
82
|
+
if window_size % 2 == 0:
|
|
83
|
+
window_size += 1
|
|
84
|
+
|
|
85
|
+
# Create 1D filter
|
|
86
|
+
coeffs = signal.savgol_coeffs(window_size, order)
|
|
87
|
+
|
|
88
|
+
# Apply horizontal and vertical
|
|
89
|
+
horizontal = ndimage.convolve1d(image, coeffs, axis=1)
|
|
90
|
+
filtered = ndimage.convolve1d(horizontal, coeffs, axis=0)
|
|
91
|
+
|
|
92
|
+
return filtered
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# 按阈值检测图像中的异常像素。
|
|
96
|
+
def find_bad_pixels(image: np.ndarray, threshold: float = 3.0,
|
|
97
|
+
method: str = 'sigma') -> np.ndarray:
|
|
98
|
+
"""
|
|
99
|
+
Find bad (outlier) pixels in an image.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
image: 2D image array
|
|
103
|
+
threshold: Detection threshold (sigma)
|
|
104
|
+
method: 'sigma' for sigma-clipping, 'percentile' for percentile-based
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Boolean mask (True = bad pixel)
|
|
108
|
+
"""
|
|
109
|
+
if method == 'sigma':
|
|
110
|
+
mean = np.mean(image)
|
|
111
|
+
std = np.std(image)
|
|
112
|
+
bad_pixels = np.abs(image - mean) > threshold * std
|
|
113
|
+
return bad_pixels
|
|
114
|
+
|
|
115
|
+
elif method == 'percentile':
|
|
116
|
+
lower = np.percentile(image, threshold)
|
|
117
|
+
upper = np.percentile(image, 100 - threshold)
|
|
118
|
+
bad_pixels = (image < lower) | (image > upper)
|
|
119
|
+
return bad_pixels
|
|
120
|
+
|
|
121
|
+
else:
|
|
122
|
+
raise ValueError(f"Unknown method: {method}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# 构建多项式或切比雪夫基矩阵。
|
|
126
|
+
def _build_design_matrix(x: np.ndarray, y: np.ndarray, order: int,
|
|
127
|
+
method: str) -> np.ndarray:
|
|
128
|
+
"""Build 2D basis matrix for polynomial or Chebyshev fitting."""
|
|
129
|
+
if method == 'chebyshev':
|
|
130
|
+
vander = np.polynomial.chebyshev.chebvander2d(x, y, [order, order])
|
|
131
|
+
return vander.reshape(x.size, -1)
|
|
132
|
+
|
|
133
|
+
# Fallback: regular polynomial basis.
|
|
134
|
+
ncoeff = (order + 1) * (order + 1)
|
|
135
|
+
A = np.zeros((x.size, ncoeff), dtype=np.float64)
|
|
136
|
+
idx = 0
|
|
137
|
+
for i in range(order + 1):
|
|
138
|
+
for j in range(order + 1):
|
|
139
|
+
A[:, idx] = (x ** i) * (y ** j)
|
|
140
|
+
idx += 1
|
|
141
|
+
return A
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# 使用中位绝对偏差估计稳健标准差。
|
|
145
|
+
def _robust_sigma(values: np.ndarray) -> float:
|
|
146
|
+
"""Robust sigma estimate via MAD; returns 0 for empty arrays."""
|
|
147
|
+
if values.size == 0:
|
|
148
|
+
return 0.0
|
|
149
|
+
med = np.median(values)
|
|
150
|
+
mad = np.median(np.abs(values - med))
|
|
151
|
+
if mad <= 0:
|
|
152
|
+
std = np.std(values)
|
|
153
|
+
return float(std) if np.isfinite(std) else 0.0
|
|
154
|
+
return float(1.4826 * mad)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# 为单幅图像拟合平滑二维背景。
|
|
158
|
+
def _estimate_single_background(image: np.ndarray, order: int = 2,
|
|
159
|
+
valid_mask: Optional[np.ndarray] = None,
|
|
160
|
+
method: str = 'chebyshev',
|
|
161
|
+
smooth_sigma: float = 20.0,
|
|
162
|
+
sigma_clip: float = 3.0,
|
|
163
|
+
maxiters: int = 4,
|
|
164
|
+
bspline_smooth: float = 1.0,
|
|
165
|
+
clip_mode: str = 'both') -> np.ndarray:
|
|
166
|
+
"""Estimate smooth 2D background for a single (non-split) image.
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
clip_mode : {'both', 'upper', 'lower'}
|
|
171
|
+
Sigma-clipping mode. 'both' rejects outliers on both sides
|
|
172
|
+
(symmetric, the traditional behaviour). 'upper' rejects only
|
|
173
|
+
positive outliers (pixels brighter than the model) which is
|
|
174
|
+
recommended for flat-field background estimation where unmasked
|
|
175
|
+
faint orders contaminate the inter-order region. 'lower' rejects
|
|
176
|
+
only negative outliers.
|
|
177
|
+
"""
|
|
178
|
+
rows, cols = image.shape
|
|
179
|
+
yy, xx = np.meshgrid(np.arange(rows), np.arange(cols), indexing='ij')
|
|
180
|
+
|
|
181
|
+
fit_mask = np.isfinite(image)
|
|
182
|
+
if valid_mask is not None:
|
|
183
|
+
fit_mask &= valid_mask.astype(bool)
|
|
184
|
+
|
|
185
|
+
if np.sum(fit_mask) < max(100, (order + 1) ** 2):
|
|
186
|
+
logger.warning("Too few valid samples for background fit; returning zeros")
|
|
187
|
+
return np.zeros_like(image, dtype=np.float64)
|
|
188
|
+
|
|
189
|
+
work_data = image.astype(np.float64)
|
|
190
|
+
|
|
191
|
+
if method in ('smooth', 'gaussian_smooth'):
|
|
192
|
+
current = fit_mask.copy()
|
|
193
|
+
s = max(1.0, float(smooth_sigma))
|
|
194
|
+
|
|
195
|
+
for _ in range(max(1, int(maxiters))):
|
|
196
|
+
use = np.zeros_like(work_data, dtype=np.float64)
|
|
197
|
+
wgt = np.zeros_like(work_data, dtype=np.float64)
|
|
198
|
+
use[current] = work_data[current]
|
|
199
|
+
wgt[current] = 1.0
|
|
200
|
+
|
|
201
|
+
num = gaussian_filter(use, sigma=s, mode='nearest')
|
|
202
|
+
den = gaussian_filter(wgt, sigma=s, mode='nearest')
|
|
203
|
+
model = num / np.clip(den, 1e-8, None)
|
|
204
|
+
|
|
205
|
+
if sigma_clip <= 0:
|
|
206
|
+
return model
|
|
207
|
+
|
|
208
|
+
resid = work_data[current] - model[current]
|
|
209
|
+
sig = _robust_sigma(resid)
|
|
210
|
+
if sig <= 0:
|
|
211
|
+
break
|
|
212
|
+
|
|
213
|
+
diff = work_data - model
|
|
214
|
+
if clip_mode == 'upper':
|
|
215
|
+
keep = diff <= sigma_clip * sig
|
|
216
|
+
elif clip_mode == 'lower':
|
|
217
|
+
keep = diff >= -sigma_clip * sig
|
|
218
|
+
else:
|
|
219
|
+
keep = np.abs(diff) <= sigma_clip * sig
|
|
220
|
+
new_current = current & keep
|
|
221
|
+
if np.array_equal(new_current, current):
|
|
222
|
+
break
|
|
223
|
+
if np.sum(new_current) < max(100, (order + 1) ** 2):
|
|
224
|
+
break
|
|
225
|
+
current = new_current
|
|
226
|
+
|
|
227
|
+
return model
|
|
228
|
+
|
|
229
|
+
x_norm = (xx.astype(np.float64) / max(cols - 1, 1)) * 2.0 - 1.0
|
|
230
|
+
y_norm = (yy.astype(np.float64) / max(rows - 1, 1)) * 2.0 - 1.0
|
|
231
|
+
|
|
232
|
+
xp = x_norm[fit_mask]
|
|
233
|
+
yp = y_norm[fit_mask]
|
|
234
|
+
zp = work_data[fit_mask]
|
|
235
|
+
keep = np.ones_like(zp, dtype=bool)
|
|
236
|
+
|
|
237
|
+
for _ in range(max(1, int(maxiters))):
|
|
238
|
+
if np.sum(keep) < max(100, (order + 1) ** 2):
|
|
239
|
+
break
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
if method == 'bspline':
|
|
243
|
+
kxy = max(1, min(3, int(order)))
|
|
244
|
+
s = max(0.0, float(bspline_smooth)) * float(np.sum(keep))
|
|
245
|
+
spline = SmoothBivariateSpline(
|
|
246
|
+
xp[keep], yp[keep], zp[keep], kx=kxy, ky=kxy, s=s
|
|
247
|
+
)
|
|
248
|
+
fit_all = spline.ev(xp, yp)
|
|
249
|
+
model = spline.ev(x_norm.ravel(), y_norm.ravel()).reshape(rows, cols)
|
|
250
|
+
else:
|
|
251
|
+
fit_kind = 'chebyshev' if method not in ('poly', 'polynomial') else 'poly'
|
|
252
|
+
A = _build_design_matrix(xp[keep], yp[keep], order, fit_kind)
|
|
253
|
+
coeffs, _, _, _ = np.linalg.lstsq(A, zp[keep], rcond=None)
|
|
254
|
+
A_all = _build_design_matrix(xp, yp, order, fit_kind)
|
|
255
|
+
fit_all = A_all @ coeffs
|
|
256
|
+
A_img = _build_design_matrix(x_norm.ravel(), y_norm.ravel(), order, fit_kind)
|
|
257
|
+
model = (A_img @ coeffs).reshape(rows, cols)
|
|
258
|
+
except Exception as e:
|
|
259
|
+
logger.warning(f"Background {method} fit failed, fallback to gaussian_smooth: {e}")
|
|
260
|
+
return _estimate_single_background(
|
|
261
|
+
image,
|
|
262
|
+
order=order,
|
|
263
|
+
valid_mask=valid_mask,
|
|
264
|
+
method='gaussian_smooth',
|
|
265
|
+
smooth_sigma=smooth_sigma,
|
|
266
|
+
sigma_clip=sigma_clip,
|
|
267
|
+
maxiters=maxiters,
|
|
268
|
+
bspline_smooth=bspline_smooth,
|
|
269
|
+
clip_mode=clip_mode,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if sigma_clip <= 0:
|
|
273
|
+
return model
|
|
274
|
+
|
|
275
|
+
resid = zp - fit_all
|
|
276
|
+
sig = _robust_sigma(resid[keep])
|
|
277
|
+
if sig <= 0:
|
|
278
|
+
break
|
|
279
|
+
|
|
280
|
+
center = np.median(resid[keep])
|
|
281
|
+
if clip_mode == 'upper':
|
|
282
|
+
new_keep = (resid - center) <= sigma_clip * sig
|
|
283
|
+
elif clip_mode == 'lower':
|
|
284
|
+
new_keep = (resid - center) >= -sigma_clip * sig
|
|
285
|
+
else:
|
|
286
|
+
new_keep = np.abs(resid - center) <= sigma_clip * sig
|
|
287
|
+
if np.array_equal(new_keep, keep):
|
|
288
|
+
break
|
|
289
|
+
keep = new_keep
|
|
290
|
+
|
|
291
|
+
return model
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# Public alias: estimate a single region without any internal splitting.
|
|
295
|
+
# Use this when the caller handles detector-half splitting externally.
|
|
296
|
+
estimate_background_region = _estimate_single_background
|
|
297
|
+
# 估计二维背景并可按区域分别拟合。
|
|
298
|
+
def estimate_background_2d(image: np.ndarray, order: int = 2,
|
|
299
|
+
split_vertically: bool = True,
|
|
300
|
+
valid_mask: Optional[np.ndarray] = None,
|
|
301
|
+
method: str = 'chebyshev',
|
|
302
|
+
smooth_sigma: float = 20.0,
|
|
303
|
+
sigma_clip: float = 3.0,
|
|
304
|
+
maxiters: int = 4,
|
|
305
|
+
bspline_smooth: float = 1.0) -> np.ndarray:
|
|
306
|
+
"""
|
|
307
|
+
Estimate smooth 2D background using polynomial fitting.
|
|
308
|
+
|
|
309
|
+
By default, splits the image into upper and lower halves for independent
|
|
310
|
+
processing (e.g., for CCDs with two readout amplifiers).
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
image: 2D image array
|
|
314
|
+
order: Polynomial order
|
|
315
|
+
split_vertically: Split image into upper/lower halves for independent
|
|
316
|
+
background fitting (default: True)
|
|
317
|
+
valid_mask: Optional boolean mask of valid sampling pixels (True = use)
|
|
318
|
+
method: 'chebyshev', 'bspline', 'gaussian_smooth', or 'poly'
|
|
319
|
+
smooth_sigma: Gaussian sigma for smooth method
|
|
320
|
+
sigma_clip: Sigma-clipping threshold for robust rejection
|
|
321
|
+
maxiters: Maximum sigma-clipping iterations
|
|
322
|
+
bspline_smooth: Smoothing factor scale for bspline method
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
Background model
|
|
326
|
+
"""
|
|
327
|
+
rows, cols = image.shape
|
|
328
|
+
|
|
329
|
+
if split_vertically and rows > 1:
|
|
330
|
+
# Split into top and bottom halves for independent processing
|
|
331
|
+
# Note: numpy image[0,:] = ds9 row 1 (bottom), image[rows-1,:] = ds9 row rows (top)
|
|
332
|
+
# image[:mid_row,:] = image_bottom = ds9 bottom half (small row numbers)
|
|
333
|
+
# image[mid_row:,:] = image_top = ds9 top half (large row numbers)
|
|
334
|
+
mid_row = rows // 2
|
|
335
|
+
logger.info(f"Estimating background with vertical split at row {mid_row}")
|
|
336
|
+
|
|
337
|
+
image_bottom = image[:mid_row, :]
|
|
338
|
+
image_top = image[mid_row:, :]
|
|
339
|
+
mask_bottom = valid_mask[:mid_row, :] if valid_mask is not None else None
|
|
340
|
+
mask_top = valid_mask[mid_row:, :] if valid_mask is not None else None
|
|
341
|
+
|
|
342
|
+
background_bottom = _estimate_single_background(
|
|
343
|
+
image_bottom,
|
|
344
|
+
order,
|
|
345
|
+
valid_mask=mask_bottom,
|
|
346
|
+
method=method,
|
|
347
|
+
smooth_sigma=smooth_sigma,
|
|
348
|
+
sigma_clip=sigma_clip,
|
|
349
|
+
maxiters=maxiters,
|
|
350
|
+
bspline_smooth=bspline_smooth,
|
|
351
|
+
)
|
|
352
|
+
background_top = _estimate_single_background(
|
|
353
|
+
image_top,
|
|
354
|
+
order,
|
|
355
|
+
valid_mask=mask_top,
|
|
356
|
+
method=method,
|
|
357
|
+
smooth_sigma=smooth_sigma,
|
|
358
|
+
sigma_clip=sigma_clip,
|
|
359
|
+
maxiters=maxiters,
|
|
360
|
+
bspline_smooth=bspline_smooth,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Combine backgrounds
|
|
364
|
+
background = np.zeros_like(image)
|
|
365
|
+
background[:mid_row, :] = background_bottom
|
|
366
|
+
background[mid_row:, :] = background_top
|
|
367
|
+
|
|
368
|
+
return background
|
|
369
|
+
else:
|
|
370
|
+
return _estimate_single_background(
|
|
371
|
+
image,
|
|
372
|
+
order,
|
|
373
|
+
valid_mask=valid_mask,
|
|
374
|
+
method=method,
|
|
375
|
+
smooth_sigma=smooth_sigma,
|
|
376
|
+
sigma_clip=sigma_clip,
|
|
377
|
+
maxiters=maxiters,
|
|
378
|
+
bspline_smooth=bspline_smooth,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# 归一化平场图像至单位响应。
|
|
383
|
+
def normalize_flat(flat_image: np.ndarray, mask: Optional[np.ndarray] = None) -> np.ndarray:
|
|
384
|
+
"""
|
|
385
|
+
Normalize flat field to unity using sigma clipping.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
flat_image: Flat field image
|
|
389
|
+
mask: Optional bad pixel mask
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
Normalized flat (clipped to valid range)
|
|
393
|
+
"""
|
|
394
|
+
from astropy.stats import sigma_clipped_stats
|
|
395
|
+
|
|
396
|
+
# Apply mask if provided
|
|
397
|
+
if mask is not None:
|
|
398
|
+
flat_array = flat_image.copy()
|
|
399
|
+
flat_array[mask > 0] = np.nan
|
|
400
|
+
# Use sigma clipping with masked values
|
|
401
|
+
mean_val, _, _ = sigma_clipped_stats(flat_array, mask=np.isnan(flat_array), sigma=3.0, maxiters=5)
|
|
402
|
+
else:
|
|
403
|
+
# Use sigma clipping without mask
|
|
404
|
+
mean_val, _, _ = sigma_clipped_stats(flat_image, sigma=3.0, maxiters=5)
|
|
405
|
+
|
|
406
|
+
normalized = flat_image / mean_val
|
|
407
|
+
|
|
408
|
+
# Clip to avoid extreme values
|
|
409
|
+
normalized = np.clip(normalized, 0.1, 10.0)
|
|
410
|
+
|
|
411
|
+
return normalized
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# 从图像中截取指定矩形区域。
|
|
415
|
+
def extract_subimage(image: np.ndarray, x_start: int, x_end: int,
|
|
416
|
+
y_start: int, y_end: int) -> np.ndarray:
|
|
417
|
+
"""Extract rectangular sub-image."""
|
|
418
|
+
return image[y_start:y_end, x_start:x_end]
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# 按边缘宽度裁剪图像。
|
|
422
|
+
def trim_image(image: np.ndarray, trim_left: int = 0, trim_right: int = 0,
|
|
423
|
+
trim_top: int = 0, trim_bottom: int = 0) -> np.ndarray:
|
|
424
|
+
"""Trim edges of image."""
|
|
425
|
+
rows, cols = image.shape
|
|
426
|
+
return image[trim_top:rows-trim_bottom, trim_left:cols-trim_right]
|