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.
- spatialdata_js_util/__init__.py +107 -0
- spatialdata_js_util/cli.py +803 -0
- spatialdata_js_util/codecs/__init__.py +67 -0
- spatialdata_js_util/codecs/backends.py +250 -0
- spatialdata_js_util/codecs/chunks.py +40 -0
- spatialdata_js_util/codecs/encoding.py +154 -0
- spatialdata_js_util/codecs/htj2k_wasm.py +266 -0
- spatialdata_js_util/codecs/names.py +26 -0
- spatialdata_js_util/codecs/probe/multicomponent.j2c +0 -0
- spatialdata_js_util/codecs/probe/multicomponent.json +3 -0
- spatialdata_js_util/codecs/probe/multicomponent.npy +0 -0
- spatialdata_js_util/codecs/vendor/__init__.py +1 -0
- spatialdata_js_util/codecs/vendor/encode-plane.mjs +164 -0
- spatialdata_js_util/codecs/vendor/openjph/index.mjs +131 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.mjs +2 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.wasm +0 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.mjs +2 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.wasm +0 -0
- spatialdata_js_util/codecs/zarr_codec.py +149 -0
- spatialdata_js_util/errors.py +5 -0
- spatialdata_js_util/images.py +849 -0
- spatialdata_js_util/index_permutations.py +311 -0
- spatialdata_js_util/points.py +445 -0
- spatialdata_js_util/points_zarr.py +799 -0
- spatialdata_js_util/provenance.py +17 -0
- spatialdata_js_util/pyramids.py +318 -0
- spatialdata_js_util/runners.py +458 -0
- spatialdata_js_util/store.py +305 -0
- spatialdata_js_util/tables.py +310 -0
- spatialdata_js_util/tui/__init__.py +0 -0
- spatialdata_js_util/tui/app.py +112 -0
- spatialdata_js_util/tui/models.py +45 -0
- spatialdata_js_util/tui/screens.py +1255 -0
- spatialdata_js_util/tui/widgets.py +50 -0
- spatialdata_js_util/verify.py +338 -0
- spatialdata_js_util-0.1.0.dist-info/METADATA +412 -0
- spatialdata_js_util-0.1.0.dist-info/RECORD +40 -0
- spatialdata_js_util-0.1.0.dist-info/WHEEL +5 -0
- spatialdata_js_util-0.1.0.dist-info/entry_points.txt +7 -0
- 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}
|