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/cutouts.py ADDED
@@ -0,0 +1,570 @@
1
+ """
2
+ Module for cropping the images and creating image cutouts / postage stamps
3
+ """
4
+
5
+ import numpy as np
6
+ import datetime
7
+
8
+ from astropy.wcs import WCS
9
+ from astropy.io import fits
10
+ from astropy.time import Time
11
+
12
+ from scipy.stats import chi2
13
+
14
+ from scipy.optimize import minimize
15
+ from scipy.ndimage import shift
16
+
17
+ from . import utils
18
+ from . import astrometry
19
+
20
+
21
+ def crop_image_centered(data, x0, y0, r0, header=None):
22
+ """Crop a square region centered on a given pixel position.
23
+
24
+ Output size is ``2*ceil(r0) + 1`` pixels. The original center falls
25
+ inside the pixel at ``x, y = ceil(r0), ceil(r0)``. If a FITS header is
26
+ provided, it is adjusted so that the WCS solution remains valid for the
27
+ cutout.
28
+
29
+ Parameters
30
+ ----------
31
+ data : ndarray
32
+ 2D image array to crop.
33
+ x0 : float
34
+ X coordinate of the center (0-based).
35
+ y0 : float
36
+ Y coordinate of the center (0-based).
37
+ r0 : float
38
+ Cutout half-size in pixels.
39
+ header : astropy.io.fits.Header, optional
40
+ FITS header to adjust (``CRPIX`` shifted in-place on a copy).
41
+
42
+ Returns
43
+ -------
44
+ ndarray or tuple
45
+ Cropped image, or ``(cropped_image, adjusted_header)`` if ``header`` is provided.
46
+ """
47
+
48
+ # x1,x2 = int(np.floor(x0 - r0)), int(np.ceil(x0 + r0))
49
+ # y1,y2 = int(np.floor(y0 - r0)), int(np.ceil(y0 + r0))
50
+ x1, x2 = int(np.round(x0) - np.ceil(r0)), int(np.round(x0) + np.ceil(r0))
51
+ y1, y2 = int(np.round(y0) - np.ceil(r0)), int(np.round(y0) + np.ceil(r0))
52
+
53
+ return crop_image(data, x1, y1, x2 - x1 + 1, y2 - y1 + 1, header=header)
54
+
55
+
56
+ def crop_image(data, x1, y1, width, height, header=None):
57
+ """Crop a rectangular region from an image.
58
+
59
+ Pixels outside the original image boundary are filled with zeros (or NaN
60
+ for floating-point images). If a FITS header is provided, ``CRPIX`` is
61
+ adjusted so that the WCS solution remains valid for the cutout.
62
+
63
+ Parameters
64
+ ----------
65
+ data : ndarray
66
+ 2D image array to crop.
67
+ x1 : int
68
+ Left edge of the crop region (0-based).
69
+ y1 : int
70
+ Bottom edge of the crop region (0-based).
71
+ width : int
72
+ Width of the crop region in pixels.
73
+ height : int
74
+ Height of the crop region in pixels.
75
+ header : astropy.io.fits.Header, optional
76
+ FITS header to adjust (copy is modified, original unchanged).
77
+
78
+ Returns
79
+ -------
80
+ ndarray or tuple
81
+ Cropped image, or ``(cropped_image, adjusted_header)`` if ``header`` is provided.
82
+ """
83
+
84
+ x2 = x1 + width
85
+ y2 = y1 + height
86
+
87
+ src = [
88
+ min(max(y1, 0), data.shape[0]),
89
+ max(min(y2, data.shape[0]), 0),
90
+ min(max(x1, 0), data.shape[1]),
91
+ max(min(x2, data.shape[1]), 0),
92
+ ]
93
+
94
+ dst = [src[0] - y1, src[1] - y1, src[2] - x1, src[3] - x1]
95
+
96
+ sub = np.zeros((y2 - y1, x2 - x1), data.dtype)
97
+ if isinstance(data[0][0], np.floating):
98
+ # For floating-point we may use NaN as a filler value
99
+ sub.fill(np.nan)
100
+ sub[dst[0] : dst[1], dst[2] : dst[3]] = data[src[0] : src[1], src[2] : src[3]]
101
+
102
+ if header is not None:
103
+ subheader = header.copy()
104
+
105
+ subheader['NAXIS1'] = sub.shape[1]
106
+ subheader['NAXIS2'] = sub.shape[0]
107
+
108
+ # Adjust the WCS keywords if present
109
+ if 'CRPIX1' in subheader and 'CRPIX2' in subheader:
110
+ subheader['CRPIX1'] -= x1
111
+ subheader['CRPIX2'] -= y1
112
+
113
+ # FIXME: should we use 0-based or 1-based coordinates here?..
114
+
115
+ # Crop position inside original frame
116
+ subheader['CROP_X1'] = x1
117
+ subheader['CROP_X2'] = x2
118
+ subheader['CROP_Y1'] = y1
119
+ subheader['CROP_Y2'] = y2
120
+
121
+ return sub, subheader
122
+ else:
123
+ return sub
124
+
125
+
126
+ def get_cutout(
127
+ image, candidate, radius, header=None, wcs=None, time=None, filename=None, name=None, **kwargs
128
+ ):
129
+ """Create a cutout postage stamp from one or more image planes.
130
+
131
+ The candidate may be a row from :class:`astropy.table.Table` or a dict with
132
+ at least ``x`` and ``y`` keys. The cutout is centered so the object falls
133
+ inside the central pixel; size is ``2*ceil(radius) + 1`` pixels.
134
+
135
+ Parameters
136
+ ----------
137
+ image : ndarray
138
+ Primary (science) image plane.
139
+ candidate : table row or dict
140
+ Object record containing at least ``x`` and ``y`` pixel coordinates.
141
+ radius : float
142
+ Cutout half-size in pixels.
143
+ header : astropy.io.fits.Header, optional
144
+ Header of the original image. Copied and adjusted to represent the
145
+ cutout WCS; stored as ``cutout['header']``.
146
+ wcs : astropy.wcs.WCS, optional
147
+ WCS of the original image. Adjusted and stored as ``cutout['wcs']``.
148
+ Takes precedence over ``header`` for WCS.
149
+ time : astropy.time.Time, datetime, or str, optional
150
+ Observation timestamp; stored in ``cutout['meta']['time']``.
151
+ filename : str, optional
152
+ Source image filename; stored in ``cutout['meta']['filename']``.
153
+ name : str, optional
154
+ Object name; stored in ``cutout['meta']['name']``. If omitted and
155
+ ``candidate`` has ``ra`` / ``dec``, a J2000 name is constructed.
156
+ **kwargs
157
+ Additional 2D arrays interpreted as extra image planes
158
+ (e.g. ``mask``, ``diff``, ``template``, ``convolved``, ``err``).
159
+
160
+ Returns
161
+ -------
162
+ dict
163
+ Cutout dictionary with at least:
164
+
165
+ - ``image`` — primary image plane
166
+ - ``mask``, ``diff``, ``template``, etc. — extra planes if provided
167
+ - ``header`` — adjusted FITS header, if provided
168
+ - ``wcs`` — adjusted WCS, if provided
169
+ - ``meta`` — dict with all candidate fields plus ``time``, ``filename``,
170
+ and ``name``
171
+ """
172
+ x0, y0 = candidate['x'], candidate['y']
173
+
174
+ _ = crop_image_centered(image, x0, y0, radius, header=header)
175
+ if header is not None:
176
+ crop, crophead = _
177
+ else:
178
+ crop, crophead = _, None
179
+
180
+ cutout = {'image': crop, 'meta': {}}
181
+
182
+ if wcs is not None:
183
+ cutout['wcs'] = wcs
184
+
185
+ # Let's update header info with this WCS
186
+ astrometry.clear_wcs(crophead, remove_underscored=True, remove_history=True)
187
+ crophead += wcs.to_header(relax=True)
188
+
189
+ if crophead is not None:
190
+ cutout['header'] = crophead
191
+
192
+ if wcs is None:
193
+ wcs = WCS(crophead)
194
+ cutout['wcs'] = wcs
195
+
196
+ # Image planes
197
+ for pname, plane in kwargs.items():
198
+ if plane is not None and np.ndim(plane) == 2:
199
+ cutout[pname] = crop_image_centered(plane, x0, y0, radius)
200
+
201
+ # Metadata
202
+ for _ in candidate.keys():
203
+ cutout['meta'][_] = candidate[_]
204
+
205
+ # Additional metadata to add or override
206
+ if time is not None:
207
+ cutout['meta']['time'] = Time(time)
208
+
209
+ if filename is not None:
210
+ cutout['meta']['filename'] = filename
211
+
212
+ if name is not None:
213
+ cutout['meta']['name'] = name
214
+ elif (
215
+ 'name' not in cutout['meta']
216
+ and 'ra' in cutout['meta'].keys()
217
+ and 'dec' in cutout['meta'].keys()
218
+ ):
219
+ cutout['meta']['name'] = utils.make_jname(candidate['ra'], candidate['dec'])
220
+
221
+ return cutout
222
+
223
+
224
+ def write_cutout(cutout, filename):
225
+ """Store a cutout as a multi-extension FITS file.
226
+
227
+ Each image plane becomes a named FITS extension; metadata is stored as
228
+ keywords in the primary header.
229
+
230
+ Parameters
231
+ ----------
232
+ cutout : dict
233
+ Cutout structure as returned by :func:`get_cutout`.
234
+ filename : str
235
+ Output FITS filename.
236
+ """
237
+
238
+ hdus = []
239
+
240
+ # Store metadata to primary header
241
+ hdu = fits.PrimaryHDU()
242
+
243
+ for _ in cutout['meta']:
244
+ data = cutout['meta'][_]
245
+ # Special handling for unsupported FITS types
246
+ if data is None:
247
+ data = None
248
+ elif type(data) == Time or type(data) == datetime.datetime:
249
+ data = Time(data).to_value('fits')
250
+ elif not np.isscalar(data):
251
+ # NumPy arrays and like
252
+ data = repr(np.ma.filled(data))
253
+ elif np.isreal(data) and np.isnan(data):
254
+ data = 'NaN'
255
+ elif np.isreal(data) and not np.isfinite(data):
256
+ data = 'Inf'
257
+
258
+ hdu.header[_] = data
259
+
260
+ for _ in [
261
+ 'x',
262
+ 'y',
263
+ 'ra',
264
+ 'dec',
265
+ 'mag',
266
+ 'magerr',
267
+ 'mag_calib',
268
+ 'flags',
269
+ 'id',
270
+ 'time',
271
+ 'filename',
272
+ ]:
273
+ if _ in cutout:
274
+ data = cutout[_]
275
+ # Special handling for unsupported FITS types
276
+ if _ == 'time':
277
+ data = Time(data).to_value('fits')
278
+
279
+ hdu.header[_] = data
280
+
281
+ hdus.append(hdu)
282
+
283
+ # Store imaging data to named extensions
284
+ for _ in cutout.keys():
285
+ if _ not in ['meta', 'header', 'wcs'] and np.ndim(cutout[_]) == 2:
286
+ data = cutout[_]
287
+
288
+ if data.dtype == bool:
289
+ data = data.astype(np.uint16)
290
+
291
+ hdu = fits.ImageHDU(data, header=cutout.get('header'), name=_)
292
+ hdus.append(hdu)
293
+
294
+ fits.HDUList(hdus).writeto(filename, overwrite=True)
295
+
296
+
297
+ def load_cutout(filename):
298
+ """Restore a cutout from a multi-extension FITS file.
299
+
300
+ Parameters
301
+ ----------
302
+ filename : str
303
+ Path to a FITS file written by :func:`write_cutout`.
304
+
305
+ Returns
306
+ -------
307
+ dict
308
+ Cutout structure as returned by :func:`get_cutout`.
309
+ """
310
+
311
+ hdus = fits.open(filename)
312
+
313
+ cutout = {'meta': {}}
314
+
315
+ for _ in hdus[0].header[4:]:
316
+ name = _.lower()
317
+ data = hdus[0].header[_]
318
+
319
+ if name == 'time':
320
+ data = Time(data)
321
+ elif data == 'NaN':
322
+ data = np.nan
323
+ elif data == 'Inf':
324
+ data = np.inf
325
+ elif isinstance(data, str) and data.startswith('array('):
326
+ # FIXME: any safer way to convert it back to array?..
327
+ array = np.array
328
+ data = eval(data)
329
+
330
+ cutout['meta'][name] = data
331
+
332
+ for hdu in hdus[1:]:
333
+ if 'header' not in cutout:
334
+ cutout['header'] = hdu.header
335
+
336
+ name = hdu.name.lower()
337
+
338
+ cutout[name] = hdu.data
339
+
340
+ # Special handling of mask plane
341
+ if name == 'mask':
342
+ cutout[name] = cutout[name].astype(bool)
343
+
344
+ hdus.close()
345
+
346
+ if cutout['header']:
347
+ cutout['wcs'] = WCS(cutout['header'])
348
+
349
+ return cutout
350
+
351
+
352
+ def adjust_cutout(
353
+ cutout,
354
+ max_shift=2,
355
+ max_scale=1.1,
356
+ inner=None,
357
+ normalize=False,
358
+ fit_bg=False,
359
+ verbose=False,
360
+ ):
361
+ """Fit a positional and flux-scaling adjustment to minimize the image–template residual.
362
+
363
+ Optimizes a shift (dx, dy), scale, and optionally background levels to
364
+ minimize the chi-squared residual between ``cutout['image']`` and
365
+ ``cutout['convolved']``. On success, adds a ``cutout['adjusted']`` plane
366
+ with the optimized difference.
367
+
368
+ Parameters
369
+ ----------
370
+ cutout : dict
371
+ Cutout structure as returned by :func:`get_cutout`. Must contain
372
+ ``image``, ``convolved``, and ``err`` planes.
373
+ max_shift : float, optional
374
+ Maximum allowed positional shift in pixels (symmetric bound).
375
+ max_scale : float, optional
376
+ Maximum allowed flux scale factor; scale is bounded to
377
+ ``(1/max_scale, max_scale)``.
378
+ inner : int, optional
379
+ If set, only the central ``inner × inner`` pixel box is used for
380
+ optimization.
381
+ normalize : bool, optional
382
+ If True, divide the ``adjusted`` plane by the ``err`` plane.
383
+ fit_bg : bool, optional
384
+ If True, fit background levels as free parameters. If False, estimate
385
+ backgrounds from the cutout using SExtractor-mode
386
+ (``2.5*median − 1.5*mean``) and hold them fixed.
387
+ verbose : bool or callable, optional
388
+ Whether to show verbose messages. May be boolean or a ``print``-like callable.
389
+
390
+ Returns
391
+ -------
392
+ bool
393
+ ``True`` if optimization succeeded, ``False`` otherwise.
394
+
395
+ Notes
396
+ -----
397
+ On success the following keys are added to ``cutout['meta']``:
398
+ ``adjust_chi2_0``, ``adjust_chi2``, ``adjust_df``, ``adjust_pval``,
399
+ ``adjust_dx``, ``adjust_dy``, ``adjust_scale``, ``adjust_bg``, ``adjust_tbg``.
400
+ """
401
+
402
+ # Simple wrapper around print for logging in verbose mode only
403
+ log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
404
+
405
+ mask = cutout['mask'] if 'mask' in cutout else ~np.isfinite(cutout['image'])
406
+ imask = np.zeros_like(mask)
407
+
408
+ # Rough estimation of backgrounds in the image and the template, using SExtractor-like mode estimation
409
+ # bg = np.nanmedian(cutout['image'][~mask])
410
+ bg = 2.5 * np.nanmedian(cutout['image'][~mask]) - 1.5 * np.nanmean(cutout['image'][~mask])
411
+ # tbg = np.nanmedian(cutout['convolved'][~mask])
412
+ tbg = 2.5 * np.nanmedian(cutout['convolved'][~mask]) - 1.5 * np.nanmean(
413
+ cutout['convolved'][~mask]
414
+ )
415
+
416
+ if inner is not None and inner > 0:
417
+ # Mask everything outside of a central box with given size
418
+ x, y = np.mgrid[0 : mask.shape[1], 0 : mask.shape[0]]
419
+ idx = np.abs(x - mask.shape[1] / 2 + 0.5) > inner / 2
420
+ idx |= np.abs(y - mask.shape[0] / 2 + 0.5) > inner / 2
421
+ imask[idx] = True
422
+
423
+ # Prepare and cleanup the arrays we will use for fitting (as 'shift' does not like nans etc)
424
+ image = cutout['image'].copy()
425
+ tmpl = cutout['convolved'].copy()
426
+ err = cutout['err'].copy()
427
+
428
+ image[~np.isfinite(image)] = 0
429
+ tmpl[~np.isfinite(tmpl)] = 0
430
+ err[~np.isfinite(err)] = 0
431
+
432
+ def _fn(dx, get_df=False, get_diff=False):
433
+ mask1 = mask | shift(mask, dx[:2], mode='reflect')
434
+ imask1 = mask1 | imask
435
+ diff = image - dx[3] - shift(tmpl - dx[4], dx[:2], mode='reflect') * dx[2]
436
+
437
+ chi2_value = np.sum((diff[~imask1] / err[~imask1]) ** 2) # Chi2
438
+
439
+ if get_diff:
440
+ return diff, mask1, imask1
441
+ elif get_df:
442
+ return chi2_value, np.sum(~imask1)
443
+ else:
444
+ return chi2_value
445
+
446
+ if fit_bg:
447
+ res = minimize(
448
+ _fn,
449
+ (0, 0, 1, bg, tbg),
450
+ bounds=(
451
+ (-max_shift, max_shift),
452
+ (-max_shift, max_shift),
453
+ (1 / max_scale, max_scale),
454
+ (None, None),
455
+ (None, None),
456
+ ),
457
+ method='Powell',
458
+ options={'disp': False},
459
+ )
460
+ else:
461
+ res = minimize(
462
+ _fn,
463
+ (0, 0, 1, bg, tbg),
464
+ bounds=(
465
+ (-max_shift, max_shift),
466
+ (-max_shift, max_shift),
467
+ (1 / max_scale, max_scale),
468
+ (bg, bg),
469
+ (tbg, tbg),
470
+ ),
471
+ method='Powell',
472
+ options={'disp': False},
473
+ )
474
+
475
+ log(res.message)
476
+
477
+ if res.success:
478
+ log(
479
+ 'Adjustment is: %.2f %.2f bg %.2g tbg %.2g scale %.2f'
480
+ % (res.x[0], res.x[1], res.x[3], res.x[4], res.x[2])
481
+ )
482
+ log('Chi2 improvement: %.2f -> %.2f' % (_fn([0, 0, 1, bg, tbg]), _fn(res.x)))
483
+
484
+ diff, mask1, _ = _fn(res.x, get_diff=True)
485
+ chi2_0 = _fn([0, 0, 1, bg, tbg])
486
+ chi2_1, df = _fn(res.x, get_df=True)
487
+ log('Final Chi2: %.2f df: %d p-value: %.2g' % (chi2_1, df, chi2.sf(chi2_1, df)))
488
+
489
+ # Keep the adjustment results in the metadata
490
+ cutout['meta']['adjust_chi2_0'] = chi2_0
491
+ cutout['meta']['adjust_chi2'] = chi2_1
492
+ cutout['meta']['adjust_df'] = df
493
+ cutout['meta']['adjust_pval'] = chi2.sf(chi2_1, df)
494
+ cutout['meta']['adjust_dx'] = res.x[0]
495
+ cutout['meta']['adjust_dy'] = res.x[1]
496
+ cutout['meta']['adjust_scale'] = res.x[2]
497
+ cutout['meta']['adjust_bg'] = res.x[3]
498
+ cutout['meta']['adjust_tbg'] = res.x[4]
499
+
500
+ if normalize:
501
+ diff[~mask1] /= err[~mask1]
502
+ diff[err > 1e-30] /= err[err > 1e-30]
503
+
504
+ # diff[mask1] = 1e-30
505
+
506
+ # Add result as a new cutout plane
507
+ cutout['adjusted'] = diff
508
+
509
+ return True
510
+
511
+ else:
512
+ return False
513
+
514
+
515
+ def downscale_image(image, scale=1, mode='sum', header=None):
516
+ """Downscale an image by an integer factor.
517
+
518
+ If the image dimensions are not divisible by ``scale``, the image is
519
+ cropped to the largest divisible size first. If a FITS header is
520
+ provided, the WCS is adjusted for the downscaled pixel grid.
521
+
522
+ Parameters
523
+ ----------
524
+ image : ndarray
525
+ 2D image array to downscale.
526
+ scale : int, optional
527
+ Integer downscaling factor.
528
+ mode : str, optional
529
+ Pixel reduction mode: ``'sum'`` (default), ``'mean'``, ``'and'``, or ``'or'``.
530
+ header : astropy.io.fits.Header, optional
531
+ If provided, the WCS is adjusted and a new header is returned alongside
532
+ the downscaled image.
533
+
534
+ Returns
535
+ -------
536
+ ndarray or tuple
537
+ Downscaled image, or ``(downscaled_image, adjusted_header)`` if
538
+ ``header`` is provided.
539
+ """
540
+
541
+ # Crop the image if necessary
542
+ maxx = scale * (image.shape[1] // scale)
543
+ maxy = scale * (image.shape[0] // scale)
544
+ image = image[:maxy, :maxx]
545
+
546
+ shape = (image.shape[0] // scale, scale, image.shape[1] // scale, scale)
547
+
548
+ image1 = image.reshape(shape)
549
+
550
+ if mode == 'mean':
551
+ image1 = image1.mean(-1).mean(1)
552
+ elif mode == 'and':
553
+ image1 = np.bitwise_and.reduce(image1, -1)
554
+ image1 = np.bitwise_and.reduce(image1, 1)
555
+ elif mode == 'or':
556
+ image1 = np.bitwise_or.reduce(image1, -1)
557
+ image1 = np.bitwise_or.reduce(image1, 1)
558
+ else:
559
+ # Fallback to mode='sum'
560
+ image1 = image1.sum(-1).sum(1)
561
+
562
+ if header is not None:
563
+ wcs = WCS(header)
564
+ wcs = wcs[::scale, ::scale]
565
+ header1 = astrometry.clear_wcs(header, copy=True)
566
+ header1 += wcs.to_header(relax=True)
567
+
568
+ return image1, header1
569
+ else:
570
+ return image1
Binary file