reprojax 0.2.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.
reprojax/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ from .adaptive import reproject_adaptive, reproject_adaptive_core
2
+ from .core import PixelTransform
3
+ from .interp import reproject_interp, reproject_interp_batched, reproject_interp_core
4
+ from .transform import build_transform, compose_transform
5
+
6
+ __all__ = [
7
+ "PixelTransform",
8
+ "build_transform",
9
+ "compose_transform",
10
+ "reproject_adaptive",
11
+ "reproject_adaptive_core",
12
+ "reproject_interp",
13
+ "reproject_interp_batched",
14
+ "reproject_interp_core",
15
+ ]
@@ -0,0 +1,74 @@
1
+ """Host-side extraction of mapprojax WCS parameters and frame rotations from astropy.
2
+
3
+ Everything in this module runs eagerly in NumPy/astropy, at transform-build
4
+ time — never inside the jit-compiled :class:`~reprojax.core.PixelTransform`
5
+ itself.
6
+ """
7
+
8
+ import numpy as np
9
+ from astropy.coordinates import SkyCoord
10
+ from astropy.wcs.utils import wcs_to_celestial_frame
11
+ from mapprojax.sip import Sip
12
+
13
+ #: FITS projection code -> mapprojax provider key (see transform.py).
14
+ SUPPORTED_PROJECTIONS = ("TAN", "SIN")
15
+
16
+
17
+ def projection_code(wcs):
18
+ """The 3-letter FITS projection code for an astropy WCS's first axis.
19
+
20
+ Per the WCS papers, CTYPEia's first 8 characters are always
21
+ ``"<axis>-<code>"`` with the code in the last 3 characters of that
22
+ 8-character field — true whether or not a further distortion suffix
23
+ (``-SIP``, ``-TPV``, ...) follows.
24
+ """
25
+ ctype = wcs.wcs.ctype[0]
26
+ code = ctype[:8][-3:].strip()
27
+ if code not in SUPPORTED_PROJECTIONS:
28
+ raise NotImplementedError(
29
+ f"Unsupported projection {code!r} (from CTYPE {ctype!r}); "
30
+ f"reprojax currently supports: {SUPPORTED_PROJECTIONS}"
31
+ )
32
+ return code
33
+
34
+
35
+ def wcs_params_from_astropy(wcs):
36
+ """Extract ``(crpix, cd, crval, sip)`` from an astropy WCS.
37
+
38
+ ``crpix`` is converted to mapprojax's 0-based convention (astropy's
39
+ ``wcs.wcs.crpix`` is 1-based, matching the FITS ``CRPIXi`` keywords).
40
+ ``cd`` is the CD-matrix-equivalent linear transform regardless of
41
+ whether the header used ``CDi_j`` or ``PCi_j``/``CDELTi`` keywords.
42
+ """
43
+ crpix = (np.asarray(wcs.wcs.crpix, dtype=float) - 1.0).tolist()
44
+ crval = np.asarray(wcs.wcs.crval, dtype=float).tolist()
45
+ cd = np.asarray(wcs.pixel_scale_matrix, dtype=float).tolist()
46
+
47
+ sip = None
48
+ if wcs.sip is not None:
49
+ sip = Sip(a=wcs.sip.a, b=wcs.sip.b, ap=wcs.sip.ap, bp=wcs.sip.bp)
50
+
51
+ return crpix, cd, crval, sip
52
+
53
+
54
+ def frame_rotation_matrix(wcs_from, wcs_to):
55
+ """3x3 matrix rotating Cartesian unit vectors from ``wcs_from``'s celestial
56
+ frame to ``wcs_to``'s.
57
+
58
+ Returns the identity when the two WCSes share the same celestial frame
59
+ (the common case, and the only case that avoids an astropy frame
60
+ transform).
61
+ """
62
+ frame_from = wcs_to_celestial_frame(wcs_from)
63
+ frame_to = wcs_to_celestial_frame(wcs_to)
64
+
65
+ if frame_from.name == frame_to.name:
66
+ return np.eye(3)
67
+
68
+ basis_lon = [0.0, 90.0, 0.0]
69
+ basis_lat = [0.0, 0.0, 90.0]
70
+ basis = SkyCoord(basis_lon, basis_lat, unit="deg", frame=frame_from)
71
+ cart = basis.transform_to(frame_to).cartesian
72
+
73
+ # Transformed basis vectors as columns: matrix @ v_from == v_to.
74
+ return np.stack([cart.x.value, cart.y.value, cart.z.value], axis=0)
reprojax/adaptive.py ADDED
@@ -0,0 +1,318 @@
1
+ """Adaptive anti-aliased reprojection: DeForest (2004), ported from mapprojax.
2
+
3
+ The core (:func:`reproject_adaptive_core`) consumes a
4
+ :class:`~reprojax.core.PixelTransform` (no mapprojax-class coupling) and
5
+ returns ``(result, footprint)`` matching :mod:`reprojax.interp`'s
6
+ conventions. The wrapper (:func:`reproject_adaptive`) does the astropy-facing
7
+ work, matching ``reproject.reproject_adaptive``'s call/return shape.
8
+
9
+ This is a from-scratch reimplementation of mapprojax's ``adaptive_reproject``
10
+ against ``reproject_adaptive``'s actual reference algorithm
11
+ (``reproject/adaptive/deforest.pyx``), not a direct port: validating against
12
+ the real algorithm surfaced several places where mapprojax's original
13
+ approximated reproject's behavior rather than matching it, corrected here
14
+ (see the "Deliberate differences from reproject_adaptive" note below).
15
+
16
+ Deliberate differences from reproject_adaptive
17
+ -----------------------------------------------
18
+ - **Jacobian**: computed exactly via JAX autodiff (``jax.jacfwd`` through the
19
+ transform), not reproject's finite-difference estimate between
20
+ neighboring output pixels. Empirically these agree to ~1e-10 on every
21
+ scenario tested (synthetic WCS pairs at ~1x and ~4x scale, with SIP, and
22
+ the real SPHEREx pair) — exact autodiff is at least as accurate as a
23
+ finite-difference approximation of the same quantity, and is the natural
24
+ choice given reprojax's differentiable transform.
25
+ - **Search window is a fixed maximum size** (``max_window_radius``), not
26
+ reproject's per-pixel dynamically-sized region: JAX/vmap requires a
27
+ static shape across every output pixel. Cells outside the true dynamic
28
+ window (computed per-pixel from the local Jacobian, same as reproject)
29
+ get zero weight, matching reproject's hard truncation, but
30
+ ``max_window_radius`` must be set large enough to contain that dynamic
31
+ window for every pixel in the image — large enough for the most
32
+ aggressive downsampling present, not just a typical pixel. Too small a
33
+ value silently truncates the kernel (no error is raised); this is the
34
+ cost of vmap's uniform per-pixel work requirement.
35
+ - **Only the algorithm's default parameters are supported**:
36
+ ``kernel_width``, ``sample_region_width``, and ``kernel`` (Gaussian or
37
+ Hann) are the only tunables. reproject_adaptive's ``boundary_mode`` and
38
+ ``bad_value_mode`` are fixed at their defaults (``"strict"`` for both —
39
+ any output pixel whose window extends beyond the input array, or whose
40
+ window contains a NaN/Inf input value, is set to NaN as a whole) with no
41
+ other mode supported; ``conserve_flux``, ``despike_jacobian``,
42
+ ``center_jacobian``, and ``x_cyclic``/``y_cyclic`` are not implemented.
43
+
44
+ Validation
45
+ ----------
46
+ Against ``reproject_adaptive`` with matching default parameters, on
47
+ non-degenerate (non-integer CRPIX/CRVAL/rotation) synthetic WCS pairs:
48
+ identical footprints and value agreement to ~1e-10, at both ~1x and ~4x
49
+ scale, with SIP, and with real masked (NaN) input pixels. See
50
+ tests/test_adaptive_synthetic.py and tests/test_real_spherex.py.
51
+
52
+ Two things were diagnosed and are *not* papered over with a looser
53
+ tolerance:
54
+
55
+ 1. Exact-integer-tie sensitivity in ``boundary_mode="strict"``'s window
56
+ check: reproject_adaptive's coordinate pipeline can return a
57
+ mathematically-exact integer position for special-case inputs (e.g. an
58
+ output WCS identical to the input WCS), while reprojax's composed
59
+ transform (via trigonometric round-trip through mapprojax) generally
60
+ lands ~1e-10 away from that exact integer even when the true answer is
61
+ exact. Since the window's ceil/floor bounds are then computed from that
62
+ position, a coincidental floating-point nudge across an exact integer
63
+ can shift a search window's boundary by one pixel, occasionally
64
+ disagreeing right at the input array's edge. This is specific to
65
+ artificially "round" test parameters (integer CRPIX, exactly-matching
66
+ in/out WCS) rather than real data — it was not observed on the real
67
+ SPHEREx pair (see tests/test_real_spherex.py), and the synthetic test
68
+ suite uses deliberately non-round parameters to avoid manufacturing it.
69
+ 2. A genuine bug caught during this validation, not present in the shipped
70
+ code: masking cells outside the per-pixel dynamic search window to zero
71
+ *weight* is not sufficient on its own — the fixed-size window array can
72
+ still contain a NaN there (e.g. a masked input pixel that happens to
73
+ fall within ``max_window_radius`` of an output pixel's mapped position,
74
+ but outside that pixel's actual, smaller dynamic window), and
75
+ `0 * nan = nan` under IEEE 754 poisons the weighted sum regardless of
76
+ the zero weight. Fixed by substituting a safe value outside the dynamic
77
+ window before the multiply — the same "never multiply an excluded
78
+ value by NaN" lesson as reprojax.interp's map_coordinates handling.
79
+ """
80
+
81
+ from functools import partial
82
+
83
+ import jax
84
+ import jax.numpy as jnp
85
+
86
+ from .interp import _as_wcs, _output_grid_dtype, _shape_from_header, _to_native_byte_order
87
+ from .transform import build_transform
88
+
89
+ SUPPORTED_KERNELS = ("gaussian", "hann")
90
+
91
+
92
+ @partial(
93
+ jax.jit,
94
+ static_argnames=("transform", "kernel", "kernel_width", "sample_region_width", "max_window_radius", "block_size"),
95
+ )
96
+ def reproject_adaptive_core(
97
+ data,
98
+ x_out,
99
+ y_out,
100
+ transform,
101
+ kernel="gaussian",
102
+ kernel_width=1.3,
103
+ sample_region_width=4.0,
104
+ max_window_radius=10,
105
+ block_size=None,
106
+ ):
107
+ """Reproject ``data`` onto the pixel grid given by ``x_out``/``y_out``
108
+ using the DeForest (2004) adaptive, anti-aliased algorithm.
109
+
110
+ Parameters
111
+ ----------
112
+ data : array, shape (ny_in, nx_in)
113
+ Input image.
114
+ x_out, y_out : arrays, any common broadcastable shape
115
+ Output-image pixel coordinates to fill (0-based, pixel-center
116
+ convention — see :mod:`reprojax.core`).
117
+ transform : PixelTransform
118
+ Maps ``(x_out, y_out) -> (x_in, y_in)``.
119
+ kernel : str
120
+ ``"gaussian"`` (default, matches reproject_adaptive's default) or
121
+ ``"hann"``.
122
+ kernel_width : float
123
+ Gaussian kernel width in pixels (unused for Hann).
124
+ sample_region_width : float
125
+ Width, in output pixels, of the sampling region for the Gaussian
126
+ kernel (unused for Hann, whose extent is determined by the
127
+ transform's local Jacobian alone).
128
+ max_window_radius : int
129
+ Fixed half-width (in input pixels) of the search window; must be
130
+ large enough to contain the true, dynamically-sized search window
131
+ for every output pixel (larger for more aggressive downsampling).
132
+ See the module docstring's note on this being a JAX/vmap-driven
133
+ departure from reproject_adaptive's per-pixel dynamic sizing.
134
+ block_size : int, optional
135
+ Process this many output pixels per `jax.lax.scan` step instead of
136
+ vectorizing the whole output grid at once, to bound memory use.
137
+
138
+ Returns
139
+ -------
140
+ result : array, same shape as x_out
141
+ Reprojected values, dtype matching ``data``. NaN where invalid.
142
+ footprint : array, same shape as x_out
143
+ 1.0 where the value is valid, 0.0 where not.
144
+ """
145
+ if kernel not in SUPPORTED_KERNELS:
146
+ raise ValueError(f"kernel must be one of {SUPPORTED_KERNELS}, got {kernel!r}")
147
+
148
+ ny_in, nx_in = data.shape
149
+ radius = max_window_radius
150
+ win_size = 2 * radius + 1
151
+ padded = jnp.pad(data, radius, mode="constant", constant_values=0.0)
152
+
153
+ def process_pixel(x0_out, y0_out):
154
+ u0, v0 = transform(x0_out, y0_out)
155
+
156
+ def t(xy):
157
+ u, v = transform(xy[0], xy[1])
158
+ return jnp.stack([u, v])
159
+
160
+ J = jax.jacfwd(t)(jnp.array([x0_out, y0_out])) # d(input)/d(output), (2, 2)
161
+
162
+ U, S, Vt = jnp.linalg.svd(J)
163
+ s_clamped = jnp.maximum(1.0, S) # never sharpen beyond native resolution
164
+ j_inv = (Vt.T * (1.0 / s_clamped)) @ U.T # d(output)/d(input), using the clamped Jacobian
165
+
166
+ if kernel == "gaussian":
167
+ half_w = half_h = sample_region_width * jnp.max(s_clamped) / 2.0
168
+ else:
169
+ ji_padded = (U * s_clamped) @ Vt
170
+ corners = jnp.array([[1.0, 1.0], [1.0, -1.0], [-1.0, 1.0], [-1.0, -1.0]])
171
+ transformed_corners = corners @ ji_padded.T
172
+ half_w = jnp.max(jnp.abs(transformed_corners[:, 0]))
173
+ half_h = jnp.max(jnp.abs(transformed_corners[:, 1]))
174
+
175
+ bottom = jnp.ceil(v0 - half_h)
176
+ top = jnp.floor(v0 + half_h)
177
+ left = jnp.ceil(u0 - half_w)
178
+ right = jnp.floor(u0 + half_w)
179
+
180
+ u0_int = jnp.rint(u0).astype(int)
181
+ v0_int = jnp.rint(v0).astype(int)
182
+ window = jax.lax.dynamic_slice(padded, (v0_int, u0_int), (win_size, win_size))
183
+
184
+ win_v_idx, win_u_idx = jnp.meshgrid(jnp.arange(win_size), jnp.arange(win_size), indexing="ij")
185
+ global_v = v0_int - radius + win_v_idx
186
+ global_u = u0_int - radius + win_u_idx
187
+ global_v_f = global_v.astype(v0.dtype)
188
+ global_u_f = global_u.astype(u0.dtype)
189
+
190
+ in_dynamic_window = (global_v_f >= bottom) & (global_v_f <= top) & (global_u_f >= left) & (global_u_f <= right)
191
+
192
+ dv = global_v_f - v0
193
+ du = global_u_f - u0
194
+ tx = j_inv[0, 0] * du + j_inv[0, 1] * dv
195
+ ty = j_inv[1, 0] * du + j_inv[1, 1] * dv
196
+
197
+ if kernel == "gaussian":
198
+ raw_weight = jnp.exp(-(tx**2 + ty**2) / kernel_width**2 * 2.0)
199
+ else:
200
+ hann_mask = (jnp.abs(tx) < 1.0) & (jnp.abs(ty) < 1.0)
201
+ raw_weight = jnp.where(hann_mask, (jnp.cos(jnp.pi * tx) + 1.0) * (jnp.cos(jnp.pi * ty) + 1.0), 0.0)
202
+
203
+ weight = jnp.where(in_dynamic_window, raw_weight, 0.0)
204
+
205
+ # Cells outside the dynamic window carry zero weight, but the
206
+ # fixed-size window can still contain a NaN there; `0 * nan = nan`
207
+ # would poison the sum regardless of weight (see module docstring).
208
+ # A NaN genuinely inside the dynamic window must still poison the
209
+ # result (strict bad_value_mode) -- only substitute outside it.
210
+ window_safe = jnp.where(in_dynamic_window, window, 0.0)
211
+
212
+ weight_sum = jnp.sum(weight)
213
+ target = jnp.sum(weight * window_safe)
214
+
215
+ in_array_bounds = (global_v >= 0) & (global_v < ny_in) & (global_u >= 0) & (global_u < nx_in)
216
+ # strict boundary_mode: any dynamic-window cell outside the input
217
+ # array invalidates the whole output pixel.
218
+ window_out_of_bounds = jnp.any(in_dynamic_window & ~in_array_bounds)
219
+ invalid_transform = jnp.isnan(u0) | jnp.isnan(v0) | jnp.any(jnp.isnan(J))
220
+
221
+ result = target / weight_sum
222
+ return jnp.where(window_out_of_bounds | invalid_transform, jnp.nan, result)
223
+
224
+ x_flat = x_out.ravel()
225
+ y_flat = y_out.ravel()
226
+
227
+ if block_size is None:
228
+ flat_result = jax.vmap(process_pixel)(x_flat, y_flat)
229
+ else:
230
+ n = x_flat.size
231
+ pad = (-n) % block_size
232
+ x_padded = jnp.pad(x_flat, (0, pad))
233
+ y_padded = jnp.pad(y_flat, (0, pad))
234
+ x_blocks = x_padded.reshape(-1, block_size)
235
+ y_blocks = y_padded.reshape(-1, block_size)
236
+
237
+ def scan_body(carry, block):
238
+ bx, by = block
239
+ return carry, jax.vmap(process_pixel)(bx, by)
240
+
241
+ _, blocks_out = jax.lax.scan(scan_body, None, (x_blocks, y_blocks))
242
+ flat_result = blocks_out.ravel()
243
+ if pad:
244
+ flat_result = flat_result[:-pad]
245
+
246
+ result = flat_result.reshape(x_out.shape).astype(data.dtype)
247
+ footprint = (~jnp.isnan(result)).astype(jnp.float32)
248
+ return result, footprint
249
+
250
+
251
+ def reproject_adaptive(
252
+ input_data,
253
+ output_projection,
254
+ shape_out=None,
255
+ kernel="gaussian",
256
+ kernel_width=1.3,
257
+ sample_region_width=4.0,
258
+ max_window_radius=10,
259
+ block_size=None,
260
+ matrix_precision="f64",
261
+ ):
262
+ """Reproject an ``(array, WCS-or-header)`` pair onto ``output_projection``
263
+ using the DeForest (2004) adaptive, anti-aliased algorithm.
264
+
265
+ Matches ``reproject.reproject_adaptive``'s call and return shape for the
266
+ parameters this port supports — see the module docstring for the
267
+ (default-matching) scope this covers and what's out of scope.
268
+
269
+ Parameters
270
+ ----------
271
+ input_data : tuple(array, astropy.wcs.WCS or astropy.io.fits.Header)
272
+ output_projection : astropy.wcs.WCS or astropy.io.fits.Header
273
+ shape_out : tuple(int, int), optional
274
+ Required unless it can be inferred from ``output_projection``
275
+ (a Header's NAXIS1/NAXIS2, or a WCS with ``array_shape`` set).
276
+ kernel, kernel_width, sample_region_width, max_window_radius, block_size
277
+ See :func:`reproject_adaptive_core`.
278
+ matrix_precision : {"f64", "f32"}
279
+ See :func:`~reprojax.transform.build_transform`. ``"f32"`` needs
280
+ no ``jax_enable_x64`` and is faster on GPUs where float64
281
+ throughput is much lower than float32's, at reduced accuracy
282
+ (~1e-4 px for typical overlapping-field reprojections; see that
283
+ function's docstring for the detailed tradeoff).
284
+
285
+ Returns
286
+ -------
287
+ array, footprint
288
+ """
289
+ data, wcs_or_header_in = input_data
290
+ wcs_in = _as_wcs(wcs_or_header_in)
291
+ wcs_out = _as_wcs(output_projection)
292
+
293
+ if shape_out is None:
294
+ shape_out = _shape_from_header(output_projection)
295
+ if shape_out is None:
296
+ raise ValueError(
297
+ "shape_out could not be inferred from output_projection; pass it explicitly"
298
+ )
299
+ ny, nx = shape_out
300
+
301
+ grid_dtype = _output_grid_dtype(matrix_precision)
302
+ y_out, x_out = jnp.meshgrid(
303
+ jnp.arange(ny, dtype=grid_dtype), jnp.arange(nx, dtype=grid_dtype), indexing="ij"
304
+ )
305
+
306
+ transform = build_transform(wcs_in, wcs_out, matrix_precision=matrix_precision)
307
+ data = jnp.asarray(_to_native_byte_order(data))
308
+ return reproject_adaptive_core(
309
+ data,
310
+ x_out,
311
+ y_out,
312
+ transform,
313
+ kernel=kernel,
314
+ kernel_width=kernel_width,
315
+ sample_region_width=sample_region_width,
316
+ max_window_radius=max_window_radius,
317
+ block_size=block_size,
318
+ )
reprojax/core.py ADDED
@@ -0,0 +1,55 @@
1
+ """The ``PixelTransform`` contract.
2
+
3
+ This is reprojax's central seam. Reprojection algorithms are written against
4
+ this contract, not against any particular WCS library — mapprojax is the
5
+ default provider (see :mod:`reprojax.transform`), and other providers (e.g.
6
+ an astropy-WCS ``pure_callback`` fallback for projections mapprojax doesn't
7
+ cover) can be added later behind the same interface.
8
+
9
+ Contract
10
+ --------
11
+ A ``PixelTransform`` is a callable::
12
+
13
+ x_in, y_in = transform(x_out, y_out)
14
+
15
+ mapping output-image pixel coordinates to input-image pixel coordinates,
16
+ with the following conventions:
17
+
18
+ - **0-based, pixel-center convention**: pixel ``0`` is the center of the
19
+ first pixel (the ``scipy.ndimage.map_coordinates`` / FITS-``origin=0``
20
+ convention), not FITS's 1-based convention.
21
+ - **Shape**: ``x_out``, ``y_out`` may be arrays of any broadcastable shape;
22
+ ``x_in``, ``y_in`` come back the same shape. Batching over many images is
23
+ done by ``vmap``-ing the transform (and whatever consumes it), not by the
24
+ transform itself handling a batch dimension.
25
+ - **Invalid points are NaN**: pixels with no valid corresponding input
26
+ position (off the projection's visible hemisphere, off the celestial
27
+ sphere) come back as NaN in both ``x_in`` and ``y_in``.
28
+ - **Units**: everything at this boundary is in pixels. Providers built on
29
+ spherical projections will generally work in radians internally, but that
30
+ is an implementation detail, not part of the contract.
31
+ - **Jit/grad-safe**: a conforming transform is built from pure array
32
+ operations (no host callbacks, no data-dependent Python control flow) so
33
+ that it can be used under ``jax.jit`` and differentiated with
34
+ ``jax.grad``.
35
+
36
+ Providers may optionally expose ``pixel_map(x_out, y_out, *,
37
+ bounds_shape=(ny, nx))``. It returns finite mapped coordinates and a boolean
38
+ validity mask; interpolation uses this capability when available and falls
39
+ back to the two-result callable contract otherwise.
40
+ """
41
+
42
+ from typing import Any, Protocol, Tuple
43
+
44
+
45
+ class PixelTransform(Protocol):
46
+ """Callable mapping output pixel coordinates to input pixel coordinates.
47
+
48
+ See the module docstring for the full contract. This is a
49
+ :class:`typing.Protocol`: any callable with this signature satisfies it
50
+ structurally, with no need to import or subclass anything from
51
+ reprojax.
52
+ """
53
+
54
+ def __call__(self, x_out: Any, y_out: Any) -> Tuple[Any, Any]:
55
+ ...
reprojax/interp.py ADDED
@@ -0,0 +1,278 @@
1
+ """Nearest/bilinear reprojection: the pure-JAX core and the reproject-shaped wrapper.
2
+
3
+ The core (:func:`reproject_interp_core`) consumes a :class:`~reprojax.core.PixelTransform`
4
+ and is a pure function of arrays — jit/vmap/grad-safe. The wrapper
5
+ (:func:`reproject_interp`) does the astropy-facing work (header/WCS parsing,
6
+ transform composition) and matches ``reproject``'s call/return shape.
7
+ """
8
+
9
+ import jax
10
+ import jax.numpy as jnp
11
+ import numpy as np
12
+ from astropy.io.fits import Header
13
+ from astropy.wcs import WCS
14
+ from jax.scipy.ndimage import map_coordinates
15
+
16
+ from .transform import build_transform, compose_transform
17
+
18
+ SUPPORTED_ORDERS = (0, 1)
19
+
20
+
21
+ def _output_grid_dtype(matrix_precision):
22
+ """The dtype for the output pixel grid built by the wrappers.
23
+
24
+ The grid must match the transform's coordinate dtype, not a fixed
25
+ float64: under ``matrix_precision="f32"`` every per-pixel call runs in
26
+ float32, so a float64 grid only costs accuracy and, with
27
+ ``jax_enable_x64`` off, emits a truncation warning on every call.
28
+ """
29
+ return jnp.float32 if matrix_precision == "f32" else jnp.float64
30
+
31
+ # Below this coverage, at least one interpolation neighbor was outside the
32
+ # input array and contributed through `cval`. The threshold sits far below
33
+ # any real partial-coverage value (>= 1/(order+2)) and far above float32
34
+ # rounding noise, so it only ever separates "fully covered" from "not".
35
+ _COVERAGE_VALID_THRESHOLD = 1e-5
36
+
37
+
38
+ def _clip_border_pixels(coord, size):
39
+ """Snap coordinates in the outer half-pixel border band to the border
40
+ pixel's center index.
41
+
42
+ ``map_coordinates`` (like plain scipy) treats pixel values as defined
43
+ only at integer pixel centers, so a coordinate in the outer half of the
44
+ border pixel is technically "outside" the defined domain even though it
45
+ is still within that pixel. ``reproject`` corrects for this with the
46
+ same clip (see ``reproject._array_utils._clip_coords``); we match it so
47
+ our footprints agree with reproject's at the image edges.
48
+ """
49
+ lower = (coord < 0) & (coord >= -0.5)
50
+ upper = (coord < size - 0.5) & (coord >= size - 1)
51
+ coord = jnp.where(lower, 0.0, coord)
52
+ coord = jnp.where(upper, size - 1.0, coord)
53
+ return coord
54
+
55
+
56
+ def reproject_interp_core(data, x_out, y_out, transform, order=1):
57
+ """Reproject ``data`` onto the pixel grid given by ``x_out``/``y_out``.
58
+
59
+ Parameters
60
+ ----------
61
+ data : array, shape (ny_in, nx_in)
62
+ Input image.
63
+ x_out, y_out : arrays, any common broadcastable shape
64
+ Output-image pixel coordinates to fill (0-based, pixel-center
65
+ convention — see :mod:`reprojax.core`).
66
+ transform : PixelTransform
67
+ Maps ``(x_out, y_out) -> (x_in, y_in)``.
68
+ order : int
69
+ 0 (nearest) or 1 (bilinear).
70
+
71
+ Returns
72
+ -------
73
+ result : array, same shape as x_out
74
+ Reprojected values, dtype matching ``data``. NaN where invalid.
75
+ footprint : array, same shape as x_out
76
+ 1.0 where the value is valid, 0.0 where not.
77
+ """
78
+ if order not in SUPPORTED_ORDERS:
79
+ raise ValueError(f"order must be one of {SUPPORTED_ORDERS} (bicubic and higher are not supported), got {order!r}")
80
+
81
+ ny, nx = data.shape
82
+ pixel_map = getattr(transform, "pixel_map", None)
83
+ has_pixel_map = callable(pixel_map)
84
+ if has_pixel_map:
85
+ x_in, y_in, valid = pixel_map(x_out, y_out, bounds_shape=(ny, nx))
86
+ coord_is_nan = ~valid
87
+ else:
88
+ x_in, y_in = transform(x_out, y_out)
89
+ coord_is_nan = jnp.isnan(x_in) | jnp.isnan(y_in)
90
+ valid = ~coord_is_nan
91
+ # Generic providers may return NaN coordinates, which must never reach
92
+ # map_coordinates: NaN interpolation weights can corrupt data gradients
93
+ # even when the final result is masked invalid. pixel_map providers
94
+ # already guarantee finite zero stand-ins, so they need no second
95
+ # sanitization pass.
96
+ if has_pixel_map:
97
+ x_safe, y_safe = x_in, y_in
98
+ else:
99
+ x_safe = jnp.where(coord_is_nan, 0.0, x_in)
100
+ y_safe = jnp.where(coord_is_nan, 0.0, y_in)
101
+ y_clipped = _clip_border_pixels(y_safe, ny)
102
+ x_clipped = _clip_border_pixels(x_safe, nx)
103
+ coords = [y_clipped, x_clipped]
104
+
105
+ result = map_coordinates(data, coords, order=order, mode="constant", cval=0.0)
106
+
107
+ if has_pixel_map:
108
+ valid_coord = valid
109
+ else:
110
+ # For generic providers, retain the existing coverage interpolation:
111
+ # its value is the fraction of interpolation weight that landed on
112
+ # in-bounds neighbors. A float32 fraction is exact enough for the
113
+ # threshold below, and matches the float32 path under x64-off.
114
+ coverage_input = jnp.ones((ny, nx), dtype=jnp.float32)
115
+ coverage = map_coordinates(
116
+ coverage_input, coords, order=order, mode="constant", cval=0.0
117
+ )
118
+ valid_coord = (
119
+ valid
120
+ & (coverage >= 1.0 - _COVERAGE_VALID_THRESHOLD)
121
+ & ~coord_is_nan
122
+ )
123
+
124
+ result = jnp.where(valid_coord, result, jnp.nan).astype(data.dtype)
125
+ # Footprint tracks the final result, not just coordinate validity: `data`
126
+ # itself can contain NaN (real masked/bad pixels), which propagates
127
+ # through interpolation even when the coordinate is fully in-bounds.
128
+ # Matches reproject's own footprint definition (~isnan(output)).
129
+ footprint = (~jnp.isnan(result)).astype(jnp.float32)
130
+ return result, footprint
131
+
132
+
133
+ def reproject_interp_batched(
134
+ data_batch,
135
+ mwcs_in_batch,
136
+ mwcs_out,
137
+ rotation_batch,
138
+ x_out,
139
+ y_out,
140
+ order=1,
141
+ ):
142
+ """Reproject a batch of exposures onto one shared output grid.
143
+
144
+ Batching is `jax.vmap` over the whole per-exposure pipeline (build the
145
+ transform, then reproject) — per the design, batching happens by
146
+ vmapping the core, not by threading a batch dimension through a single
147
+ transform. This is a thin, documented recipe: build ``mwcs_in_batch``
148
+ (and ``rotation_batch``) yourself with mapprojax's ``Tan``/``Sin`` (which are
149
+ JAX pytrees), each leaf carrying a leading batch dimension, and everything
150
+ else follows the same path as the single-exposure core.
151
+
152
+ Parameters
153
+ ----------
154
+ data_batch : array, shape (n, ny_in, nx_in)
155
+ Stacked input images. Individual exposures may differ in size only
156
+ if you pad them to a common shape first — vmap requires uniform
157
+ shapes across the batch.
158
+ mwcs_in_batch : a mapprojax WCS (e.g. ``mapprojax.wcs.Tan``), batched
159
+ Construct with each parameter (crpix, cd, crval, and SIP
160
+ coefficients if used) carrying a leading dimension of size ``n`` —
161
+ e.g. ``Tan(crpix_batch, cd_batch, crval_batch, sip=sip_batch,
162
+ precision="f64")`` (``precision=`` is required).
163
+ Parameters that don't vary across the batch (e.g. a shared crpix for
164
+ exposures from the same detector) still need that leading dimension;
165
+ broadcast them yourself (``jnp.broadcast_to``) before constructing.
166
+ Every exposure must share the same SIP-or-not structure (vmap
167
+ requires a uniform pytree).
168
+ mwcs_out : a mapprojax WCS
169
+ The single, shared output projection (not batched).
170
+ rotation_batch : array, shape (n, 3, 3)
171
+ Per-exposure celestial frame rotation (see :func:`build_transform`).
172
+ Often identical across the batch (same input/output frame pair for
173
+ every exposure); broadcast to shape ``(n, 3, 3)`` in that case.
174
+ x_out, y_out : arrays
175
+ The shared output pixel grid (not batched).
176
+ order : int
177
+ 0 (nearest) or 1 (bilinear).
178
+
179
+ Returns
180
+ -------
181
+ results, footprints : arrays, shape (n, *x_out.shape)
182
+ Element-wise identical to calling :func:`reproject_interp_core` once
183
+ per exposure with the same ``x_out``/``y_out``.
184
+ """
185
+
186
+ def _single(data, mwcs_in, rotation):
187
+ transform = compose_transform(mwcs_in, mwcs_out, rotation)
188
+ return reproject_interp_core(data, x_out, y_out, transform, order=order)
189
+
190
+ return jax.vmap(_single, in_axes=(0, 0, 0))(data_batch, mwcs_in_batch, rotation_batch)
191
+
192
+
193
+ def _as_wcs(wcs_or_header):
194
+ if isinstance(wcs_or_header, WCS):
195
+ return wcs_or_header
196
+ if isinstance(wcs_or_header, Header):
197
+ return WCS(wcs_or_header)
198
+ raise TypeError(f"expected an astropy WCS or Header, got {type(wcs_or_header)!r}")
199
+
200
+
201
+ def _to_native_byte_order(data):
202
+ """FITS data is always big-endian; JAX only accepts native-byte-order
203
+ arrays. A plain ``jnp.asarray`` on data read straight from
204
+ ``astropy.io.fits`` fails outright, so the wrapper normalizes here
205
+ before anything touches JAX.
206
+ """
207
+ data = np.asarray(data)
208
+ if not data.dtype.isnative:
209
+ data = data.astype(data.dtype.newbyteorder("="))
210
+ return data
211
+
212
+
213
+ def _shape_from_header(wcs_or_header):
214
+ if isinstance(wcs_or_header, Header):
215
+ header = wcs_or_header
216
+ elif getattr(wcs_or_header, "array_shape", None) is not None:
217
+ return tuple(wcs_or_header.array_shape)
218
+ else:
219
+ return None
220
+ if "NAXIS2" in header and "NAXIS1" in header:
221
+ return (header["NAXIS2"], header["NAXIS1"])
222
+ return None
223
+
224
+
225
+ def reproject_interp(input_data, output_projection, shape_out=None, order=1, matrix_precision="f64"):
226
+ """Reproject an ``(array, WCS-or-header)`` pair onto ``output_projection``.
227
+
228
+ Matches ``reproject.reproject_interp``'s call and return shape for the
229
+ subset of input forms reprojax currently supports.
230
+
231
+ Parameters
232
+ ----------
233
+ input_data : tuple(array, astropy.wcs.WCS or astropy.io.fits.Header)
234
+ output_projection : astropy.wcs.WCS or astropy.io.fits.Header
235
+ shape_out : tuple(int, int), optional
236
+ Required unless it can be inferred from ``output_projection``
237
+ (a Header's NAXIS1/NAXIS2, or a WCS with ``array_shape`` set).
238
+ order : int
239
+ 0 (nearest) or 1 (bilinear).
240
+ matrix_precision : {"f64", "f32"}
241
+ See :func:`~reprojax.transform.build_transform`. ``"f32"`` needs
242
+ no ``jax_enable_x64`` and is faster on GPUs where float64
243
+ throughput is much lower than float32's, at reduced accuracy
244
+ (~1e-4 px for typical overlapping-field reprojections; see that
245
+ function's docstring for the detailed tradeoff).
246
+
247
+ Returns
248
+ -------
249
+ array, footprint
250
+
251
+ Note
252
+ ----
253
+ Like :func:`~reprojax.adaptive.reproject_adaptive`, this calls
254
+ :func:`~reprojax.transform.build_transform` internally, so repeated
255
+ calls with an equivalent ``wcs_in``/``wcs_out`` pair reuse the same
256
+ transform object rather than rebuilding (and retracing) it — see that
257
+ function's docstring for the caching details.
258
+ """
259
+ data, wcs_or_header_in = input_data
260
+ wcs_in = _as_wcs(wcs_or_header_in)
261
+ wcs_out = _as_wcs(output_projection)
262
+
263
+ if shape_out is None:
264
+ shape_out = _shape_from_header(output_projection)
265
+ if shape_out is None:
266
+ raise ValueError(
267
+ "shape_out could not be inferred from output_projection; pass it explicitly"
268
+ )
269
+ ny, nx = shape_out
270
+
271
+ grid_dtype = _output_grid_dtype(matrix_precision)
272
+ y_out, x_out = jnp.meshgrid(
273
+ jnp.arange(ny, dtype=grid_dtype), jnp.arange(nx, dtype=grid_dtype), indexing="ij"
274
+ )
275
+
276
+ transform = build_transform(wcs_in, wcs_out, matrix_precision=matrix_precision)
277
+ data = jnp.asarray(_to_native_byte_order(data))
278
+ return reproject_interp_core(data, x_out, y_out, transform, order=order)
reprojax/py.typed ADDED
File without changes
reprojax/transform.py ADDED
@@ -0,0 +1,231 @@
1
+ """Build a :class:`~reprojax.core.PixelTransform` from a pair of astropy WCS objects.
2
+
3
+ mapprojax provides the projection math (TAN, SIN, with optional SIP
4
+ distortion); astropy provides celestial frame determination and rotation.
5
+ Everything astropy-facing runs once, eagerly, at build time in
6
+ :func:`build_transform`; the returned callable is pure JAX and safe under
7
+ ``jit``/``vmap``/``grad``.
8
+ """
9
+
10
+ from collections import OrderedDict
11
+
12
+ import jax.numpy as jnp
13
+ from astropy.wcs.utils import wcs_to_celestial_frame
14
+ from mapprojax.wcs import Sin, Tan
15
+ from mapprojax.reproject import Reprojector
16
+ from mapprojax.utils import apply_rotation
17
+
18
+ from ._astropy_wcs import frame_rotation_matrix, projection_code, wcs_params_from_astropy
19
+
20
+ _PROVIDERS = {"TAN": Tan, "SIN": Sin}
21
+ MATRIX_PRECISIONS = ("f64", "f32")
22
+
23
+ # build_transform's cache: keyed on the numeric content of wcs_in/wcs_out
24
+ # (not object identity), so repeated calls with re-parsed-but-identical WCS
25
+ # (e.g. the same header read from disk each time, or the same pointing's
26
+ # multiple spectral channels) hit the cache too, not just calls reusing the
27
+ # same Python WCS object. Bounded and LRU-evicted since each entry pins a
28
+ # compiled transform closure (and jit will hold a compilation cache entry
29
+ # keyed on its identity) in memory for as long as the cache keeps it.
30
+ _TRANSFORM_CACHE_MAXSIZE = 32
31
+ _TRANSFORM_CACHE = OrderedDict()
32
+
33
+
34
+ def _mapprojax_wcs(astropy_wcs, precision="f64"):
35
+ code = projection_code(astropy_wcs)
36
+ crpix, cd, crval, sip = wcs_params_from_astropy(astropy_wcs)
37
+ return _PROVIDERS[code](crpix, cd, crval, sip=sip, precision=precision)
38
+
39
+
40
+ def _sip_fingerprint(sip):
41
+ if sip is None:
42
+ return None
43
+ return tuple(None if arr is None else (arr.shape, arr.tobytes()) for arr in (sip.a, sip.b, sip.ap, sip.bp))
44
+
45
+
46
+ def _wcs_fingerprint(astropy_wcs):
47
+ code = projection_code(astropy_wcs)
48
+ crpix, cd, crval, sip = wcs_params_from_astropy(astropy_wcs)
49
+ frame = repr(wcs_to_celestial_frame(astropy_wcs))
50
+ return (code, tuple(crpix), tuple(map(tuple, cd)), tuple(crval), _sip_fingerprint(sip), frame)
51
+
52
+
53
+ def compose_transform(mwcs_in, mwcs_out, rotation):
54
+ """Build a ``PixelTransform`` from already-constructed mapprojax WCS
55
+ objects and a fixed frame-rotation matrix — the pure-JAX half of
56
+ :func:`build_transform`, with no astropy involved.
57
+
58
+ ``mwcs_in``/``mwcs_out`` are JAX pytrees (mapprojax registers
59
+ ``Tan``/``Sin``/``Sip`` for exactly this), so unlike
60
+ ``build_transform`` (which takes non-traceable ``astropy.wcs.WCS``
61
+ objects), this function's WCS parameters — CRVAL, CD, SIP coefficients —
62
+ are differentiable: construct ``mwcs_in``/``mwcs_out`` inside a function
63
+ you pass to ``jax.grad``, and gradients flow through here into whatever
64
+ consumes the returned transform.
65
+
66
+ The relative rotation ``mwcs_in.r_matrix @ rotation @ mwcs_out.r_matrix.T``
67
+ is formed in JAX (so it stays differentiable with respect to CRVAL) and
68
+ handed to :meth:`mapprojax.reproject.Reprojector.from_matrix` with
69
+ ``matrix_precision="f64"``; the returned transform is that
70
+ ``Reprojector``'s ``pix_to_pix``.
71
+
72
+ Parameters
73
+ ----------
74
+ mwcs_in, mwcs_out : mapprojax WCS
75
+ ``mapprojax.wcs.Tan`` or ``mapprojax.wcs.Sin``, constructed with
76
+ ``precision="f64"`` (the ``precision=`` argument is required), and
77
+ optionally carrying a ``mapprojax.sip.Sip``.
78
+ rotation : array, shape (3, 3)
79
+ Fixed celestial-frame rotation from ``mwcs_out``'s frame to
80
+ ``mwcs_in``'s (not a differentiable parameter — it comes from
81
+ astropy's frame machinery in ``build_transform``, but a constant
82
+ array is equally valid to pass directly).
83
+ Returns
84
+ -------
85
+ PixelTransform
86
+ """
87
+
88
+ m_rel = mwcs_in.r_matrix @ rotation @ jnp.swapaxes(mwcs_out.r_matrix, -1, -2)
89
+ reprojector = Reprojector.from_matrix(
90
+ mwcs_out, mwcs_in, m_rel, matrix_precision="f64"
91
+ )
92
+
93
+ def transform(x_out, y_out):
94
+ return reprojector.pix_to_pix(x_out, y_out)
95
+
96
+ def pixel_map(x_out, y_out, *, bounds_shape=None):
97
+ return reprojector.pixel_map(x_out, y_out, bounds_shape=bounds_shape)
98
+
99
+ transform.pixel_map = pixel_map
100
+ return transform
101
+
102
+
103
+ def compose_transform_fp32(reprojector):
104
+ """Build a ``PixelTransform`` from a :class:`mapprojax.reproject.Reprojector`
105
+ -- the fp64-free counterpart of :func:`compose_transform`.
106
+
107
+ The celestial<->native rotation runs once, in true float64, at
108
+ ``Reprojector`` construction time (not per pixel -- see its
109
+ docstring); every per-pixel call after that (``pix_to_native -> apply
110
+ the composed rotation -> native_to_pix``) runs in float32,
111
+ independent of ``jax_enable_x64``.
112
+
113
+ Note
114
+ ----
115
+ Unlike :func:`compose_transform`, this is *not* differentiable with
116
+ respect to either WCS's ``crval``: ``Reprojector``'s rotation is
117
+ precomputed outside JAX's trace (see its docstring). It remains
118
+ differentiable with respect to the output pixel coordinates
119
+ (``x_out``, ``y_out``) -- all ``reproject_adaptive``'s
120
+ ``jax.jacfwd`` needs -- and, through the target leg, with respect to
121
+ ``crpix``/``cd``/SIP coefficients.
122
+
123
+ Parameters
124
+ ----------
125
+ reprojector : mapprojax.reproject.Reprojector
126
+ Maps output pixels (its ``wcs_src``) to input pixels (its
127
+ ``wcs_tgt``) -- see :func:`build_transform`.
128
+ Returns
129
+ -------
130
+ PixelTransform
131
+ """
132
+ def transform(x_out, y_out):
133
+ return reprojector.pix_to_pix(x_out, y_out)
134
+
135
+ def pixel_map(x_out, y_out, *, bounds_shape=None):
136
+ return reprojector.pixel_map(x_out, y_out, bounds_shape=bounds_shape)
137
+
138
+ transform.pixel_map = pixel_map
139
+ return transform
140
+
141
+
142
+ def build_transform(wcs_in, wcs_out, matrix_precision="f64"):
143
+ """Build a ``PixelTransform`` mapping output pixels to input pixels.
144
+
145
+ The composed pipeline, per output pixel: output pixel -> output-frame
146
+ unit vector (``wcs_out.unproj_xyz``) -> input-frame unit vector
147
+ (celestial frame rotation) -> input pixel (``wcs_in.proj_xyz``).
148
+
149
+ Parameters
150
+ ----------
151
+ wcs_in, wcs_out : astropy.wcs.WCS
152
+ TAN or SIN projections, optionally with SIP distortion.
153
+ matrix_precision : {"f64", "f32"}
154
+ - ``"f64"`` (default): builds ``mwcs_in``/``mwcs_out`` as mapprojax
155
+ ``Tan``/``Sin`` with ``precision="f64"`` and composes them via
156
+ :func:`compose_transform` (``Reprojector.from_matrix`` with
157
+ ``matrix_precision="f64"``), entirely in float64. Requires
158
+ ``jax_enable_x64`` for correctness.
159
+ - ``"f32"``: builds ``precision="f32"`` WCS objects and a
160
+ :class:`mapprojax.reproject.Reprojector` with
161
+ ``matrix_precision="f32"``, which composes the two WCSes'
162
+ rotations with the frame rotation into one matrix, once, in true
163
+ float64 on the host, independent of ``jax_enable_x64``; every
164
+ per-pixel call then runs in float32. No ``jax_enable_x64``
165
+ needed, and faster on GPUs where float64 throughput is much
166
+ lower than float32's. Less accurate than ``"f64"`` -- a few
167
+ e-4 px on the real SPHEREx ICRS<->Galactic pair in
168
+ ``tests/test_real_spherex.py``, growing with field-center
169
+ separation and with finer plate scales (see mapprojax's
170
+ ``Reprojector`` docstring); not differentiable with respect to
171
+ either WCS's ``crval`` (see :func:`compose_transform_fp32`).
172
+
173
+ Returns
174
+ -------
175
+ PixelTransform
176
+
177
+ Note
178
+ ----
179
+ This function itself is not traceable (it constructs and reads
180
+ ``astropy.wcs.WCS`` objects, which are not JAX pytrees) — the returned
181
+ *transform* is pure JAX, but you cannot ``jax.grad`` through
182
+ ``build_transform`` with respect to ``wcs_in``/``wcs_out``. To
183
+ differentiate with respect to WCS parameters (CRVAL, CD, SIP
184
+ coefficients), construct mapprojax WCS objects directly and use
185
+ :func:`compose_transform`.
186
+
187
+ Note
188
+ ----
189
+ Results are cached on the numeric content of ``wcs_in``/``wcs_out`` (CD,
190
+ CRPIX, CRVAL, SIP coefficients, projection code, and celestial frame)
191
+ — calling this again with an equivalent
192
+ WCS pair, even freshly reconstructed from the same header, returns the
193
+ *same* transform object rather than rebuilding it. Reusing that object
194
+ also keeps the compiled interpolation/adaptive call paths on their
195
+ established cache key; a changed WCS fingerprint or static configuration
196
+ can require retracing, especially for the autodiff-heavy adaptive core.
197
+ Reusing the same WCS pair across many calls (e.g. per-band exposures
198
+ sharing one pointing) pays the construction and compilation costs once.
199
+ """
200
+ if matrix_precision not in MATRIX_PRECISIONS:
201
+ raise ValueError(f"matrix_precision must be one of {MATRIX_PRECISIONS}, got {matrix_precision!r}")
202
+
203
+ cache_key = (_wcs_fingerprint(wcs_in), _wcs_fingerprint(wcs_out), matrix_precision)
204
+ cached = _TRANSFORM_CACHE.get(cache_key)
205
+ if cached is not None:
206
+ _TRANSFORM_CACHE.move_to_end(cache_key)
207
+ return cached
208
+
209
+ if matrix_precision == "f32":
210
+ wcs_in_np = _mapprojax_wcs(wcs_in, precision="f32")
211
+ wcs_out_np = _mapprojax_wcs(wcs_out, precision="f32")
212
+ frame_rotation = frame_rotation_matrix(wcs_out, wcs_in)
213
+ reprojector = Reprojector(wcs_out_np, wcs_in_np, frame_rotation=frame_rotation, matrix_precision="f32")
214
+ transform = compose_transform_fp32(reprojector)
215
+ else:
216
+ mwcs_in = _mapprojax_wcs(wcs_in)
217
+ mwcs_out = _mapprojax_wcs(wcs_out)
218
+ rotation = jnp.asarray(frame_rotation_matrix(wcs_out, wcs_in))
219
+ transform = compose_transform(mwcs_in, mwcs_out, rotation)
220
+
221
+ _TRANSFORM_CACHE[cache_key] = transform
222
+ if len(_TRANSFORM_CACHE) > _TRANSFORM_CACHE_MAXSIZE:
223
+ _TRANSFORM_CACHE.popitem(last=False)
224
+ return transform
225
+
226
+
227
+ def _clear_transform_cache():
228
+ _TRANSFORM_CACHE.clear()
229
+
230
+
231
+ build_transform.cache_clear = _clear_transform_cache
@@ -0,0 +1,198 @@
1
+ Metadata-Version: 2.4
2
+ Name: reprojax
3
+ Version: 0.2.0
4
+ Summary: JAX-native, differentiable reprojection of astronomical images (a port of astropy's reproject)
5
+ License-Expression: BSD-3-Clause
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Operating System :: OS Independent
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: numpy>=2.4.6
12
+ Requires-Dist: jax>=0.10.2
13
+ Requires-Dist: astropy>=7.2.0
14
+ Requires-Dist: mapprojax<0.4,>=0.3.0
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest; extra == "test"
17
+ Requires-Dist: reproject>=0.21; extra == "test"
18
+ Dynamic: license-file
19
+
20
+ # reprojax
21
+
22
+ A JAX-native, differentiable port of astropy's [`reproject`](https://github.com/astropy/reproject) package.
23
+
24
+ reprojax reprojects astronomical images between WCS frames on top of
25
+ [mapprojax](https://github.com/leejjoon/mapprojax), a JAX port of CDS's
26
+ `mapproj` crate providing fast, differentiable pixel↔sky transforms. The goal
27
+ is GPU-accelerated, batchable, gradient-friendly reprojection, validated
28
+ against the original `reproject` package as the accuracy reference.
29
+
30
+ reprojax is a JAX port of `reproject`'s algorithms and API shape; see
31
+ `LICENSE` for `reproject`'s BSD-3-Clause license, carried alongside
32
+ reprojax's own.
33
+
34
+ ## Status
35
+
36
+ 0.2.0, first public release. The validated scope is deliberate:
37
+
38
+ - **Projections**: TAN and SIN, with SIP distortion. Anything else raises
39
+ `NotImplementedError`.
40
+ - **Algorithms**: bilinear/nearest interpolation (`reproject_interp`) and the
41
+ DeForest (2004) adaptive anti-aliased algorithm (`reproject_adaptive`).
42
+ - **Reference**: `reproject` 0.21.0 is the golden accuracy reference. It is a
43
+ **test-time** dependency only; nothing in `reprojax` imports it at runtime.
44
+ - **Not included**: cubic interpolation, flux-conserving resampling, mosaic
45
+ coadds, filename/HDU/`NDData` input forms. See the
46
+ [issue tracker](https://github.com/leejjoon/reprojax/issues) for the plan.
47
+
48
+ ## Precision: read this before your first call
49
+
50
+ `matrix_precision` selects the transform's coordinate precision, and the
51
+ **default is `"f64"`, which requires JAX x64**. Without it your first call
52
+ raises:
53
+
54
+ ```
55
+ ValueError: precision='f64' requires float64. Call
56
+ jax.config.update('jax_enable_x64', True) before constructing it.
57
+ ```
58
+
59
+ Two valid choices:
60
+
61
+ | Mode | Requirement | Use it for |
62
+ | --- | --- | --- |
63
+ | `"f64"` (default) | `jax.config.update("jax_enable_x64", True)` before importing/creating anything JAX | Full-precision work, astrometry, gradients with respect to WCS parameters |
64
+ | `"f32"` | none — works with x64 off | GPU throughput; the celestial rotation is still composed in float64 on the host, only the per-pixel path is float32 |
65
+
66
+ `"f64"` is the accurate default. `"f32"` is the fast path and is documented
67
+ in terms of its own measured caps, not as an equivalent to `"f64"` (below).
68
+
69
+ ## Quick start
70
+
71
+ No data files needed. This runs exactly as written, from an installed package:
72
+
73
+ ```python
74
+ import numpy as np
75
+ import jax
76
+ jax.config.update("jax_enable_x64", True) # required by the default matrix_precision="f64"
77
+
78
+ from astropy.io import fits
79
+ from astropy.wcs import WCS
80
+ from reprojax import reproject_interp
81
+
82
+ ny_in = nx_in = 64
83
+ hdr_in = fits.Header()
84
+ hdr_in["NAXIS"] = 2
85
+ hdr_in["NAXIS1"] = hdr_in["NAXIS2"] = ny_in
86
+ hdr_in["CTYPE1"], hdr_in["CTYPE2"] = "RA---TAN", "DEC--TAN"
87
+ hdr_in["CRPIX1"] = hdr_in["CRPIX2"] = ny_in / 2 + 0.5
88
+ hdr_in["CRVAL1"], hdr_in["CRVAL2"] = 210.0, 54.0
89
+ hdr_in["CDELT1"], hdr_in["CDELT2"] = -1e-3, 1e-3
90
+
91
+ y, x = np.mgrid[:ny_in, :nx_in]
92
+ data = np.exp(-(((x - 32) ** 2 + (y - 32) ** 2) / (2 * 6.0 ** 2))).astype(np.float32)
93
+
94
+ hdr_out = hdr_in.copy()
95
+ hdr_out["CRVAL1"] = 210.02 # shift the output grid
96
+
97
+ array, footprint = reproject_interp((data, WCS(hdr_in)), hdr_out, shape_out=(40, 40))
98
+
99
+ assert array.shape == footprint.shape == (40, 40)
100
+ assert np.isfinite(array).any()
101
+ print(float(np.nanmax(array)))
102
+ ```
103
+
104
+ `array` holds the resampled image (NaN where no valid input pixel was
105
+ covered) and `footprint` is 1.0 where a valid input contributed, 0.0
106
+ otherwise. Adding `matrix_precision="f32"` runs the same call without x64.
107
+
108
+ ## Architecture
109
+
110
+ - `reprojax.core` — the `PixelTransform` contract: a callable
111
+ `transform(x_out, y_out) -> (x_in, y_in)` mapping output-image pixel
112
+ coordinates to input-image pixel coordinates. This is the seam the rest of
113
+ the package is built around.
114
+ - `reprojax.transform` — builds a `PixelTransform` from a pair of astropy
115
+ WCS objects, using mapprojax for the projection math and astropy for
116
+ celestial frame rotation.
117
+ - Mapprojax-backed transforms also expose an optional
118
+ `transform.pixel_map(x_out, y_out, bounds_shape=(ny, nx))` capability. It
119
+ supplies finite coordinates and geometric validity to interpolation while
120
+ the two-result callable contract remains available for generic providers.
121
+
122
+ ## Accuracy
123
+
124
+ Two kinds of numbers appear here, and they are labelled: an **observation**
125
+ is what the two implementations measured on a case, an **enforced tolerance**
126
+ is what the test suite requires.
127
+
128
+ Real SPHEREx pair (2040² ICRS TAN+SIP exposure onto the 1201² Galactic TAN
129
+ template, compared against `reproject` 0.21.0):
130
+
131
+ | Algorithm | Observation | Enforced |
132
+ | --- | --- | --- |
133
+ | `reproject_interp` | 1,345,444 of 1,345,460 jointly-valid pixels agree to `rtol <= 1e-8` | `rtol=1e-6, atol=0.01` over all jointly-valid pixels, plus exact footprint equality |
134
+ | `reproject_adaptive` | median disagreement ~`4e-8`; 1,334,193 of 1,334,200 jointly-valid pixels form the bulk | `rtol=1e-6, atol=0.005`, plus exact footprint equality |
135
+
136
+ In both algorithms the pixels outside the stated bulk are 16 (interp) and 7
137
+ (adaptive) pixels sitting at one real, un-flagged data spike: this fixture
138
+ drops the `FLAGS` extension that would normally mask it, so a sub-micropixel
139
+ position difference next to a ~10⁴-per-pixel local gradient is amplified into
140
+ a value difference of up to ~0.01 (interp) / ~0.003 (adaptive). The fixtures
141
+ are described in [`tests/data/README.md`](tests/data/README.md).
142
+
143
+ Synthetic TAN / TAN-SIP pairs: interpolated images agree within enforced
144
+ `rtol=1e-8, atol=1e-9`; adaptive within enforced `rtol=1e-6, atol=1e-8`.
145
+
146
+ `matrix_precision="f32"` has **enforced coordinate caps** of `6e-4` px
147
+ (forward) and `5e-4` px (reverse) per direction on the real pair. These bound
148
+ the coordinate map only — they are not image-agreement bounds, and they are
149
+ not a claim of float64 equivalence.
150
+
151
+ On a Strix Halo host (ROCm device, JAX 0.11.1 + rocm7 plugins 0.11.1),
152
+ batched and `jax.jit`-compiled reprojax reprojects 8 SPHEREx-scale
153
+ (2040² → 1201²) exposures in 0.026 s. The looped CPU `reproject_interp`
154
+ baseline for the same work measured 4.4–7.9 s across runs on this host, i.e.
155
+ roughly 170–300×. The reprojax figure is stable; the baseline is what varies.
156
+ This is a cross-backend batched-vs-looped comparison, not a like-for-like
157
+ backend comparison, and the CPU skips the batched GPU-only tests.
158
+
159
+ ## Development
160
+
161
+ ```bash
162
+ python -m pip install -e ".[test]" -c ci/latest.txt
163
+ python -m pytest tests/
164
+ ```
165
+
166
+ Using a `ci/` constraints file matters: the golden reference is pinned to
167
+ `reproject==0.21.0` there, and the suite asserts the installed version. Use
168
+ `ci/minimum.txt` for the oldest supported stack (Python 3.11). If a
169
+ constraint conflicts with your environment, install `.[test]` without it and
170
+ expect the version guard to tell you which pin is off.
171
+
172
+ The test suite needs `tests/data/`, which is 16 MB of real SPHEREx
173
+ exposures and is therefore not shipped in the sdist. Install from a checkout
174
+ when you want to run the tests.
175
+
176
+ ## License
177
+
178
+ BSD-3-Clause. The SIP and reprojection algorithms are ported from
179
+ [`reproject`](https://github.com/astropy/reproject); its license is carried in
180
+ [`LICENSE`](LICENSE) alongside reprojax's own.
181
+
182
+ ## Test data
183
+
184
+ The integration tests use a real SPHEREx exposure and a Galactic template
185
+ header. Only the exposure is mission data: NASA's Science Mission
186
+ Directorate releases mission data into the public domain (SPD-41), while the
187
+ template header is project metadata under reprojax's own licence. If you
188
+ publish work that uses the exposure, include the acknowledgement IRSA
189
+ requests:
190
+
191
+ > This publication makes use of data products from the Spectro-Photometer for
192
+ the History of the Universe, Epoch of Reionization and Ices Explorer
193
+ > (SPHEREx), which is a joint project of the Jet Propulsion Laboratory and the
194
+ > California Institute of Technology, and is funded by the National
195
+ > Aeronautics and Space Administration.
196
+
197
+ Provenance, checksums and the citation DOIs are in
198
+ [`tests/data/README.md`](tests/data/README.md).
@@ -0,0 +1,12 @@
1
+ reprojax/__init__.py,sha256=YYsuLMCsd7olVt0avymZIHvAP8W-w-FR05h1b_9Vmkk,470
2
+ reprojax/_astropy_wcs.py,sha256=thtdOSwdV5cJSY6HwB5G9zDzVK_Uan-QMC7P8a0WtpA,2733
3
+ reprojax/adaptive.py,sha256=AggxrpAmgVFpUPCqvbbYtzl6ouRR8jJIPvpA2PgWIeY,14038
4
+ reprojax/core.py,sha256=Ad6y0cC9ceTiWAD0A3YtCgbYgRgehvZkcoaPJqdE3H8,2427
5
+ reprojax/interp.py,sha256=YeDMbE6fL05qgHI9WuzeuScOYQh7BbMwtGBIGDeuB1E,11419
6
+ reprojax/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ reprojax/transform.py,sha256=8F_1-BGhMo7FCuZY9J5B-zEz6rS7KT1GS71rYlKOMiM,9992
8
+ reprojax-0.2.0.dist-info/licenses/LICENSE,sha256=i43S9vaJvTB0wJJqbreVhKW33qADGXP2ZkgY6A9XuPI,3130
9
+ reprojax-0.2.0.dist-info/METADATA,sha256=_Du-WoIdixNt2UmFAnPiZc-XOzsm82NeVyAgGe_gxU4,8604
10
+ reprojax-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ reprojax-0.2.0.dist-info/top_level.txt,sha256=EwQoY_6Xd2nriNTaiQU7SBqK7IKHjclqGM_bDkpvank,9
12
+ reprojax-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,63 @@
1
+ Copyright (c) 2026, Jae-Joon Lee
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ * Redistributions of source code must retain the above copyright notice, this
8
+ list of conditions and the following disclaimer.
9
+
10
+ * Redistributions in binary form must reproduce the above copyright notice,
11
+ this list of conditions and the following disclaimer in the documentation
12
+ and/or other materials provided with the distribution.
13
+
14
+ * Neither the name of the reprojax project nor the names of its contributors
15
+ may be used to endorse or promote products derived from this software
16
+ without specific prior written permission.
17
+
18
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
21
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
22
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
23
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
24
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
25
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
26
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
27
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
28
+ POSSIBILITY OF SUCH DAMAGE.
29
+
30
+ ---
31
+
32
+ reprojax is a JAX port of astropy's `reproject` package
33
+ (https://github.com/astropy/reproject), which carries the following
34
+ license:
35
+
36
+ Copyright (c) 2011-16, Thomas P. Robitaille
37
+ All rights reserved.
38
+
39
+ Redistribution and use in source and binary forms, with or without
40
+ modification, are permitted provided that the following conditions are met:
41
+
42
+ * Redistributions of source code must retain the above copyright notice, this
43
+ list of conditions and the following disclaimer.
44
+
45
+ * Redistributions in binary form must reproduce the above copyright notice,
46
+ this list of conditions and the following disclaimer in the documentation
47
+ and/or other materials provided with the distribution.
48
+
49
+ * Neither the name of the Astropy project nor the names of its contributors
50
+ may be used to endorse or promote products derived from this software
51
+ without specific prior written permission.
52
+
53
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
54
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
55
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
56
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
57
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
58
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
59
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
60
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
61
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
62
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
63
+ POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1 @@
1
+ reprojax