Skip to content

Port to geotessera 0.10.1 and remove workarounds - #4

Merged
sk818 merged 17 commits into
ucam-eo:mainfrom
avsm:fix/reprojection-and-correctness
Aug 29, 2026
Merged

Port to geotessera 0.10.1 and remove workarounds#4
sk818 merged 17 commits into
ucam-eo:mainfrom
avsm:fix/reprojection-and-correctness

Conversation

@avsm

@avsm avsm commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Fixes from a code review (only limited testing), but the upgrade to geotessera>=0.10 is important so that the right source is used for the embeddings fetch:

  • The external tessera-zarr-utils dependency is removed. geotessera 0.10.1 fixes the issues it worked around, so the compute server now uses GeoTesseraZarr directly, with the zarr fast path re-enabled, its disk cache bounded at 20 GB, and the geotessera floor raised to 0.10.1.
  • Maps are now predicted on the embeddings' native UTM grids. The NPY path previously resampled every embedding vector to lon/lat before prediction, and the zarr path crashed with a CRS mismatch when a map area crossed a UTM zone boundary. Only the per-block prediction rasters are reprojected, at merge time, and map chunks break at zone edges so no strip along a boundary is silently lost. The output GeoTIFF is now georeferenced in the native UTM CRS, reported in the map_ready event.
  • Sentinel nodata values in reference rasters (such as MS-NFI's 32766/32767) are converted to nodata before resampling. Bilinear resampling previously blended them into neighbouring pixels, producing large values that passed as valid regression targets.
  • Spatial MLP models are skipped, with a message, when the test set is a separate region or year. They previously fell back to a random split of their own training pixels and reported optimistic scores alongside honestly held-out ones. The CLI no longer crashes when a model is skipped this way.
  • evaluate() reads its training sizes as pixel counts again rather than percentages, and Results.summary() no longer raises KeyError.
  • The VQ loader chunks shapefile bounds in lon/lat degrees regardless of the input or target CRS, and still accepts shapefiles with no CRS.
  • Map GeoTIFFs are compressed with DEFLATE; the previous lz4 setting is not a GeoTIFF compression method and GDAL silently wrote uncompressed files.

avsm added 17 commits August 28, 2026 18:08
Reference rasters such as forest inventories often mark missing data
with large sentinel values (for example 32766 and 32767).  These were
stripped from the aligned raster only after resampling, by exact
comparison.  With bilinear resampling a sentinel first blends with its
real neighbours, producing large in-between values that no longer equal
the sentinel and so pass through as apparently valid regression targets.

align_raster_to_grid now converts the raster's declared nodata and any
extra sentinel values to NaN before resampling, so they are excluded
from interpolation entirely.  Only the window of the source raster that
covers the destination grid is read, keeping memory use bounded by the
destination size rather than the source raster.
run_learning_curve works in percentages of the labelled data, but the
evaluate() convenience wrapper still passed absolute training sizes
straight through.  A size of 10000 was therefore read as 10000 percent,
so every requested size above roughly 100 collapsed to the same 80%
training split and the learning curve's x-axis was meaningless.
Results.summary() also read a 'size' key that progress events no longer
carry, and raised KeyError.

evaluate() now converts the requested sizes into percentages of the
data, capped at 80% and deduplicated, and Results.summary() reports the
actual number of training pixels used at each step.
The spatial MLP models train on neighbourhood features, and no such
features exist for a fixed test set (one built from drawn train/test
regions, or from a different test year).  Instead of refusing, the
learning curve quietly fell back to a random split of the spatial
models' own training pixels -- so their scores appeared alongside the
honestly held-out scores of the other models while measuring something
easier, and looked optimistic as a result.

These models are now skipped for such runs, with a status message
saying so, and the server no longer extracts their neighbourhood
features when they cannot be used.  An unreachable and broken fallback
branch (it trained on neighbourhood features but predicted plain pixel
vectors, which always failed and scored 0.0) is removed.
Embeddings are produced on each tile's native UTM grid, and the
geotessera guidance is to classify on that grid and reproject only the
result.  create_map's NPY fallback instead fetched each chunk already
reprojected to plain latitude/longitude, resampling every
128-dimensional embedding vector before the model saw it, and producing
a map on a different grid than the zarr path.

The NPY fallback now reads one tile at a time on its native grid, crops
it to the map area, and predicts there.  The per-block prediction
rasters -- not the embeddings -- are then reprojected onto a common
coordinate system, with nearest-neighbour resampling so no class IDs or
values are invented, before being merged into the output GeoTIFF.

This also fixes the zarr path for map areas that span a UTM zone
boundary: its chunks arrive in different zones' coordinate systems, and
merging them previously failed with "CRS mismatch with source".
The map GeoTIFF was written with compress="lz4", which is not a
compression method GeoTIFF supports.  GDAL ignored the unknown value
without raising an error, so every generated map was silently written
uncompressed.  Use DEFLATE, which every GeoTIFF reader understands.
load_embeddings_for_shapefile_vq splits the shapefile's bounding box
into chunks using degree arithmetic, but a shapefile that was not
already in lon/lat was first reprojected into target_crs.  With a
projected target CRS the chunk maths then ran over metre coordinates,
producing nonsense chunk bounds and, in practice, an enormous number of
tiny chunks.

The shapefile is now always brought to lon/lat for chunking, and the
polygons are reprojected onto each returned mosaic's own CRS just
before rasterization.  The docstring also now notes that mosaics come
back resampled onto target_crs rather than the embeddings' native UTM
grid, so accuracy measured through this loader includes that resampling
as well as the VQ reconstruction.
The old floor of 0.9.0 admits releases that still download from the
retired tessera-embeddings hosting, which is being shut down.
geotessera 0.10 moved all data hosting to the Source Cooperative, so
older releases will simply stop working.  Note that geotessera has
required Python 3.12 for some time, so installations using any of the
extras that pull it in already need 3.12 even though the base package
still supports 3.10.
The external tessera-zarr-utils package existed to work around two
upstream problems -- a UTM-zone-boundary bug in zarr region reads and an
incomplete rollout of years other than 2024 -- and its pinned release
disabled zarr outright, so the compute server has been using NPY tiles
for everything.  geotessera 0.10.1 fixes both: its GeoTesseraZarr
interface reads regions on their native UTM grids and serves every
published year.

The compute server now uses GeoTesseraZarr directly, with two small
helpers alongside its existing cached GeoTessera handle: _get_zarr()
opens the store once per process, caches chunk reads on disk next to
the NPY tile cache, and remembers a failed open so callers fall back to
NPY tiles as before; _probe_zarr_coverage() uses geotessera's
single-pixel probe to accept only genuinely valid embeddings for the
requested year and region.  This re-enables the zarr fast path and
removes the git-pinned dependency.
Summarise the reprojection and correctness fixes, the re-enabled zarr
fast path via geotessera's own interface, and the new geotessera floor
under an Unreleased heading.
run_learning_curve now removes spatial models from a run when the test
set is a separate region or year, so their names no longer appear in
progress events.  The command-line printer still indexed every
requested model unconditionally and crashed with KeyError -- after the
embeddings had already been downloaded.  Models absent from an event
are now simply not printed; the engine already logs why they were
skipped.
A destination grid lying just outside the raster, but within the
sentinel path's two-pixel interpolation margin, passed the
window-intersection check with a fraction of a pixel of overlap that
then rounded down to a zero-width read.  Reprojecting from an empty
array crashes inside GDAL, where the same call without sentinel values
quietly returns an all-NaN grid.  A window that rounds to nothing now
returns all-NaN like any other non-overlapping case.
A shapefile missing its projection file has no CRS, and the loader
deliberately treats that as already lon/lat.  The recent change that
reprojects polygons onto each mosaic's coordinate system broke this:
geopandas refuses to reproject naive geometries, so such a shapefile
crashed the whole load.  Polygons without a CRS are now rasterized
as-is, restoring the previous behaviour.
The zarr store serves a bounding box that straddles a UTM zone edge
from the centre zone alone, silently clipping at the edge.  Map chunks
started at the user's arbitrary western edge and stepped by 0.1
degrees, so a chunk could straddle a zone boundary and quietly lose the
strip on its far side -- a nodata seam in the merged map, in exactly
the multi-zone areas the native-grid rework is meant to handle.
Chunk edges now also break at 6-degree multiples, so every request
stays within a single zone.
Maps are now georeferenced in the embeddings' native UTM CRS rather
than always EPSG:4326, and the GeoTIFF's own metadata was the only
place that said so.  The map_ready event now carries a crs field so
consumers that assumed lon/lat can detect the change, and the changelog
calls it out.
The zarr store handle cached its chunk reads on disk with no size
limit, so a long-lived compute server generating maps over several
large regions and years would fill the disk -- taking the co-located
NPY and result caches down with it.  The cache is now capped at 20 GB,
using the eviction support the store interface already provides.
In the map endpoint's NPY fallback, the registry query that lists a map
area's tiles ran outside any error handling, unlike every fetch and
prediction step around it.  An exception there escaped the response
generator and truncated the event stream mid-response, leaving the
frontend waiting with no error to show.  A failed listing now yields an
error event for that map area and moves on to the next one.
The list of models that train on neighbourhood features was spelled out
as a literal tuple in six places across three modules, including every
site that decides when such models must be skipped.  Adding a variant
or renaming one and missing a site would silently reintroduce the
optimistic-scores bug the fixed-test-set skip exists to prevent.  The
names now live in a single SPATIAL_MODELS constant in classify.py,
which every site imports.
@sk818
sk818 merged commit dac8057 into ucam-eo:main Aug 29, 2026
1 check passed
sk818 added a commit that referenced this pull request Aug 30, 2026
Release for PR #4. Highlights:
- tessera-zarr-utils dependency removed; compute server uses
  GeoTesseraZarr directly with a 20 GiB bounded disk cache, zarr fast
  path re-enabled, geotessera floor raised to >=0.10.1.
- Maps predicted on native UTM grids; only prediction rasters are
  reprojected, at merge time. Output GeoTIFF is georeferenced in the
  native UTM CRS (reported in the map_ready event), DEFLATE-compressed.
- Sentinel nodata (e.g. 32766/32767) excluded from raster resampling.
- Spatial MLP models skipped (not silently random-split) when the test
  set is a separate region or year.
- evaluate() honours requested training sizes; Results.summary() fixed.
- VQ loader chunks shapefile bounds in degrees regardless of CRS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LxpfB5ug78vFswQ5tkW5Qw
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants