stdpipe 0.4.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.
- stdpipe/__init__.py +9 -0
- stdpipe/artefacts.py +370 -0
- stdpipe/astrometry.py +2001 -0
- stdpipe/astrometry_quad.py +1643 -0
- stdpipe/astrometry_wcs.py +1141 -0
- stdpipe/catalogs.py +942 -0
- stdpipe/cutouts.py +570 -0
- stdpipe/data/legacysurvey_bricks.fits.gz +0 -0
- stdpipe/data/ps1skycells.txt +199708 -0
- stdpipe/db.py +130 -0
- stdpipe/fringe_removal.py +1086 -0
- stdpipe/lcs.py +440 -0
- stdpipe/photometry.py +2340 -0
- stdpipe/photometry_background.py +998 -0
- stdpipe/photometry_iraf.py +913 -0
- stdpipe/photometry_measure.py +2268 -0
- stdpipe/photometry_model.py +931 -0
- stdpipe/photometry_psf.py +1124 -0
- stdpipe/photometry_quality.py +185 -0
- stdpipe/pipeline.py +1303 -0
- stdpipe/plots.py +1582 -0
- stdpipe/psf.py +1542 -0
- stdpipe/realbogus.py +971 -0
- stdpipe/realbogus_features.py +1657 -0
- stdpipe/reproject.py +943 -0
- stdpipe/resolve.py +267 -0
- stdpipe/sfft.py +1077 -0
- stdpipe/simulation.py +2131 -0
- stdpipe/smoothing.py +488 -0
- stdpipe/subtraction.py +1324 -0
- stdpipe/templates.py +929 -0
- stdpipe/utils.py +365 -0
- stdpipe-0.4.0.dist-info/METADATA +171 -0
- stdpipe-0.4.0.dist-info/RECORD +37 -0
- stdpipe-0.4.0.dist-info/WHEEL +5 -0
- stdpipe-0.4.0.dist-info/licenses/LICENSE.md +21 -0
- stdpipe-0.4.0.dist-info/top_level.txt +1 -0
stdpipe/simulation.py
ADDED
|
@@ -0,0 +1,2131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Image simulation module for generating realistic stellar fields with contaminants.
|
|
3
|
+
|
|
4
|
+
This module provides functions for creating simulated astronomical images with:
|
|
5
|
+
- Stars with Gaussian and Moffat PSF (pixel-integrated)
|
|
6
|
+
- Extended sources (galaxies with Sersic profiles)
|
|
7
|
+
- Imaging artifacts (cosmic rays, hot pixels, bad columns, satellite trails)
|
|
8
|
+
- Diffraction spikes and optical ghosts for bright sources
|
|
9
|
+
|
|
10
|
+
Designed for real-bogus classifier development and photometry testing.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import absolute_import, division, print_function, unicode_literals
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from astropy.table import Table
|
|
17
|
+
from scipy.ndimage import rotate, gaussian_filter
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_psf_model(
|
|
21
|
+
fwhm=3.0,
|
|
22
|
+
psf_type='gaussian',
|
|
23
|
+
beta=2.5,
|
|
24
|
+
size=None,
|
|
25
|
+
oversampling=2,
|
|
26
|
+
defocus=0.0,
|
|
27
|
+
astigmatism_x=0.0,
|
|
28
|
+
astigmatism_y=0.0,
|
|
29
|
+
coma_x=0.0,
|
|
30
|
+
coma_y=0.0,
|
|
31
|
+
wavelength=550e-9,
|
|
32
|
+
pupil_diameter=1.0,
|
|
33
|
+
):
|
|
34
|
+
"""
|
|
35
|
+
Create an oversampled PSF model compatible with PSFEx format.
|
|
36
|
+
|
|
37
|
+
This creates a PSF model that can be used with psf.place_psf_stamp() and other
|
|
38
|
+
STDPipe routines. Uses oversampling for accurate flux conservation and fast
|
|
39
|
+
evaluation with sub-pixel positioning.
|
|
40
|
+
|
|
41
|
+
Optical aberrations can be added via Zernike polynomial coefficients (Noll ordering).
|
|
42
|
+
When any aberration coefficient is non-zero, a diffraction PSF is computed via
|
|
43
|
+
Fourier optics and convolved with the atmospheric seeing PSF (Gaussian or Moffat).
|
|
44
|
+
When all coefficients are zero, the existing analytical path is used unchanged.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
fwhm : float, optional
|
|
49
|
+
Full width at half maximum in pixels.
|
|
50
|
+
psf_type : str, optional
|
|
51
|
+
Type of PSF model ('gaussian' or 'moffat').
|
|
52
|
+
beta : float, optional
|
|
53
|
+
Moffat beta parameter (default 2.5, only used for psf_type='moffat').
|
|
54
|
+
size : int, optional
|
|
55
|
+
Output stamp size in pixels (after downsampling). If None, automatically
|
|
56
|
+
sized to capture ~99% of PSF flux (>=8*FWHM).
|
|
57
|
+
oversampling : int, optional
|
|
58
|
+
Oversampling factor (default 2).
|
|
59
|
+
defocus : float, optional
|
|
60
|
+
Zernike Z4 defocus coefficient in waves (default 0.0).
|
|
61
|
+
astigmatism_x : float, optional
|
|
62
|
+
Zernike Z5 oblique astigmatism coefficient in waves (default 0.0).
|
|
63
|
+
astigmatism_y : float, optional
|
|
64
|
+
Zernike Z6 vertical astigmatism coefficient in waves (default 0.0).
|
|
65
|
+
coma_x : float, optional
|
|
66
|
+
Zernike Z7 vertical coma coefficient in waves (default 0.0).
|
|
67
|
+
coma_y : float, optional
|
|
68
|
+
Zernike Z8 horizontal coma coefficient in waves (default 0.0).
|
|
69
|
+
wavelength : float, optional
|
|
70
|
+
Observation wavelength in meters (default 550e-9, affects diffraction scale).
|
|
71
|
+
pupil_diameter : float, optional
|
|
72
|
+
Pupil diameter in meters (default 1.0, affects diffraction scale).
|
|
73
|
+
|
|
74
|
+
Returns
|
|
75
|
+
-------
|
|
76
|
+
dict
|
|
77
|
+
Dictionary with PSFEx-compatible structure containing the PSF model.
|
|
78
|
+
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
# Auto-size to capture full PSF if not specified
|
|
82
|
+
# For Gaussian/Moffat PSF, significant flux extends to ~4 FWHM from center
|
|
83
|
+
# Use 8*FWHM total size to capture ~99% of flux
|
|
84
|
+
if size is None:
|
|
85
|
+
size = int(np.ceil(fwhm * 8))
|
|
86
|
+
if size % 2 == 0:
|
|
87
|
+
size += 1
|
|
88
|
+
|
|
89
|
+
# Oversampled data grid following PSFEx convention:
|
|
90
|
+
# - data array has size * oversampling pixels per side
|
|
91
|
+
# - sampling = 1/oversampling (image pixels per data pixel; <1 means oversampled)
|
|
92
|
+
# - get_psf_stamp() output size: floor(width * sampling / 2) * 2 + 1 = size (constant)
|
|
93
|
+
psf_data_size = size * oversampling
|
|
94
|
+
psf_sampling = 1.0 / oversampling # PSFEx convention: image pixels per data pixel
|
|
95
|
+
psf_width = float(psf_data_size) # Data array dimension
|
|
96
|
+
psf_height = float(psf_data_size)
|
|
97
|
+
|
|
98
|
+
# Center of the oversampled grid
|
|
99
|
+
center = (psf_data_size - 1) / 2.0
|
|
100
|
+
|
|
101
|
+
# Create coordinate grid in data-pixel space
|
|
102
|
+
y, x = np.mgrid[0:psf_data_size, 0:psf_data_size]
|
|
103
|
+
|
|
104
|
+
if psf_type.lower() == 'gaussian':
|
|
105
|
+
# Pixel-integrated Gaussian PSF using error function
|
|
106
|
+
# This properly accounts for flux integrated over each data pixel area
|
|
107
|
+
# Data pixel [i-0.5, i+0.5] covers image space [(i-0.5)*sampling, (i+0.5)*sampling]
|
|
108
|
+
from scipy.special import erf
|
|
109
|
+
|
|
110
|
+
sigma = fwhm / (2 * np.sqrt(2 * np.log(2))) # in image pixels
|
|
111
|
+
sqrt2_sigma = np.sqrt(2) * sigma
|
|
112
|
+
|
|
113
|
+
# X-direction: integrate Gaussian over each data pixel, converting to image space
|
|
114
|
+
erf_xp = erf((x + 0.5 - center) * psf_sampling / sqrt2_sigma)
|
|
115
|
+
erf_xm = erf((x - 0.5 - center) * psf_sampling / sqrt2_sigma)
|
|
116
|
+
integral_x = 0.5 * (erf_xp - erf_xm)
|
|
117
|
+
|
|
118
|
+
# Y-direction integration
|
|
119
|
+
erf_yp = erf((y + 0.5 - center) * psf_sampling / sqrt2_sigma)
|
|
120
|
+
erf_ym = erf((y - 0.5 - center) * psf_sampling / sqrt2_sigma)
|
|
121
|
+
integral_y = 0.5 * (erf_yp - erf_ym)
|
|
122
|
+
|
|
123
|
+
# Combined 2D pixel-integrated PSF
|
|
124
|
+
psf_data = integral_x * integral_y
|
|
125
|
+
|
|
126
|
+
elif psf_type.lower() == 'moffat':
|
|
127
|
+
# Moffat PSF with pixel integration
|
|
128
|
+
# For Moffat, analytical pixel integration is complex, so we use
|
|
129
|
+
# supersampling for accurate pixel integration
|
|
130
|
+
|
|
131
|
+
alpha = fwhm / (2 * np.sqrt(2 ** (1.0 / beta) - 1))
|
|
132
|
+
|
|
133
|
+
# Use 5x supersampling for pixel integration (vectorized)
|
|
134
|
+
# Offsets are in data-pixel space; convert to image space with psf_sampling
|
|
135
|
+
supersample = 5
|
|
136
|
+
offsets = np.linspace(-0.5 + 0.5 / supersample, 0.5 - 0.5 / supersample, supersample)
|
|
137
|
+
|
|
138
|
+
psf_data = np.zeros((psf_data_size, psf_data_size))
|
|
139
|
+
for dy in offsets:
|
|
140
|
+
for dx in offsets:
|
|
141
|
+
r = np.sqrt(
|
|
142
|
+
((x + dx - center) * psf_sampling) ** 2
|
|
143
|
+
+ ((y + dy - center) * psf_sampling) ** 2
|
|
144
|
+
)
|
|
145
|
+
psf_data += 1.0 / (1 + (r / alpha) ** 2) ** beta
|
|
146
|
+
psf_data /= supersample**2
|
|
147
|
+
|
|
148
|
+
else:
|
|
149
|
+
raise ValueError(f"Unknown psf_type '{psf_type}'. Supported types: 'gaussian', 'moffat'")
|
|
150
|
+
|
|
151
|
+
# Normalize
|
|
152
|
+
psf_data /= np.sum(psf_data)
|
|
153
|
+
|
|
154
|
+
# Apply optical aberrations if any are non-zero
|
|
155
|
+
has_aberrations = any([defocus, astigmatism_x, astigmatism_y, coma_x, coma_y])
|
|
156
|
+
|
|
157
|
+
if has_aberrations:
|
|
158
|
+
from scipy.signal import fftconvolve
|
|
159
|
+
|
|
160
|
+
# Build pupil grid in polar coordinates on the unit disk
|
|
161
|
+
N = psf_data_size
|
|
162
|
+
coords = np.linspace(-1, 1, N)
|
|
163
|
+
xx, yy = np.meshgrid(coords, coords)
|
|
164
|
+
rho = np.sqrt(xx**2 + yy**2)
|
|
165
|
+
theta = np.arctan2(yy, xx)
|
|
166
|
+
aperture = (rho <= 1.0).astype(float)
|
|
167
|
+
|
|
168
|
+
# Wavefront from Noll-ordered Zernike polynomials Z4-Z8
|
|
169
|
+
W = np.zeros_like(rho)
|
|
170
|
+
if defocus:
|
|
171
|
+
# Z4: sqrt(3) * (2*rho^2 - 1)
|
|
172
|
+
W += defocus * np.sqrt(3) * (2 * rho**2 - 1)
|
|
173
|
+
if astigmatism_x:
|
|
174
|
+
# Z5: sqrt(6) * rho^2 * sin(2*theta)
|
|
175
|
+
W += astigmatism_x * np.sqrt(6) * rho**2 * np.sin(2 * theta)
|
|
176
|
+
if astigmatism_y:
|
|
177
|
+
# Z6: sqrt(6) * rho^2 * cos(2*theta)
|
|
178
|
+
W += astigmatism_y * np.sqrt(6) * rho**2 * np.cos(2 * theta)
|
|
179
|
+
if coma_x:
|
|
180
|
+
# Z7: sqrt(8) * (3*rho^3 - 2*rho) * sin(theta)
|
|
181
|
+
W += coma_x * np.sqrt(8) * (3 * rho**3 - 2 * rho) * np.sin(theta)
|
|
182
|
+
if coma_y:
|
|
183
|
+
# Z8: sqrt(8) * (3*rho^3 - 2*rho) * cos(theta)
|
|
184
|
+
W += coma_y * np.sqrt(8) * (3 * rho**3 - 2 * rho) * np.cos(theta)
|
|
185
|
+
|
|
186
|
+
# Scale wavefront by wavelength ratio to make PSF wavelength-dependent
|
|
187
|
+
# Reference wavelength is 550nm; different wavelengths scale the
|
|
188
|
+
# effective aberration strength (shorter wavelength = stronger effect)
|
|
189
|
+
wavelength_ref = 550e-9
|
|
190
|
+
W *= wavelength_ref / wavelength
|
|
191
|
+
|
|
192
|
+
# Complex pupil function
|
|
193
|
+
pupil = aperture * np.exp(2j * np.pi * W)
|
|
194
|
+
|
|
195
|
+
# Diffraction PSF via FFT
|
|
196
|
+
E = np.fft.fftshift(np.fft.fft2(np.fft.fftshift(pupil)))
|
|
197
|
+
psf_diffraction = np.abs(E) ** 2
|
|
198
|
+
psf_diffraction /= np.sum(psf_diffraction)
|
|
199
|
+
|
|
200
|
+
# Convolve diffraction PSF with seeing PSF (already computed as psf_data)
|
|
201
|
+
psf_data = fftconvolve(psf_diffraction, psf_data, mode='same')
|
|
202
|
+
psf_data /= np.sum(psf_data)
|
|
203
|
+
|
|
204
|
+
psf_type_name = psf_type.lower() + '_aberrated'
|
|
205
|
+
else:
|
|
206
|
+
psf_type_name = psf_type.lower()
|
|
207
|
+
|
|
208
|
+
# Create PSFEx-compatible structure
|
|
209
|
+
# Add polynomial dimension (ncoeffs=1 for constant PSF)
|
|
210
|
+
psf_data_3d = psf_data[np.newaxis, :, :]
|
|
211
|
+
|
|
212
|
+
psf_model = {
|
|
213
|
+
'data': psf_data_3d,
|
|
214
|
+
'width': psf_width,
|
|
215
|
+
'height': psf_height,
|
|
216
|
+
'sampling': psf_sampling,
|
|
217
|
+
'fwhm': fwhm,
|
|
218
|
+
'degree': 0, # Constant PSF (no position dependence)
|
|
219
|
+
'ncoeffs': 1,
|
|
220
|
+
'x0': 0.0, # Reference position
|
|
221
|
+
'y0': 0.0,
|
|
222
|
+
'sx': 1.0, # Scaling factors
|
|
223
|
+
'sy': 1.0,
|
|
224
|
+
'psf_type': psf_type_name,
|
|
225
|
+
'beta': beta if psf_type.lower() == 'moffat' else None,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if has_aberrations:
|
|
229
|
+
psf_model['aberrations'] = {
|
|
230
|
+
'defocus': defocus,
|
|
231
|
+
'astigmatism_x': astigmatism_x,
|
|
232
|
+
'astigmatism_y': astigmatism_y,
|
|
233
|
+
'coma_x': coma_x,
|
|
234
|
+
'coma_y': coma_y,
|
|
235
|
+
'wavelength': wavelength,
|
|
236
|
+
'pupil_diameter': pupil_diameter,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return psf_model
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def create_sersic_profile(
|
|
243
|
+
size, x0, y0, amplitude=1.0, r_eff=5.0, n=1.0, ellipticity=0.0, position_angle=0.0
|
|
244
|
+
):
|
|
245
|
+
"""
|
|
246
|
+
Create a Sersic profile for galaxy simulation.
|
|
247
|
+
|
|
248
|
+
The Sersic profile is: I(r) = amplitude * exp(-b_n * ((r/r_eff)^(1/n) - 1))
|
|
249
|
+
where b_n ≈ 2n - 0.324 for n >= 0.5 (more accurate formula used internally)
|
|
250
|
+
|
|
251
|
+
Common cases:
|
|
252
|
+
- n=0.5: Gaussian-like profile
|
|
253
|
+
- n=1.0: Exponential disk (typical spiral galaxy disk)
|
|
254
|
+
- n=4.0: de Vaucouleurs profile (typical elliptical galaxy)
|
|
255
|
+
|
|
256
|
+
Parameters
|
|
257
|
+
----------
|
|
258
|
+
size : int
|
|
259
|
+
Size of the profile stamp (must be odd).
|
|
260
|
+
x0 : float
|
|
261
|
+
X center position within the stamp.
|
|
262
|
+
y0 : float
|
|
263
|
+
Y center position within the stamp.
|
|
264
|
+
amplitude : float, optional
|
|
265
|
+
Central surface brightness amplitude.
|
|
266
|
+
r_eff : float, optional
|
|
267
|
+
Effective radius in pixels (half-light radius).
|
|
268
|
+
n : float, optional
|
|
269
|
+
Sersic index (shape parameter).
|
|
270
|
+
ellipticity : float, optional
|
|
271
|
+
Ellipticity (0 = circular, 0.5 = moderate, 0.9 = very elongated).
|
|
272
|
+
position_angle : float, optional
|
|
273
|
+
Position angle in degrees (0 = vertical, increases CCW).
|
|
274
|
+
|
|
275
|
+
Returns
|
|
276
|
+
-------
|
|
277
|
+
numpy.ndarray
|
|
278
|
+
2D array of shape (size, size) with Sersic profile.
|
|
279
|
+
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
# Compute b_n parameter using more accurate formula
|
|
283
|
+
# For n >= 0.5: b_n ≈ 2n - 1/3 + 4/(405n) + 46/(25515n^2) + ...
|
|
284
|
+
# For simplicity, use: b_n ≈ 1.9992n - 0.3271 for n > 0.5
|
|
285
|
+
if n > 0.5:
|
|
286
|
+
b_n = 1.9992 * n - 0.3271
|
|
287
|
+
else:
|
|
288
|
+
b_n = 0.01 # Very broad profile
|
|
289
|
+
|
|
290
|
+
# Create coordinate grid
|
|
291
|
+
y, x = np.mgrid[0:size, 0:size]
|
|
292
|
+
x = x - x0
|
|
293
|
+
y = y - y0
|
|
294
|
+
|
|
295
|
+
# Apply rotation
|
|
296
|
+
if position_angle != 0:
|
|
297
|
+
theta = np.radians(position_angle)
|
|
298
|
+
x_rot = x * np.cos(theta) + y * np.sin(theta)
|
|
299
|
+
y_rot = -x * np.sin(theta) + y * np.cos(theta)
|
|
300
|
+
x, y = x_rot, y_rot
|
|
301
|
+
|
|
302
|
+
# Apply ellipticity: convert ellipticity to axis ratio
|
|
303
|
+
# ellipticity = 1 - b/a, so b/a = 1 - ellipticity
|
|
304
|
+
q = 1 - ellipticity # Axis ratio (minor/major)
|
|
305
|
+
if q <= 0:
|
|
306
|
+
q = 0.01 # Avoid division by zero
|
|
307
|
+
|
|
308
|
+
# Elliptical radius
|
|
309
|
+
r = np.sqrt(x**2 + (y / q) ** 2)
|
|
310
|
+
|
|
311
|
+
# Sersic profile
|
|
312
|
+
profile = amplitude * np.exp(-b_n * ((r / r_eff) ** (1.0 / n) - 1))
|
|
313
|
+
|
|
314
|
+
return profile
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def place_galaxy(image, x0, y0, flux, r_eff=5.0, n=1.0, ellipticity=0.0, position_angle=0.0):
|
|
318
|
+
"""
|
|
319
|
+
Place a galaxy with Sersic profile into an image.
|
|
320
|
+
|
|
321
|
+
The galaxy stamp is created, scaled to the requested flux, and added to the image.
|
|
322
|
+
Similar to psf.place_psf_stamp() but for extended sources.
|
|
323
|
+
|
|
324
|
+
The image is modified in-place.
|
|
325
|
+
|
|
326
|
+
Parameters
|
|
327
|
+
----------
|
|
328
|
+
image : numpy.ndarray
|
|
329
|
+
Target image (2D array), modified in-place.
|
|
330
|
+
x0 : float
|
|
331
|
+
X coordinate where to place the galaxy center.
|
|
332
|
+
y0 : float
|
|
333
|
+
Y coordinate where to place the galaxy center.
|
|
334
|
+
flux : float
|
|
335
|
+
Total integrated flux in ADU units.
|
|
336
|
+
r_eff : float, optional
|
|
337
|
+
Effective radius in pixels.
|
|
338
|
+
n : float, optional
|
|
339
|
+
Sersic index.
|
|
340
|
+
ellipticity : float, optional
|
|
341
|
+
Ellipticity (0 = circular, 1 = line).
|
|
342
|
+
position_angle : float, optional
|
|
343
|
+
Position angle in degrees.
|
|
344
|
+
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
# Determine stamp size (should contain most of the flux)
|
|
348
|
+
# For Sersic profiles, most flux is within ~5-8 * r_eff
|
|
349
|
+
size = int(np.ceil(r_eff * 8)) * 2 + 1
|
|
350
|
+
|
|
351
|
+
# Integer coordinates and sub-pixel offset
|
|
352
|
+
ix, iy = int(np.round(x0)), int(np.round(y0))
|
|
353
|
+
dx_sub = x0 - ix
|
|
354
|
+
dy_sub = y0 - iy
|
|
355
|
+
|
|
356
|
+
# Create galaxy stamp centered with sub-pixel offset
|
|
357
|
+
stamp_x0 = size // 2 + dx_sub
|
|
358
|
+
stamp_y0 = size // 2 + dy_sub
|
|
359
|
+
|
|
360
|
+
stamp = create_sersic_profile(
|
|
361
|
+
size, stamp_x0, stamp_y0, 1.0, r_eff, n, ellipticity, position_angle
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# Normalize and scale to requested flux
|
|
365
|
+
stamp_sum = np.sum(stamp)
|
|
366
|
+
if stamp_sum > 0:
|
|
367
|
+
stamp = stamp / stamp_sum * flux
|
|
368
|
+
else:
|
|
369
|
+
return # Degenerate profile
|
|
370
|
+
|
|
371
|
+
# Integer coordinates inside the stamp
|
|
372
|
+
y, x = np.mgrid[0 : stamp.shape[0], 0 : stamp.shape[1]]
|
|
373
|
+
|
|
374
|
+
# Corresponding image pixels
|
|
375
|
+
y1, x1 = np.mgrid[0 : stamp.shape[0], 0 : stamp.shape[1]]
|
|
376
|
+
x1 += int(np.round(x0) - np.floor(stamp.shape[1] / 2))
|
|
377
|
+
y1 += int(np.round(y0) - np.floor(stamp.shape[0] / 2))
|
|
378
|
+
|
|
379
|
+
# Crop coordinates outside target image
|
|
380
|
+
idx = np.isfinite(stamp)
|
|
381
|
+
idx &= (x1 >= 0) & (x1 < image.shape[1])
|
|
382
|
+
idx &= (y1 >= 0) & (y1 < image.shape[0])
|
|
383
|
+
|
|
384
|
+
# Add the stamp to the image
|
|
385
|
+
image[y1[idx], x1[idx]] += stamp[y[idx], x[idx]]
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def create_cosmic_ray(length, width, angle, max_intensity, profile='sharp'):
|
|
389
|
+
"""
|
|
390
|
+
Create a cosmic ray track.
|
|
391
|
+
|
|
392
|
+
Cosmic rays appear as elongated tracks with sharp edges, oriented randomly.
|
|
393
|
+
|
|
394
|
+
Parameters
|
|
395
|
+
----------
|
|
396
|
+
length : float
|
|
397
|
+
Length of the track in pixels.
|
|
398
|
+
width : float
|
|
399
|
+
Width of the track in pixels.
|
|
400
|
+
angle : float
|
|
401
|
+
Angle in degrees (0 = horizontal, increases CCW).
|
|
402
|
+
max_intensity : float
|
|
403
|
+
Peak intensity in ADU.
|
|
404
|
+
profile : str, optional
|
|
405
|
+
Intensity profile ('sharp', 'tapered', 'worm').
|
|
406
|
+
|
|
407
|
+
Returns
|
|
408
|
+
-------
|
|
409
|
+
dict
|
|
410
|
+
Dictionary with 'stamp' (2D array), 'size' (stamp dimensions).
|
|
411
|
+
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
# Create a horizontal track on an oversized canvas
|
|
415
|
+
canvas_size = int(np.ceil(max(length, width) * 3))
|
|
416
|
+
canvas = np.zeros((canvas_size, canvas_size))
|
|
417
|
+
|
|
418
|
+
# Center of canvas
|
|
419
|
+
cx, cy = canvas_size // 2, canvas_size // 2
|
|
420
|
+
|
|
421
|
+
# Create horizontal track
|
|
422
|
+
track_length = int(np.ceil(length))
|
|
423
|
+
track_width = int(np.ceil(width))
|
|
424
|
+
|
|
425
|
+
if profile == 'sharp':
|
|
426
|
+
# Sharp-edged rectangular track
|
|
427
|
+
y_start = cy - track_width // 2
|
|
428
|
+
y_end = y_start + track_width
|
|
429
|
+
x_start = cx - track_length // 2
|
|
430
|
+
x_end = x_start + track_length
|
|
431
|
+
canvas[y_start:y_end, x_start:x_end] = max_intensity
|
|
432
|
+
|
|
433
|
+
elif profile == 'tapered':
|
|
434
|
+
# Tapered track (intensity decreases toward ends, symmetric)
|
|
435
|
+
y_start = cy - track_width // 2
|
|
436
|
+
y_end = y_start + track_width
|
|
437
|
+
x_start = cx - track_length // 2
|
|
438
|
+
x_end = x_start + track_length
|
|
439
|
+
# Symmetric taper: ramps from 0.2 at edges to 1.0 at center
|
|
440
|
+
taper = np.linspace(-1.0, 1.0, track_length)
|
|
441
|
+
taper = 0.2 + 0.8 * (1.0 - np.abs(taper))
|
|
442
|
+
canvas[y_start:y_end, x_start:x_end] = max_intensity * taper[None, :]
|
|
443
|
+
|
|
444
|
+
elif profile == 'worm':
|
|
445
|
+
# Worm-like track with slight curvature
|
|
446
|
+
x_coords = np.arange(track_length) - track_length // 2 + cx
|
|
447
|
+
# Add sinusoidal variation
|
|
448
|
+
y_variation = np.sin(np.linspace(0, 2 * np.pi, track_length)) * (track_width * 1)
|
|
449
|
+
y_coords = y_variation + cy
|
|
450
|
+
|
|
451
|
+
for i, (x, y) in enumerate(zip(x_coords, y_coords)):
|
|
452
|
+
ix, iy = int(np.round(x)), int(np.round(y))
|
|
453
|
+
# Draw small blob at each position
|
|
454
|
+
for dy in range(-track_width // 2, -track_width // 2 + track_width):
|
|
455
|
+
for dx in range(-1, 2):
|
|
456
|
+
if i + dx < 0 or i + dx >= track_length:
|
|
457
|
+
continue
|
|
458
|
+
py, px = iy + dy, ix + dx
|
|
459
|
+
if 0 <= py < canvas_size and 0 <= px < canvas_size:
|
|
460
|
+
canvas[py, px] = max_intensity
|
|
461
|
+
|
|
462
|
+
else:
|
|
463
|
+
raise ValueError(f"Unknown profile '{profile}'. Use 'sharp', 'tapered', or 'worm'")
|
|
464
|
+
|
|
465
|
+
# Rotate the canvas
|
|
466
|
+
if angle != 0:
|
|
467
|
+
# Cosmics are supposed to be sharp so let's rotate without interpolation
|
|
468
|
+
canvas = rotate(canvas, angle, reshape=False, order=0)
|
|
469
|
+
|
|
470
|
+
# Crop to minimal bounding box
|
|
471
|
+
nonzero = np.nonzero(canvas > 0.01 * max_intensity)
|
|
472
|
+
if len(nonzero[0]) == 0:
|
|
473
|
+
return {'stamp': np.array([[max_intensity]]), 'size': (1, 1)}
|
|
474
|
+
|
|
475
|
+
y_min, y_max = nonzero[0].min(), nonzero[0].max()
|
|
476
|
+
x_min, x_max = nonzero[1].min(), nonzero[1].max()
|
|
477
|
+
stamp = canvas[y_min : y_max + 1, x_min : x_max + 1]
|
|
478
|
+
|
|
479
|
+
return {'stamp': stamp, 'size': stamp.shape}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def add_cosmic_rays(
|
|
483
|
+
image,
|
|
484
|
+
n_rays=5,
|
|
485
|
+
length_range=(5, 50),
|
|
486
|
+
width_range=(1, 3),
|
|
487
|
+
intensity_range=(1000, 10000),
|
|
488
|
+
profile='sharp',
|
|
489
|
+
):
|
|
490
|
+
"""
|
|
491
|
+
Add cosmic ray tracks to an image.
|
|
492
|
+
|
|
493
|
+
Cosmic rays are placed at random positions with random orientations.
|
|
494
|
+
|
|
495
|
+
Parameters
|
|
496
|
+
----------
|
|
497
|
+
image : numpy.ndarray
|
|
498
|
+
Target image (modified in-place).
|
|
499
|
+
n_rays : int, optional
|
|
500
|
+
Number of cosmic rays to add.
|
|
501
|
+
length_range : tuple, optional
|
|
502
|
+
(min, max) length in pixels.
|
|
503
|
+
width_range : tuple, optional
|
|
504
|
+
(min, max) width in pixels.
|
|
505
|
+
intensity_range : tuple, optional
|
|
506
|
+
(min, max) peak intensity in ADU.
|
|
507
|
+
profile : str, optional
|
|
508
|
+
Intensity profile ('sharp', 'tapered', 'worm').
|
|
509
|
+
|
|
510
|
+
Returns
|
|
511
|
+
-------
|
|
512
|
+
astropy.table.Table
|
|
513
|
+
Catalog of added cosmic rays with columns: x, y, length, width, angle, intensity, type.
|
|
514
|
+
|
|
515
|
+
"""
|
|
516
|
+
|
|
517
|
+
height, width = image.shape
|
|
518
|
+
rays = []
|
|
519
|
+
|
|
520
|
+
for i in range(n_rays):
|
|
521
|
+
# Random parameters
|
|
522
|
+
length = np.random.uniform(*length_range)
|
|
523
|
+
ray_width = np.random.uniform(*width_range)
|
|
524
|
+
angle = np.random.uniform(0, 180)
|
|
525
|
+
intensity = np.random.uniform(*intensity_range)
|
|
526
|
+
|
|
527
|
+
# Random position
|
|
528
|
+
x0 = np.random.uniform(0, width)
|
|
529
|
+
y0 = np.random.uniform(0, height)
|
|
530
|
+
|
|
531
|
+
# Create cosmic ray stamp
|
|
532
|
+
cr = create_cosmic_ray(length, ray_width, angle, intensity, profile)
|
|
533
|
+
stamp = cr['stamp']
|
|
534
|
+
|
|
535
|
+
# Place stamp on image
|
|
536
|
+
ix, iy = int(np.round(x0)), int(np.round(y0))
|
|
537
|
+
sy, sx = stamp.shape
|
|
538
|
+
|
|
539
|
+
# Stamp bounds in image coordinates
|
|
540
|
+
x_start = ix - sx // 2
|
|
541
|
+
y_start = iy - sy // 2
|
|
542
|
+
|
|
543
|
+
# Clip to image boundaries
|
|
544
|
+
x_img_start = max(0, x_start)
|
|
545
|
+
y_img_start = max(0, y_start)
|
|
546
|
+
x_img_end = min(width, x_start + sx)
|
|
547
|
+
y_img_end = min(height, y_start + sy)
|
|
548
|
+
|
|
549
|
+
# Corresponding stamp coordinates
|
|
550
|
+
x_stamp_start = x_img_start - x_start
|
|
551
|
+
y_stamp_start = y_img_start - y_start
|
|
552
|
+
x_stamp_end = x_stamp_start + (x_img_end - x_img_start)
|
|
553
|
+
y_stamp_end = y_stamp_start + (y_img_end - y_img_start)
|
|
554
|
+
|
|
555
|
+
# Add to image
|
|
556
|
+
if x_img_end > x_img_start and y_img_end > y_img_start:
|
|
557
|
+
image[y_img_start:y_img_end, x_img_start:x_img_end] += stamp[
|
|
558
|
+
y_stamp_start:y_stamp_end, x_stamp_start:x_stamp_end
|
|
559
|
+
]
|
|
560
|
+
|
|
561
|
+
rays.append(
|
|
562
|
+
{
|
|
563
|
+
'x': x0,
|
|
564
|
+
'y': y0,
|
|
565
|
+
'length': length,
|
|
566
|
+
'width': ray_width,
|
|
567
|
+
'angle': angle,
|
|
568
|
+
'intensity': intensity,
|
|
569
|
+
'type': 'cosmic_ray',
|
|
570
|
+
'is_real': False,
|
|
571
|
+
}
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
return Table(rays)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def add_hot_pixels(
|
|
578
|
+
image, n_pixels=10, intensity_range=(500, 5000), clustering=False, cluster_size=3
|
|
579
|
+
):
|
|
580
|
+
"""
|
|
581
|
+
Add hot pixels to an image.
|
|
582
|
+
|
|
583
|
+
Hot pixels are single bright pixels at random locations, optionally clustered
|
|
584
|
+
to simulate physical detector defects.
|
|
585
|
+
|
|
586
|
+
Parameters
|
|
587
|
+
----------
|
|
588
|
+
image : numpy.ndarray
|
|
589
|
+
Target image (modified in-place).
|
|
590
|
+
n_pixels : int, optional
|
|
591
|
+
Number of hot pixels to add.
|
|
592
|
+
intensity_range : tuple, optional
|
|
593
|
+
(min, max) intensity in ADU.
|
|
594
|
+
clustering : bool, optional
|
|
595
|
+
If True, create clusters of hot pixels.
|
|
596
|
+
cluster_size : int, optional
|
|
597
|
+
Number of pixels per cluster (if clustering=True).
|
|
598
|
+
|
|
599
|
+
Returns
|
|
600
|
+
-------
|
|
601
|
+
astropy.table.Table
|
|
602
|
+
Catalog of added hot pixels with columns: x, y, intensity, type.
|
|
603
|
+
|
|
604
|
+
"""
|
|
605
|
+
|
|
606
|
+
height, width = image.shape
|
|
607
|
+
pixels = []
|
|
608
|
+
|
|
609
|
+
if not clustering:
|
|
610
|
+
# Independent hot pixels
|
|
611
|
+
for i in range(n_pixels):
|
|
612
|
+
x = np.random.randint(0, width)
|
|
613
|
+
y = np.random.randint(0, height)
|
|
614
|
+
intensity = np.random.uniform(*intensity_range)
|
|
615
|
+
|
|
616
|
+
image[y, x] += intensity
|
|
617
|
+
|
|
618
|
+
pixels.append(
|
|
619
|
+
{'x': x, 'y': y, 'intensity': intensity, 'type': 'hot_pixel', 'is_real': False}
|
|
620
|
+
)
|
|
621
|
+
else:
|
|
622
|
+
# Clustered hot pixels — produce exactly n_pixels total
|
|
623
|
+
cluster_size = max(1, cluster_size)
|
|
624
|
+
n_clusters = max(1, int(np.ceil(n_pixels / cluster_size)))
|
|
625
|
+
placed = 0
|
|
626
|
+
for i in range(n_clusters):
|
|
627
|
+
# Cluster center
|
|
628
|
+
cx = np.random.randint(0, width)
|
|
629
|
+
cy = np.random.randint(0, height)
|
|
630
|
+
|
|
631
|
+
# Add pixels around center (stop at n_pixels total)
|
|
632
|
+
for j in range(cluster_size):
|
|
633
|
+
if placed >= n_pixels:
|
|
634
|
+
break
|
|
635
|
+
|
|
636
|
+
x = cx + np.random.randint(-2, 3)
|
|
637
|
+
y = cy + np.random.randint(-2, 3)
|
|
638
|
+
|
|
639
|
+
# Clip to image boundaries
|
|
640
|
+
x = np.clip(x, 0, width - 1)
|
|
641
|
+
y = np.clip(y, 0, height - 1)
|
|
642
|
+
|
|
643
|
+
intensity = np.random.uniform(*intensity_range)
|
|
644
|
+
image[y, x] += intensity
|
|
645
|
+
|
|
646
|
+
pixels.append(
|
|
647
|
+
{'x': x, 'y': y, 'intensity': intensity, 'type': 'hot_pixel', 'is_real': False}
|
|
648
|
+
)
|
|
649
|
+
placed += 1
|
|
650
|
+
|
|
651
|
+
return Table(pixels)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def add_bad_columns(
|
|
655
|
+
image, n_columns=2, intensity_range=None, bad_type='dead', orientation='vertical'
|
|
656
|
+
):
|
|
657
|
+
"""
|
|
658
|
+
Add bad columns or rows to an image.
|
|
659
|
+
|
|
660
|
+
Bad columns can be dead (low/zero values), hot (high values), or noisy (high variance).
|
|
661
|
+
|
|
662
|
+
Parameters
|
|
663
|
+
----------
|
|
664
|
+
image : numpy.ndarray
|
|
665
|
+
Target image (modified in-place).
|
|
666
|
+
n_columns : int, optional
|
|
667
|
+
Number of bad columns/rows to add.
|
|
668
|
+
intensity_range : tuple, optional
|
|
669
|
+
(min, max) intensity for 'hot' type. Ignored for 'dead' and 'noisy'.
|
|
670
|
+
bad_type : str, optional
|
|
671
|
+
Type of bad column ('dead', 'hot', 'noisy').
|
|
672
|
+
orientation : str, optional
|
|
673
|
+
'vertical' for columns, 'horizontal' for rows.
|
|
674
|
+
|
|
675
|
+
Returns
|
|
676
|
+
-------
|
|
677
|
+
astropy.table.Table
|
|
678
|
+
Catalog of bad columns with columns: position, bad_type, orientation, type.
|
|
679
|
+
|
|
680
|
+
"""
|
|
681
|
+
|
|
682
|
+
height, width = image.shape
|
|
683
|
+
columns = []
|
|
684
|
+
|
|
685
|
+
for i in range(n_columns):
|
|
686
|
+
if orientation == 'vertical':
|
|
687
|
+
# Bad column
|
|
688
|
+
col = np.random.randint(0, width)
|
|
689
|
+
|
|
690
|
+
if bad_type == 'dead':
|
|
691
|
+
image[:, col] = 0
|
|
692
|
+
elif bad_type == 'hot':
|
|
693
|
+
if intensity_range is None:
|
|
694
|
+
intensity_range = (5000, 20000)
|
|
695
|
+
intensity = np.random.uniform(*intensity_range)
|
|
696
|
+
image[:, col] = intensity
|
|
697
|
+
elif bad_type == 'noisy':
|
|
698
|
+
noise = np.random.normal(0, 1000, height)
|
|
699
|
+
image[:, col] += noise
|
|
700
|
+
else:
|
|
701
|
+
raise ValueError(f"Unknown bad_type '{bad_type}'. Use 'dead', 'hot', or 'noisy'")
|
|
702
|
+
|
|
703
|
+
columns.append(
|
|
704
|
+
{
|
|
705
|
+
'position': col,
|
|
706
|
+
'bad_type': bad_type,
|
|
707
|
+
'orientation': orientation,
|
|
708
|
+
'type': 'bad_column',
|
|
709
|
+
'is_real': False,
|
|
710
|
+
}
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
elif orientation == 'horizontal':
|
|
714
|
+
# Bad row
|
|
715
|
+
row = np.random.randint(0, height)
|
|
716
|
+
|
|
717
|
+
if bad_type == 'dead':
|
|
718
|
+
image[row, :] = 0
|
|
719
|
+
elif bad_type == 'hot':
|
|
720
|
+
if intensity_range is None:
|
|
721
|
+
intensity_range = (5000, 20000)
|
|
722
|
+
intensity = np.random.uniform(*intensity_range)
|
|
723
|
+
image[row, :] = intensity
|
|
724
|
+
elif bad_type == 'noisy':
|
|
725
|
+
noise = np.random.normal(0, 1000, width)
|
|
726
|
+
image[row, :] += noise
|
|
727
|
+
else:
|
|
728
|
+
raise ValueError(f"Unknown bad_type '{bad_type}'. Use 'dead', 'hot', or 'noisy'")
|
|
729
|
+
|
|
730
|
+
columns.append(
|
|
731
|
+
{
|
|
732
|
+
'position': row,
|
|
733
|
+
'bad_type': bad_type,
|
|
734
|
+
'orientation': orientation,
|
|
735
|
+
'type': 'bad_row',
|
|
736
|
+
'is_real': False,
|
|
737
|
+
}
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
return Table(columns)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def create_satellite_trail(length, width, intensity, angle=None, profile='linear', tumbling=False):
|
|
744
|
+
"""
|
|
745
|
+
Create a satellite trail.
|
|
746
|
+
|
|
747
|
+
Satellite trails are long, thin, bright streaks caused by satellites passing through the field.
|
|
748
|
+
|
|
749
|
+
Parameters
|
|
750
|
+
----------
|
|
751
|
+
length : float
|
|
752
|
+
Length of the trail in pixels.
|
|
753
|
+
width : float
|
|
754
|
+
Width of the trail in pixels.
|
|
755
|
+
intensity : float
|
|
756
|
+
Peak intensity in ADU.
|
|
757
|
+
angle : float, optional
|
|
758
|
+
Angle in degrees (0 = horizontal). If None, random.
|
|
759
|
+
profile : str, optional
|
|
760
|
+
Transverse profile ('linear', 'gaussian').
|
|
761
|
+
tumbling : bool, optional
|
|
762
|
+
If True, add intensity variations along the trail (tumbling satellite).
|
|
763
|
+
|
|
764
|
+
Returns
|
|
765
|
+
-------
|
|
766
|
+
dict
|
|
767
|
+
Dictionary with 'stamp' (2D array), 'angle' (actual angle used).
|
|
768
|
+
|
|
769
|
+
"""
|
|
770
|
+
|
|
771
|
+
if angle is None:
|
|
772
|
+
angle = np.random.uniform(0, 180)
|
|
773
|
+
|
|
774
|
+
# Create horizontal trail on oversized canvas
|
|
775
|
+
canvas_size = int(np.ceil(max(length, width) * 3))
|
|
776
|
+
canvas = np.zeros((canvas_size, canvas_size))
|
|
777
|
+
|
|
778
|
+
cx, cy = canvas_size // 2, canvas_size // 2
|
|
779
|
+
|
|
780
|
+
track_length = int(np.ceil(length))
|
|
781
|
+
track_width = int(np.ceil(width))
|
|
782
|
+
|
|
783
|
+
if profile == 'linear':
|
|
784
|
+
# Rectangular profile
|
|
785
|
+
y_start = cy - track_width // 2
|
|
786
|
+
y_end = y_start + track_width
|
|
787
|
+
x_start = cx - track_length // 2
|
|
788
|
+
x_end = x_start + track_length
|
|
789
|
+
canvas[y_start:y_end, x_start:x_end] = intensity
|
|
790
|
+
|
|
791
|
+
elif profile == 'gaussian':
|
|
792
|
+
# Gaussian transverse profile
|
|
793
|
+
x_coords = np.arange(cx - track_length // 2, cx + track_length // 2)
|
|
794
|
+
for x in x_coords:
|
|
795
|
+
for dy in range(-track_width * 2, track_width * 2 + 1):
|
|
796
|
+
y = cy + dy
|
|
797
|
+
if 0 <= y < canvas_size and 0 <= x < canvas_size:
|
|
798
|
+
gauss = np.exp(-0.5 * (dy / (track_width / 2.355)) ** 2)
|
|
799
|
+
canvas[y, x] += intensity * gauss
|
|
800
|
+
|
|
801
|
+
else:
|
|
802
|
+
raise ValueError(f"Unknown profile '{profile}'. Use 'linear' or 'gaussian'")
|
|
803
|
+
|
|
804
|
+
# Add tumbling variations
|
|
805
|
+
if tumbling:
|
|
806
|
+
x_start = cx - track_length // 2
|
|
807
|
+
x_end = x_start + track_length
|
|
808
|
+
variation = 0.5 + 0.5 * np.sin(np.linspace(0, 4 * np.pi, track_length))
|
|
809
|
+
y_start = cy - track_width // 2
|
|
810
|
+
y_end = y_start + track_width
|
|
811
|
+
if x_end - x_start == len(variation):
|
|
812
|
+
canvas[y_start:y_end, x_start:x_end] *= variation[None, :]
|
|
813
|
+
|
|
814
|
+
# Rotate
|
|
815
|
+
if angle != 0:
|
|
816
|
+
canvas = rotate(canvas, angle, reshape=False, order=1)
|
|
817
|
+
|
|
818
|
+
# Crop to bounding box
|
|
819
|
+
nonzero = np.nonzero(canvas > 0.01 * intensity)
|
|
820
|
+
if len(nonzero[0]) == 0:
|
|
821
|
+
return {'stamp': np.array([[intensity]]), 'angle': angle, 'size': (1, 1)}
|
|
822
|
+
|
|
823
|
+
y_min, y_max = nonzero[0].min(), nonzero[0].max()
|
|
824
|
+
x_min, x_max = nonzero[1].min(), nonzero[1].max()
|
|
825
|
+
stamp = canvas[y_min : y_max + 1, x_min : x_max + 1]
|
|
826
|
+
|
|
827
|
+
return {'stamp': stamp, 'angle': angle, 'size': stamp.shape}
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def add_satellite_trails(
|
|
831
|
+
image,
|
|
832
|
+
n_trails=1,
|
|
833
|
+
length_range=(100, 400),
|
|
834
|
+
width_range=(2, 8),
|
|
835
|
+
intensity_range=(5000, 20000),
|
|
836
|
+
profile='linear',
|
|
837
|
+
tumbling_prob=0.2,
|
|
838
|
+
):
|
|
839
|
+
"""
|
|
840
|
+
Add satellite trails to an image.
|
|
841
|
+
|
|
842
|
+
Parameters
|
|
843
|
+
----------
|
|
844
|
+
image : numpy.ndarray
|
|
845
|
+
Target image (modified in-place).
|
|
846
|
+
n_trails : int, optional
|
|
847
|
+
Number of satellite trails to add.
|
|
848
|
+
length_range : tuple, optional
|
|
849
|
+
(min, max) length in pixels.
|
|
850
|
+
width_range : tuple, optional
|
|
851
|
+
(min, max) width in pixels.
|
|
852
|
+
intensity_range : tuple, optional
|
|
853
|
+
(min, max) peak intensity in ADU.
|
|
854
|
+
profile : str, optional
|
|
855
|
+
Transverse profile ('linear', 'gaussian').
|
|
856
|
+
tumbling_prob : float, optional
|
|
857
|
+
Probability of tumbling satellite (intensity variations).
|
|
858
|
+
|
|
859
|
+
Returns
|
|
860
|
+
-------
|
|
861
|
+
astropy.table.Table
|
|
862
|
+
Catalog of added trails.
|
|
863
|
+
|
|
864
|
+
"""
|
|
865
|
+
|
|
866
|
+
height, width = image.shape
|
|
867
|
+
trails = []
|
|
868
|
+
|
|
869
|
+
for i in range(n_trails):
|
|
870
|
+
length = np.random.uniform(*length_range)
|
|
871
|
+
trail_width = np.random.uniform(*width_range)
|
|
872
|
+
intensity = np.random.uniform(*intensity_range)
|
|
873
|
+
tumbling = np.random.random() < tumbling_prob
|
|
874
|
+
|
|
875
|
+
# Random position
|
|
876
|
+
x0 = np.random.uniform(0, width)
|
|
877
|
+
y0 = np.random.uniform(0, height)
|
|
878
|
+
|
|
879
|
+
# Create trail
|
|
880
|
+
trail = create_satellite_trail(length, trail_width, intensity, None, profile, tumbling)
|
|
881
|
+
stamp = trail['stamp']
|
|
882
|
+
angle = trail['angle']
|
|
883
|
+
|
|
884
|
+
# Place on image
|
|
885
|
+
ix, iy = int(np.round(x0)), int(np.round(y0))
|
|
886
|
+
sy, sx = stamp.shape
|
|
887
|
+
|
|
888
|
+
x_start = ix - sx // 2
|
|
889
|
+
y_start = iy - sy // 2
|
|
890
|
+
|
|
891
|
+
x_img_start = max(0, x_start)
|
|
892
|
+
y_img_start = max(0, y_start)
|
|
893
|
+
x_img_end = min(width, x_start + sx)
|
|
894
|
+
y_img_end = min(height, y_start + sy)
|
|
895
|
+
|
|
896
|
+
x_stamp_start = x_img_start - x_start
|
|
897
|
+
y_stamp_start = y_img_start - y_start
|
|
898
|
+
x_stamp_end = x_stamp_start + (x_img_end - x_img_start)
|
|
899
|
+
y_stamp_end = y_stamp_start + (y_img_end - y_img_start)
|
|
900
|
+
|
|
901
|
+
if x_img_end > x_img_start and y_img_end > y_img_start:
|
|
902
|
+
image[y_img_start:y_img_end, x_img_start:x_img_end] += stamp[
|
|
903
|
+
y_stamp_start:y_stamp_end, x_stamp_start:x_stamp_end
|
|
904
|
+
]
|
|
905
|
+
|
|
906
|
+
trails.append(
|
|
907
|
+
{
|
|
908
|
+
'x': x0,
|
|
909
|
+
'y': y0,
|
|
910
|
+
'length': length,
|
|
911
|
+
'width': trail_width,
|
|
912
|
+
'angle': angle,
|
|
913
|
+
'intensity': intensity,
|
|
914
|
+
'tumbling': tumbling,
|
|
915
|
+
'type': 'satellite_trail',
|
|
916
|
+
'is_real': False,
|
|
917
|
+
}
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
return Table(trails)
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def create_diffraction_spikes(size, x0, y0, star_flux, n_spikes=4, spike_length=50, spike_width=2):
|
|
924
|
+
"""
|
|
925
|
+
Create diffraction spikes for a bright star.
|
|
926
|
+
|
|
927
|
+
Diffraction spikes are caused by the support structure of telescopes (refractors, SCTs).
|
|
928
|
+
Typically 4 spikes at 45-degree intervals.
|
|
929
|
+
|
|
930
|
+
Parameters
|
|
931
|
+
----------
|
|
932
|
+
size : int
|
|
933
|
+
Size of the stamp.
|
|
934
|
+
x0 : float
|
|
935
|
+
X center (star position).
|
|
936
|
+
y0 : float
|
|
937
|
+
Y center (star position).
|
|
938
|
+
star_flux : float
|
|
939
|
+
Flux of the source star (determines spike intensity).
|
|
940
|
+
n_spikes : int, optional
|
|
941
|
+
Number of spikes (typically 4).
|
|
942
|
+
spike_length : float, optional
|
|
943
|
+
Length of each spike in pixels.
|
|
944
|
+
spike_width : float, optional
|
|
945
|
+
Width of each spike in pixels.
|
|
946
|
+
|
|
947
|
+
Returns
|
|
948
|
+
-------
|
|
949
|
+
numpy.ndarray
|
|
950
|
+
2D array with diffraction spikes.
|
|
951
|
+
|
|
952
|
+
"""
|
|
953
|
+
|
|
954
|
+
stamp = np.zeros((size, size))
|
|
955
|
+
|
|
956
|
+
# Spike intensity proportional to star flux with power-law decline
|
|
957
|
+
spike_fraction = 0.01 # 1% of star flux goes into spikes
|
|
958
|
+
|
|
959
|
+
for i in range(n_spikes):
|
|
960
|
+
angle = i * (360.0 / n_spikes) # Evenly spaced angles
|
|
961
|
+
theta = np.radians(angle)
|
|
962
|
+
|
|
963
|
+
# Create spike along radial direction
|
|
964
|
+
for r in range(1, int(spike_length)):
|
|
965
|
+
# Radial power law: I(r) ∝ flux / r^2
|
|
966
|
+
intensity = spike_fraction * star_flux / (r**2 + 1)
|
|
967
|
+
|
|
968
|
+
# Position along spike
|
|
969
|
+
x = x0 + r * np.cos(theta)
|
|
970
|
+
y = y0 + r * np.sin(theta)
|
|
971
|
+
|
|
972
|
+
# Draw spike with finite width
|
|
973
|
+
# for dw in np.linspace(-spike_width / 2, spike_width / 2, int(spike_width) + 1):
|
|
974
|
+
for dw in np.linspace(
|
|
975
|
+
-np.floor(spike_width / 2), np.floor(spike_width / 2), int(2 * spike_width + 1)
|
|
976
|
+
):
|
|
977
|
+
xp = x + dw * np.sin(theta)
|
|
978
|
+
yp = y - dw * np.cos(theta)
|
|
979
|
+
|
|
980
|
+
ix, iy = int(np.round(xp)), int(np.round(yp))
|
|
981
|
+
if 0 <= ix < size and 0 <= iy < size:
|
|
982
|
+
stamp[iy, ix] += intensity
|
|
983
|
+
|
|
984
|
+
return stamp
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def create_optical_ghost(
|
|
988
|
+
size, x0, y0, source_flux, ghost_fraction=0.05, offset=(50, 50), blur_sigma=5.0
|
|
989
|
+
):
|
|
990
|
+
"""
|
|
991
|
+
Create an optical ghost (reflection artifact).
|
|
992
|
+
|
|
993
|
+
Optical ghosts are faint, blurred copies of bright sources caused by reflections
|
|
994
|
+
in the optical system.
|
|
995
|
+
|
|
996
|
+
Parameters
|
|
997
|
+
----------
|
|
998
|
+
size : int
|
|
999
|
+
Minimum size of the stamp (will be expanded if needed to fit ghost).
|
|
1000
|
+
x0 : float
|
|
1001
|
+
X center of original source within stamp.
|
|
1002
|
+
y0 : float
|
|
1003
|
+
Y center of original source within stamp.
|
|
1004
|
+
source_flux : float
|
|
1005
|
+
Flux of the original source.
|
|
1006
|
+
ghost_fraction : float, optional
|
|
1007
|
+
Fraction of source flux appearing in ghost (typically 0.01-0.1).
|
|
1008
|
+
offset : tuple, optional
|
|
1009
|
+
(dx, dy) offset of ghost from source in pixels.
|
|
1010
|
+
blur_sigma : float, optional
|
|
1011
|
+
Gaussian blur sigma for the ghost.
|
|
1012
|
+
|
|
1013
|
+
Returns
|
|
1014
|
+
-------
|
|
1015
|
+
dict
|
|
1016
|
+
Dictionary with 'stamp' (2D array) and 'source_pos' (x, y) giving the
|
|
1017
|
+
source position within the stamp (needed to align stamp onto the image).
|
|
1018
|
+
|
|
1019
|
+
"""
|
|
1020
|
+
|
|
1021
|
+
# Calculate required stamp size to accommodate both source and ghost.
|
|
1022
|
+
# For negative offsets, the ghost lies at coordinates < source position,
|
|
1023
|
+
# so we need to shift the origin so all coordinates are non-negative.
|
|
1024
|
+
gx = x0 + offset[0]
|
|
1025
|
+
gy = y0 + offset[1]
|
|
1026
|
+
|
|
1027
|
+
# Compute origin shift so the leftmost/topmost point is at the margin
|
|
1028
|
+
margin = int(blur_sigma * 3) # 3-sigma margin for Gaussian blur
|
|
1029
|
+
shift_x = max(0, margin - int(min(x0, gx)))
|
|
1030
|
+
shift_y = max(0, margin - int(min(y0, gy)))
|
|
1031
|
+
|
|
1032
|
+
# Apply shift to both source and ghost coordinates
|
|
1033
|
+
x0_s = x0 + shift_x
|
|
1034
|
+
y0_s = y0 + shift_y
|
|
1035
|
+
gx_s = gx + shift_x
|
|
1036
|
+
gy_s = gy + shift_y
|
|
1037
|
+
|
|
1038
|
+
# Stamp must contain both positions plus margin on all sides
|
|
1039
|
+
actual_width = max(size, int(max(x0_s, gx_s) + margin + 1))
|
|
1040
|
+
actual_height = max(size, int(max(y0_s, gy_s) + margin + 1))
|
|
1041
|
+
|
|
1042
|
+
stamp = np.zeros((actual_height, actual_width))
|
|
1043
|
+
|
|
1044
|
+
# Place point source at ghost position
|
|
1045
|
+
ix, iy = int(np.round(gx_s)), int(np.round(gy_s))
|
|
1046
|
+
if 0 <= ix < actual_width and 0 <= iy < actual_height:
|
|
1047
|
+
stamp[iy, ix] = source_flux * ghost_fraction
|
|
1048
|
+
|
|
1049
|
+
# Blur to simulate defocus
|
|
1050
|
+
stamp = gaussian_filter(stamp, sigma=blur_sigma)
|
|
1051
|
+
|
|
1052
|
+
return {'stamp': stamp, 'source_pos': (x0_s, y0_s)}
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def add_close_companions_to_catalog(
|
|
1056
|
+
catalog,
|
|
1057
|
+
fwhm,
|
|
1058
|
+
fraction=0.2,
|
|
1059
|
+
min_separation_fwhm=1.0,
|
|
1060
|
+
max_separation_fwhm=3.0,
|
|
1061
|
+
flux_variation=(0.5, 1.5),
|
|
1062
|
+
image_shape=None,
|
|
1063
|
+
edge=10,
|
|
1064
|
+
):
|
|
1065
|
+
"""
|
|
1066
|
+
Add close companions to stars in a catalog (before image placement).
|
|
1067
|
+
|
|
1068
|
+
This function creates a new catalog with additional companion stars placed
|
|
1069
|
+
near existing stars to simulate crowded fields. Companions are only added
|
|
1070
|
+
to sources with type='star'.
|
|
1071
|
+
|
|
1072
|
+
Parameters
|
|
1073
|
+
----------
|
|
1074
|
+
catalog : astropy.table.Table
|
|
1075
|
+
Input catalog with 'x', 'y', 'flux', 'type' columns.
|
|
1076
|
+
fwhm : float
|
|
1077
|
+
FWHM of the PSF in pixels (used to calculate separations).
|
|
1078
|
+
fraction : float, optional
|
|
1079
|
+
Fraction of stars (0-1) that will have companions.
|
|
1080
|
+
min_separation_fwhm : float, optional
|
|
1081
|
+
Minimum separation in FWHM units.
|
|
1082
|
+
max_separation_fwhm : float, optional
|
|
1083
|
+
Maximum separation in FWHM units.
|
|
1084
|
+
flux_variation : tuple, optional
|
|
1085
|
+
(min, max) flux ratio for companion relative to primary.
|
|
1086
|
+
image_shape : tuple, optional
|
|
1087
|
+
(height, width) tuple for bounds checking, or None to skip.
|
|
1088
|
+
edge : int, optional
|
|
1089
|
+
Minimum distance from edges when bounds checking.
|
|
1090
|
+
|
|
1091
|
+
Returns
|
|
1092
|
+
-------
|
|
1093
|
+
astropy.table.Table
|
|
1094
|
+
New catalog with companions added (original catalog unchanged).
|
|
1095
|
+
|
|
1096
|
+
"""
|
|
1097
|
+
from astropy.table import vstack, Table
|
|
1098
|
+
|
|
1099
|
+
# Select only stars for companion addition
|
|
1100
|
+
stars = catalog[catalog['type'] == 'star']
|
|
1101
|
+
|
|
1102
|
+
if len(stars) == 0 or fraction <= 0:
|
|
1103
|
+
return catalog.copy()
|
|
1104
|
+
|
|
1105
|
+
# Randomly select stars to give companions
|
|
1106
|
+
n_companions = int(len(stars) * fraction)
|
|
1107
|
+
if n_companions == 0:
|
|
1108
|
+
return catalog.copy()
|
|
1109
|
+
|
|
1110
|
+
companion_indices = np.random.choice(len(stars), size=n_companions, replace=False)
|
|
1111
|
+
|
|
1112
|
+
companions_added = []
|
|
1113
|
+
min_separation = min_separation_fwhm * fwhm
|
|
1114
|
+
max_separation = max_separation_fwhm * fwhm
|
|
1115
|
+
|
|
1116
|
+
for idx in companion_indices:
|
|
1117
|
+
source = stars[idx]
|
|
1118
|
+
|
|
1119
|
+
# Random separation and position angle
|
|
1120
|
+
separation = np.random.uniform(min_separation, max_separation)
|
|
1121
|
+
angle = np.random.uniform(0, 2 * np.pi)
|
|
1122
|
+
|
|
1123
|
+
# Companion position
|
|
1124
|
+
comp_x = source['x'] + separation * np.cos(angle)
|
|
1125
|
+
comp_y = source['y'] + separation * np.sin(angle)
|
|
1126
|
+
|
|
1127
|
+
# Check if companion is within image bounds
|
|
1128
|
+
if image_shape is not None:
|
|
1129
|
+
height, width = image_shape
|
|
1130
|
+
if not (edge < comp_x < width - edge and edge < comp_y < height - edge):
|
|
1131
|
+
continue
|
|
1132
|
+
|
|
1133
|
+
# Companion flux (similar to primary, with variation)
|
|
1134
|
+
comp_flux = source['flux'] * np.random.uniform(*flux_variation)
|
|
1135
|
+
|
|
1136
|
+
# Build companion entry matching the catalog structure
|
|
1137
|
+
companion = {
|
|
1138
|
+
'x': comp_x,
|
|
1139
|
+
'y': comp_y,
|
|
1140
|
+
'flux': comp_flux,
|
|
1141
|
+
'type': 'star',
|
|
1142
|
+
'is_real': True,
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
# Copy additional columns from source if they exist
|
|
1146
|
+
for col in ['fwhm', 'psf_type']:
|
|
1147
|
+
if col in source.colnames:
|
|
1148
|
+
companion[col] = source[col]
|
|
1149
|
+
|
|
1150
|
+
companions_added.append(companion)
|
|
1151
|
+
|
|
1152
|
+
# Return extended catalog
|
|
1153
|
+
if companions_added:
|
|
1154
|
+
comp_table = Table(companions_added)
|
|
1155
|
+
return vstack([catalog, comp_table])
|
|
1156
|
+
else:
|
|
1157
|
+
return catalog.copy()
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def add_stars(
|
|
1161
|
+
image,
|
|
1162
|
+
n=100,
|
|
1163
|
+
flux_range=(100, 10000),
|
|
1164
|
+
fwhm=3.0,
|
|
1165
|
+
psf='gaussian',
|
|
1166
|
+
beta=2.5,
|
|
1167
|
+
edge=0,
|
|
1168
|
+
saturation=None,
|
|
1169
|
+
diffraction_spikes=False,
|
|
1170
|
+
spike_threshold=50000,
|
|
1171
|
+
optical_ghosts=False,
|
|
1172
|
+
ghost_threshold=100000,
|
|
1173
|
+
wcs=None,
|
|
1174
|
+
):
|
|
1175
|
+
"""
|
|
1176
|
+
Add stars to an image with realistic PSF.
|
|
1177
|
+
|
|
1178
|
+
Parameters
|
|
1179
|
+
----------
|
|
1180
|
+
image : numpy.ndarray
|
|
1181
|
+
Target image (modified in-place).
|
|
1182
|
+
n : int, optional
|
|
1183
|
+
Number of stars to add.
|
|
1184
|
+
flux_range : tuple, optional
|
|
1185
|
+
(min, max) flux range in ADU.
|
|
1186
|
+
fwhm : float, optional
|
|
1187
|
+
FWHM of the PSF in pixels (used if psf is a string).
|
|
1188
|
+
psf : str or dict, optional
|
|
1189
|
+
PSF specification — either a string ('gaussian', 'moffat') or a PSF model
|
|
1190
|
+
dictionary (PSFEx format).
|
|
1191
|
+
beta : float, optional
|
|
1192
|
+
Moffat beta parameter (only used if psf='moffat').
|
|
1193
|
+
edge : int, optional
|
|
1194
|
+
Minimum distance from image edges.
|
|
1195
|
+
saturation : float, optional
|
|
1196
|
+
Saturation level in ADU.
|
|
1197
|
+
diffraction_spikes : bool, optional
|
|
1198
|
+
If True, add diffraction spikes to bright stars.
|
|
1199
|
+
spike_threshold : float, optional
|
|
1200
|
+
Flux threshold for adding diffraction spikes.
|
|
1201
|
+
optical_ghosts : bool, optional
|
|
1202
|
+
If True, add optical ghosts to very bright stars.
|
|
1203
|
+
ghost_threshold : float, optional
|
|
1204
|
+
Flux threshold for adding optical ghosts.
|
|
1205
|
+
wcs : astropy.wcs.WCS, optional
|
|
1206
|
+
WCS object for computing RA/Dec.
|
|
1207
|
+
|
|
1208
|
+
Returns
|
|
1209
|
+
-------
|
|
1210
|
+
astropy.table.Table
|
|
1211
|
+
Catalog of added stars.
|
|
1212
|
+
|
|
1213
|
+
"""
|
|
1214
|
+
|
|
1215
|
+
from . import pipeline
|
|
1216
|
+
from . import psf as psf_module
|
|
1217
|
+
|
|
1218
|
+
# Generate random star catalog
|
|
1219
|
+
cat = pipeline.make_random_stars(
|
|
1220
|
+
shape=image.shape,
|
|
1221
|
+
nstars=n,
|
|
1222
|
+
minflux=flux_range[0],
|
|
1223
|
+
maxflux=flux_range[1],
|
|
1224
|
+
edge=edge,
|
|
1225
|
+
wcs=wcs,
|
|
1226
|
+
)
|
|
1227
|
+
|
|
1228
|
+
# Determine PSF model
|
|
1229
|
+
if isinstance(psf, str):
|
|
1230
|
+
# Create oversampled PSF model once for all stars
|
|
1231
|
+
psf_model = create_psf_model(fwhm=fwhm, psf_type=psf, beta=beta)
|
|
1232
|
+
psf_type_name = psf
|
|
1233
|
+
elif isinstance(psf, dict):
|
|
1234
|
+
# User provided PSF model (e.g., from PSFEx)
|
|
1235
|
+
psf_model = psf
|
|
1236
|
+
psf_type_name = psf.get('psf_type', 'custom')
|
|
1237
|
+
# Extract FWHM from model if not provided
|
|
1238
|
+
if 'fwhm' in psf_model:
|
|
1239
|
+
fwhm = psf_model['fwhm']
|
|
1240
|
+
else:
|
|
1241
|
+
raise ValueError(
|
|
1242
|
+
"psf parameter must be either a string ('gaussian', 'moffat') or a PSF model dictionary"
|
|
1243
|
+
)
|
|
1244
|
+
|
|
1245
|
+
# Add metadata to catalog
|
|
1246
|
+
cat['psf_type'] = psf_type_name
|
|
1247
|
+
cat['fwhm'] = fwhm
|
|
1248
|
+
cat['type'] = 'star'
|
|
1249
|
+
cat['is_real'] = True
|
|
1250
|
+
|
|
1251
|
+
# Place stars using PSFEx-compatible placement
|
|
1252
|
+
for star in cat:
|
|
1253
|
+
x, y, flux = star['x'], star['y'], star['flux']
|
|
1254
|
+
|
|
1255
|
+
# Use psf.place_psf_stamp for fast, accurate placement with sub-pixel positioning
|
|
1256
|
+
psf_module.place_psf_stamp(image, psf_model, x, y, flux=flux)
|
|
1257
|
+
|
|
1258
|
+
# Add diffraction spikes if enabled and star is bright enough
|
|
1259
|
+
if diffraction_spikes and flux > spike_threshold:
|
|
1260
|
+
# Need to create spike stamp and add it manually
|
|
1261
|
+
size = int(np.ceil(fwhm * 6)) * 2 + 1
|
|
1262
|
+
ix, iy = int(np.round(x)), int(np.round(y))
|
|
1263
|
+
dx_sub = x - ix
|
|
1264
|
+
dy_sub = y - iy
|
|
1265
|
+
x0_spike = size // 2 + dx_sub
|
|
1266
|
+
y0_spike = size // 2 + dy_sub
|
|
1267
|
+
|
|
1268
|
+
spikes = create_diffraction_spikes(size, x0_spike, y0_spike, flux)
|
|
1269
|
+
|
|
1270
|
+
# Place spikes on image
|
|
1271
|
+
y_grid, x_grid = np.mgrid[0 : spikes.shape[0], 0 : spikes.shape[1]]
|
|
1272
|
+
y1, x1 = np.mgrid[0 : spikes.shape[0], 0 : spikes.shape[1]]
|
|
1273
|
+
x1 += ix - np.floor(spikes.shape[1] // 2)
|
|
1274
|
+
y1 += iy - np.floor(spikes.shape[0] // 2)
|
|
1275
|
+
|
|
1276
|
+
idx = (x1 >= 0) & (x1 < image.shape[1])
|
|
1277
|
+
idx &= (y1 >= 0) & (y1 < image.shape[0])
|
|
1278
|
+
|
|
1279
|
+
image[y1[idx].astype(int), x1[idx].astype(int)] += spikes[y_grid[idx], x_grid[idx]]
|
|
1280
|
+
|
|
1281
|
+
# Add optical ghost if enabled and star is very bright
|
|
1282
|
+
if optical_ghosts and flux > ghost_threshold:
|
|
1283
|
+
size = int(np.ceil(fwhm * 6)) * 2 + 1
|
|
1284
|
+
ix, iy = int(np.round(x)), int(np.round(y))
|
|
1285
|
+
dx_sub = x - ix
|
|
1286
|
+
dy_sub = y - iy
|
|
1287
|
+
x0_ghost = size // 2 + dx_sub
|
|
1288
|
+
y0_ghost = size // 2 + dy_sub
|
|
1289
|
+
|
|
1290
|
+
ghost_result = create_optical_ghost(size, x0_ghost, y0_ghost, flux)
|
|
1291
|
+
ghost = ghost_result['stamp']
|
|
1292
|
+
src_x, src_y = ghost_result['source_pos']
|
|
1293
|
+
|
|
1294
|
+
# Place ghost on image
|
|
1295
|
+
# The source position within the stamp (src_x, src_y) should align
|
|
1296
|
+
# with the star position in the image (ix, iy)
|
|
1297
|
+
y_grid, x_grid = np.mgrid[0 : ghost.shape[0], 0 : ghost.shape[1]]
|
|
1298
|
+
y1, x1 = np.mgrid[0 : ghost.shape[0], 0 : ghost.shape[1]]
|
|
1299
|
+
|
|
1300
|
+
# Offset so that stamp position (src_x, src_y) maps to image position (ix, iy)
|
|
1301
|
+
x1 += ix - int(np.round(src_x))
|
|
1302
|
+
y1 += iy - int(np.round(src_y))
|
|
1303
|
+
|
|
1304
|
+
idx = (x1 >= 0) & (x1 < image.shape[1])
|
|
1305
|
+
idx &= (y1 >= 0) & (y1 < image.shape[0])
|
|
1306
|
+
|
|
1307
|
+
image[y1[idx].astype(int), x1[idx].astype(int)] += ghost[y_grid[idx], x_grid[idx]]
|
|
1308
|
+
|
|
1309
|
+
# Apply saturation
|
|
1310
|
+
if saturation is not None:
|
|
1311
|
+
image[image > saturation] = saturation
|
|
1312
|
+
|
|
1313
|
+
return cat
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
def add_galaxies(
|
|
1317
|
+
image,
|
|
1318
|
+
n=20,
|
|
1319
|
+
flux_range=(500, 5000),
|
|
1320
|
+
r_eff_range=(3, 15),
|
|
1321
|
+
n_range=(0.5, 4.0),
|
|
1322
|
+
ellipticity_range=(0.0, 0.7),
|
|
1323
|
+
edge=0,
|
|
1324
|
+
wcs=None,
|
|
1325
|
+
):
|
|
1326
|
+
"""
|
|
1327
|
+
Add galaxies with Sersic profiles to an image.
|
|
1328
|
+
|
|
1329
|
+
Parameters
|
|
1330
|
+
----------
|
|
1331
|
+
image : numpy.ndarray
|
|
1332
|
+
Target image (modified in-place).
|
|
1333
|
+
n : int, optional
|
|
1334
|
+
Number of galaxies to add.
|
|
1335
|
+
flux_range : tuple, optional
|
|
1336
|
+
(min, max) total flux in ADU.
|
|
1337
|
+
r_eff_range : tuple, optional
|
|
1338
|
+
(min, max) effective radius in pixels.
|
|
1339
|
+
n_range : tuple, optional
|
|
1340
|
+
(min, max) Sersic index.
|
|
1341
|
+
ellipticity_range : tuple, optional
|
|
1342
|
+
(min, max) ellipticity.
|
|
1343
|
+
edge : int, optional
|
|
1344
|
+
Minimum distance from image edges.
|
|
1345
|
+
wcs : astropy.wcs.WCS, optional
|
|
1346
|
+
WCS object for computing RA/Dec.
|
|
1347
|
+
|
|
1348
|
+
Returns
|
|
1349
|
+
-------
|
|
1350
|
+
astropy.table.Table
|
|
1351
|
+
Catalog of added galaxies.
|
|
1352
|
+
|
|
1353
|
+
"""
|
|
1354
|
+
|
|
1355
|
+
height, width = image.shape
|
|
1356
|
+
galaxies = []
|
|
1357
|
+
|
|
1358
|
+
for i in range(n):
|
|
1359
|
+
# Random parameters
|
|
1360
|
+
x = np.random.uniform(edge, width - 1 - edge)
|
|
1361
|
+
y = np.random.uniform(edge, height - 1 - edge)
|
|
1362
|
+
flux = 10 ** np.random.uniform(np.log10(flux_range[0]), np.log10(flux_range[1]))
|
|
1363
|
+
r_eff = np.random.uniform(*r_eff_range)
|
|
1364
|
+
sersic_n = np.random.uniform(*n_range)
|
|
1365
|
+
ellipticity = np.random.uniform(*ellipticity_range)
|
|
1366
|
+
position_angle = np.random.uniform(0, 180)
|
|
1367
|
+
|
|
1368
|
+
# Place galaxy
|
|
1369
|
+
place_galaxy(image, x, y, flux, r_eff, sersic_n, ellipticity, position_angle)
|
|
1370
|
+
|
|
1371
|
+
# Catalog entry
|
|
1372
|
+
galaxy = {
|
|
1373
|
+
'x': x,
|
|
1374
|
+
'y': y,
|
|
1375
|
+
'flux': flux,
|
|
1376
|
+
'r_eff': r_eff,
|
|
1377
|
+
'sersic_n': sersic_n,
|
|
1378
|
+
'ellipticity': ellipticity,
|
|
1379
|
+
'position_angle': position_angle,
|
|
1380
|
+
'type': 'galaxy',
|
|
1381
|
+
'is_real': True,
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
if wcs is not None and wcs.celestial:
|
|
1385
|
+
galaxy['ra'], galaxy['dec'] = wcs.all_pix2world(x, y, 0)
|
|
1386
|
+
else:
|
|
1387
|
+
galaxy['ra'], galaxy['dec'] = np.nan, np.nan
|
|
1388
|
+
|
|
1389
|
+
galaxies.append(galaxy)
|
|
1390
|
+
|
|
1391
|
+
return Table(galaxies)
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
def simulate_image(
|
|
1395
|
+
width,
|
|
1396
|
+
height,
|
|
1397
|
+
n_stars=100,
|
|
1398
|
+
star_flux_range=(100, 10000),
|
|
1399
|
+
star_fwhm=3.0,
|
|
1400
|
+
star_psf='gaussian',
|
|
1401
|
+
star_beta=2.5,
|
|
1402
|
+
n_galaxies=20,
|
|
1403
|
+
galaxy_flux_range=(500, 5000),
|
|
1404
|
+
galaxy_r_eff_range=(3, 15),
|
|
1405
|
+
galaxy_n_range=(0.5, 4.0),
|
|
1406
|
+
galaxy_ellipticity_range=(0.0, 0.7),
|
|
1407
|
+
n_cosmic_rays=5,
|
|
1408
|
+
cosmic_ray_length_range=(5, 50),
|
|
1409
|
+
cosmic_ray_width_range=(1, 3),
|
|
1410
|
+
cosmic_ray_intensity_range=(1000, 10000),
|
|
1411
|
+
cosmic_ray_profile='worm',
|
|
1412
|
+
n_hot_pixels=10,
|
|
1413
|
+
hot_pixel_intensity_range=(500, 5000),
|
|
1414
|
+
n_bad_columns=0,
|
|
1415
|
+
bad_column_type='dead',
|
|
1416
|
+
n_satellites=0,
|
|
1417
|
+
satellite_length_range=(100, 400),
|
|
1418
|
+
satellite_width_range=(2, 8),
|
|
1419
|
+
satellite_intensity_range=(5000, 20000),
|
|
1420
|
+
diffraction_spikes=False,
|
|
1421
|
+
spike_threshold=50000,
|
|
1422
|
+
optical_ghosts=False,
|
|
1423
|
+
ghost_threshold=100000,
|
|
1424
|
+
add_companions=False,
|
|
1425
|
+
companion_fraction=0.2,
|
|
1426
|
+
companion_separation_fwhm=(1.0, 3.0),
|
|
1427
|
+
companion_flux_ratio=(0.5, 1.5),
|
|
1428
|
+
background=1000.0,
|
|
1429
|
+
readnoise=10.0,
|
|
1430
|
+
gain=1.0,
|
|
1431
|
+
edge=10,
|
|
1432
|
+
wcs=None,
|
|
1433
|
+
return_catalog=True,
|
|
1434
|
+
return_masks=False,
|
|
1435
|
+
seed=None,
|
|
1436
|
+
verbose=False,
|
|
1437
|
+
):
|
|
1438
|
+
"""
|
|
1439
|
+
Simulate a realistic astronomical image with stars, galaxies, and contaminants.
|
|
1440
|
+
|
|
1441
|
+
This is the high-level API that creates a complete simulated image in one call.
|
|
1442
|
+
|
|
1443
|
+
Parameters
|
|
1444
|
+
----------
|
|
1445
|
+
width : int, optional
|
|
1446
|
+
Image width in pixels.
|
|
1447
|
+
height : int, optional
|
|
1448
|
+
Image height in pixels.
|
|
1449
|
+
n_stars : int, optional
|
|
1450
|
+
Number of stars to add.
|
|
1451
|
+
star_flux_range : tuple, optional
|
|
1452
|
+
(min, max) star flux in ADU.
|
|
1453
|
+
star_fwhm : float, optional
|
|
1454
|
+
FWHM of stellar PSF in pixels.
|
|
1455
|
+
star_psf : str or dict, optional
|
|
1456
|
+
PSF type — either a string ('gaussian', 'moffat') or a PSF model dict
|
|
1457
|
+
(from create_psf_model). Use dict to specify aberrated PSFs.
|
|
1458
|
+
star_beta : float, optional
|
|
1459
|
+
Moffat beta parameter (if star_psf='moffat').
|
|
1460
|
+
n_galaxies : int, optional
|
|
1461
|
+
Number of galaxies to add.
|
|
1462
|
+
galaxy_flux_range : tuple, optional
|
|
1463
|
+
(min, max) galaxy flux in ADU.
|
|
1464
|
+
galaxy_r_eff_range : tuple, optional
|
|
1465
|
+
(min, max) effective radius in pixels.
|
|
1466
|
+
galaxy_n_range : tuple, optional
|
|
1467
|
+
(min, max) Sersic index.
|
|
1468
|
+
galaxy_ellipticity_range : tuple, optional
|
|
1469
|
+
(min, max) ellipticity.
|
|
1470
|
+
n_cosmic_rays : int, optional
|
|
1471
|
+
Number of cosmic ray tracks.
|
|
1472
|
+
cosmic_ray_length_range : tuple, optional
|
|
1473
|
+
(min, max) cosmic ray length in pixels.
|
|
1474
|
+
cosmic_ray_width_range : tuple, optional
|
|
1475
|
+
(min, max) cosmic ray width in pixels.
|
|
1476
|
+
cosmic_ray_intensity_range : tuple, optional
|
|
1477
|
+
(min, max) cosmic ray peak intensity.
|
|
1478
|
+
cosmic_ray_profile : str, optional
|
|
1479
|
+
Cosmic ray profile ('sharp', 'tapered', 'worm').
|
|
1480
|
+
n_hot_pixels : int, optional
|
|
1481
|
+
Number of hot pixels.
|
|
1482
|
+
hot_pixel_intensity_range : tuple, optional
|
|
1483
|
+
(min, max) hot pixel intensity.
|
|
1484
|
+
n_bad_columns : int, optional
|
|
1485
|
+
Number of bad columns.
|
|
1486
|
+
bad_column_type : str, optional
|
|
1487
|
+
Type of bad columns ('dead', 'hot', 'noisy').
|
|
1488
|
+
n_satellites : int, optional
|
|
1489
|
+
Number of satellite trails.
|
|
1490
|
+
satellite_length_range : tuple, optional
|
|
1491
|
+
(min, max) satellite trail length in pixels.
|
|
1492
|
+
satellite_width_range : tuple, optional
|
|
1493
|
+
(min, max) satellite trail width in pixels.
|
|
1494
|
+
satellite_intensity_range : tuple, optional
|
|
1495
|
+
(min, max) satellite trail intensity.
|
|
1496
|
+
diffraction_spikes : bool, optional
|
|
1497
|
+
Add diffraction spikes to bright stars.
|
|
1498
|
+
spike_threshold : float, optional
|
|
1499
|
+
Flux threshold for diffraction spikes.
|
|
1500
|
+
optical_ghosts : bool, optional
|
|
1501
|
+
Add optical ghosts to very bright stars.
|
|
1502
|
+
ghost_threshold : float, optional
|
|
1503
|
+
Flux threshold for optical ghosts.
|
|
1504
|
+
add_companions : bool, optional
|
|
1505
|
+
If True, add close stellar companions to simulate crowded fields.
|
|
1506
|
+
companion_fraction : float, optional
|
|
1507
|
+
Fraction of stars (0-1) that will have companions.
|
|
1508
|
+
companion_separation_fwhm : tuple, optional
|
|
1509
|
+
(min, max) companion separation in FWHM units.
|
|
1510
|
+
companion_flux_ratio : tuple, optional
|
|
1511
|
+
(min, max) companion flux relative to primary star.
|
|
1512
|
+
background : float, optional
|
|
1513
|
+
Background level in ADU.
|
|
1514
|
+
readnoise : float, optional
|
|
1515
|
+
Read noise in ADU.
|
|
1516
|
+
gain : float, optional
|
|
1517
|
+
Detector gain in e-/ADU.
|
|
1518
|
+
edge : int, optional
|
|
1519
|
+
Minimum distance from image edges for sources.
|
|
1520
|
+
wcs : astropy.wcs.WCS, optional
|
|
1521
|
+
WCS object for computing sky coordinates.
|
|
1522
|
+
return_catalog : bool, optional
|
|
1523
|
+
If True, return catalog of all injected sources.
|
|
1524
|
+
return_masks : bool, optional
|
|
1525
|
+
If True, return separate masks for each artifact type.
|
|
1526
|
+
seed : int, optional
|
|
1527
|
+
Random seed for reproducibility. If set, calls np.random.seed(seed).
|
|
1528
|
+
verbose : bool, optional
|
|
1529
|
+
Enable verbose output.
|
|
1530
|
+
|
|
1531
|
+
Returns
|
|
1532
|
+
-------
|
|
1533
|
+
dict
|
|
1534
|
+
Dictionary with 'image', 'catalog' (if requested), 'masks' (if requested),
|
|
1535
|
+
'background', 'noise'.
|
|
1536
|
+
|
|
1537
|
+
"""
|
|
1538
|
+
|
|
1539
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
1540
|
+
|
|
1541
|
+
if seed is not None:
|
|
1542
|
+
np.random.seed(seed)
|
|
1543
|
+
|
|
1544
|
+
log(f"Simulating {width}x{height} image with {n_stars} stars and {n_galaxies} galaxies")
|
|
1545
|
+
|
|
1546
|
+
# Initialize image with background
|
|
1547
|
+
image = np.ones((height, width), dtype=float) * background
|
|
1548
|
+
|
|
1549
|
+
# Collect all catalogs
|
|
1550
|
+
catalogs = []
|
|
1551
|
+
|
|
1552
|
+
# Add stars
|
|
1553
|
+
if n_stars > 0:
|
|
1554
|
+
log(f"Adding {n_stars} stars...")
|
|
1555
|
+
|
|
1556
|
+
# Resolve effective FWHM: if star_psf is a dict with its own fwhm, use that
|
|
1557
|
+
effective_fwhm = star_fwhm
|
|
1558
|
+
if isinstance(star_psf, dict) and 'fwhm' in star_psf:
|
|
1559
|
+
effective_fwhm = star_psf['fwhm']
|
|
1560
|
+
|
|
1561
|
+
# Add companions to catalog before placement if requested
|
|
1562
|
+
if add_companions:
|
|
1563
|
+
# First add the base stars
|
|
1564
|
+
cat_stars = add_stars(
|
|
1565
|
+
image,
|
|
1566
|
+
n=n_stars,
|
|
1567
|
+
flux_range=star_flux_range,
|
|
1568
|
+
fwhm=star_fwhm,
|
|
1569
|
+
psf=star_psf,
|
|
1570
|
+
beta=star_beta,
|
|
1571
|
+
edge=edge,
|
|
1572
|
+
diffraction_spikes=diffraction_spikes,
|
|
1573
|
+
spike_threshold=spike_threshold,
|
|
1574
|
+
optical_ghosts=optical_ghosts,
|
|
1575
|
+
ghost_threshold=ghost_threshold,
|
|
1576
|
+
wcs=wcs,
|
|
1577
|
+
)
|
|
1578
|
+
|
|
1579
|
+
# Get number of companions before adding
|
|
1580
|
+
n_before = len(cat_stars)
|
|
1581
|
+
|
|
1582
|
+
# Add companions to catalog
|
|
1583
|
+
cat_stars_with_companions = add_close_companions_to_catalog(
|
|
1584
|
+
cat_stars,
|
|
1585
|
+
fwhm=effective_fwhm,
|
|
1586
|
+
fraction=companion_fraction,
|
|
1587
|
+
min_separation_fwhm=companion_separation_fwhm[0],
|
|
1588
|
+
max_separation_fwhm=companion_separation_fwhm[1],
|
|
1589
|
+
flux_variation=companion_flux_ratio,
|
|
1590
|
+
image_shape=(height, width),
|
|
1591
|
+
edge=edge,
|
|
1592
|
+
)
|
|
1593
|
+
|
|
1594
|
+
n_companions = len(cat_stars_with_companions) - n_before
|
|
1595
|
+
if n_companions > 0:
|
|
1596
|
+
log(f" Added {n_companions} close companions")
|
|
1597
|
+
|
|
1598
|
+
# Place only the companion stars (primaries already placed)
|
|
1599
|
+
# Determine PSF model for companions
|
|
1600
|
+
if isinstance(star_psf, str):
|
|
1601
|
+
psf_model = create_psf_model(fwhm=star_fwhm, psf_type=star_psf, beta=star_beta)
|
|
1602
|
+
else:
|
|
1603
|
+
psf_model = star_psf
|
|
1604
|
+
|
|
1605
|
+
# Place companions
|
|
1606
|
+
from . import psf as psf_module
|
|
1607
|
+
|
|
1608
|
+
for companion in cat_stars_with_companions[n_before:]:
|
|
1609
|
+
psf_module.place_psf_stamp(
|
|
1610
|
+
image, psf_model, companion['x'], companion['y'], flux=companion['flux']
|
|
1611
|
+
)
|
|
1612
|
+
|
|
1613
|
+
cat_stars = cat_stars_with_companions
|
|
1614
|
+
else:
|
|
1615
|
+
# No companions - use standard add_stars
|
|
1616
|
+
cat_stars = add_stars(
|
|
1617
|
+
image,
|
|
1618
|
+
n=n_stars,
|
|
1619
|
+
flux_range=star_flux_range,
|
|
1620
|
+
fwhm=star_fwhm,
|
|
1621
|
+
psf=star_psf,
|
|
1622
|
+
beta=star_beta,
|
|
1623
|
+
edge=edge,
|
|
1624
|
+
diffraction_spikes=diffraction_spikes,
|
|
1625
|
+
spike_threshold=spike_threshold,
|
|
1626
|
+
optical_ghosts=optical_ghosts,
|
|
1627
|
+
ghost_threshold=ghost_threshold,
|
|
1628
|
+
wcs=wcs,
|
|
1629
|
+
)
|
|
1630
|
+
|
|
1631
|
+
catalogs.append(cat_stars)
|
|
1632
|
+
|
|
1633
|
+
# Add galaxies
|
|
1634
|
+
if n_galaxies > 0:
|
|
1635
|
+
log(f"Adding {n_galaxies} galaxies...")
|
|
1636
|
+
cat_galaxies = add_galaxies(
|
|
1637
|
+
image,
|
|
1638
|
+
n=n_galaxies,
|
|
1639
|
+
flux_range=galaxy_flux_range,
|
|
1640
|
+
r_eff_range=galaxy_r_eff_range,
|
|
1641
|
+
n_range=galaxy_n_range,
|
|
1642
|
+
ellipticity_range=galaxy_ellipticity_range,
|
|
1643
|
+
edge=edge,
|
|
1644
|
+
wcs=wcs,
|
|
1645
|
+
)
|
|
1646
|
+
catalogs.append(cat_galaxies)
|
|
1647
|
+
|
|
1648
|
+
# Add cosmic rays
|
|
1649
|
+
if n_cosmic_rays > 0:
|
|
1650
|
+
log(f"Adding {n_cosmic_rays} cosmic rays...")
|
|
1651
|
+
cat_cosmic = add_cosmic_rays(
|
|
1652
|
+
image,
|
|
1653
|
+
n_rays=n_cosmic_rays,
|
|
1654
|
+
length_range=cosmic_ray_length_range,
|
|
1655
|
+
width_range=cosmic_ray_width_range,
|
|
1656
|
+
intensity_range=cosmic_ray_intensity_range,
|
|
1657
|
+
profile=cosmic_ray_profile,
|
|
1658
|
+
)
|
|
1659
|
+
catalogs.append(cat_cosmic)
|
|
1660
|
+
|
|
1661
|
+
# Add hot pixels
|
|
1662
|
+
if n_hot_pixels > 0:
|
|
1663
|
+
log(f"Adding {n_hot_pixels} hot pixels...")
|
|
1664
|
+
cat_hot = add_hot_pixels(
|
|
1665
|
+
image, n_pixels=n_hot_pixels, intensity_range=hot_pixel_intensity_range
|
|
1666
|
+
)
|
|
1667
|
+
catalogs.append(cat_hot)
|
|
1668
|
+
|
|
1669
|
+
# Add bad columns
|
|
1670
|
+
if n_bad_columns > 0:
|
|
1671
|
+
log(f"Adding {n_bad_columns} bad columns...")
|
|
1672
|
+
cat_bad = add_bad_columns(image, n_columns=n_bad_columns, bad_type=bad_column_type)
|
|
1673
|
+
catalogs.append(cat_bad)
|
|
1674
|
+
|
|
1675
|
+
# Add satellite trails
|
|
1676
|
+
if n_satellites > 0:
|
|
1677
|
+
log(f"Adding {n_satellites} satellite trails...")
|
|
1678
|
+
cat_satellites = add_satellite_trails(
|
|
1679
|
+
image,
|
|
1680
|
+
n_trails=n_satellites,
|
|
1681
|
+
length_range=satellite_length_range,
|
|
1682
|
+
width_range=satellite_width_range,
|
|
1683
|
+
intensity_range=satellite_intensity_range,
|
|
1684
|
+
)
|
|
1685
|
+
catalogs.append(cat_satellites)
|
|
1686
|
+
|
|
1687
|
+
# Apply Poisson noise to entire image (sources + background) in one shot.
|
|
1688
|
+
# Physically correct: noise from photon counting on total signal per pixel.
|
|
1689
|
+
log("Adding noise...")
|
|
1690
|
+
if gain is not None and gain > 0:
|
|
1691
|
+
image_e = image * gain
|
|
1692
|
+
idx = image_e > 0
|
|
1693
|
+
image_e[idx] = np.random.poisson(image_e[idx].astype(np.float64))
|
|
1694
|
+
image = image_e / gain
|
|
1695
|
+
|
|
1696
|
+
# Add readnoise (Gaussian, after Poisson — correct physical order)
|
|
1697
|
+
noise_map = np.random.normal(0, readnoise, (height, width))
|
|
1698
|
+
image += noise_map
|
|
1699
|
+
|
|
1700
|
+
# Prepare output
|
|
1701
|
+
result = {
|
|
1702
|
+
'image': image,
|
|
1703
|
+
'background': np.ones((height, width)) * background,
|
|
1704
|
+
'noise': noise_map,
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
# Combine catalogs
|
|
1708
|
+
if return_catalog and len(catalogs) > 0:
|
|
1709
|
+
from astropy.table import vstack
|
|
1710
|
+
|
|
1711
|
+
# Stack all catalogs
|
|
1712
|
+
combined_cat = vstack(catalogs, join_type='outer')
|
|
1713
|
+
result['catalog'] = combined_cat
|
|
1714
|
+
|
|
1715
|
+
if return_masks:
|
|
1716
|
+
# Create separate masks for each artifact type
|
|
1717
|
+
# This would require tracking which pixels were modified by each artifact
|
|
1718
|
+
# For now, return empty dict (can be implemented later if needed)
|
|
1719
|
+
result['masks'] = {}
|
|
1720
|
+
|
|
1721
|
+
log("Simulation complete")
|
|
1722
|
+
|
|
1723
|
+
return result
|
|
1724
|
+
|
|
1725
|
+
|
|
1726
|
+
def generate_realbogus_training_data(
|
|
1727
|
+
n_images=100,
|
|
1728
|
+
image_size=(2048, 2048),
|
|
1729
|
+
n_stars_range=(50, 200),
|
|
1730
|
+
n_galaxies_range=(10, 50),
|
|
1731
|
+
fwhm_range=(1.5, 8.0),
|
|
1732
|
+
background_range=(100, 10000),
|
|
1733
|
+
n_cosmic_rays_range=(5, 20),
|
|
1734
|
+
n_hot_pixels_range=(5, 30),
|
|
1735
|
+
n_satellites_range=(0, 2),
|
|
1736
|
+
detection_threshold=3.0,
|
|
1737
|
+
match_radius=3.0,
|
|
1738
|
+
cutout_radius=15,
|
|
1739
|
+
augment=True,
|
|
1740
|
+
real_source_types=['star'],
|
|
1741
|
+
close_pair_fraction=0.2,
|
|
1742
|
+
min_separation_fwhm=1.0,
|
|
1743
|
+
max_separation_fwhm=3.0,
|
|
1744
|
+
aberration_fraction=0.0,
|
|
1745
|
+
defocus_range=(0.0, 2.0),
|
|
1746
|
+
astigmatism_range=(0.0, 1.5),
|
|
1747
|
+
coma_range=(0.0, 1.0),
|
|
1748
|
+
readnoise_range=(5.0, 20.0),
|
|
1749
|
+
gain_range=(0.5, 2.0),
|
|
1750
|
+
asinh_softening=None,
|
|
1751
|
+
seed=None,
|
|
1752
|
+
verbose=False,
|
|
1753
|
+
):
|
|
1754
|
+
"""
|
|
1755
|
+
Generate labeled training data for real-bogus classifier.
|
|
1756
|
+
|
|
1757
|
+
This function creates a dataset of labeled cutouts by:
|
|
1758
|
+
1. Simulating images with known source positions
|
|
1759
|
+
2. Running object detection
|
|
1760
|
+
3. Matching detections to truth catalog
|
|
1761
|
+
4. Labeling matched detections as 'real', others as 'bogus'
|
|
1762
|
+
5. Extracting and preprocessing cutouts
|
|
1763
|
+
6. Optionally applying data augmentation
|
|
1764
|
+
|
|
1765
|
+
Flux ranges are automatically calculated for each image based on the background
|
|
1766
|
+
noise level to ensure sources are detectable:
|
|
1767
|
+
- Minimum flux = detection_threshold × noise (e.g., 3σ for detectability)
|
|
1768
|
+
- Maximum flux = 1000 × noise (bright sources)
|
|
1769
|
+
This prevents generating sources that are too faint to detect or unrealistically bright.
|
|
1770
|
+
|
|
1771
|
+
Close pairs are automatically added to ensure the classifier learns to accept
|
|
1772
|
+
crowded sources. A fraction of stars will have stellar companions added at
|
|
1773
|
+
controlled separations (e.g., 1-3 FWHM). Companions are only added to stars,
|
|
1774
|
+
not galaxies.
|
|
1775
|
+
|
|
1776
|
+
PSF diversity can be included in training data by setting aberration_fraction > 0.
|
|
1777
|
+
A fraction of images will use aberrated PSFs with randomized Zernike coefficients
|
|
1778
|
+
(defocus, astigmatism, coma) computed via Fourier optics, helping the classifier
|
|
1779
|
+
handle realistic PSF variations from optical aberrations.
|
|
1780
|
+
|
|
1781
|
+
Parameters
|
|
1782
|
+
----------
|
|
1783
|
+
n_images : int, optional
|
|
1784
|
+
Number of simulated images to generate.
|
|
1785
|
+
image_size : tuple, optional
|
|
1786
|
+
(width, height) of simulated images.
|
|
1787
|
+
n_stars_range : tuple, optional
|
|
1788
|
+
(min, max) number of stars per image.
|
|
1789
|
+
n_galaxies_range : tuple, optional
|
|
1790
|
+
(min, max) number of galaxies per image.
|
|
1791
|
+
fwhm_range : tuple, optional
|
|
1792
|
+
(min, max) FWHM in pixels (varied per image).
|
|
1793
|
+
background_range : tuple, optional
|
|
1794
|
+
(min, max) background level in ADU.
|
|
1795
|
+
n_cosmic_rays_range : tuple, optional
|
|
1796
|
+
(min, max) cosmic rays per image.
|
|
1797
|
+
n_hot_pixels_range : tuple, optional
|
|
1798
|
+
(min, max) hot pixels per image.
|
|
1799
|
+
n_satellites_range : tuple, optional
|
|
1800
|
+
(min, max) satellite trails per image.
|
|
1801
|
+
detection_threshold : float, optional
|
|
1802
|
+
Detection threshold in sigma (also used for min flux calculation).
|
|
1803
|
+
match_radius : float, optional
|
|
1804
|
+
Matching radius in pixels for truth matching.
|
|
1805
|
+
cutout_radius : int, optional
|
|
1806
|
+
Cutout radius in pixels.
|
|
1807
|
+
augment : bool, optional
|
|
1808
|
+
Apply data augmentation (rotations, flips).
|
|
1809
|
+
real_source_types : list, optional
|
|
1810
|
+
List of source types to consider 'real'. Default: ['star'] treats only stars
|
|
1811
|
+
as real and galaxies as bogus. Use ['star', 'galaxy'] to treat both as real.
|
|
1812
|
+
close_pair_fraction : float, optional
|
|
1813
|
+
Fraction of sources (0-1) that will have close companions added.
|
|
1814
|
+
Default: 0.2 (20% of sources get companions).
|
|
1815
|
+
min_separation_fwhm : float, optional
|
|
1816
|
+
Minimum separation for companions in FWHM units. Default: 1.0 (1x FWHM).
|
|
1817
|
+
max_separation_fwhm : float, optional
|
|
1818
|
+
Maximum separation for companions in FWHM units. Default: 3.0 (3x FWHM).
|
|
1819
|
+
aberration_fraction : float, optional
|
|
1820
|
+
Fraction of images (0-1) with aberrated PSFs. Default: 0.0 (all Gaussian).
|
|
1821
|
+
When > 0 and aberration ranges have non-zero max values, uses Fourier optics
|
|
1822
|
+
with Zernike polynomials. Otherwise falls back to Moffat PSFs as a simpler proxy.
|
|
1823
|
+
defocus_range : tuple, optional
|
|
1824
|
+
(min, max) defocus aberration in waves (default: (0.0, 2.0)).
|
|
1825
|
+
astigmatism_range : tuple, optional
|
|
1826
|
+
(min, max) astigmatism aberration in waves (default: (0.0, 1.5)).
|
|
1827
|
+
coma_range : tuple, optional
|
|
1828
|
+
(min, max) coma aberration in waves (default: (0.0, 1.0)).
|
|
1829
|
+
readnoise_range : tuple, optional
|
|
1830
|
+
(min, max) read noise in ADU (randomized per image).
|
|
1831
|
+
gain_range : tuple, optional
|
|
1832
|
+
(min, max) detector gain in e-/ADU (randomized per image).
|
|
1833
|
+
asinh_softening : float, optional
|
|
1834
|
+
Asinh softening in units of background sigma for realbogus preprocessing.
|
|
1835
|
+
If None, uses DEFAULT_ASINH_SOFTENING_SIGMA.
|
|
1836
|
+
seed : int, optional
|
|
1837
|
+
Random seed for reproducibility. If set, calls np.random.seed(seed).
|
|
1838
|
+
verbose : bool, optional
|
|
1839
|
+
Print progress.
|
|
1840
|
+
|
|
1841
|
+
Returns
|
|
1842
|
+
-------
|
|
1843
|
+
dict
|
|
1844
|
+
Dictionary with 'X' (cutouts), 'y' (labels), 'fwhm' (FWHM values), 'metadata'.
|
|
1845
|
+
|
|
1846
|
+
"""
|
|
1847
|
+
from . import photometry
|
|
1848
|
+
from . import realbogus
|
|
1849
|
+
|
|
1850
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
1851
|
+
|
|
1852
|
+
if seed is not None:
|
|
1853
|
+
np.random.seed(seed)
|
|
1854
|
+
|
|
1855
|
+
log(f"Generating training data from {n_images} simulated images...")
|
|
1856
|
+
|
|
1857
|
+
width, height = image_size
|
|
1858
|
+
|
|
1859
|
+
# Containers for training data
|
|
1860
|
+
all_cutouts = []
|
|
1861
|
+
all_labels = []
|
|
1862
|
+
all_metadata = []
|
|
1863
|
+
|
|
1864
|
+
for img_idx in range(n_images):
|
|
1865
|
+
# Randomize parameters for diversity
|
|
1866
|
+
fwhm = np.random.uniform(*fwhm_range)
|
|
1867
|
+
background = 10 ** np.random.uniform(
|
|
1868
|
+
np.log10(background_range[0]), np.log10(background_range[1])
|
|
1869
|
+
)
|
|
1870
|
+
n_stars = np.random.randint(*n_stars_range)
|
|
1871
|
+
n_galaxies = np.random.randint(*n_galaxies_range)
|
|
1872
|
+
n_cosmic_rays = np.random.randint(*n_cosmic_rays_range)
|
|
1873
|
+
n_hot_pixels = np.random.randint(*n_hot_pixels_range)
|
|
1874
|
+
n_satellites = np.random.randint(*n_satellites_range) if n_satellites_range[1] > 0 else 0
|
|
1875
|
+
|
|
1876
|
+
# Randomize readnoise and gain for diversity
|
|
1877
|
+
readnoise = np.random.uniform(*readnoise_range)
|
|
1878
|
+
gain = np.random.uniform(*gain_range)
|
|
1879
|
+
aperture_radius = fwhm
|
|
1880
|
+
n_pix = np.pi * aperture_radius**2
|
|
1881
|
+
|
|
1882
|
+
# Noise in aperture (photon noise + readnoise)
|
|
1883
|
+
noise_aperture = np.sqrt(n_pix * (background / gain + readnoise**2))
|
|
1884
|
+
|
|
1885
|
+
# Set flux ranges based on S/N ratio
|
|
1886
|
+
# min_flux: detectable at threshold (e.g., 3 sigma)
|
|
1887
|
+
# max_flux: very bright sources (1000 sigma)
|
|
1888
|
+
min_sn = detection_threshold
|
|
1889
|
+
max_sn = 1000.0
|
|
1890
|
+
|
|
1891
|
+
star_flux_range = (min_sn * noise_aperture, max_sn * noise_aperture)
|
|
1892
|
+
galaxy_flux_range = (min_sn * noise_aperture, max_sn * noise_aperture)
|
|
1893
|
+
|
|
1894
|
+
# Optionally use aberrated PSF for this image to add diversity
|
|
1895
|
+
psf_type_str = 'gaussian'
|
|
1896
|
+
has_aberration_ranges = (
|
|
1897
|
+
defocus_range[1] > 0 or astigmatism_range[1] > 0 or coma_range[1] > 0
|
|
1898
|
+
)
|
|
1899
|
+
if aberration_fraction > 0 and np.random.random() < aberration_fraction:
|
|
1900
|
+
if has_aberration_ranges:
|
|
1901
|
+
# Use Fourier optics aberrations with random Zernike coefficients
|
|
1902
|
+
defocus = np.random.uniform(*defocus_range) if defocus_range[1] > 0 else 0.0
|
|
1903
|
+
astig_mag = (
|
|
1904
|
+
np.random.uniform(*astigmatism_range) if astigmatism_range[1] > 0 else 0.0
|
|
1905
|
+
)
|
|
1906
|
+
coma_mag = np.random.uniform(*coma_range) if coma_range[1] > 0 else 0.0
|
|
1907
|
+
|
|
1908
|
+
# Random orientation for directional aberrations
|
|
1909
|
+
angle = np.random.uniform(0, 2 * np.pi)
|
|
1910
|
+
astigmatism_x = astig_mag * np.cos(angle)
|
|
1911
|
+
astigmatism_y = astig_mag * np.sin(angle)
|
|
1912
|
+
coma_x = coma_mag * np.cos(angle)
|
|
1913
|
+
coma_y = coma_mag * np.sin(angle)
|
|
1914
|
+
|
|
1915
|
+
star_psf = create_psf_model(
|
|
1916
|
+
fwhm=fwhm,
|
|
1917
|
+
psf_type='gaussian',
|
|
1918
|
+
defocus=defocus,
|
|
1919
|
+
astigmatism_x=astigmatism_x,
|
|
1920
|
+
astigmatism_y=astigmatism_y,
|
|
1921
|
+
coma_x=coma_x,
|
|
1922
|
+
coma_y=coma_y,
|
|
1923
|
+
)
|
|
1924
|
+
psf_type_str = (
|
|
1925
|
+
f'aberrated(def={defocus:.1f},ast={astig_mag:.1f},coma={coma_mag:.1f})'
|
|
1926
|
+
)
|
|
1927
|
+
else:
|
|
1928
|
+
# Fall back to Moffat PSF as simpler proxy for non-ideal optics
|
|
1929
|
+
beta = np.random.uniform(1.5, 4.5)
|
|
1930
|
+
star_psf = create_psf_model(fwhm=fwhm, psf_type='moffat', beta=beta)
|
|
1931
|
+
psf_type_str = f'moffat(beta={beta:.1f})'
|
|
1932
|
+
else:
|
|
1933
|
+
# Use standard Gaussian PSF
|
|
1934
|
+
star_psf = 'gaussian'
|
|
1935
|
+
|
|
1936
|
+
log(
|
|
1937
|
+
f"Image {img_idx + 1}/{n_images}: FWHM={fwhm:.2f}, BG={background:.1f}, "
|
|
1938
|
+
f"stars={n_stars}, gal={n_galaxies}, CR={n_cosmic_rays}, hot={n_hot_pixels}, "
|
|
1939
|
+
f"flux_range={star_flux_range[0]:.0f}-{star_flux_range[1]:.0f}, PSF={psf_type_str}"
|
|
1940
|
+
)
|
|
1941
|
+
|
|
1942
|
+
# Simulate image with companions (cleaner, unified approach)
|
|
1943
|
+
sim = simulate_image(
|
|
1944
|
+
width=width,
|
|
1945
|
+
height=height,
|
|
1946
|
+
n_stars=n_stars,
|
|
1947
|
+
star_flux_range=star_flux_range,
|
|
1948
|
+
star_fwhm=fwhm,
|
|
1949
|
+
star_psf=star_psf, # Can be string or PSF model dict
|
|
1950
|
+
n_galaxies=n_galaxies,
|
|
1951
|
+
galaxy_flux_range=galaxy_flux_range,
|
|
1952
|
+
n_cosmic_rays=n_cosmic_rays,
|
|
1953
|
+
n_hot_pixels=n_hot_pixels,
|
|
1954
|
+
n_satellites=n_satellites,
|
|
1955
|
+
add_companions=close_pair_fraction > 0,
|
|
1956
|
+
companion_fraction=close_pair_fraction,
|
|
1957
|
+
companion_separation_fwhm=(min_separation_fwhm, max_separation_fwhm),
|
|
1958
|
+
companion_flux_ratio=(0.5, 1.5),
|
|
1959
|
+
background=background,
|
|
1960
|
+
readnoise=readnoise,
|
|
1961
|
+
gain=gain,
|
|
1962
|
+
return_catalog=True,
|
|
1963
|
+
verbose=False,
|
|
1964
|
+
)
|
|
1965
|
+
|
|
1966
|
+
image = sim['image']
|
|
1967
|
+
truth_catalog = sim['catalog']
|
|
1968
|
+
|
|
1969
|
+
# Detect objects
|
|
1970
|
+
try:
|
|
1971
|
+
detected = photometry.get_objects_sep(
|
|
1972
|
+
image,
|
|
1973
|
+
thresh=detection_threshold,
|
|
1974
|
+
aper=fwhm,
|
|
1975
|
+
minarea=5,
|
|
1976
|
+
verbose=False,
|
|
1977
|
+
)
|
|
1978
|
+
except Exception as e:
|
|
1979
|
+
log(f"Detection failed for image {img_idx + 1}: {e}")
|
|
1980
|
+
continue
|
|
1981
|
+
|
|
1982
|
+
if len(detected) == 0:
|
|
1983
|
+
log(f"No detections in image {img_idx + 1}, skipping")
|
|
1984
|
+
continue
|
|
1985
|
+
|
|
1986
|
+
# Match detections to truth catalog
|
|
1987
|
+
# Real sources: determined by real_source_types parameter
|
|
1988
|
+
# Artifacts: cosmic rays, hot pixels, satellites (type='cosmic_ray', etc.)
|
|
1989
|
+
|
|
1990
|
+
# Build filter for real sources based on real_source_types
|
|
1991
|
+
real_filter = np.zeros(len(truth_catalog), dtype=bool)
|
|
1992
|
+
for source_type in real_source_types:
|
|
1993
|
+
real_filter |= truth_catalog['type'] == source_type
|
|
1994
|
+
|
|
1995
|
+
truth_real = truth_catalog[real_filter]
|
|
1996
|
+
|
|
1997
|
+
# Simple distance-based matching
|
|
1998
|
+
matched_indices = np.full(len(detected), -1, dtype=int)
|
|
1999
|
+
|
|
2000
|
+
for i, det in enumerate(detected):
|
|
2001
|
+
dx = truth_real['x'] - det['x']
|
|
2002
|
+
dy = truth_real['y'] - det['y']
|
|
2003
|
+
dist = np.sqrt(dx**2 + dy**2)
|
|
2004
|
+
|
|
2005
|
+
if len(dist) > 0 and np.min(dist) < match_radius:
|
|
2006
|
+
matched_indices[i] = 1 # Real source
|
|
2007
|
+
else:
|
|
2008
|
+
matched_indices[i] = 0 # Bogus (artifact or spurious)
|
|
2009
|
+
|
|
2010
|
+
# Count matches
|
|
2011
|
+
n_real = np.sum(matched_indices == 1)
|
|
2012
|
+
n_bogus = np.sum(matched_indices == 0)
|
|
2013
|
+
|
|
2014
|
+
log(f" Matched: {n_real} real, {n_bogus} bogus from {len(detected)} detections")
|
|
2015
|
+
|
|
2016
|
+
if n_real == 0 and n_bogus == 0:
|
|
2017
|
+
continue
|
|
2018
|
+
|
|
2019
|
+
# Extract cutouts
|
|
2020
|
+
try:
|
|
2021
|
+
cutouts, valid_indices = realbogus.extract_cutouts(
|
|
2022
|
+
detected,
|
|
2023
|
+
image,
|
|
2024
|
+
bg=background,
|
|
2025
|
+
radius=cutout_radius,
|
|
2026
|
+
fwhm=fwhm,
|
|
2027
|
+
asinh_softening=asinh_softening,
|
|
2028
|
+
verbose=False,
|
|
2029
|
+
)
|
|
2030
|
+
except Exception as e:
|
|
2031
|
+
log(f"Cutout extraction failed for image {img_idx + 1}: {e}")
|
|
2032
|
+
continue
|
|
2033
|
+
|
|
2034
|
+
# Get labels for valid cutouts
|
|
2035
|
+
labels = matched_indices[valid_indices]
|
|
2036
|
+
|
|
2037
|
+
# Store data
|
|
2038
|
+
all_cutouts.append(cutouts)
|
|
2039
|
+
all_labels.append(labels)
|
|
2040
|
+
|
|
2041
|
+
# Metadata for each cutout
|
|
2042
|
+
metadata = {
|
|
2043
|
+
'image_idx': np.full(len(cutouts), img_idx),
|
|
2044
|
+
'fwhm': np.full(len(cutouts), fwhm),
|
|
2045
|
+
'background': np.full(len(cutouts), background),
|
|
2046
|
+
}
|
|
2047
|
+
all_metadata.append(metadata)
|
|
2048
|
+
|
|
2049
|
+
# Concatenate all data
|
|
2050
|
+
if len(all_cutouts) == 0:
|
|
2051
|
+
raise ValueError("No valid training data generated")
|
|
2052
|
+
|
|
2053
|
+
X = np.concatenate(all_cutouts, axis=0)
|
|
2054
|
+
y = np.concatenate(all_labels, axis=0)
|
|
2055
|
+
|
|
2056
|
+
log(f"Total samples: {len(X)} ({np.sum(y)} real, {len(y) - np.sum(y)} bogus)")
|
|
2057
|
+
|
|
2058
|
+
# Apply data augmentation
|
|
2059
|
+
if augment:
|
|
2060
|
+
log("Applying data augmentation...")
|
|
2061
|
+
X_aug, y_aug = _augment_training_data(X, y, verbose=verbose)
|
|
2062
|
+
log(f"After augmentation: {len(X_aug)} samples")
|
|
2063
|
+
else:
|
|
2064
|
+
X_aug = X
|
|
2065
|
+
y_aug = y
|
|
2066
|
+
|
|
2067
|
+
# Convert labels to binary (ensure proper dtype)
|
|
2068
|
+
y_aug = y_aug.astype(np.float32)
|
|
2069
|
+
|
|
2070
|
+
result = {
|
|
2071
|
+
'X': X_aug,
|
|
2072
|
+
'y': y_aug,
|
|
2073
|
+
'metadata': all_metadata,
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
return result
|
|
2077
|
+
|
|
2078
|
+
|
|
2079
|
+
def _augment_training_data(X, y, augment_factor=8, verbose=False):
|
|
2080
|
+
"""
|
|
2081
|
+
Apply data augmentation to training cutouts.
|
|
2082
|
+
|
|
2083
|
+
Augmentation strategies:
|
|
2084
|
+
- Rotations: 0°, 90°, 180°, 270°
|
|
2085
|
+
- Flips: horizontal, vertical
|
|
2086
|
+
- Combined: rotations + flips (~8-16× augmentation)
|
|
2087
|
+
|
|
2088
|
+
Parameters
|
|
2089
|
+
----------
|
|
2090
|
+
X : numpy.ndarray
|
|
2091
|
+
Input cutouts (N, H, W, C).
|
|
2092
|
+
y : numpy.ndarray
|
|
2093
|
+
Labels (N,).
|
|
2094
|
+
augment_factor : int, optional
|
|
2095
|
+
Target augmentation factor.
|
|
2096
|
+
verbose : bool, optional
|
|
2097
|
+
Print progress.
|
|
2098
|
+
|
|
2099
|
+
Returns
|
|
2100
|
+
-------
|
|
2101
|
+
tuple
|
|
2102
|
+
(X_aug, y_aug) with augmented data.
|
|
2103
|
+
"""
|
|
2104
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
2105
|
+
|
|
2106
|
+
n_samples = len(X)
|
|
2107
|
+
X_list = [X]
|
|
2108
|
+
y_list = [y]
|
|
2109
|
+
|
|
2110
|
+
# Rotation augmentation (90°, 180°, 270°)
|
|
2111
|
+
for angle in [90, 180, 270]:
|
|
2112
|
+
X_list.append(np.rot90(X, k=angle // 90, axes=(1, 2)))
|
|
2113
|
+
y_list.append(y)
|
|
2114
|
+
|
|
2115
|
+
# Flip augmentation
|
|
2116
|
+
if augment_factor >= 8:
|
|
2117
|
+
X_list.append(np.flip(X, axis=2)) # Horizontal flip
|
|
2118
|
+
y_list.append(y)
|
|
2119
|
+
X_list.append(np.flip(X, axis=1)) # Vertical flip
|
|
2120
|
+
y_list.append(y)
|
|
2121
|
+
X_list.append(np.flip(np.flip(X, axis=1), axis=2)) # Both flips
|
|
2122
|
+
y_list.append(y)
|
|
2123
|
+
|
|
2124
|
+
X_aug = np.concatenate(X_list, axis=0)
|
|
2125
|
+
y_aug = np.concatenate(y_list, axis=0)
|
|
2126
|
+
|
|
2127
|
+
log(
|
|
2128
|
+
f"Augmentation: {n_samples} → {len(X_aug)} samples ({len(X_aug) / n_samples:.1f}× increase)"
|
|
2129
|
+
)
|
|
2130
|
+
|
|
2131
|
+
return X_aug, y_aug
|