- GDAL's internal source files (
gdalrasterband.cpp,gdaldefaultoverviews.cpp) are not designed to be compiled standalone; they are part oflibgdaland depend on hundreds of other internal files - Copying individual GDAL source files into a project and compiling them against the installed
libgdalcauses duplicate symbol conflicts - The correct way to extend GDAL behaviour is through its public C++ API, not by re-compiling its internals
- COG overview/pyramid generation in GDAL happens via
GDALDefaultOverviews::BuildOverviews()→GDALRegenerateOverviewsEx()per band GDALRegenerateOverviewsEx()is CPU-only: uses SIMD (SSE2/AVX2 on x86, NEON via sse2neon on Apple Silicon). No GPU, no Metal, no CUDA. The Apple Silicon GPU is completely idle during GDAL overview generationBuildOverviews()computes overview sizes asceil(N/2). However,gdal_translate -co OVERVIEWS=AUTO(the COG driver path) usesfloor(N/2). These are different internal code paths in GDAL. Our pipeline usesBuildOverviewsso our overview bands are 1 pixel larger per dimension than whatgdal_translateproduces for odd-sized rasters. The level COUNT matches exactly. The geographic extent is unaffected.- GDAL's AVERAGE resampling handles NoData by excluding NoData pixels from each block's average; our MLX implementation must do the same or elevation values get contaminated at NoData boundaries
- The COG driver accepts
OVERVIEWS=FORCE_USE_EXISTINGto use pre-built overviews rather than regenerating them; this is how we inject MLX-computed overviews into the COG pipeline
- Open source file: reads input
.tifinto aGDALDataset - Create output dataset: creates a GTiff with
LAYOUT=COGcreation option - Copy raster data: copies pixel data band by band into the output
- Build overviews: calls
GDALDefaultOverviews::BuildOverviews()→GDALRegenerateOverviewsEx()per band GDALRegenerateOverviewsEx(): the actual CPU resampling step per band, produces each overview level- Write COG structure: tiles data and writes final file with overviews embedded
Our replacement point is GDALRegenerateOverviewsEx(): instead of calling it, we read band data into an MLX array, downsample iteratively per overview level on GPU, and write results back via GDAL's public API. Everything else stays as GDAL.
- Open source with GDAL
- Create in-memory temp GTiff via
/vsimem/ - Call
BuildOverviews("NONE", ...)on temp: allocates overview band structure with zero CPU compute (see NONE resampling note below) - Call
MLXBuildOverviews(): fills overview bands with GPU-computed downsampling (AVERAGE or BILINEAR) - Create final COG from temp using COG driver with
OVERVIEWS=FORCE_USE_EXISTING
- MLX available via
brew install mlx(verified 0.32.0 on 2026-07-17), provides C++ API for GPU-accelerated array ops on Apple Silicon - Use
mx::default_stream(device)notmx::Stream(device)to get a stream for a device mx::mean()requiresstd::vector<int>not an initializer list for axesmx::slice()takesShape(SmallVector<int>); use initializer lists{start, ...}notstd::vector<int>- MLX ops are lazy; nothing executes until
mx::eval()is called - Edge replication for odd dimensions: replicate last row/col before reshape+mean, matching GDAL's
BuildOverviewsceil(N/2) convention
- GDAL's default for COG overview generation is NEAREST: listed first in
-r nearest,bilinear,...and used when no-rflag is passed - NEAREST formula:
out[i,j] = src[2i, 2j], picks the top-left pixel of each 2×2 block and discards the other 3 (75% of data lost per level) - AVERAGE formula:
out[i,j] = Σ valid_src_pixels / count_valid, all pixels in the block contribute; NoData pixels excluded from sum and count - NEAREST is correct for categorical data (land cover classes, labels) where averaging would create meaningless blended values
- AVERAGE is correct for continuous data (elevation, imagery, temperature). NEAREST can make narrow features (a ridge, a river) disappear entirely at coarser levels depending on pixel alignment
- BILINEAR uses a separable tent filter: for each output pixel i, the sample point in source space is
(i + 0.5) * 2 - 0.5 = 2i + 0.5, which lands halfway between source pixels 2i and 2i+1. The tent filter assigns weight 0.5 to each. Applied as two 1D passes (horizontal then vertical). For strict 2x downsampling with no NoData, this is numerically identical to AVERAGE because the 0.5/0.5 weights produce the same 2x2 mean. They diverge at NoData boundaries and for non-2x factors. - GDAL's bilinear overview generation goes through the convolution code path (
GDALResampleChunk_ConvolutionT), which is a more general implementation than the AVERAGE path (GDALResampleChunk_AverageOrRMS). On multi-GSD benches (including dem_5cm), MLX AVERAGE and BILINEAR wall times stay nearly identical because the GPU parallelises both uniformly. - Our benchmark compares both tools using both AVERAGE and BILINEAR, each method run independently against its GDAL equivalent
- Without NoData masking, averaging
-9999NoData pixels into real elevation values produces severely corrupted overview pixels (e.g. min dropping to -9842 instead of -4.93) - Contamination compounds at each overview level because each level downsamples from the previous
- Fix: mask NoData pixels before averaging; zero them out, sum valid pixels only, divide by valid count, output NoData where all 4 pixels in a block are NoData
- Read NoData value per band via
poBand->GetNoDataValue(&hasNodata); always check thehasNodataflag before masking - NaN no-data substitution (2026-04-15): Implemented NaN substitution strategy instead of rejection. When NaN is detected as the nodata value, the implementation:
- Detects NaN nodata using
std::isnan()on the GDAL nodata value - Internally uses -9999 as the working nodata value for MLX operations
- Substitutes NaN pixels with -9999 on CPU after RasterIO (before creating MLX array)
- Performs all downsampling operations with -9999 as nodata
- Restores -9999 back to NaN on CPU before writing to GDAL
- Sets output band nodata metadata to NaN via
SetNoDataValue()This approach works around MLX's inability to detect NaN withmx::equal()while maintaining full compatibility with NaN nodata datasets. CPU-based substitution is used because MLX may not haveisnan()support or it may cause segmentation faults.
- Detects NaN nodata using
Test results (live 2026-07-17): sample_dem_nan_nodata.tif (4772x5125, NaN nodata). Dev = |MLX − GDAL| / |GDAL| on min/max/mean/stddev. Comparison is MLX (NaN→-9999 path) vs GDAL native NaN convolution, not GDAL-with--9999.
| Method | Level | min dev | max dev | mean dev | stddev dev | Status |
|---|---|---|---|---|---|---|
| AVERAGE | Full res | 0.00% | 0.00% | 0.00% | 0.00% | PASS |
| AVERAGE | Overview 1 | 0.02% | 0.04% | 0.00% | 0.00% | PASS |
| AVERAGE | Overview 2 | 0.06% | 0.02% | 0.00% | 0.00% | PASS |
| AVERAGE | Overview 3 | 0.10% | 0.53% | 0.00% | 0.00% | PASS |
| AVERAGE | Overview 4 | 0.25% | 1.05% | 1.46% | 0.00% | PASS |
| BILINEAR | Full res | 0.00% | 0.00% | 0.00% | 0.00% | PASS |
| BILINEAR | Overview 1 | 0.14% | 0.14% | 0.75% | 0.00% | PASS |
| BILINEAR | Overview 2 | 0.41% | 0.22% | 1.47% | 0.05% | PASS |
| BILINEAR | Overview 3 | 0.57% | 0.02% | 2.88% | 0.10% | PASS |
| BILINEAR | Overview 4 | 1.52% | 0.17% | 1.42% | 0.43% | PASS |
- Worst AVERAGE: ~1.5% (mean @ ovr 4). Worst BILINEAR: ~2.9% (mean @ ovr 3).
- Gates in
check_nan_nodata.cpp: 5% AVERAGE and BILINEAR (AVG_TOLERANCE/BIL_TOLERANCE). BILINEAR gate tightened from 15% → 5% on 2026-07-17 after live remeasure (old table had stale BILINEAR mean up to 16.81% and is obsolete). - Float16 nodata quantization issue: Float16 precision near 10000 is ±8 (exponent 13, 10-bit mantissa).
-9999stored as Float16 rounds to-10000.0. The nodata metadata still says-9999.0. Both GDAL and our implementation compare the stored pixel value against the metadata value and find a mismatch; nodata pixels are silently treated as valid data. This is not specific to our code; GDAL has the same problem. Confirmed experimentally: GDAL overview min drops to -10560 (contamination from nodata averaging), while MLX min stays at -10000 (contamination present but bounded since all nodata pixels have the same quantised value). This is the same class of failure as nodata=0; any nodata value that cannot be exactly represented in Float16 triggers it.
sample_dem.tiflives intests/; gitignored via*.tifbut explicitly unignored via!tests/sample_dem.tif. Current file has 1.9% nodata coverage. A better test file with 22.24% nodata (128MB, exceeds GitHub size limit) is available locally and validates nodata handling correctly at higher nodata percentages.build/is gitignored; must be recreated on fresh clone
mlx_translatecopies the source into/vsimem/(RAM filesystem); all reads hit RAM throughoutgdal_translatereads from disk, but macOS's OS page cache keeps the file in RAM after the first read; subsequent runs confirm no disk variance- Cannot compare speedup ratios across different benchmark sessions; system state, memory pressure, and page cache warmth all vary; only absolute times within the same session are meaningful
gdal_translate -of COG performance is controlled by more than just GDAL_NUM_THREADS. The benchmark currently only controls threading. The following flags can all shift GDAL wall-clock time and must be understood before drawing conclusions about what "maximum credible GDAL" looks like.
Threading (currently benchmarked)
GDAL_NUM_THREADS(--config): default1. Parallelises both overview downsampling computation and LZW/DEFLATE tile compression.ALL_CPUSenables all cores. Supported since GDAL 3.2 for overview generation; LZW compression also benefits. This is the single largest lever.NUM_THREADS(-co): COG-driver-specific form of the same control. Canonical for COG creation. Both--config GDAL_NUM_THREADSand-co NUM_THREADSare respected; in benchmarks we use the--configform.
Memory and block cache (not currently benchmarked)
GDAL_CACHEMAX(--config): default 5% of RAM (~800MB on 16GB). Controls how many decoded source blocks GDAL keeps in RAM during overview generation. At large raster sizes, a small cache causes blocks to be evicted and re-read for each successive overview level. Setting4096MBor higher could measurably reduce I/O during multi-level overview generation.GDAL_SWATH_SIZE(--config): defaults toGDAL_CACHEMAX / 4. Controls the in-flight transfer buffer when GDAL copies pixels between datasets (used during the final COG assembly step). Automatically scales with GDAL_CACHEMAX.GDAL_BAND_BLOCK_CACHE(--config): defaultAUTO.ARRAYmode is faster for typical rasters (direct array indexing vs. hash lookup). AUTO chooses based on block count and usually picks ARRAY.VSI_CACHE(--config): defaultFALSE. Set toTRUEto add a per-file-handle RAM read-ahead cache on top of the OS page cache. Useful when the same source blocks are read repeatedly across overview levels.VSI_CACHE_SIZE(--config): default 25MB. Size of per-file VSI cache. Only effective whenVSI_CACHE=TRUE.
Compression (not currently benchmarked)
PREDICTOR(-co): defaultNO(= 1, no predictor). Setting2(horizontal differencing) before LZW reduces input entropy so the compressor does less work. Standard recommendation for integer DEM data; can improve LZW speed by 10–30% while also improving compression ratio. Setting3(floating-point differencing) is appropriate for Float32 data. LZW has no configurable level; PREDICTOR is the only way to tune LZW performance.LEVEL(-co): controls effort for DEFLATE (1–12, default 6) and ZSTD (1–22, default 9). Level 1 is maximally fast. Has no effect on LZW.OVERVIEW_COMPRESS/OVERVIEW_PREDICTOR(-co): same controls applied independently to overview tiles.
I/O path (minor, situational)
GTIFF_VIRTUAL_MEM_IO(--config): defaultNO. Set toYESorIF_ENOUGH_RAMto usemmap()instead of GDAL's block cache for reading uncompressed source TIFFs. Can reduce source-read time on large uncompressed inputs by delegating memory management to the OS. No effect on compressed sources.GTIFF_DIRECT_IO(--config): defaultNO. Bypasses block cache for uncompressed TIFF reads. Lower priority thanGTIFF_VIRTUAL_MEM_IO; only effective when mmap is not in use.GDAL_DISABLE_READDIR_ON_OPEN(--config): defaultFALSE. Set toEMPTY_DIRto skip scanning the source directory for.aux/.ovr/.tfwsidecar files on everyGDALOpen(). Eliminates areaddirsyscall per benchmark iteration.
What the benchmark currently covers vs. does not cover
- Covered:
GDAL_NUM_THREADS= 1 andALL_CPUS;COMPRESS=LZW;OVERVIEWS=AUTO; default tile size (512) - Not covered:
GDAL_CACHEMAX,GDAL_SWATH_SIZE,PREDICTOR,GTIFF_VIRTUAL_MEM_IO,VSI_CACHE - The "true maximum GDAL" baseline has not been established. Before concluding that MLX underperforms or that any fix closes the gap, the impact of
GDAL_CACHEMAXandPREDICTOR=2on the GDAL ALL_CPUS numbers must be measured.
- Execution hardware: GDAL uses CPU SIMD (NEON on Apple Silicon) via
GDALResampleChunk_AverageOrRMS. The resampling runs in a job queue (OvrJobinoverview.cpp) that is single-threaded by default (GDAL_NUM_THREADSdefaults to"1"). SettingGDAL_NUM_THREADS=ALL_CPUSparallelises the resampling chunks across CPU cores; this has been supported since GDAL 3.2. MLX dispatches to the Apple Silicon GPU via Metal, massively parallel. - Memory access pattern: GDAL processes in horizontal chunks/strips; reads a few rows at a time, writes them, repeats. Designed to handle rasters larger than RAM. MLX loads the entire band into GPU memory once, computes all levels, writes back; simpler but requires the full band to fit in memory.
- Overview chain: both cascade identically; level N is sourced from level N-1, not from the original band. GDAL does this explicitly in
GDALRegenerateCascadingOverviews()for AVERAGE; MLX does it viacurrent = downsampled. - Resampling math (AVERAGE): same 2×2 box filter, different form. GDAL is a pixel loop with SIMD intrinsics; MLX expresses it as
reshape([H, 2, W, 2])+mean([1, 3])which the GPU executes as a single kernel. BILINEAR: GDAL uses a separable convolution with a tent filter kernel viaGDALResampleChunk_ConvolutionT; MLX implements it as two sequential reshape+mean passes (horizontal then vertical), which is structurally equivalent and numerically identical at 2x. - COG assembly: identical; both use the same GDAL COG driver with
OVERVIEWS=FORCE_USE_EXISTING. - Key architectural constraint: MLX requires the full band to fit in GPU/unified memory. GDAL's chunked model handles arbitrarily large rasters. This is the one real limitation of the MLX approach.
BuildOverviews("NONE", ...)is a valid public API call; GDAL creates the TIFF IFD structures (overview band slots at correct dimensions) but immediately returns without computing any pixel data (GDALRegenerateOverviewsExbails out at theEQUAL(pszResampling, "NONE")check inoverview.cpp:4816)- This replaces the previous NEAREST warmup pass; we used to call
BuildOverviews("NEAREST", ...)just to allocate structure, which wasted a full CPU resample pass that was immediately overwritten by MLX - Switching to NONE eliminated the wasted CPU pass entirely; MLX absolute times improved (measured in a prior benchmark session, exact deltas no longer verifiable against current data)
- Replaced the single fixed test file with dynamically generated DEMs at multiple GSDs (80cm, 40cm, 20cm) so benchmarks capture how speedup scales with raster size
- DEMs are generated from 5k random points via TIN interpolation (
gdal_grid -a linear); synthetic but realistic Float32 single-band rasters with NoData outside the convex hull - Project goal: beat GDAL ALL_CPUS (nT), not single-threaded GDAL. Single-threaded GDAL is not a meaningful target; any real user will invoke GDAL with
GDAL_NUM_THREADS=ALL_CPUS. The relevant speedup column is vs GDAL nT. - MLX is slower than GDAL nT at small rasters where Metal kernel launch overhead dominates over GPU compute time; it wins at large rasters where GPU parallelism overwhelms CPU core count. The crossover point shifts with optimisations (see README for current numbers).
- Optimising the small-raster regime is the primary open problem. The dominant fixed cost is Metal framework overhead (kernel launch, command buffer submission) plus GDAL I/O setup, not GPU compute time.
- A single-raster benchmark at one scale is misleading; behaviour must be measured across raster sizes
Tried building the full downsample chain as a lazy graph and calling mx::eval(all_levels) once instead of mx::eval per level. Result on dem_160cm/80cm/40cm: no measurable improvement (differences within 5ms, run-to-run noise). Conclusion: per-level eval() fences were not the bottleneck; memory bandwidth and Metal/GDAL fixed costs dominate.
Current code (2026-07-17): still per-level mx::eval(downsampled) inside the overview loop in MLXBuildOverviews (needed before CPU NaN restore / RasterIO write). Batched-eval is historical only, not production structure.
Easy wins:
- ZSTD default instead of LZW: ZSTD compresses 2–3× faster than LZW at similar ratio; supported since GDAL 2.3. One-line default change. Directly reduces COG write time, which is the second-largest cost at large rasters.
- PREDICTOR=3 for Float32: reduces LZW/DEFLATE input entropy before compression. Standard for Float32 rasters. One creation-option change. Makes compression faster AND improves ratio.
Medium effort:
- Eliminate the /vsimem double-pass: currently the pipeline writes all overview data into /vsimem, then the COG driver reads it all back to tile+compress. Every pixel (full-res + overviews ≈ 4/3 × band size) is touched twice for the write path alone. Eliminating this requires structuring the pipeline so the COG driver reads directly from the MLX output without an intermediate GTiff copy.
- Multi-band GPU parallelism: bands are processed serially. For multi-band rasters (RGB, multispectral), all bands could be batched as a
[bands, H, W]array and downsampled simultaneously. No benefit for single-band DEMs (current test data).
Large effort / diminishing returns:
- GPU-accelerated tiling: the COG write step tiles into 512×512 blocks and reorders them (pure data movement that the GPU could do faster). But using GPU-tiled output requires implementing the TIFF/COG file structure (IFDs, tile offsets) manually, essentially replacing the COG driver.
- Avoid CPU copy on overview write: read path already uses
mx::allocator::mallocso RasterIO fills MLX-owned memory. Write path still copies into astd::vector<float>when restoring NaN nodata before RasterIO. Numeric-nodata could write fromdownsampled.data<float>()directly; NaN path needs the copy (or an in-place restore if MLX buffer is writable after eval).
gdal_grid -a linear:radius=-1performs TIN interpolation;radius=-1restricts output to the convex hull of the input points (pixels outside get nodata)-txeand-tye(explicit extent) are required when-tr(resolution) is used; gdal_grid errors without them- GSD can be expressed in degrees when working in WGS84; no need to reproject to a metric CRS just for raster generation; convert from metres using
1 deg lat approx 111,320 m - Generation time scales roughly with output pixel count: 5k points over a ~3km×3km area at 20cm GSD (~15k×15k pixels) takes ~11 minutes on M1 Pro single-threaded; ~6 minutes with
--config GDAL_NUM_THREADS ALL_CPUS gdal_griddoes not accept-multi; multithreading is enabled via--config GDAL_NUM_THREADS ALL_CPUSpassed as the first argument. Uniform point distributions benefit most since all threads get equal work.- OGR VRT is the correct way to make a CSV readable as a spatial layer by GDAL tools; specify
GeometryType,LayerSRS, andGeometryFieldwithencoding="PointFromColumns"
- macOS ships with bash 3.2, so
mapfileis not available; usewhile IFS= read -r f; do arr+=("$f"); done < <(...)instead - Separate stdout and stderr in bench functions (
>&2for progress, plainechofor the return value) to cleanly capture averages via command substitution
Location: gcore/overview.cpp (GDALResampleChunk_ConvolutionT; line numbers shift by GDAL version)
Architecture:
- Two-pass separable convolution: horizontal filter first, then vertical filter
- General-purpose implementation supporting arbitrary downsample factors (not just 2x)
- Supports kernels with negative weights (Cubic, Lanczos) via
bKernelWithNegativeWeightstemplate parameter - Kernel radius for bilinear: 1 (tent filter spans 2 pixels in each dimension)
Horizontal Pass (lines 3550-3820):
- For each output pixel
iDstPixel, compute source position:For 2x downsampling:dfSrcPixel = (iDstPixel + 0.5) * dfXRatioDstToSrc + dfSrcXDeltadfXRatioDstToSrc = 2.0,dfSrcXDelta = -0.5Result:dfSrcPixel = 2 * iDstPixel + 0.5(halfway between source pixels) - Determine source pixel range:
[dfSrcPixel - dfXScaledRadius, dfSrcPixel + dfXScaledRadius]For 2x bilinear: radius = 1, scaled radius = 2, so samples from 4 source pixels - Compute kernel weights for each source pixel using tent filter:
weight = 1 - |x|wherexis distance from sample point - Normalize weights so they sum to 1.0 (unless sum is 0)
- NoData path: Uses
GDALResampleConvolutionHorizontalWithMask(line 2774):- Multiplies each weight by the mask value (0 for NoData, 1 for valid)
- Accumulates
dfVal += pixel * weightanddfWeightSum += weight - After pass: if
dfWeightSum > 0, outputdfVal / dfWeightSum, else NoData - For kernels with negative weights, checks for consecutive valid pixels; if < 50% consecutive, rejects entire output as NoData
- Stores intermediate result in
padfHorizontalFilteredarray - Also computes
pabyChunkNodataMaskHorizontalFiltered(1 if any valid weight survived, else 0)
Vertical Pass (lines 3830-4100):
- Same logic as horizontal pass but applied vertically
- Operates on
padfHorizontalFilteredfrom the horizontal pass - Uses
pabyChunkNodataMaskHorizontalFilteredas the mask - For each output line, computes source line position and kernel weights
- NoData path: Same masked weighted sum approach as horizontal
- Final output written to destination buffer
Key GDAL characteristics:
- Separable convolution: horizontal and vertical passes are independent
- 2D weight accumulation in NoData path: the horizontal pass produces a mask that the vertical pass uses, so the final 2D weight distribution is the product of 1D weights
- Boundary handling: clamps kernel to valid source extent (no extrapolation)
- Weight normalization: weights always normalized to sum to 1.0 (unless all masked out)
Location: src/mlx_overviews.cpp (mlx_downsample_bilinear)
No-NoData path:
- Two-pass separable convolution using reshape+mean
- Horizontal: reshape
[pH, pW] → [pH, targetW, 2], mean over axis 2 - Vertical: reshape
[pH, targetW] → [targetH, 2, targetW], mean over axis 1 - At 2x with no NoData, tent weights 0.5/0.5 match reshape+mean exactly
- Odd dims: pad by replicating last row/col (simulates GDAL kernel clamp)
NoData path (current, post 2026-04-15 fix):
- Separable masked convolution matching GDAL's
GDALResampleChunk_ConvolutionT - Horizontal: reshape
[pH, targetW, 2], weight 0.5, mask,dataSum/weightSumif weightSum > 0 else 0; intermediate validity mask - Vertical: same on intermediate using intermediate mask; final 2D weights = product of 1D weights
- Output NoData where no valid weight survived either pass
Before 2026-04-15, the NoData path used a 2D box filter (reshape to 2×2 blocks, uniform mean of valid pixels). That diverged from GDAL's separable tent product at nodata boundaries (example: single valid pixel in 2×2 got weight 1.0 in MLX vs 0.25 in GDAL; max abs error ~0.51 on a circular nodata test). That analysis and the "2D weighting" docs are historical only.
- NoData strategy: both separable masked convolution (aligned after fix)
- Boundary: GDAL clamps kernel; MLX replicates last row/col (same effect for no-NoData 2x)
- Generality: GDAL arbitrary factors/kernels; MLX 2x only (enough for COG power-of-2 overviews)
- No NoData, 2x: mathematically equivalent
- Residual divergence: FP order (CPU vs GPU), 1px ceil/floor overview dim drift vs COG AUTO, and NaN-nodata vs GDAL native NaN path (see NaN table above)
Numeric-nodata COG stats (check_cog_statistics, live reconfirmed 2026-07-17): gate 3%. Max observed 2.48% (BILINEAR ovr 3 mean).
| Method | Level | min dev | max dev | mean dev | stddev dev |
|---|---|---|---|---|---|
| AVERAGE | Full res | 0.00% | 0.00% | 0.00% | 0.00% |
| AVERAGE | Overview 1 | 0.01% | 0.08% | 0.19% | 0.01% |
| AVERAGE | Overview 2 | 0.04% | 0.03% | 0.18% | 0.01% |
| AVERAGE | Overview 3 | 0.33% | 0.35% | 2.02% | 0.01% |
| AVERAGE | Overview 4 | 0.20% | 0.73% | 1.12% | 0.02% |
| BILINEAR | Full res | 0.00% | 0.00% | 0.00% | 0.00% |
| BILINEAR | Overview 1 | 0.14% | 0.14% | 0.51% | 0.01% |
| BILINEAR | Overview 2 | 0.39% | 0.22% | 1.22% | 0.00% |
| BILINEAR | Overview 3 | 0.56% | 0.02% | 2.48% | 0.07% |
| BILINEAR | Overview 4 | 1.51% | 0.18% | 1.80% | 0.41% |
- Deleted
tests/check_float16_pipeline.cppand CMake/ctest registration - Reason: current phase is Float32-only (DSM/DTM/DEM/slope/etc.); a forced Float16 library probe is out of scope
- Float16 remains covered only as a rejected dtype in
check_dtype_rejection - Historical finding kept in learnings (Float16 nodata quantization of -9999 → -10000); not an active test
- Policy: Float32-only for this phase
- In scope: DSM, DTM, DEM, slope, aspect, similar continuous analytic rasters
- Out of scope: Byte orthos, UInt16 imagery, Float16, integer elevation, categorical maps, Byte hillshade deliverables
- Non-Float32 must be rejected at CLI, not converted
- Known Limitations corrected: previously said other dtypes were "converted on read"; actual behaviour is reject
Industry-typical dtypes for products users might feed into a COG pipeline:
| Product | Most common dtype | Why |
|---|---|---|
| Ortho / orthomosaic | Byte (UInt8) RGB/RGBA | Display imagery, 0–255 per band |
| High-bit aerial/sat | UInt16 | 12–14 bit sensors stored in 16-bit |
| DSM / DTM / DEM | Float32 | Continuous elevation, decimals, nodata |
| Hillshade | Byte | Visualization product (0–255 gray) |
| Slope | Float32 | Degrees or percent, continuous |
| Aspect | Float32 | Degrees 0–360 |
| Roughness / TPI / curvature | Float32 | Continuous morphometrics |
| Land cover / class maps | Byte or UInt16 | Categories, not continuous |
Implications for mlx-cog-gen:
- Float32-only is correct for the elevation/analytic target (DSM, DTM, DEM, slope, aspect, related morphometrics)
- Orthos are usually Byte multichannel and are a different workflow; not the primary fit for this tool
- Hillshade as a deliverable is often Byte, even when derived from a Float32 DEM
- CLI dtype rejection (Float32 only) matches DEM-centric use; accepting Byte orthos would need a separate product path, not silent conversion to Float32
Related tests:
check_dtype_rejection: enforces Float32-only at the CLI (Float16 among rejected types)
- Each test file documents agenda, why it matters, and pass criteria in a header comment
- Full English file names, e.g.
check_mlx_availability.cppnottest_mlx.cpp - Renames applied (current suite):
test_mlx.cpp→check_mlx_availability.cpptest_overview_dims.cpp→check_overview_dimensions.cpptest_cog_stats.cpp→check_cog_statistics.cpptest_dtype_rejection.cpp→check_dtype_rejection.cpptest_nan_nodata.cpp→check_nan_nodata.cpp
test_float16.cppwas renamed tocheck_float16_pipeline.cppthen removed (Float32-only phase; see above)- CMake targets and ctest names match the five active
check_*.cppstems - README test list updated to the full suite
- Previous test checked MLX overview sizes against a hardcoded
ceil(N/2)formula only. - That does not state the real contract: for supported resampling methods (AVERAGE, BILINEAR), MLX overview dimensions must match GDAL's overview dimensions exactly.
- Rewrite:
- Reference path:
BuildOverviews("AVERAGE"|"BILINEAR", factors) - MLX path:
BuildOverviews("NONE", factors)thenMLXBuildOverviews(..., method)(production allocate+fill) - Cases: even/odd single-level, multi-level even/odd,
sample_dem.tifsize (4772x5125, 4 levels) - Methods: both AVERAGE and BILINEAR
- Pass criteria: overview count equal; each level width/height equal (exact)
- Reference path:
- Documented in the test file header: not comparing COG
OVERVIEWS=AUTOfloor path (known 1px difference in docs/known-issues.md) - Resampling method does not change GDAL overview sizes, but both methods are still exercised so a method-specific regression cannot slip through
- Replaced obsolete NaN BILINEAR deviation table (claimed mean up to 16.81%) with live 2026-07-17 numbers (worst ~2.9%); documented 15% → 5% gate tighten
- Rewrote bilinear NoData section: current path is separable masked convolution; 2D box path is historical only
- Corrected batched-eval note: production still per-level
mx::eval - Corrected read/write buffer description: MLX allocator on read; vector copy mainly for NaN restore on write
- MLX version note 0.32.0; GDAL citations use tree paths only (e.g.
gcore/overview.cpp); float16 rename marked removed