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/reproject.py ADDED
@@ -0,0 +1,943 @@
1
+ """
2
+ Image reprojection routines.
3
+
4
+ Provides :func:`reproject_swarp` (SWarp wrapper) and :func:`reproject_lanczos`
5
+ (pure-Python Lanczos interpolation with SWarp-style oversampling and Jacobian
6
+ flux conservation).
7
+ """
8
+
9
+ import numpy as np
10
+
11
+ import os
12
+ import tempfile
13
+ import shlex
14
+ import time
15
+ import shutil
16
+ from concurrent.futures import ThreadPoolExecutor
17
+
18
+ from astropy.wcs import WCS
19
+ from astropy.wcs.utils import proj_plane_pixel_scales
20
+ from astropy.io import fits
21
+
22
+ from . import utils
23
+ from . import astrometry
24
+
25
+
26
+ def _pixel_to_pixel(wcs_from, wcs_to, x, y):
27
+ """Map pixel coordinates from one WCS to another.
28
+
29
+ Like :func:`astropy.wcs.utils.pixel_to_pixel` but uses ``quiet=True``
30
+ for the inverse SIP transformation so that non-converging solutions do
31
+ not raise an exception. Sky positions outside the target projection
32
+ domain map to NaN. Note that pixels where the iterative SIP inversion
33
+ fails to converge get best-effort (possibly inaccurate) coordinates
34
+ rather than NaN; in practice such divergent solutions land far outside
35
+ the image and are rejected by the bounds checks downstream.
36
+ """
37
+ sky = wcs_from.all_pix2world(x, y, 0)
38
+ try:
39
+ pix = wcs_to.all_world2pix(sky[0], sky[1], 0, quiet=True)
40
+ except TypeError:
41
+ # Very old astropy without quiet= support
42
+ pix = wcs_to.all_world2pix(sky[0], sky[1], 0)
43
+ return pix
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Lanczos helpers
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ def _lanczos_kernel(x, a):
52
+ """Lanczos kernel of order *a*."""
53
+ x = np.asarray(x, dtype=np.float64)
54
+ result = np.zeros_like(x)
55
+ mask = np.abs(x) < a
56
+ zero = x == 0
57
+ result[zero] = 1.0
58
+ nonzero = mask & ~zero
59
+ xn = x[nonzero]
60
+ result[nonzero] = np.sin(np.pi * xn) * np.sin(np.pi * xn / a) / (np.pi * xn * np.pi * xn / a)
61
+ return result
62
+
63
+
64
+ def _lanczos_map_coordinates(image, coords, a=3, cval=np.nan, weight=None, min_weight=0.5):
65
+ """Interpolate *image* at fractional pixel coordinates using Lanczos kernel.
66
+
67
+ Parameters
68
+ ----------
69
+ image : 2D array
70
+ coords : (2, N) array of (row, col) coordinates
71
+ a : int
72
+ Lanczos kernel order (2, 3 or 4 typical).
73
+ cval : float
74
+ Fill value for out-of-bounds pixels.
75
+ weight : 2D array or None
76
+ Optional weight map (1.0 for valid, 0.0 for masked pixels). If
77
+ provided, ``image * weight`` and *weight* are interpolated with the
78
+ same kernel and their ratio is returned (normalized convolution), so
79
+ masked pixels are excluded from the interpolation. The image is
80
+ expected to have finite values (e.g. zero) at masked pixels.
81
+ min_weight : float
82
+ With *weight*: points receiving less than this fraction of their
83
+ kernel mass from valid pixels are set to *cval*.
84
+
85
+ Returns
86
+ -------
87
+ values : 1D array of interpolated values
88
+
89
+ Notes
90
+ -----
91
+ Kernel taps falling outside the image are replicated from the nearest
92
+ edge pixel (clamped indexing). Without *weight*, NaN pixels in the
93
+ input propagate to every output value whose ``2*a x 2*a`` kernel
94
+ support includes them.
95
+ """
96
+ ny, nx = image.shape
97
+ n_pts = coords.shape[1]
98
+ result = np.full(n_pts, cval, dtype=np.float64)
99
+
100
+ yr, xr = coords[0], coords[1]
101
+
102
+ # Filter out-of-bounds
103
+ valid = (yr >= -0.5) & (yr < ny - 0.5) & (xr >= -0.5) & (xr < nx - 0.5)
104
+
105
+ yr_v = yr[valid]
106
+ xr_v = xr[valid]
107
+
108
+ if len(yr_v) == 0:
109
+ return result
110
+
111
+ # Integer and fractional parts
112
+ iy = np.floor(yr_v).astype(int)
113
+ ix = np.floor(xr_v).astype(int)
114
+ fy = yr_v - iy
115
+ fx = xr_v - ix
116
+
117
+ # Kernel support: -a+1 to a
118
+ offsets = np.arange(-a + 1, a + 1)
119
+
120
+ # Precompute kernels: shape (n_valid, 2*a)
121
+ ky = _lanczos_kernel(fy[:, None] - offsets[None, :], a)
122
+ kx = _lanczos_kernel(fx[:, None] - offsets[None, :], a)
123
+
124
+ # Normalize
125
+ ky /= ky.sum(axis=1, keepdims=True)
126
+ kx /= kx.sum(axis=1, keepdims=True)
127
+
128
+ # Row and column indices
129
+ row_idx = np.clip(iy[:, None] + offsets[None, :], 0, ny - 1)
130
+ col_idx = np.clip(ix[:, None] + offsets[None, :], 0, nx - 1)
131
+
132
+ # Apply separable kernel (vectorized: 2a iters instead of 4a²)
133
+ vals = np.zeros(len(yr_v))
134
+ if weight is None:
135
+ for j in range(len(offsets)):
136
+ row_pixels = image[row_idx[:, j][:, None], col_idx] # (n_valid, 2a)
137
+ vals += ky[:, j] * np.sum(kx * row_pixels, axis=1)
138
+
139
+ result[valid] = vals
140
+ else:
141
+ # Normalized convolution: interpolate image*weight and weight with
142
+ # the same kernel, take the ratio. The denominator is the fraction
143
+ # of kernel mass on valid pixels (exactly 1 where all are valid).
144
+ dens = np.zeros(len(yr_v))
145
+ for j in range(len(offsets)):
146
+ rows = (row_idx[:, j][:, None], col_idx)
147
+ row_weight = weight[rows] # (n_valid, 2a)
148
+ vals += ky[:, j] * np.sum(kx * image[rows] * row_weight, axis=1)
149
+ dens += ky[:, j] * np.sum(kx * row_weight, axis=1)
150
+
151
+ out = np.full(len(yr_v), cval, dtype=np.float64)
152
+ good = dens >= min_weight
153
+ out[good] = vals[good] / dens[good]
154
+ result[valid] = out
155
+
156
+ return result
157
+
158
+
159
+ def _local_area_ratio(x_in, y_in, fallback):
160
+ """Per-pixel Jacobian area ratio of the output->input pixel mapping.
161
+
162
+ Computes ``|det d(x_in, y_in) / d(x_out, y_out)|`` by finite differences
163
+ of the coordinate grids, so it follows SIP distortion and
164
+ projection-induced pixel scale variation across the field.
165
+
166
+ Parameters
167
+ ----------
168
+ x_in, y_in : 2D arrays
169
+ Input pixel coordinates sampled on the (unit-spaced) output grid.
170
+ fallback : float
171
+ Value used where the determinant cannot be computed (grid smaller
172
+ than 2 pixels along an axis, or non-finite coordinates).
173
+
174
+ Returns
175
+ -------
176
+ area : 2D array or float scalar
177
+ """
178
+ if x_in.shape[0] < 2 or x_in.shape[1] < 2:
179
+ return fallback
180
+
181
+ dx_dc = np.gradient(x_in, axis=1)
182
+ dx_dr = np.gradient(x_in, axis=0)
183
+ dy_dc = np.gradient(y_in, axis=1)
184
+ dy_dr = np.gradient(y_in, axis=0)
185
+
186
+ area = np.abs(dx_dc * dy_dr - dx_dr * dy_dc)
187
+ area[~np.isfinite(area)] = fallback
188
+ return area
189
+
190
+
191
+ def _reproject_single_flags(image, wcs_in, wcs_out, shape_out, oversamp=None):
192
+ """Reproject a single integer flag image using nearest-neighbor sampling.
193
+
194
+ When output pixels are larger than input ones (*oversamp* > 1, chosen
195
+ automatically by default), each output pixel is sampled on an
196
+ ``oversamp x oversamp`` sub-pixel grid and the flags of all sampled
197
+ input pixels are combined with bitwise OR, so that isolated flagged
198
+ pixels are not lost in downscaling (like SWarp RESAMPLING_TYPE=FLAGS).
199
+
200
+ Returns
201
+ -------
202
+ result : 2D integer array (0 where no data)
203
+ footprint : 2D float array (fractional coverage 0.0–1.0)
204
+ """
205
+ ny_out, nx_out = shape_out
206
+ image = np.asarray(image)
207
+ dtype = image.dtype
208
+ ny_in, nx_in = image.shape
209
+
210
+ if oversamp is None:
211
+ area_ratio = np.prod(proj_plane_pixel_scales(wcs_out)) / np.prod(
212
+ proj_plane_pixel_scales(wcs_in)
213
+ )
214
+ oversamp = max(1, int(np.sqrt(area_ratio) + 0.5))
215
+
216
+ if np.issubdtype(dtype, np.floating):
217
+ # Float "flags" cannot be combined bitwise - single-sample nearest
218
+ oversamp = 1
219
+
220
+ step = 1.0 / oversamp
221
+ sub_offsets = np.arange(oversamp) * step + step / 2 - 0.5 if oversamp > 1 else [0.0]
222
+ n_sub = len(sub_offsets) ** 2
223
+
224
+ yy, xx = np.mgrid[0:ny_out, 0:nx_out]
225
+
226
+ if np.issubdtype(dtype, np.floating):
227
+ result = np.full(ny_out * nx_out, np.nan, dtype=dtype)
228
+ else:
229
+ result = np.zeros(ny_out * nx_out, dtype=dtype)
230
+ count = np.zeros(ny_out * nx_out, dtype=np.int32)
231
+
232
+ for dy_off in sub_offsets:
233
+ for dx_off in sub_offsets:
234
+ pixel_in = _pixel_to_pixel(
235
+ wcs_out,
236
+ wcs_in,
237
+ (xx + dx_off).ravel().astype(float),
238
+ (yy + dy_off).ravel().astype(float),
239
+ )
240
+ px = np.asarray(pixel_in[0])
241
+ py = np.asarray(pixel_in[1])
242
+
243
+ # NaN coordinates (outside projection domain) cannot be cast to int
244
+ finite = np.isfinite(px) & np.isfinite(py)
245
+ ix = np.full(px.shape, -1, dtype=int)
246
+ iy = np.full(py.shape, -1, dtype=int)
247
+ ix[finite] = np.round(px[finite]).astype(int)
248
+ iy[finite] = np.round(py[finite]).astype(int)
249
+
250
+ valid = (ix >= 0) & (ix < nx_in) & (iy >= 0) & (iy < ny_in)
251
+
252
+ if np.issubdtype(dtype, np.floating):
253
+ result[valid] = image[iy[valid], ix[valid]]
254
+ else:
255
+ result[valid] |= image[iy[valid], ix[valid]]
256
+ count[valid] += 1
257
+
258
+ footprint = (count.astype(np.float64) / n_sub).reshape(shape_out)
259
+ return result.reshape(shape_out), footprint
260
+
261
+
262
+ def _reproject_chunk(
263
+ image,
264
+ wcs_in,
265
+ wcs_out,
266
+ row_start,
267
+ row_end,
268
+ nx_out,
269
+ order,
270
+ oversamp,
271
+ conserve_flux,
272
+ area_fallback,
273
+ sub_offsets,
274
+ weight=None,
275
+ ):
276
+ """Reproject a horizontal chunk of rows. Used by :func:`_reproject_single`.
277
+
278
+ Parameters
279
+ ----------
280
+ conserve_flux : bool
281
+ If True, multiply by the local Jacobian area ratio (per pixel).
282
+ area_fallback : float
283
+ Global mean area ratio, used where the local Jacobian cannot be
284
+ computed.
285
+ weight : 2D array or None
286
+ Optional weight map for masked input pixels, passed to
287
+ :func:`_lanczos_map_coordinates`.
288
+
289
+ Returns
290
+ -------
291
+ row_start : int
292
+ result : 2D array (NaN where no data)
293
+ footprint : 2D float array (fractional coverage 0.0–1.0)
294
+ """
295
+ ny_chunk = row_end - row_start
296
+ chunk_shape = (ny_chunk, nx_out)
297
+
298
+ yy, xx = np.mgrid[row_start:row_end, 0:nx_out]
299
+
300
+ if oversamp <= 1:
301
+ pixel_in = _pixel_to_pixel(
302
+ wcs_out, wcs_in, xx.ravel().astype(float), yy.ravel().astype(float)
303
+ )
304
+ x_in = np.asarray(pixel_in[0]).reshape(chunk_shape)
305
+ y_in = np.asarray(pixel_in[1]).reshape(chunk_shape)
306
+ coords = np.array([y_in.ravel(), x_in.ravel()])
307
+ values = _lanczos_map_coordinates(image, coords, a=order, weight=weight)
308
+
309
+ area = _local_area_ratio(x_in, y_in, area_fallback) if conserve_flux else 1.0
310
+ result = values.reshape(chunk_shape) * area
311
+ footprint = np.isfinite(result).astype(np.float64)
312
+ return row_start, result, footprint
313
+
314
+ accumulator = np.zeros(chunk_shape, dtype=np.float64)
315
+ count = np.zeros(chunk_shape, dtype=np.int32)
316
+ n_sub = len(sub_offsets) ** 2
317
+ area = None
318
+
319
+ for dy_off in sub_offsets:
320
+ for dx_off in sub_offsets:
321
+ pixel_out_x = (xx + dx_off).ravel().astype(float)
322
+ pixel_out_y = (yy + dy_off).ravel().astype(float)
323
+
324
+ pixel_in = _pixel_to_pixel(wcs_out, wcs_in, pixel_out_x, pixel_out_y)
325
+ x_in = np.asarray(pixel_in[0]).reshape(chunk_shape)
326
+ y_in = np.asarray(pixel_in[1]).reshape(chunk_shape)
327
+
328
+ if conserve_flux and area is None:
329
+ # The Jacobian varies negligibly over a sub-pixel offset,
330
+ # so one sub-grid is enough to estimate it
331
+ area = _local_area_ratio(x_in, y_in, area_fallback)
332
+
333
+ values = _lanczos_map_coordinates(
334
+ image, np.array([y_in.ravel(), x_in.ravel()]), a=order, weight=weight
335
+ )
336
+ vals_2d = values.reshape(chunk_shape)
337
+
338
+ valid = np.isfinite(vals_2d)
339
+ accumulator[valid] += vals_2d[valid]
340
+ count[valid] += 1
341
+
342
+ if area is None:
343
+ area = 1.0
344
+
345
+ result = np.full(chunk_shape, np.nan, dtype=np.float64)
346
+ good = count > 0
347
+ area_good = area[good] if isinstance(area, np.ndarray) else area
348
+ result[good] = (accumulator[good] / count[good]) * area_good
349
+ footprint = count.astype(np.float64) / n_sub
350
+ return row_start, result, footprint
351
+
352
+
353
+ def _reproject_single(
354
+ image,
355
+ wcs_in,
356
+ wcs_out,
357
+ shape_out,
358
+ order,
359
+ conserve_flux,
360
+ oversamp,
361
+ weight_nans=True,
362
+ parallel=False,
363
+ ):
364
+ """Reproject a single image with Lanczos interpolation.
365
+
366
+ Parameters
367
+ ----------
368
+ weight_nans : bool
369
+ If True, handle NaN input pixels through a SWarp-style weight map
370
+ (normalized convolution) instead of propagating them.
371
+ parallel : bool or int
372
+ If True, use threads (number chosen automatically).
373
+ If int > 1, use that many threads.
374
+
375
+ Returns
376
+ -------
377
+ result : 2D array (NaN where no data)
378
+ footprint : 2D float array (fractional coverage 0.0–1.0)
379
+ """
380
+ ny_out, nx_out = shape_out
381
+ image = np.asarray(image, dtype=np.float64)
382
+
383
+ # SWarp-style weight map for masked (NaN) input pixels; skipped
384
+ # entirely for clean images where it would be a no-op
385
+ weight = None
386
+ if weight_nans and not np.all(np.isfinite(image)):
387
+ weight = np.isfinite(image).astype(np.float64)
388
+ image = np.where(weight > 0, image, 0.0)
389
+
390
+ # Global mean pixel area ratio (product of per-axis scales handles
391
+ # non-square pixels); >1 means output pixels cover more sky
392
+ area_fallback = np.prod(proj_plane_pixel_scales(wcs_out)) / np.prod(
393
+ proj_plane_pixel_scales(wcs_in)
394
+ )
395
+ scale_ratio = np.sqrt(area_fallback)
396
+
397
+ # Auto oversampling
398
+ if oversamp is None:
399
+ oversamp = max(1, int(scale_ratio + 0.5))
400
+
401
+ # Sub-pixel offsets for oversampling
402
+ if oversamp > 1:
403
+ step = 1.0 / oversamp
404
+ sub_offsets = np.arange(oversamp) * step + step / 2 - 0.5
405
+ else:
406
+ sub_offsets = None
407
+
408
+ # Determine number of workers
409
+ if parallel is True:
410
+ n_workers = min(os.cpu_count() or 1, ny_out)
411
+ elif isinstance(parallel, int) and parallel > 1:
412
+ n_workers = min(parallel, ny_out)
413
+ else:
414
+ n_workers = 1
415
+
416
+ if n_workers <= 1:
417
+ # Sequential path
418
+ _, result, footprint = _reproject_chunk(
419
+ image,
420
+ wcs_in,
421
+ wcs_out,
422
+ 0,
423
+ ny_out,
424
+ nx_out,
425
+ order,
426
+ oversamp,
427
+ conserve_flux,
428
+ area_fallback,
429
+ sub_offsets,
430
+ weight,
431
+ )
432
+ return result, footprint
433
+
434
+ # Threaded path — split by row chunks
435
+ chunk_size = max(1, (ny_out + n_workers - 1) // n_workers)
436
+ row_ranges = []
437
+ for i in range(n_workers):
438
+ rs = i * chunk_size
439
+ re = min(rs + chunk_size, ny_out)
440
+ if rs < re:
441
+ row_ranges.append((rs, re))
442
+
443
+ result = np.full(shape_out, np.nan, dtype=np.float64)
444
+ footprint = np.zeros(shape_out, dtype=np.float64)
445
+ with ThreadPoolExecutor(max_workers=len(row_ranges)) as pool:
446
+ # WCS transformations are not guaranteed thread-safe when sharing
447
+ # a single object, so give each worker its own copies
448
+ futures = [
449
+ pool.submit(
450
+ _reproject_chunk,
451
+ image,
452
+ wcs_in.deepcopy(),
453
+ wcs_out.deepcopy(),
454
+ rs,
455
+ re,
456
+ nx_out,
457
+ order,
458
+ oversamp,
459
+ conserve_flux,
460
+ area_fallback,
461
+ sub_offsets,
462
+ weight,
463
+ )
464
+ for rs, re in row_ranges
465
+ ]
466
+ for f in futures:
467
+ row_start, chunk, fp_chunk = f.result()
468
+ nrows = chunk.shape[0]
469
+ result[row_start : row_start + nrows] = chunk
470
+ footprint[row_start : row_start + nrows] = fp_chunk
471
+
472
+ return result, footprint
473
+
474
+
475
+ # ---------------------------------------------------------------------------
476
+ # Public API
477
+ # ---------------------------------------------------------------------------
478
+
479
+
480
+ def reproject_lanczos(
481
+ input=None,
482
+ wcs=None,
483
+ shape=None,
484
+ width=None,
485
+ height=None,
486
+ header=None,
487
+ order=3,
488
+ conserve_flux=True,
489
+ oversamp=None,
490
+ is_flags=False,
491
+ use_nans=True,
492
+ weight_nans=True,
493
+ parallel=False,
494
+ return_footprint=False,
495
+ verbose=False,
496
+ ):
497
+ """Reproject images using Lanczos interpolation with automatic oversampling.
498
+
499
+ Implements SWarp-style oversampling (sub-pixel averaging when output pixels
500
+ are larger than input pixels) and Jacobian area scaling for flux
501
+ conservation.
502
+
503
+ Accepts the same input format as :func:`reproject_swarp`: a list of
504
+ ``(image, header/WCS)`` tuples or a list of FITS filenames. For multiple
505
+ inputs the reprojected frames are averaged (simple coadd).
506
+
507
+ Parameters
508
+ ----------
509
+ input : list or tuple
510
+ List of ``(image, header_or_wcs)`` tuples or FITS filenames.
511
+ A single ``(image, header_or_wcs)`` tuple is also accepted
512
+ (wrapped into a list automatically for reproject compatibility).
513
+ wcs : `~astropy.wcs.WCS`, optional
514
+ Output WCS. Overrides any WCS already present in *header*.
515
+ shape : tuple, optional
516
+ Output ``(height, width)``.
517
+ width, height : int, optional
518
+ Output dimensions (alternative to *shape*).
519
+ header : `~astropy.io.fits.Header`, optional
520
+ Output FITS header providing the WCS (unless *wcs* is given) and
521
+ image dimensions (unless *shape*/*width*/*height* are given).
522
+ order : int
523
+ Lanczos kernel order (default 3).
524
+ conserve_flux : bool
525
+ If True (default), multiply by the local Jacobian area ratio of
526
+ the pixel mapping (computed per output pixel by finite
527
+ differences, so it follows SIP distortion and projection-induced
528
+ scale variation across the field) so that *total flux* is
529
+ conserved. If False, *surface brightness* is conserved instead.
530
+ oversamp : int or None
531
+ Sub-pixel oversampling factor per axis. ``None`` (default) selects
532
+ automatically: ``max(1, round(output_scale / input_scale))``.
533
+ is_flags : bool
534
+ If True, treat input as integer flag/mask images: use
535
+ nearest-neighbor resampling (no interpolation) and bitwise AND
536
+ for combining multiple inputs. Only frames actually covering a
537
+ pixel participate in the AND. When output pixels are larger
538
+ than input ones, the flags of all contributing input pixels are
539
+ combined with bitwise OR (controlled by *oversamp*, like SWarp
540
+ RESAMPLING_TYPE=FLAGS), so isolated flagged pixels survive
541
+ downscaling. Overrides *order* and *conserve_flux*.
542
+ use_nans : bool
543
+ If True (default), regions with no input coverage are set to NaN
544
+ for floating-point images, or have all flag bits set (``0xFFFF``
545
+ for 16-bit integers) for flag images. If False, they are set
546
+ to zero instead.
547
+ weight_nans : bool
548
+ If True (default), NaN input pixels are handled SWarp-style
549
+ through an internal weight map: the image (with masked pixels
550
+ zeroed) and the weight are resampled with the same kernel and
551
+ their ratio is taken (normalized convolution), so isolated masked
552
+ pixels do not poison their whole kernel neighbourhood. Output
553
+ pixels receiving less than half of their kernel weight from valid
554
+ pixels remain NaN. If False, any NaN within the kernel support
555
+ propagates to the output. Ignored for flag images.
556
+ parallel : bool or int
557
+ If True, use threads for parallel interpolation (number chosen
558
+ automatically). If int > 1, use that many threads. Gives
559
+ ~3-4x speedup on multi-core machines.
560
+ return_footprint : bool
561
+ If True, return ``(coadd, footprint)`` where *footprint* is a
562
+ float array with values between 0.0 (no coverage) and 1.0 (full
563
+ coverage). When oversampling is active, fractional values
564
+ indicate partial sub-pixel coverage. Default is False for
565
+ backward compatibility.
566
+ verbose : bool or callable
567
+ Logging control.
568
+
569
+ Returns
570
+ -------
571
+ coadd : 2D `~numpy.ndarray` or None
572
+ Reprojected (and optionally coadded) image.
573
+ footprint : 2D `~numpy.ndarray`
574
+ Coverage map (only returned when ``return_footprint=True``).
575
+
576
+ Notes
577
+ -----
578
+ NaN pixels in the input images are treated as masked. With
579
+ ``weight_nans=True`` (default) they are excluded from the
580
+ interpolation through a SWarp-style weight map, and only output
581
+ pixels dominated by masked input (less than half of the kernel
582
+ weight on valid pixels) become NaN. With ``weight_nans=False``
583
+ every output pixel whose Lanczos kernel support (``2*order x
584
+ 2*order`` input pixels) contains a NaN becomes NaN. NaN output
585
+ pixels are excluded from the multi-frame average.
586
+ """
587
+ if input is None:
588
+ input = []
589
+
590
+ # Accept a single (image, header/WCS) tuple for reproject compatibility
591
+ if isinstance(input, tuple) and len(input) == 2 and isinstance(input[0], np.ndarray):
592
+ input = [input]
593
+
594
+ log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
595
+
596
+ # Resolve output geometry
597
+ if header is not None:
598
+ header = header.copy()
599
+ else:
600
+ header = fits.Header({'NAXIS': 2, 'BITPIX': -64, 'EQUINOX': 2000.0})
601
+
602
+ if wcs is not None and wcs.is_celestial:
603
+ astrometry.clear_wcs(header)
604
+ header += wcs.to_header(relax=True)
605
+
606
+ if (width is None or height is None) and shape is not None:
607
+ height, width = shape
608
+
609
+ if width is not None:
610
+ header['NAXIS1'] = width
611
+ if height is not None:
612
+ header['NAXIS2'] = height
613
+
614
+ wcs_out = WCS(header)
615
+ if not wcs_out.is_celestial:
616
+ log("Can't reproject without target WCS")
617
+ return (None, None) if return_footprint else None
618
+
619
+ if 'NAXIS1' not in header or 'NAXIS2' not in header:
620
+ log("Can't reproject without output image dimensions")
621
+ return (None, None) if return_footprint else None
622
+
623
+ shape_out = (header['NAXIS2'], header['NAXIS1'])
624
+
625
+ if is_flags:
626
+ log('Input images will be handled as integer flags')
627
+
628
+ # Collect input frames
629
+ frames = []
630
+ for item in input:
631
+ if isinstance(item, str):
632
+ hdulist = fits.open(item)
633
+ img = hdulist[0].data
634
+ if not is_flags:
635
+ img = img.astype(np.float64)
636
+ wcs_in = WCS(hdulist[0].header)
637
+ hdulist.close()
638
+ else:
639
+ img, hdr_or_wcs = item
640
+ if not is_flags:
641
+ img = np.asarray(img, dtype=np.float64)
642
+ if isinstance(hdr_or_wcs, WCS):
643
+ wcs_in = hdr_or_wcs
644
+ else:
645
+ wcs_in = WCS(hdr_or_wcs)
646
+ frames.append((img, wcs_in))
647
+
648
+ if not frames:
649
+ log("No input frames")
650
+ if return_footprint:
651
+ return None, None
652
+ return None
653
+
654
+ # Reproject and combine, frame by frame (flat memory)
655
+ if is_flags:
656
+ # Bitwise AND over covering frames (like SWarp COMBINE_TYPE=AND);
657
+ # frames not covering a pixel do not clear its flags
658
+ coadd = None
659
+ footprint = None
660
+ for i, (img, wcs_in) in enumerate(frames):
661
+ log('Reprojecting frame %d/%d' % (i + 1, len(frames)))
662
+ result, fp = _reproject_single_flags(
663
+ img, wcs_in, wcs_out, shape_out, oversamp=oversamp
664
+ )
665
+
666
+ if coadd is None:
667
+ if np.issubdtype(result.dtype, np.floating):
668
+ blank = np.nan
669
+ else:
670
+ # All bits set: neutral element for AND, and the
671
+ # "no coverage" sentinel (0xFFFF for 16-bit types)
672
+ blank = np.invert(result.dtype.type(0))
673
+ coadd = np.full(shape_out, blank, dtype=result.dtype)
674
+ footprint = np.zeros(shape_out, dtype=np.float64)
675
+
676
+ covered = fp > 0
677
+ if np.issubdtype(coadd.dtype, np.floating):
678
+ # Float "flags" cannot be combined bitwise; last frame wins
679
+ coadd[covered] = result[covered]
680
+ else:
681
+ coadd[covered] &= result[covered]
682
+
683
+ # Combined footprint: covered if any frame covers the pixel
684
+ footprint = np.maximum(footprint, fp)
685
+
686
+ if not use_nans:
687
+ coadd[footprint == 0] = 0
688
+ else:
689
+ sum_img = np.zeros(shape_out, dtype=np.float64)
690
+ count = np.zeros(shape_out, dtype=np.int32)
691
+ fp_sum = np.zeros(shape_out, dtype=np.float64)
692
+ for i, (img, wcs_in) in enumerate(frames):
693
+ log('Reprojecting frame %d/%d' % (i + 1, len(frames)))
694
+ result, fp = _reproject_single(
695
+ img,
696
+ wcs_in,
697
+ wcs_out,
698
+ shape_out,
699
+ order,
700
+ conserve_flux,
701
+ oversamp,
702
+ weight_nans=weight_nans,
703
+ parallel=parallel,
704
+ )
705
+ valid = np.isfinite(result)
706
+ sum_img[valid] += result[valid]
707
+ count[valid] += 1
708
+ fp_sum += fp
709
+
710
+ coadd = np.full(shape_out, np.nan, dtype=np.float64)
711
+ good = count > 0
712
+ coadd[good] = sum_img[good] / count[good]
713
+ # Average footprint across frames
714
+ footprint = fp_sum / len(frames)
715
+
716
+ if not use_nans:
717
+ coadd[~good] = 0.0
718
+
719
+ if return_footprint:
720
+ return coadd, footprint
721
+ return coadd
722
+
723
+
724
+ def reproject_swarp(
725
+ input=None,
726
+ wcs=None,
727
+ shape=None,
728
+ width=None,
729
+ height=None,
730
+ header=None,
731
+ extra=None,
732
+ is_flags=False,
733
+ use_nans=True,
734
+ get_weights=False,
735
+ _workdir=None,
736
+ _tmpdir=None,
737
+ _exe=None,
738
+ verbose=False,
739
+ ):
740
+ """
741
+ Wrapper for running SWarp for re-projecting and mosaicking of images onto target WCS grid.
742
+
743
+ It accepts as input either list of filenames, or list of tuples where first
744
+ element is an image, and second one - either FITS header or WCS.
745
+
746
+ If the input images are integer flags, set `is_flags=True` so that it will be handled
747
+ by passing `RESAMPLING_TYPE=FLAGS` and `COMBINE_TYPE=AND`.
748
+
749
+ If `use_nans=True`, the regions with zero weights will be filled with NaNs (or 0xFFFF).
750
+
751
+ Any additional configuration parameter may be passed to SWarp through `extra` argument which
752
+ should be the dictionary with parameter names as keys.
753
+
754
+ """
755
+
756
+ if input is None:
757
+ input = []
758
+ if extra is None:
759
+ extra = {}
760
+
761
+ # Simple wrapper around print for logging in verbose mode only
762
+ log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
763
+
764
+ # Find the binary
765
+ binname = None
766
+
767
+ if _exe is not None:
768
+ # Check user-provided binary path, and fail if not found
769
+ if os.path.isfile(_exe):
770
+ binname = _exe
771
+ else:
772
+ # Find SWarp binary in common paths
773
+ for exe in ['swarp']:
774
+ binname = shutil.which(exe)
775
+ if binname is not None:
776
+ break
777
+
778
+ if binname is None:
779
+ log("Can't find SWarp binary")
780
+ return None
781
+ # else:
782
+ # log("Using SWarp binary at", binname)
783
+
784
+ if (width is None or height is None) and shape is not None:
785
+ height, width = shape
786
+
787
+ if header is None:
788
+ # Construct minimal FITS header
789
+ header = fits.Header(
790
+ {
791
+ 'NAXIS': 2,
792
+ 'NAXIS1': width,
793
+ 'NAXIS2': height,
794
+ 'BITPIX': -64,
795
+ 'EQUINOX': 2000.0,
796
+ }
797
+ )
798
+ else:
799
+ header = header.copy()
800
+
801
+ if wcs is not None and wcs.is_celestial:
802
+ # Add WCS information to the header
803
+ astrometry.clear_wcs(header)
804
+ whdr = wcs.to_header(relax=True)
805
+
806
+ if wcs.sip is not None:
807
+ whdr = astrometry.wcs_sip2pv(whdr)
808
+
809
+ # Here we will try to fix some common problems with WCS not supported by SWarp
810
+ # FIXME: handle SIP distortions!
811
+ if wcs.wcs.has_pc() and 'PC1_1' not in whdr:
812
+ pc = wcs.wcs.get_pc()
813
+ whdr['PC1_1'] = pc[0, 0]
814
+ whdr['PC1_2'] = pc[0, 1]
815
+ whdr['PC2_1'] = pc[1, 0]
816
+ whdr['PC2_2'] = pc[1, 1]
817
+
818
+ header += whdr
819
+ else:
820
+ wcs = WCS(header)
821
+
822
+ if wcs is None or not wcs.is_celestial:
823
+ log("Can't re-project without target WCS")
824
+ return None
825
+
826
+ workdir = _workdir if _workdir is not None else tempfile.mkdtemp(prefix='swarp', dir=_tmpdir)
827
+
828
+ # Output coadd filename
829
+ coaddname = os.path.join(workdir, 'coadd.fits')
830
+ if os.path.exists(coaddname):
831
+ os.unlink(coaddname)
832
+
833
+ # Input header filename - the result will be re-projected to it
834
+ headername = os.path.join(workdir, 'coadd.head')
835
+ utils.file_write(headername, header.tostring(endcard=True, sep='\n'))
836
+
837
+ # Output weights filename
838
+ weightsname = os.path.join(workdir, 'coadd.weights.fits')
839
+
840
+ # Dummy config filename, to prevent loading from current dir
841
+ confname = os.path.join(workdir, 'empty.conf')
842
+ utils.file_write(confname)
843
+
844
+ xmlname = os.path.join(workdir, 'swarp.xml')
845
+
846
+ opts = {
847
+ 'VERBOSE_TYPE': 'QUIET' if not verbose else 'NORMAL',
848
+ 'IMAGEOUT_NAME': coaddname,
849
+ 'WEIGHTOUT_NAME': weightsname,
850
+ 'c': confname,
851
+ 'XML_NAME': xmlname,
852
+ 'VMEM_DIR': workdir,
853
+ 'RESAMPLE_DIR': workdir,
854
+ #
855
+ 'SUBTRACT_BACK': False, # Do not subtract the backgrounds
856
+ 'FSCALASTRO_TYPE': 'VARIABLE', # and not re-scale the images by default
857
+ }
858
+
859
+ if is_flags:
860
+ log('The images will be handled as integer flags')
861
+ opts['RESAMPLING_TYPE'] = 'FLAGS'
862
+ opts['COMBINE_TYPE'] = 'AND' # Use only common flags in overlapping masks
863
+
864
+ opts.update(extra)
865
+
866
+ # Handle input data
867
+ filenames = []
868
+ bzero = 0
869
+ for i, item in enumerate(input):
870
+ if isinstance(item, str):
871
+ # Item is filename already
872
+ filename = item
873
+ elif len(item) == 2:
874
+ # It should be a tuple of image plus header or WCS
875
+ image = item[0]
876
+ header = item[1]
877
+
878
+ if image.dtype.name == 'bool':
879
+ image = image.astype(np.int16)
880
+
881
+ if isinstance(header, WCS):
882
+ header = header.to_header(relax=True)
883
+
884
+ # Convert SIP headers to TPV
885
+ if WCS(header).sip is not None:
886
+ header = astrometry.wcs_sip2pv(header)
887
+
888
+ filename = os.path.join(workdir, 'image_%04d.fits' % i)
889
+ fits.writeto(filename, image, header, overwrite=True)
890
+
891
+ filenames.append(filename)
892
+ bzero = max(
893
+ bzero, fits.getheader(filename).get('BZERO', 0)
894
+ ) # Keep the largest BZERO among input files
895
+
896
+ # Build the command line
897
+ command = (
898
+ binname
899
+ + ' '
900
+ + utils.format_astromatic_opts(opts)
901
+ + ' '
902
+ + ' '.join([shlex.quote(_) for _ in filenames])
903
+ )
904
+ if not verbose:
905
+ command += ' > /dev/null 2>/dev/null'
906
+ log('Will run SWarp like that:')
907
+ log(command)
908
+
909
+ # Run the command!
910
+
911
+ t0 = time.time()
912
+ res = os.system(command)
913
+ t1 = time.time()
914
+
915
+ if res == 0 and os.path.exists(coaddname) and os.path.exists(weightsname):
916
+ log('SWarp run successfully in %.2f seconds' % (t1 - t0))
917
+
918
+ coadd = fits.getdata(coaddname)
919
+ weights = fits.getdata(weightsname)
920
+
921
+ # it seems SWarp adds BZERO to the output if inputs had them (e.g. unsigned ints do)
922
+ # FIXME: this point needs further investigation!
923
+ if np.issubdtype(coadd.dtype.type, int):
924
+ coadd -= bzero
925
+
926
+ if use_nans:
927
+ if np.issubdtype(coadd.dtype, np.floating):
928
+ coadd[weights == 0] = np.nan
929
+ else:
930
+ coadd[weights == 0] = 0xFFFF
931
+
932
+ else:
933
+ log('Error', res, 'running SWarp')
934
+ coadd = None
935
+ weights = None
936
+
937
+ if _workdir is None:
938
+ shutil.rmtree(workdir)
939
+
940
+ if get_weights:
941
+ return coadd, weights
942
+ else:
943
+ return coadd