pyadata-cli 0.5.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.
adata/formats/image.py ADDED
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+ from PIL import Image
8
+ from rich.console import Console
9
+
10
+ from adata.elements.write import write_dense, write_mapping
11
+ from adata.formats.validate import DATAFRAME_PATHS, validate_dimensions
12
+ from adata.formats.common import _resolve
13
+ from adata.storage import is_dataset
14
+ from adata.util.path import norm_path
15
+
16
+
17
+ def export_image(root: Any, obj: str, out: Path, console: Console) -> None:
18
+ h5obj = _resolve(root, obj)
19
+ if not is_dataset(h5obj):
20
+ raise ValueError("Image export requires a dataset.")
21
+ arr = np.asarray(h5obj[...])
22
+
23
+ if arr.ndim not in (2, 3):
24
+ raise ValueError(f"Expected 2D or 3D image array; got shape {arr.shape}.")
25
+ if arr.ndim == 3 and arr.shape[2] not in (1, 3, 4):
26
+ raise ValueError(
27
+ f"Expected last dimension (channels) to be 1, 3, or 4; got {arr.shape}."
28
+ )
29
+
30
+ if np.issubdtype(arr.dtype, np.floating):
31
+ amax = float(np.nanmax(arr)) if arr.size else 0.0
32
+ if amax <= 1.0:
33
+ arr = np.clip(arr, 0.0, 1.0) * 255.0
34
+ else:
35
+ arr = np.clip(arr, 0.0, 255.0)
36
+ arr = arr.astype(np.uint8)
37
+ elif np.issubdtype(arr.dtype, np.integer):
38
+ arr = np.clip(arr, 0, 255).astype(np.uint8)
39
+ elif arr.dtype == np.bool_:
40
+ arr = arr.astype(np.uint8) * 255
41
+ else:
42
+ raise ValueError(f"Unsupported image dtype: {arr.dtype}")
43
+
44
+ if arr.ndim == 3 and arr.shape[2] == 1:
45
+ arr = arr[:, :, 0]
46
+
47
+ img = Image.fromarray(arr)
48
+ out.parent.mkdir(parents=True, exist_ok=True)
49
+ img.save(out)
50
+ console.print(f"[green]Wrote[/] {out}")
51
+
52
+
53
+ def import_image(root: Any, obj: str, input_file: Path, console: Console) -> None:
54
+ """Read an image file into the store as a dense array.
55
+
56
+ Kept as raw pixels (H, W) or (H, W, C) with no encoding beyond `array`,
57
+ which is how spatial tooling stores tissue images under uns/spatial.
58
+ """
59
+ obj = norm_path(obj)
60
+ arr = np.asarray(Image.open(input_file))
61
+
62
+ if arr.ndim not in (2, 3):
63
+ raise ValueError(f"Expected a 2D or 3D image; got shape {arr.shape}.")
64
+
65
+ if obj in DATAFRAME_PATHS:
66
+ raise ValueError(
67
+ f"'{obj}' must hold a dataframe; writing an image there would "
68
+ "corrupt the store. Images belong somewhere unstructured, such as "
69
+ "'uns/spatial/hires'."
70
+ )
71
+
72
+ # An image's height is not an axis, so a path that implies one is almost
73
+ # certainly a mistake -- but check it rather than assume.
74
+ validate_dimensions(root, obj, arr.shape, console)
75
+
76
+ parts = obj.split("/")
77
+ parent = root
78
+ for part in parts[:-1]:
79
+ parent = parent[part] if part in parent else write_mapping(parent, part)
80
+
81
+ write_dense(parent, parts[-1], arr, replace=True)
82
+ console.print(
83
+ f"[green]Imported[/] {'x'.join(str(d) for d in arr.shape)} "
84
+ f"image ({arr.dtype}) into '{obj}'"
85
+ )
@@ -0,0 +1,196 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any, Dict
7
+
8
+ import numpy as np
9
+ from rich.console import Console
10
+
11
+ from adata.core.read import decode_str_array
12
+ from adata.elements import spec
13
+ from adata.formats.common import _check_json_exportable, _resolve
14
+ from adata.elements.write import (
15
+ write_dense,
16
+ write_mapping,
17
+ write_null,
18
+ write_scalar,
19
+ write_string_array,
20
+ )
21
+ from adata.storage import create_dataset, is_dataset, is_group
22
+ from adata.util.path import norm_path
23
+
24
+
25
+ def export_json(
26
+ root: Any,
27
+ obj: str,
28
+ out: Path | None,
29
+ max_elements: int,
30
+ include_attrs: bool,
31
+ console: Console,
32
+ ) -> None:
33
+ h5obj = _resolve(root, obj)
34
+ _check_json_exportable(h5obj, max_elements=max_elements)
35
+
36
+ payload = _to_jsonable(
37
+ h5obj, max_elements=max_elements, include_attrs=include_attrs
38
+ )
39
+ if out is None or str(out) == "-":
40
+ out_fh = sys.stdout
41
+ else:
42
+ out.parent.mkdir(parents=True, exist_ok=True)
43
+ out_fh = open(out, "w", encoding="utf-8")
44
+ try:
45
+ json.dump(payload, out_fh, indent=2, ensure_ascii=False, sort_keys=True)
46
+ out_fh.write("\n")
47
+ finally:
48
+ if out_fh is not sys.stdout:
49
+ out_fh.close()
50
+ if out_fh is not sys.stdout:
51
+ console.print(f"[green]Wrote[/] {out}")
52
+
53
+
54
+ def _attrs_to_jsonable(attrs: Any, max_elements: int) -> Dict[str, Any]:
55
+ out: Dict[str, Any] = {}
56
+ for k in attrs.keys():
57
+ v = attrs.get(k)
58
+ out[str(k)] = _pyify(v, max_elements=max_elements)
59
+ return out
60
+
61
+
62
+ def _pyify(value: Any, max_elements: int) -> Any:
63
+ if isinstance(value, bytes):
64
+ try:
65
+ return value.decode("utf-8")
66
+ except Exception:
67
+ return value.decode("utf-8", errors="replace")
68
+ if isinstance(value, np.generic):
69
+ return value.item()
70
+ if isinstance(value, np.ndarray):
71
+ if value.size > max_elements:
72
+ raise ValueError(
73
+ f"Refusing to convert array of size {value.size} (> {max_elements}) to JSON."
74
+ )
75
+ if np.issubdtype(value.dtype, np.bytes_) or value.dtype.kind == "O":
76
+ value = decode_str_array(value)
77
+ return value.tolist()
78
+ return value
79
+
80
+
81
+ def _dataset_to_jsonable(ds: Any, max_elements: int) -> Any:
82
+ if spec.encoding_type(ds) == spec.NULL:
83
+ # A null is a placeholder -- an h5py.Empty or a 0-d zarr bool -- whose
84
+ # stored value is meaningless. Reading it would emit that placeholder
85
+ # rather than restoring the None it represents.
86
+ return None
87
+
88
+ if ds.shape == ():
89
+ v = ds[()]
90
+ return _pyify(v, max_elements=max_elements)
91
+ n = int(np.prod(ds.shape)) if ds.shape else 0
92
+ if n > max_elements:
93
+ ds_name = getattr(ds, "name", "<dataset>")
94
+ raise ValueError(
95
+ f"Refusing to convert dataset {ds_name!r} with {n} elements (> {max_elements}) to JSON."
96
+ )
97
+ arr = np.asarray(ds[...])
98
+ return _pyify(arr, max_elements=max_elements)
99
+
100
+
101
+ def _to_jsonable(h5obj: Any, max_elements: int, include_attrs: bool) -> Any:
102
+ if is_dataset(h5obj):
103
+ return _dataset_to_jsonable(h5obj, max_elements=max_elements)
104
+
105
+ d: Dict[str, Any] = {}
106
+ if include_attrs and len(h5obj.attrs):
107
+ d["__attrs__"] = _attrs_to_jsonable(h5obj.attrs, max_elements=max_elements)
108
+
109
+ for key in h5obj.keys():
110
+ child = h5obj[key]
111
+ if is_group(child) or is_dataset(child):
112
+ d[str(key)] = _to_jsonable(
113
+ child,
114
+ max_elements=max_elements,
115
+ include_attrs=include_attrs,
116
+ )
117
+ return d
118
+
119
+
120
+ def import_json(
121
+ root: Any,
122
+ obj: str,
123
+ input_file: Path,
124
+ console: Console,
125
+ ) -> None:
126
+ obj = norm_path(obj)
127
+ with open(input_file, "r", encoding="utf-8") as fh:
128
+ payload = json.load(fh)
129
+
130
+ parts = obj.split("/")
131
+ parent = root
132
+ for part in parts[:-1]:
133
+ parent = parent[part] if part in parent else parent.create_group(part)
134
+ name = parts[-1]
135
+
136
+ if name in parent:
137
+ del parent[name]
138
+
139
+ _write_json_to_group(parent, name, payload)
140
+
141
+ console.print(f"[green]Imported[/] JSON data into '{obj}'")
142
+
143
+
144
+ def _write_json_to_group(parent: Any, name: str, value: Any) -> None:
145
+ """Write one JSON value as the AnnData element that best represents it."""
146
+ if isinstance(value, dict):
147
+ group = write_mapping(parent, name, replace=True)
148
+ for k, v in value.items():
149
+ _write_json_to_group(group, k, v)
150
+ return
151
+
152
+ if value is None:
153
+ write_null(parent, name, replace=True)
154
+ return
155
+
156
+ if isinstance(value, str):
157
+ write_scalar(parent, name, value, replace=True)
158
+ return
159
+
160
+ if isinstance(value, (bool, int, float)):
161
+ write_scalar(parent, name, value, replace=True)
162
+ return
163
+
164
+ if isinstance(value, list):
165
+ _write_json_list(parent, name, value)
166
+ return
167
+
168
+ raise ValueError(f"Cannot convert JSON value of type {type(value).__name__}")
169
+
170
+
171
+ def _write_json_list(parent: Any, name: str, value: list) -> None:
172
+ """Write a JSON array as a string or numeric array where it is uniform.
173
+
174
+ Ragged or mixed lists have no array representation in the spec, so they
175
+ are stored as their JSON text rather than silently reshaped.
176
+ """
177
+ if all(isinstance(v, str) for v in value):
178
+ write_string_array(parent, name, value, replace=True)
179
+ return
180
+
181
+ try:
182
+ arr = np.array(value)
183
+ except (ValueError, TypeError):
184
+ arr = None
185
+
186
+ if arr is not None and arr.dtype.kind in ("b", "i", "u", "f"):
187
+ write_dense(parent, name, arr, replace=True)
188
+ return
189
+
190
+ if arr is not None and arr.dtype.kind in ("U", "S", "O", "T"):
191
+ # Pass the shaped array through: flattening would turn a nested list
192
+ # such as [["a","b"],["c","d"]] into a length-4 vector.
193
+ write_string_array(parent, name, arr, replace=True)
194
+ return
195
+
196
+ write_scalar(parent, name, json.dumps(value), replace=True)
@@ -0,0 +1,253 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, List, Tuple
5
+ import sys
6
+ from contextlib import nullcontext
7
+
8
+ import numpy as np
9
+ from rich.console import Console
10
+
11
+ from adata.elements import spec
12
+ from adata.elements.write import write_sparse
13
+ from adata.formats.common import _get_encoding_type, _resolve
14
+ from adata.formats.validate import validate_dimensions
15
+ from adata.storage import create_dataset, is_dataset, is_group, is_zarr_group
16
+ from adata.util.path import norm_path
17
+
18
+
19
+ def _read_mtx(
20
+ input_file: Path,
21
+ ) -> Tuple[List[Tuple[int, int, float]], Tuple[int, int], int]:
22
+ with open(input_file, "r", encoding="utf-8") as fh:
23
+ header = fh.readline()
24
+ if not header.startswith("%%MatrixMarket"):
25
+ raise ValueError("Invalid MTX file: missing MatrixMarket header.")
26
+
27
+ parts = header.lower().split()
28
+ field = "real"
29
+ for p in parts:
30
+ if p in ("real", "integer", "complex", "pattern"):
31
+ field = p
32
+ break
33
+
34
+ line = fh.readline()
35
+ while line.startswith("%"):
36
+ line = fh.readline()
37
+
38
+ dims = line.split()
39
+ n_rows, n_cols, nnz = int(dims[0]), int(dims[1]), int(dims[2])
40
+
41
+ entries = []
42
+ for _ in range(nnz):
43
+ parts = fh.readline().split()
44
+ r, c = int(parts[0]) - 1, int(parts[1]) - 1
45
+ if field == "pattern":
46
+ v = 1.0
47
+ else:
48
+ v = float(parts[2])
49
+ entries.append((r, c, v))
50
+
51
+ return entries, (n_rows, n_cols), nnz
52
+
53
+
54
+ def _create_csr_from_entries(
55
+ entries: List[Tuple[int, int, float]], shape: Tuple[int, int]
56
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
57
+ n_rows, _ = shape
58
+ entries.sort(key=lambda x: (x[0], x[1]))
59
+
60
+ data = np.array([e[2] for e in entries], dtype=np.float32)
61
+ indices = np.array([e[1] for e in entries], dtype=np.int32)
62
+
63
+ indptr = np.zeros(n_rows + 1, dtype=np.int32)
64
+ for r, _, _ in entries:
65
+ indptr[r + 1] += 1
66
+ indptr = np.cumsum(indptr)
67
+
68
+ return data, indices, indptr
69
+
70
+
71
+ def export_mtx(
72
+ root: Any,
73
+ obj: str,
74
+ out: Path | None,
75
+ head: int | None,
76
+ chunk_elements: int,
77
+ in_memory: bool,
78
+ console: Console,
79
+ ) -> None:
80
+ h5obj = _resolve(root, obj)
81
+ if not is_group(h5obj):
82
+ raise ValueError("MTX export requires a CSR/CSC matrix group (not a dataset).")
83
+
84
+ enc = _get_encoding_type(h5obj)
85
+ if enc not in ("csr_matrix", "csc_matrix"):
86
+ raise ValueError(
87
+ f"Target group encoding-type is {enc!r}; expected 'csr_matrix' or 'csc_matrix'."
88
+ )
89
+
90
+ data = h5obj.get("data")
91
+ indices = h5obj.get("indices")
92
+ indptr = h5obj.get("indptr")
93
+ if not (is_dataset(data) and is_dataset(indices) and is_dataset(indptr)):
94
+ raise ValueError(
95
+ "Sparse matrix group must contain datasets: data, indices, indptr"
96
+ )
97
+
98
+ shape = h5obj.attrs.get("shape", None)
99
+ if shape is None:
100
+ raise ValueError("Sparse matrix group is missing required 'shape' attribute.")
101
+ n_rows, n_cols = (int(shape[0]), int(shape[1]))
102
+
103
+ field = "real" if np.issubdtype(data.dtype, np.floating) else "integer"
104
+
105
+ indptr_arr = np.asarray(indptr[...], dtype=np.int64)
106
+ nnz_ptr = int(indptr_arr[-1]) if indptr_arr.size else 0
107
+ nnz_data = int(data.shape[0])
108
+ nnz_idx = int(indices.shape[0])
109
+
110
+ if not (nnz_ptr == nnz_data == nnz_idx):
111
+ raise ValueError(
112
+ f"Sparse matrix data inconsistency: indptr implies {nnz_ptr} nonzeros, "
113
+ f"but data has {nnz_data} and indices has {nnz_idx}."
114
+ )
115
+
116
+ nnz = nnz_data
117
+ major_step = max(1, int(chunk_elements))
118
+ if head is not None and head > 0:
119
+ nnz = min(nnz_data, head)
120
+
121
+ if out is None or str(out) == "-":
122
+ out_fh = sys.stdout
123
+ else:
124
+ out.parent.mkdir(parents=True, exist_ok=True)
125
+ out_fh = open(out, "w", encoding="utf-8", newline="\n")
126
+
127
+ use_status = out_fh is not sys.stdout
128
+ status_ctx = (
129
+ console.status(f"[magenta]Exporting {obj} to {out}...[/]")
130
+ if use_status
131
+ else nullcontext()
132
+ )
133
+ try:
134
+ out_fh.write(f"%%MatrixMarket matrix coordinate {field} general\n")
135
+ out_fh.write("% generated by h5ad-cli\n")
136
+ if head is not None and head > 0:
137
+ out_fh.write(f"% output limited to first {nnz}/{nnz_data} nonzero entries\n")
138
+ out_fh.write(f"{n_rows} {n_cols} {nnz}\n")
139
+
140
+ if in_memory:
141
+ with status_ctx as status:
142
+ if use_status and status:
143
+ status.update(
144
+ f"[magenta]Loading entire matrix {obj} into memory...[/]"
145
+ )
146
+ data_arr = np.asarray(data[...])
147
+ indices_arr = np.asarray(indices[...], dtype=np.int64)
148
+ counts = np.diff(indptr_arr)
149
+ if int(counts.sum()) != nnz_data:
150
+ raise ValueError(
151
+ "Sparse matrix indptr does not match data/indices length."
152
+ )
153
+
154
+ if enc == "csr_matrix":
155
+ major_idx = np.repeat(np.arange(n_rows, dtype=np.int64), counts)
156
+ row_idx = major_idx
157
+ col_idx = indices_arr
158
+ else:
159
+ major_idx = np.repeat(np.arange(n_cols, dtype=np.int64), counts)
160
+ row_idx = indices_arr
161
+ col_idx = major_idx
162
+
163
+ if head is not None and head > 0:
164
+ row_idx = row_idx[:nnz]
165
+ col_idx = col_idx[:nnz]
166
+ data_arr = data_arr[:nnz]
167
+
168
+ data_fmt = "%.18g" if field == "real" else "%d"
169
+ coords = np.column_stack((row_idx + 1, col_idx + 1, data_arr))
170
+ if use_status and status:
171
+ status.update(f"[magenta]Saving {nnz} entries to {out}...[/]")
172
+ np.savetxt(out_fh, coords, fmt=["%d", "%d", data_fmt], newline="\n")
173
+ else:
174
+ major = n_rows if enc == "csr_matrix" else n_cols
175
+ max_lines = head if head is not None and head > 0 else None
176
+ written = 0
177
+ with status_ctx as status:
178
+ for major_start in range(0, major, major_step):
179
+ major_end = min(major_start + major_step, major)
180
+ if use_status and status:
181
+ status.update(
182
+ f"[magenta]Exporting {obj}: {major_start+1}-{major_end} of {major}...[/]"
183
+ )
184
+ for major_i in range(major_start, major_end):
185
+ start = min(int(indptr_arr[major_i]), nnz_data)
186
+ end = min(int(indptr_arr[major_i + 1]), nnz_data)
187
+ if end <= start:
188
+ continue
189
+ idx = np.asarray(indices[start:end], dtype=np.int64)
190
+ vals = np.asarray(data[start:end])
191
+ m = min(len(idx), len(vals))
192
+ if m == 0:
193
+ raise ValueError("Sparse matrix chunk has zero length.")
194
+ if max_lines is not None:
195
+ remaining = max_lines - written
196
+ if remaining <= 0:
197
+ break
198
+ if m > remaining:
199
+ m = remaining
200
+ idx = idx[:m]
201
+ vals = vals[:m]
202
+ idx_list = idx.tolist()
203
+ vals_list = vals.tolist()
204
+ if enc == "csr_matrix":
205
+ r = major_i + 1
206
+ lines = [
207
+ f"{r} {c + 1} {v}\n"
208
+ for c, v in zip(idx_list, vals_list)
209
+ ]
210
+ else:
211
+ c = major_i + 1
212
+ lines = [
213
+ f"{r + 1} {c} {v}\n"
214
+ for r, v in zip(idx_list, vals_list)
215
+ ]
216
+ out_fh.write("".join(lines))
217
+ written += m
218
+ if max_lines is not None and written >= max_lines:
219
+ break
220
+ if max_lines is not None and written >= max_lines:
221
+ break
222
+ finally:
223
+ if out_fh is not sys.stdout:
224
+ out_fh.close()
225
+ if out_fh is not sys.stdout:
226
+ console.print(f"[green]Wrote[/] {out}")
227
+
228
+
229
+ def import_mtx(
230
+ root: Any,
231
+ obj: str,
232
+ input_file: Path,
233
+ console: Console,
234
+ ) -> None:
235
+ obj = norm_path(obj)
236
+ entries, shape, nnz = _read_mtx(input_file)
237
+ data, indices, indptr = _create_csr_from_entries(entries, shape)
238
+
239
+ validate_dimensions(root, obj, shape, console)
240
+
241
+ parts = obj.split("/")
242
+ parent = root
243
+ for part in parts[:-1]:
244
+ parent = parent[part] if part in parent else parent.create_group(part)
245
+ name = parts[-1]
246
+
247
+ write_sparse(
248
+ parent, name, data, indices, indptr, shape, spec.CSR_MATRIX, replace=True
249
+ )
250
+
251
+ console.print(
252
+ f"[green]Imported[/] {shape[0]}×{shape[1]} sparse matrix ({nnz} non-zero) into '{obj}'"
253
+ )