Spacelab 0.28.0__tar.gz
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.
- spacelab-0.28.0/.gitignore +38 -0
- spacelab-0.28.0/LICENSE +21 -0
- spacelab-0.28.0/PKG-INFO +30 -0
- spacelab-0.28.0/README.md +15 -0
- spacelab-0.28.0/build.sh +15 -0
- spacelab-0.28.0/pyproject.toml +40 -0
- spacelab-0.28.0/src/spacelab/__init__.py +6 -0
- spacelab-0.28.0/src/spacelab/__main__.py +3 -0
- spacelab-0.28.0/src/spacelab/cli.py +97 -0
- spacelab-0.28.0/src/spacelab/hlod/__init__.py +8 -0
- spacelab-0.28.0/src/spacelab/hlod/__main__.py +5 -0
- spacelab-0.28.0/src/spacelab/hlod/build.py +349 -0
- spacelab-0.28.0/src/spacelab/hlod/cells.py +82 -0
- spacelab-0.28.0/src/spacelab/hlod/cli.py +90 -0
- spacelab-0.28.0/src/spacelab/hlod/hashing.py +38 -0
- spacelab-0.28.0/src/spacelab/hlod/hilbert.py +91 -0
- spacelab-0.28.0/src/spacelab/hlod/read.py +150 -0
- spacelab-0.28.0/src/spacelab/hlod/verify.py +142 -0
- spacelab-0.28.0/src/spacelab/prep_spatialdata.py +700 -0
- spacelab-0.28.0/src/spacelab/server.py +130 -0
- spacelab-0.28.0/src/spacelab/static/AI_lens_black.png +0 -0
- spacelab-0.28.0/src/spacelab/static/ai-new-logo.png +0 -0
- spacelab-0.28.0/src/spacelab/static/aifi_logo.png +0 -0
- spacelab-0.28.0/src/spacelab/static/assets/AllenInstitutePlusText-Bold-DxLtQ4oE.woff2 +0 -0
- spacelab-0.28.0/src/spacelab/static/assets/AllenInstitutePlusText-BoldItalic-mxzHQhQO.woff2 +0 -0
- spacelab-0.28.0/src/spacelab/static/assets/AllenInstitutePlusText-Italic-CaKmxpsW.woff2 +0 -0
- spacelab-0.28.0/src/spacelab/static/assets/AllenInstitutePlusText-Light-DUThXl1m.woff2 +0 -0
- spacelab-0.28.0/src/spacelab/static/assets/AllenInstitutePlusText-LightItalic-CxfKknzG.woff2 +0 -0
- spacelab-0.28.0/src/spacelab/static/assets/AllenInstitutePlusText-Regular-B0ryviHQ.woff2 +0 -0
- spacelab-0.28.0/src/spacelab/static/assets/AllenInstitutePlusText-Semibold-3PoiDF1d.woff2 +0 -0
- spacelab-0.28.0/src/spacelab/static/assets/AllenInstitutePlusText-SemiboldItalic-uXaVqnFo.woff2 +0 -0
- spacelab-0.28.0/src/spacelab/static/assets/blosc-BKQbfHyB.js +18 -0
- spacelab-0.28.0/src/spacelab/static/assets/blosc-j6ygecpn.js +18 -0
- spacelab-0.28.0/src/spacelab/static/assets/chunk-INHXZS53-D3tQiqtZ.js +2 -0
- spacelab-0.28.0/src/spacelab/static/assets/chunk-INHXZS53-QjxmivJp.js +2 -0
- spacelab-0.28.0/src/spacelab/static/assets/deflate-BRVR_fNk.js +2 -0
- spacelab-0.28.0/src/spacelab/static/assets/hlodTranscripts.worker-CjhNLk56.js +2 -0
- spacelab-0.28.0/src/spacelab/static/assets/index-BT-mDgg2.js +5025 -0
- spacelab-0.28.0/src/spacelab/static/assets/index-CNIqsj6m.css +1 -0
- spacelab-0.28.0/src/spacelab/static/assets/jpeg-JUDfHHDo.js +2 -0
- spacelab-0.28.0/src/spacelab/static/assets/lerc-CXQdKTps.js +2 -0
- spacelab-0.28.0/src/spacelab/static/assets/lz4-C2MQfzIA.js +16 -0
- spacelab-0.28.0/src/spacelab/static/assets/lz4-rzy_Siro.js +16 -0
- spacelab-0.28.0/src/spacelab/static/assets/lzw-DBCCrYEo.js +2 -0
- spacelab-0.28.0/src/spacelab/static/assets/packbits-LrtZ2mhj.js +2 -0
- spacelab-0.28.0/src/spacelab/static/assets/pako.esm-C69tvH9t.js +2 -0
- spacelab-0.28.0/src/spacelab/static/assets/polygons.worker-ClOPdWlW.js +2 -0
- spacelab-0.28.0/src/spacelab/static/assets/raw-lPmjnySZ.js +2 -0
- spacelab-0.28.0/src/spacelab/static/assets/spatialdataExpression.worker-D4W_OHS3.js +4 -0
- spacelab-0.28.0/src/spacelab/static/assets/spatialdataShapes.worker-DQV1zeTt.js +974 -0
- spacelab-0.28.0/src/spacelab/static/assets/spatialdataTranscripts.worker-DH9L-MwZ.js +974 -0
- spacelab-0.28.0/src/spacelab/static/assets/transcripts.worker-CtxyO4n3.js +2 -0
- spacelab-0.28.0/src/spacelab/static/assets/umap.worker-BPHuoLPV.js +2 -0
- spacelab-0.28.0/src/spacelab/static/assets/webimage-xg0Pp5Jv.js +2 -0
- spacelab-0.28.0/src/spacelab/static/assets/zstd-Cp7BEDDH.js +16 -0
- spacelab-0.28.0/src/spacelab/static/assets/zstd-WlDjm0Vu.js +16 -0
- spacelab-0.28.0/src/spacelab/static/blurred-bg.jpg +0 -0
- spacelab-0.28.0/src/spacelab/static/favicon.ico +0 -0
- spacelab-0.28.0/src/spacelab/static/index.html +22 -0
- spacelab-0.28.0/src/spacelab/static/logo192.png +0 -0
- spacelab-0.28.0/src/spacelab/static/logo512.png +0 -0
- spacelab-0.28.0/src/spacelab/static/manifest.json +15 -0
- spacelab-0.28.0/src/spacelab/static/robots.txt +3 -0
- spacelab-0.28.0/tests/conftest.py +67 -0
- spacelab-0.28.0/tests/test_build.py +141 -0
- spacelab-0.28.0/tests/test_cli.py +37 -0
- spacelab-0.28.0/tests/test_hashing.py +27 -0
- spacelab-0.28.0/tests/test_hilbert.py +46 -0
- spacelab-0.28.0/tests/test_read.py +102 -0
- spacelab-0.28.0/uv.lock +847 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
|
|
4
|
+
# Build output
|
|
5
|
+
dist/
|
|
6
|
+
|
|
7
|
+
# Test output
|
|
8
|
+
coverage/
|
|
9
|
+
playwright-report/
|
|
10
|
+
test-results/
|
|
11
|
+
|
|
12
|
+
# Local env
|
|
13
|
+
.env
|
|
14
|
+
.env.local
|
|
15
|
+
|
|
16
|
+
# Editor / OS cruft
|
|
17
|
+
.DS_Store
|
|
18
|
+
._*
|
|
19
|
+
.last-review.md
|
|
20
|
+
|
|
21
|
+
# Python
|
|
22
|
+
__pycache__/
|
|
23
|
+
*.py[cod]
|
|
24
|
+
.ipynb_checkpoints/
|
|
25
|
+
.venv/
|
|
26
|
+
*.egg-info/
|
|
27
|
+
|
|
28
|
+
# Personal Claude Code settings (per-clone)
|
|
29
|
+
.claude/settings.local.json
|
|
30
|
+
|
|
31
|
+
# Sphinx docs build
|
|
32
|
+
docs/_build/
|
|
33
|
+
|
|
34
|
+
# PyPI package build output (frontend copied in by py/build.sh)
|
|
35
|
+
py/src/spacelab/static/
|
|
36
|
+
py/src/spacelab/prep_spatialdata.py
|
|
37
|
+
py/dist/
|
|
38
|
+
py/LICENSE
|
spacelab-0.28.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Allen Institute
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
spacelab-0.28.0/PKG-INFO
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: Spacelab
|
|
3
|
+
Version: 0.28.0
|
|
4
|
+
Summary: Browser-based viewer for bioimaging data (spatial transcriptomics, multiplexed immunofluorescence).
|
|
5
|
+
Project-URL: Repository, https://github.com/aifimmunology/Spacelab
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: spatial-transcriptomics,visualization,xenium,zarr
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Provides-Extra: hlod
|
|
11
|
+
Requires-Dist: numpy>=1.26; extra == 'hlod'
|
|
12
|
+
Requires-Dist: pyarrow>=14; extra == 'hlod'
|
|
13
|
+
Requires-Dist: zarr>=3.1; (python_version >= '3.11') and extra == 'hlod'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# spacelab
|
|
17
|
+
|
|
18
|
+
Browser-based viewer for spatial transcriptomics data (Xenium, Atera, CellDIVE).
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
uvx spacelab /path/to/data_app_ready # or: pip install spacelab && spacelab
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Run `spacelab` with no argument to serve the current directory. The directory must
|
|
25
|
+
contain a `tissue-set-manifest.json` (the output of the conversion pipeline).
|
|
26
|
+
|
|
27
|
+
Options: `--port 3000`, `--host 127.0.0.1`, `--no-browser`.
|
|
28
|
+
|
|
29
|
+
See the [main repository](https://github.com/aifimmunology/Spacelab) for
|
|
30
|
+
converting data.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# spacelab
|
|
2
|
+
|
|
3
|
+
Browser-based viewer for spatial transcriptomics data (Xenium, Atera, CellDIVE).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
uvx spacelab /path/to/data_app_ready # or: pip install spacelab && spacelab
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Run `spacelab` with no argument to serve the current directory. The directory must
|
|
10
|
+
contain a `tissue-set-manifest.json` (the output of the conversion pipeline).
|
|
11
|
+
|
|
12
|
+
Options: `--port 3000`, `--host 127.0.0.1`, `--no-browser`.
|
|
13
|
+
|
|
14
|
+
See the [main repository](https://github.com/aifimmunology/Spacelab) for
|
|
15
|
+
converting data.
|
spacelab-0.28.0/build.sh
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Build the frontend, bundle it into the Python package, and build the wheel + sdist.
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
here="$(cd "$(dirname "$0")" && pwd)"
|
|
5
|
+
cd "$here/.."
|
|
6
|
+
bun install --frozen-lockfile
|
|
7
|
+
bun run build
|
|
8
|
+
rm -rf "$here/src/spacelab/static"
|
|
9
|
+
# Source maps are ~most of dist/ and only useful for debugging.
|
|
10
|
+
rsync -a --exclude '*.map' dist/ "$here/src/spacelab/static/"
|
|
11
|
+
cp LICENSE "$here/LICENSE"
|
|
12
|
+
cp data/prep_spatialdata.py "$here/src/spacelab/prep_spatialdata.py"
|
|
13
|
+
cd "$here"
|
|
14
|
+
rm -rf dist
|
|
15
|
+
uv build
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "Spacelab"
|
|
3
|
+
version = "0.28.0"
|
|
4
|
+
description = "Browser-based viewer for bioimaging data (spatial transcriptomics, multiplexed immunofluorescence)."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
# The viewer is standard-library only; the frontend ships prebuilt in spacelab/static.
|
|
9
|
+
dependencies = []
|
|
10
|
+
keywords = ["spatial-transcriptomics", "xenium", "visualization", "zarr"]
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
# `spacelab hlod`: build/read/verify the Hilbert LOD transcript store (points.zarr).
|
|
14
|
+
# zarr 3 needs Python 3.11+; the viewer itself stays 3.10-compatible.
|
|
15
|
+
hlod = ["numpy>=1.26", "pyarrow>=14", "zarr>=3.1; python_version >= '3.11'"]
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = ["pytest>=8"]
|
|
19
|
+
|
|
20
|
+
[tool.pytest.ini_options]
|
|
21
|
+
testpaths = ["tests"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Repository = "https://github.com/aifimmunology/Spacelab"
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
spacelab = "spacelab.cli:main"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["hatchling>=1.26"]
|
|
31
|
+
build-backend = "hatchling.build"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/spacelab"]
|
|
35
|
+
# static/ is gitignored build output (copied from the Vite dist/), so it must be
|
|
36
|
+
# listed as an artifact or hatch will skip it.
|
|
37
|
+
artifacts = ["src/spacelab/static/**", "src/spacelab/prep_spatialdata.py"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
artifacts = ["src/spacelab/static/**", "src/spacelab/prep_spatialdata.py"]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import webbrowser
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from spacelab import __version__
|
|
11
|
+
from spacelab.server import STATIC_DIR, make_server
|
|
12
|
+
|
|
13
|
+
PREP_SCRIPT = Path(__file__).parent / "prep_spatialdata.py"
|
|
14
|
+
|
|
15
|
+
MANIFEST = "tissue-set-manifest.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def prep(args: list[str]) -> None:
|
|
19
|
+
if not PREP_SCRIPT.is_file():
|
|
20
|
+
sys.exit(f"error: prep script not bundled at {PREP_SCRIPT} (broken install?)")
|
|
21
|
+
# uv resolves the script's inline dependencies (zarr 3, shapely, ...) on the fly.
|
|
22
|
+
uv = shutil.which("uv")
|
|
23
|
+
cmd = [uv, "run", "--script", str(PREP_SCRIPT), *args] if uv else [sys.executable, str(PREP_SCRIPT), *args]
|
|
24
|
+
try:
|
|
25
|
+
sys.exit(subprocess.call(cmd))
|
|
26
|
+
except KeyboardInterrupt:
|
|
27
|
+
sys.exit(130)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def hlod(args: list[str]) -> None:
|
|
31
|
+
try:
|
|
32
|
+
import numpy, pyarrow, zarr # noqa: F401, E401
|
|
33
|
+
except ImportError:
|
|
34
|
+
# Same trick as prep: let uv supply the hlod extra's dependencies.
|
|
35
|
+
uv = shutil.which("uv")
|
|
36
|
+
if not uv:
|
|
37
|
+
sys.exit("error: `spacelab hlod` needs the hlod extra: pip install 'spacelab[hlod]'")
|
|
38
|
+
env = {**os.environ, "PYTHONPATH": str(Path(__file__).parent.parent)}
|
|
39
|
+
cmd = [uv, "run", "--no-project", "--with", "numpy>=1.26", "--with", "pyarrow>=14",
|
|
40
|
+
"--with", "zarr>=3.1", "python", "-m", "spacelab.hlod", *args]
|
|
41
|
+
try:
|
|
42
|
+
sys.exit(subprocess.call(cmd, env=env))
|
|
43
|
+
except KeyboardInterrupt:
|
|
44
|
+
sys.exit(130)
|
|
45
|
+
from spacelab.hlod.cli import main as hlod_main
|
|
46
|
+
|
|
47
|
+
sys.exit(hlod_main(args))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def main(argv: list[str] | None = None) -> None:
|
|
51
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
52
|
+
# Handled before argparse: the serve command's optional positional would swallow "prep".
|
|
53
|
+
if argv and argv[0] == "prep":
|
|
54
|
+
prep(argv[1:])
|
|
55
|
+
return
|
|
56
|
+
if argv and argv[0] == "hlod":
|
|
57
|
+
hlod(argv[1:])
|
|
58
|
+
return
|
|
59
|
+
parser = argparse.ArgumentParser(
|
|
60
|
+
prog="spacelab",
|
|
61
|
+
description="Serve a converted spatial dataset in the browser. "
|
|
62
|
+
"Run `spacelab prep --help` to add fast-loading sidecars to a SpatialData store, "
|
|
63
|
+
"or `spacelab hlod --help` for the transcript LOD store.",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"data_dir", nargs="?", default=".", help="*_app_ready directory (default: cwd)"
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument("--port", type=int, default=3000)
|
|
69
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
70
|
+
parser.add_argument("--no-browser", action="store_true", help="don't open a browser tab")
|
|
71
|
+
parser.add_argument("--version", action="version", version=f"spacelab {__version__}")
|
|
72
|
+
args = parser.parse_args(argv)
|
|
73
|
+
|
|
74
|
+
data_dir = Path(args.data_dir).expanduser().resolve()
|
|
75
|
+
# Without a manifest the app 404s on it and falls back to its connect-a-bucket page.
|
|
76
|
+
has_manifest = (data_dir / MANIFEST).is_file()
|
|
77
|
+
if not has_manifest:
|
|
78
|
+
print(f"No {MANIFEST} found in {data_dir}, so defaulting to cloud connect for data.")
|
|
79
|
+
if not (STATIC_DIR / "index.html").is_file():
|
|
80
|
+
sys.exit(f"error: frontend not bundled at {STATIC_DIR} (broken install?)")
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
server = make_server(str(data_dir), args.host, args.port)
|
|
84
|
+
except OSError as e:
|
|
85
|
+
sys.exit(f"error: cannot bind {args.host}:{args.port} ({e}); try --port")
|
|
86
|
+
|
|
87
|
+
url = f"http://{'localhost' if args.host in ('127.0.0.1', '0.0.0.0') else args.host}:{args.port}"
|
|
88
|
+
target = f"{data_dir} " if has_manifest else ""
|
|
89
|
+
print(f"🚀 spacelab v{__version__} launching {target}on {url} (Ctrl+C to stop)", flush=True)
|
|
90
|
+
if not args.no_browser:
|
|
91
|
+
threading.Timer(0.5, webbrowser.open, [url]).start()
|
|
92
|
+
try:
|
|
93
|
+
server.serve_forever()
|
|
94
|
+
except KeyboardInterrupt:
|
|
95
|
+
print("\nstopped")
|
|
96
|
+
finally:
|
|
97
|
+
server.server_close()
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Build a Hilbert additive-LOD point store from a transcript table.
|
|
2
|
+
|
|
3
|
+
Rows are sorted by (gene, level, h): a gene is one contiguous slice, levels
|
|
4
|
+
0..d of a gene are one contiguous prefix of it, and within a level every
|
|
5
|
+
quadtree cell at the level's index depth is one contiguous run. Levels are
|
|
6
|
+
disjoint random subsets chosen by hashing a stable per-row key, so levels
|
|
7
|
+
0..d together are a uniform random sample of fraction F[d].
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable, Sequence
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import pyarrow as pa
|
|
19
|
+
import pyarrow.parquet as pq
|
|
20
|
+
import zarr
|
|
21
|
+
from zarr.codecs import ZstdCodec
|
|
22
|
+
|
|
23
|
+
from .cells import block_offsets
|
|
24
|
+
from .hashing import gene_xy_key, splitmix64
|
|
25
|
+
from .hilbert import quantize, xy2d
|
|
26
|
+
|
|
27
|
+
# Written to the root attrs. The browser reader (src/hlod/store.ts) refuses a
|
|
28
|
+
# store with any other format or version, read.py checks the format, and
|
|
29
|
+
# `hlod verify` the version; bump the version on any layout change.
|
|
30
|
+
FORMAT = "hilbert-lod-points"
|
|
31
|
+
FORMAT_VERSION = 1
|
|
32
|
+
# Cap on the deepest level, Dmax. Each level is 4x the previous, so 6 levels past
|
|
33
|
+
# level 0 reach P * 4^6 (~41M rows at the default P); a larger gene puts the rest
|
|
34
|
+
# in its last level. Bounds the per-gene cell table, 4^(d+g) entries per level d.
|
|
35
|
+
MAX_DMAX = 6
|
|
36
|
+
# Array names the builder writes itself; an extra column with one of these names
|
|
37
|
+
# would overwrite it.
|
|
38
|
+
RESERVED = {"coords", "index", "original_index"}
|
|
39
|
+
|
|
40
|
+
DEFAULTS = dict(
|
|
41
|
+
# Level-0 rows per gene: the subsample drawn when the whole tissue is in view.
|
|
42
|
+
# Smaller means sparser zoomed-out views and more levels.
|
|
43
|
+
P=10_000,
|
|
44
|
+
# Hilbert grid resolution: 2^K x 2^K over the square extent. 16 keeps h in uint32.
|
|
45
|
+
K=16,
|
|
46
|
+
# Extra quadtree depth for the cell index: level d is indexed at depth d + g,
|
|
47
|
+
# so level 0 already splits into 4^g cells for viewport queries.
|
|
48
|
+
g=1,
|
|
49
|
+
# Seed for the per-row hash that assigns rows to levels; fixed so rebuilds of
|
|
50
|
+
# the same input give the same store.
|
|
51
|
+
seed=42,
|
|
52
|
+
# Rows per zarr chunk, the unit of compression and of a byte-range read.
|
|
53
|
+
chunk=16_384,
|
|
54
|
+
# Rows per shard file (a multiple of chunk); fewer, larger files to serve.
|
|
55
|
+
shard=4_194_304,
|
|
56
|
+
# Where each shard file keeps its chunk index ("start" or "end", zarr's
|
|
57
|
+
# default); recorded in the array metadata, and the reader handles both.
|
|
58
|
+
index_location="end",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BuildError(Exception):
|
|
63
|
+
"""Bad input or options; the message is meant for the user."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def choose_dmax(gene_counts: np.ndarray, P: int) -> int:
|
|
67
|
+
"""Deepest level any gene needs for its last level to hold all its rows,
|
|
68
|
+
in 1..MAX_DMAX."""
|
|
69
|
+
deepest = 0
|
|
70
|
+
for n in gene_counts:
|
|
71
|
+
if n > P:
|
|
72
|
+
deepest = max(deepest, math.ceil(math.log(n / P, 4)))
|
|
73
|
+
return max(1, min(MAX_DMAX, deepest))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def level_thresholds(n_gene: int, P: int, dmax: int) -> list[int]:
|
|
77
|
+
"""T[d] for d < dmax as uint64 cut points: a row is in level d when
|
|
78
|
+
T[d-1] <= r < T[d]. Integer comparison avoids float rounding at the edges."""
|
|
79
|
+
out = []
|
|
80
|
+
for d in range(dmax):
|
|
81
|
+
frac = min(1.0, P * 4 ** d / n_gene) if n_gene else 1.0
|
|
82
|
+
out.append(min(math.floor(frac * 2 ** 64), 2 ** 64 - 1))
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def assign_levels(r: np.ndarray, n_gene: int, P: int, dmax: int) -> np.ndarray:
|
|
87
|
+
"""Level (uint8) of each row of one gene, from its uint64 hash `r`."""
|
|
88
|
+
t = level_thresholds(n_gene, P, dmax)
|
|
89
|
+
level = np.searchsorted(np.array(t, dtype=np.uint64), r, side="right").astype(np.uint8)
|
|
90
|
+
# A level whose cumulative fraction is 1 takes every remaining row, including
|
|
91
|
+
# the one hash value (2^64 - 1) the clamped threshold would push past it.
|
|
92
|
+
full = next((d for d in range(dmax) if n_gene and P * 4 ** d >= n_gene), None)
|
|
93
|
+
if full is not None:
|
|
94
|
+
np.minimum(level, full, out=level)
|
|
95
|
+
return level
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _read_table(source, cols: list[str]) -> pa.Table:
|
|
99
|
+
"""`cols` of a pyarrow Table or parquet file/directory; BuildError if any is missing."""
|
|
100
|
+
if isinstance(source, pa.Table):
|
|
101
|
+
missing = [c for c in cols if c not in source.column_names]
|
|
102
|
+
if missing:
|
|
103
|
+
raise BuildError(f"input has no column(s) {missing}")
|
|
104
|
+
return source.select(cols)
|
|
105
|
+
schema_names = set(pq.read_schema(_first_parquet(source)).names)
|
|
106
|
+
missing = [c for c in cols if c not in schema_names]
|
|
107
|
+
if missing:
|
|
108
|
+
raise BuildError(f"{source} has no column(s) {missing} (has: {sorted(schema_names)})")
|
|
109
|
+
return pq.read_table(source, columns=cols)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _first_parquet(path: str) -> str:
|
|
113
|
+
"""`path` itself if a file, else its first .parquet part (for reading the schema)."""
|
|
114
|
+
if os.path.isfile(path):
|
|
115
|
+
return path
|
|
116
|
+
parts = sorted(f for f in os.listdir(path) if f.endswith(".parquet"))
|
|
117
|
+
if not parts:
|
|
118
|
+
raise BuildError(f"{path} has no parquet files")
|
|
119
|
+
return os.path.join(path, parts[0])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _dictionary(column: pa.ChunkedArray) -> tuple[np.ndarray, list[str]]:
|
|
123
|
+
"""(codes, names) with names sorted, so a code is the position in `names`.
|
|
124
|
+
Each parquet part carries its own dictionary, so they are unified first.
|
|
125
|
+
A null becomes the empty string."""
|
|
126
|
+
column = column.cast(pa.string()) if not pa.types.is_dictionary(column.type) else column
|
|
127
|
+
if not pa.types.is_dictionary(column.type):
|
|
128
|
+
column = column.dictionary_encode(null_encoding="encode")
|
|
129
|
+
arr = column.unify_dictionaries().combine_chunks()
|
|
130
|
+
if arr.null_count:
|
|
131
|
+
arr = pa.DictionaryArray.from_arrays(
|
|
132
|
+
arr.indices.fill_null(len(arr.dictionary)),
|
|
133
|
+
pa.concat_arrays([arr.dictionary.cast(pa.string()), pa.array([""])]))
|
|
134
|
+
raw = ["" if v is None else str(v) for v in arr.dictionary.to_pylist()]
|
|
135
|
+
order = sorted(range(len(raw)), key=lambda i: raw[i])
|
|
136
|
+
names, remap = [], np.empty(len(raw), dtype=np.int64)
|
|
137
|
+
for i in order: # merge duplicate names (a unified dictionary can repeat after casting)
|
|
138
|
+
if not names or names[-1] != raw[i]:
|
|
139
|
+
names.append(raw[i])
|
|
140
|
+
remap[i] = len(names) - 1
|
|
141
|
+
codes = remap[arr.indices.to_numpy(zero_copy_only=False).astype(np.int64)]
|
|
142
|
+
return codes, names
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _numeric(column: pa.ChunkedArray, dtype=None) -> np.ndarray:
|
|
146
|
+
"""Column as a numpy array, float nulls as NaN, cast to `dtype` if given."""
|
|
147
|
+
arr = column.combine_chunks()
|
|
148
|
+
if arr.null_count and pa.types.is_floating(arr.type):
|
|
149
|
+
arr = arr.fill_null(float("nan"))
|
|
150
|
+
out = arr.to_numpy(zero_copy_only=False)
|
|
151
|
+
return out.astype(dtype) if dtype is not None else out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _is_categorical(t: pa.DataType) -> bool:
|
|
155
|
+
"""String-like columns, stored as codes plus a `<column>_categories` array."""
|
|
156
|
+
return pa.types.is_string(t) or pa.types.is_large_string(t) or pa.types.is_dictionary(t)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def build(source, output: str, *, gene_col: str, x_col: str, y_col: str,
|
|
160
|
+
id_col: str | None = None, columns: Sequence[str] = (),
|
|
161
|
+
P: int = DEFAULTS["P"], K: int = DEFAULTS["K"], g: int = DEFAULTS["g"],
|
|
162
|
+
seed: int = DEFAULTS["seed"], chunk: int = DEFAULTS["chunk"],
|
|
163
|
+
shard: int = DEFAULTS["shard"], index_location: str = DEFAULTS["index_location"],
|
|
164
|
+
original_index: bool = True, tmpdir: str | None = None,
|
|
165
|
+
extra_attrs: dict | None = None, log: Callable[[str], None] = print) -> dict:
|
|
166
|
+
"""Write the store to `output` (replaced atomically via `output.tmp`).
|
|
167
|
+
`source` is a parquet file, a directory of parquet parts, or a pyarrow Table.
|
|
168
|
+
Returns a summary dict (also logged)."""
|
|
169
|
+
if shard % chunk:
|
|
170
|
+
raise BuildError(f"shard ({shard}) must be a multiple of chunk ({chunk})")
|
|
171
|
+
if index_location not in ("start", "end"):
|
|
172
|
+
raise BuildError("index_location must be 'start' or 'end'")
|
|
173
|
+
if not 1 <= K <= 16:
|
|
174
|
+
raise BuildError("K must be in 1..16 (h is stored in uint32)")
|
|
175
|
+
columns = list(columns)
|
|
176
|
+
bad = RESERVED.intersection(columns)
|
|
177
|
+
if bad:
|
|
178
|
+
raise BuildError(f"column name(s) {sorted(bad)} are reserved")
|
|
179
|
+
t0 = time.time()
|
|
180
|
+
|
|
181
|
+
read_cols = list(dict.fromkeys([gene_col, x_col, y_col, *([id_col] if id_col else []),
|
|
182
|
+
*columns]))
|
|
183
|
+
tbl = _read_table(source, read_cols)
|
|
184
|
+
n = tbl.num_rows
|
|
185
|
+
gene_codes, genes = _dictionary(tbl.column(gene_col))
|
|
186
|
+
# Quantize the float32 values actually stored, so verify (which only sees the
|
|
187
|
+
# stored coords) recomputes exactly the h the build sorted by.
|
|
188
|
+
x = _numeric(tbl.column(x_col), np.float32)
|
|
189
|
+
y = _numeric(tbl.column(y_col), np.float32)
|
|
190
|
+
if n and not (np.isfinite(x).all() and np.isfinite(y).all()):
|
|
191
|
+
raise BuildError("x/y contain NaN or infinite values")
|
|
192
|
+
ids = _numeric(tbl.column(id_col)).astype(np.uint64) if id_col else None
|
|
193
|
+
|
|
194
|
+
extras: dict[str, np.ndarray] = {}
|
|
195
|
+
categories: dict[str, list[str]] = {}
|
|
196
|
+
for c in columns:
|
|
197
|
+
col = tbl.column(c)
|
|
198
|
+
if _is_categorical(col.type):
|
|
199
|
+
codes, names = _dictionary(col)
|
|
200
|
+
extras[c] = codes.astype(np.uint32)
|
|
201
|
+
categories[c] = names
|
|
202
|
+
else:
|
|
203
|
+
extras[c] = _numeric(col)
|
|
204
|
+
del tbl
|
|
205
|
+
|
|
206
|
+
x0 = float(x.min()) if n else 0.0
|
|
207
|
+
y0 = float(y.min()) if n else 0.0
|
|
208
|
+
span = max(float(x.max()) - x0, float(y.max()) - y0) if n else 0.0
|
|
209
|
+
size = span * (1 + 1e-9) if span > 0 else 1.0
|
|
210
|
+
|
|
211
|
+
G = len(genes)
|
|
212
|
+
counts = np.bincount(gene_codes, minlength=G).astype(np.int64)
|
|
213
|
+
dmax = choose_dmax(counts, P)
|
|
214
|
+
if dmax + g > K:
|
|
215
|
+
raise BuildError(f"index depth {dmax + g} exceeds K={K}")
|
|
216
|
+
B = block_offsets(g, dmax)
|
|
217
|
+
S = int(B[-1])
|
|
218
|
+
gene_offsets = np.concatenate([[0], np.cumsum(counts)]).astype(np.uint64)
|
|
219
|
+
order_g = np.argsort(gene_codes, kind="stable")
|
|
220
|
+
del gene_codes
|
|
221
|
+
|
|
222
|
+
tmp_root = tempfile.mkdtemp(prefix="hlod-", dir=tmpdir)
|
|
223
|
+
try:
|
|
224
|
+
def mm(name, dtype, shape):
|
|
225
|
+
return np.lib.format.open_memmap(os.path.join(tmp_root, f"{name}.npy"), mode="w+",
|
|
226
|
+
dtype=dtype, shape=shape)
|
|
227
|
+
|
|
228
|
+
out_coords = mm("coords", np.float32, (n, 2))
|
|
229
|
+
out_orig = mm("original_index", np.uint64, (n,)) if original_index else None
|
|
230
|
+
out_extra = {c: mm(f"col_{i}", a.dtype, (n,)) for i, (c, a) in enumerate(extras.items())}
|
|
231
|
+
level_offsets = np.zeros((G, dmax + 2), dtype=np.uint64)
|
|
232
|
+
cell_index = np.zeros((G, S), dtype=np.uint32)
|
|
233
|
+
level_rows = np.zeros((G, dmax + 1), dtype=np.int64)
|
|
234
|
+
|
|
235
|
+
for gi in range(G):
|
|
236
|
+
a, b = int(gene_offsets[gi]), int(gene_offsets[gi + 1])
|
|
237
|
+
idx = order_g[a:b]
|
|
238
|
+
gx, gy = x[idx], y[idx]
|
|
239
|
+
h = xy2d(K, quantize(gx, x0, size, K), quantize(gy, y0, size, K))
|
|
240
|
+
key = ids[idx] if ids is not None else gene_xy_key(gi, gx, gy)
|
|
241
|
+
r = splitmix64(key ^ np.uint64(seed))
|
|
242
|
+
level = assign_levels(r, b - a, P, dmax)
|
|
243
|
+
order = np.lexsort((h, level))
|
|
244
|
+
h, level, idx = h[order], level[order], idx[order]
|
|
245
|
+
|
|
246
|
+
out_coords[a:b, 0] = gx[order]
|
|
247
|
+
out_coords[a:b, 1] = gy[order]
|
|
248
|
+
if out_orig is not None:
|
|
249
|
+
out_orig[a:b] = idx
|
|
250
|
+
for c, arr in extras.items():
|
|
251
|
+
out_extra[c][a:b] = arr[idx]
|
|
252
|
+
|
|
253
|
+
per_level = np.bincount(level, minlength=dmax + 1)
|
|
254
|
+
level_rows[gi] = per_level
|
|
255
|
+
rel = np.concatenate([[0], np.cumsum(per_level)])
|
|
256
|
+
level_offsets[gi] = a + rel
|
|
257
|
+
for d in range(dmax + 1):
|
|
258
|
+
m = d + g
|
|
259
|
+
cells = (h[rel[d]:rel[d + 1]] >> np.uint32(2 * (K - m))).astype(np.int64)
|
|
260
|
+
hist = np.bincount(cells, minlength=4 ** m)
|
|
261
|
+
cell_index[gi, B[d]:B[d + 1]] = rel[d] + np.concatenate([[0], np.cumsum(hist)])
|
|
262
|
+
|
|
263
|
+
attrs = {
|
|
264
|
+
"format": FORMAT,
|
|
265
|
+
"format_version": FORMAT_VERSION,
|
|
266
|
+
"K": K, "g": g, "P": P, "Dmax": dmax,
|
|
267
|
+
"extent": {"x0": x0, "y0": y0, "size": size},
|
|
268
|
+
"hash": {"algo": "splitmix64", "seed": seed,
|
|
269
|
+
"key": "transcript_id" if id_col else "gene_xy"},
|
|
270
|
+
"genes": genes,
|
|
271
|
+
"columns": columns,
|
|
272
|
+
"categories": {c: f"{c}_categories" for c in categories},
|
|
273
|
+
"coordinate_units": "micron",
|
|
274
|
+
"chunk": chunk, "shard": shard, "index_location": index_location,
|
|
275
|
+
"source": {"path": source if isinstance(source, str) else None, "rows": n},
|
|
276
|
+
**(extra_attrs or {}),
|
|
277
|
+
}
|
|
278
|
+
_write_store(output, attrs, tmp_root, out_coords, out_orig, out_extra, categories,
|
|
279
|
+
gene_offsets, level_offsets, cell_index, chunk, shard, index_location)
|
|
280
|
+
finally:
|
|
281
|
+
shutil.rmtree(tmp_root, ignore_errors=True)
|
|
282
|
+
|
|
283
|
+
index_bytes = S * 4 + (dmax + 2) * 8 + 8
|
|
284
|
+
largest = int(np.diff(cell_index.astype(np.int64), axis=1).max()) if G and S > 1 else 0
|
|
285
|
+
summary = {"path": output, "rows": n, "genes": G, "Dmax": dmax, "S": S,
|
|
286
|
+
"index_bytes_per_gene": index_bytes, "largest_cell_rows": largest,
|
|
287
|
+
"rows_per_level": level_rows.sum(axis=0).tolist(),
|
|
288
|
+
"seconds": round(time.time() - t0, 1)}
|
|
289
|
+
log(f"hlod: {n:,} rows, {G} genes, Dmax={dmax}, rows/level={summary['rows_per_level']}, "
|
|
290
|
+
f"index {index_bytes:,} B/gene, largest cell {largest:,} rows, {summary['seconds']}s")
|
|
291
|
+
return summary
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _sharded(group, name, data: np.ndarray, chunk_rows: int, shard_rows: int,
|
|
295
|
+
index_location: str, compress: bool):
|
|
296
|
+
"""Write `data` as a sharded zarr array, chunked along rows. Chunk and shard
|
|
297
|
+
sizes are clamped to the array."""
|
|
298
|
+
rows = data.shape[0]
|
|
299
|
+
chunk_rows = max(1, min(chunk_rows, rows))
|
|
300
|
+
# Whole multiple of the chunk; a shard larger than the array just holds every chunk.
|
|
301
|
+
shard_rows = max(chunk_rows, min(shard_rows, -(-rows // chunk_rows) * chunk_rows))
|
|
302
|
+
shard_rows -= shard_rows % chunk_rows
|
|
303
|
+
tail = data.shape[1:]
|
|
304
|
+
arr = group.create_array(
|
|
305
|
+
name, shape=data.shape, dtype=data.dtype,
|
|
306
|
+
chunks=(chunk_rows, *tail), shards={"shape": (shard_rows, *tail),
|
|
307
|
+
"index_location": index_location},
|
|
308
|
+
compressors=ZstdCodec(level=3) if compress else None,
|
|
309
|
+
# Keep every chunk present so uncompressed byte offsets stay contiguous.
|
|
310
|
+
config={"write_empty_chunks": True},
|
|
311
|
+
)
|
|
312
|
+
# Whole shards at a time: a partial shard write is a read-modify-write.
|
|
313
|
+
for s in range(0, rows, shard_rows):
|
|
314
|
+
arr[s:s + shard_rows] = np.asarray(data[s:s + shard_rows])
|
|
315
|
+
return arr
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _write_store(output, attrs, tmp_root, coords, orig, extra, categories, gene_offsets,
|
|
319
|
+
level_offsets, cell_index, chunk, shard, index_location):
|
|
320
|
+
"""Write every array and the attrs into `output`.tmp, consolidate the metadata,
|
|
321
|
+
then move it to `output`."""
|
|
322
|
+
staging = output.rstrip("/") + ".tmp"
|
|
323
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
324
|
+
root = zarr.open_group(staging, mode="w", zarr_format=3)
|
|
325
|
+
_sharded(root, "coords", coords, chunk, shard, index_location, compress=False)
|
|
326
|
+
if orig is not None:
|
|
327
|
+
_sharded(root, "original_index", orig, chunk, shard, index_location, compress=True)
|
|
328
|
+
for c, arr in extra.items():
|
|
329
|
+
_sharded(root, c, arr, chunk, shard, index_location, compress=True)
|
|
330
|
+
if c in categories:
|
|
331
|
+
names = np.array(categories[c], dtype=object)
|
|
332
|
+
cat = root.create_array(f"{c}_categories", shape=names.shape, dtype=str,
|
|
333
|
+
chunks=(max(1, min(names.size, 1 << 20)),),
|
|
334
|
+
compressors=ZstdCodec(level=3))
|
|
335
|
+
if names.size:
|
|
336
|
+
cat[:] = names
|
|
337
|
+
index = root.create_group("index")
|
|
338
|
+
G, S = cell_index.shape
|
|
339
|
+
# Uncompressed so the client reads them with the same byte-range reader as coords.
|
|
340
|
+
_sharded(index, "gene_offsets", gene_offsets, gene_offsets.size, gene_offsets.size,
|
|
341
|
+
index_location, compress=False)
|
|
342
|
+
_sharded(index, "level_offsets", level_offsets, max(1, G), max(1, G), index_location,
|
|
343
|
+
compress=False)
|
|
344
|
+
_sharded(index, "cell_index", cell_index, 1, max(1, G), index_location, compress=False)
|
|
345
|
+
root.attrs.update(attrs)
|
|
346
|
+
zarr.consolidate_metadata(staging)
|
|
347
|
+
if os.path.exists(output):
|
|
348
|
+
shutil.rmtree(output)
|
|
349
|
+
os.replace(staging, output)
|