spatialscape 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. spatialscape/__init__.py +13 -0
  2. spatialscape/_app/assets/array-utils-flat-BL8Cekj1.js +13 -0
  3. spatialscape/_app/assets/blosc-DEOJs6D5.js +18 -0
  4. spatialscape/_app/assets/blosc-Df7TknHH.js +18 -0
  5. spatialscape/_app/assets/chunk-INHXZS53-DAOLzMPX.js +2 -0
  6. spatialscape/_app/assets/chunk-INHXZS53-DfBSTIxr.js +2 -0
  7. spatialscape/_app/assets/deflate-B2AbjNo_.js +2 -0
  8. spatialscape/_app/assets/expression-E359vKZW.js +368 -0
  9. spatialscape/_app/assets/get-attribute-from-layouts-Dd2gixpo.js +2 -0
  10. spatialscape/_app/assets/index-BKPnXJSt.css +1 -0
  11. spatialscape/_app/assets/index-Gxpj1e1n.js +8454 -0
  12. spatialscape/_app/assets/jpeg-b0MwBR_9.js +2 -0
  13. spatialscape/_app/assets/lerc-ujbbHc0C.js +2 -0
  14. spatialscape/_app/assets/lz4-BFJkNc3o.js +16 -0
  15. spatialscape/_app/assets/lz4-DNE3IovK.js +16 -0
  16. spatialscape/_app/assets/lzw-DfRkoFB0.js +2 -0
  17. spatialscape/_app/assets/packbits-BDCGuk_Z.js +2 -0
  18. spatialscape/_app/assets/pako.esm-Ch1S5qKo.js +2 -0
  19. spatialscape/_app/assets/raw-vNZ-QFXZ.js +2 -0
  20. spatialscape/_app/assets/webgl-D2Fkccwd.js +345 -0
  21. spatialscape/_app/assets/webimage-DDU0IC05.js +2 -0
  22. spatialscape/_app/assets/worker-CUy87eWB.js +8 -0
  23. spatialscape/_app/assets/zstd-CGD_7wnU.js +16 -0
  24. spatialscape/_app/assets/zstd-qXhZ_1DN.js +16 -0
  25. spatialscape/_app/favicon.svg +161 -0
  26. spatialscape/_app/index.html +23 -0
  27. spatialscape/build.py +630 -0
  28. spatialscape/cli.py +399 -0
  29. spatialscape/config.py +570 -0
  30. spatialscape/convert.py +321 -0
  31. spatialscape/discover.py +98 -0
  32. spatialscape/expression.py +300 -0
  33. spatialscape/fingerprint.py +152 -0
  34. spatialscape/genes.py +251 -0
  35. spatialscape/geometry.py +132 -0
  36. spatialscape/images.py +240 -0
  37. spatialscape/init.py +578 -0
  38. spatialscape/manifest.py +162 -0
  39. spatialscape/obs.py +48 -0
  40. spatialscape/outlines.py +145 -0
  41. spatialscape/polygons.py +85 -0
  42. spatialscape/rds.py +609 -0
  43. spatialscape/rdsread.py +556 -0
  44. spatialscape/readers.py +195 -0
  45. spatialscape/registry.py +83 -0
  46. spatialscape/serve.py +262 -0
  47. spatialscape/session.py +474 -0
  48. spatialscape/site.py +274 -0
  49. spatialscape/synthetic.py +142 -0
  50. spatialscape/thumbnail.py +95 -0
  51. spatialscape/tracks.py +551 -0
  52. spatialscape/validate.py +193 -0
  53. spatialscape/vocab.py +188 -0
  54. spatialscape-0.3.0.dist-info/METADATA +68 -0
  55. spatialscape-0.3.0.dist-info/RECORD +58 -0
  56. spatialscape-0.3.0.dist-info/WHEEL +4 -0
  57. spatialscape-0.3.0.dist-info/entry_points.txt +3 -0
  58. spatialscape-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,162 @@
1
+ """Output schema: manifest.json written into every bundle.
2
+
3
+ Field names are camelCase on purpose so the JSON matches the TypeScript types in
4
+ app/src/data/manifest.ts one-to-one.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Annotated, Literal
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+ FORMAT_VERSION = 1
13
+
14
+ Platform = Literal["visium", "visium_hd", "xenium", "merfish", "snrnaseq", "other"]
15
+ SampleKind = Literal["spatial", "embedding"]
16
+ LayoutMode = Literal["grid", "strip"]
17
+ BUILTIN_COLORMAPS = ["viridis", "magma", "inferno", "plasma", "cividis", "turbo", "greys", "rdbu"]
18
+
19
+
20
+ class _Model(BaseModel):
21
+ model_config = ConfigDict(extra="forbid")
22
+
23
+
24
+ class Vocabulary(_Model):
25
+ categories: list[str]
26
+ colors: list[str]
27
+ aliases: dict[str, str] = Field(default_factory=dict)
28
+
29
+
30
+ class CategoricalField(_Model):
31
+ id: str
32
+ name: str
33
+ type: Literal["categorical"] = "categorical"
34
+ vocabulary: str
35
+ description: str | None = None
36
+
37
+
38
+ class ContinuousField(_Model):
39
+ id: str
40
+ name: str
41
+ type: Literal["continuous"] = "continuous"
42
+ range: tuple[float, float] | None = None
43
+ colormap: str | None = None
44
+ description: str | None = None
45
+
46
+
47
+ ObsField = Annotated[CategoricalField | ContinuousField, Field(discriminator="type")]
48
+
49
+
50
+ class ColorSpec(_Model):
51
+ kind: Literal["gene", "field"]
52
+ gene: str | None = None
53
+ field: str | None = None
54
+
55
+
56
+ class Layout(_Model):
57
+ mode: LayoutMode = "grid"
58
+ gutterFraction: float = 0.1
59
+ order: list[str] = Field(default_factory=list)
60
+ columns: int | None = None # grid: a fixed number of columns
61
+
62
+
63
+ class ImageChannel(_Model):
64
+ name: str
65
+ color: str
66
+ window: tuple[float, float]
67
+
68
+
69
+ class SampleImage(_Model):
70
+ id: str
71
+ name: str
72
+ path: str
73
+ kind: Literal["rgb", "multichannel"] = "rgb"
74
+ pixelSize: float
75
+ translate: tuple[float, float] = (0.0, 0.0)
76
+ size: tuple[int, int] # level-0 (height, width) in pixels
77
+ channels: list[ImageChannel] | None = None
78
+ defaultOpacity: float = 1.0
79
+
80
+
81
+ class ExprSpec(_Model):
82
+ kind: Literal["u8", "csc"] = "u8"
83
+ sharded: bool = True
84
+ shardGenes: int = 512
85
+ normalized: Literal["lognorm", "log1p_cp10k"] | None = None # as stored by the source, or log1p(CP10k) applied by the build
86
+
87
+
88
+ class PolygonInfo(_Model):
89
+ path: str = "polygons" # polygons.i16 + polygons.offsets.u32
90
+ cells: int = 0 # cells that have a polygon
91
+ vertices: int = 0
92
+ scale: float = 0.1 # µm per int16 unit (deltas from the cell centroid)
93
+
94
+
95
+ class TrackSet(_Model):
96
+ field: str
97
+ categories: dict[str, str] # category label -> bigWig path relative to the sample folder
98
+
99
+
100
+ class TracksInfo(_Model):
101
+ """Genome tracks of an ATAC sample (see tracks.py): bigWigs of insertions per million per category."""
102
+
103
+ genome: str
104
+ bin: int
105
+ all: str | None = None # bigWig of every cell: built from fragments, or given with bigwigs
106
+ units: str = "insertions per million" # what the tracks measure, for the panel's scale
107
+ by: list[TrackSet] = Field(default_factory=list)
108
+ peaks: str | None = None # BED of the sample's peak features
109
+ genes: str | None = None # folder of gene models: index.json (names per chromosome) + <chrom>.json (see genes.py)
110
+ links: str | None = None # BEDPE of peak-gene links
111
+ chromSizes: str = "tracks/chrom.sizes"
112
+
113
+
114
+ class Sample(_Model):
115
+ id: str
116
+ name: str
117
+ platform: Platform
118
+ kind: SampleKind = "spatial"
119
+ group: str | None = None
120
+ nObs: int
121
+ nGenes: int
122
+ bbox: tuple[float, float, float, float]
123
+ toDataset: list[float] = Field(default_factory=lambda: [1, 0, 0, 0, 1, 0, 0, 0, 1])
124
+ pointRadius: float
125
+ expr: ExprSpec = Field(default_factory=ExprSpec)
126
+ hasF16: bool = False
127
+ fields: list[str] = Field(default_factory=list)
128
+ images: list[SampleImage] = Field(default_factory=list)
129
+ outlines: list[str] = Field(default_factory=list) # categorical field ids with outlines/<field>.json
130
+ polygons: PolygonInfo | None = None
131
+ idBlock: int = 65536
132
+ suggestedGene: str | None = None # most variable gene in this sample; the dataset's default when none is configured
133
+ tracks: TracksInfo | None = None # genome tracks (ATAC samples)
134
+
135
+
136
+ class FeatureGroup(_Model):
137
+ id: str
138
+ name: str
139
+ units: str | None = None
140
+ count: int = 0
141
+
142
+
143
+ class Manifest(_Model):
144
+ formatVersion: Literal[1] = FORMAT_VERSION
145
+ id: str
146
+ name: str
147
+ description: str | None = None
148
+ defaultGene: str | None = None
149
+ defaultColor: ColorSpec
150
+ layout: Layout = Field(default_factory=Layout)
151
+ colormaps: list[str] = Field(default_factory=lambda: list(BUILTIN_COLORMAPS))
152
+ vocabularies: dict[str, Vocabulary] = Field(default_factory=dict)
153
+ fields: list[ObsField] = Field(default_factory=list)
154
+ featureGroups: list[FeatureGroup] = Field(default_factory=list)
155
+ samples: list[Sample] = Field(default_factory=list)
156
+ thumbnailSample: str | None = None # sample drawn on the dataset thumbnail (default: first spatial sample in layout order)
157
+
158
+ def field_by_id(self, fid: str) -> CategoricalField | ContinuousField | None:
159
+ for f in self.fields:
160
+ if f.id == fid:
161
+ return f
162
+ return None
spatialscape/obs.py ADDED
@@ -0,0 +1,48 @@
1
+ """Per-observation arrays: positions, shuffle order, ids, categorical codes, continuous values."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import zarr
9
+ from zarr.codecs import ZstdCodec
10
+
11
+ MAX_CHUNK = 1 << 20
12
+
13
+
14
+ def _chunk(n: int) -> int:
15
+ return max(1, min(n, MAX_CHUNK))
16
+
17
+
18
+ def write_obs(
19
+ out_dir: Path,
20
+ *,
21
+ xy: np.ndarray,
22
+ order: np.ndarray,
23
+ ids: list[str],
24
+ cats: dict[str, np.ndarray],
25
+ nums: dict[str, np.ndarray],
26
+ id_block: int = 65536,
27
+ ) -> None:
28
+ n = xy.shape[0]
29
+ store = zarr.storage.LocalStore(str(out_dir / "obs.zarr"))
30
+ root = zarr.open_group(store, mode="w", zarr_format=3)
31
+ z = ZstdCodec(level=5)
32
+ a = root.create_array("xy", shape=(n, 2), chunks=(_chunk(n), 2), dtype="float32", compressors=[z])
33
+ a[:] = xy.astype(np.float32)
34
+ o = root.create_array("order", shape=(n,), chunks=(_chunk(n),), dtype="uint32", compressors=[z])
35
+ o[:] = order.astype(np.uint32)
36
+ cg = root.create_group("cat")
37
+ for fid, codes in cats.items():
38
+ arr = cg.create_array(fid, shape=(n,), chunks=(_chunk(n),), dtype="uint16", compressors=[z])
39
+ arr[:] = codes.astype(np.uint16)
40
+ ng = root.create_group("num")
41
+ for fid, vals in nums.items():
42
+ arr = ng.create_array(fid, shape=(n,), chunks=(_chunk(n),), dtype="float32", compressors=[z])
43
+ arr[:] = vals.astype(np.float32)
44
+ ids_dir = out_dir / "ids"
45
+ ids_dir.mkdir(parents=True, exist_ok=True)
46
+ for b, start in enumerate(range(0, n, id_block)):
47
+ with open(ids_dir / f"{b}.json", "w") as fh:
48
+ json.dump(ids[start : start + id_block], fh, separators=(",", ":"))
@@ -0,0 +1,145 @@
1
+ """Boundary polylines for categorical fields (e.g. spatial domains), computed from a bundle's obs arrays.
2
+
3
+ Approach: rasterize labels onto a grid (nearest point within ~0.75 x spacing), majority-smooth,
4
+ trace each category with marching squares, simplify (RDP), and store as µm polylines.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ from scipy import ndimage
13
+ from scipy.spatial import cKDTree
14
+
15
+ MAX_GRID_CELLS = 2_000_000
16
+ BACKGROUND = -1
17
+
18
+
19
+ def _median_nn(xy: np.ndarray, sample: int = 3000, seed: int = 0) -> float:
20
+ rng = np.random.default_rng(seed)
21
+ idx = rng.choice(len(xy), size=min(sample, len(xy)), replace=False)
22
+ d, _ = cKDTree(xy).query(xy[idx], k=2)
23
+ return float(np.median(d[:, 1]))
24
+
25
+
26
+ def _rdp(points: np.ndarray, tol: float) -> np.ndarray:
27
+ """Iterative Ramer-Douglas-Peucker for an (n, 2) polyline."""
28
+ n = len(points)
29
+ if n < 3:
30
+ return points
31
+ keep = np.zeros(n, dtype=bool)
32
+ keep[0] = keep[-1] = True
33
+ stack = [(0, n - 1)]
34
+ while stack:
35
+ a, b = stack.pop()
36
+ if b <= a + 1:
37
+ continue
38
+ seg = points[b] - points[a]
39
+ L = np.hypot(*seg)
40
+ pts = points[a + 1 : b]
41
+ if L == 0:
42
+ d = np.hypot(*(pts - points[a]).T)
43
+ else:
44
+ d = np.abs(seg[0] * (points[a][1] - pts[:, 1]) - (points[a][0] - pts[:, 0]) * seg[1]) / L
45
+ i = int(np.argmax(d))
46
+ if d[i] > tol:
47
+ k = a + 1 + i
48
+ keep[k] = True
49
+ stack.append((a, k))
50
+ stack.append((k, b))
51
+ return points[keep]
52
+
53
+
54
+ def compute_outlines(
55
+ xy: np.ndarray,
56
+ codes: np.ndarray,
57
+ n_categories: int,
58
+ *,
59
+ skip_codes: set[int] | None = None,
60
+ bin_um: float | None = None,
61
+ min_cells: int = 25,
62
+ smooth_um: float = 150.0,
63
+ min_feature_um: float = 300.0,
64
+ ) -> dict:
65
+ """Return {"bin": bin_um, "paths": [{"c": code, "p": [[x, y], ...]}, ...]} in the xy units (µm)."""
66
+ from skimage.measure import find_contours
67
+
68
+ skip_codes = skip_codes or set()
69
+ xy = np.asarray(xy, dtype=np.float64)
70
+ codes = np.asarray(codes).astype(np.int64)
71
+ nn = _median_nn(xy)
72
+ # unlabeled cells (e.g. "NA") are treated as unknown: dropped so neighbours fill their space
73
+ if skip_codes:
74
+ keep = ~np.isin(codes, list(skip_codes))
75
+ if keep.sum() >= min_cells:
76
+ xy, codes = xy[keep], codes[keep]
77
+ b = float(bin_um) if bin_um else max(0.5, 0.5 * nn)
78
+ x0, y0 = xy.min(axis=0)
79
+ x1, y1 = xy.max(axis=0)
80
+ while ((x1 - x0) / b + 3) * ((y1 - y0) / b + 3) > MAX_GRID_CELLS:
81
+ b *= 1.5
82
+ w = int(np.ceil((x1 - x0) / b)) + 3
83
+ h = int(np.ceil((y1 - y0) / b)) + 3
84
+ gx = x0 - b + (np.arange(w) + 0.5) * b
85
+ gy = y0 - b + (np.arange(h) + 0.5) * b
86
+ GX, GY = np.meshgrid(gx, gy)
87
+ tree = cKDTree(xy)
88
+ # fill radius: 2x the median spacing closes gaps inside tissue but keeps real holes/edges as background
89
+ r = max(2.0 * nn, 0.75 * b * np.sqrt(2))
90
+ d, i = tree.query(np.column_stack([GX.ravel(), GY.ravel()]), k=1, distance_upper_bound=r)
91
+ lab = np.full(GX.size, BACKGROUND, dtype=np.int64)
92
+ ok = np.isfinite(d)
93
+ lab[ok] = codes[i[ok]]
94
+ lab = lab.reshape(h, w)
95
+
96
+ # majority smoothing over a (smooth_um)^2 window (one-hot sums, argmax); background competes too
97
+ counts = np.bincount(codes, minlength=n_categories)
98
+ cats = [c for c in range(n_categories) if counts[c] >= min_cells and c not in skip_codes]
99
+ smooth = int(max(3, round(smooth_um / b)) | 1) # odd window size in bins
100
+ if smooth > 1:
101
+ best = np.zeros((h, w), dtype=np.float32)
102
+ best_lab = np.full((h, w), BACKGROUND, dtype=np.int64)
103
+ for c in [BACKGROUND, *cats]:
104
+ s = ndimage.uniform_filter((lab == c).astype(np.float32), size=smooth, mode="constant")
105
+ upd = s > best
106
+ best[upd] = s[upd]
107
+ best_lab[upd] = c
108
+ lab = best_lab
109
+
110
+ import warnings
111
+
112
+ from skimage.morphology import remove_small_holes, remove_small_objects
113
+
114
+ # drop islands/holes smaller than (min_feature_um)^2 so outlines follow regions, not single cells
115
+ min_px = int(max(16, (min_feature_um / b) ** 2))
116
+ paths = []
117
+ tol = 0.35 * b
118
+ for c in cats:
119
+ m = lab == c
120
+ with warnings.catch_warnings(): # skimage 0.26 renamed these parameters; both spellings still work
121
+ warnings.simplefilter("ignore", FutureWarning)
122
+ m = remove_small_objects(m, min_size=min_px)
123
+ m = remove_small_holes(m, area_threshold=min_px)
124
+ mask = np.pad(m.astype(np.float32), 1)
125
+ for cont in find_contours(mask, 0.5):
126
+ if len(cont) < 6:
127
+ continue
128
+ pts = np.column_stack([x0 - b + (cont[:, 1] - 1 + 0.5) * b, y0 - b + (cont[:, 0] - 1 + 0.5) * b])
129
+ pts = _rdp(pts, tol)
130
+ if len(pts) < 4:
131
+ continue
132
+ per = np.hypot(*np.diff(pts, axis=0).T).sum()
133
+ if per < 10 * b:
134
+ continue
135
+ paths.append({"c": int(c), "p": np.round(pts, 1).tolist()})
136
+ return {"bin": round(b, 3), "paths": paths}
137
+
138
+
139
+ def write_outlines(out_dir: Path, field: str, data: dict) -> Path:
140
+ d = Path(out_dir) / "outlines"
141
+ d.mkdir(parents=True, exist_ok=True)
142
+ p = d / f"{field}.json"
143
+ with open(p, "w") as fh:
144
+ json.dump(data, fh, separators=(",", ":"))
145
+ return p
@@ -0,0 +1,85 @@
1
+ """Per-cell boundary polygons packed as int16 deltas from the cell centroid.
2
+
3
+ Files (sample-local µm frame, bundle row order):
4
+ polygons.offsets.u32 uint32[n_obs + 1] vertex start index per cell (zero-length = no polygon)
5
+ polygons.i16 int16[n_vertices * 2] (dx, dy) * 10 relative to the cell's xy
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ import pyarrow.parquet as pq
14
+
15
+ SCALE = 0.1 # µm per unit
16
+
17
+
18
+ def read_polygon_table(path: Path, id_col: str, x_col: str, y_col: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
19
+ t = pq.read_table(path, columns=[id_col, x_col, y_col])
20
+ ids = np.asarray(t.column(id_col).to_pylist(), dtype=str)
21
+ x = t.column(x_col).to_numpy(zero_copy_only=False).astype(np.float64)
22
+ y = t.column(y_col).to_numpy(zero_copy_only=False).astype(np.float64)
23
+ return ids, x, y
24
+
25
+
26
+ def read_polygon_obsm(adata, key: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
27
+ """Flatten an obsm array of shape (n_obs, n_vertices, 2) into (ids, x, y); NaN-padded vertices are dropped."""
28
+ arr = np.asarray(adata.obsm[key], dtype=np.float64)
29
+ if arr.ndim != 3 or arr.shape[2] != 2:
30
+ raise ValueError(f"obsm/{key}: expected shape (n_obs, n_vertices, 2), got {arr.shape}")
31
+ _n, v, _ = arr.shape
32
+ ids = np.repeat(np.asarray(adata.obs_names, dtype=str), v)
33
+ x, y = arr[:, :, 0].ravel(), arr[:, :, 1].ravel()
34
+ ok = np.isfinite(x) & np.isfinite(y)
35
+ return ids[ok], x[ok], y[ok]
36
+
37
+
38
+ def apply_affine(x: np.ndarray, y: np.ndarray, affine) -> tuple[np.ndarray, np.ndarray]:
39
+ a, b, c, d, e, f = (float(v) for v in affine)
40
+ return a * x + b * y + c, d * x + e * y + f
41
+
42
+
43
+ def pack_polygons(
44
+ obs_ids: list[str],
45
+ xy_um: np.ndarray,
46
+ poly_ids: np.ndarray,
47
+ poly_xy_um: np.ndarray,
48
+ *,
49
+ max_vertices: int = 24,
50
+ ) -> tuple[np.ndarray, np.ndarray, int, float]:
51
+ """Return (offsets uint32[n+1], deltas int16[nv*2], n_cells_with_polygon, median_centroid_offset_um) in bundle
52
+ row order. The last value is the median distance between a polygon's mean vertex and its cell's xy; a large
53
+ value means the polygons are in a different frame than the coordinates."""
54
+ order = np.argsort(poly_ids, kind="stable")
55
+ poly_ids = poly_ids[order]
56
+ poly_xy_um = poly_xy_um[order]
57
+ uniq, start, counts = np.unique(poly_ids, return_index=True, return_counts=True)
58
+ lookup = {u: (int(s), int(c)) for u, s, c in zip(uniq, start, counts, strict=True)}
59
+ n = len(obs_ids)
60
+ offsets = np.zeros(n + 1, dtype=np.uint32)
61
+ chunks: list[np.ndarray] = []
62
+ total = 0
63
+ with_poly = 0
64
+ dev: list[float] = []
65
+ for i, cid in enumerate(obs_ids):
66
+ hit = lookup.get(str(cid))
67
+ if hit:
68
+ s, c = hit
69
+ pts = poly_xy_um[s : s + c]
70
+ dev.append(float(np.hypot(*(pts.mean(axis=0) - xy_um[i]))))
71
+ if c > max_vertices:
72
+ pts = pts[np.linspace(0, c - 1, max_vertices).round().astype(int)]
73
+ d = np.rint((pts - xy_um[i]) / SCALE)
74
+ d = np.clip(d, -32767, 32767).astype(np.int16)
75
+ chunks.append(d.ravel())
76
+ total += len(pts)
77
+ with_poly += 1
78
+ offsets[i + 1] = total
79
+ deltas = np.concatenate(chunks) if chunks else np.zeros(0, dtype=np.int16)
80
+ return offsets, deltas, with_poly, (float(np.median(dev)) if dev else 0.0)
81
+
82
+
83
+ def write_polygons(out_dir: Path, offsets: np.ndarray, deltas: np.ndarray) -> None:
84
+ (Path(out_dir) / "polygons.offsets.u32").write_bytes(offsets.astype("<u4").tobytes())
85
+ (Path(out_dir) / "polygons.i16").write_bytes(deltas.astype("<i2").tobytes())