worldarray 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.
- worldarray/__init__.py +43 -0
- worldarray/array.py +935 -0
- worldarray/context.py +53 -0
- worldarray/geometry.py +173 -0
- worldarray/levels.py +147 -0
- worldarray/placement.py +739 -0
- worldarray/resample.py +229 -0
- worldarray/retry.py +76 -0
- worldarray/store.py +427 -0
- worldarray/units.py +77 -0
- worldarray-0.1.0.dist-info/METADATA +214 -0
- worldarray-0.1.0.dist-info/RECORD +14 -0
- worldarray-0.1.0.dist-info/WHEEL +4 -0
- worldarray-0.1.0.dist-info/licenses/LICENSE +28 -0
worldarray/store.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""Where an array's files are, reading its metadata documents, and opening it.
|
|
2
|
+
|
|
3
|
+
Everything goes through tensorstore's kvstore: ``zarr.json``, ``.zarray``, ``.zattrs``,
|
|
4
|
+
``.zgroup``, ``attributes.json`` and ``info`` are read as bytes and parsed here, so no
|
|
5
|
+
zarr-python is involved. Remote stores are read the way a public dataset most likely lets
|
|
6
|
+
one in, with credentials only when a public read is refused (spec, Conventions 5).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import functools
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import threading
|
|
17
|
+
from collections.abc import Callable, Sequence
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, TypeVar
|
|
20
|
+
|
|
21
|
+
import tensorstore as ts
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
|
|
26
|
+
PRECOMPUTED_PREFIX = "precomputed://"
|
|
27
|
+
GCS_PUBLIC_URL = "https://storage.googleapis.com"
|
|
28
|
+
METADATA_RETRIES = {"max_retries": 5, "initial_delay": "0.2s", "max_delay": "2s"}
|
|
29
|
+
PROBE_KEYS = ("zarr.json", ".zarray", ".zgroup", "info", "attributes.json")
|
|
30
|
+
|
|
31
|
+
_SCHEME = re.compile(r"^(s3|gs|http|https|file|memory)://")
|
|
32
|
+
_REMOTE = ("s3://", "gs://", "http://", "https://")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Paths
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def normalize_path(path: str) -> str:
|
|
40
|
+
"""``precomputed://`` dropped, trailing slashes dropped, a local path made absolute."""
|
|
41
|
+
path = str(path)
|
|
42
|
+
if path.startswith(PRECOMPUTED_PREFIX):
|
|
43
|
+
path = path[len(PRECOMPUTED_PREFIX) :]
|
|
44
|
+
path = path.rstrip("/") or "/"
|
|
45
|
+
if not _SCHEME.match(path):
|
|
46
|
+
path = os.path.abspath(path)
|
|
47
|
+
return path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_remote(path: str) -> bool:
|
|
51
|
+
return normalize_path(path).startswith(_REMOTE)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parent(path: str) -> str | None:
|
|
55
|
+
"""The containing directory or URL prefix; None at a root (``/``, ``s3://bucket``)."""
|
|
56
|
+
path = normalize_path(path)
|
|
57
|
+
m = _SCHEME.match(path)
|
|
58
|
+
head = m.group(0) if m else ""
|
|
59
|
+
rest = path[len(head) :]
|
|
60
|
+
if "/" not in rest.strip("/"):
|
|
61
|
+
return None if (head or rest == "/") else "/"
|
|
62
|
+
return head + rest.rsplit("/", 1)[0]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_root(path: str | None) -> bool:
|
|
66
|
+
"""The filesystem root, a URL's host or bucket root, or nothing: never probed for documents."""
|
|
67
|
+
if path is None:
|
|
68
|
+
return True
|
|
69
|
+
path = normalize_path(path)
|
|
70
|
+
if path == "/":
|
|
71
|
+
return True
|
|
72
|
+
m = _SCHEME.match(path)
|
|
73
|
+
return bool(m) and "/" not in path[len(m.group(0)) :].strip("/")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
GUARD_GCE_METADATA = True # see kvstore_specs
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def basename(path: str) -> str:
|
|
80
|
+
return normalize_path(path).rsplit("/", 1)[-1]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def join(base: str, *parts: str) -> str:
|
|
84
|
+
return "/".join([normalize_path(base).rstrip("/"), *(p.strip("/") for p in parts)])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Access
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def kvstore_specs(root: str) -> list[dict]:
|
|
92
|
+
"""The tensorstore kvstore specs that read the directory ``root``, in the order to try
|
|
93
|
+
them. ``s3://``: anonymously, then AWS's default credentials (S3 refuses even a public
|
|
94
|
+
read signed with a stale or foreign key). ``gs://``: Google's default credentials
|
|
95
|
+
(anonymous without any), then the bucket's public https URL for credentials that are
|
|
96
|
+
there but broken (``TENSORSTORE_GCS_HTTP_URL`` moves it). Anything else: one way. Each
|
|
97
|
+
root ends in ``/`` because tensorstore concatenates keys literally."""
|
|
98
|
+
root = normalize_path(root).rstrip("/") + "/" # "/" stays "/", never "//"
|
|
99
|
+
if not _SCHEME.match(root):
|
|
100
|
+
return [{"driver": "file", "path": root}]
|
|
101
|
+
spec = ts.KvStore.Spec(root).to_json()
|
|
102
|
+
if spec["driver"] == "s3":
|
|
103
|
+
extra: dict = {}
|
|
104
|
+
endpoint = os.environ.get("AWS_ENDPOINT_URL_S3") or os.environ.get("AWS_ENDPOINT_URL")
|
|
105
|
+
if endpoint: # an S3-compatible endpoint (MinIO, moto, a mirror), as the AWS tools read it
|
|
106
|
+
extra["endpoint"] = endpoint
|
|
107
|
+
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
|
|
108
|
+
if region:
|
|
109
|
+
extra["aws_region"] = region
|
|
110
|
+
return [{**spec, **extra, "aws_credentials": {"type": t}} for t in ("anonymous", "default")]
|
|
111
|
+
if spec["driver"] == "gcs":
|
|
112
|
+
if GUARD_GCE_METADATA:
|
|
113
|
+
# off Google Cloud, tensorstore's probe of the GCE metadata server for credentials
|
|
114
|
+
# stalls every gs:// open for seconds; point it at a name that fails at once
|
|
115
|
+
os.environ.setdefault("GCE_METADATA_ROOT", "metadata.google.internal.invalid")
|
|
116
|
+
public = (os.environ.get("TENSORSTORE_GCS_HTTP_URL") or GCS_PUBLIC_URL).rstrip("/")
|
|
117
|
+
return [
|
|
118
|
+
spec,
|
|
119
|
+
{
|
|
120
|
+
"driver": "http",
|
|
121
|
+
"base_url": public,
|
|
122
|
+
"path": f"/{spec['bucket']}/{spec.get('path', '')}",
|
|
123
|
+
},
|
|
124
|
+
]
|
|
125
|
+
return [spec]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class Refused(Exception):
|
|
129
|
+
"""A read was refused for want of credentials (not a missing key, not a network error)."""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def is_refused(error: BaseException) -> bool:
|
|
133
|
+
return isinstance(error, Refused) or str(error).startswith(
|
|
134
|
+
("PERMISSION_DENIED", "UNAUTHENTICATED")
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
_admitted: dict[tuple[str, str], int] = {} # (driver, bucket) -> index of the spec let in
|
|
139
|
+
_admitted_lock = threading.Lock()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def with_access(specs: Sequence[dict], attempt: Callable[[dict], T]) -> T:
|
|
143
|
+
"""``attempt(spec)`` with each spec in turn, moving on only while access is refused; the
|
|
144
|
+
way that worked is tried first next time, for its bucket. Any other error, and the last
|
|
145
|
+
refusal, is raised."""
|
|
146
|
+
if len(specs) == 1:
|
|
147
|
+
return attempt(specs[0])
|
|
148
|
+
key = (specs[0]["driver"], specs[0].get("bucket", ""))
|
|
149
|
+
with _admitted_lock:
|
|
150
|
+
first = _admitted.get(key, 0)
|
|
151
|
+
order = [first] + [i for i in range(len(specs)) if i != first]
|
|
152
|
+
for i in order[:-1]:
|
|
153
|
+
try:
|
|
154
|
+
result = attempt(specs[i])
|
|
155
|
+
except Exception as e:
|
|
156
|
+
if not is_refused(e):
|
|
157
|
+
raise
|
|
158
|
+
logger.info(
|
|
159
|
+
"%s refused a read (%s); trying the next way in", key, str(e).splitlines()[0][:200]
|
|
160
|
+
)
|
|
161
|
+
continue
|
|
162
|
+
with _admitted_lock:
|
|
163
|
+
_admitted[key] = i
|
|
164
|
+
return result
|
|
165
|
+
result = attempt(specs[order[-1]])
|
|
166
|
+
with _admitted_lock:
|
|
167
|
+
_admitted[key] = order[-1]
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def metadata_spec(spec: dict) -> dict:
|
|
172
|
+
"""``spec`` with its requests' retries capped, so a mistyped host fails in seconds."""
|
|
173
|
+
if spec["driver"] not in ("s3", "gcs", "http"):
|
|
174
|
+
return spec
|
|
175
|
+
return {**spec, "context": {f"{spec['driver']}_request_retries": METADATA_RETRIES}}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@functools.lru_cache(maxsize=512)
|
|
179
|
+
def _metadata_kvstore(spec_json: str, gcs_url: str | None) -> ts.KvStore:
|
|
180
|
+
"""One opened kvstore per spec, and per ``TENSORSTORE_GCS_HTTP_URL``: tensorstore's gcs
|
|
181
|
+
driver reads that variable when the store is opened, so a cached store would keep
|
|
182
|
+
talking to the host the variable named then."""
|
|
183
|
+
return ts.KvStore.open(metadata_spec(json.loads(spec_json))).result()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _read_key(spec: dict, key: str) -> bytes | None:
|
|
187
|
+
kv = _metadata_kvstore(
|
|
188
|
+
json.dumps(spec, sort_keys=True), os.environ.get("TENSORSTORE_GCS_HTTP_URL")
|
|
189
|
+
)
|
|
190
|
+
result = kv.read(key).result()
|
|
191
|
+
return None if result.state == "missing" else bytes(result.value)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class Location:
|
|
195
|
+
"""A directory (local or a URL) and the ways in to it."""
|
|
196
|
+
|
|
197
|
+
def __init__(self, root: str):
|
|
198
|
+
self.root = normalize_path(root)
|
|
199
|
+
self.specs = kvstore_specs(self.root)
|
|
200
|
+
|
|
201
|
+
def read(self, key: str) -> bytes | None:
|
|
202
|
+
return with_access(self.specs, lambda spec: _read_key(spec, key))
|
|
203
|
+
|
|
204
|
+
def read_json(self, key: str) -> dict | None:
|
|
205
|
+
data = self.read(key)
|
|
206
|
+
return None if data is None else json.loads(data)
|
|
207
|
+
|
|
208
|
+
def read_many(self, keys: Sequence[str]) -> dict[str, bytes | None]:
|
|
209
|
+
"""All of ``keys`` under one way in. A bucket with listing off answers a refusal for a
|
|
210
|
+
missing key, so a refusal on one key counts as missing when another key was read
|
|
211
|
+
under the same way; only when every key is refused is the next way tried."""
|
|
212
|
+
|
|
213
|
+
def attempt(spec: dict) -> dict[str, bytes | None]:
|
|
214
|
+
found: dict[str, bytes | None] = {}
|
|
215
|
+
refusals: dict[str, BaseException] = {}
|
|
216
|
+
for key in keys:
|
|
217
|
+
try:
|
|
218
|
+
found[key] = _read_key(spec, key)
|
|
219
|
+
except Exception as e:
|
|
220
|
+
if not is_refused(e):
|
|
221
|
+
raise
|
|
222
|
+
refusals[key] = e
|
|
223
|
+
if refusals and len(refusals) == len(keys):
|
|
224
|
+
raise Refused(next(iter(refusals.values())).args[0] if refusals else "refused")
|
|
225
|
+
for key in refusals:
|
|
226
|
+
found[key] = None
|
|
227
|
+
return found
|
|
228
|
+
|
|
229
|
+
return with_access(self.specs, attempt)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# Detection
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass(frozen=True)
|
|
237
|
+
class Node:
|
|
238
|
+
"""What is at a path: its format, whether it is an array or a group, and the JSON
|
|
239
|
+
documents found there. For a precomputed scale named by its key, ``path`` is the
|
|
240
|
+
volume root and ``scale_key`` the key."""
|
|
241
|
+
|
|
242
|
+
format: str # zarr2 | zarr3 | n5 | precomputed
|
|
243
|
+
kind: str # array | group
|
|
244
|
+
path: str
|
|
245
|
+
docs: dict[str, Any] = field(default_factory=dict)
|
|
246
|
+
scale_key: str | None = None
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def location(self) -> Location:
|
|
250
|
+
return Location(self.path)
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def attrs(self) -> dict:
|
|
254
|
+
"""The node's user attributes, raw: zarr v3 ``attributes``, zarr v2 ``.zattrs``, the N5
|
|
255
|
+
``attributes.json``, a precomputed ``info``. ``{}`` for a group with no document."""
|
|
256
|
+
if self.format == "zarr3":
|
|
257
|
+
return dict(self.docs.get("zarr.json", {}).get("attributes", {}) or {})
|
|
258
|
+
if self.format == "zarr2":
|
|
259
|
+
return dict(self.location.read_json(".zattrs") or {})
|
|
260
|
+
if self.format == "n5":
|
|
261
|
+
return dict(self.docs.get("attributes.json", {}) or {})
|
|
262
|
+
return dict(self.docs.get("info", {}) or {})
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def ome_attrs(self) -> dict:
|
|
266
|
+
"""``attrs`` with OME-NGFF 0.5's ``ome`` member unwrapped onto the top level, so
|
|
267
|
+
``multiscales``, ``coordinateSystems``, ``coordinateTransformations`` and the rest
|
|
268
|
+
read the same for 0.4 and 0.5."""
|
|
269
|
+
attrs = self.attrs
|
|
270
|
+
ome = attrs.get("ome")
|
|
271
|
+
return {**attrs, **ome} if isinstance(ome, dict) else attrs
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def multiscales(self) -> list[dict]:
|
|
275
|
+
"""The OME-NGFF ``multiscales`` list (0.5's ``ome`` nesting unwrapped), else ``[]``."""
|
|
276
|
+
return multiscales_of(self.attrs)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def multiscales_of(attrs: dict) -> list[dict]:
|
|
280
|
+
ome = attrs.get("ome")
|
|
281
|
+
if isinstance(ome, dict) and "multiscales" in ome:
|
|
282
|
+
return list(ome["multiscales"])
|
|
283
|
+
return list(attrs.get("multiscales", []) or [])
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _decode(docs: dict[str, bytes | None]) -> dict[str, Any]:
|
|
287
|
+
return {k: json.loads(v) for k, v in docs.items() if v is not None}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def detect(path: str, *, missing_ok: bool = False) -> Node | None:
|
|
291
|
+
"""Probe ``zarr.json``, ``.zarray``, ``.zgroup``, ``info``, ``attributes.json`` in that
|
|
292
|
+
order; failing all, probe the parent for ``info`` and take the last path segment as a
|
|
293
|
+
precomputed scale key, then probe ``s0`` for a group that has no document of its own.
|
|
294
|
+
The ``.n5`` or ``.zarr`` suffix is never consulted. Nothing found raises
|
|
295
|
+
``FileNotFoundError``, or returns None with ``missing_ok``."""
|
|
296
|
+
path = normalize_path(path)
|
|
297
|
+
docs = _decode(Location(path).read_many(PROBE_KEYS))
|
|
298
|
+
if "zarr.json" in docs:
|
|
299
|
+
node_type = docs["zarr.json"].get("node_type", "array")
|
|
300
|
+
return Node("zarr3", "array" if node_type == "array" else "group", path, docs)
|
|
301
|
+
if ".zarray" in docs:
|
|
302
|
+
return Node("zarr2", "array", path, docs)
|
|
303
|
+
if ".zgroup" in docs:
|
|
304
|
+
return Node("zarr2", "group", path, docs)
|
|
305
|
+
if "info" in docs:
|
|
306
|
+
return Node("precomputed", "group", path, docs)
|
|
307
|
+
if "attributes.json" in docs:
|
|
308
|
+
kind = "array" if "dimensions" in docs["attributes.json"] else "group"
|
|
309
|
+
return Node("n5", kind, path, docs)
|
|
310
|
+
up = parent(path)
|
|
311
|
+
if up is not None:
|
|
312
|
+
info = Location(up).read_json("info")
|
|
313
|
+
if info is not None:
|
|
314
|
+
return Node("precomputed", "group", up, {"info": info}, scale_key=basename(path))
|
|
315
|
+
child = _decode(
|
|
316
|
+
Location(join(path, "s0")).read_many(("zarr.json", ".zarray", "attributes.json"))
|
|
317
|
+
)
|
|
318
|
+
if child:
|
|
319
|
+
fmt = "zarr3" if "zarr.json" in child else "zarr2" if ".zarray" in child else "n5"
|
|
320
|
+
return Node(fmt, "group", path, {})
|
|
321
|
+
if missing_ok:
|
|
322
|
+
return None
|
|
323
|
+
raise FileNotFoundError(
|
|
324
|
+
f"{path}: none of {', '.join(PROBE_KEYS)} found, the parent holds no precomputed info, and there is no s0"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
# Opening
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def precomputed_scale_index(info: dict, key: str | int) -> int:
|
|
333
|
+
"""The index of a precomputed scale named by its key (``"8_8_8"``), by position
|
|
334
|
+
(``1``), or by cellmap-flow's ``s<N>`` convention when no scale is keyed that way."""
|
|
335
|
+
keys = [str(s.get("key", i)) for i, s in enumerate(info["scales"])]
|
|
336
|
+
if isinstance(key, int) and not isinstance(key, bool):
|
|
337
|
+
if not -len(keys) <= key < len(keys):
|
|
338
|
+
raise ValueError(f"no precomputed scale {key}; there are {len(keys)}: {keys}")
|
|
339
|
+
return key % len(keys)
|
|
340
|
+
if key in keys:
|
|
341
|
+
return keys.index(key)
|
|
342
|
+
m = re.fullmatch(r"s(\d+)", str(key))
|
|
343
|
+
if m and int(m.group(1)) < len(keys):
|
|
344
|
+
return int(m.group(1))
|
|
345
|
+
raise ValueError(f"no precomputed scale {key!r}; scales: {keys}")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
_KNOWN_COMPRESSOR_MEMBERS = {
|
|
349
|
+
"zstd": {"id", "level"},
|
|
350
|
+
"blosc": {"id", "cname", "clevel", "shuffle", "blocksize"},
|
|
351
|
+
"gzip": {"id", "level"},
|
|
352
|
+
"zlib": {"id", "level"},
|
|
353
|
+
"bz2": {"id", "level"},
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def cleaned_zarray(zarray: dict) -> dict:
|
|
358
|
+
"""A zarr v2 ``.zarray`` with compressor members tensorstore rejects dropped, such as the
|
|
359
|
+
``checksum`` newer numcodecs writes for zstd."""
|
|
360
|
+
compressor = zarray.get("compressor")
|
|
361
|
+
if isinstance(compressor, dict) and compressor.get("id") in _KNOWN_COMPRESSOR_MEMBERS:
|
|
362
|
+
keep = _KNOWN_COMPRESSOR_MEMBERS[compressor["id"]]
|
|
363
|
+
compressor = {k: v for k, v in compressor.items() if k in keep}
|
|
364
|
+
return {**zarray, "compressor": compressor}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
_DRIVER = {"zarr2": "zarr", "zarr3": "zarr3", "n5": "n5", "precomputed": "neuroglancer_precomputed"}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def open_array(
|
|
371
|
+
node: Node,
|
|
372
|
+
*,
|
|
373
|
+
context: ts.Context,
|
|
374
|
+
scale_index: int | None = None,
|
|
375
|
+
squeeze_channel: bool = True,
|
|
376
|
+
) -> ts.TensorStore:
|
|
377
|
+
"""The tensorstore of an array node (or one scale of a precomputed node), in C order
|
|
378
|
+
with its origin at 0: N5 and precomputed are transposed, a precomputed ``voxel_offset``
|
|
379
|
+
is moved to 0 (and carried in the metadata as the translation), and a precomputed
|
|
380
|
+
single channel is dropped."""
|
|
381
|
+
if node.kind != "array" and node.format != "precomputed":
|
|
382
|
+
raise ValueError(f"{node.path} is a {node.format} group, not an array")
|
|
383
|
+
|
|
384
|
+
def attempt(kvstore: dict) -> ts.TensorStore:
|
|
385
|
+
spec: dict[str, Any] = {"driver": _DRIVER[node.format], "kvstore": kvstore}
|
|
386
|
+
if node.format == "zarr2":
|
|
387
|
+
spec["metadata"] = cleaned_zarray(node.docs[".zarray"])
|
|
388
|
+
spec["assume_metadata"] = True
|
|
389
|
+
if node.format == "n5":
|
|
390
|
+
# only the array members: tensorstore rejects attributes it validates and finds
|
|
391
|
+
# odd (a "units" string instead of a list); everything else is read here, not by it
|
|
392
|
+
core = node.docs["attributes.json"]
|
|
393
|
+
spec["metadata"] = {
|
|
394
|
+
k: core[k]
|
|
395
|
+
for k in ("dimensions", "blockSize", "dataType", "compression")
|
|
396
|
+
if k in core
|
|
397
|
+
}
|
|
398
|
+
spec["assume_metadata"] = True
|
|
399
|
+
if node.format == "precomputed" and scale_index is not None:
|
|
400
|
+
spec["scale_index"] = scale_index
|
|
401
|
+
return ts.open(spec, read=True, context=context).result()
|
|
402
|
+
|
|
403
|
+
store = with_access(kvstore_specs(node.path), attempt)
|
|
404
|
+
if node.format in ("n5", "precomputed"):
|
|
405
|
+
store = store.T
|
|
406
|
+
if node.format == "precomputed":
|
|
407
|
+
store = store[ts.d[:].translate_to[0]]
|
|
408
|
+
if squeeze_channel and store.shape[0] == 1:
|
|
409
|
+
store = store[0]
|
|
410
|
+
return store
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def list_chunk_keys(
|
|
414
|
+
root: str, separator: str = ".", ndim: int | None = None
|
|
415
|
+
) -> list[tuple[int, ...]]:
|
|
416
|
+
"""The chunk indices present under ``root`` by listing its keys and parsing those that are
|
|
417
|
+
``separator``-joined integers (``0.1.2``, ``0/1/2``), with no metadata document needed:
|
|
418
|
+
a directory of chunk files with no ``.zarray`` lists the same as one with it. ``ndim``
|
|
419
|
+
keeps only keys with that many parts. Any kvstore, URLs included."""
|
|
420
|
+
specs = kvstore_specs(root)
|
|
421
|
+
kv = with_access(specs, lambda spec: ts.KvStore.open(metadata_spec(spec)).result())
|
|
422
|
+
keys = [k.decode() if isinstance(k, bytes) else str(k) for k in kv.list().result()]
|
|
423
|
+
pattern = re.compile(r"^\d+(?:" + re.escape(separator) + r"\d+)*$")
|
|
424
|
+
found = {tuple(int(p) for p in k.split(separator)) for k in keys if pattern.match(k)}
|
|
425
|
+
if ndim is not None:
|
|
426
|
+
found = {f for f in found if len(f) == ndim}
|
|
427
|
+
return sorted(found)
|
worldarray/units.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Units: kept as stored, converted only on the axes a caller asks to convert.
|
|
2
|
+
|
|
3
|
+
One table, accepting American and British spellings, ``um``, ``µm`` (U+00B5) and ``μm``
|
|
4
|
+
(U+03BC), and the usual time units. A missing unit is the empty string. Nothing here is
|
|
5
|
+
consulted until a conversion is asked for, so an array with ``"degrees_north"`` axes is as
|
|
6
|
+
welcome as one in nanometres (spec, Conventions 3).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
NANOMETER = "nanometer"
|
|
12
|
+
SECOND = "second"
|
|
13
|
+
|
|
14
|
+
LENGTH_M = {
|
|
15
|
+
"picometer": 1e-12, "picometre": 1e-12, "pm": 1e-12,
|
|
16
|
+
"angstrom": 1e-10, "ångström": 1e-10, "å": 1e-10, "Å": 1e-10,
|
|
17
|
+
"nanometer": 1e-9, "nanometre": 1e-9, "nm": 1e-9,
|
|
18
|
+
"micrometer": 1e-6, "micrometre": 1e-6, "micron": 1e-6, "um": 1e-6, "µm": 1e-6, "μm": 1e-6,
|
|
19
|
+
"millimeter": 1e-3, "millimetre": 1e-3, "mm": 1e-3,
|
|
20
|
+
"centimeter": 1e-2, "centimetre": 1e-2, "cm": 1e-2,
|
|
21
|
+
"meter": 1.0, "metre": 1.0, "m": 1.0,
|
|
22
|
+
} # fmt: skip
|
|
23
|
+
TIME_S = {
|
|
24
|
+
"nanosecond": 1e-9, "ns": 1e-9,
|
|
25
|
+
"microsecond": 1e-6, "us": 1e-6, "µs": 1e-6, "μs": 1e-6,
|
|
26
|
+
"millisecond": 1e-3, "ms": 1e-3,
|
|
27
|
+
"second": 1.0, "sec": 1.0, "s": 1.0,
|
|
28
|
+
"minute": 60.0, "min": 60.0,
|
|
29
|
+
"hour": 3600.0, "h": 3600.0,
|
|
30
|
+
"day": 86400.0, "d": 86400.0,
|
|
31
|
+
} # fmt: skip
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _lookup(unit: str) -> tuple[str, float] | None:
|
|
35
|
+
"""``(kind, size in the kind's base unit)`` of a unit string, or None when unknown.
|
|
36
|
+
Plurals and case are forgiven; the Greek letters are matched as they are."""
|
|
37
|
+
for key in (unit, unit.strip(), unit.strip().lower(), unit.strip().lower().rstrip("s")):
|
|
38
|
+
if key in LENGTH_M:
|
|
39
|
+
return "space", LENGTH_M[key]
|
|
40
|
+
if key in TIME_S:
|
|
41
|
+
return "time", TIME_S[key]
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def kind(unit: str) -> str:
|
|
46
|
+
"""``"space"``, ``"time"`` or ``"unknown"`` for a unit string; ``""`` is unknown."""
|
|
47
|
+
found = _lookup(unit) if unit else None
|
|
48
|
+
return found[0] if found else "unknown"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
TABLE = {"length": LENGTH_M, "time": TIME_S} # public: cellmap-flow exports it to the browser
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def factor(
|
|
55
|
+
unit_from: str, unit_to: str, *, missing_as: str | None = None, lenient: bool = False
|
|
56
|
+
) -> float:
|
|
57
|
+
"""How many ``unit_to`` one ``unit_from`` is.
|
|
58
|
+
|
|
59
|
+
``unit_from == ""`` (nothing stored) is read as ``missing_as`` when that is given, else
|
|
60
|
+
it is an error: the caller decides what an unlabelled axis means, not this table. An
|
|
61
|
+
unknown unit, or a length asked for in a time unit, is a ``ValueError``; with
|
|
62
|
+
``lenient`` an unknown unit is a factor of 1 (the caller warns).
|
|
63
|
+
"""
|
|
64
|
+
if unit_from == "":
|
|
65
|
+
if missing_as is None:
|
|
66
|
+
raise ValueError("no unit stored on this axis and no default given")
|
|
67
|
+
unit_from = missing_as
|
|
68
|
+
a, b = _lookup(unit_from), _lookup(unit_to)
|
|
69
|
+
if a is None:
|
|
70
|
+
if lenient: # "pixel", "voxel", a typo: the numbers are taken as already in unit_to
|
|
71
|
+
return 1.0
|
|
72
|
+
raise ValueError(f"unknown unit {unit_from!r}")
|
|
73
|
+
if b is None:
|
|
74
|
+
raise ValueError(f"unknown unit {unit_to!r}")
|
|
75
|
+
if a[0] != b[0]:
|
|
76
|
+
raise ValueError(f"cannot convert {unit_from!r} ({a[0]}) to {unit_to!r} ({b[0]})")
|
|
77
|
+
return a[1] / b[1]
|