Skip to content

Feat/xenium celldega regular grid - #415

Open
cornhundred wants to merge 17 commits into
scverse:mainfrom
cornhundred:feat/xenium-celldega-regular-grid
Open

Feat/xenium celldega regular grid#415
cornhundred wants to merge 17 commits into
scverse:mainfrom
cornhundred:feat/xenium-celldega-regular-grid

Conversation

@cornhundred

Copy link
Copy Markdown

No description provided.

Nicolas Fernandez and others added 9 commits September 6, 2026 13:43
Adds the viewer-independent core of the celldega_regular_grid_v1 profile: the
tile geometry and the tile -> row-group -> file numbering.

- x-major tile ids (tile_x * num_tiles_y + tile_y), matching Celldega's
  RowGroupTileReader, but reimplemented rather than imported so spatialdata-io
  gains no dependency on a viewer package (Celldega already depends on
  spatialdata-io, so importing it would be circular). A conformance test pins
  the two formulas together.
- Half-open tile bounds, with the dataset's upper edge clamped into the last
  tile so points on x_max/y_max are not dropped.
- Out-of-grid coordinates raise instead of being silently clamped, since they
  indicate a mismatched transform rather than a rounding artefact.
- Zero-padded chunk filenames: Celldega indexes the manifest's files array by
  position while dask globs and sorts lexicographically, so unpadded names
  would make dask reorder partitions past 10 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FeatureCatalog assigns genes codes [0, n_genes) in the table's var_names order,
so a gene's feature_code is also its CBG row-group index -- no second lookup and
no browser-side string join. Non-gene features (controls, unassigned codewords)
are retained but coded above every gene and flagged, never folded into a real
gene. Verified on Xenium pancreas: 377 genes + 164 controls.

write_points_regular_grid keeps every canonical column and the index, and adds
display_xy (fixed_size_list<uint32>[2], already interleaved for deck.gl) and
feature_code. Rows are grouped by tile, one row group per logical tile including
empty ones, split across zero-padded chunk files.

Notable details:
- statistics are disabled: the tile formula is the spatial index, so no client
  reads column-chunk min/max, and they inflate the footer the browser fetches.
- coordinates are cast to float64 before the affine: under numpy 2's NEP-50
  promotion, float32 * python float stays float32 and loses pixel precision.
- the rewrite is idempotent. Re-running on an already-optimized element replaces
  the render columns rather than appending duplicates, which previously produced
  an unreadable file ("Multiple matches for FieldRef") because pandas also
  round-trips fixed_size_list back as a variable-length list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds display_geometry (list<list<fixed_size_list<uint32>[2]>>: polygon -> rings
-> interleaved integer pixel vertices) and cell_code, alongside the untouched
canonical WKB geometry.

- Cells are assigned to exactly one tile by centroid in display pixel space; a
  polygon whose outline crosses a tile boundary is not duplicated. Covered by a
  fixture case with exactly that shape.
- display_geometry is marked lossy in the manifest: exterior ring only, largest
  part of a MultiPolygon, matching Celldega's current behaviour.
- The nested layout is walked by a test that mirrors getPolygonDataFromChunk
  (polygon offset -> ring offset -> coordinate index), so a layout change that
  would break the browser fails here instead.
- GeoParquet 'geo' metadata is preserved via the same conversion to_parquet uses.
  GeoDataFrame.to_arrow() emits GeoArrow extension metadata but not the 'geo'
  key, and without it the rewritten file stops being readable by
  geopandas.read_parquet and by SpatialData.

Verified against Xenium pancreas: 140,702 cells, all single-ring Polygons,
shapes index matches table obs_names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One row group per gene, so selecting a gene fetches a single row group instead of
touching any transcript data. Schema matches Celldega's existing CBG reader
exactly: cell_id / expression / gene, with gene_to_row_group and num_genes in the
parquet schema metadata.

Deliberate difference from Celldega's own writer: every catalog gene gets a row
group in catalog order, including all-zero genes, so that
'row group index == feature_code' holds and one integer addresses both a
transcript's gene and its expression vector. gene_to_row_group is still written
for clients that look it up rather than assume it.

Explicit stored zeros are dropped -- a sparse matrix can carry them and they are
not expression.

Validated against Xenium pancreas: 377 genes, 2,607,168 values, 16.2 MB, 0.2s;
INS/GCG/ACTA2 cell sets and values match the AnnData table exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured on Xenium pancreas (8.07M transcripts, 250px tiles), total overhead of
the two render columns plus row-group fragmentation, against the 249.1 MB
SpatialData default write:

  snappy  345.1 MB  +38.5%   7.0s
  zstd    261.4 MB   +4.9%   7.3s
  brotli  250.4 MB   +0.5%  50.1s

snappy barely compresses display_xy at all; zstd removes most of the overhead for
no meaningful write cost, and brotli is 7x slower for another 4 points.

Verified parquet-wasm 0.7.1 (celldega's pinned version) can read zstd: ZSTD = 5 is
in its Compression enum and the zstd shims are compiled into the wasm bundle. This
matters because an unsupported codec would fail only in the browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
add_spatial_tiling() tiles an existing store in place; xenium_spatially_tiled()
goes from raw Xenium to a tiled store in one call. Both are single calls; the
one-shot path is internally read -> write -> tile because the tiling rewrites
written parquet.

The manifest reuses Celldega's landscape_parameters.json keys (use_row_groups,
tile_grid, row_group_files, technology, image_info) so its reader consumes it
unchanged, and declares paths relative to the profile directory
(../../points/transcripts/points.parquet) so Celldega can be pointed at that
directory as base_url with no reader change.

validate_manifest() fails loudly on the mistakes that would otherwise surface as
a blank viewport: row-group counts disagreeing with the tile grid, file counts
that cannot hold the declared row groups, CBG mappings pointing past the end, and
declared files that do not exist.

The re-runnability test caught the same idempotency bug in the shapes writer that
was previously fixed for points: re-tiling appended duplicate display_geometry /
cell_code columns rather than replacing them.

Verified on Xenium pancreas: 15.0s to tile, 7,535 row groups across 19 files,
read_zarr still returns 8,073,840 points / 140,702 shapes / (140702, 377) table,
and canonical geometry is byte-identical after the reorder (including the 159
pre-existing invalid polygons, which are 10x source data, not corruption).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Normative description of celldega_regular_grid_v1: coordinate system, grid and
tile numbering, row-group and multi-part file numbering, empty-tile handling,
the render columns, feature and cell codes, CBG mapping, manifest layout,
transport requirements, invalidation rules and a conformance checklist.

Written viewer-independently with Celldega named as the reference client, and
carrying the measurements behind the non-obvious choices (tile size, multi-file
splitting, zstd) so the rationale survives the prototype.

Also declares the 'columns' projection in the writer manifest fragments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… profile

Writes a browser-ready WebP pyramid as Parquet row groups in Celldega's existing
ImageRowGroupReader layout: zoom/tile_x/tile_y/image_data, one tile per row group,
zoom_info in the schema metadata.

Zoom numbering follows DeepZoom and was verified tile-for-tile against
'vips dzsave' on the same input: levels 0..11 for a 1300x700 image, level 11 =
3x2, level 10 = 2x1. So the output is interchangeable with tiles Celldega already
produces. On Xenium pancreas it independently derives max_pyramid_zoom=16 and
image_dimensions 34155x13770, matching the DegaFiles reference exactly.

Encoding uses Pillow rather than libvips, so spatialdata-io gains no system
dependency. Two memory measures matter on real images: the display window is
taken from the smallest multiscale level rather than by scanning a 500-megapixel
plane, and the 8-bit conversion runs in row blocks instead of materializing a
float copy of the whole image.

The canonical OME-Zarr image is untouched; this is explicitly a display cache,
and the source element, dimensions, dtype, window, gamma, downsampling method and
WebP settings are all recorded for invalidation.

Also aligns the manifest with a real DegaFiles landscape_parameters.json
(max_pyramid_zoom, image_dimensions, image_format, use_int_index,
segmentation_approach, top-level tile_size).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ted memory

Grouping rows by tile is a global sort -- a row at the end of the input can belong
to the first tile -- so streaming the read alone is not enough. The streaming path
makes two passes: stream the element one partition at a time and spill each row
into a temporary file chosen by its destination chunk file, then sort each spill
file independently and write its chunk. Peak memory scales with one partition plus
one spill file rather than the dataset.

Enabled automatically for partitioned (dask) elements; single-partition elements
keep the single-pass path. A test asserts the two produce identical row groups, so
they cannot silently diverge.

Chunks pin the categorical dictionary to the element's full category list, since
partitions observing different feature subsets would otherwise convert to
incompatible Arrow dictionary types and could not be written to one file.

Measured on Xenium Prime human skin (74,011,892 transcripts, 5,006 genes, 9.2x the
pancreas): 94s to tile at 3.0 GB peak RSS, on a 17 GB machine where the in-memory
path was estimated at 17-20 GB. 14,022 row groups across 36 files, all rows
preserved, manifest validates, read_zarr returns the full element.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov-commenter

codecov-commenter commented Sep 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.87879% with 112 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.76%. Comparing base (d2664fa) to head (bfbf7df).

Files with missing lines Patch % Lines
src/spatialdata_io/experimental/tiled_access.py 71.20% 36 Missing ⚠️
src/spatialdata_io/experimental/points_parquet.py 85.92% 28 Missing ⚠️
src/spatialdata_io/experimental/webp_parquet.py 89.85% 14 Missing ⚠️
src/spatialdata_io/experimental/feature_catalog.py 87.37% 13 Missing ⚠️
src/spatialdata_io/experimental/shapes_parquet.py 91.53% 11 Missing ⚠️
src/spatialdata_io/experimental/manifest.py 92.20% 6 Missing ⚠️
src/spatialdata_io/experimental/cbg_parquet.py 94.28% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #415      +/-   ##
==========================================
+ Coverage   63.15%   68.76%   +5.60%     
==========================================
  Files          26       34       +8     
  Lines        3257     4181     +924     
==========================================
+ Hits         2057     2875     +818     
- Misses       1200     1306     +106     
Files with missing lines Coverage Δ
src/spatialdata_io/experimental/regular_grid.py 100.00% <100.00%> (ø)
src/spatialdata_io/experimental/cbg_parquet.py 94.28% <94.28%> (ø)
src/spatialdata_io/experimental/manifest.py 92.20% <92.20%> (ø)
src/spatialdata_io/experimental/shapes_parquet.py 91.53% <91.53%> (ø)
src/spatialdata_io/experimental/feature_catalog.py 87.37% <87.37%> (ø)
src/spatialdata_io/experimental/webp_parquet.py 89.85% <89.85%> (ø)
src/spatialdata_io/experimental/points_parquet.py 85.92% <85.92%> (ø)
src/spatialdata_io/experimental/tiled_access.py 71.20% <71.20%> (ø)

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Nicolas Fernandez and others added 2 commits September 6, 2026 17:53
…ixed-path assets

Renames the profile from celldega_regular_grid_v1 to grid_files_v1. Naming it
after one client contradicted the viewer-independent intent; nothing about the
layout is Celldega-specific.

Adds the files a client reads at fixed paths rather than through the manifest,
which is what lets the profile directory stand in for a DegaFiles root so no
client needs to know it is looking at a SpatialData store:

- cell_metadata.parquet: per-cell centroids and names. Cells are the overview
  representation, so every centroid is needed up front. Centroids are not stored
  anywhere in a SpatialData store (the Xenium reader puts no x/y_centroid in obs),
  so they are derived from the shape geometry -- the same centroids already
  computed for tile assignment. Row order matches the table's obs order, so a
  cell's position here is its cell_code.
- micron_to_image_transform.csv: the real micron-to-pixel affine. Coordinates are
  already in display pixels so this places nothing, but it drives the scale bar,
  and writing identity would make the scale bar wrong by the pixel size.
- cell_clusters/: placeholder single-group clustering, since SpatialData does not
  require one and the Xenium reader does not load one. Without the file the
  viewer's category machinery fails on a 404.

Also drops the design doc from the branch (it moves to the PR discussion) and
removes supports_points_writer_hook, which was unused and untested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The render columns no longer go into the canonical elements. Canonical points and
shapes are still re-ordered into tile row groups -- that is what makes spatial
subsetting cheap from Python -- but keep only their own columns. display_xy and
feature_code go to <profile>/trx, display_geometry and cell_code to
<profile>/cell_seg, with the same row-group layout.

This fixes two separate problems with one change:

1. SpatialData.write() round-trip. A nested Arrow column cannot survive dask's
   parquet round-trip: it either fails outright on a schema mismatch or silently
   comes back as a string. A tiled store could therefore not be rewritten. Now it
   can, and a test asserts it.
2. Column projection. parquet-wasm corrupts the IPC stream whenever 'columns' is
   passed (0.7.1 and 0.7.2, apache-arrow 15 and 18, scalar and nested alike, even
   an empty array), which is what left the viewer showing only the image layer.
   A standalone render file makes projection unnecessary: reading every column of
   it already transfers only what is drawn.

Also in this change:

- All image channels are written, not just the first. Xenium morphology_focus has
  four; each gets its own pyramid and image_info entry with a distinct colour.
  Channel names are sanitised for paths, since names like
  'ATP1A1/CD45/E-Cadherin' would otherwise create nested directories.
- Render files are written before the canonical rewrite. Rewriting canonical
  replaces the files the lazy dask frame still points at, so the other order made
  the second read fail.
- The streaming path reported render_only=False regardless; both paths now report
  it, with a test covering each.
- Re-tiling drops render columns left in canonical by the previous layout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cornhundred
cornhundred force-pushed the feat/xenium-celldega-regular-grid branch from b4d94db to d133ced Compare September 6, 2026 22:43
Nicolas Fernandez and others added 6 commits September 6, 2026 19:05
…e intensity

Two fixes found by rendering the profile in a viewer.

meta_gene.parquet had the wrong shape. A client reads it by convention, taking the
gene list from the parquet *index* and colours from a 'color' column, alongside
mean/std/max/non-zero. The profile wrote name/feature_code/is_gene as plain
columns with no index and no colour, so the gene list came back empty: the viewer
showed no transcript controls at all and logged nothing, because nothing had
failed. It now matches that layout, with per-gene statistics computed column-wise
from the sparse matrix (never densified -- a 5,006-gene panel would be several
GB), and carries feature_code/is_gene alongside as profile additions.

Image intensity is no longer stretched by default. The previous 1st-99.9th
percentile window was applied on top of the viewer's own intensity slider and
blew out the mid-tones: on a Xenium DAPI tile it took the mean from 1.3 to 37.4
with 1.2% of pixels saturated. The default is now the full dtype range, a linear
mapping matching Celldega's pipeline, which saves raw values and leaves
brightening to the slider. display_min/display_max still override it and are
recorded in the manifest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pandas writes a named index as a column of that name. Our index was named 'name',
so the parquet had a 'name' column and no '__index_level_0__' -- and a client
looks for the gene list only under '__index_level_0__'. The gene list therefore
still came back empty, with no error, exactly as before the schema fix.

Leaving the index unnamed makes pandas emit '__index_level_0__', matching a
DegaFiles meta_gene.parquet, whose index is also unnamed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… feature_code

A client derives its integer gene id from a feature's row position in
meta_gene.parquet, then colours transcripts by indexing an array built the same
way. Sorting the frame by name interleaved the 164 control features among the 377
genes, so feature_code no longer matched that position and most colour lookups
returned undefined -- which renders transparent rather than erroring.

The catalog order is already the normative one (genes first, in var_names order,
so a gene's feature_code is also its CBG row group), so writing the frame
unsorted makes row position, feature_code and CBG row group all agree.

Test uses a deliberately non-alphabetical var_names order with a control that
sorts in the middle, so a reintroduced sort fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…integers

Rounding to whole pixels put every transcript on an integer lattice. The
displacement is scientifically negligible -- mean 0.08 um against Xenium's
~0.1-0.3 um localisation precision -- but visually obvious, because regular
quantisation creates structure the eye reads as real.

float32 was chosen over fixed-point integers despite storing less densely. On
Xenium pancreas the render file measures:

  whole pixels (uint32)        27.6 MB   1 px      max err 0.150 um
  fixed-point x4 (uint32)      43.3 MB   1/4 px    max err 0.038 um
  float32                      78.5 MB   ~0.004 px max err 0

Fixed-point is denser, but requires the client to apply a scale read from the
manifest; a client that ignores it renders everything silently offset by that
factor. float32 needs no client-side arithmetic and cannot be misread. The extra
~50 MB is about 1% of a 3.5 GB store, which is a good trade for removing a whole
class of silent-wrongness.

Applies to display_geometry as well, so polygon vertices are no longer quantised
either, and deck.gl still receives the Arrow buffer with no copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hook was prototyped in core and then dropped, since nothing used it: tiling
operates on an already-written store. This comment was the last reference to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only the defaults are Xenium (element names, the technology string); the profile
itself reads generic SpatialData elements, which is why it lives in
spatialdata-io rather than beside the Xenium reader.

Uses a store with different element names, a different micron-to-pixel scale, and
a control feature absent from var_names, so a Xenium assumption creeping into the
writer fails here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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