The resamplers#

This notebook runs all resamplers on the same dataset so you can compare their behaviour, accuracy, and speed.

Setup#

import numpy as np
from healpix_resample import (
    NearestResampler,
    BilinearResampler,
    BicubicResampler,
    CloughTocherResampler,
    PSFResampler,
    CellPointResampler,
    ConservativeResampler,
    BitmaskResampler,
    CategoricalResampler,
)

# Shared dataset: a small structured grid near the origin
ndata = 128
lon_grid, lat_grid = np.meshgrid(
    0.3 * np.arange(ndata) / ndata,
    0.3 * np.arange(ndata) / ndata,
)
lon = lon_grid.ravel()
lat = lat_grid.ravel()
val = lon  # simple field: value = longitude

level = 15  # nside = 32768 — high resolution

NearestResampler#

Each point is assigned to its single nearest HEALPix cell. Fast and simple, but can produce blocky results.

nr_nearest = NearestResampler(lon_deg=lon, lat_deg=lat, level=level)
res_nearest = nr_nearest.resample(val)

rval_nearest = nr_nearest.invert(res_nearest.cell_data)
mse_nearest = np.mean((rval_nearest - val) ** 2)
print(f"Nearest  — output cells: {res_nearest.cell_data.shape[0]}, MSE: {mse_nearest:.2e}")
Nearest  — output cells: 28268, MSE: 3.09e-37

BilinearResampler#

Uses the 4 nearest cells with distance-based weights. Smoother than nearest, good for locally grid-like data.

nr_bili = BilinearResampler(lon_deg=lon, lat_deg=lat, level=level)
res_bili = nr_bili.resample(val, lam=0.0)

rval_bili = nr_bili.invert(res_bili.cell_data)
mse_bili = np.mean((rval_bili - val) ** 2)
print(f"Bilinear — output cells: {res_bili.cell_data.shape[0]}, MSE: {mse_bili:.2e}")
Bilinear — output cells: 28268, MSE: 2.91e-08
/home/runner/work/healpix-resample/healpix-resample/healpix_resample/bilinear.py:79: UserWarning: Sparse invariant checks are implicitly disabled. Memory errors (e.g. SEGFAULT) will occur when operating on a sparse tensor which violates the invariants, but checks incur performance overhead. To silence this warning, explicitly opt in or out. See `torch.sparse.check_sparse_tensor_invariants.__doc__` for guidance.  (Triggered internally at /home/conda/feedstock_root/build_artifacts/libtorch_1784990603925/work/aten/src/ATen/Context.cpp:816.)
  M_coo = torch.sparse_coo_tensor(
/home/runner/work/healpix-resample/healpix-resample/healpix_resample/bilinear.py:120: UserWarning: Sparse CSR tensor support is in beta state. If you miss a functionality in the sparse tensor support, please submit a feature request to https://github.com/pytorch/pytorch/issues. (Triggered internally at /home/conda/feedstock_root/build_artifacts/libtorch_1784990603925/work/aten/src/ATen/SparseCsrTensorImpl.cpp:49.)
  self.M  = M_coo.to_sparse_csr()

BicubicResampler#

Uses the 16 nearest cells with a radial generalization of Keys’ cubic convolution kernel. Smoother/sharper than bilinear on fields with curvature, still a fixed non-iterative interpolation (no CG solve).

nr_bicubic = BicubicResampler(lon_deg=lon, lat_deg=lat, level=level)
res_bicubic = nr_bicubic.resample(val, lam=0.0)

rval_bicubic = nr_bicubic.invert(res_bicubic.cell_data)
mse_bicubic = np.mean((rval_bicubic - val) ** 2)
print(f"Bicubic  — output cells: {res_bicubic.cell_data.shape[0]}, MSE: {mse_bicubic:.2e}")
Bicubic  — output cells: 28268, MSE: 1.20e-07

CloughTocherResampler#

A Delaunay triangulation + Clough-Tocher C1 cubic interpolant — a genuine bivariate interpolant (exact at input points, C1 continuous across triangle edges), rather than a radial kernel sum like BicubicResampler. On fields with real curvature this shows fewer small-scale artifacts than BicubicResampler, because its discrete KNN neighbour-set membership can flip between adjacent output cells; Delaunay/CT has no such failure mode. Only resamples cells whose center falls inside the convex hull of the input samples (no extrapolation), and is intended for regional/local input extents (it projects samples to a local tangent plane — see docs/user-guide/regrid_to_healpix_clough_tocher.md). invert() is not implemented for this class (see its docstring).

nr_ct = CloughTocherResampler(lon_deg=lon, lat_deg=lat, level=level, verbose=False)
res_ct = nr_ct.resample(val)

print(f"Clough-Tocher — output cells: {res_ct.cell_data.shape[0]} (only cells inside the sample convex hull)")
Clough-Tocher — output cells: 27662 (only cells inside the sample convex hull)

Conservative mode (BilinearResampler / BicubicResampler)#

Both resamplers above interpolate: each cell’s value is renormalized against whichever samples happen to link to it, which does not guarantee the total is preserved. Passing area= and resample(conservative=True) switches to redistributing each sample’s own value across its nearest cells instead, so the global total is conserved exactly (see docs/user-guide/regrid_to_healpix_bilinear.md for the full derivation — this is issue #44, “conservative bi-linear is missing”).

rng = np.random.default_rng(0)
area = rng.uniform(0.5, 2.0, size=lon.shape)
target = (val * area).sum()

nr_bili_area = BilinearResampler(lon_deg=lon, lat_deg=lat, level=level, area=area)
res_bili_interp = nr_bili_area.resample(val)                          # conservative=False (default)
res_bili_cons = nr_bili_area.resample(val, conservative=True)

print(f"Input               sum(val*area)            = {target:.6f}")
print(f"Bilinear interpolating (conservative=False)   = {res_bili_interp.cell_data.sum():.6f}  (not expected to match)")
print(f"Bilinear conservative  (conservative=True)     = {res_bili_cons.cell_data.sum():.6f}  (should match exactly)")
Input               sum(val*area)            = 3053.753943
Bilinear interpolating (conservative=False)   = 4211.637612  (not expected to match)
Bilinear conservative  (conservative=True)     = 3053.753943  (should match exactly)

PSFResampler#

Applies a Gaussian PSF kernel around each sample and solves a damped least-squares problem with Conjugate Gradient. Best reconstruction quality — especially when data is dense or the field has fine structure.

nr_psf = PSFResampler(lon_deg=lon, lat_deg=lat, level=level, threshold=0.5, verbose=False)
res_psf = nr_psf.resample(val, lam=0.0)

rval_psf = nr_psf.invert(res_psf.cell_data)
mse_psf = np.mean((rval_psf - val) ** 2)
print(f"PSF      — output cells: {res_psf.cell_data.shape[0]}, MSE: {mse_psf:.2e}")
print(f"           CG iterations: {res_psf.cg_niters}")
PSF      — output cells: 27892, MSE: 3.86e-14
           CG iterations: 100

CellPointResampler#

Special mode: encodes each point as a HEALPix cell ID at level 29. No interpolation — used for exact point indexing.

nr_zuniq = CellPointResampler(lon_deg=lon, lat_deg=lat)
res_zuniq = nr_zuniq.resample(val)

rval_zuniq = nr_zuniq.invert(res_zuniq.cell_data)
max_err = np.max(np.abs(rval_zuniq - val))
print(f"Zuniq    — output cells: {res_zuniq.cell_data.shape[0]}, max error: {max_err:.2e}")
Zuniq    — output cells: 16384, max error: 0.00e+00

ConservativeResampler#

Bins each point into its containing HEALPix cell and accumulates an area-weighted sum, so the total integrated quantity (sum(val * area)) is preserved exactly between the sample-space and HEALPix-cell representations — unlike the other resamplers, which interpolate values rather than conserve a flux.

Use this when val is an intensive/density quantity (flux per m², temperature, …) measured over pixels of known — possibly non-uniform — footprint area. If your samples already carry an extensive, pre-integrated quantity (a total, e.g. counts), leave area at its default of 1.0 and plain summation is exactly conservative regardless of how footprint sizes vary.

# Give samples a non-uniform footprint area to demonstrate the area weighting.
rng = np.random.default_rng(0)
area = rng.uniform(0.5, 2.0, size=lon.shape)

nr_cons = ConservativeResampler(lon_deg=lon, lat_deg=lat, level=level, area=area)
res_cons = nr_cons.resample(val)

rval_cons = nr_cons.invert(res_cons.cell_data)

flux_in = np.sum(val * area)
flux_out = np.sum(res_cons.cell_data)
flux_back = np.sum(rval_cons * area)
print(f"Conservative — output cells: {res_cons.cell_data.shape[0]}")
print(f"  sum(val*area)         = {flux_in:.6f}")
print(f"  sum(hval)              = {flux_out:.6f}  (should equal the line above)")
print(f"  sum(invert(hval)*area) = {flux_back:.6f}  (should equal the line above)")
Conservative — output cells: 15573
  sum(val*area)         = 3053.753943
  sum(hval)              = 3053.753943  (should equal the line above)
  sum(invert(hval)*area) = 3053.753943  (should equal the line above)

CategoricalResampler#

For mutually-exclusive class labels (issue #43): resamples a one-hot indicator per class through BilinearResampler (by default) and picks the argmax per cell. return_scores=True also returns a softmax-normalized per-class confidence.

# Three "land-cover" classes split by longitude tercile.
land_cover = np.digitize(lon, np.quantile(lon, [1 / 3, 2 / 3])).astype(np.int64)

nr_cat = CategoricalResampler(lon_deg=lon, lat_deg=lat, level=level)
res_cat = nr_cat.resample(land_cover, return_scores=True)

winner_score = res_cat.scores.max(axis=0)  # (K,) softmax score of the winning class per cell
print(f"Categorical — output cells: {res_cat.cell_data.shape[0]}, classes found: {res_cat.classes}")
print(f"Winning-class softmax score range: [{winner_score.min():.3f}, {winner_score.max():.3f}]")
Categorical — output cells: 28268, classes found: [0 1 2]
Winning-class softmax score range: [0.817, 1.000]

BitmaskResampler#

For independent, co-occurring boolean flags packed into an integer (e.g. an 8-bit quality/cloud mask): each bit is resampled and thresholded independently, then the bits are recombined.

bit0 = (lon > np.median(lon)).astype(np.int64)   # e.g. "cloud" flag
bit1 = (lat > np.median(lat)).astype(np.int64)   # e.g. "cloud-shadow" flag, independent of bit0
quality_mask = bit0 | (bit1 << 1)

nr_bitmask = BitmaskResampler(lon_deg=lon, lat_deg=lat, level=level, n_bits=2)
res_bitmask = nr_bitmask.resample(quality_mask)

print(f"Bitmask — output cells: {res_bitmask.cell_data.shape[0]}")
print(f"Distinct output values: {sorted(set(res_bitmask.cell_data.tolist()))}  (subset of [0, 1, 2, 3])")
Bitmask — output cells: 28268
Distinct output values: [0, 1, 2, 3]  (subset of [0, 1, 2, 3])