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,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]