|
| 1 | +# Reproject Module: Comprehensive Benchmarks |
| 2 | + |
| 3 | +Generated: 2026-03-22 |
| 4 | + |
| 5 | +Hardware: AMD Ryzen / NVIDIA A6000 GPU, PCIe Gen4, NVMe SSD |
| 6 | + |
| 7 | +Python 3.14, NumPy, Numba, CuPy, Dask, pyproj, rioxarray (GDAL) |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## 1. Full Pipeline Benchmark (read -> reproject -> write) |
| 12 | + |
| 13 | +Source file: Copernicus DEM COG (`Copernicus_DSM_COG_10_N40_00_W075_00_DEM.tif`), 3600x3600, WGS84, deflate+floating-point predictor. Reprojected to Web Mercator (EPSG:3857). Median of 3 runs after warmup. |
| 14 | + |
| 15 | +```python |
| 16 | +from xrspatial.geotiff import read_geotiff, write_geotiff |
| 17 | +from xrspatial.reproject import reproject |
| 18 | + |
| 19 | +dem = read_geotiff('Copernicus_DSM_COG_10_N40_00_W075_00_DEM.tif') |
| 20 | +dem_merc = reproject(dem, 'EPSG:3857') |
| 21 | +write_geotiff(dem_merc, 'output.tif') |
| 22 | +``` |
| 23 | + |
| 24 | +| Backend | End-to-end time | Notes | |
| 25 | +|:--------|----------------:|:------| |
| 26 | +| NumPy | 2,723 ms | Single-threaded Numba JIT resampling | |
| 27 | +| CuPy GPU | 348 ms | CUDA kernel for coordinate transform + resampling | |
| 28 | +| Dask+CuPy GPU | 343 ms | Chunked (512) GPU pipeline | |
| 29 | +| Dask (CPU) | 10,967 ms | Chunked (512) with Dask scheduler overhead | |
| 30 | +| rioxarray (GDAL) | 418 ms | C-level warp, highly optimized | |
| 31 | + |
| 32 | +The GPU path (CuPy or Dask+CuPy) is the fastest option for large rasters, running slightly faster than GDAL. The NumPy path is slower due to Python/Numba overhead in the resampling loop. The Dask CPU path has significant scheduler overhead for this single-file workload. |
| 33 | + |
| 34 | +--- |
| 35 | + |
| 36 | +## 2. Projection Coverage and Accuracy |
| 37 | + |
| 38 | +Each projection was tested with 5 geographically appropriate points. "Max error vs pyproj" measures the maximum positional difference between the Numba JIT inverse transform and pyproj's reference implementation. Errors are measured as approximate ground distance. |
| 39 | + |
| 40 | +| Projection | EPSG examples | Max error vs pyproj | CPU Numba | CUDA GPU | |
| 41 | +|:-----------|:-------------|--------------------:|:---------:|:--------:| |
| 42 | +| Web Mercator | 3857 | < 0.001 mm | yes | yes | |
| 43 | +| UTM / Transverse Mercator | 326xx, 327xx | < 0.001 mm | yes | yes | |
| 44 | +| Ellipsoidal Mercator | 3395 | < 0.001 mm | yes | yes | |
| 45 | +| Lambert Conformal Conic | 2154, 2229 | 0.003 mm | yes | yes | |
| 46 | +| Albers Equal Area | 5070 | 3.5 m | yes | yes | |
| 47 | +| Cylindrical Equal Area | 6933 | 4.8 m | yes | yes | |
| 48 | +| Sinusoidal | MODIS | 0.001 mm | yes | yes | |
| 49 | +| Lambert Azimuthal Equal Area | 3035 | see note | yes | yes | |
| 50 | +| Polar Stereographic (Antarctic) | 3031 | < 0.001 mm | yes | yes | |
| 51 | +| Polar Stereographic (Arctic) | 3413 | < 0.001 mm | yes | yes | |
| 52 | +| Oblique Stereographic | custom WGS84 | < 0.001 mm | yes | fallback | |
| 53 | +| Oblique Mercator (Hotine) | 3375 | N/A | disabled | fallback | |
| 54 | +| State Plane (tmerc) | 26983 | 43 cm | yes | yes | |
| 55 | +| State Plane (LCC, ftUS) | 2229 | 19 cm | yes | yes | |
| 56 | + |
| 57 | +**Notes:** |
| 58 | +- LAEA Europe (3035): The current implementation has a known latitude bias (~700m near Paris, larger at the projection's edges). This is an area for future improvement; for high-accuracy LAEA work, the pyproj fallback is used for unsupported ellipsoids. |
| 59 | +- Albers and CEA: Errors of 3-5m stem from the authalic latitude series approximation. Acceptable for most raster reprojection at typical DEM resolutions (30m+). |
| 60 | +- State Plane: Sub-metre accuracy in both tmerc and LCC variants. Unit conversion (US survey feet) is handled internally. |
| 61 | +- Oblique Stereographic: The Numba kernel exists and works for WGS84-based CRS definitions. EPSG:28992 (RD New) uses the Bessel ellipsoid without a registered datum, so it falls back to pyproj. |
| 62 | +- Oblique Mercator: Kernel implemented but disabled pending alignment with PROJ's omerc.cpp variant handling. Falls back to pyproj. |
| 63 | + |
| 64 | +### Reproject-only timing (1024x1024, bilinear) |
| 65 | + |
| 66 | +| Transform | xrspatial | rioxarray | |
| 67 | +|:-----------|----------:|----------:| |
| 68 | +| WGS84 -> Web Mercator | 23 ms | 14 ms | |
| 69 | +| WGS84 -> UTM 33N | 24 ms | 18 ms | |
| 70 | +| WGS84 -> Albers CONUS | 41 ms | 33 ms | |
| 71 | +| WGS84 -> LAEA Europe | 57 ms | 17 ms | |
| 72 | +| WGS84 -> Polar Stere S | 44 ms | 38 ms | |
| 73 | +| WGS84 -> LCC France | 44 ms | 25 ms | |
| 74 | +| WGS84 -> Ellipsoidal Merc | 27 ms | 14 ms | |
| 75 | +| WGS84 -> CEA EASE-Grid | 24 ms | 15 ms | |
| 76 | + |
| 77 | +At 1024x1024, rioxarray (GDAL) is generally faster than the NumPy backend for reproject-only workloads. The GPU backend closes this gap and pulls ahead for larger rasters (see Section 1). The xrspatial advantage is its pure-Python stack with no GDAL dependency, four-backend dispatch (NumPy/CuPy/Dask/Dask+CuPy), and integrated vertical/datum handling. |
| 78 | + |
| 79 | +### Merge timing (4 overlapping same-CRS tiles) |
| 80 | + |
| 81 | +| Tile size | xrspatial | rioxarray | Speedup | |
| 82 | +|:----------|----------:|----------:|--------:| |
| 83 | +| 512x512 | 16 ms | 29 ms | 1.8x | |
| 84 | +| 1024x1024 | 52 ms | 76 ms | 1.5x | |
| 85 | +| 2048x2048 | 361 ms | 280 ms | 0.8x | |
| 86 | + |
| 87 | +Same-CRS merge skips reprojection and places tiles by coordinate alignment. xrspatial is faster at small to medium sizes; rioxarray catches up at larger sizes due to its C-level copy routines. |
| 88 | + |
| 89 | +--- |
| 90 | + |
| 91 | +## 3. Datum Shift Coverage |
| 92 | + |
| 93 | +The reproject module handles horizontal datum shifts for non-WGS84 source CRS. It first tries grid-based shifts (downloaded from the PROJ CDN on first use), falling back to 7-parameter Helmert transforms when no grid is available. |
| 94 | + |
| 95 | +### Grid-based shifts (sub-metre accuracy) |
| 96 | + |
| 97 | +| Registry key | Grid file | Coverage | Description | |
| 98 | +|:-------------|:----------|:---------|:------------| |
| 99 | +| NAD27_CONUS | us_noaa_conus.tif | CONUS | NAD27 -> NAD83 (NADCON) | |
| 100 | +| NAD27_NADCON5_CONUS | us_noaa_nadcon5_nad27_nad83_1986_conus.tif | CONUS | NAD27 -> NAD83 (NADCON5, preferred) | |
| 101 | +| NAD27_ALASKA | us_noaa_alaska.tif | Alaska | NAD27 -> NAD83 (NADCON) | |
| 102 | +| NAD27_HAWAII | us_noaa_hawaii.tif | Hawaii | Old Hawaiian -> NAD83 | |
| 103 | +| NAD27_PRVI | us_noaa_prvi.tif | PR/USVI | NAD27 -> NAD83 | |
| 104 | +| OSGB36_UK | uk_os_OSTN15_NTv2_OSGBtoETRS.tif | UK | OSGB36 -> ETRS89 (OSTN15) | |
| 105 | +| AGD66_GDA94 | au_icsm_A66_National_13_09_01.tif | Australia NT | AGD66 -> GDA94 | |
| 106 | +| DHDN_ETRS89_DE | de_adv_BETA2007.tif | Germany | DHDN -> ETRS89 | |
| 107 | +| MGI_ETRS89_AT | at_bev_AT_GIS_GRID.tif | Austria | MGI -> ETRS89 | |
| 108 | +| ED50_ETRS89_ES | es_ign_SPED2ETV2.tif | Spain (E coast) | ED50 -> ETRS89 | |
| 109 | +| RD_ETRS89_NL | nl_nsgi_rdcorr2018.tif | Netherlands | RD -> ETRS89 | |
| 110 | +| BD72_ETRS89_BE | be_ign_bd72lb72_etrs89lb08.tif | Belgium | BD72 -> ETRS89 | |
| 111 | +| CH1903_ETRS89_CH | ch_swisstopo_CHENyx06_ETRS.tif | Switzerland | CH1903 -> ETRS89 | |
| 112 | +| D73_ETRS89_PT | pt_dgt_D73_ETRS89_geo.tif | Portugal | D73 -> ETRS89 | |
| 113 | + |
| 114 | +Grids are downloaded from `cdn.proj.org` on first use and cached in `~/.cache/xrspatial/proj_grids/`. Bilinear interpolation within the grid is done via Numba JIT. |
| 115 | + |
| 116 | +### Helmert fallback (1-5m accuracy) |
| 117 | + |
| 118 | +When no grid covers the area, a 7-parameter (or 3-parameter) geocentric Helmert transform is applied: |
| 119 | + |
| 120 | +| Datum / Ellipsoid | Type | Parameters (dx, dy, dz, rx, ry, rz, ds) | |
| 121 | +|:------------------|:-----|:-----------------------------------------| |
| 122 | +| NAD27 / Clarke 1866 | 3-param | (-8, 160, 176, 0, 0, 0, 0) | |
| 123 | +| OSGB36 / Airy | 7-param | (446.4, -125.2, 542.1, 0.15, 0.25, 0.84, -20.5) | |
| 124 | +| DHDN / Bessel | 7-param | (598.1, 73.7, 418.2, 0.20, 0.05, -2.46, 6.7) | |
| 125 | +| MGI / Bessel | 7-param | (577.3, 90.1, 463.9, 5.14, 1.47, 5.30, 2.42) | |
| 126 | +| ED50 / Intl 1924 | 7-param | (-87, -98, -121, 0, 0, 0.81, -0.38) | |
| 127 | +| BD72 / Intl 1924 | 7-param | (-106.9, 52.3, -103.7, 0.34, -0.46, 1.84, -1.27) | |
| 128 | +| CH1903 / Bessel | 3-param | (674.4, 15.1, 405.3, 0, 0, 0, 0) | |
| 129 | +| D73 / Intl 1924 | 3-param | (-239.7, 88.2, 30.5, 0, 0, 0, 0) | |
| 130 | +| AGD66 / ANS | 3-param | (-133, -48, 148, 0, 0, 0, 0) | |
| 131 | +| Tokyo / Bessel | 3-param | (-146.4, 507.3, 680.5, 0, 0, 0, 0) | |
| 132 | + |
| 133 | +Grid-based accuracy is typically 0.01-0.1m; Helmert fallback accuracy is 1-5m depending on the datum. |
| 134 | + |
| 135 | +--- |
| 136 | + |
| 137 | +## 4. Vertical Datum Support |
| 138 | + |
| 139 | +The module provides geoid undulation lookup from EGM96 (vendored, 15-arcminute global grid, 2.6MB) and optionally EGM2008 (25-arcminute, 77MB, downloaded on first use). |
| 140 | + |
| 141 | +### API |
| 142 | + |
| 143 | +```python |
| 144 | +from xrspatial.reproject import geoid_height, ellipsoidal_to_orthometric |
| 145 | + |
| 146 | +# Single point |
| 147 | +N = geoid_height(-74.0, 40.7) # New York: -32.86m |
| 148 | + |
| 149 | +# Convert GPS height to map height |
| 150 | +H = ellipsoidal_to_orthometric(100.0, -74.0, 40.7) # 132.86m |
| 151 | + |
| 152 | +# Batch (array) |
| 153 | +N = geoid_height(lon_array, lat_array) |
| 154 | + |
| 155 | +# Raster grid |
| 156 | +from xrspatial.reproject import geoid_height_raster |
| 157 | +N_grid = geoid_height_raster(dem) |
| 158 | +``` |
| 159 | + |
| 160 | +### Accuracy vs pyproj geoid |
| 161 | + |
| 162 | +| Location | xrspatial EGM96 (m) | pyproj EGM96 (m) | Difference | |
| 163 | +|:---------|---------------------:|------------------:|-----------:| |
| 164 | +| New York (-74.0, 40.7) | -32.86 | -32.77 | 0.09 m | |
| 165 | +| Paris (2.35, 48.85) | 44.59 | 44.57 | 0.02 m | |
| 166 | +| Tokyo (139.7, 35.7) | 35.75 | 36.80 | 1.06 m | |
| 167 | +| Null Island (0.0, 0.0) | 17.15 | 17.16 | 0.02 m | |
| 168 | +| Rio (-43.2, -22.9) | -5.59 | -5.43 | 0.16 m | |
| 169 | + |
| 170 | +The 1.06m Tokyo difference is due to the 15-arcminute grid resolution in EGM96; the steep geoid gradient near Japan amplifies interpolation differences. Roundtrip accuracy (`ellipsoidal_to_orthometric` then `orthometric_to_ellipsoidal`) is exact (0.0 error). |
| 171 | + |
| 172 | +### Integration with reproject |
| 173 | + |
| 174 | +The `reproject` function accepts a `vertical_crs` parameter to apply vertical datum shifts during reprojection: |
| 175 | + |
| 176 | +```python |
| 177 | +from xrspatial.reproject import reproject |
| 178 | + |
| 179 | +# Reproject and convert ellipsoidal heights to orthometric (MSL) |
| 180 | +dem_merc = reproject( |
| 181 | + dem, 'EPSG:3857', |
| 182 | + src_vertical_crs='ellipsoidal', |
| 183 | + tgt_vertical_crs='EGM96', |
| 184 | +) |
| 185 | +``` |
| 186 | + |
| 187 | +--- |
| 188 | + |
| 189 | +## 5. ITRF Frame Support |
| 190 | + |
| 191 | +Time-dependent transformations between International Terrestrial Reference Frames using 14-parameter Helmert transforms (7 static + 7 rates) from PROJ data files. |
| 192 | + |
| 193 | +### Available frames |
| 194 | + |
| 195 | +- ITRF2000 |
| 196 | +- ITRF2008 |
| 197 | +- ITRF2014 |
| 198 | +- ITRF2020 |
| 199 | + |
| 200 | +### Example |
| 201 | + |
| 202 | +```python |
| 203 | +from xrspatial.reproject import itrf_transform, itrf_frames |
| 204 | + |
| 205 | +print(itrf_frames()) # ['ITRF2000', 'ITRF2008', 'ITRF2014', 'ITRF2020'] |
| 206 | + |
| 207 | +lon2, lat2, h2 = itrf_transform( |
| 208 | + -74.0, 40.7, 10.0, |
| 209 | + src='ITRF2014', tgt='ITRF2020', epoch=2024.0, |
| 210 | +) |
| 211 | +# -> (-73.9999999782, 40.6999999860, 9.996897) |
| 212 | +# Horizontal shift: 2.4 mm, vertical shift: -3.1 mm |
| 213 | +``` |
| 214 | + |
| 215 | +### All frame-pair shifts (at epoch 2020.0, location 0E 45N) |
| 216 | + |
| 217 | +| Source | Target | Horizontal shift | Vertical shift | |
| 218 | +|:-------|:-------|:----------------:|:--------------:| |
| 219 | +| ITRF2000 | ITRF2008 | 33.0 mm | 32.8 mm | |
| 220 | +| ITRF2000 | ITRF2014 | 33.2 mm | 30.7 mm | |
| 221 | +| ITRF2000 | ITRF2020 | 30.5 mm | 30.0 mm | |
| 222 | +| ITRF2008 | ITRF2014 | 1.9 mm | -2.1 mm | |
| 223 | +| ITRF2008 | ITRF2020 | 2.6 mm | -2.8 mm | |
| 224 | +| ITRF2014 | ITRF2020 | 3.0 mm | -0.7 mm | |
| 225 | + |
| 226 | +Shifts between recent frames (ITRF2014/2020) are at the mm level. Older frames (ITRF2000) show larger shifts (~30mm) due to accumulated tectonic motion. |
| 227 | + |
| 228 | +--- |
| 229 | + |
| 230 | +## 6. pyproj Usage |
| 231 | + |
| 232 | +The reproject module uses pyproj for metadata operations only. The heavy per-pixel work is done in Numba JIT or CUDA. |
| 233 | + |
| 234 | +### What pyproj does (runs once per reproject call) |
| 235 | + |
| 236 | +| Task | Cost | Description | |
| 237 | +|:-----|:-----|:------------| |
| 238 | +| CRS metadata parsing | ~1 ms | `CRS.from_user_input()`, `CRS.to_dict()`, extract projection parameters | |
| 239 | +| EPSG code lookup | ~0.1 ms | `CRS.to_epsg()` to check for known fast paths | |
| 240 | +| Output grid estimation | ~1 ms | `Transformer.transform()` on ~500 boundary points to determine output extent | |
| 241 | +| Fallback transform | per-pixel | Only used for CRS pairs without a built-in Numba/CUDA kernel | |
| 242 | + |
| 243 | +### What Numba/CUDA does (the per-pixel bottleneck) |
| 244 | + |
| 245 | +| Task | Implementation | Notes | |
| 246 | +|:-----|:---------------|:------| |
| 247 | +| Coordinate transforms | Numba `@njit(parallel=True)` / CUDA `@cuda.jit` | Per-pixel forward/inverse projection | |
| 248 | +| Bilinear resampling | Numba `@njit` / CUDA `@cuda.jit` | Source pixel interpolation | |
| 249 | +| Nearest-neighbor resampling | Numba `@njit` / CUDA `@cuda.jit` | Source pixel lookup | |
| 250 | +| Cubic resampling | `scipy.ndimage.map_coordinates` | CPU only (no Numba/CUDA kernel yet) | |
| 251 | +| Datum grid interpolation | Numba `@njit(parallel=True)` | Bilinear interp of NTv2/NADCON grids | |
| 252 | +| Geoid undulation interpolation | Numba `@njit(parallel=True)` | Bilinear interp of EGM96/EGM2008 grid | |
| 253 | +| 7-param Helmert datum shift | Numba `@njit(parallel=True)` | Geocentric ECEF transform | |
| 254 | +| 14-param ITRF transform | Numba `@njit(parallel=True)` | Time-dependent Helmert in ECEF | |
0 commit comments