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/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