spatialdata-js-util 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. spatialdata_js_util/__init__.py +107 -0
  2. spatialdata_js_util/cli.py +803 -0
  3. spatialdata_js_util/codecs/__init__.py +67 -0
  4. spatialdata_js_util/codecs/backends.py +250 -0
  5. spatialdata_js_util/codecs/chunks.py +40 -0
  6. spatialdata_js_util/codecs/encoding.py +154 -0
  7. spatialdata_js_util/codecs/htj2k_wasm.py +266 -0
  8. spatialdata_js_util/codecs/names.py +26 -0
  9. spatialdata_js_util/codecs/probe/multicomponent.j2c +0 -0
  10. spatialdata_js_util/codecs/probe/multicomponent.json +3 -0
  11. spatialdata_js_util/codecs/probe/multicomponent.npy +0 -0
  12. spatialdata_js_util/codecs/vendor/__init__.py +1 -0
  13. spatialdata_js_util/codecs/vendor/encode-plane.mjs +164 -0
  14. spatialdata_js_util/codecs/vendor/openjph/index.mjs +131 -0
  15. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.mjs +2 -0
  16. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.wasm +0 -0
  17. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.mjs +2 -0
  18. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.wasm +0 -0
  19. spatialdata_js_util/codecs/zarr_codec.py +149 -0
  20. spatialdata_js_util/errors.py +5 -0
  21. spatialdata_js_util/images.py +849 -0
  22. spatialdata_js_util/index_permutations.py +311 -0
  23. spatialdata_js_util/points.py +445 -0
  24. spatialdata_js_util/points_zarr.py +799 -0
  25. spatialdata_js_util/provenance.py +17 -0
  26. spatialdata_js_util/pyramids.py +318 -0
  27. spatialdata_js_util/runners.py +458 -0
  28. spatialdata_js_util/store.py +305 -0
  29. spatialdata_js_util/tables.py +310 -0
  30. spatialdata_js_util/tui/__init__.py +0 -0
  31. spatialdata_js_util/tui/app.py +112 -0
  32. spatialdata_js_util/tui/models.py +45 -0
  33. spatialdata_js_util/tui/screens.py +1255 -0
  34. spatialdata_js_util/tui/widgets.py +50 -0
  35. spatialdata_js_util/verify.py +338 -0
  36. spatialdata_js_util-0.1.0.dist-info/METADATA +412 -0
  37. spatialdata_js_util-0.1.0.dist-info/RECORD +40 -0
  38. spatialdata_js_util-0.1.0.dist-info/WHEEL +5 -0
  39. spatialdata_js_util-0.1.0.dist-info/entry_points.txt +7 -0
  40. spatialdata_js_util-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,799 @@
1
+ """Write points in the multiscale Zarr layout of merfisheyes' points-zarr spec 0.1.
2
+
3
+ https://github.com/kresnajenie/merfisheyes/blob/main/docs/points-zarr-spec.md
4
+
5
+ A points element becomes a Zarr v3 group of levels. Level 0 holds every point and
6
+ each further level a uniform random quarter of the one below. Every level stores the
7
+ points twice: ``tile_major`` (sorted by tile, then feature) for "everything in this
8
+ region" and ``feature_major`` (sorted by feature, then tile) for "this feature
9
+ anywhere". An ``index`` of row numbers says where each tile's and each feature's
10
+ rows are, so a reader plans a read from counts before fetching any point.
11
+
12
+ Experimental: the spec is one group's draft, built on maturity-0 zarr conventions.
13
+ Its group declares ``spatialdata_attrs.version`` ``0.3-zarr``, which spatialdata 0.8
14
+ fails on, so stores here keep it beside a standard element rather than in its place
15
+ (ADR 0006): ``points/<key>/`` stays an ordinary SpatialData points element with its
16
+ ``points.parquet``, and the points-zarr group is its ``experimental.points-zarr``
17
+ subgroup, which standard readers ignore. :func:`write_points_zarr_store` writes both;
18
+ :func:`add_points_zarr_to_store` adds the copy to an existing element.
19
+
20
+ The writer works out of core, so the input may be far larger than memory:
21
+
22
+ 1. **Survey**: one pass over the input for the extent, the point count and the
23
+ feature names.
24
+ 2. **Spill**: a second pass writes the points to temporary files, one per horizontal
25
+ band of whole level-0 tile rows, with each point's uniform draw ``r`` (its level
26
+ membership) beside it.
27
+ 3. **Per level**: whole tile rows are loaded a group of bands at a time, sorted, and
28
+ appended to ``tile_major``; tile ids increase with the row, so appending groups in
29
+ order keeps the whole level sorted. Each group's ``feature_major`` run is spilled
30
+ with per-feature offsets, then gathered feature by feature across groups, which
31
+ keeps the (feature, tile) order.
32
+
33
+ Arrays are written a whole shard at a time, so nothing is read back and rewritten.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import math
40
+ import shutil
41
+ import tempfile
42
+ from collections.abc import Callable, Iterable
43
+ from pathlib import Path
44
+ from typing import Any, Sequence
45
+
46
+ import numpy as np
47
+ import pandas as pd
48
+ import pyarrow as pa
49
+ import pyarrow.dataset as ds
50
+ import pyarrow.parquet as pq
51
+ import zarr
52
+ from zarr.codecs import BloscCodec
53
+
54
+ POINTS_ZARR_VERSION = "0.1"
55
+ #: The points-zarr copy's subgroup in a standard points element (ADR 0006).
56
+ SIDECAR_GROUP = "experimental.points-zarr"
57
+ #: Rows per chunk of a point column, and chunks per shard: the reference writer's.
58
+ DEFAULT_CHUNK_ROWS = 65_536
59
+ DEFAULT_SHARD_CHUNKS = 64
60
+ #: Levels are added until one holds no more than this many points (spec §3).
61
+ COARSEST_LEVEL_TARGET = 1_000_000
62
+ #: Points a spill band may be expected to hold. A level-0 band is sorted in memory,
63
+ #: at roughly 70 bytes a point including the sort's temporaries.
64
+ DEFAULT_BAND_POINTS = 50_000_000
65
+
66
+ #: A re-iterable source of points: each call yields the same frames, in the same
67
+ #: order. A frame may be one tile of a converter's input, or the whole input.
68
+ PointsSource = Callable[[], Iterable[pd.DataFrame]]
69
+
70
+ _MULTISCALES_CONVENTION = {
71
+ "uuid": "d35379db-88df-4056-af3a-620245f8e347",
72
+ "name": "multiscales",
73
+ "schema_url": "https://raw.githubusercontent.com/zarr-conventions/multiscales/refs/tags/v0.1/schema.json",
74
+ "spec_url": "https://github.com/zarr-conventions/multiscales/blob/v0.1/README.md",
75
+ }
76
+ _POINTS_CONVENTION = {
77
+ "name": "points:",
78
+ "description": "Multiscale points: levels, tile- and feature-major orders, row index",
79
+ "spec_url": "https://github.com/kresnajenie/merfisheyes/blob/develop/docs/points-zarr-spec.md",
80
+ }
81
+ _DATAFRAME_CONVENTION = {"uuid": "1f1b36d2-3185-6da8-91f6-9a0823af2d33", "name": "df:"}
82
+ _ENUM_CONVENTION = {"uuid": "c906c423-56ed-413c-9943-7b2ff52d18f2", "name": "enum:"}
83
+
84
+
85
+ def _compressor(dtype: np.dtype) -> BloscCodec:
86
+ return BloscCodec(cname="zstd", clevel=3, shuffle="shuffle", typesize=dtype.itemsize)
87
+
88
+
89
+ def _write_strings(group: zarr.Group, name: str, values: Sequence[str]) -> None:
90
+ # Variable-length UTF-8 (`string`), as the spec's readers expect; numpy's `str`
91
+ # would be written as fixed-length UTF-32.
92
+ group.create_array(name, data=np.asarray(list(values), dtype=np.dtypes.StringDType()))
93
+
94
+
95
+ class _ShardWriter:
96
+ """Fills a 1-D sharded array front to back, writing only whole shards until the end.
97
+
98
+ zarr rewrites a shard that a write covers partly; buffering to shard boundaries
99
+ keeps every write a single, final one.
100
+ """
101
+
102
+ def __init__(
103
+ self, group: zarr.Group, name: str, n_rows: int, dtype: Any, chunk_rows: int, shard_chunks: int
104
+ ):
105
+ chunk = max(1, min(chunk_rows, n_rows))
106
+ self.shard_rows = chunk * shard_chunks
107
+ self.array = group.create_array(
108
+ name,
109
+ shape=(n_rows,),
110
+ dtype=dtype,
111
+ chunks=(chunk,),
112
+ shards=(self.shard_rows,),
113
+ compressors=_compressor(np.dtype(dtype)),
114
+ fill_value=0,
115
+ )
116
+ self.dtype = np.dtype(dtype)
117
+ self.position = 0
118
+ self.pending: list[np.ndarray] = []
119
+ self.pending_rows = 0
120
+
121
+ def append(self, values: np.ndarray) -> None:
122
+ if len(values) == 0:
123
+ return
124
+ self.pending.append(np.asarray(values, dtype=self.dtype))
125
+ self.pending_rows += len(values)
126
+ if self.pending_rows >= self.shard_rows:
127
+ self._flush(final=False)
128
+
129
+ def _flush(self, final: bool) -> None:
130
+ data = self.pending[0] if len(self.pending) == 1 else np.concatenate(self.pending)
131
+ whole = len(data) if final else len(data) // self.shard_rows * self.shard_rows
132
+ if whole:
133
+ self.array[self.position : self.position + whole] = data[:whole]
134
+ self.position += whole
135
+ rest = data[whole:]
136
+ self.pending = [rest.copy()] if len(rest) else []
137
+ self.pending_rows = len(rest)
138
+
139
+ def close(self) -> None:
140
+ if self.pending_rows:
141
+ self._flush(final=True)
142
+ if self.position != self.array.shape[0]:
143
+ raise RuntimeError(
144
+ f"{self.array.path}: wrote {self.position} of {self.array.shape[0]} rows"
145
+ )
146
+
147
+
148
+ def _create_dataframe(
149
+ group: zarr.Group,
150
+ columns: list[tuple[str, Any]],
151
+ n_rows: int,
152
+ *,
153
+ feature_key: str,
154
+ features: Sequence[str],
155
+ attrs: dict[str, Any],
156
+ chunk_rows: int,
157
+ shard_chunks: int,
158
+ ) -> dict[str, _ShardWriter]:
159
+ """One row order, in the `df:` convention, the feature column as an `enum:`."""
160
+ group.attrs.update(
161
+ {"zarr_conventions": [_DATAFRAME_CONVENTION], "df:shape": [n_rows, len(columns)], **attrs}
162
+ )
163
+ _write_strings(group, "columns", [name for name, _ in columns])
164
+ writers: dict[str, _ShardWriter] = {}
165
+ for i, (name, dtype) in enumerate(columns):
166
+ member = f"column_{i}"
167
+ if name == feature_key:
168
+ enum = group.create_group(member)
169
+ enum.attrs.update(
170
+ {"zarr_conventions": [_ENUM_CONVENTION], "enum:ordered": False, "enum:codes": ["codes"]}
171
+ )
172
+ _write_strings(enum, "enum_values", features)
173
+ writers[name] = _ShardWriter(enum, "codes", n_rows, dtype, chunk_rows, shard_chunks)
174
+ else:
175
+ writers[name] = _ShardWriter(group, member, n_rows, dtype, chunk_rows, shard_chunks)
176
+ return writers
177
+
178
+
179
+ def _write_index(
180
+ group: zarr.Group, name: str, data: np.ndarray, chunk_rows: int, shard_chunks: int
181
+ ) -> None:
182
+ writer = _ShardWriter(group, name, len(data), data.dtype, chunk_rows, shard_chunks)
183
+ writer.append(data)
184
+ writer.close()
185
+
186
+
187
+ def _offsets(counts: np.ndarray) -> np.ndarray:
188
+ """Run starts from run lengths, with the total appended: the index's row form."""
189
+ return np.concatenate(([0], np.cumsum(counts))).astype(np.uint64)
190
+
191
+
192
+ def _default_level_count(n_points: int) -> int:
193
+ if n_points <= COARSEST_LEVEL_TARGET:
194
+ return 1
195
+ return 1 + math.ceil(math.log(n_points / COARSEST_LEVEL_TARGET, 4))
196
+
197
+
198
+ class _Survey:
199
+ """What the first pass learns: extent, count and the feature names in order."""
200
+
201
+ def __init__(self, dims: int):
202
+ self.lo = np.full(dims, np.inf)
203
+ self.hi = np.full(dims, -np.inf)
204
+ self.n_points = 0
205
+ self.names: dict[str, None] = {}
206
+ self.all_categorical = True
207
+
208
+ def add(self, coords: list[np.ndarray], feature: pd.Series, feature_key: str) -> None:
209
+ if len(feature) == 0:
210
+ return
211
+ if feature.isna().any():
212
+ raise ValueError(f"Column {feature_key!r} has missing values")
213
+ for d, c in enumerate(coords):
214
+ self.lo[d] = min(self.lo[d], float(c.min()))
215
+ self.hi[d] = max(self.hi[d], float(c.max()))
216
+ self.n_points += len(feature)
217
+ if isinstance(feature.dtype, pd.CategoricalDtype):
218
+ names = feature.cat.categories
219
+ else:
220
+ self.all_categorical = False
221
+ names = pd.unique(feature)
222
+ for name in names:
223
+ self.names.setdefault(str(name), None)
224
+
225
+ def features(self) -> list[str]:
226
+ # A categorical's own order is kept; otherwise names are sorted, as
227
+ # `pd.Categorical` would.
228
+ return list(self.names) if self.all_categorical else sorted(self.names)
229
+
230
+
231
+ def _feature_codes(feature: pd.Series, code_of: pd.Index) -> np.ndarray:
232
+ """Each row's index in the feature list, matched by name."""
233
+ if isinstance(feature.dtype, pd.CategoricalDtype):
234
+ # Map the categories once, not every row's string.
235
+ by_category = code_of.get_indexer(feature.cat.categories.astype(str))
236
+ codes = by_category[feature.cat.codes.to_numpy()]
237
+ else:
238
+ codes = code_of.get_indexer(feature.astype(str))
239
+ if (codes < 0).any():
240
+ unknown = sorted(set(feature.astype(str).to_numpy()[codes < 0]))[:5]
241
+ raise ValueError(f"Features not in the feature list: {unknown}")
242
+ return codes
243
+
244
+
245
+ def write_points_zarr(
246
+ data: pd.DataFrame | PointsSource,
247
+ element_dir: str | Path,
248
+ *,
249
+ feature_key: str,
250
+ coordinates: Sequence[str] = ("x", "y"),
251
+ features: Sequence[str] | None = None,
252
+ tile_size: float = 100.0,
253
+ n_levels: int | None = None,
254
+ seed: int = 0,
255
+ units: str = "micrometer",
256
+ coordinate_system: str = "global",
257
+ transformations: list[dict[str, Any]] | None = None,
258
+ chunk_rows: int = DEFAULT_CHUNK_ROWS,
259
+ shard_chunks: int = DEFAULT_SHARD_CHUNKS,
260
+ band_points: int = DEFAULT_BAND_POINTS,
261
+ work_dir: str | Path | None = None,
262
+ progress: Callable[[str], None] | None = None,
263
+ overwrite: bool = False,
264
+ ) -> dict[str, Any]:
265
+ """Write points as a points-zarr element group at ``element_dir``.
266
+
267
+ ``data`` is a DataFrame, or a :data:`PointsSource` for input larger than memory
268
+ (read twice: once to survey, once to spill). ``coordinates`` name the coordinate
269
+ columns, two or three, written as axes ``x``, ``y`` and ``z`` in that order;
270
+ ``feature_key`` names the feature column. Categorical input keeps its categories,
271
+ in order, as the feature list; other input is listed in sorted order; pass
272
+ ``features`` to fix it. Every listed feature appears in the store, with or
273
+ without points.
274
+
275
+ Levels are added until one holds about a million points unless ``n_levels`` is
276
+ given, stopping early at a level that would be empty. ``seed`` fixes the random
277
+ sample, so a rerun on the same input writes the same rows.
278
+
279
+ ``transformations`` are written as the element's ``coordinateTransformations``,
280
+ so an element converted from another keeps its placement (copy them from the
281
+ source element's attrs); by default, identity into ``coordinate_system``.
282
+
283
+ Temporary files go under ``work_dir`` (a new directory beside ``element_dir`` by
284
+ default): about 18 bytes a point while spilling, plus one level's sorted copy.
285
+ ``band_points`` bounds the points sorted at once, and so the memory used.
286
+
287
+ Returns a summary: point counts per level and the feature list.
288
+ """
289
+ element_dir = Path(element_dir)
290
+ source: PointsSource = (lambda: [data]) if isinstance(data, pd.DataFrame) else data
291
+ say = progress or (lambda _message: None)
292
+ if not 2 <= len(coordinates) <= 3:
293
+ raise ValueError(f"coordinates must name two or three columns, got {list(coordinates)}")
294
+ if element_dir.exists() and not overwrite:
295
+ raise FileExistsError(f"{element_dir} exists; pass overwrite=True to replace it")
296
+ element_dir.parent.mkdir(parents=True, exist_ok=True)
297
+ dims = len(coordinates)
298
+ axes = ("x", "y", "z")[:dims]
299
+
300
+ def frames():
301
+ for frame in source():
302
+ missing = [c for c in (*coordinates, feature_key) if c not in frame.columns]
303
+ if missing:
304
+ raise ValueError(f"Missing columns: {', '.join(missing)}")
305
+ yield [frame[c].to_numpy(dtype=np.float32) for c in coordinates], frame[feature_key]
306
+
307
+ say("surveying")
308
+ survey = _Survey(dims)
309
+ for coords, feature in frames():
310
+ survey.add(coords, feature, feature_key)
311
+ if survey.n_points == 0:
312
+ raise ValueError("No points to write")
313
+ feature_list = [str(f) for f in features] if features is not None else survey.features()
314
+ code_of = pd.Index(feature_list)
315
+ code_dtype = np.uint16 if len(feature_list) <= np.iinfo(np.uint16).max + 1 else np.uint32
316
+ n_points = survey.n_points
317
+ extent_min, extent_max = [float(v) for v in survey.lo], [float(v) for v in survey.hi]
318
+ x0, y0 = extent_min[:2]
319
+ x1, y1 = extent_max[:2]
320
+ level_count = n_levels if n_levels is not None else _default_level_count(n_points)
321
+
322
+ def grid(level: int) -> tuple[float, int, int]:
323
+ size = tile_size * 2**level
324
+ return size, math.floor((x1 - x0) / size) + 1, math.floor((y1 - y0) / size) + 1
325
+
326
+ # Bands of 2^j level-0 tile rows. A level-k tile row is 2^k level-0 rows, so for
327
+ # k >= j it is 2^(k-j) whole bands, and for k < j a band holds whole level-k rows.
328
+ _, _, ny0 = grid(0)
329
+ rows_per_tile_row = n_points / ny0
330
+ band_shift = 0
331
+ # Twice the average, for uneven density across the section.
332
+ while band_shift < level_count - 1 and 2 * rows_per_tile_row * 2 ** (band_shift + 1) <= band_points:
333
+ band_shift += 1
334
+ n_bands = math.ceil(ny0 / 2**band_shift)
335
+
336
+ work = Path(work_dir) if work_dir is not None else Path(
337
+ tempfile.mkdtemp(prefix=f".{element_dir.name}-", dir=element_dir.parent)
338
+ )
339
+ work.mkdir(parents=True, exist_ok=True)
340
+ try:
341
+ say(f"spilling {n_points:,} points into {n_bands} bands")
342
+ spill_fields = [*[(a, np.float32) for a in axes], ("code", code_dtype), ("r", np.float32)]
343
+ handles = {
344
+ (b, name): open(work / f"band{b}.{name}", "wb")
345
+ for b in range(n_bands)
346
+ for name, _ in spill_fields
347
+ }
348
+ band_level_counts = np.zeros((n_bands, level_count), dtype=np.int64)
349
+ thresholds = np.array([4.0**-k for k in range(level_count)], dtype=np.float32)
350
+ try:
351
+ for frame_index, (coords, feature) in enumerate(frames()):
352
+ if len(feature) == 0:
353
+ continue
354
+ codes = _feature_codes(feature, code_of)
355
+ # One uniform draw per point; level k is every point with r < 4^-k.
356
+ r = np.random.default_rng([seed, frame_index]).random(len(codes), dtype=np.float32)
357
+ ty0 = np.clip(((coords[1] - y0) // tile_size).astype(np.int64), 0, ny0 - 1)
358
+ band = ty0 >> band_shift
359
+ order = np.argsort(band, kind="stable")
360
+ starts = np.searchsorted(band[order], np.arange(n_bands + 1))
361
+ columns = {**dict(zip(axes, coords)), "code": codes.astype(code_dtype), "r": r}
362
+ for b in np.unique(band):
363
+ rows = order[starts[b] : starts[b + 1]]
364
+ for name, dtype in spill_fields:
365
+ handles[(b, name)].write(np.ascontiguousarray(columns[name][rows], dtype=dtype).tobytes())
366
+ band_level_counts[b] += (r[rows][:, None] < thresholds[None, :]).sum(axis=0)
367
+ finally:
368
+ for handle in handles.values():
369
+ handle.close()
370
+
371
+ level_sizes = band_level_counts.sum(axis=0)
372
+ level_count = int(np.count_nonzero(level_sizes)) if level_sizes[-1] == 0 else level_count
373
+ # Replaced only now that the input has been read and spilled, so a bad input
374
+ # leaves the existing group as it was.
375
+ if element_dir.exists():
376
+ shutil.rmtree(element_dir)
377
+ root = zarr.open_group(element_dir, mode="w", zarr_format=3)
378
+ layout: list[dict[str, Any]] = []
379
+ for level in range(level_count):
380
+ layout.append(
381
+ _write_level(
382
+ root,
383
+ level,
384
+ work=work,
385
+ n_rows=int(level_sizes[level]),
386
+ grid=grid(level),
387
+ origin=(x0, y0),
388
+ band_shift=band_shift,
389
+ n_bands=n_bands,
390
+ axes=axes,
391
+ spill_fields=spill_fields,
392
+ code_dtype=code_dtype,
393
+ feature_key=feature_key,
394
+ features=feature_list,
395
+ chunk_rows=chunk_rows,
396
+ shard_chunks=shard_chunks,
397
+ say=say,
398
+ )
399
+ )
400
+ finally:
401
+ if work_dir is None:
402
+ shutil.rmtree(work, ignore_errors=True)
403
+
404
+ root.attrs.update(
405
+ {
406
+ "encoding-type": "ngff:points",
407
+ "axes": list(axes),
408
+ "coordinateTransformations": transformations
409
+ if transformations is not None
410
+ else _identity_transform(axes, coordinate_system, units),
411
+ "spatialdata_attrs": {"feature_key": feature_key, "version": "0.3-zarr"},
412
+ "zarr_conventions": [_MULTISCALES_CONVENTION, _POINTS_CONVENTION],
413
+ "multiscales": {"layout": layout},
414
+ "points:version": POINTS_ZARR_VERSION,
415
+ "points:units": units,
416
+ "points:n_points": n_points,
417
+ "points:extent": [extent_min, extent_max],
418
+ }
419
+ )
420
+ return {
421
+ "format": "points-zarr",
422
+ "version": POINTS_ZARR_VERSION,
423
+ "element": str(element_dir),
424
+ "levels": [entry["points:n_points"] for entry in layout],
425
+ "features": feature_list,
426
+ }
427
+
428
+
429
+ def _write_level(
430
+ root: zarr.Group,
431
+ level: int,
432
+ *,
433
+ work: Path,
434
+ n_rows: int,
435
+ grid: tuple[float, int, int],
436
+ origin: tuple[float, float],
437
+ band_shift: int,
438
+ n_bands: int,
439
+ axes: Sequence[str],
440
+ spill_fields: list[tuple[str, Any]],
441
+ code_dtype: Any,
442
+ feature_key: str,
443
+ features: Sequence[str],
444
+ chunk_rows: int,
445
+ shard_chunks: int,
446
+ say: Callable[[str], None],
447
+ ) -> dict[str, Any]:
448
+ size, nx, ny = grid
449
+ x0, y0 = origin
450
+ n_tiles = nx * ny
451
+ n_features = len(features)
452
+ threshold = np.float32(4.0**-level)
453
+ # Whole level-k tile rows per group of bands; see the band comment above.
454
+ group_bands = 2 ** max(0, level - band_shift)
455
+ groups = [range(b, min(b + group_bands, n_bands)) for b in range(0, n_bands, group_bands)]
456
+ say(f"level {level}: {n_rows:,} points, {len(groups)} groups")
457
+
458
+ group = root.create_group(str(level))
459
+ tm_columns = [*[(a, np.float32) for a in axes], (feature_key, code_dtype)]
460
+ if level > 0:
461
+ # Optional in the spec (§4); level 0's row number is already the id. The
462
+ # reference validator reads it to check that each level nests in the last.
463
+ tm_columns.append(("id", np.uint64))
464
+ tile_major = _create_dataframe(
465
+ group.create_group("tile_major"),
466
+ tm_columns,
467
+ n_rows,
468
+ feature_key=feature_key,
469
+ features=features,
470
+ attrs={"points:order": "tile_major"},
471
+ chunk_rows=chunk_rows,
472
+ shard_chunks=shard_chunks,
473
+ )
474
+ tile_counts = np.zeros(n_tiles, dtype=np.int64)
475
+ level_work = work / f"level{level}"
476
+ level_work.mkdir()
477
+ fm_fields = [*[(a, np.float32) for a in axes], ("id", np.uint64)]
478
+ group_feature_offsets: list[np.ndarray] = []
479
+ group_pair_offsets: list[np.ndarray] = []
480
+ written = 0
481
+
482
+ for g, bands in enumerate(groups):
483
+ loaded = {
484
+ name: np.concatenate([np.fromfile(work / f"band{b}.{name}", dtype=dtype) for b in bands])
485
+ for name, dtype in spill_fields
486
+ }
487
+ if level == 0:
488
+ ids = None
489
+ else:
490
+ ids = np.concatenate([np.fromfile(work / f"band{b}.id", dtype=np.uint64) for b in bands])
491
+ member = loaded["r"] < threshold
492
+ tx = np.clip(((loaded["x"][member] - x0) // size).astype(np.int64), 0, nx - 1)
493
+ ty = np.clip(((loaded["y"][member] - y0) // size).astype(np.int64), 0, ny - 1)
494
+ tile = ty * nx + tx
495
+ codes = loaded["code"][member]
496
+ if level == 0:
497
+ # (tile, feature, r): any prefix of a (tile, feature) run is a uniform sample.
498
+ order = np.lexsort((loaded["r"], codes, tile))
499
+ # A point's id is its row in level-0 tile_major; kept per band, in spill
500
+ # order, for the coarser levels to carry.
501
+ ids = np.empty(len(codes), dtype=np.uint64)
502
+ ids[order] = np.arange(written, written + len(codes), dtype=np.uint64)
503
+ (band,) = bands
504
+ ids.tofile(work / f"band{band}.id")
505
+ member_ids = ids
506
+ else:
507
+ assert ids is not None
508
+ member_ids = ids[member]
509
+ # (tile, feature, id): coarser levels are samples already; rising ids
510
+ # compress well.
511
+ order = np.lexsort((member_ids, codes, tile))
512
+ written += len(order)
513
+
514
+ tm_tile = tile[order]
515
+ tm_codes = codes[order]
516
+ tm_ids = member_ids[order]
517
+ tm_coords = {a: loaded[a][member][order] for a in axes}
518
+ for a in axes:
519
+ tile_major[a].append(tm_coords[a])
520
+ tile_major[feature_key].append(tm_codes)
521
+ if level > 0:
522
+ tile_major["id"].append(tm_ids)
523
+ tile_counts += np.bincount(tm_tile, minlength=n_tiles)
524
+
525
+ # Stable, so each (feature, tile) run keeps its tile_major order (spec §4).
526
+ fm = np.lexsort((tm_tile, tm_codes))
527
+ fm_codes = tm_codes[fm]
528
+ fm_tile = tm_tile[fm]
529
+ for a in axes:
530
+ tm_coords[a][fm].tofile(level_work / f"g{g}.{a}")
531
+ tm_ids[fm].tofile(level_work / f"g{g}.id")
532
+ group_feature_offsets.append(_offsets(np.bincount(fm_codes, minlength=n_features)))
533
+ # Already sorted by (feature, tile): the pairs are its runs.
534
+ keys = fm_codes.astype(np.int64) * n_tiles + fm_tile
535
+ run_starts = np.flatnonzero(np.diff(keys, prepend=-1))
536
+ pair_keys = keys[run_starts]
537
+ pair_counts = np.diff(np.append(run_starts, len(keys)))
538
+ (pair_keys % n_tiles).astype(np.uint32).tofile(level_work / f"g{g}.pair_tile")
539
+ pair_counts.astype(np.uint32).tofile(level_work / f"g{g}.pair_count")
540
+ group_pair_offsets.append(_offsets(np.bincount(pair_keys // n_tiles, minlength=n_features)))
541
+ del loaded, ids, member, order, tm_coords
542
+
543
+ for writer in tile_major.values():
544
+ writer.close()
545
+
546
+ feature_counts = np.sum([np.diff(o.astype(np.int64)) for o in group_feature_offsets], axis=0)
547
+ pair_totals = np.sum([np.diff(o.astype(np.int64)) for o in group_pair_offsets], axis=0)
548
+ n_pairs = int(pair_totals.sum())
549
+ feature_major = _create_dataframe(
550
+ group.create_group("feature_major"),
551
+ [*[(a, np.float32) for a in axes], (feature_key, code_dtype), ("id", np.uint64)],
552
+ n_rows,
553
+ feature_key=feature_key,
554
+ features=features,
555
+ attrs={"points:order": "feature_major", "points:copied_columns": []},
556
+ chunk_rows=chunk_rows,
557
+ shard_chunks=shard_chunks,
558
+ )
559
+ index = group.create_group("index")
560
+ pair_tile = _ShardWriter(index, "feature_tile", n_pairs, np.uint32, chunk_rows, shard_chunks)
561
+ pair_count = _ShardWriter(index, "feature_tile_count", n_pairs, np.uint32, chunk_rows, shard_chunks)
562
+ spilled = [
563
+ {
564
+ **{name: np.memmap(level_work / f"g{g}.{name}", dtype=dtype, mode="r")
565
+ if (level_work / f"g{g}.{name}").stat().st_size else np.empty(0, dtype=dtype)
566
+ for name, dtype in fm_fields},
567
+ **{name: np.memmap(level_work / f"g{g}.{name}", dtype=np.uint32, mode="r")
568
+ if (level_work / f"g{g}.{name}").stat().st_size else np.empty(0, dtype=np.uint32)
569
+ for name in ("pair_tile", "pair_count")},
570
+ }
571
+ for g in range(len(groups))
572
+ ]
573
+ say(f"level {level}: gathering feature_major for {n_features:,} features")
574
+ for f in range(n_features):
575
+ if feature_counts[f] == 0:
576
+ continue
577
+ for g, columns in enumerate(spilled):
578
+ lo, hi = int(group_feature_offsets[g][f]), int(group_feature_offsets[g][f + 1])
579
+ if hi > lo:
580
+ for name, _ in fm_fields:
581
+ feature_major[name].append(columns[name][lo:hi])
582
+ plo, phi = int(group_pair_offsets[g][f]), int(group_pair_offsets[g][f + 1])
583
+ if phi > plo:
584
+ pair_tile.append(columns["pair_tile"][plo:phi])
585
+ pair_count.append(columns["pair_count"][plo:phi])
586
+ feature_major[feature_key].append(np.full(int(feature_counts[f]), f, dtype=code_dtype))
587
+ for writer in (*feature_major.values(), pair_tile, pair_count):
588
+ writer.close()
589
+ del spilled
590
+ shutil.rmtree(level_work, ignore_errors=True)
591
+
592
+ _write_index(index, "tile_rows", _offsets(tile_counts), chunk_rows, shard_chunks)
593
+ _write_index(index, "feature_rows", _offsets(feature_counts), chunk_rows, shard_chunks)
594
+ _write_index(index, "feature_tile_ptr", _offsets(pair_totals), chunk_rows, shard_chunks)
595
+
596
+ entry: dict[str, Any] = {
597
+ "asset": str(level),
598
+ "points:n_points": n_rows,
599
+ "points:grid": {"origin": [x0, y0], "tile_size": size, "shape": [ny, nx]},
600
+ }
601
+ if level > 0:
602
+ entry |= {
603
+ "derived_from": str(level - 1),
604
+ "transform": {"scale": [1.0] * len(axes)},
605
+ "resampling_method": "random",
606
+ }
607
+ return entry
608
+
609
+
610
+ def _identity_transform(axes: Sequence[str], coordinate_system: str, units: str) -> list[dict[str, Any]]:
611
+ axis_list = [{"name": a, "type": "space", "unit": units} for a in axes]
612
+ return [
613
+ {
614
+ "type": "identity",
615
+ "input": {"name": "".join(axes), "axes": axis_list},
616
+ "output": {"name": coordinate_system, "axes": axis_list},
617
+ }
618
+ ]
619
+
620
+
621
+ def _write_standard_points(
622
+ source: PointsSource,
623
+ element_dir: Path,
624
+ *,
625
+ feature_key: str,
626
+ coordinates: Sequence[str],
627
+ features: Sequence[str],
628
+ transformations: list[dict[str, Any]],
629
+ ) -> int:
630
+ """An ordinary SpatialData points element: ``points.parquet`` as parts, and attrs.
631
+
632
+ Coordinates are written as ``x``, ``y`` (``z``), the feature column as a
633
+ categorical over ``features`` so every part shares one dictionary.
634
+ """
635
+ axes = ("x", "y", "z")[: len(coordinates)]
636
+ parquet_dir = element_dir / "points.parquet"
637
+ parquet_dir.mkdir(parents=True)
638
+ categories = pd.CategoricalDtype(list(features))
639
+ parts = 0
640
+ rows = 0
641
+ for frame in source():
642
+ if len(frame) == 0:
643
+ continue
644
+ # A RangeIndex continuing across parts, recorded in the pandas metadata rather
645
+ # than as a column: the form pandas and dask write, and readers expect an index.
646
+ table = pd.DataFrame(
647
+ {
648
+ **{a: frame[c].to_numpy(dtype=np.float32) for a, c in zip(axes, coordinates)},
649
+ # An array, not a Series: a Series would be aligned to the index below
650
+ # by label, emptying every part whose frame index restarts at 0.
651
+ feature_key: pd.Categorical(frame[feature_key].astype(str), dtype=categories),
652
+ },
653
+ index=pd.RangeIndex(rows, rows + len(frame)),
654
+ )
655
+ pq.write_table(
656
+ pa.Table.from_pandas(table),
657
+ parquet_dir / f"part.{parts}.parquet",
658
+ compression="zstd",
659
+ )
660
+ parts += 1
661
+ rows += len(frame)
662
+ group = zarr.open_group(element_dir, mode="a", zarr_format=3)
663
+ group.attrs.update(
664
+ {
665
+ "encoding-type": "ngff:points",
666
+ "axes": list(axes),
667
+ "coordinateTransformations": transformations,
668
+ "spatialdata_attrs": {"feature_key": feature_key, "version": "0.2"},
669
+ }
670
+ )
671
+ return parts
672
+
673
+
674
+ def _merge_consolidated(store_dir: Path, group_dir: Path, prefix: str) -> bool:
675
+ """Put ``group_dir``'s nodes into the root's consolidated metadata under ``prefix``,
676
+ replacing what was there; leave every other entry as it was. False if the root has
677
+ no consolidated metadata."""
678
+ root_json = store_dir / "zarr.json"
679
+ root = json.loads(root_json.read_text())
680
+ consolidated = root.get("consolidated_metadata")
681
+ if consolidated is None:
682
+ return False
683
+ metadata = {
684
+ key: value
685
+ for key, value in consolidated["metadata"].items()
686
+ if key != prefix and not key.startswith(f"{prefix}/")
687
+ }
688
+ for node in group_dir.rglob("zarr.json"):
689
+ if node.name.startswith("._"):
690
+ continue
691
+ relative = node.parent.relative_to(group_dir).as_posix()
692
+ metadata[prefix if relative == "." else f"{prefix}/{relative}"] = json.loads(node.read_text())
693
+ consolidated["metadata"] = dict(sorted(metadata.items()))
694
+ root_json.write_text(json.dumps(root, indent=2, sort_keys=True))
695
+ return True
696
+
697
+
698
+ def write_points_zarr_store(
699
+ data: pd.DataFrame | PointsSource,
700
+ store_dir: str | Path,
701
+ *,
702
+ points_key: str = "transcripts",
703
+ feature_key: str,
704
+ coordinates: Sequence[str] = ("x", "y"),
705
+ units: str = "micrometer",
706
+ coordinate_system: str = "global",
707
+ transformations: list[dict[str, Any]] | None = None,
708
+ overwrite: bool = False,
709
+ **kwargs: Any,
710
+ ) -> dict[str, Any]:
711
+ """Write a new SpatialData store with one points element and its points-zarr copy.
712
+
713
+ ``points/<points_key>`` is a standard element (``points.parquet`` and attrs) that
714
+ any SpatialData reader opens; the points-zarr copy is its ``experimental.points-zarr``
715
+ subgroup (ADR 0006). The store gets consolidated metadata, which browser readers
716
+ need to open it without listing. Other ``kwargs`` are :func:`write_points_zarr`'s.
717
+ """
718
+ store_dir = Path(store_dir)
719
+ if store_dir.exists():
720
+ if not overwrite:
721
+ raise FileExistsError(f"{store_dir} exists; pass overwrite=True to replace it")
722
+ shutil.rmtree(store_dir)
723
+ source: PointsSource = (lambda: [data]) if isinstance(data, pd.DataFrame) else data
724
+ transforms = transformations or _identity_transform(
725
+ ("x", "y", "z")[: len(coordinates)], coordinate_system, units
726
+ )
727
+ root = zarr.open_group(store_dir, mode="w", zarr_format=3)
728
+ root.attrs["spatialdata_attrs"] = {"version": "0.2"}
729
+ root.create_group("points")
730
+ element_dir = store_dir / "points" / points_key
731
+ summary = write_points_zarr(
732
+ source,
733
+ element_dir / SIDECAR_GROUP,
734
+ feature_key=feature_key,
735
+ coordinates=coordinates,
736
+ units=units,
737
+ transformations=transforms,
738
+ **kwargs,
739
+ )
740
+ _write_standard_points(
741
+ source,
742
+ element_dir,
743
+ feature_key=feature_key,
744
+ coordinates=coordinates,
745
+ features=summary["features"],
746
+ transformations=transforms,
747
+ )
748
+ zarr.consolidate_metadata(store_dir)
749
+ return {**summary, "store": str(store_dir)}
750
+
751
+
752
+ def add_points_zarr_to_store(
753
+ data: pd.DataFrame | PointsSource | None,
754
+ store_dir: str | Path,
755
+ *,
756
+ points_key: str,
757
+ overwrite: bool = False,
758
+ **kwargs: Any,
759
+ ) -> dict[str, Any]:
760
+ """Add a points-zarr copy beside the standard points element ``points/<points_key>``.
761
+
762
+ The copy is written to the element's ``experimental.points-zarr`` subgroup, which
763
+ standard readers ignore (ADR 0006). With ``data`` None it is read from the element's
764
+ own ``points.parquet``, a batch at a time, with the element's axes, feature key and
765
+ transformations. When the store root has consolidated metadata, only the copy's
766
+ nodes are merged into it. ``kwargs`` are :func:`write_points_zarr`'s.
767
+ """
768
+ store_dir = Path(store_dir)
769
+ element_dir = store_dir / "points" / points_key
770
+ element_json = element_dir / "zarr.json"
771
+ if not element_json.is_file():
772
+ raise FileNotFoundError(f"No points element at {element_dir}")
773
+ attrs = json.loads(element_json.read_text()).get("attributes", {})
774
+ if "points:version" in attrs:
775
+ raise ValueError(
776
+ f"{element_dir} is itself a points-zarr element; there is no standard copy to add to"
777
+ )
778
+ if data is None:
779
+ declared = (attrs.get("spatialdata_attrs") or {}).get("feature_key")
780
+ if declared is None and "feature_key" not in kwargs:
781
+ raise ValueError(
782
+ f"{element_dir} declares no feature_key; points-zarr needs a feature column"
783
+ )
784
+ feature_key = kwargs.setdefault("feature_key", declared)
785
+ coordinates = kwargs.setdefault("coordinates", tuple(attrs["axes"]))
786
+ kwargs.setdefault("transformations", attrs.get("coordinateTransformations"))
787
+ dataset = ds.dataset(element_dir / "points.parquet", format="parquet", exclude_invalid_files=True)
788
+ columns = [*coordinates, feature_key]
789
+
790
+ def parquet_frames() -> Iterable[pd.DataFrame]:
791
+ for batch in dataset.to_batches(columns=columns, batch_size=4_000_000):
792
+ yield batch.to_pandas()
793
+
794
+ data = parquet_frames
795
+
796
+ group_dir = element_dir / SIDECAR_GROUP
797
+ summary = write_points_zarr(data, group_dir, overwrite=overwrite, **kwargs)
798
+ consolidated = _merge_consolidated(store_dir, group_dir, f"points/{points_key}/{SIDECAR_GROUP}")
799
+ return {**summary, "store": str(store_dir), "consolidated": consolidated}