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 +15 -0
- reprojax/_astropy_wcs.py +74 -0
- reprojax/adaptive.py +318 -0
- reprojax/core.py +55 -0
- reprojax/interp.py +278 -0
- reprojax/py.typed +0 -0
- reprojax/transform.py +231 -0
- reprojax-0.2.0.dist-info/METADATA +198 -0
- reprojax-0.2.0.dist-info/RECORD +12 -0
- reprojax-0.2.0.dist-info/WHEEL +5 -0
- reprojax-0.2.0.dist-info/licenses/LICENSE +63 -0
- reprojax-0.2.0.dist-info/top_level.txt +1 -0
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
|
+
]
|
reprojax/_astropy_wcs.py
ADDED
|
@@ -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,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
|