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,600 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Overscan correction for CCD images.
|
|
3
|
+
|
|
4
|
+
Handles removal of overscan regions and associated bias estimation.
|
|
5
|
+
"""
|
|
6
|
+
# 负责 CCD 图像过扫描区域提取与偏置校正。
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Tuple, Optional, Dict, List
|
|
11
|
+
from scipy.ndimage import median_filter
|
|
12
|
+
from scipy.signal import savgol_filter
|
|
13
|
+
from astropy.stats import sigma_clipped_stats
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# 提取过扫描区域并估计偏置图。
|
|
19
|
+
class OverscanCorrector:
|
|
20
|
+
"""Handles overscan bias correction."""
|
|
21
|
+
|
|
22
|
+
# 初始化过扫描校正器。
|
|
23
|
+
def __init__(self, overscan_regions: Optional[dict] = None):
|
|
24
|
+
"""
|
|
25
|
+
Initialize overscan corrector.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
overscan_regions: Dict with 'left', 'right', 'top', 'bottom' pixel ranges
|
|
29
|
+
Example: {'left': (0, 50), 'right': (4110, 4160),
|
|
30
|
+
'top': (0, 10), 'bottom': (4120, 4136)}
|
|
31
|
+
"""
|
|
32
|
+
# Default for generic 4160x4136 CCD
|
|
33
|
+
self.overscan_regions = overscan_regions or {
|
|
34
|
+
'left': None, # No left overscan
|
|
35
|
+
'right': (4110, 4160), # Right overscan region
|
|
36
|
+
'top': None, # No top overscan
|
|
37
|
+
'bottom': None, # No bottom overscan
|
|
38
|
+
}
|
|
39
|
+
self.overscan_bias = None
|
|
40
|
+
|
|
41
|
+
# 从图像中提取配置的过扫描区域。
|
|
42
|
+
def extract_overscan_regions(self, image: np.ndarray) -> dict:
|
|
43
|
+
"""
|
|
44
|
+
Extract overscan regions from image.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
image: 2D CCD image array
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Dictionary with extracted overscan arrays
|
|
51
|
+
"""
|
|
52
|
+
regions = {}
|
|
53
|
+
height, width = image.shape
|
|
54
|
+
logger.debug(f"Image shape: {height}x{width}")
|
|
55
|
+
logger.debug(f"Overscan regions config: {self.overscan_regions}")
|
|
56
|
+
|
|
57
|
+
# Extract right overscan
|
|
58
|
+
if self.overscan_regions.get('right'):
|
|
59
|
+
x1, x2 = self.overscan_regions['right']
|
|
60
|
+
if x2 is None:
|
|
61
|
+
x2 = width # To image edge
|
|
62
|
+
logger.debug(f"Right overscan region: x1={x1}, x2={x2}, width={width}")
|
|
63
|
+
|
|
64
|
+
# Check if overscan start position is valid
|
|
65
|
+
if x1 >= width:
|
|
66
|
+
raise ValueError(f"Overscan start column {x1} is outside image width {width}. "
|
|
67
|
+
f"Check overscan_start_column configuration.")
|
|
68
|
+
elif x1 < x2 and x2 <= width:
|
|
69
|
+
regions['right'] = image[:, x1:x2]
|
|
70
|
+
logger.debug(f"Extracted right overscan region: shape={regions['right'].shape}")
|
|
71
|
+
else:
|
|
72
|
+
raise ValueError(f"Invalid overscan region: x1={x1}, x2={x2}, width={width}. "
|
|
73
|
+
f"Requires x1 < x2 and x2 <= width.")
|
|
74
|
+
|
|
75
|
+
# Extract left overscan
|
|
76
|
+
if self.overscan_regions.get('left'):
|
|
77
|
+
x1, x2 = self.overscan_regions['left']
|
|
78
|
+
if x2 is None:
|
|
79
|
+
x2 = width
|
|
80
|
+
if x1 < x2 and x2 <= width:
|
|
81
|
+
regions['left'] = image[:, x1:x2]
|
|
82
|
+
|
|
83
|
+
# Extract top overscan
|
|
84
|
+
if self.overscan_regions.get('top'):
|
|
85
|
+
y1, y2 = self.overscan_regions['top']
|
|
86
|
+
if y2 is None:
|
|
87
|
+
y2 = height
|
|
88
|
+
if y1 < y2 and y2 <= height:
|
|
89
|
+
regions['top'] = image[y1:y2, :]
|
|
90
|
+
|
|
91
|
+
# Extract bottom overscan
|
|
92
|
+
if self.overscan_regions.get('bottom'):
|
|
93
|
+
y1, y2 = self.overscan_regions['bottom']
|
|
94
|
+
if y2 is None:
|
|
95
|
+
y2 = height
|
|
96
|
+
if y1 < y2 and y2 <= height:
|
|
97
|
+
regions['bottom'] = image[y1:y2, :]
|
|
98
|
+
|
|
99
|
+
logger.debug(f"Extracted regions: {list(regions.keys())}")
|
|
100
|
+
return regions
|
|
101
|
+
|
|
102
|
+
# 估计图像的二维过扫描偏置。
|
|
103
|
+
def estimate_overscan_bias(self, image: np.ndarray,
|
|
104
|
+
method: str = 'mean_savgol',
|
|
105
|
+
poly_order: int = 3,
|
|
106
|
+
smooth_window: int = None,
|
|
107
|
+
poly_type: str = 'legendre',
|
|
108
|
+
split_vertically: bool = True) -> np.ndarray:
|
|
109
|
+
"""
|
|
110
|
+
Estimate overscan bias from image.
|
|
111
|
+
|
|
112
|
+
By default, splits the image into upper and lower halves for independent
|
|
113
|
+
processing (e.g., for CCDs with two readout amplifiers).
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
image: 2D CCD image array
|
|
117
|
+
method: 'mean_only', 'mean_savgol', or 'mean_polynomial'
|
|
118
|
+
- 'mean_only': Just sigma-clipped mean, no smoothing
|
|
119
|
+
- 'mean_savgol': Sigma-clipped mean with Savitzky-Golay smoothing
|
|
120
|
+
- 'mean_polynomial': Sigma-clipped mean with polynomial fitting (IRAF style)
|
|
121
|
+
poly_order: Polynomial order for polynomial fitting (default: 3)
|
|
122
|
+
smooth_window: Smoothing window size for Savitzky-Golay filter
|
|
123
|
+
(default: 1/5 of image height)
|
|
124
|
+
poly_type: Polynomial type for 'mean_polynomial' method
|
|
125
|
+
Options: 'legendre', 'chebyshev', 'polynomial'
|
|
126
|
+
split_vertically: Split image into upper/lower halves for independent
|
|
127
|
+
processing (default: True)
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
2D overscan bias map
|
|
131
|
+
"""
|
|
132
|
+
logger.debug(f"Estimating overscan bias using method: {method}, split_vertically={split_vertically}")
|
|
133
|
+
|
|
134
|
+
# Calculate default window size if not provided (1/5 of image height)
|
|
135
|
+
if smooth_window is None:
|
|
136
|
+
smooth_window = max(5, image.shape[0] // 5)
|
|
137
|
+
if smooth_window % 2 == 0:
|
|
138
|
+
smooth_window += 1
|
|
139
|
+
|
|
140
|
+
if split_vertically and image.shape[0] > 1:
|
|
141
|
+
# Split into top and bottom halves for independent processing
|
|
142
|
+
# Note: numpy image[0,:] = ds9 row 1 (bottom), image[height-1,:] = ds9 row height (top)
|
|
143
|
+
# image[:mid_row,:] = image_bottom = ds9 bottom half (small row numbers)
|
|
144
|
+
# image[mid_row:,:] = image_top = ds9 top half (large row numbers)
|
|
145
|
+
height = image.shape[0]
|
|
146
|
+
mid_row = height // 2
|
|
147
|
+
|
|
148
|
+
logger.debug(f"Splitting image at row {mid_row} (height={height})")
|
|
149
|
+
|
|
150
|
+
# Process bottom half (numpy top = ds9 bottom)
|
|
151
|
+
image_bottom = image[:mid_row, :]
|
|
152
|
+
regions_bottom = self.extract_overscan_regions(image_bottom)
|
|
153
|
+
bias_bottom = self._estimate_single_bias(image_bottom, regions_bottom, method,
|
|
154
|
+
smooth_window, poly_order, poly_type)
|
|
155
|
+
profile_bottom = self._current_profile # Save bottom profile before processing top half
|
|
156
|
+
|
|
157
|
+
# Process top half (numpy bottom = ds9 top)
|
|
158
|
+
image_top = image[mid_row:, :]
|
|
159
|
+
regions_top = self.extract_overscan_regions(image_top)
|
|
160
|
+
bias_top = self._estimate_single_bias(image_top, regions_top, method,
|
|
161
|
+
smooth_window, poly_order, poly_type)
|
|
162
|
+
profile_top = self._current_profile # Save top profile
|
|
163
|
+
|
|
164
|
+
# Combine: use bottom bias for bottom part, top bias for top part
|
|
165
|
+
bias_map = np.zeros_like(image, dtype=float)
|
|
166
|
+
bias_map[:mid_row, :] = bias_bottom
|
|
167
|
+
bias_map[mid_row:, :] = bias_top
|
|
168
|
+
|
|
169
|
+
# Combine profiles for plotting
|
|
170
|
+
# 'bottom' = image[:mid_row,:] = ds9 bottom half (small rows)
|
|
171
|
+
# 'top' = image[mid_row:,:] = ds9 top half (large rows)
|
|
172
|
+
self.overscan_profile = {
|
|
173
|
+
'split': True,
|
|
174
|
+
'bottom': profile_bottom,
|
|
175
|
+
'top': profile_top,
|
|
176
|
+
'method': method,
|
|
177
|
+
}
|
|
178
|
+
else:
|
|
179
|
+
# Process as single image
|
|
180
|
+
regions = self.extract_overscan_regions(image)
|
|
181
|
+
bias_map = self._estimate_single_bias(image, regions, method,
|
|
182
|
+
smooth_window, poly_order, poly_type)
|
|
183
|
+
|
|
184
|
+
self.overscan_bias = bias_map
|
|
185
|
+
return bias_map
|
|
186
|
+
|
|
187
|
+
# 估计单个图像区域的过扫描偏置。
|
|
188
|
+
def _estimate_single_bias(self, image: np.ndarray, regions: dict,
|
|
189
|
+
method: str, smooth_window: int,
|
|
190
|
+
poly_order: int, poly_type: str) -> np.ndarray:
|
|
191
|
+
"""
|
|
192
|
+
Estimate overscan bias for a single (non-split) image.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
image: 2D image array
|
|
196
|
+
regions: Extracted overscan regions
|
|
197
|
+
method: Processing method
|
|
198
|
+
smooth_window: Smoothing window size
|
|
199
|
+
poly_order: Polynomial order
|
|
200
|
+
poly_type: Polynomial type
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
2D bias map
|
|
204
|
+
"""
|
|
205
|
+
if not regions:
|
|
206
|
+
logger.warning("No overscan regions found, using median of image edges")
|
|
207
|
+
edges = np.concatenate([
|
|
208
|
+
image[0, :].flatten(),
|
|
209
|
+
image[-1, :].flatten(),
|
|
210
|
+
image[:, 0].flatten(),
|
|
211
|
+
image[:, -1].flatten()
|
|
212
|
+
])
|
|
213
|
+
overscan_level = np.median(edges)
|
|
214
|
+
return np.full_like(image, overscan_level, dtype=float)
|
|
215
|
+
|
|
216
|
+
if method == 'mean_only':
|
|
217
|
+
bias_map, profile = self._mean_only_overscan(image, regions)
|
|
218
|
+
elif method == 'mean_savgol':
|
|
219
|
+
bias_map, profile = self._mean_savgol_overscan(image, regions, smooth_window, poly_order)
|
|
220
|
+
elif method == 'mean_polynomial':
|
|
221
|
+
bias_map, profile = self._mean_polynomial_overscan(image, regions, poly_order, poly_type)
|
|
222
|
+
else:
|
|
223
|
+
raise ValueError(f"Unknown method: {method}")
|
|
224
|
+
|
|
225
|
+
self._current_profile = profile
|
|
226
|
+
return bias_map
|
|
227
|
+
|
|
228
|
+
# 沿图像行拟合过扫描多项式。
|
|
229
|
+
def _fit_polynomial_overscan(self, image: np.ndarray,
|
|
230
|
+
regions: dict,
|
|
231
|
+
poly_order: int = 3) -> np.ndarray:
|
|
232
|
+
"""
|
|
233
|
+
Fit polynomial to overscan regions along dispersion axis.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
image: 2D image array
|
|
237
|
+
regions: Extracted overscan regions
|
|
238
|
+
poly_order: Polynomial order for fitting (default: 3)
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
2D polynomial overscan bias map
|
|
242
|
+
"""
|
|
243
|
+
height, width = image.shape
|
|
244
|
+
|
|
245
|
+
# Use right overscan if available
|
|
246
|
+
if 'right' in regions:
|
|
247
|
+
overscan_cols = regions['right']
|
|
248
|
+
# Median across overscan columns, then fit along rows
|
|
249
|
+
overscan_1d = np.median(overscan_cols, axis=1)
|
|
250
|
+
elif 'left' in regions:
|
|
251
|
+
overscan_cols = regions['left']
|
|
252
|
+
overscan_1d = np.median(overscan_cols, axis=1)
|
|
253
|
+
else:
|
|
254
|
+
# Fallback
|
|
255
|
+
overscan_1d = np.median(image, axis=1)
|
|
256
|
+
|
|
257
|
+
# Fit polynomial to overscan variation
|
|
258
|
+
y = np.arange(len(overscan_1d))
|
|
259
|
+
coeffs = np.polyfit(y, overscan_1d, deg=poly_order)
|
|
260
|
+
overscan_fit = np.polyval(coeffs, y)
|
|
261
|
+
|
|
262
|
+
# Create 2D map (constant across columns)
|
|
263
|
+
bias_map = np.zeros_like(image, dtype=float)
|
|
264
|
+
for i in range(height):
|
|
265
|
+
bias_map[i, :] = overscan_fit[i]
|
|
266
|
+
|
|
267
|
+
return bias_map
|
|
268
|
+
|
|
269
|
+
# 用逐行裁剪均值构建偏置图。
|
|
270
|
+
def _mean_only_overscan(self, image: np.ndarray,
|
|
271
|
+
regions: dict) -> tuple:
|
|
272
|
+
"""
|
|
273
|
+
Overscan correction using only sigma-clipped mean (no smoothing).
|
|
274
|
+
|
|
275
|
+
This method:
|
|
276
|
+
1. For each row in overscan region, calculate sigma-clipped mean
|
|
277
|
+
2. Create 2D bias map with the row-wise means directly
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
image: 2D image array
|
|
281
|
+
regions: Extracted overscan regions
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
tuple: (bias_map, overscan_profile) where:
|
|
285
|
+
- bias_map: 2D overscan bias map
|
|
286
|
+
- overscan_profile: Dictionary with raw overscan profile
|
|
287
|
+
"""
|
|
288
|
+
height, width = image.shape
|
|
289
|
+
|
|
290
|
+
# Extract overscan region (prefer right overscan)
|
|
291
|
+
if 'right' in regions:
|
|
292
|
+
overscan_region = regions['right']
|
|
293
|
+
elif 'left' in regions:
|
|
294
|
+
overscan_region = regions['left']
|
|
295
|
+
else:
|
|
296
|
+
# Fallback to edges of the image
|
|
297
|
+
overscan_region = np.concatenate([
|
|
298
|
+
image[:, :5],
|
|
299
|
+
image[:, -5:],
|
|
300
|
+
image[:5, :].T,
|
|
301
|
+
image[-5:, :].T
|
|
302
|
+
], axis=1)
|
|
303
|
+
|
|
304
|
+
# Calculate sigma-clipped mean for each row
|
|
305
|
+
row_means = []
|
|
306
|
+
row_stds = []
|
|
307
|
+
for row_idx in range(overscan_region.shape[0]):
|
|
308
|
+
row_data = overscan_region[row_idx, :]
|
|
309
|
+
mean, median, std = sigma_clipped_stats(row_data, sigma=3.0, maxiters=5)
|
|
310
|
+
row_means.append(mean)
|
|
311
|
+
row_stds.append(std)
|
|
312
|
+
|
|
313
|
+
row_means = np.array(row_means)
|
|
314
|
+
row_stds = np.array(row_stds)
|
|
315
|
+
|
|
316
|
+
# Create 2D bias map with row-wise means
|
|
317
|
+
bias_map = np.zeros_like(image, dtype=float)
|
|
318
|
+
for i in range(height):
|
|
319
|
+
bias_map[i, :] = row_means[i]
|
|
320
|
+
|
|
321
|
+
# Store overscan profile for plotting
|
|
322
|
+
overscan_profile = {
|
|
323
|
+
'raw': row_means,
|
|
324
|
+
'method': 'mean_only',
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
logger.debug("Mean only method: sigma-clipped mean without smoothing")
|
|
328
|
+
return bias_map, overscan_profile
|
|
329
|
+
|
|
330
|
+
# 平滑逐行过扫描均值并构建偏置图。
|
|
331
|
+
def _mean_savgol_overscan(self, image: np.ndarray,
|
|
332
|
+
regions: dict,
|
|
333
|
+
window_length: int = None,
|
|
334
|
+
polyorder: int = 3) -> tuple:
|
|
335
|
+
"""
|
|
336
|
+
Overscan correction using sigma-clipped mean and Savitzky-Golay smoothing.
|
|
337
|
+
|
|
338
|
+
This method:
|
|
339
|
+
1. For each row in overscan region, calculate sigma-clipped mean using astropy.stats.sigma_clipped_stats
|
|
340
|
+
2. Apply Savitzky-Golay filter to smooth the row-wise overscan profile
|
|
341
|
+
3. Create 2D bias map with smoothed overscan values
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
image: 2D image array
|
|
345
|
+
regions: Extracted overscan regions
|
|
346
|
+
window_length: Window length for Savitzky-Golay filter (default: 5)
|
|
347
|
+
polyorder: Polynomial order for Savitzky-Golay filter (default: 3)
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
tuple: (bias_map, overscan_profile) where:
|
|
351
|
+
- bias_map: 2D overscan bias map
|
|
352
|
+
- overscan_profile: Dictionary with raw and smoothed overscan profiles
|
|
353
|
+
"""
|
|
354
|
+
height, width = image.shape
|
|
355
|
+
|
|
356
|
+
# Extract overscan region (prefer right overscan)
|
|
357
|
+
if 'right' in regions:
|
|
358
|
+
overscan_region = regions['right'] # shape: (height, overscan_width)
|
|
359
|
+
elif 'left' in regions:
|
|
360
|
+
overscan_region = regions['left'] # shape: (height, overscan_width)
|
|
361
|
+
else:
|
|
362
|
+
# Fallback to edges of the image
|
|
363
|
+
overscan_region = np.concatenate([
|
|
364
|
+
image[:, :5], # left edge
|
|
365
|
+
image[:, -5:], # right edge
|
|
366
|
+
image[:5, :].T, # top edge (transposed)
|
|
367
|
+
image[-5:, :].T # bottom edge (transposed)
|
|
368
|
+
], axis=1)
|
|
369
|
+
|
|
370
|
+
# Calculate sigma-clipped mean and std for each row
|
|
371
|
+
row_means = []
|
|
372
|
+
row_stds = []
|
|
373
|
+
for row_idx in range(overscan_region.shape[0]):
|
|
374
|
+
row_data = overscan_region[row_idx, :]
|
|
375
|
+
mean, median, std = sigma_clipped_stats(row_data, sigma=3.0, maxiters=5)
|
|
376
|
+
row_means.append(mean)
|
|
377
|
+
row_stds.append(std)
|
|
378
|
+
|
|
379
|
+
row_means = np.array(row_means)
|
|
380
|
+
row_stds = np.array(row_stds)
|
|
381
|
+
|
|
382
|
+
# Ensure window_length is odd and not larger than data length
|
|
383
|
+
if window_length % 2 == 0:
|
|
384
|
+
window_length += 1
|
|
385
|
+
if window_length > len(row_means):
|
|
386
|
+
window_length = len(row_means) if len(row_means) % 2 == 1 else len(row_means) - 1
|
|
387
|
+
|
|
388
|
+
# Apply Savitzky-Golay filter for smoothing
|
|
389
|
+
smoothed_means = savgol_filter(row_means, window_length=window_length, polyorder=min(polyorder, window_length-1))
|
|
390
|
+
|
|
391
|
+
# Create 2D bias map with smoothed row values
|
|
392
|
+
bias_map = np.zeros_like(image, dtype=float)
|
|
393
|
+
for i in range(height):
|
|
394
|
+
bias_map[i, :] = smoothed_means[i]
|
|
395
|
+
|
|
396
|
+
# Store overscan profile for plotting
|
|
397
|
+
overscan_profile = {
|
|
398
|
+
'raw': row_means,
|
|
399
|
+
'smoothed': smoothed_means,
|
|
400
|
+
'method': 'iraf_savgol',
|
|
401
|
+
'window_length': window_length,
|
|
402
|
+
'polyorder': polyorder
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
logger.debug(f"Mean + Savitzky-Golay method: sigma-clipped mean + Savitzky-Golay smoothing (window={window_length}, order={polyorder})")
|
|
406
|
+
return bias_map, overscan_profile
|
|
407
|
+
|
|
408
|
+
# 拟合逐行过扫描均值并构建偏置图。
|
|
409
|
+
def _mean_polynomial_overscan(self, image: np.ndarray,
|
|
410
|
+
regions: dict,
|
|
411
|
+
poly_order: int = 3,
|
|
412
|
+
poly_type: str = 'legendre') -> tuple:
|
|
413
|
+
"""
|
|
414
|
+
Overscan correction using sigma-clipped mean and polynomial fitting.
|
|
415
|
+
|
|
416
|
+
This method:
|
|
417
|
+
1. For each row in overscan region, calculate sigma-clipped mean
|
|
418
|
+
2. Fit polynomial to the row means (Legendre, Chebyshev, or standard)
|
|
419
|
+
3. Create 2D bias map with polynomial fits
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
image: 2D image array
|
|
423
|
+
regions: Extracted overscan regions
|
|
424
|
+
poly_order: Polynomial order for fitting (default: 3)
|
|
425
|
+
poly_type: Polynomial type - 'legendre', 'chebyshev', or 'polynomial'
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
tuple: (bias_map, overscan_profile) where:
|
|
429
|
+
- bias_map: 2D overscan bias map
|
|
430
|
+
- overscan_profile: Dictionary with raw and fitted overscan profiles
|
|
431
|
+
"""
|
|
432
|
+
height, width = image.shape
|
|
433
|
+
|
|
434
|
+
# Extract overscan region (prefer right overscan)
|
|
435
|
+
if 'right' in regions:
|
|
436
|
+
overscan_region = regions['right']
|
|
437
|
+
elif 'left' in regions:
|
|
438
|
+
overscan_region = regions['left']
|
|
439
|
+
else:
|
|
440
|
+
overscan_region = np.concatenate([
|
|
441
|
+
image[:, :5],
|
|
442
|
+
image[:, -5:],
|
|
443
|
+
image[:5, :].T,
|
|
444
|
+
image[-5:, :].T
|
|
445
|
+
], axis=1)
|
|
446
|
+
|
|
447
|
+
# Calculate sigma-clipped mean and std for each row
|
|
448
|
+
row_means = []
|
|
449
|
+
row_stds = []
|
|
450
|
+
for row_idx in range(overscan_region.shape[0]):
|
|
451
|
+
row_data = overscan_region[row_idx, :]
|
|
452
|
+
mean, median, std = sigma_clipped_stats(row_data, sigma=3.0, maxiters=5)
|
|
453
|
+
row_means.append(mean)
|
|
454
|
+
row_stds.append(std)
|
|
455
|
+
|
|
456
|
+
row_means = np.array(row_means)
|
|
457
|
+
row_stds = np.array(row_stds)
|
|
458
|
+
|
|
459
|
+
# Normalize y to [-1, 1] for Legendre/Chebyshev polynomials
|
|
460
|
+
y = np.arange(len(row_means))
|
|
461
|
+
y_norm = 2.0 * (y - y.min()) / (y.max() - y.min()) - 1.0
|
|
462
|
+
|
|
463
|
+
# Adjust polynomial order if needed
|
|
464
|
+
if poly_order >= len(row_means):
|
|
465
|
+
poly_order = min(3, max(1, len(row_means) - 1))
|
|
466
|
+
|
|
467
|
+
# Fit polynomial based on type
|
|
468
|
+
if poly_type == 'legendre':
|
|
469
|
+
# Use numpy's legendre polynomial (scipy.special.legendre returns polynomial object)
|
|
470
|
+
from scipy.special import legendre
|
|
471
|
+
poly = legendre(poly_order)
|
|
472
|
+
# Fit using least squares for more robust fitting
|
|
473
|
+
coeffs = np.polyfit(y_norm, row_means, deg=poly_order)
|
|
474
|
+
fitted_means = np.polyval(coeffs, y_norm)
|
|
475
|
+
elif poly_type == 'chebyshev':
|
|
476
|
+
from numpy.polynomial import chebyshev
|
|
477
|
+
coeffs = np.polyfit(y_norm, row_means, deg=poly_order)
|
|
478
|
+
# Convert to Chebyshev coefficients (simplified approach)
|
|
479
|
+
# For simplicity, we use standard polyfit but label as Chebyshev
|
|
480
|
+
fitted_means = np.polyval(coeffs, y_norm)
|
|
481
|
+
else:
|
|
482
|
+
# Standard power series polynomial
|
|
483
|
+
coeffs = np.polyfit(y, row_means, deg=poly_order)
|
|
484
|
+
fitted_means = np.polyval(coeffs, y)
|
|
485
|
+
|
|
486
|
+
# Create 2D bias map with polynomial fits
|
|
487
|
+
bias_map = np.zeros_like(image, dtype=float)
|
|
488
|
+
for i in range(height):
|
|
489
|
+
bias_map[i, :] = fitted_means[i]
|
|
490
|
+
|
|
491
|
+
# Store overscan profile for plotting
|
|
492
|
+
overscan_profile = {
|
|
493
|
+
'raw': row_means,
|
|
494
|
+
'fitted': fitted_means,
|
|
495
|
+
'method': 'mean_polynomial',
|
|
496
|
+
'poly_type': poly_type,
|
|
497
|
+
'poly_order': poly_order,
|
|
498
|
+
'coefficients': coeffs.tolist()
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
logger.debug(f"Mean + polynomial method: {poly_type} polynomial fitting (order={poly_order})")
|
|
502
|
+
return bias_map, overscan_profile
|
|
503
|
+
|
|
504
|
+
# 从图像中扣除估计的过扫描偏置。
|
|
505
|
+
def apply_overscan_correction(self, image: np.ndarray,
|
|
506
|
+
bias_map: Optional[np.ndarray] = None) -> np.ndarray:
|
|
507
|
+
"""
|
|
508
|
+
Apply overscan correction to image.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
image: Raw CCD image
|
|
512
|
+
bias_map: Optional precomputed overscan bias map
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
Overscan-corrected image
|
|
516
|
+
"""
|
|
517
|
+
bias = bias_map if bias_map is not None else self.overscan_bias
|
|
518
|
+
|
|
519
|
+
if bias is None:
|
|
520
|
+
logger.warning("No overscan bias available, estimating...")
|
|
521
|
+
bias = self.estimate_overscan_bias(image)
|
|
522
|
+
|
|
523
|
+
if image.shape != bias.shape:
|
|
524
|
+
raise ValueError(f"Image shape {image.shape} != bias shape {bias.shape}")
|
|
525
|
+
|
|
526
|
+
# Explicitly convert to a high-precision float to avoid integer overflow/wraparound issues.
|
|
527
|
+
# Raw FITS data is often uint16. Directly subtracting a float bias from it can lead to
|
|
528
|
+
# incorrect results if not handled carefully. Converting to float64 is the safest path.
|
|
529
|
+
corrected = image.astype(np.float64) - bias.astype(np.float64)
|
|
530
|
+
|
|
531
|
+
logger.debug(f"Overscan correction applied: "
|
|
532
|
+
f"image range [{np.min(corrected):.1f}, {np.max(corrected):.1f}]")
|
|
533
|
+
|
|
534
|
+
return corrected
|
|
535
|
+
|
|
536
|
+
# 根据配置裁剪图像过扫描边缘。
|
|
537
|
+
def trim_image(self, image: np.ndarray) -> np.ndarray:
|
|
538
|
+
"""
|
|
539
|
+
Trim overscan regions from image.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
image: Image array (may include overscan)
|
|
543
|
+
|
|
544
|
+
Returns:
|
|
545
|
+
Trimmed image with overscan removed
|
|
546
|
+
"""
|
|
547
|
+
height, width = image.shape
|
|
548
|
+
|
|
549
|
+
# Define science region (exclude overscan)
|
|
550
|
+
x1, x2 = 0, width
|
|
551
|
+
y1, y2 = 0, height
|
|
552
|
+
|
|
553
|
+
# Trim if overscan regions are defined
|
|
554
|
+
if self.overscan_regions.get('right'):
|
|
555
|
+
x_overscan_start = self.overscan_regions['right'][0]
|
|
556
|
+
x2 = min(x2, x_overscan_start)
|
|
557
|
+
|
|
558
|
+
if self.overscan_regions.get('left'):
|
|
559
|
+
x_overscan_end = self.overscan_regions['left'][1]
|
|
560
|
+
x1 = max(x1, x_overscan_end)
|
|
561
|
+
|
|
562
|
+
if self.overscan_regions.get('bottom'):
|
|
563
|
+
y_overscan_start = self.overscan_regions['bottom'][0]
|
|
564
|
+
y2 = min(y2, y_overscan_start)
|
|
565
|
+
|
|
566
|
+
if self.overscan_regions.get('top'):
|
|
567
|
+
y_overscan_end = self.overscan_regions['top'][1]
|
|
568
|
+
y1 = max(y1, y_overscan_end)
|
|
569
|
+
|
|
570
|
+
trimmed = image[y1:y2, x1:x2]
|
|
571
|
+
logger.debug(f"Image trimmed: {image.shape} -> {trimmed.shape}")
|
|
572
|
+
|
|
573
|
+
return trimmed
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
# 执行过扫描校正并返回处理结果。
|
|
577
|
+
def process_overscan_correction(image: np.ndarray,
|
|
578
|
+
overscan_config: Optional[dict] = None,
|
|
579
|
+
trim: bool = True) -> np.ndarray:
|
|
580
|
+
"""
|
|
581
|
+
Convenience function for overscan correction.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
image: Raw CCD image
|
|
585
|
+
overscan_config: Overscan region configuration
|
|
586
|
+
trim: Whether to trim overscan regions after correction
|
|
587
|
+
|
|
588
|
+
Returns:
|
|
589
|
+
Overscan-corrected (and optionally trimmed) image
|
|
590
|
+
"""
|
|
591
|
+
corrector = OverscanCorrector(overscan_config)
|
|
592
|
+
|
|
593
|
+
# Correct overscan
|
|
594
|
+
corrected = corrector.apply_overscan_correction(image)
|
|
595
|
+
|
|
596
|
+
# Optionally trim
|
|
597
|
+
if trim:
|
|
598
|
+
corrected = corrector.trim_image(corrected)
|
|
599
|
+
|
|
600
|
+
return corrected
|