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.
Files changed (61) hide show
  1. specproc/__init__.py +9 -0
  2. specproc/calib_data/linelists/FeAr.dat +375 -0
  3. specproc/calib_data/linelists/README +79 -0
  4. specproc/calib_data/linelists/airvac.cl +61 -0
  5. specproc/calib_data/linelists/argon.dat +161 -0
  6. specproc/calib_data/linelists/ctiohear.dat +106 -0
  7. specproc/calib_data/linelists/ctiohenear.dat +146 -0
  8. specproc/calib_data/linelists/cuar.dat +459 -0
  9. specproc/calib_data/linelists/henear.dat +93 -0
  10. specproc/calib_data/linelists/henearhres.dat +300 -0
  11. specproc/calib_data/linelists/idhenear.dat +127 -0
  12. specproc/calib_data/linelists/krypton.dat +109 -0
  13. specproc/calib_data/linelists/lowskylines.dat +577 -0
  14. specproc/calib_data/linelists/ohlines.dat +268 -0
  15. specproc/calib_data/linelists/skylines.dat +1385 -0
  16. specproc/calib_data/linelists/thar-noao.dat +4169 -0
  17. specproc/calib_data/linelists/thar.dat +3057 -0
  18. specproc/calib_data/linelists/thorium.dat +3057 -0
  19. specproc/calib_data/linelists/vacidhenear.dat +127 -0
  20. specproc/calib_data/linelists/vacthar.dat +3057 -0
  21. specproc/calib_data/linelists/vacthorium.dat +3057 -0
  22. specproc/calib_data/linelists/xenon.dat +89 -0
  23. specproc/calib_data/telescopes/lijiang24hrs/lijiang24_hrs_thar_lines.csv +550 -0
  24. 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
  25. specproc/calib_data/telescopes/xinglong216bfosc//346/212/245/345/221/212/345/233/276.docx +0 -0
  26. 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
  27. specproc/calib_data/telescopes/xinglong216hrs/wlcalib_20211123011_A.png +0 -0
  28. specproc/calib_data/telescopes/xinglong216hrs/xinglong216_thar_lines.csv +517 -0
  29. specproc/config/__init__.py +1 -0
  30. specproc/config/config_manager.py +431 -0
  31. specproc/core/__init__.py +1 -0
  32. specproc/core/basic_reduction.py +615 -0
  33. specproc/core/data_structures.py +525 -0
  34. specproc/core/de_blazing.py +210 -0
  35. specproc/core/extraction.py +375 -0
  36. specproc/core/flat_correction.py +1255 -0
  37. specproc/core/line_identification.py +302 -0
  38. specproc/core/order_stitching.py +187 -0
  39. specproc/core/order_tracing.py +1587 -0
  40. specproc/core/processing_pipeline.py +1183 -0
  41. specproc/core/scattered_light.py +654 -0
  42. specproc/core/slit_pipeline.py +762 -0
  43. specproc/core/wave_calibration.py +961 -0
  44. specproc/gui/__init__.py +1 -0
  45. specproc/gui/line_identifier.py +731 -0
  46. specproc/gui/main_window.py +2315 -0
  47. specproc/gui/settings_dialog.py +946 -0
  48. specproc/main.py +388 -0
  49. specproc/plotting/__init__.py +1 -0
  50. specproc/plotting/spectra_plotter.py +483 -0
  51. specproc/utils/__init__.py +1 -0
  52. specproc/utils/airvac.py +123 -0
  53. specproc/utils/fits_io.py +269 -0
  54. specproc/utils/image_processing.py +426 -0
  55. specproc/utils/overscan.py +600 -0
  56. specproc-1.0.0.dist-info/METADATA +668 -0
  57. specproc-1.0.0.dist-info/RECORD +61 -0
  58. specproc-1.0.0.dist-info/WHEEL +5 -0
  59. specproc-1.0.0.dist-info/entry_points.txt +5 -0
  60. specproc-1.0.0.dist-info/licenses/LICENSE +674 -0
  61. 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