Skip to content

Bottom-Up DEM (Positive Y Resolution)

rasterGeoTIFFEPSG:32633tinybundled

A south-up GeoTIFF whose affine transform has a positive e term: the origin is the bottom-left corner and row 0 is the southernmost row. Every other raster in the catalog is north-up, so code that assumes a negative e passes the whole corpus and then flips this one.

Pixels of bottom_up_dem_small, a 12x12 raster, with NoData in magenta
1 band, 12x12 px, float32, sentinel NoData. Rendered from the case's actual pixels, contrast-stretched for display; NoData is shown in magenta.
Property Value
Case ID bottom_up_dem_small
Category raster
Format GeoTIFF
CRS EPSG:32633
Location Central Mediterranean (synthetic, UTM 33N) — 15.00°E, 37.95°N → 15.00°E, 37.95°N
Test tier unit
Size class tiny
Storage class bundled
Redistributable yes
Loader rasterio
Status validated

Use this case

import pytest


@pytest.mark.geocase_case("bottom_up_dem_small")
def test_bottom_up_dem_small(geocase_case) -> None:
    data = geocase_case.load()
    assert data is not None

Use GeoCase in your tests

Install the complete set of vector, raster, and NetCDF dependencies:

pip install "geocase[all]"

View GeoCase on PyPI.

What this case checks

Verify that a consumer reads row order from the transform rather than assuming north-up, and that it orders bounds explicitly instead of trusting bounds.bottom to be the smaller northing.

Risk types covered

Expected behavior

Assertion Expected
expect_loadable yes
expect_crs yes
expected_epsg 32633
expect_nodata yes
expected_band_count 1
expected_dtype float32
expected_shape [12, 12]
expected_nodata_value -9999.0
nodata_convention sentinel
expected_transform_signs positive_e
expected_pixel_anchor area

Known answer

Computed from the actual bytes and gated against them. Grade your own output against these.

Quantity Value
Mean over valid pixels 72.0
Mean including NoData 2.0625
NoData pixels 1
Bounds (case CRS) [500000.0, 4200360.0, 500360.0, 4200000.0]

Known consumer divergences

Disagreements already investigated on this case. If your reader reproduces one of these, it is catalogued — not a new finding.

rio-tiler — rio-tiler 9.4.3, GDAL 3.12.2

The positive-e affine is valid and rasterio reads it, but rio-tiler propagates the inverted BoundingBox (bottom > top) onto Reader.bounds, so bounds[3] - bounds[1] is negative. feature() then raises WindowError while part() over the identical area succeeds. reader.py:487 calls windows.from_bounds with no ordering normalisation, and reader.py:58 computes y_res = (bounds[3] - bounds[1]) / height with no abs().

Upstream: https://github.com/farzinashouri/geocase/blob/main/docs/plans/37-raster-signal-and-differential-adapters.md

titiler — titiler 0.24 / rio-tiler 8.x, GDAL 3.12.2

/cog/info returns bounds with miny > maxy for the bottom-up (positive e) affine, and the normalised /cog/bbox request built from those bounds then returns HTTP 500 rather than the window.

Upstream: https://github.com/farzinashouri/geocase/blob/main/docs/plans/38-six-consumer-round-2-and-the-stac-adapter.md

rio-stac — rio-stac 0.11, GDAL 3.12.2

create_stac_item writes proj:bbox unnormalised for the bottom-up affine, so the Item is invalid per the projection extension even though the WGS84 bbox it writes alongside is correct.

Upstream: https://github.com/farzinashouri/geocase/blob/main/docs/plans/38-six-consumer-round-2-and-the-stac-adapter.md

Notes

Three fixtures covering two georeferencing conventions that nearly every consumer assumes without checking, and that nothing in this catalog exercised before plan 34. Like the footprint edge cases, they share a directory because their value is in the comparison between them.

Why these exist

Both failures here are silent. Neither produces an exception, a warning, or a visibly broken file — the output is a plausible raster describing the wrong piece of ground.

bottom_up_dem_small — positive e

Every other raster in the catalog is built with rasterio.transform.from_origin, which always emits a negative e term: north-up, origin at the top-left, row 0 the northernmost. Code that hardcodes that assumption passes all 32 of them.

This one has e = +30.0, origin at the bottom-left, and row 0 is the southernmost row. The array is the north-up ramp flipped vertically, so the file describes the same ground the other two do — get the flip wrong and you have a file that is internally consistent and geographically upside down, which is the exact bug the case exists to catch.

A concrete trap this fixture exposes: rasterio does not normalise bounds. BoundingBox is computed straight from the affine, so this file reports bottom = 4200360 and top = 4200000 — the larger northing in bottom. Anything computing a height as top - bottom, or handing these bounds to a windowing helper, gets a negative number or an empty read. Every other fixture in the catalog hides this, because for them the two happen to be ordered the way people expect.

pixel_is_area_dem_small / pixel_is_point_dem_small — AREA_OR_POINT

The pair is the test. Both files carry the same array and the same transform, and differ only in one metadata tag:

  • Area — the transform's coordinates name pixel corners.
  • Point — they name pixel centres.

That is a half-pixel difference in where every value sits: 15 m on a 30 m grid. It survives every gate this repository had — shape, dtype, CRS, nodata, checksum, even the rendered preview are all identical.

Area is written explicitly, though GDAL's default would produce the same behaviour by omitting the tag. A file that says nothing cannot be one half of a differential: a consumer reading tags()["AREA_OR_POINT"] on a silent file gets a KeyError, and one using .get() without a default gets None and guesses. assert_pixel_anchor defaults to "Area" for exactly this reason.

Typical checks

  • src.transform.e > 0 distinguishes the bottom-up case.
  • src.tags().get("AREA_OR_POINT", "Area") — with the default.
  • Order bounds explicitly rather than assuming bottom < top.

Widening the axis (plan 37 phase 3)

The external validation runs of 2026-08-31 found real defects in rio-tiler, titiler and rio-stac, and every one of them came from a transform convention rather than a format baseline. At that point the corpus held exactly one rotated raster and exactly one bottom-up raster — a sample size of one per axis, each of which paid on its first run. Two cases here widen it:

  • rotated_bottom_up_small carries both conventions at once: non-zero b/d skew terms and a positive e. rio-tiler's two defects have adjacent root causes and a single WarpedVRT guard addresses both, so a case carrying only one convention cannot distinguish a complete fix from a partial one. Its rows are flipped for the same reason bottom_up_dem_small's are: the file has to describe the same ground, not the same array. Flipping the transform sign alone yields a file that is internally consistent and geographically upside down.
  • rotated_steep_small rotates by roughly 40°, against rotated_two_islands' ~14°. At that angle a north-up assumption misplaces the grid's far corner by about ten cells rather than one, so a differential report shows the magnitude and direction of a consumer's error instead of something that reads as a one-pixel edge effect.

Both reuse the affine_transform_bug risk type rather than minting new terms.

rotated_two_islands and nonsquare_diagonal_sparse also carry non-default transforms — a rotated affine and anisotropic 60 m × 30 m pixels respectively. Both were backfilled with expected_transform_signs in plan 34, turning a property the corpus always had into one it declares.

Required capabilities

  • load
  • bounds-check
  • transform-inspection

Files

Browse this case on GitHub

Source and license

  • Source: geocase-curated
  • License: MIT

Tags

georeferencing geotiff raster south-up transform